771 lines
38 KiB
Go
771 lines
38 KiB
Go
package main
|
|
|
|
// sanction.go — LOCAL SANCTION: the user, at their own machine, saying
|
|
// "this one is mine".
|
|
//
|
|
// NO BUILD TAG, on purpose, exactly like agents_parse.go: every judgement in
|
|
// here has to be exercised by the one `go test` people actually run, and the
|
|
// predicate below is the seam a policy engine will be built on.
|
|
//
|
|
// ── WHY THIS EXISTS ─────────────────────────────────────────────────────────
|
|
//
|
|
// This is the harder half of the pitch, and it comes second in the build
|
|
// order. The pitch is to identify the good so the bad can be terminated, and
|
|
// until this there was no way for a user to say THIS ONE IS MINE. Without
|
|
// that, a policy engine's first act is to kill the user's own editor.
|
|
// Detection is largely built — four kinds (agents.go:177), origin
|
|
// (origin.go), typed evidence (origin.go:53) — and containment is real
|
|
// (contain.go). What has been missing is the only input that makes any of it
|
|
// safe to act on.
|
|
//
|
|
// ── TWO MECHANISMS, AND THEY CAN NEVER BE ONE ───────────────────────────────
|
|
//
|
|
// THE HOST'S TAG. `DelegationToken` (agents.go:203) is read out of the
|
|
// process's OWN ENVIRONMENT — `procEnvToken` reads /proc/<pid>/environ for
|
|
// PRIVATAE_AGENT_TOKEN (agents_linux.go:149), `ps -E` on darwin
|
|
// (agents_darwin.go:192), the PEB on Windows (agents_windows.go:498). An
|
|
// environment variable is fixed at exec time by whoever called exec. It can
|
|
// mark an agent the HOST LAUNCHED. It can never mark a process that is already
|
|
// running, which is every process the user is looking at when they open this
|
|
// page.
|
|
//
|
|
// LOCAL SANCTION is the other half and it cannot use that mechanism at all.
|
|
// Nothing here mints a tag, writes to another process, or re-execs anything.
|
|
// It records, on this machine, that a person said a program is theirs — and a
|
|
// record is the only form this can take, because the thing being marked is
|
|
// already running.
|
|
//
|
|
// ── WHAT IDENTITY A SANCTION IS RECORDED AGAINST ────────────────────────────
|
|
//
|
|
// agentSanction gets one agentObs (agents.go:166-213) and nothing else, so the
|
|
// identity has to be built out of its fields. Taken one at a time:
|
|
//
|
|
// PID (agents.go:167) Worthless. It changes on every exec, and contain.go's
|
|
// own header records the shape that makes it worse than
|
|
// useless: "An agent under a supervisor comes straight
|
|
// back with a new pid." A sanction keyed on a pid is
|
|
// void by the next restart.
|
|
// PPID The same fact, one level up.
|
|
// Signature (agents.go:180) The TABLE ENTRY that matched (agents.go:34-59) —
|
|
// the PRODUCT, not the instance. Stable, and derived
|
|
// from the basename of argv[0] (agents_parse.go:47-49),
|
|
// so on its own it means "every claude-code on this box
|
|
// is mine" and anything that calls itself `claude` is a
|
|
// claude-code.
|
|
// Cmdline (agents.go:181) Stable for one exact invocation and different the
|
|
// moment an argument changes. `claude` and
|
|
// `claude --resume abc` would be two separate marks.
|
|
// That is the shape of control people stop using. Its
|
|
// first token — argv[0] — keyed this record before
|
|
// the exe identity; see THE FORGERY THIS CLOSES.
|
|
// Exe (agents.go:182-201) The KERNEL's record of which FILE the process is
|
|
// running. The ONLY field in an observation that the
|
|
// process did not choose for itself. EMPTY when the
|
|
// platform could not read it, and never back-filled.
|
|
// User (agents.go:179) Coarse alone; load-bearing as a component, because a
|
|
// mark must not cross OS accounts on a shared machine.
|
|
// Kind (agents.go:178) A class, not an identity; load-bearing as a
|
|
// component, because "the Claude desktop APP is mine"
|
|
// must not quietly also vouch for a claude-code AGENT.
|
|
// Origin, LLMEndpoints, All vary run to run. An identity built on them would
|
|
// Evidence need re-marking whenever the agent talked to a
|
|
// different endpoint.
|
|
//
|
|
// CHOSEN: the four-field tuple (Kind, Signature, Exe, User), fingerprinted.
|
|
// The signature stays in the tuple because for an agent that is a SCRIPT the
|
|
// kernel's executable is the interpreter, not the agent (see WHAT THIS STILL
|
|
// CONCEDES): without the signature, marking one node-hosted agent would vouch
|
|
// for every node process the plane observes for that user.
|
|
//
|
|
// Matched EXACTLY, never normalised. A launcher that spells the same path
|
|
// differently (Windows case, a symlink vs its target on macOS) produces a
|
|
// different identity and needs its own mark. That is a false NEGATIVE — it shows
|
|
// as unsanctioned — and unsanctioned is the safe direction for a wrong answer
|
|
// here.
|
|
//
|
|
// AN OBSERVATION WITH NO KERNEL PATH CANNOT BE MARKED AT ALL. Not "falls back to
|
|
// argv[0]", not "matches on the rest of the tuple": addSanction refuses to write
|
|
// one and agentSanction answers sanctionWhyNoExe. A fallback would mean the
|
|
// forgery below still worked, and would work precisely where this box is most
|
|
// blind — a process it cannot read is not a process it may vouch for. The cost
|
|
// is real and lands unevenly, so it is named in the platform table below rather
|
|
// than discovered later.
|
|
//
|
|
// ── WHAT EACH PLATFORM ACTUALLY SUPPLIES ────────────────────────────────────
|
|
//
|
|
// Linux /proc/<pid>/exe (agents_linux.go, procExe). A link the KERNEL
|
|
// maintains from the vnode, resolved: a `#!/bin/bash` script reported
|
|
// /usr/bin/bash here while its own argv[0] said /bin/bash. Strongest
|
|
// of the three. Readable for any process of the same user, and for
|
|
// every process when the bastion runs as root.
|
|
// macOS The exec path the kernel saved at exec time, read from
|
|
// KERN_PROCARGS2 (agents_darwin.go, procExe; decoded by
|
|
// parseProcargs2ExePath). NOT `ps -o comm=`, which three comments in
|
|
// this tree called the kernel's path and which is argv[0]: Apple's ps
|
|
// maps comm to just_command (adv_cmds ps/keyword.c:101), and
|
|
// getproclline (ps/print.c:115) skips the saved exec_path to reach
|
|
// argv[0]. The saved path is the string PASSED to execve, kept before
|
|
// namei resolves it (xnu bsd/kern/kern_exec.c exec_save_path), so it
|
|
// is the path the kernel opened but NOT re-derived from the vnode: a
|
|
// symlink stays the symlink. Same-user or root only.
|
|
// Windows QueryFullProcessImageName with PROCESS_QUERY_LIMITED_INFORMATION
|
|
// (agents_windows.go, winImagePath). NOT Toolhelp32's ExeFile
|
|
// (origin_windows.go:50), which is a BASENAME with no directory — so
|
|
// "claude.exe" in %LOCALAPPDATA% and "claude.exe" in %TEMP% were one
|
|
// string. QUERY_LIMITED is less than the PEB reads need, so a process
|
|
// whose memory is closed to the bastion can still be marked.
|
|
// other Empty, always (agents_other.go). That build reports it cannot watch
|
|
// itself, and now also cannot vouch for anything — which is the honest
|
|
// pair of statements for a box with no enumerator.
|
|
//
|
|
// ── WHAT THIS STILL CONCEDES ────────────────────────────────────────────────
|
|
//
|
|
// The forgery is NOT eliminated. Its price changes, and the price is the claim:
|
|
//
|
|
// BEFORE choose a string. One exec with a chosen argv[0] — free, needs no
|
|
// access to anything, leaves no trace on disk.
|
|
// AFTER control a FILE at the sanctioned path. Write access to that exact
|
|
// path (or to its directory), which for `~/.local/bin/claude` is the
|
|
// user's own account and for `/usr/local/bin/...` is root.
|
|
//
|
|
// So an attacker who can already write where the user's agent lives inherits
|
|
// the mark, and nothing here pretends otherwise. What it costs them is a
|
|
// durable artifact on disk in a place a person can look, instead of a flag on
|
|
// one exec.
|
|
//
|
|
// THREE MORE THINGS IT DOES NOT FIX, each of which has to be said out loud:
|
|
//
|
|
// - AN AGENT THAT IS A SCRIPT PINS ITS INTERPRETER, NOT ITSELF. On every
|
|
// platform the kernel's executable for a `#!` script is the interpreter —
|
|
// measured: /usr/bin/bash for a shell script here; macOS stores the shebang
|
|
// line's interpreter explicitly (xnu kern_exec.c, exec_save_path on
|
|
// ip_interp_buffer). For that class the tuple is (agent, <signature>,
|
|
// /usr/bin/node, user), and the only part telling one node-hosted agent
|
|
// from another is the SIGNATURE, which is derived from argv
|
|
// (agents_parse.go:43-68). Interpreter-hosted agents therefore keep an
|
|
// argv-shaped component: the forgery narrows from "any argv[0]" to "that
|
|
// argv[0] AND the same interpreter path", which is a smaller set and not a
|
|
// hard one to enter.
|
|
// - AN EMPTY EXE IS UNMARKABLE, AND A POLICY ENGINE MUST NOT READ THAT AS
|
|
// UNWANTED. sanctionWhyNoExe exists so the two are different answers.
|
|
// - THE PATH IS NOT THE FILE. Nothing here checks that the bytes at that path
|
|
// are the bytes the user vouched for; see THE NEXT RUNG.
|
|
//
|
|
// Therefore, unchanged in size: **LOCAL SANCTION IS A CONVENIENCE AND VISIBILITY
|
|
// CONTROL ON THE USER'S OWN MACHINE. IT IS NOT AN AUTHENTICATION BOUNDARY, AND
|
|
// NOTHING DOWNSTREAM MAY TREAT IT AS ONE.** README.md sets the same bar for the
|
|
// plane underneath it — the observe plane is "soft and evadable by design:
|
|
// visibility and early-warning, never the security wall". This is the same kind
|
|
// of thing and the same size of claim.
|
|
//
|
|
// What it buys, which is not nothing: the UNSANCTIONED SET becomes small enough
|
|
// for a person to read, a policy engine gains a way to not kill the user's
|
|
// editor on its first run, and the cheapest evasion now needs write access
|
|
// somewhere a person can audit.
|
|
//
|
|
// ── THE FORGERY THIS CLOSES ─────────────────────────────────────────────────
|
|
//
|
|
// Measured on this box, and it is one line:
|
|
//
|
|
// $ bash -c 'exec -a /home/user/.local/bin/claude sleep 25'
|
|
// /proc/<pid>/cmdline -> /home/user/.local/bin/claude 25
|
|
// /proc/<pid>/exe -> /usr/lib/cargo/bin/coreutils/sleep
|
|
//
|
|
// Every platform enumerator reads the process's own command line
|
|
// (agents_linux.go:41, agents_darwin.go:88, agents_windows.go:374), so while
|
|
// argv[0] was in the tuple that process matched a mark made for the real
|
|
// claude EXACTLY, without ever touching that file. The test that must never go
|
|
// green again is TestAForgedArgv0FromADifferentExecutableIsNotSanctioned.
|
|
//
|
|
// ── THE NEXT RUNG, NAMED AND NOT BUILT ──────────────────────────────────────
|
|
//
|
|
// Verify the executable itself rather than its path: Authenticode on Windows
|
|
// (WinVerifyTrust), `codesign -v` / the SecStaticCode API on macOS, and on
|
|
// Linux no standard equivalent at all — there is no system-wide signature
|
|
// scheme for ELF binaries, so it would have to be a hash the user pins.
|
|
//
|
|
// The tradeoff is the reason it is not here: A CONTENT HASH PINS A VERSION.
|
|
// Every legitimate update rewrites the binary, so every update un-sanctions the
|
|
// agent, and the user re-marks their editor on a release cadence they do not
|
|
// control. That is the same "control they stop using" failure the STORAGE
|
|
// section is written around. A signature CHAIN (publisher, not bytes) survives
|
|
// updates and is the only version of this worth building — which is why it is
|
|
// Windows and macOS first, and why Linux would need something else entirely.
|
|
//
|
|
// ── THE HOST TAG: THIS BOX CANNOT CHECK IT ──────────────────────────────────
|
|
//
|
|
// The tag is minted on the HOST as `"del_" + 18 url-safe random bytes`, and
|
|
// which tags are live lives in the host's delegations table. The
|
|
// reconciliation is explicitly the host's, and its own schema states the rule:
|
|
// the bastion observes the tag from the agent's env and reports it; the host
|
|
// reconciles, and an observed agent carrying a valid (non-revoked, non-expired)
|
|
// tag for its server counts as requested. Nothing in this repo holds a copy of that table; the
|
|
// only `del_` in the tree is test fixtures and one comment
|
|
// (agents_parse.go:703).
|
|
//
|
|
// So what this box can check is SHAPE, and the reason string says exactly that
|
|
// rather than implying more. A tag whose shape is wrong is NOT sanctioned: a
|
|
// real host tag always has the shape, so no real agent is refused by it, and a
|
|
// string that is not a host tag is not evidence the host launched anything.
|
|
//
|
|
// A shape check costs an attacker 24 random characters. It is not a control. It
|
|
// is here so the sentence shown to the user can be precise about which of the
|
|
// two mechanisms answered.
|
|
//
|
|
// ── RECORDS WRITTEN BEFORE THIS (v1), AND WHY THEY ARE NOT HONOURED ─────────
|
|
//
|
|
// A sanctions.json written by the argv[0] version may already be on a machine.
|
|
// Every one of those records says "the thing that CALLS ITSELF this is mine",
|
|
// which is the sentence this change exists to stop believing. Carrying them
|
|
// forward would preserve exactly the forgery being fixed, so:
|
|
//
|
|
// NOT MATCHED A v1 record has no Exe. sanctionRecord.stale() reports that,
|
|
// matching skips it, and the fingerprint domain moved to
|
|
// "privatae-sanction-v2" as well — so a v1 key cannot even be
|
|
// produced by a new observation. Two independent gates, because
|
|
// one of them being subtly wrong is how this kind of thing
|
|
// survives a migration.
|
|
// NOT DELETED It is the user's record of a decision they made. Deleting it
|
|
// silently would be this program throwing away their state and
|
|
// telling them nothing. It is kept, shown in its own section of
|
|
// the page with what it named and WHY it no longer counts, and
|
|
// the only button on it removes it.
|
|
// RE-MADE, NOT There is no "upgrade" path that could be honest: the v1 record
|
|
// UPGRADED holds a string the program chose, and nothing on this machine
|
|
// can turn that into the file the kernel loaded for a process
|
|
// that may not even be running. The user marks the live program
|
|
// again, which is one click on a page they are already on.
|
|
//
|
|
// The legacy value is retained on the record (sanctionRecord.LegacyArgv0) for
|
|
// ONE purpose: so the page can say what the old mark named. It is never an
|
|
// input to matching.
|
|
//
|
|
// ── STORAGE ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// sanctions.json, beside events.json and the pairing state, in the config dir
|
|
// configDir() creates at 0700 (config.go:137-142). Written through atomicWrite
|
|
// (ctx.go:133-160 — temp file, chmod 0600, fsync, rename), so a reader never
|
|
// sees a half-written blob and a crash mid-write leaves the previous registry
|
|
// rather than an empty one.
|
|
//
|
|
// IT SURVIVES A RESTART, and the restart that matters is this binary's own
|
|
// self-update: maybeSelfUpdate replaces the executable and calls os.Exit(0)
|
|
// (update.go:127). events.go:35-45 records the same reasoning for the same
|
|
// directory. A sanction the user must re-apply after every update is a control
|
|
// they stop using, and the moment they stop, the unsanctioned set stops meaning
|
|
// anything.
|
|
//
|
|
// READS CREATE NOTHING. sanctionsPath is built on configDirPath()
|
|
// (config.go:129), which resolves the path without creating it — NOT configDir()
|
|
// (config.go:137), which is MkdirAll-on-read. agentSanction is a predicate a
|
|
// policy engine will call in a loop over every observation, and a predicate that
|
|
// creates a directory as a side effect of being asked a question is the exact
|
|
// defect ui_approvals_flow_test.go:53-84 exists to stop, which has already been
|
|
// written into this codebase twice. The WRITE path creates the directory, via
|
|
// atomicWrite's own MkdirAll(0700) at ctx.go:135.
|
|
//
|
|
// NO IN-MEMORY CACHE. The registry is read from disk on every call. The cheaper
|
|
// alternative — a package-level list loaded once, the shape events.go:81-90 uses
|
|
// — was rejected on two counts that outweigh the saving, and the saving was
|
|
// measured rather than assumed. On this box (go test -bench, EPYC 4345P):
|
|
// loadSanctions over a 5-record registry is 4.8µs; scanAgents(), which the
|
|
// CALLER must run to produce a single agentObs at all, is 24.2ms. The cache
|
|
// would be saving one part in five thousand. What it costs is worse than that:
|
|
// package state every test has to reset (events.go:47-57 records the accident
|
|
// that shape already caused in this package), and a copy that goes stale the
|
|
// moment anything outside this process writes the file.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ── the reasons ─────────────────────────────────────────────────────────────
|
|
|
|
// The `why` a predicate returns is ALWAYS one of these constants. They are the
|
|
// stable half of the seam: a policy engine COMPARES (why == sanctionWhyHostTag),
|
|
// it never parses the sentence. They are written as sentences because the local
|
|
// UI renders them verbatim, and a reason a person cannot read is a reason that
|
|
// gets ignored.
|
|
//
|
|
// The two positive reasons are kept apart because "the host launched it" and
|
|
// "you marked it" are different facts and a later policy may want to treat them
|
|
// differently — one is an external authority this box cannot check, the other is
|
|
// a local record with an audit trail. sanctionWhyBoth exists so that a process
|
|
// carrying both does not silently lose one of them: collapsing to either would
|
|
// make the other unrecoverable by the caller.
|
|
const (
|
|
sanctionWhyHostTag = "The host launched it: this process carries a host-issued " +
|
|
"delegation tag. This machine cannot check whether that tag is still valid — " +
|
|
"only your host can."
|
|
sanctionWhyLocal = "You marked it: someone at this machine recorded this program " +
|
|
"as yours."
|
|
sanctionWhyBoth = "The host launched it — it carries a host-issued delegation tag, " +
|
|
"which only your host can check — and someone at this machine also marked it " +
|
|
"as yours."
|
|
sanctionWhyNone = "Nobody has vouched for this. It carries no host delegation tag, " +
|
|
"and nothing at this machine has marked it as yours."
|
|
sanctionWhyBadTag = "Nobody has vouched for this. It sets PRIVATAE_AGENT_TOKEN to " +
|
|
"something that is not shaped like a tag your host mints, and nothing at this " +
|
|
"machine has marked it as yours."
|
|
// sanctionWhyUnreadable is the one reason that comes back with
|
|
// sanctioned=TRUE without anything having vouched for anything. See
|
|
// agentSanction for why that is the safe direction, and why a caller that
|
|
// RENDERS the verdict has to test for this constant.
|
|
sanctionWhyUnreadable = "Unknown. This machine cannot read its own record of what " +
|
|
"you marked as yours, so it cannot say whether this was vouched for. Nothing " +
|
|
"here may be treated as unvouched until that is fixed."
|
|
// sanctionWhyNoExe (added with the exe identity) is NOT
|
|
// "nobody vouched for this". It is "this machine could not establish what
|
|
// this is", and the two must stay apart for the same reason
|
|
// sanctionWhyUnreadable is its own constant: a caller that terminates
|
|
// things is entitled to treat "I could not identify it" more carefully than
|
|
// "nothing has vouched for it", and it can only do that if the predicate
|
|
// says which one happened. It also tells a UI not to offer a mark button
|
|
// that could only fail.
|
|
sanctionWhyNoExe = "Nobody has vouched for this, and nothing here can. This machine " +
|
|
"could not read which file the process is actually running — the one thing " +
|
|
"about it that cannot be renamed — so there is nothing to mark as yours. Its " +
|
|
"command line is only the name it chose for itself."
|
|
)
|
|
|
|
// ── the host tag's shape ────────────────────────────────────────────────────
|
|
|
|
// hostTagPrefix and hostTagMinBody are the SHAPE of a tag the host mints:
|
|
// `"del_" + 18 url-safe random bytes`, which is 24 base64url characters after
|
|
// the prefix.
|
|
//
|
|
// The check is a FLOOR (16) rather than an equality (24) deliberately. A host
|
|
// that changes the entropy or the mint would otherwise make every tagged agent
|
|
// on every machine in the fleet read as unsanctioned overnight, and the box
|
|
// would be wrong in the direction that gets someone's work killed. 16 is still
|
|
// far past anything a person types into a shell by hand, which is the only
|
|
// thing this check actually catches.
|
|
const (
|
|
hostTagPrefix = "del_"
|
|
hostTagMinBody = 16
|
|
)
|
|
|
|
// looksLikeHostTag reports whether a delegation tag has the shape the host
|
|
// mints. It is NOT validation and nothing here pretends otherwise — see the
|
|
// header. The alphabet is base64url, which is what token_urlsafe produces.
|
|
func looksLikeHostTag(tok string) bool {
|
|
if !strings.HasPrefix(tok, hostTagPrefix) {
|
|
return false
|
|
}
|
|
body := tok[len(hostTagPrefix):]
|
|
if len(body) < hostTagMinBody {
|
|
return false
|
|
}
|
|
for _, r := range body {
|
|
switch {
|
|
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ── the identity ────────────────────────────────────────────────────────────
|
|
|
|
// sanctionIdentity is the tuple a local sanction is recorded against. The
|
|
// header argues each field; the short version is that this is the most specific
|
|
// thing in an agentObs that survives a restart.
|
|
type sanctionIdentity struct {
|
|
Kind string `json:"kind"`
|
|
Signature string `json:"signature"`
|
|
// Exe is the KERNEL's executable path for the process (agents.go, procExe
|
|
// per platform) — the file it is running, not the name it gave itself.
|
|
// EMPTY means this is not an identity: nothing may be marked against it and
|
|
// nothing may match it. The field it replaced was `argv0`, which is why the
|
|
// fingerprint's domain moved to v2.
|
|
Exe string `json:"exe"`
|
|
User string `json:"user"`
|
|
}
|
|
|
|
// sanctionIdentityOf derives the identity of one observation.
|
|
//
|
|
// a.Exe is taken VERBATIM. It is deliberately not defaulted, not cleaned up and
|
|
// not filled in from a.Cmdline when it is empty: every one of those is the same
|
|
// bug wearing a different hat, because the command line is the attacker-chosen
|
|
// value this record stopped keying on.
|
|
func sanctionIdentityOf(a agentObs) sanctionIdentity {
|
|
return sanctionIdentity{
|
|
Kind: a.Kind,
|
|
Signature: a.Signature,
|
|
Exe: a.Exe,
|
|
User: a.User,
|
|
}
|
|
}
|
|
|
|
// usable reports whether this identity can be marked or matched at all. An
|
|
// identity with no kernel path names nothing a later observation could be
|
|
// checked against — two different programs the box could not read would share
|
|
// it — so it is refused at both ends (addSanction, agentSanction).
|
|
func (id sanctionIdentity) usable() bool { return id.Exe != "" }
|
|
|
|
// fingerprint is the stable id of an identity: the value the UI posts and the
|
|
// key the registry is stored under.
|
|
//
|
|
// A hash rather than the tuple itself, for three reasons that are all about the
|
|
// form rather than about secrecy (the record stores the tuple in clear beside
|
|
// it): it is fixed-length, it is safe as a map key and a form value with no
|
|
// escaping, and a domain prefix means changing the fields cannot silently
|
|
// collide with an older record.
|
|
//
|
|
// THE DOMAIN IS v2 BECAUSE THE THIRD FIELD CHANGED MEANING. v1 hashed argv[0];
|
|
// this hashes the kernel's path. Bumping it makes it arithmetically impossible
|
|
// for a new observation to land on a v1 key — so the "old marks must not
|
|
// silently match" rule holds even if the Exe check below were removed by
|
|
// accident. That is the whole reason the prefix was there in the first place.
|
|
func (id sanctionIdentity) fingerprint() string {
|
|
sum := sha256.Sum256([]byte("privatae-sanction-v2\x00" + id.Kind + "\x00" +
|
|
id.Signature + "\x00" + id.Exe + "\x00" + id.User))
|
|
return hex.EncodeToString(sum[:16])
|
|
}
|
|
|
|
// describe is the identity in the words the page and the record use. Kept here
|
|
// so the page and the audit line can never drift apart.
|
|
//
|
|
// "run from" rather than "called" on purpose: the value is now the file, and
|
|
// the sentence a person reads should say which of the two it is.
|
|
func (id sanctionIdentity) describe() string {
|
|
what := id.Signature
|
|
if what == "" {
|
|
what = "an unnamed program"
|
|
}
|
|
if id.Exe != "" {
|
|
what += " run from " + id.Exe
|
|
}
|
|
if id.User != "" {
|
|
what += ", as " + id.User
|
|
}
|
|
return what
|
|
}
|
|
|
|
// ── the record ──────────────────────────────────────────────────────────────
|
|
|
|
// sanctionBy names the MECHANISM that made a mark, so a record written by a
|
|
// later surface (a CLI verb, another page) is distinguishable from this one
|
|
// without anybody having to date it.
|
|
const sanctionByLocalUI = "local-ui"
|
|
|
|
// sanctionRecord is one mark. It holds three separate things on purpose:
|
|
//
|
|
// the identity what a future observation is matched against
|
|
// Cmdline WHAT WAS SANCTIONED, as it looked at the moment of the
|
|
// click. Never matched on; it is what lets someone reading
|
|
// this record a month later see what the person was actually
|
|
// looking at, which the identity alone does not show.
|
|
// By / ByUser / At BY WHOM, HOW and WHEN.
|
|
type sanctionRecord struct {
|
|
ID string `json:"id"`
|
|
sanctionIdentity
|
|
Cmdline string `json:"cmdline,omitempty"`
|
|
At time.Time `json:"at"`
|
|
By string `json:"by"`
|
|
// ByUser is the OS account this program was running as when it wrote the
|
|
// record. It is NOT proof a human did anything — the bastion is commonly
|
|
// root — and it is empty rather than guessed when it cannot be read.
|
|
ByUser string `json:"by_user,omitempty"`
|
|
// LegacyArgv0 decodes the `argv0` field of a v1 record — the identity this
|
|
// registry used before the exe identity. NEVER WRITTEN by this code and never an
|
|
// input to matching; it exists so the page can tell the user what an expired
|
|
// mark named, instead of showing them a record with an empty description and
|
|
// asking them to trust it was theirs. See the header, RECORDS WRITTEN
|
|
// BEFORE THIS.
|
|
LegacyArgv0 string `json:"argv0,omitempty"`
|
|
}
|
|
|
|
// stale reports a record that can no longer vouch for anything: a v1 mark, kept
|
|
// for the user to see and remove, keyed on a string the program chose for
|
|
// itself. The test is the absence of a kernel path rather than the presence of
|
|
// the legacy field, because the property that matters is "there is nothing here
|
|
// to check against", which is also true of a hand-edited or truncated record.
|
|
func (r sanctionRecord) stale() bool { return !r.usable() }
|
|
|
|
// describeMark is what the page calls a record. It is NOT the promoted
|
|
// describe(): a stale record's path lived in a field the identity no longer
|
|
// has, and printing it as "run from …" would state the exact thing this change
|
|
// stopped believing. Named rather than shadowing so no call site picks the
|
|
// wrong one by accident.
|
|
func (r sanctionRecord) describeMark() string {
|
|
if !r.stale() {
|
|
return r.sanctionIdentity.describe()
|
|
}
|
|
what := r.Signature
|
|
if what == "" {
|
|
what = "an unnamed program"
|
|
}
|
|
if r.LegacyArgv0 != "" {
|
|
what += " that called itself " + r.LegacyArgv0
|
|
}
|
|
if r.User != "" {
|
|
what += ", as " + r.User
|
|
}
|
|
return what
|
|
}
|
|
|
|
// sanctionNow is the clock seam, exactly as events.go:89 and
|
|
// directive_refusal.go:35 do it: a test pins the timestamp rather than sleeping.
|
|
var sanctionNow = time.Now
|
|
|
|
// sanctionMu serialises read-modify-write. Two POSTs can land at once — the
|
|
// page is a browser and a browser will happily send both.
|
|
var sanctionMu sync.Mutex
|
|
|
|
// sanctionsPath is the registry's home. configDirPath(), NOT configDir(): see
|
|
// the header. Resolving a path must not create a directory.
|
|
func sanctionsPath() (string, error) {
|
|
d, err := configDirPath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(d, "sanctions.json"), nil
|
|
}
|
|
|
|
// loadSanctions reads the registry, keyed by fingerprint.
|
|
//
|
|
// A MISSING FILE IS AN EMPTY REGISTRY AND NOT AN ERROR — that is the ordinary
|
|
// state of a machine where nobody has marked anything yet.
|
|
//
|
|
// AN UNREADABLE OR CORRUPT FILE IS AN ERROR, and this is where it deliberately
|
|
// parts company with events.go:128-143, which swallows the same failure. There,
|
|
// losing the file loses history. Here, treating an unreadable registry as empty
|
|
// would make every marked agent on the machine read as UNSANCTIONED, and the
|
|
// thing downstream of this predicate terminates processes. "I could not read it"
|
|
// and "there is nothing in it" have to stay different answers.
|
|
func loadSanctions() (map[string]sanctionRecord, error) {
|
|
p, err := sanctionsPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]sanctionRecord{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var out map[string]sanctionRecord
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", p, err)
|
|
}
|
|
if out == nil {
|
|
out = map[string]sanctionRecord{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// saveSanctionsLocked writes the whole registry. Caller holds sanctionMu.
|
|
func saveSanctionsLocked(m map[string]sanctionRecord) error {
|
|
p, err := sanctionsPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(p, b)
|
|
}
|
|
|
|
// sanctionRecords is the registry as a list, newest mark first — the order a
|
|
// person reads "what have I vouched for" in.
|
|
func sanctionRecords() ([]sanctionRecord, error) {
|
|
m, err := loadSanctions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]sanctionRecord, 0, len(m))
|
|
for _, r := range m {
|
|
out = append(out, r)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].At.Equal(out[j].At) {
|
|
return out[i].ID < out[j].ID // a stable page, not a shuffling one
|
|
}
|
|
return out[i].At.After(out[j].At)
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// addSanction records one observation as the user's.
|
|
//
|
|
// IDEMPOTENT, and it keeps the ORIGINAL time: marking something already marked
|
|
// returns the existing record untouched. The first mark is the audit fact; a
|
|
// second click is not a new decision and must not overwrite when it was made.
|
|
//
|
|
// IT REFUSES AN OBSERVATION WITH NO KERNEL PATH, with an error the caller shows
|
|
// the user. This is the FIRST of the three gates on that rule — refuse the
|
|
// write, refuse the match, and a v2 fingerprint domain a v1 key cannot reach —
|
|
// and it is the one that keeps the registry itself clean: a record with no Exe
|
|
// is a record nothing could ever check, so writing one would only make the
|
|
// registry lie about how much has been vouched for.
|
|
func addSanction(a agentObs, by string) (sanctionRecord, error) {
|
|
id := sanctionIdentityOf(a)
|
|
if !id.usable() {
|
|
return sanctionRecord{}, errors.New("this machine could not read which file " +
|
|
"that process is running, so there is nothing here that can be marked as " +
|
|
"yours. A command line is a name a program chooses for itself")
|
|
}
|
|
sanctionMu.Lock()
|
|
defer sanctionMu.Unlock()
|
|
m, err := loadSanctions()
|
|
if err != nil {
|
|
return sanctionRecord{}, err
|
|
}
|
|
fp := id.fingerprint()
|
|
if existing, ok := m[fp]; ok {
|
|
return existing, nil
|
|
}
|
|
rec := sanctionRecord{
|
|
ID: fp,
|
|
sanctionIdentity: id,
|
|
Cmdline: a.Cmdline,
|
|
At: sanctionNow().UTC(),
|
|
By: by,
|
|
ByUser: currentOSUser(),
|
|
}
|
|
m[fp] = rec
|
|
if err := saveSanctionsLocked(m); err != nil {
|
|
return sanctionRecord{}, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// dropSanction removes one mark by id and says whether it was there.
|
|
//
|
|
// IT TAKES AN ID AND NOTHING ELSE, so a mark can be withdrawn for a program that
|
|
// is NO LONGER RUNNING. Requiring a live observation to un-mark would leave every
|
|
// record for a program the user has since uninstalled permanently on the machine
|
|
// with no way to reach it — which is the accumulation this page exists to prevent.
|
|
func dropSanction(id string) (bool, error) {
|
|
sanctionMu.Lock()
|
|
defer sanctionMu.Unlock()
|
|
m, err := loadSanctions()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, ok := m[id]; !ok {
|
|
return false, nil
|
|
}
|
|
delete(m, id)
|
|
return true, saveSanctionsLocked(m)
|
|
}
|
|
|
|
// currentOSUser is the account this program is running as, or "" when it cannot
|
|
// be read. Never guessed: an empty by_user is an honest "this machine could not
|
|
// say", and a made-up one would be worse than no field at all in a record whose
|
|
// job is to be auditable.
|
|
func currentOSUser() string {
|
|
u, err := user.Current()
|
|
if err != nil || u == nil {
|
|
return ""
|
|
}
|
|
return u.Username
|
|
}
|
|
|
|
// ── the seam ────────────────────────────────────────────────────────────────
|
|
|
|
// agentSanction reports whether this observation is one the user has
|
|
// marked as theirs, and why.
|
|
//
|
|
// THE CONTRACT, because a policy engine is being built on this:
|
|
//
|
|
// - `why` is ALWAYS one of the sanctionWhy* constants above. Compare it;
|
|
// never parse it. It is populated for a false verdict too — "nothing has
|
|
// vouched for this" is the answer the whole product claim is about, and a
|
|
// caller that has to infer it from an empty string will infer wrong.
|
|
// - BOTH mechanisms are accounted for, and a process carrying both comes back
|
|
// as sanctionWhyBoth rather than losing one of them.
|
|
// - The host tag is reported as PRESENT AND WELL-SHAPED, which is all this box
|
|
// can establish (see the header). A caller that needs to know a tag is live
|
|
// must ask the host.
|
|
// - This function creates nothing, writes nothing, and terminates nothing.
|
|
//
|
|
// AN OBSERVATION WITH NO KERNEL PATH (a.Exe == "") CAN CARRY A HOST TAG BUT CAN
|
|
// NEVER CARRY A LOCAL MARK, and the order below says so: the tag is an
|
|
// environment variable the host set at exec, which has nothing to do with
|
|
// whether this box can read the process's executable, so it is answered first
|
|
// and unchanged. Only the LOCAL half depends on the path.
|
|
//
|
|
// PRECEDENCE for the unsanctioned answers, stated because it is a choice:
|
|
// sanctionWhyNoExe outranks sanctionWhyBadTag. A malformed tag is a fact about
|
|
// a value this machine READ; no kernel path is a statement that it could not
|
|
// identify the program at all, and the second is the more honest headline when
|
|
// both are true. It also carries the more useful instruction for a caller —
|
|
// "do not offer to mark this, and do not read 'not marked' as 'not wanted'".
|
|
//
|
|
// WHEN THE REGISTRY CANNOT BE READ it returns (true, sanctionWhyUnreadable), and
|
|
// that is the one case where a true verdict does not mean anything vouched for
|
|
// anything. The direction is chosen from the asymmetry the whole design rests
|
|
// on: a missed rogue agent is a bad day, while a false positive that kills the
|
|
// user's own long-running job is the thing they tell other people about. A
|
|
// corrupt registry that read as "nothing is
|
|
// sanctioned" would hand a policy engine a machine where every process looks
|
|
// unvouched-for, which is the one input that turns this whole feature into the
|
|
// failure it was built to prevent.
|
|
//
|
|
// The cost of that choice is that an unreadable registry hides everything, and
|
|
// it is worth naming rather than hiding: the file is 0600 in the directory that
|
|
// already holds this machine's pairing credentials and TOTP seeds, so anything
|
|
// able to corrupt it can already do considerably worse than conceal a process.
|
|
// A caller that RENDERS this verdict must test for sanctionWhyUnreadable and say
|
|
// so, rather than drawing 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.
|
|
func agentSanction(a agentObs) (sanctioned bool, why string) {
|
|
tag := a.DelegationToken
|
|
hostTagged := tag != "" && looksLikeHostTag(tag)
|
|
|
|
id := sanctionIdentityOf(a)
|
|
if !id.usable() {
|
|
// No local mark can exist for this — addSanction refuses to write one —
|
|
// so the registry has no answer to give and is not even read. That also
|
|
// means an unreadable registry changes nothing here: the verdict would
|
|
// be the same with a perfect one.
|
|
if hostTagged {
|
|
return true, sanctionWhyHostTag
|
|
}
|
|
return false, sanctionWhyNoExe
|
|
}
|
|
|
|
m, err := loadSanctions()
|
|
if err != nil {
|
|
return true, sanctionWhyUnreadable
|
|
}
|
|
// The SECOND gate on a v1 record (the first is the v2 fingerprint domain):
|
|
// a record that cannot say which file it vouched for does not vouch, even
|
|
// if its key were somehow reached. Defence in depth against a hand-edited
|
|
// registry, and the property is checked directly rather than inferred.
|
|
rec, found := m[id.fingerprint()]
|
|
marked := found && !rec.stale()
|
|
|
|
switch {
|
|
case hostTagged && marked:
|
|
return true, sanctionWhyBoth
|
|
case hostTagged:
|
|
return true, sanctionWhyHostTag
|
|
case marked:
|
|
return true, sanctionWhyLocal
|
|
case tag != "":
|
|
// A tag is set and it is not shaped like one the host mints. Reported
|
|
// as its own reason because it is the one unsanctioned state with
|
|
// something a person can act on in it.
|
|
return false, sanctionWhyBadTag
|
|
}
|
|
return false, sanctionWhyNone
|
|
}
|