333 lines
13 KiB
Go
333 lines
13 KiB
Go
package main
|
|
|
|
// delta_blind_class_test.go — a class one pass could not see.
|
|
//
|
|
// A failed process walk already leaves every clock and the machine store alone
|
|
// (the walkErr guard in deltaPass). The listener and identity classes can go
|
|
// blind one at a time: `ss` exits non-zero or reads short, an account file
|
|
// cannot be opened. Every consumer that reads "absent from this pass" as a
|
|
// fact about the machine must then leave that class alone too, or a flaky
|
|
// reader turns into findings that close and reopen, and absences that never
|
|
// mature. These drive the real deltaPass and the real store, with only the two
|
|
// class readers replaced.
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// blindableListeners installs a listener reader that sees `socks` when
|
|
// *blind is false and reports a failed read when it is true.
|
|
func blindableListeners(t *testing.T, blind *bool, socks []listenSocket) {
|
|
t.Helper()
|
|
oI, oL := deltaIdentityReader, deltaListenerReader
|
|
deltaIdentityReader = func() identityScanReport { return identityScanReport{Supported: true} }
|
|
deltaListenerReader = func() ([]listenSocket, bool, string) {
|
|
if *blind {
|
|
return nil, false, "ss exited 1"
|
|
}
|
|
return socks, true, ""
|
|
}
|
|
t.Cleanup(func() { deltaIdentityReader, deltaListenerReader = oI, oL })
|
|
}
|
|
|
|
func saveFourClassAccounting(t *testing.T, entries []baselineEntry) {
|
|
t.Helper()
|
|
if err := baselineSave(baselineFile{
|
|
Version: baselineVersion,
|
|
TakenAt: stamp(time.Now()),
|
|
Coverage: baselineCoverage(true, true, true, true),
|
|
Entries: entries,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func findingOfKind(r deltaReport, kind string) *deltaEvent {
|
|
for i := range r.Findings {
|
|
if r.Findings[i].Kind == kind {
|
|
return &r.Findings[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A listener finding on a box whose `ss` fails every other pass stays OPEN in
|
|
// the machine store. It closed and reopened on every flap: twelve times in 25
|
|
// passes for a listener on 0.0.0.0:4444.
|
|
func TestABlindListenerPassClosesNoListenerFinding(t *testing.T) {
|
|
isolateConfigDir(t)
|
|
if !processIsRoot() {
|
|
t.Skip("the pass refuses without machine authority, which is its own test")
|
|
}
|
|
blind := false
|
|
blindableListeners(t, &blind, []listenSocket{sock("tcp", "0.0.0.0", 4444, "nc", 1)})
|
|
saveFourClassAccounting(t, nil)
|
|
|
|
now := time.Date(2026, 9, 23, 3, 0, 0, 0, time.UTC)
|
|
var fp string
|
|
for i := 0; i < 6; i++ {
|
|
blind = i%2 == 1
|
|
at := now.Add(time.Duration(i) * time.Minute)
|
|
r := deltaPass(&digestCache{}, at, deltaRoleOperator)
|
|
if blind && (len(r.Skipped) != 1 || r.Skipped[0] != "listener") {
|
|
t.Fatalf("pass %d could not read the socket table and did not say so: skipped=%v", i, r.Skipped)
|
|
}
|
|
// The heartbeat carries it too: the host reads the summary, and zero
|
|
// counts on a blind pass must not reach it as a clean machine.
|
|
if sum := r.summary(); blind && (len(sum.Skipped) != 1 || sum.Skipped[0] != "listener") {
|
|
t.Fatalf("pass %d: the summary the heartbeat carries does not name the skipped class: %v", i, sum.Skipped)
|
|
}
|
|
if i == 0 {
|
|
ev := findingOfKind(r, deltaListenerNew)
|
|
if ev == nil {
|
|
t.Fatalf("the listener on 0.0.0.0:4444 was not a finding: %v", kindsOf(r.Findings))
|
|
}
|
|
fp = ev.FP
|
|
// The pass itself records the finding's class, which is what the
|
|
// prune reads on a later blind pass.
|
|
if st := deltaStateLoad(r.BaselineDigest); st.SeenKind[fp] != "listener" {
|
|
t.Fatalf("the pass did not record the finding's class: seen_kind=%q", st.SeenKind[fp])
|
|
}
|
|
}
|
|
if _, err := storeRecordPass(r, at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var transitions []string
|
|
for _, l := range storeReadJournal(t, now) {
|
|
if l.Event != nil && l.Event.FP == fp {
|
|
transitions = append(transitions, l.Transition)
|
|
}
|
|
}
|
|
if len(transitions) != 1 || transitions[0] != "opened" {
|
|
t.Fatalf("a finding in a class the pass could not see must stay open; journal for it: %v", transitions)
|
|
}
|
|
|
|
// And a pass that DID look and found it gone still closes it.
|
|
blind = false
|
|
deltaListenerReader = func() ([]listenSocket, bool, string) { return nil, true, "" }
|
|
at := now.Add(10 * time.Minute)
|
|
if _, err := storeRecordPass(deltaPass(&digestCache{}, at, deltaRoleOperator), at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
transitions = nil
|
|
for _, l := range storeReadJournal(t, now) {
|
|
if l.Event != nil && l.Event.FP == fp {
|
|
transitions = append(transitions, l.Transition)
|
|
}
|
|
}
|
|
if len(transitions) != 2 || transitions[1] != "closed" {
|
|
t.Fatalf("a pass that looked and found it gone must close it: %v", transitions)
|
|
}
|
|
}
|
|
|
|
// A listener that disappears is reported once its absence has lasted the grace
|
|
// period, even when every other pass cannot read the socket table. A blind
|
|
// pass reset the clock, so it was never reported at all.
|
|
func TestABlindPassDoesNotResetTheGoneClock(t *testing.T) {
|
|
isolateConfigDir(t)
|
|
if !processIsRoot() {
|
|
t.Skip("the pass refuses without machine authority, which is its own test")
|
|
}
|
|
ssh, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)})
|
|
blind := false
|
|
blindableListeners(t, &blind, nil) // :22 is gone whenever the table is read
|
|
saveFourClassAccounting(t, ssh)
|
|
|
|
now := time.Date(2026, 9, 23, 3, 0, 0, 0, time.UTC)
|
|
reportedAt := -1
|
|
for i := 0; i < 25 && reportedAt < 0; i++ {
|
|
blind = i%2 == 1
|
|
r := deltaPass(&digestCache{}, now.Add(time.Duration(i)*time.Minute), deltaRoleOperator)
|
|
if findingOfKind(r, deltaListenerGone) != nil {
|
|
reportedAt = i
|
|
}
|
|
}
|
|
if reportedAt < 0 {
|
|
t.Fatal("listener_gone for :22 was never reported across 25 passes with every other pass blind")
|
|
}
|
|
if reportedAt < int(deltaGoneGrace/time.Minute) {
|
|
t.Fatalf("reported at pass %d, before the %v grace had passed", reportedAt, deltaGoneGrace)
|
|
}
|
|
}
|
|
|
|
// The class check runs BEFORE the comparison. A socket table that can never be
|
|
// read must not turn the accounting's listeners into listener_gone findings,
|
|
// however long it stays unreadable. Run after the comparison instead, the
|
|
// events were already made and matured on the grace clock.
|
|
func TestAClassBlindOnEveryPassProducesNoGoneFindings(t *testing.T) {
|
|
isolateConfigDir(t)
|
|
if !processIsRoot() {
|
|
t.Skip("the pass refuses without machine authority, which is its own test")
|
|
}
|
|
ssh, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)})
|
|
blind := true
|
|
blindableListeners(t, &blind, nil)
|
|
saveFourClassAccounting(t, ssh)
|
|
|
|
now := time.Date(2026, 9, 23, 3, 0, 0, 0, time.UTC)
|
|
for _, after := range []time.Duration{0, deltaGoneGrace + time.Minute, 2*deltaGoneGrace + time.Minute} {
|
|
r := deltaPass(&digestCache{}, now.Add(after), deltaRoleOperator)
|
|
for _, ev := range append(append([]deltaEvent{}, r.Findings...), r.Filtered...) {
|
|
if ev.Entry.Kind == "listener" {
|
|
t.Fatalf("a class nobody could read produced %s at +%v", ev.Kind, after)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The prune forgets a first-seen stamp a week after its last sighting, unless
|
|
// its class was not looked at on this pass.
|
|
func TestThePruneKeepsAClassItCouldNotSee(t *testing.T) {
|
|
now := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
|
|
old := stamp(now.Add(-deltaStateForget - time.Hour))
|
|
mk := func() *deltaState {
|
|
return &deltaState{
|
|
FirstSeen: map[string]string{"lst": old, "prc": old},
|
|
LastSeen: map[string]string{"lst": old, "prc": old},
|
|
SeenKind: map[string]string{"lst": "listener", "prc": "process"},
|
|
}
|
|
}
|
|
s := mk()
|
|
s.setBlind([]string{"listener"}, nil)
|
|
s.prune(now)
|
|
if _, kept := s.FirstSeen["lst"]; !kept {
|
|
t.Fatal("a listener's first-seen was forgotten on a pass that could not read listeners")
|
|
}
|
|
if _, kept := s.FirstSeen["prc"]; kept {
|
|
t.Fatal("a process the pass could see was not pruned after a week")
|
|
}
|
|
s = mk()
|
|
s.setBlind(nil, nil)
|
|
s.prune(now)
|
|
if _, kept := s.FirstSeen["lst"]; kept {
|
|
t.Fatal("a listener seen by nobody for a week was kept on a pass that could see listeners")
|
|
}
|
|
}
|
|
|
|
// A listener hold is not settled as short-lived by a pass that could not read
|
|
// the socket table. A process hold still is.
|
|
func TestAListenerHoldIsNotSettledByABlindPass(t *testing.T) {
|
|
lst := deltaListenerHoldPrefix + "tcp\x00x"
|
|
s := &deltaState{Held: map[string]string{lst: stamp(time.Now()), "prc": stamp(time.Now())}}
|
|
s.setBlind([]string{"listener"}, nil)
|
|
if n := s.settleHeld(map[string]bool{}); n != 1 {
|
|
t.Fatalf("settled %d, want only the process hold", n)
|
|
}
|
|
if _, kept := s.Held[lst]; !kept {
|
|
t.Fatal("a listener hold was counted short-lived on a pass that could not see listeners")
|
|
}
|
|
s.setBlind(nil, nil)
|
|
if n := s.settleHeld(map[string]bool{}); n != 1 {
|
|
t.Fatalf("a pass that could see listeners must settle the hold: settled %d", n)
|
|
}
|
|
}
|
|
|
|
// The class maps are bookkeeping for what is still open or still remembered;
|
|
// neither may outlive the entry it describes.
|
|
func TestTheClassMapsDoNotOutliveTheirEntries(t *testing.T) {
|
|
now := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
|
|
old := stamp(now.Add(-deltaStateForget - time.Hour))
|
|
s := &deltaState{
|
|
FirstSeen: map[string]string{"prc": old},
|
|
LastSeen: map[string]string{"prc": old},
|
|
SeenKind: map[string]string{"prc": "process"},
|
|
}
|
|
s.setBlind(nil, nil)
|
|
s.prune(now)
|
|
if _, left := s.SeenKind["prc"]; left {
|
|
t.Fatal("the prune forgot a fingerprint but kept its class")
|
|
}
|
|
|
|
isolateConfigDir(t)
|
|
at := time.Date(2026, 9, 23, 3, 0, 0, 0, time.UTC)
|
|
ev := storeFinding("aaa", deltaHigh)
|
|
if _, err := storeRecordPass(storeReport("base-a", ev), at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if k := storeStateLoadRaw().OpenKind["aaa"]; k != "process" {
|
|
t.Fatalf("an open finding's class was not recorded: %q", k)
|
|
}
|
|
if _, err := storeRecordPass(storeReport("base-a"), at.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st := storeStateLoadRaw()
|
|
if _, open := st.Open["aaa"]; open {
|
|
t.Fatal("a pass that looked and did not find it left it open")
|
|
}
|
|
if _, left := st.OpenKind["aaa"]; left {
|
|
t.Fatal("a closed finding's class stayed in the open-class map")
|
|
}
|
|
}
|
|
|
|
// An accounting taken while a reader failed on a platform that CAN read the
|
|
// class says `unread`, not `unsupported`: the second means a platform that
|
|
// never compares the class, and whoever reads the line (the host, a person)
|
|
// would be told a retake changes nothing when a retake is exactly the fix.
|
|
func TestAnAccountingTakenWithAFailedReaderSaysUnread(t *testing.T) {
|
|
isolateConfigDir(t)
|
|
if !processIsRoot() {
|
|
t.Skip("the accounting refuses without machine authority, which is its own test")
|
|
}
|
|
oI, oL := deltaIdentityReader, deltaListenerReader
|
|
t.Cleanup(func() { deltaIdentityReader, deltaListenerReader = oI, oL })
|
|
deltaListenerReader = func() ([]listenSocket, bool, string) { return nil, false, "ss exited 1" }
|
|
deltaIdentityReader = func() identityScanReport {
|
|
return identityScanReport{Reason: "the account database could not be read"}
|
|
}
|
|
f, _, _, _, err := collectBaseline()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[bool]string{true: "unread", false: "unsupported"}
|
|
for class, readable := range map[string]bool{"listeners": listenersReadableHere, "identities": identitiesReadableHere} {
|
|
if !strings.Contains(f.Coverage, class+"="+want[readable]) {
|
|
t.Fatalf("%s: a failed reader on a platform that %s read it wrote %q",
|
|
class, map[bool]string{true: "can", false: "cannot"}[readable], f.Coverage)
|
|
}
|
|
if baselineHasClass(f.Coverage, class) {
|
|
t.Fatalf("%s is counted as held by an accounting that could not read it: %q", class, f.Coverage)
|
|
}
|
|
}
|
|
|
|
// The same readers working: the classes are held.
|
|
deltaListenerReader = func() ([]listenSocket, bool, string) { return nil, true, "" }
|
|
deltaIdentityReader = func() identityScanReport { return identityScanReport{Supported: true} }
|
|
f, _, _, _, err = collectBaseline()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if listenersReadableHere && (!baselineHasClass(f.Coverage, "listeners") || strings.Contains(f.Coverage, "unread")) {
|
|
t.Fatalf("working readers must hold the classes: %q", f.Coverage)
|
|
}
|
|
}
|
|
|
|
// The noise filter's "started by something the accounting holds" evidence comes
|
|
// from accounted PROCESSES only. An accounted listener held by a replaced
|
|
// program must not vouch for that program's children.
|
|
func TestAnAccountedListenerIsNotABaselineParent(t *testing.T) {
|
|
sshd := baselineEntry{FP: "sshd-real", Kind: "process", User: "root", Unit: "ssh.service",
|
|
ArgvShape: "sshd -D", ExeDigest: "real"}
|
|
port, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)})
|
|
trojan := sshd
|
|
trojan.FP, trojan.ExeDigest = "sshd-trojan", "trojan"
|
|
holder, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 4242)})
|
|
cron := baselineEntry{FP: "cron", Kind: "process", User: "root", Unit: "cron.service",
|
|
ArgvShape: "cron -f", ExeDigest: "c"}
|
|
base := &baselineFile{Entries: append([]baselineEntry{sshd, cron}, port...)}
|
|
live := []deltaLiveEntry{
|
|
{Entry: trojan, PIDs: []int{4242}},
|
|
{Entry: holder[0], PIDs: []int{4242}},
|
|
{Entry: cron, PIDs: []int{77}},
|
|
}
|
|
n := deltaNoiseFor(deltaCompare(base.Entries, live), base, live)
|
|
if n.BaselinePIDs[4242] {
|
|
t.Fatal("an accounted listener made the replaced program holding it a baseline parent")
|
|
}
|
|
if !n.BaselinePIDs[77] {
|
|
t.Fatal("an accounted process running now must still be a baseline parent")
|
|
}
|
|
}
|