bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+439
View File
@@ -0,0 +1,439 @@
package main
// deltanoise.go — the deterministic noise filter, on the box, before
// anything travels.
//
// A detection plane is judged by its second week, not its first. Every rule
// here exists because something ordinary would otherwise be reported as a
// finding, and a list with ordinary things in it is a list nobody opens.
//
// Two dispositions, and the difference between them is the whole design:
//
// FILTERED taken out of the findings entirely, with the reason kept on the
// event so the operator can read what was removed. Only for things
// this bastion itself caused, or that were never really there.
// DEMOTED still a finding, at tier `known`, which moves it down the level table
// (a known-tier proc_new is a notice, not a warn). For things that
// have an ordinary explanation but are still true.
//
// A rule earns a disposition by how much it EXPLAINS, and one of the four here
// explains almost nothing once measured, so it sets neither — see rule 4.
//
// The default when a rule CANNOT BE EVALUATED on this platform is to report.
// A filter that fails closed would turn "this build cannot read process ages"
// into silence, and silence is indistinguishable from a quiet machine.
import (
"os"
"sort"
"strconv"
"strings"
"time"
)
// deltaShortLived is the short-lived threshold: a process younger than this that holds no
// listening socket and talks to no model is counted rather than reported. The
// design proposes 5 seconds and it is the right order of magnitude — it is the
// lifetime of a shell command, not of anything installed.
const deltaShortLived = 5 * time.Second
// deltaHoldKey is what the ceiling counts against. For a process it is the
// fingerprint, which is stable across restarts by design — that is what lets a
// respawner accumulate and escape.
//
// A LISTENER'S FINGERPRINT CONTAINS ITS BIND POINT, so a holder that takes a
// FRESH ephemeral port each time never accumulates anything: measured, twelve
// passes reopening the same port produced two findings and twelve passes taking
// a new one produced ZERO, indefinitely. The ceiling
// existed and could not fire. Keying a listener on its protocol and HOLDER
// instead makes the pattern — "this program keeps opening ephemeral loopback
// ports" — the thing that accumulates, which is the pattern worth reporting.
//
// The bound on being wrong: two unrelated programs with the same name would
// share a key and escape together. Both would then be reported, which is the
// safe direction.
// deltaListenerHoldPrefix begins every listener hold key, which is how the hold
// list tells a listener hold from a process one without storing the class.
const deltaListenerHoldPrefix = "listener-holder\x00"
func deltaHoldKey(ev deltaEvent) string {
if ev.Kind != deltaListenerNew {
return ev.FP
}
proto, _ := listenerProtoPort(ev.Entry.Unit)
return deltaListenerHoldPrefix + proto + "\x00" + ev.Entry.ArgvShape
}
// deltaHoldMax bounds how long one fingerprint may be held back by the rule
// above. Five minutes is five passes at the heartbeat's cadence — long enough
// that a service restarting once or twice is still filtered, short enough that
// something respawning continuously is a finding while it is still happening.
const deltaHoldMax = 5 * time.Minute
// The filter reasons, as they appear on the event.
const (
deltaFilterBastion = "started by this bastion"
deltaFilterShortLived = "gone before it was old enough to report"
)
// deltaNoise is everything the filter needs to know about the live machine that
// the two lists being compared do not carry.
type deltaNoise struct {
// Self is this process. Everything below it in the tree is ours.
Self int
Parents map[int]int
// BaselinePIDs are the pids of accounted PROCESS entries, so a process
// parented by one of them is a child of something normal. Never a
// listener's holder: a listener's identity is only its bind point and the
// holder's chosen name (deltaNoiseFor).
BaselinePIDs map[int]bool
// Age answers how long a pid has been running. The bool is false where the
// platform cannot say, and that is not the same as zero.
Age func(pid int) (time.Duration, bool)
// Listening is the set of pids holding a listening socket, and Sockets says
// whether the set could be read at all. An unreadable set means the
// short-lived rule cannot check its own exemption, so it does not fire.
Listening map[int]bool
Sockets bool
// Packaged answers, for the executables asked about, which ones the
// platform's package database owns AND still matches. Nil where there is no
// package database to ask.
Packaged func(paths []string) map[string]string
}
// deltaFilter applies the noise rules to one pass of events. It returns the findings and
// the events it took out, both in the order they arrived.
func deltaFilter(events []deltaEvent, n deltaNoise, st *deltaState, now time.Time) (findings, filtered []deltaEvent) {
packaged := n.lookupPackaged(events)
seenHeld := map[string]bool{}
for _, ev := range events {
switch ev.Kind {
case deltaProcGone, deltaSchedGone, deltaBinaryChanged:
// An absence has no process to explain away, and the bastion's own
// binary is never noise.
findings = append(findings, ev)
continue
}
// 1. Anything this bastion started. A harness run, a lab job, a session
// PTY, the local agent: the bastion knows it launched them because
// they are below it in the process tree, and a program reporting its
// own work as an intrusion is the fastest way to be ignored.
//
// IT DOES NOT NAME WHICH OP STARTED IT. The design tags a bastion-started
// process with its op id; this says only "ours", read off the process
// tree. The tree is the more robust signal — it needs no bookkeeping
// and cannot go stale — but a filtered row that cannot say whether it
// was a harness run, a lab job or a session shell is one an operator
// has to take on trust. The op id needs a registry the bastion does
// not keep; it belongs with the findings store the host can read.
if len(ev.PIDs) > 0 && n.allOurs(ev.PIDs) {
ev.Filtered = deltaFilterBastion
ev.Evidence = append(ev.Evidence, evidence{Signal: "bastion-child", Tier: tierVerified,
Value: "every process behind this entry is below this bastion in the tree " +
"(which op started it is not recorded)"})
filtered = append(filtered, ev)
continue
}
// 2. Short-lived. Held rather than dropped: if it is still here next
// pass it was not short-lived after all and becomes a finding then.
//
// AND THE HOLD HAS A CEILING, because without one a respawner is
// held for ever. Something that keeps starting fresh copies of
// itself — a crash-looping unit, a beacon relaunched every few
// seconds — is young at EVERY pass, so it was re-held at every pass
// and, being seen each time, never settled into the short-lived
// count either: ten passes, no finding and a storm counter reading
// zero. A thing that has been "about to go
// away" for deltaHoldMax is not short-lived work, it is a pattern,
// and the pattern is the finding.
if hold, why := n.shortLived(ev); hold {
if since, held := st.heldSince(deltaHoldKey(ev)); held && now.Sub(since) >= deltaHoldMax {
ev.Evidence = append(ev.Evidence, evidence{Signal: "respawning", Tier: tierVerified,
Value: "seen as a new short-lived process at every pass for " +
now.Sub(since).Round(time.Minute).String()})
st.release(deltaHoldKey(ev))
findings = append(findings, ev)
continue
}
st.hold(deltaHoldKey(ev), now)
seenHeld[deltaHoldKey(ev)] = true
ev.Filtered = why
ev.Evidence = append(ev.Evidence, evidence{Signal: "short-lived", Tier: tierVerified, Value: why})
filtered = append(filtered, ev)
continue
}
// RELEASED UNDER THE KEY IT WAS HELD UNDER. This said ev.FP while the
// hold above it used deltaHoldKey, and for a process those are the same
// string so nothing showed — but a listener's differ, so its hold entry
// was never released by name and was swept by settleHeld instead, which
// counts it as something that came and went. Measured: a listener still
// open and being reported as a finding in the same breath also bumped
// the storm counter, on a running total that no report resets and that
// rides the heartbeat as short_lived. The noise filter's
// storm signal was measuring something other than what it says.
st.release(deltaHoldKey(ev))
// 3. Package-managed. The distribution's own database says this is the
// file that package ships, byte for byte. Demoted, never removed:
// /usr/bin/python3 is packaged and is also how half the agents on
// earth are started, which is exactly why this sets a tier instead
// of dropping the row.
if pkg, ok := packaged[ev.Entry.Exe]; ok {
ev.Tier = tierKnown
ev.Evidence = append(ev.Evidence, evidence{Signal: "package-managed", Tier: tierVerified,
Value: "shipped by " + pkg + " and unmodified"})
}
// 4. Child of a baseline entry — RECORDED AND NEVER ACTED ON, because
// the version of this rule that can be built today is worse than no
// rule, and that is a measurement rather than an opinion.
//
// The design folds a new process into its parent when the parent is a
// baseline entry AND the child's exe is in "that entry's declared
// children set (built during baseline from what the service actually
// spawned over the scan window)". There is no such set: the
// accounting is one instantaneous scan (baseline.go collectBaseline),
// with no window and no record of what anything spawned.
//
// Dropping the children-set half leaves "the parent was running at
// baseline time", which on a real machine is nearly everything.
// MEASURED on a live machine: a brand-new root-owned
// binary at /tmp/brandnewthing and a swapped binary at a known post
// were BOTH lowered to `notice` — batched into a daily summary,
// waking nobody — because each had been started from a shell that
// existed when the accounting was taken. That is the filter hiding
// the two findings this plane exists to produce.
//
// So the fact travels as evidence, at the tier that "annotates and
// never promotes" (origin.go:44-45), and the level is left alone.
// The fold comes back with the children set, which belongs to a
// baseline taken over a window rather than in an instant.
if len(ev.PIDs) > 0 && n.allChildrenOfBaseline(ev.PIDs) {
ev.Evidence = append(ev.Evidence, evidence{Signal: "child-of-baseline", Tier: tierAmbiguous,
Value: "started by something the accounting already holds — context, not an explanation"})
}
findings = append(findings, ev)
}
// Everything still held that did not turn up this pass came and went
// between two scans. That is the count the design wants riding the beat.
st.settleHeld(seenHeld)
return findings, filtered
}
// allOurs reports whether every pid behind an entry descends from this bastion.
// EVERY, not any: a binary the bastion happens to run and the user also runs
// independently is still the user's process, and dropping the entry because one
// of its pids is ours would hide it.
func (n deltaNoise) allOurs(pids []int) bool {
if n.Self <= 0 {
return false
}
for _, pid := range pids {
if !n.descendsFromSelf(pid) {
return false
}
}
return true
}
// descendsFromSelf walks up the parent map. Bounded like the origin walk
// (origin.go maxOriginHops) so a corrupt map terminates instead of looping.
func (n deltaNoise) descendsFromSelf(pid int) bool {
cur := pid
for hop := 0; hop < maxOriginHops; hop++ {
if cur == n.Self {
return true
}
next, ok := n.Parents[cur]
if !ok || next <= 0 || next == cur {
return false
}
cur = next
}
return false
}
func (n deltaNoise) allChildrenOfBaseline(pids []int) bool {
if len(n.BaselinePIDs) == 0 {
return false
}
for _, pid := range pids {
parent, ok := n.Parents[pid]
if !ok || !n.BaselinePIDs[parent] {
return false
}
}
return true
}
// shortLived decides whether to hold an entry back for one pass.
//
// The two exemptions are the design's and they are not optional: something that opened
// a listening socket or reached a model endpoint in its first five seconds is
// the most interesting thing on the box, not the least. Where the socket table
// could not be read, the rule does not fire at all — an exemption that cannot
// be checked is not an exemption.
func (n deltaNoise) shortLived(ev deltaEvent) (bool, string) {
// A LOOPBACK LISTENER ON AN EPHEMERAL PORT IS THE SAME EVENT TWICE, and on
// the machine class this plane actually runs on — a person's own computer —
// it is most of what the listener class would say.
//
// Measured while adding the class: a `go test` run on this box produced two
// `listener_new` warns on tcp/127.0.0.1:41579 and :44945, held by a test
// binary that had already been reported as a proc_new. The kernel's
// ephemeral range here is 32768-60999 (/proc/sys/net/ipv4/ip_local_port_range),
// which is where a language server, a browser, a debugger and every test
// harness bind. Reporting each one is how a plane gets ignored.
//
// NARROW ON PURPOSE, and each condition is load-bearing:
// loopback nothing off this machine can reach it
// ephemeral range the kernel's own "this is not a service" range
// young holder it has not survived even one pass
// A backdoor on 4444, anything on a routable address, and anything a
// long-running process holds are all untouched. And this HOLDS BACK ONE
// PASS, exactly like the first-miss rule for an absence — it never
// suppresses: a port still there on the next pass is reported.
if ev.Kind == deltaListenerNew {
if why, held := n.ephemeralLoopbackListener(ev); held {
return true, why
}
return false, ""
}
if ev.Kind != deltaProcNew && ev.Kind != deltaProcChanged {
return false, ""
}
if len(ev.Entry.Endpoints) > 0 || n.Age == nil || len(ev.PIDs) == 0 {
return false, ""
}
if !n.Sockets {
return false, ""
}
oldest := time.Duration(0)
for _, pid := range ev.PIDs {
if n.Listening[pid] {
return false, ""
}
age, ok := n.Age(pid)
if !ok {
// One unreadable age is enough to report: the platform cannot tell
// us this is young, so it is not treated as young.
return false, ""
}
if age > oldest {
oldest = age
}
}
if oldest >= deltaShortLived {
return false, ""
}
return true, deltaFilterShortLived
}
// lookupPackaged asks the platform's package database about every executable in
// this pass's events, in ONE query. Doing it per event would read the whole
// package corpus once per finding; doing it eagerly for every process on the
// box would read it on every pass, findings or not. This reads it only when
// there is something to weigh, which in steady state is never.
func (n deltaNoise) lookupPackaged(events []deltaEvent) map[string]string {
if n.Packaged == nil {
return nil
}
seen := map[string]bool{}
paths := []string{}
for _, ev := range events {
p := strings.TrimSpace(ev.Entry.Exe)
if p == "" || seen[p] || ev.Entry.ExeDigest == "" {
continue
}
seen[p] = true
paths = append(paths, p)
}
if len(paths) == 0 {
return nil
}
sort.Strings(paths)
return n.Packaged(paths)
}
// ephemeralLoopbackListener is the listener half of shortLived. See its comment
// for why all three conditions have to hold.
func (n deltaNoise) ephemeralLoopbackListener(ev deltaEvent) (string, bool) {
if listenerIsExposed(ev.Entry) {
return "", false
}
port := listenerPortNum(ev.Entry)
if port < readEphemeralFloor() {
return "", false
}
// ONE PASS, AND THE SENTENCE SAYS ONE PASS. This tested deltaShortLived (5s)
// while a pass is deltaRefreshEvery (60s), so the words an operator reads —
// "younger than one pass" — described something the code did not do, and the
// case the rule was added for was suppressed only when a pass happened to
// land in the holder's first five seconds: about one sighting in twelve.
// A `go test` run is thirty seconds old when the
// pass sees it, so the noise this exists to stop came through anyway.
//
// The process half keeps deltaShortLived because it means something
// different there: "gone before it was old enough to report". Here the
// question is "has it survived a pass yet", and that is the pass interval.
//
// An unreadable age is NOT young — the same call the process half makes, for
// the same reason: a rule that fires where the platform cannot answer is a
// rule that fires on the wrong machines.
if n.Age == nil || len(ev.PIDs) == 0 {
return "", false
}
for _, pid := range ev.PIDs {
age, ok := n.Age(pid)
if !ok || age >= deltaRefreshEvery {
return "", false
}
}
return "a loopback port in the kernel's ephemeral range, held by a process " +
"younger than one pass — held back until it survives one", true
}
// ephemeralPortFloor is the bottom of THIS kernel's ephemeral range, read from
// net.ipv4.ip_local_port_range and not compiled in.
//
// THE DIRECTION OF BEING WRONG IS NOT SYMMETRIC, which is why it is read. A box
// that WIDENS its range (a lower floor than the stock 32768) would, against a
// hardcoded floor, have its genuinely-ephemeral ports reported — noisy, but
// nothing is hidden. A box that NARROWS it (say 49152-60999) is the dangerous
// one: ports 32768-49151 are ordinary service ports on that machine, and a
// hardcoded floor would hold a real listener back on them. Reading the kernel's
// own answer removes the second case entirely.
//
// Falls back to the stock floor when the file cannot be read, and a sysctl this
// box will not show us is not a reason to suppress MORE — the fallback is the
// stock value, which is the conservative end of the range in practice.
// ephemeralRangePath is a var so a test can point it at a fixture. Without that
// the floor test compared the value against THIS box's sysctl — which is the
// stock 32768, identical to the fallback, so hardcoding the floor left the whole
// suite green. A guard that cannot fail on the machine
// it runs on is not a guard. Same remedy as sshdConfigPath.
var ephemeralRangePath = "/proc/sys/net/ipv4/ip_local_port_range"
func readEphemeralFloor() int {
b, err := os.ReadFile(ephemeralRangePath)
if err != nil {
return 32768
}
f := strings.Fields(string(b))
if len(f) == 0 {
return 32768
}
n, err := strconv.Atoi(f[0])
if err != nil || n <= 0 || n > 65535 {
return 32768
}
return n
}
// READ AT THE POINT OF USE, not cached in a package var, and the reason is that
// the var could not be tested. A test pointing ephemeralRangePath at a fixture
// could not change a value computed once at program start, so the whole floor
// could be hardcoded with the suite green — measured once, and then again by
// the sweep that was supposed to prove the fix.
//
// The cost is one small read of a proc file, and only for a listener_new event
// that already passed the loopback test. That is a handful of reads on a pass
// that has findings at all, and zero on a quiet one.