Files
privatae-bastion/client-go/containfinding.go
T
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

478 lines
18 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
)
// Containing a FINDING, not only an agent.
//
// contain.go stops a pid only if the agent plane recognises it — a known agent
// binary, an interpreter running one, or a process talking to a model. The
// delta (delta.go) sees far more than that: every process whose fingerprint is
// not in the machine's accounting. Before this file, a program the delta
// reported as proc_new could be seen from the host and could not be stopped
// from it: its pid was never in the agent map, so contain refused it.
//
// The rule: a pid may be contained only if it is a finding (new-tier or
// changed) and not a baseline entry. The host names the finding by its
// FINGERPRINT, never by a pid. The fingerprint is the entry's identity (exe digest + user + unit +
// masked argv, baseline.go), so this box resolves it to the pids running under
// it NOW, from a fresh walk, at the moment of the order. A pid the host saw an
// hour ago and that now belongs to something else cannot be the target, because
// the host never sent a pid.
//
// Each order carries the host's own ORDER ID and the result echoes it. Results
// stay on the beat for containResultKeep, so without the id a second order for
// the same fingerprint (the program was stopped, restarted, and stopped again)
// would be answered by the first order's result and never delivered.
//
// What may be stopped this way:
// - a PROCESS entry (a scheduled job has no process until it fires; it is
// not killed, it is removed, which is the exec:plan rung, not this one);
// - reported as proc_new or proc_changed by a comparison run at the time of
// the order, against an accounting whose digest still verifies;
// - whose fingerprint is not in the accounting — a baseline entry is never a
// target here, however it is asked for;
// - and not an app the user is running, not this bastion, not anything this
// bastion started, not init.
//
// Nothing here runs by itself. It acts only on a directive from the anchor
// host, which queues it only under a signed management grant listing `contain`.
// Automatic termination is still not built
// (deltaPolicyIsNotSigned).
// The outcomes the box reports back. The host clears its order on any of them
// and writes it to the management audit; only containKilled means the program
// was stopped.
const (
containKilled = "killed" // every pid under the fingerprint was reached
containNotFinding = "not_a_finding" // nothing running under this fingerprint is a finding now
containInBaseline = "in_baseline" // the fingerprint is in the accounting: never a target here
containRefusedApp = "refused_app" // it is an app the user is running
containRefusedOurs = "refused_ours" // this bastion started it
containNoAuthority = "no_authority" // this bastion does not administer the machine
containNoAccount = "no_accounting" // no usable accounting to compare against
containWalkFailed = "walk_failed" // the process walk failed; nothing was compared
containNotAnchor = "refused_anchor" // only the anchor host may direct a kill on this box
containKillFailed = "kill_failed" // it was a finding and the signal did not reach it
)
// containResultKeep is how long a result stays on the beat. Longer than the
// host's delivery TTL for a contain order (one hour) so an
// order the host is still holding always finds its answer.
const containResultKeep = 2 * time.Hour
type containResult struct {
ID int64 `json:"id"`
FP string `json:"fp"`
Outcome string `json:"outcome"`
// Stopped is how many processes the kill reached, children included.
Stopped int `json:"stopped"`
At string `json:"at"`
at time.Time
pairing string
}
var (
containResultsMu sync.Mutex
containResults = map[string]containResult{}
containNow = time.Now
)
// Results are kept PER PAIRING. A box can serve several hosts; a member host
// that orders a kill is refused (only the anchor may direct one), and that
// refusal must not arrive on the anchor's beat as the answer to the anchor's own
// order for the same fingerprint.
func recordContainResult(pairing string, o findingOrder, outcome string, stopped int) {
now := containNow()
containResultsMu.Lock()
defer containResultsMu.Unlock()
containResultsLoadLocked()
containResults[pairing+"\x00"+strconv.FormatInt(o.ID, 10)] = containResult{ID: o.ID, FP: o.FP,
Outcome: outcome, Stopped: stopped, At: now.UTC().Format(time.RFC3339), at: now, pairing: pairing}
containResultsSaveLocked()
}
// ── results survive a restart ────────────────────────────────────────────────
// "Carried out once" held only while this process lived: a bastion that
// restarted after a kill and before the beat carried the answer (a self-update,
// a crash) would receive the order again, run it again, and report
// not_a_finding — or kill a restarted copy. So every result
// is written to the config dir as it is recorded and read back on first use.
var containResultsLoaded bool
type containResultDisk struct {
Pairing string `json:"pairing"`
ID int64 `json:"id"`
FP string `json:"fp"`
Outcome string `json:"outcome"`
Stopped int `json:"stopped"`
At string `json:"at"`
}
func containResultsPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "contain-results.json"), nil
}
func containResultsLoadLocked() {
if containResultsLoaded {
return
}
containResultsLoaded = true
p, err := containResultsPath()
if err != nil {
return
}
raw, err := os.ReadFile(p)
if err != nil {
return
}
var disk []containResultDisk
if json.Unmarshal(raw, &disk) != nil {
return // a corrupt record forgets answers; it never invents one
}
for _, d := range disk {
at, err := time.Parse(time.RFC3339, d.At)
if err != nil {
continue
}
containResults[d.Pairing+"\x00"+strconv.FormatInt(d.ID, 10)] = containResult{ID: d.ID, FP: d.FP,
Outcome: d.Outcome, Stopped: d.Stopped, At: d.At, at: at, pairing: d.Pairing}
}
}
func containResultsSaveLocked() {
p, err := containResultsPath()
if err != nil {
return
}
disk := make([]containResultDisk, 0, len(containResults))
for _, r := range containResults {
disk = append(disk, containResultDisk{Pairing: r.pairing, ID: r.ID, FP: r.FP,
Outcome: r.Outcome, Stopped: r.Stopped, At: r.At})
}
if b, err := json.Marshal(disk); err == nil {
// Atomic: a crash mid-write must not corrupt the record and make the
// box forget answers it gave.
_ = atomicWrite(p, b)
}
}
func unansweredOrders(pairing string, orders []findingOrder) []findingOrder {
containResultsMu.Lock()
defer containResultsMu.Unlock()
containResultsLoadLocked()
out := orders[:0:0]
for _, o := range orders {
if _, done := containResults[pairing+"\x00"+strconv.FormatInt(o.ID, 10)]; !done {
out = append(out, o)
}
}
return out
}
// findingOrder is one entry of the directive: the host's order id and the
// fingerprint it names.
type findingOrder struct {
ID int64 `json:"id"`
FP string `json:"fp"`
}
// containResultsReport is what rides the beat: every result younger than
// containResultKeep, oldest first. Always a list (possibly empty) so the host
// can tell "nothing to report" from a box that predates this key (absent).
func containResultsReport(pairing string) []containResult {
now := containNow()
containResultsMu.Lock()
defer containResultsMu.Unlock()
containResultsLoadLocked()
out := []containResult{}
for key, r := range containResults {
if now.Sub(r.at) > containResultKeep {
delete(containResults, key)
continue
}
if r.pairing == pairing {
out = append(out, r)
}
}
sort.Slice(out, func(i, j int) bool {
if !out[i].at.Equal(out[j].at) {
return out[i].at.Before(out[j].at)
}
return out[i].ID < out[j].ID
})
return out
}
// findingView is what one fresh comparison says about this machine, as the
// guard needs it.
type findingView struct {
// findings: containable fingerprint -> the pids running under it now.
findings map[string][]int
// inBaseline: every fingerprint the accounting holds, so a refusal can say
// why.
inBaseline map[string]bool
// protected: live pids whose OWN entry is in the accounting. The tree kill
// spares them and everything beneath them (killTree's spare prunes), so
// stopping a finding can never take an accounting entry with it.
protected map[int]bool
// identity: each finding pid's identity read straight after the walk. The
// kill checks it before touching the root, so a pid reused between the walk
// and the kill is never signalled.
identity map[int]string
}
// processFindings is the comparison the guard trusts. A var so tests can drive
// the decision without a machine underneath it.
var processFindings = liveProcessFindings
// The machine under liveProcessFindings, as seams, so each refusal it makes is
// tested with the real function rather than by replacing it.
var (
containIsRoot = processIsRoot
containBaselineLoad = baselineLoad
containCollectLive = func() ([]deltaLiveEntry, error) {
// The live set now holds listener and identity entries too. They are
// never a target and never protect a pid: processFindingsFrom reads
// only process entries for both.
live, _, _, _, err := deltaCollectLive(deltaCache.digest)
return live, err
}
// containFilter is the SAME noise filter the delta pass applies, run over
// this comparison with the box's own state (read, never saved): a finding
// here is exactly what the box would report, so the host can only ever
// order what it was shown.
containFilter = func(events []deltaEvent, base *baselineFile, live []deltaLiveEntry) []deltaEvent {
st := deltaStateLoad(base.Digest)
findings, _ := deltaFilter(events, deltaNoiseFor(events, base, live), st, time.Now())
return findings
}
)
// liveProcessFindings runs the comparison fresh. The outcome string is empty
// when the comparison happened; otherwise it is the reason nothing can be
// contained on this pass.
func liveProcessFindings() (findingView, string) {
if !containIsRoot() {
return findingView{}, containNoAuthority
}
base, digestOK, err := containBaselineLoad()
if err != nil || base == nil || base.Version != baselineVersion || !digestOK {
// An accounting that is missing, from other rules, or edited since it
// was taken cannot define what is foreign here — and "not in the
// accounting" is the only licence this path has to kill.
return findingView{}, containNoAccount
}
live, walkErr := containCollectLive()
if walkErr != nil {
return findingView{}, containWalkFailed
}
events := containFilter(deltaCompare(base.Entries, live), base, live)
v := processFindingsFrom(base.Entries, live, events)
v.identity = map[int]string{}
for _, pids := range v.findings {
for _, pid := range pids {
if id, _, ok := procIdentity(pid); ok {
v.identity[pid] = id
}
}
}
return v, ""
}
// processFindingsFrom is the rule, separate from the machine so it can be
// tested against the real comparison. Containable:
// - a PROCESS entry (a listener or scheduler kind is never a kill target);
// - proc_new at the NEW tier; a package-managed binary
// (tier known) is a finding to read, not one this rung may stop;
// - proc_changed for a real change — never `digest_unreadable`, which the
// comparison itself says is not a security finding (delta.go);
// - and whose fingerprint the accounting does not hold (a known program
// that began talking to a model is proc_changed under its OWN fingerprint:
// a finding, and still a baseline entry).
func processFindingsFrom(base []baselineEntry, live []deltaLiveEntry, events []deltaEvent) findingView {
v := findingView{findings: map[string][]int{}, inBaseline: make(map[string]bool, len(base)),
protected: map[int]bool{}}
for _, e := range base {
v.inBaseline[e.FP] = true
}
// ONLY AN ACCOUNTED PROCESS PROTECTS A PID. The live set also holds
// listener entries, and a listener's identity is its bind point and the
// holder's NAME, which the holder chooses: a replaced sshd restarted on :22
// under the name `sshd` matches the accounted listener exactly. Letting that
// protect its pid made the one finding this rung exists for uncontainable.
for _, l := range live {
if l.Entry.Kind == "process" && v.inBaseline[l.Entry.FP] {
for _, pid := range l.PIDs {
v.protected[pid] = true
}
}
}
for _, ev := range events {
switch {
case ev.Kind == deltaProcNew && ev.Tier == tierKnown:
continue
case ev.Kind == deltaProcChanged && ev.Reason == deltaReasonUnreadable:
continue
case ev.Kind != deltaProcNew && ev.Kind != deltaProcChanged:
continue
}
if ev.Entry.Kind != "process" || v.inBaseline[ev.FP] || len(ev.PIDs) == 0 {
continue
}
v.findings[ev.FP] = append(v.findings[ev.FP], ev.PIDs...)
}
return v
}
// maybeContainFindings acts on the heartbeat's "contain_findings" directive:
// a list of {id, fp} orders. Every one gets a result, including the refusals, so
// the host never has to infer from silence whether an order was carried out.
func maybeContainFindings(pairing, dcnDir string, heartbeatResponse []byte) {
var hb struct {
ContainFindings []findingOrder `json:"contain_findings"`
}
if json.Unmarshal(heartbeatResponse, &hb) != nil || len(hb.ContainFindings) == 0 {
return
}
// AN ORDER IS CARRIED OUT ONCE. The host resends a pending order on every
// beat until it reads the result, and a beat can cross the result in
// flight. Running it again would find the program already gone and
// overwrite "killed" with "not_a_finding" — the user would read that their
// order did nothing.
hb.ContainFindings = unansweredOrders(pairing, hb.ContainFindings)
if len(hb.ContainFindings) == 0 {
return
}
// directiveGate (writes.go): anchor-only, the local switch, then on an
// anchored box an approval PER ORDER — `approvals["contain_finding:<id>"]`,
// bound to "finding:<fp>", the same argument the host's contain op takes,
// so the person approves the words they were shown. An order without its
// approval is answered as refused (containNotAnchor carries the reason to
// the host) rather than left unanswered.
approved := hb.ContainFindings[:0]
for _, o := range hb.ContainFindings {
key := "contain_finding:" + strconv.FormatInt(o.ID, 10)
why := directiveGate(pairing, dcnDir, directiveContain, []string{"finding:" + o.FP},
directiveApproval(heartbeatResponse, key))
if why != "" {
fmt.Printf("contain finding %s: %s\n", o.FP, why)
recordDirectiveRefusal("contain", why)
recordContainResult(pairing, o, containNotAnchor, 0)
continue
}
approved = append(approved, o)
}
hb.ContainFindings = approved
if len(hb.ContainFindings) == 0 {
return
}
view, outcome := processFindings()
if outcome != "" {
for _, o := range hb.ContainFindings {
recordContainResult(pairing, o, outcome, 0)
}
return
}
// Built once per directive: the agent map (to refuse apps) and the parent
// map (to refuse what this bastion started).
observed := observedAgentMap()
ours, ancestors, protected := containGuard(observed, view.protected)
for _, o := range hb.ContainFindings {
if o.FP == "" || o.ID <= 0 {
continue
}
fp := o.FP
if view.inBaseline[fp] {
recordContainResult(pairing, o, containInBaseline, 0)
continue
}
pids := view.findings[fp]
if len(pids) == 0 {
recordContainResult(pairing, o, containNotFinding, 0)
continue
}
refused := ""
for _, pid := range pids {
if a, ok := observed[pid]; ok && a.Kind == kindApp {
refused = containRefusedApp
break
}
if ours.descendsFromSelf(pid) || spareFromContain(pid) || ancestors[pid] {
refused = containRefusedOurs
break
}
}
if refused != "" {
recordContainResult(pairing, o, refused, 0)
continue
}
stopped := 0
for _, pid := range pids {
stopped += killTreeExpect(pid, protected, view.identity[pid])
}
if stopped == 0 {
// Zero reached on a fingerprint that WAS a finding a moment ago is
// usually the benign race (it exited between the walk and the
// signal), but it is reported as what it is: nothing was stopped.
recordContainResult(pairing, o, containKillFailed, 0)
continue
}
recordContainResult(pairing, o, containKilled, stopped)
fmt.Printf("contain: stopped finding %s (%d process(es), host-directed)\n", shortDigest(fp), stopped)
recordLocalEvent(eventContain, findingContainSentence(pairing, fp, stopped))
}
}
// containGuard is the tree guard both kill paths use: the SAME guards on every
// process in the tree, not only the ones an order names — an accounting entry
// (accountingPIDs), an app the user is running, anything this bastion started,
// init, the bastion. killTree does not signal a protected process or walk
// beneath it: without that, stopping a replaced sshd would take every ssh
// session with it. It also returns the bastion's ANCESTORS — its launch
// chain — which the finding path refuses by name: if any process in
// a tree were an ancestor of the bastion, the tree's root would be one too, so
// the named check always fires first and a tree-level check could never be
// reached.
func containGuard(observed map[int]agentObs, accountingPIDs map[int]bool) (deltaNoise, map[int]bool, func(int) bool) {
ours := deltaNoise{Self: os.Getpid(), Parents: processTree()}
ancestors := map[int]bool{}
for cur, hops := ours.Parents[ours.Self], 0; cur > 1 && hops < 64; cur, hops = ours.Parents[cur], hops+1 {
ancestors[cur] = true
}
protected := func(pid int) bool {
if spareFromContain(pid) || accountingPIDs[pid] || ours.descendsFromSelf(pid) {
return true
}
a, ok := observed[pid]
return ok && a.Kind == kindApp
}
return ours, ancestors, protected
}
func findingContainSentence(pairing, fp string, stopped int) string {
s := hostPhrase(pairing) + " stopped a program that was not in this machine's accounting (" +
shortDigest(fp) + ")"
switch {
case stopped == 1:
case stopped == 2:
s += ", and 1 other process it had started"
default:
s += fmt.Sprintf(", and %d other processes it had started", stopped-1)
}
return s + "."
}