422 lines
17 KiB
Go
422 lines
17 KiB
Go
package main
|
|
|
|
// uisanction.go — the page where a person says which of the automated things on
|
|
// their machine are theirs.
|
|
//
|
|
// WHY IT EXISTS. Without a way for a user to say THIS ONE IS MINE, a policy
|
|
// engine's first act is to kill the user's own editor.
|
|
// sanction.go is the record and the predicate; this is
|
|
// the only surface that can produce one, because the act being recorded is a
|
|
// person looking at a list and recognising something.
|
|
//
|
|
// ── THERE IS NO KILL BUTTON ON THIS PAGE, AND THAT IS A DECISION ────────────
|
|
//
|
|
// Containment is host-directed today: `maybeContain` runs off the heartbeat
|
|
// response (run.go:575), the host decides and the box carries it out. Whether
|
|
// the LOCAL UI may act is an open product question — the local UI's design
|
|
// keeps the same question open for turning a granted capability off,
|
|
// and uigiven.go's header says why a page must not be shaped like a settings
|
|
// screen with the switches missing.
|
|
//
|
|
// Beyond that: MARKING SOMETHING AS YOURS IS NOT THE SAME ACT AS TERMINATING
|
|
// SOMETHING. One is a person vouching, the other takes their work away. Putting
|
|
// both on one page would put a kill one mis-click from a vouch, and would settle
|
|
// a decision that is not this unit's to settle. So there is no Stop, no
|
|
// Terminate, and no disabled control hinting at one — and a test drives the
|
|
// whole page with killProcess stubbed to prove nothing here can reach it.
|
|
//
|
|
// ── THE PAGE MUST NOT BECOME A WAY TO HIDE ──────────────────────────────────
|
|
//
|
|
// A sanction is the user vouching for something, so the page is built around
|
|
// the set that has NOT been vouched for:
|
|
//
|
|
// - The unsanctioned count is the headline, above everything else.
|
|
// - A sanctioned entry is still LISTED. It moves down the page and loses its
|
|
// emphasis; it never disappears. A control that makes things vanish is a
|
|
// control an attacker wants the user to use.
|
|
// - A mark whose program is not running right now is listed too, in its own
|
|
// section, with the only button that removes it. Without that, a record for
|
|
// something long uninstalled would sit on the machine for ever, vouching for
|
|
// an identity nobody can see and nobody can withdraw.
|
|
// - A mark that has EXPIRED — written before the identity moved to the
|
|
// kernel's executable path (sanction.go, RECORDS WRITTEN BEFORE THIS) — is
|
|
// listed in a third section that says plainly that it vouches for nothing
|
|
// and why. The user's own record of a decision is not something this program
|
|
// deletes on their behalf without saying so, and "your marks stopped
|
|
// counting" is exactly the kind of change a security page must not make
|
|
// silently.
|
|
// - A build with no enumerator says so instead of showing an empty list.
|
|
// the README section "Observe" calls this the quiet-versus-blind distinction and
|
|
// agents_other.go:5-10 is the whole reason agentScanSupported exists; a page
|
|
// that renders "0 unsanctioned" on a box that cannot look is the most
|
|
// reassuring sentence this program could print about the least known machine.
|
|
// - An unreadable registry is an alarm, never a tick. ui_approvals_flow_test.go:
|
|
// 113-151 is the same lesson on the same kind of page: a read that FAILED is
|
|
// not a machine with nothing on it.
|
|
//
|
|
// ── NO SECOND FACTOR, AND THE REASON IS STRUCTURAL ──────────────────────────
|
|
//
|
|
// Approving a host's request needs a code (ui.go:484). Marking your own editor
|
|
// as yours does not, for two reasons. The step-up exists to defend against a
|
|
// compromised HOST and nothing here is host-facing. And there is no machine-wide
|
|
// second factor to ask for: step-up is enrolled PER PAIRING, in that pairing's
|
|
// own DCN dir (ui.go:264-277), while a sanction belongs to the box. ui.go:474-483
|
|
// already argues the same shape for the approval throttle — a control that only
|
|
// costs the real user is not a control.
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ── what the page shows ─────────────────────────────────────────────────────
|
|
|
|
// uiAgentRow is one observation, as a person reads it.
|
|
type uiAgentRow struct {
|
|
// ID is the sanction fingerprint (sanction.go). It is what the form posts,
|
|
// and it is deliberately NOT the pid: a pid posted back a second later may
|
|
// belong to something else entirely.
|
|
//
|
|
// EMPTY when the observation has no kernel executable path, because then
|
|
// there is no identity to post: addSanction would refuse it, and two
|
|
// unreadable processes of the same kind and signature would share the same
|
|
// fingerprint over an empty path. The template renders no button for an
|
|
// empty ID, so the page cannot offer a control that could only fail.
|
|
ID string
|
|
PID int
|
|
Kind string
|
|
Signature string
|
|
User string
|
|
Origin string
|
|
Cmdline string
|
|
// Exe is the file the kernel says this process is running — what the mark is
|
|
// actually recorded against. Shown beside the command line and never instead
|
|
// of it: they are different facts, and the page that asks someone to vouch
|
|
// for a program is the last place to conflate them.
|
|
Exe string
|
|
Endpoints []string
|
|
Evidence []evidence
|
|
|
|
Sanctioned bool
|
|
Why string
|
|
// Marked: a LOCAL record covers this, so Unmark is the action. Derived from
|
|
// the reason constant rather than by looking the record up again — asking
|
|
// the predicate is what stops this page drifting away from the gate, the
|
|
// same rule uigiven.go:36-41 sets for the caps matrix.
|
|
Marked bool
|
|
// Unknown: the registry could not be read, so Sanctioned is not a verdict.
|
|
// The page must not draw a tick for this (sanction.go, agentSanction).
|
|
Unknown bool
|
|
// App: contain.go:85 refuses to terminate an app however it is asked, so
|
|
// "not marked as yours" means something different here and the row says so.
|
|
App bool
|
|
}
|
|
|
|
// uiDormantSanction is a mark that matches nothing running right now. The same
|
|
// shape serves the EXPIRED list below — a record, what it named, when and by
|
|
// whom — because that is all either section shows; what differs is the sentence
|
|
// above them, which is the template's job.
|
|
type uiDormantSanction struct {
|
|
ID string
|
|
What string
|
|
Cmdline string
|
|
At string
|
|
By string
|
|
ByUser string
|
|
}
|
|
|
|
type uiAgentsView struct {
|
|
Version string
|
|
CheckedAt string
|
|
|
|
Rows []uiAgentRow
|
|
Observed int
|
|
Unsanctioned int
|
|
Dormant []uiDormantSanction
|
|
// Expired: marks written before the identity moved to the kernel's
|
|
// executable path (sanction.go, RECORDS WRITTEN BEFORE THIS). They vouch for
|
|
// nothing and they are shown ANYWAY, because the alternative is this program
|
|
// quietly discarding a decision the user made and never mentioning it.
|
|
Expired []uiDormantSanction
|
|
|
|
// Blind: this build has no enumerator for its platform, so the list below is
|
|
// empty because nothing looked — not because nothing is running.
|
|
Blind bool
|
|
// ReadError is why the registry could not be read. Every row is then
|
|
// Unknown, and the page says so instead of counting.
|
|
ReadError string
|
|
// WalkError is why the process walk failed for this render: the list is
|
|
// empty because nothing looked — Blind for one pass, not for the platform.
|
|
WalkError string
|
|
|
|
Flash string
|
|
FlashClass string
|
|
}
|
|
|
|
// collectAgentsView asks the observe plane and the registry, once.
|
|
//
|
|
// observedAgents (contain.go:34) rather than scanAgents directly: it is the same
|
|
// var contain consults, so the page and the thing that kills can never be
|
|
// answering different questions — and it is the seam every test in this package
|
|
// already stubs.
|
|
//
|
|
// Re-read on every render, never cached, for the reason renderWith gives
|
|
// (ui.go:424-427): a stale copy of a security page is worse than no page,
|
|
// because it is the page the user trusted.
|
|
func collectAgentsView() uiAgentsView {
|
|
v := uiAgentsView{
|
|
Version: clientVersion,
|
|
CheckedAt: uiNow().Format("15:04:05"),
|
|
Blind: !agentScanSupported,
|
|
}
|
|
if _, err := loadSanctions(); err != nil {
|
|
v.ReadError = err.Error()
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
agents, err := observedAgents()
|
|
if err != nil {
|
|
// The error wins over anything beside it: a list that arrived with an
|
|
// error is not believed, the same rule the kill guard holds.
|
|
v.WalkError = err.Error()
|
|
agents = nil
|
|
}
|
|
for _, a := range agents {
|
|
sanctioned, why := agentSanction(a)
|
|
id := sanctionIdentityOf(a)
|
|
row := uiAgentRow{
|
|
PID: a.PID,
|
|
Kind: a.Kind,
|
|
Signature: a.Signature,
|
|
User: a.User,
|
|
Origin: a.Origin,
|
|
Cmdline: a.Cmdline,
|
|
Exe: a.Exe,
|
|
Endpoints: a.LLMEndpoints,
|
|
Evidence: a.Evidence,
|
|
Sanctioned: sanctioned,
|
|
Why: why,
|
|
Marked: why == sanctionWhyLocal || why == sanctionWhyBoth,
|
|
Unknown: why == sanctionWhyUnreadable,
|
|
App: a.Kind == kindApp,
|
|
}
|
|
// No identity, no id to post. Asked of the identity rather than of the
|
|
// reason string, so the page cannot offer a mark button in some future
|
|
// state where the two disagree.
|
|
if id.usable() {
|
|
row.ID = id.fingerprint()
|
|
seen[row.ID] = true
|
|
}
|
|
v.Rows = append(v.Rows, row)
|
|
v.Observed++
|
|
if !sanctioned {
|
|
v.Unsanctioned++
|
|
}
|
|
}
|
|
sortAgentRows(v.Rows)
|
|
|
|
// Marks with nothing running behind them, and marks that no longer count at
|
|
// all. Skipped entirely when the registry could not be read, because an
|
|
// empty "you have marked nothing" list would be the reassuring half of the
|
|
// same lie the ReadError banner exists to stop. A failed WALK withholds
|
|
// only the dormant half — "not running right now" needs `seen` — while a
|
|
// mark that no longer counts is a fact about the record and is still said:
|
|
// silence about a control that changed meaning is the worse failure.
|
|
if v.ReadError == "" {
|
|
recs, err := sanctionRecords()
|
|
if err != nil {
|
|
v.ReadError = err.Error()
|
|
}
|
|
for _, r := range recs {
|
|
entry := uiDormantSanction{
|
|
ID: r.ID, What: r.describeMark(), Cmdline: r.Cmdline,
|
|
At: r.At.Local().Format(uiEventTime), By: r.By, ByUser: r.ByUser,
|
|
}
|
|
// EXPIRED IS TESTED FIRST, before `seen`, and the order is load
|
|
// bearing in two different ways — both measured by swapping it:
|
|
//
|
|
// a record whose key matches something running DISAPPEARS, because
|
|
// `seen` swallows it and the user is never told their mark stopped
|
|
// counting. (A hand-edited or truncated registry produces this.)
|
|
// a record whose key matches nothing lands under "marked
|
|
// as yours, but not running", which is the one sentence about it
|
|
// that is no longer true.
|
|
//
|
|
// The first is the worse of the two: silence about a control that
|
|
// changed meaning.
|
|
if r.stale() {
|
|
v.Expired = append(v.Expired, entry)
|
|
continue
|
|
}
|
|
if seen[r.ID] || v.WalkError != "" {
|
|
continue
|
|
}
|
|
v.Dormant = append(v.Dormant, entry)
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// sortAgentRows puts the page's subject first: everything nobody has vouched
|
|
// for, then everything that has been.
|
|
//
|
|
// Within the unsanctioned set, an APP sinks below the rest — contain.go:85
|
|
// refuses to terminate one however the host asks, so it can never be the thing
|
|
// this whole feature exists to make safe to stop. It is still counted and still
|
|
// listed; it simply does not lead a list a person reads top-down.
|
|
//
|
|
// The remaining keys (kind, signature, pid) exist so the order is STABLE across
|
|
// a 10-second poll. A list that reshuffles under the cursor is a list nobody
|
|
// clicks the right row in.
|
|
func sortAgentRows(rows []uiAgentRow) {
|
|
rank := func(r uiAgentRow) int {
|
|
switch {
|
|
case !r.Sanctioned && !r.App:
|
|
return 0
|
|
case !r.Sanctioned:
|
|
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
|
|
})
|
|
}
|
|
|
|
// ── the routes ──────────────────────────────────────────────────────────────
|
|
|
|
// agentRoutes registers this page. The shape copies setupRoutes
|
|
// (uisetup.go:199-212) exactly, including WHY the method check is 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.
|
|
func (u *uiServer) agentRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/agents", u.guarded(u.agentsPage))
|
|
mux.HandleFunc("/fragments/agents", u.guarded(u.agentsFragment))
|
|
mux.HandleFunc("/agents/mark", u.guarded(u.agentsMark))
|
|
mux.HandleFunc("/agents/unmark", u.guarded(u.agentsUnmark))
|
|
}
|
|
|
|
// renderAgents sets the SAME four headers renderWith does (ui.go:429-434) and
|
|
// not one fewer, for the reason uigiven.go:513-518 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) renderAgents(w http.ResponseWriter, name string, v uiAgentsView) {
|
|
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) agentsPage(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
u.renderAgents(w, "agents-page", collectAgentsView())
|
|
}
|
|
|
|
func (u *uiServer) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
u.renderAgents(w, "agents", collectAgentsView())
|
|
}
|
|
|
|
// agentsFlash re-reads the machine and renders the fragment with an outcome
|
|
// line, exactly as renderWith does after an approve (ui.go:424-427). The state
|
|
// is always re-read: after a mark, the row the person clicked has changed sides.
|
|
func (u *uiServer) agentsFlash(w http.ResponseWriter, flash, class string) {
|
|
v := collectAgentsView()
|
|
v.Flash, v.FlashClass = flash, class
|
|
u.renderAgents(w, "agents", v)
|
|
}
|
|
|
|
// agentsMark records one observation as the user's.
|
|
//
|
|
// THE POSTED ID IS LOOKED UP AMONG WHAT IS RUNNING, never trusted as an
|
|
// identity. approvalTarget (ui.go:442-448) takes the same care with a posted
|
|
// host name and says why: the form is local and session-authed, but a handler
|
|
// that builds state out of a posted string is the shape of bug that turns a
|
|
// local UI into something wider. Here it also buys a real property — a person
|
|
// can only vouch for something the plane can SEE — so a mark can never be
|
|
// pre-planted for an identity that has not run yet.
|
|
//
|
|
// The cost is one extra scan per click (24ms, measured; see sanction.go). A
|
|
// click is a human, and the alternative is trusting the browser about which
|
|
// program it is talking about.
|
|
func (u *uiServer) agentsMark(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.FormValue("id"))
|
|
if id == "" {
|
|
u.agentsFlash(w, "Nothing was marked: that form carried no program.", "bad")
|
|
return
|
|
}
|
|
agents, err := observedAgents()
|
|
if err != nil {
|
|
// The right cause, not "not running any more": nothing was looked at.
|
|
u.agentsFlash(w, "Nothing was marked: this machine could not read its processes "+
|
|
"just now ("+err.Error()+"). Try again.", "bad")
|
|
return
|
|
}
|
|
for _, a := range agents {
|
|
if sanctionIdentityOf(a).fingerprint() != id {
|
|
continue
|
|
}
|
|
rec, err := addSanction(a, sanctionByLocalUI)
|
|
if err != nil {
|
|
u.agentsFlash(w, "Not marked: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
u.agentsFlash(w, "Marked as yours: "+rec.sanctionIdentity.describe()+
|
|
". It stays marked across restarts, including this program's own updates.", "ok")
|
|
return
|
|
}
|
|
// Said as a fact about the machine rather than as an error about the form:
|
|
// between the render and the click the process may simply have exited, which
|
|
// is not the user doing anything wrong.
|
|
u.agentsFlash(w, "Nothing was marked: that program is not running on this "+
|
|
"machine any more. Only something this machine can see right now can be "+
|
|
"marked as yours.", "warn")
|
|
}
|
|
|
|
// agentsUnmark withdraws a mark.
|
|
//
|
|
// BY ID ALONE, with no scan. A mark must be withdrawable for a program that is
|
|
// no longer running — see dropSanction — or the dormant list would be a list of
|
|
// records with no way to remove any of them.
|
|
func (u *uiServer) agentsUnmark(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.FormValue("id"))
|
|
dropped, err := dropSanction(id)
|
|
switch {
|
|
case err != nil:
|
|
u.agentsFlash(w, "Not unmarked: "+err.Error(), "bad")
|
|
case !dropped:
|
|
u.agentsFlash(w, "Nothing to unmark: this machine holds no mark for that program.", "warn")
|
|
default:
|
|
u.agentsFlash(w, "Unmarked. Nothing at this machine vouches for that program now.", "ok")
|
|
}
|
|
}
|