bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
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 (review of #100). 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 (review of #100).
|
||||
_ = 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 (review of #100).
|
||||
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 (review of #100: stopping a replaced sshd would otherwise have
|
||||
// taken 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 + "."
|
||||
}
|
||||
Reference in New Issue
Block a user