432 lines
13 KiB
Go
432 lines
13 KiB
Go
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<<i) != 0 {
|
|||
|
|
names = append(names, ind.name)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if len(names) == 0 {
|
|||
|
|
return "—"
|
|||
|
|
}
|
|||
|
|
return strings.Join(names, ", ")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// changeText says step by step what differs between the two runs for a common
|
|||
|
|
// address. An address with the same indicators is "без изменений" even if its
|
|||
|
|
// failed targets or sites differ.
|
|||
|
|
func (c *Comparison) changeText(r *cmpRow) string {
|
|||
|
|
if r.ma == r.mb {
|
|||
|
|
return "без изменений"
|
|||
|
|
}
|
|||
|
|
var steps []string
|
|||
|
|
if r.a.res.Verdict != r.b.res.Verdict {
|
|||
|
|
steps = append(steps, "вердикт "+arrow(r.a.res.Verdict, r.b.res.Verdict))
|
|||
|
|
}
|
|||
|
|
var in, out []string
|
|||
|
|
for i, ind := range indicators {
|
|||
|
|
if strings.HasPrefix(ind.key, "verdict_") {
|
|||
|
|
continue // the verdict step says it
|
|||
|
|
}
|
|||
|
|
bit := uint8(1) << i
|
|||
|
|
switch {
|
|||
|
|
case r.ma&bit == 0 && r.mb&bit != 0:
|
|||
|
|
in = append(in, ind.name)
|
|||
|
|
case r.ma&bit != 0 && r.mb&bit == 0:
|
|||
|
|
out = append(out, ind.name)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if len(in) > 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, " ")
|
|||
|
|
}
|