Files
privatae-bastion/client-go/uipolicy.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

472 lines
18 KiB
Go

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())
}