package main // uipolicy.go — the page that shows what the dry-run rule WOULD stop, and the // record of what it would have stopped while nobody was looking. // // ── WHY THIS PAGE HAS NO BUTTONS AT ALL ───────────────────────────────────── // // The dry run is step 3 of the design and acting is step 4, and shipping // them in the other order is how this product acquires a reputation for // killing people's work. So this page renders, and that is the whole of it. // There is no Stop, no // Contain, no disabled control hinting at one, and — unlike the sanction page, // which at least POSTs a mark (uisanction.go:240-245) — there is no mutating // route in this file for anything to grow out of. policy_test.go drives the // whole page with killProcess stubbed and then parses the source to prove the // unit cannot so much as name it. // // The one action a person might want after reading this page is to say "that one // is mine", and that already exists one page over. This page links there rather // than growing its own copy: two surfaces that both write marks would be two // places to get the identity wrong. // // ── THE THREE WAYS THIS PAGE COULD LIE, AND WHAT STOPS EACH ───────────────── // // A BLIND BUILD. agentScanSupported is false where no enumerator exists // (agents_other.go:13). "Nothing would be stopped" from a machine that cannot // list its own processes is the most reassuring sentence this program could // print about the least known machine — the README, "Observe" calls it the // quiet-versus-blind distinction and uisanction.go:41-45 holds the same line for // the same reason. // // AN UNREADABLE REGISTRY. Every sanction term is then unknown, every agent-kind // verdict is cannot-judge, and no count on this page means anything. It renders // as an alarm and the counts are withheld — ui_approvals_flow_test.go:113-151 is this // exact lesson on a neighbouring page. // // A CENSUS THAT STOPPED. The subtlest one, and the reason the ledger carries // last_scan as well as since (policy.go). A record that stopped being written // three days ago keeps answering "nothing since Tuesday", which reads as an // all-clear; agents.go:594-598 records the same trap for the second-bastion scan // and the same fix. When the census has never run at all, the page says THAT // rather than "nothing would have been stopped". import ( "net/http" "sort" "strconv" "time" ) // ── what the page shows ───────────────────────────────────────────────────── // uiPolicyRow is one live observation and what the rule says about it. It // embeds the verdict (policy.go) rather than copying it field by field: the // verdict IS the product of this unit, and a page that re-typed it would be a // second place for it to drift. type uiPolicyRow struct { policyVerdict What string // the identity in words — sanctionIdentity.describe() Word string // the decision as a person reads it Class string // which of the three states the left edge carries } // The four things that can have become of a past candidate. A page that only // said "it was a candidate once" would answer none of the question step 4 is // waiting on. const ( policyPastStill = "still a candidate right now" policyPastVouched = "something has vouched for it since" policyPastRunning = "running now, and this rule no longer proposes it" policyPastNotSeen = "not running right now" policyPastUnknowable = "this machine cannot say — it could not read its own record of marks" policyPastUnwalked = "this machine cannot say — it could not read its processes just now" ) // uiPolicyPast is one row of the census: a program this rule would have stopped, // what it looked like, and what became of it. type uiPolicyPast struct { ID string What string Cmdline string Origin string Endpoints []string FirstSeen string LastSeen string Status string // Why it is no longer a candidate, in the words of the term that said so. // Empty while it still is one. Because string // Vouched: somebody has since said this is theirs. That makes it a false // positive this rule has been MEASURED making, which is the number step 4 // is waiting on. Vouched bool // Unknowable: neither running nor not — the registry or the walk failed // for this render, and the row's edge must not read as "nothing behind it". Unknowable bool Still bool } type uiPolicyView struct { Version string CheckedAt string // The rule itself, in the words policy.go keeps beside the terms. Rule []policyReason Rows []uiPolicyRow Observed int WouldStop int CannotJudge int // Blind: no enumerator on this platform, so the list is empty because // nothing looked. Blind bool // RegistryError: the sanction registry would not load. Every verdict below // is cannot-judge and no count is worth printing. RegistryError string // CensusError: the record of what would have been stopped is not being // written, and a page that stayed quiet about that would report a machine it // had stopped measuring as a machine with nothing to report. CensusError string CensusErrorAt string // WalkError: the process walk failed for THIS render, so the rows below // are empty because nothing looked — the same reading Blind gets, for one // pass instead of for the platform. WalkError string // CensusUnreadable: the record could not be READ, which is a different state // from a write that failed. A failed write still leaves everything written // before it worth showing; a failed read leaves nothing that may be shown as // an absence. Kept apart so the second never renders as "never measured". CensusUnreadable bool // CensusRan is false when this machine has never taken a census — which is // a different fact from taking one and finding nothing, and must never be // rendered as the second. CensusRan bool CensusSince string CensusLast string // CensusStale: the last scan is older than it should be, so the rows below // are history rather than news. CensusStale bool Past []uiPolicyPast PastTotal int PastStill int PastVouched int PastDropped int CensusWindow string } // uiPolicyStaleAfter is when a census stops being news. // // IT IS COUNTED IN FLUSHES, NOT IN SCANS, and that is the whole subtlety. The // census scans every policyCensusEvery but only writes its clock every // policyLedgerFlush (policy.go says why), so last_scan on a perfectly healthy // machine is routinely a flush interval old. A threshold shorter than that // would put "this measurement has stopped" on a machine that is measuring // correctly, which is the cry-wolf failure every page in this UI is built to // avoid. Three flushes: long enough that a slow box or a closed laptop lid is // not an alarm, short enough that a daemon that died this morning is visible // this afternoon. const uiPolicyStaleAfter = 3 * policyLedgerFlush // collectPolicyView asks the rule and the census, once. // // Re-read on every render and never cached, for the reason renderWith gives // (ui.go:432-435): a stale copy of a security page is worse than no page, // because it is the page the user trusted. func collectPolicyView() uiPolicyView { now := uiNow() v := uiPolicyView{ Version: clientVersion, CheckedAt: now.Format("15:04:05"), Rule: policyRuleLines(), Blind: !agentScanSupported, } // Asked once here for the banner, exactly as collectAgentsView does // (uisanction.go:144-146). Each verdict below asks again through // agentSanction, which re-reads by design (sanction.go:169-178). marks, err := loadSanctions() if err != nil { v.RegistryError = err.Error() } if msg, at := policyCensusError(); msg != "" { v.CensusError, v.CensusErrorAt = msg, at.Local().Format(uiEventTime) } live := map[string]policyVerdict{} verdicts, err := policyVerdicts() if err != nil { v.WalkError = err.Error() } for _, verdict := range verdicts { live[verdict.ID] = verdict v.Rows = append(v.Rows, uiPolicyRow{ policyVerdict: verdict, What: verdict.sanctionIdentity.describe(), Word: policyWord(verdict.Decision), Class: policyClass(verdict.Decision), }) } v.Observed = len(v.Rows) v.WouldStop, v.CannotJudge = policyWouldStopCount(policyRowVerdicts(v.Rows)) sortPolicyRows(v.Rows) l, err := loadPolicyLedger() if err != nil { // The census file itself is unreadable. Same rule as the registry: say // so, and do not print a count — or an absence — derived from a read // that failed. In particular this must NOT fall through to "this machine // has never taken this measurement", which is the reassuring half of the // same lie (ui_approvals_flow_test.go:113-151). v.CensusUnreadable = true if v.CensusError == "" { v.CensusError, v.CensusErrorAt = err.Error(), v.CheckedAt } return v } v.CensusRan = !l.Since.IsZero() if !v.CensusRan { return v } v.CensusSince = l.Since.Local().Format(uiEventTime) v.CensusLast = l.LastScan.Local().Format(uiEventTime) v.CensusStale = now.Sub(l.LastScan) > uiPolicyStaleAfter v.CensusWindow = uiPolicyWindow(l.Since, now) v.PastDropped = l.Dropped for _, c := range l.Candidates { // A failed walk is as unknowable as an unreadable registry: `live` is // empty because nothing looked, and "not running right now" about a // named program would be the same lie one row down from the banner. // Each cause gets its own sentence, so the row says which. unknowable := "" switch { case v.RegistryError != "": unknowable = policyPastUnknowable case v.WalkError != "": unknowable = policyPastUnwalked } v.Past = append(v.Past, uiPolicyPastRow(c, live, marks, unknowable)) } v.PastTotal = len(v.Past) for _, p := range v.Past { if p.Still { v.PastStill++ } if p.Vouched { v.PastVouched++ } } sortPolicyPast(v.Past) return v } // uiPolicyPastRow says what became of one past candidate. // // The order of the questions is the order of certainty. What this machine can // SEE running now beats what it can infer from a record, and a mark it could // not read is never reported as an absence of one. // // unknowable, when not empty, is the sentence for a row about which nothing // can be said: the registry could not be read, or the walk did not happen. func uiPolicyPastRow(c policyCandidate, live map[string]policyVerdict, marks map[string]sanctionRecord, unknowable string) uiPolicyPast { p := uiPolicyPast{ ID: c.ID, What: c.sanctionIdentity.describe(), Cmdline: c.LastCmdline, Origin: c.LastOrigin, Endpoints: c.LastEndpoints, FirstSeen: c.FirstSeen.Local().Format(uiEventTime), LastSeen: c.LastSeen.Local().Format(uiEventTime), } if verdict, running := live[c.ID]; running { switch verdict.Decision { case policyWouldStop: p.Status, p.Still = policyPastStill, true case policyCannotJudge: // Running, but the registry could not be read: unknowable, and the // row must not wear the "nothing behind it" edge. p.Status, p.Unknowable = policyPastUnknowable, true default: p.Status = policyPastRunning for _, r := range verdict.Reasons { if r.Points != policyPointsLeave { continue } p.Because = r.Detail // Something vouched for it: the rule was wrong about this one, // and that is the measurement this page exists to take. Any // other term (it stopped talking to a model, a person picked up // the session) is the rule being right and the world moving. p.Vouched = r.Term == policyTermSanction break } } return p } // Not running. The only thing this machine can still check is the LOCAL // mark: a host delegation tag is read out of a live process's environment // (sanction.go:23-30), so for something that has exited there is nothing to // read it from. // ORDER IS LOAD-BEARING. The registry being unreadable takes everything // with it — `marks` came from it. A failed WALK takes only "running" with // it: the mark is read from the registry, not from the process list, so a // vouched candidate stays vouched (the count the whole page is waiting on // must not zero because nothing looked — that turned the box green). switch { case unknowable == policyPastUnknowable: p.Status, p.Unknowable = unknowable, true case marks[c.ID].ID != "": p.Status, p.Vouched = policyPastVouched, true case unknowable != "": p.Status, p.Unknowable = unknowable, true default: p.Status = policyPastNotSeen } return p } // policyRowVerdicts unwraps the embedded verdicts so the count is taken by the // same function the census counts with, rather than a second loop that could // come to a different total. func policyRowVerdicts(rows []uiPolicyRow) []policyVerdict { out := make([]policyVerdict, 0, len(rows)) for _, r := range rows { out = append(out, r.policyVerdict) } return out } // policyWord is the decision in the words the page uses. The constants are the // seam; these are for reading. func policyWord(decision string) string { switch decision { case policyWouldStop: return "would be stopped" case policyCannotJudge: return "cannot judge" default: return "left alone" } } // policyClass maps a decision onto the three states app.css already carries, so // this page looks like the rest of the UI rather than like a new product. // // Amber for would-stop, never red. Red is reserved here for a verdict this // machine could not reach, which is the state that needs a person — the same // order agents.css:17-26 sets, and for the same reason: a page that shouts at // ordinary state is a page that gets ignored the one time it matters. func policyClass(decision string) string { switch decision { case policyWouldStop: return "warn" case policyCannotJudge: return "bad" default: return "ok" } } // uiPolicyWindow is how long the census has been running, in the words a person // would use. Precision below a day is noise on a measurement whose unit is // "long enough to trust". func uiPolicyWindow(since, now time.Time) string { d := now.Sub(since) switch { case d < time.Hour: return "less than an hour" case d < 24*time.Hour: h := int(d.Hours()) if h == 1 { return "1 hour" } return strconv.Itoa(h) + " hours" case d < 48*time.Hour: return "1 day" default: return strconv.Itoa(int(d.Hours()/24)) + " days" } } // sortPolicyRows puts the page's subject first: what this rule would stop, then // what it could not judge, then everything it leaves alone. The remaining keys // exist so the order is STABLE across a poll — a list that reshuffles under the // cursor is a list nobody can read down (uisanction.go:205-207). func sortPolicyRows(rows []uiPolicyRow) { rank := func(r uiPolicyRow) int { switch r.Decision { case policyWouldStop: return 0 case policyCannotJudge: return 1 default: return 2 } } sort.SliceStable(rows, func(i, j int) bool { if a, b := rank(rows[i]), rank(rows[j]); a != b { return a < b } if rows[i].Kind != rows[j].Kind { return rows[i].Kind < rows[j].Kind } if rows[i].Signature != rows[j].Signature { return rows[i].Signature < rows[j].Signature } return rows[i].PID < rows[j].PID }) } // sortPolicyPast puts what is still a candidate first — those are what step 4 // would take away today — then the rest, most recently seen first. func sortPolicyPast(past []uiPolicyPast) { sort.SliceStable(past, func(i, j int) bool { if past[i].Still != past[j].Still { return past[i].Still } if past[i].LastSeen != past[j].LastSeen { return past[i].LastSeen > past[j].LastSeen } return past[i].ID < past[j].ID }) } // ── the routes ────────────────────────────────────────────────────────────── // policyRoutes registers this page. The shape copies agentRoutes // (uisanction.go:240-245) exactly, including WHY the method check lives inside // each handler rather than in the mux pattern: Go's method patterns would answer // a rebound host with 405 before uiRequestAllowed ever ran, and the rebinding // wall has to be the outermost thing on every route. // // BOTH ROUTES ARE READS. There is no third one, and that is the point of the // unit rather than an omission. func (u *uiServer) policyRoutes(mux *http.ServeMux) { mux.HandleFunc("/policy", u.guarded(u.policyPage)) mux.HandleFunc("/fragments/policy", u.guarded(u.policyFragment)) } // renderPolicy sets the SAME four headers renderWith does (ui.go:437-442) and // not one fewer, for the reason uisanction.go:251-255 gives: renderWith is typed // to uiStatus, widening it belongs to whoever owns that file, and the // duplication is made safe by a test that compares the two responses header by // header. func (u *uiServer) renderPolicy(w http.ResponseWriter, name string, v uiPolicyView) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; form-action 'self'") w.Header().Set("X-Content-Type-Options", "nosniff") if err := u.tpl.ExecuteTemplate(w, name, v); err != nil { uilogf("render %s: %v\n", name, err) } } func (u *uiServer) policyPage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } u.renderPolicy(w, "policy-page", collectPolicyView()) } func (u *uiServer) policyFragment(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } u.renderPolicy(w, "policy", collectPolicyView()) }