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.