bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user