bastion 0.53.2
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user