package analytics import ( "sort" "strings" "cloudipvalidator/internal/db" ) // indicator is one of the seven counters of the analytics page. Its key is the // name of the list behind the counter; has repeats the condition Compute counts // it by (an address with a cancelled result is never asked). type indicator struct { key, name string has func(a *addr) bool } var indicators = []indicator{ {ListVerdictPass, "pass", func(a *addr) bool { return a.res.Verdict == db.ResultPass }}, {ListVerdictPartial, "partial", func(a *addr) bool { return a.res.Verdict == db.ResultPartial }}, {ListVerdictFail, "fail", func(a *addr) bool { return a.res.Verdict == db.ResultFail }}, {ListEgressHTTPSAny, "Egress https: есть провалы", func(a *addr) bool { return a.https.n > 0 && a.https.ok < a.https.n }}, {ListEgressHTTPSAll, "Egress https: все провалены", func(a *addr) bool { return a.https.n > 0 && a.https.ok == 0 }}, {ListIngressSSHAny, "Ingress ssh: есть провалы", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok < a.ssh.n }}, {ListIngressSSHAll, "Ingress ssh: все провалены", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok == 0 }}, } // indicatorIndex is the position of an indicator key in the table, -1 if unknown. func indicatorIndex(key string) int { for i, ind := range indicators { if ind.key == key { return i } } return -1 } // verdicts are the rows and columns of the transition matrix. var verdicts = []string{db.ResultPass, db.ResultPartial, db.ResultFail} func verdictIndex(v string) int { for i, x := range verdicts { if x == v { return i } } return -1 } // membership is the set of indicators an address belongs to, one bit per // entry of the indicators table. func membership(a *addr) uint8 { var m uint8 for i, ind := range indicators { if ind.has(a) { m |= 1 << i } } return m } // Comparison is the difference between two finished runs: the base (older) and // the target (newer) one. An address is its IP; one with a cancelled result is // not in its run. type Comparison struct { Runs CompareRuns `json:"runs"` Groups CompareGroups `json:"groups"` Indicators []IndicatorDiff `json:"indicators"` Transitions Transitions `json:"transitions"` Cancelled CompareCancel `json:"cancelled"` rows []*cmpRow // numeric address order target *Analysis } type CompareRuns struct { Base CompareRun `json:"base"` Target CompareRun `json:"target"` } type CompareRun struct { RunInfo Addresses int `json:"addresses"` } // CompareGroups counts the addresses by how they relate to the two runs: // New are only in the target, Left only in the base, Common in both, and // Common = Changed + Same. type CompareGroups struct { New int `json:"new"` Left int `json:"left"` Common int `json:"common"` Changed int `json:"changed"` Same int `json:"same"` } // IndicatorDiff is one indicator in both runs. Delta = Target - Base = // New - Left + Entered - Exited. type IndicatorDiff struct { Key string `json:"key"` Name string `json:"name"` Base int `json:"base"` Target int `json:"target"` Delta int `json:"delta"` New int `json:"new"` // new addresses that are in the indicator Left int `json:"left"` // left addresses that were in it Entered int `json:"entered"` // common addresses that entered it Exited int `json:"exited"` // common addresses that left it } // Transitions is the verdict of the common addresses: Matrix[from][to] with // the verdicts of the base on the rows and of the target on the columns. New // holds the new addresses by their verdict in the target, Left the addresses // that left by their verdict in the base. type Transitions struct { Verdicts []string `json:"verdicts"` Matrix [][]int `json:"matrix"` New []int `json:"new"` Left []int `json:"left"` } type CompareCancel struct { Base int `json:"base"` Target int `json:"target"` } // cmpRow is one address with its state in each run (nil when absent) and the // indicators it belongs to there. type cmpRow struct { ip string a, b *addr ma, mb uint8 } func (r *cmpRow) common() bool { return r.a != nil && r.b != nil } func (r *cmpRow) changed() bool { return r.common() && r.ma != r.mb } // Compare puts two analyses side by side; base is the older run, target the newer. func Compare(base, target *Analysis) *Comparison { byIP := map[string]*cmpRow{} var rows []*cmpRow add := func(list []*addr, isBase bool) { for _, x := range list { r := byIP[x.res.IPAddress] if r == nil { r = &cmpRow{ip: x.res.IPAddress} byIP[r.ip] = r rows = append(rows, r) } if isBase { r.a, r.ma = x, membership(x) } else { r.b, r.mb = x, membership(x) } } } add(base.sorted(), true) add(target.sorted(), false) sort.Slice(rows, func(i, j int) bool { return lessIP(rows[i].ip, rows[j].ip) }) c := &Comparison{rows: rows, target: target} c.Runs = CompareRuns{ Base: CompareRun{RunInfo: base.Report.Run, Addresses: base.Report.Summary.Addresses}, Target: CompareRun{RunInfo: target.Report.Run, Addresses: target.Report.Summary.Addresses}, } c.Cancelled = CompareCancel{Base: base.Report.Summary.Cancelled, Target: target.Report.Summary.Cancelled} c.Indicators = make([]IndicatorDiff, len(indicators)) for i, ind := range indicators { c.Indicators[i] = IndicatorDiff{Key: ind.key, Name: ind.name} } tr := &c.Transitions tr.Verdicts = verdicts tr.New, tr.Left = make([]int, len(verdicts)), make([]int, len(verdicts)) tr.Matrix = make([][]int, len(verdicts)) for i := range tr.Matrix { tr.Matrix[i] = make([]int, len(verdicts)) } for _, r := range rows { switch { case r.a == nil: c.Groups.New++ if v := verdictIndex(r.b.res.Verdict); v >= 0 { tr.New[v]++ } case r.b == nil: c.Groups.Left++ if v := verdictIndex(r.a.res.Verdict); v >= 0 { tr.Left[v]++ } default: c.Groups.Common++ if r.changed() { c.Groups.Changed++ } else { c.Groups.Same++ } if from, to := verdictIndex(r.a.res.Verdict), verdictIndex(r.b.res.Verdict); from >= 0 && to >= 0 { tr.Matrix[from][to]++ } } for i := range indicators { bit := uint8(1) << i inA, inB := r.ma&bit != 0, r.mb&bit != 0 d := &c.Indicators[i] if inA { d.Base++ } if inB { d.Target++ } switch { case r.a == nil && inB: d.New++ case r.b == nil && inA: d.Left++ case r.common() && !inA && inB: d.Entered++ case r.common() && inA && !inB: d.Exited++ } } } for i := range c.Indicators { c.Indicators[i].Delta = c.Indicators[i].Target - c.Indicators[i].Base } return c } // Groups served by Comparison.List. const ( GroupNew = "new" GroupLeft = "left" GroupCommon = "common" GroupChanged = "changed" GroupSame = "same" GroupEntered = "entered" GroupExited = "exited" ) // CompareFilter narrows a group. Indicator is a list key of the indicator // table: for new and left the address is in it in its own run, for common, // changed and same in either run, for entered and exited it is required. // From and To, only together, keep the common addresses whose verdict was From // in the base and is To in the target. type CompareFilter struct { Indicator string From, To string } // CompareList is a table of addresses of one group. type CompareList struct { Group string `json:"group"` Indicator string `json:"indicator,omitempty"` Columns []string `json:"columns"` Rows [][]string `json:"rows"` } // List builds the table of a group; an unknown group, indicator or verdict, or // a filter that does not fit the group, is an ErrUnknownList. func (c *Comparison) List(group string, f CompareFilter) (*CompareList, error) { switch group { case GroupNew, GroupLeft, GroupCommon, GroupChanged, GroupSame, GroupEntered, GroupExited: default: return nil, ErrUnknownList(group) } var bit uint8 if f.Indicator != "" { i := indicatorIndex(f.Indicator) if i < 0 { return nil, ErrUnknownList("indicator " + f.Indicator) } bit = 1 << i } if bit == 0 && (group == GroupEntered || group == GroupExited) { return nil, ErrUnknownList(group + " without indicator") } if (f.From != "") != (f.To != "") { return nil, ErrUnknownList("from without to") } if f.From != "" { if verdictIndex(f.From) < 0 || verdictIndex(f.To) < 0 { return nil, ErrUnknownList("verdict " + f.From + " → " + f.To) } if group == GroupNew || group == GroupLeft { return nil, ErrUnknownList("from and to for " + group) } } l := &CompareList{Group: group, Indicator: f.Indicator, Rows: [][]string{}} if group == GroupNew || group == GroupLeft { l.Columns = []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"} } else { l.Columns = []string{"Адрес", "Подсеть", "Вердикт (A → B)", "Egress (A → B)", "Ingress (A → B)", "Что изменилось"} } for _, r := range c.rows { var ok bool switch group { case GroupNew: ok = r.a == nil && (bit == 0 || r.mb&bit != 0) case GroupLeft: ok = r.b == nil && (bit == 0 || r.ma&bit != 0) case GroupCommon: ok = r.common() case GroupChanged: ok = r.changed() case GroupSame: ok = r.common() && !r.changed() case GroupEntered: ok = r.common() && r.ma&bit == 0 && r.mb&bit != 0 case GroupExited: ok = r.common() && r.ma&bit != 0 && r.mb&bit == 0 } if !ok { continue } if r.common() && bit != 0 && group != GroupEntered && group != GroupExited && (r.ma|r.mb)&bit == 0 { continue } if f.From != "" && (r.a.res.Verdict != f.From || r.b.res.Verdict != f.To) { continue } switch { case r.a == nil: l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, r.b.res.Verdict, okOf(r.b.egress), okOf(r.b.ingress), indicatorsCell(r.mb)}) case r.b == nil: l.Rows = append(l.Rows, []string{r.ip, r.a.subnet, r.a.res.Verdict, okOf(r.a.egress), okOf(r.a.ingress), indicatorsCell(r.ma)}) default: l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, arrow(r.a.res.Verdict, r.b.res.Verdict), arrow(okOf(r.a.egress), okOf(r.b.egress)), arrow(okOf(r.a.ingress), okOf(r.b.ingress)), c.changeText(r)}) } } return l, nil } func arrow(from, to string) string { return from + " → " + to } // indicatorsCell names the indicators of a membership set, "—" for none. func indicatorsCell(m uint8) string { var names []string for i, ind := range indicators { if m&(1< 0 { steps = append(steps, "вошёл в: "+strings.Join(in, ", ")) } if len(out) > 0 { steps = append(steps, "вышел из: "+strings.Join(out, ", ")) } if added, removed := setDiff(r.a.https.failedTargets, r.b.https.failedTargets, sortedStrings); len(added)+len(removed) > 0 { steps = append(steps, "https: провалены цели "+signed(added, removed)) } if added, removed := setDiff(r.a.ssh.sites, r.b.ssh.sites, c.target.sortSites); len(added)+len(removed) > 0 { steps = append(steps, "ssh: площадки "+signed(added, removed)) } if was, now := r.a.https.validator, r.b.https.validator; was != "" && now != "" && was != now { steps = append(steps, "валидатор "+arrow(ShortValidator(was), ShortValidator(now))) } return strings.Join(steps, "; ") } // setDiff is what is in now and not in was, and the reverse, each ordered by order. func setDiff(was, now []string, order func([]string) []string) (added, removed []string) { in := func(list []string) map[string]bool { m := make(map[string]bool, len(list)) for _, s := range list { m[s] = true } return m } w, n := in(was), in(now) for s := range n { if !w[s] { added = append(added, s) } } for s := range w { if !n[s] { removed = append(removed, s) } } return order(added), order(removed) } func sortedStrings(s []string) []string { sort.Strings(s) return s } // signed writes a set difference as "+new1 +new2 −gone1". func signed(added, removed []string) string { parts := make([]string, 0, len(added)+len(removed)) for _, s := range added { parts = append(parts, "+"+s) } for _, s := range removed { parts = append(parts, "−"+s) } return strings.Join(parts, " ") }