package main // Tests for the listener and identity classes. Each one pins a sentence the // plane is supposed to be able to say, or a false finding it must not produce. import ( "strings" "testing" ) func sock(proto, local string, port int, proc string, pid int) listenSocket { return listenSocket{Proto: proto, Local: local, Port: port, Process: proc, PID: pid} } func kindsOf(evs []deltaEvent) []string { out := make([]string, 0, len(evs)) for _, e := range evs { out = append(out, e.Kind) } return out } // liveList is the plural of delta_test.go's liveOf, which takes one entry. func liveList(entries []baselineEntry) []deltaLiveEntry { out := make([]deltaLiveEntry, 0, len(entries)) for _, e := range entries { out = append(out, deltaLiveEntry{Entry: e}) } return out } // ── listeners ─────────────────────────────────────────────────────────────── // THE SENTENCE THE SLOT EXISTS FOR. A different program answering on a port // that was already open is ONE event that says so — not a gone and a new that a // person has to correlate by eye. func TestADifferentProgramOnAKnownPortIsOneChangedNotAGoneAndANew(t *testing.T) { was, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 443, "nginx", 10)}) now, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 443, "evil", 99)}) evs := deltaCompare(was, liveList(now)) if len(evs) != 1 || evs[0].Kind != deltaListenerChanged { t.Fatalf("want one listener_changed, got %v", kindsOf(evs)) } if evs[0].Was == nil || evs[0].Was.ArgvShape != "nginx" { t.Error("the event does not carry what used to hold the port") } if got := deltaLevel(evs[0]); got != deltaHigh { t.Errorf("a service replaced at a known port levelled %q, want high", got) } // And the words name both sides and the port, because that is the finding. var words string for _, e := range evs[0].Evidence { if e.Signal == "listener-holder-changed" { words = e.Value } } for _, want := range []string{"nginx", "evil", "443"} { if !strings.Contains(words, want) { t.Errorf("evidence %q does not name %q", words, want) } } } // A port nothing was on before. The one the class is really for. func TestAPortNothingWasOnBeforeIsListenerNewAndItsLevelFollowsTheAddress(t *testing.T) { was, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 5432, "postgres", 3)}) exposed, _ := baselineFromListeners([]listenSocket{ sock("tcp", "127.0.0.1", 5432, "postgres", 3), sock("tcp", "0.0.0.0", 4444, "nc", 77), }) evs := deltaCompare(was, liveList(exposed)) if len(evs) != 1 || evs[0].Kind != deltaListenerNew { t.Fatalf("want one listener_new, got %v", kindsOf(evs)) } if got := deltaLevel(evs[0]); got != deltaHigh { t.Errorf("a new listener on a routable address levelled %q, want high", got) } // THE SAME PORT ON LOOPBACK IS NOT THE SAME FINDING. A developer running // something locally must not page anyone, and the address is in the slot // precisely so this can be told apart. local, _ := baselineFromListeners([]listenSocket{ sock("tcp", "127.0.0.1", 5432, "postgres", 3), sock("tcp", "127.0.0.1", 4444, "nc", 77), }) evs = deltaCompare(was, liveList(local)) if len(evs) != 1 || evs[0].Kind != deltaListenerNew { t.Fatalf("want one listener_new, got %v", kindsOf(evs)) } if got := deltaLevel(evs[0]); got != deltaWarn { t.Errorf("a new loopback listener levelled %q, want warn", got) } } // THE SAME SERVICE MOVING FROM LOOPBACK TO THE WILDCARD IS THE FINDING, and // the address being in the slot is what makes it visible at all. This is the // judgement baseline_listeners.go's header argues for, so it is pinned. func TestAServiceThatStartsBindingTheWildcardIsReported(t *testing.T) { was, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 9000, "app", 5)}) now, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 9000, "app", 5)}) evs := deltaCompare(was, liveList(now)) var sawNew bool for _, e := range evs { if e.Kind == deltaListenerNew && deltaLevel(e) == deltaHigh { sawNew = true } } if !sawNew { t.Fatalf("a service that opened itself to the network produced %v, with no high listener_new", kindsOf(evs)) } } // Four workers accept()ing one bound port are one listener to a person. func TestOneBindPointWithSeveralWorkersIsOneEntryCarryingEveryPID(t *testing.T) { entries, pids := baselineFromListeners([]listenSocket{ sock("tcp", "0.0.0.0", 80, "nginx", 11), sock("tcp", "0.0.0.0", 80, "nginx", 12), sock("tcp", "0.0.0.0", 80, "nginx", 13), }) if len(entries) != 1 { t.Fatalf("want one folded entry, got %d", len(entries)) } if len(pids[0]) != 3 { t.Errorf("the folded entry carries %v, want all three worker pids", pids[0]) } } // THE COLLISION THE MARKER EXISTS TO STOP. `ss` omits the process column for // sockets this account may not read. Recording that as the empty string would // make every unreadable socket ONE fingerprint, so the second and later ones // would never be reported at all. func TestTwoSocketsWithNoReadableOwnerStayDistinct(t *testing.T) { entries, _ := baselineFromListeners([]listenSocket{ sock("tcp", "0.0.0.0", 8000, "", 0), sock("tcp", "0.0.0.0", 9999, "", 0), }) if len(entries) != 2 { t.Fatalf("two unreadable sockets folded into %d entries — one of them can never be reported", len(entries)) } if entries[0].FP == entries[1].FP { t.Error("two different bind points share a fingerprint") } if !strings.Contains(entries[0].ArgvShape, "unreadable") { t.Errorf("an unreadable owner reads as %q, which does not say the box could not look", entries[0].ArgvShape) } } // ── identities ────────────────────────────────────────────────────────────── func idEntry(name string, sudo bool, keys int, digest, shell string) baselineEntry { return baselineEntryOfIdentity(identityObs{ Name: name, UID: 1000, Shell: shell, Sudo: sudo, AuthKeys: keys, KeyDigest: digest, }) } // A NEW ACCOUNT THAT ARRIVES ALREADY PRIVILEGED IS NOT HOW A COLLEAGUE IS // ADDED. It is how persistence is installed, and the level says so. func TestANewAccountIsHighAndCriticalWhenItArrivesArmed(t *testing.T) { base := []baselineEntry{idEntry("erin", false, 1, "d1", "/bin/bash")} plain := append([]baselineEntry{}, base...) plain = append(plain, idEntry("intern", false, 0, "", "/bin/bash")) evs := deltaCompare(base, liveList(plain)) if len(evs) != 1 || evs[0].Kind != deltaIdentityNew { t.Fatalf("want one identity_new, got %v", kindsOf(evs)) } if got := deltaLevel(evs[0]); got != deltaHigh { t.Errorf("a plain new account levelled %q, want high", got) } for _, armed := range []baselineEntry{ idEntry("backdoor", true, 0, "", "/bin/bash"), // sudo on arrival idEntry("backdoor", false, 2, "dx", "/bin/sh"), // keys on arrival } { live := append([]baselineEntry{}, base...) live = append(live, armed) evs := deltaCompare(base, liveList(live)) if len(evs) != 1 { t.Fatalf("want one event, got %v", kindsOf(evs)) } if got := deltaLevel(evs[0]); got != deltaCritical { t.Errorf("an account arriving with sudo or keys levelled %q, want critical", got) } } } // Granting sudo to an EXISTING account must read as a change to that account, // never as a new one — the slot is the account for exactly this reason. func TestGrantingSudoIsACriticalChangeToAKnownAccountNotANewOne(t *testing.T) { was := []baselineEntry{idEntry("erin", false, 1, "d1", "/bin/bash")} now := []baselineEntry{idEntry("erin", true, 1, "d1", "/bin/bash")} evs := deltaCompare(was, liveList(now)) if len(evs) != 1 || evs[0].Kind != deltaIdentityChanged { t.Fatalf("want one identity_changed, got %v", kindsOf(evs)) } if evs[0].Reason != deltaReasonSudoers { t.Errorf("reason = %q, want %q", evs[0].Reason, deltaReasonSudoers) } if got := deltaLevel(evs[0]); got != deltaCritical { t.Errorf("privilege moving levelled %q, want critical", got) } if w := identityChangeWords(was[0], now[0]); !strings.Contains(w, "sudo") { t.Errorf("the sentence %q does not mention sudo", w) } } // THE CASE A COUNT CANNOT SEE, and the only reason the digest is kept at all: // one key swapped for another is the same number and a different machine. func TestAKeySwappedAtTheSameCountIsStillReported(t *testing.T) { was := []baselineEntry{idEntry("erin", false, 1, "digest-of-old-key", "/bin/bash")} now := []baselineEntry{idEntry("erin", false, 1, "digest-of-new-key", "/bin/bash")} evs := deltaCompare(was, liveList(now)) if len(evs) != 1 || evs[0].Kind != deltaIdentityChanged { t.Fatalf("a key swap at an unchanged count produced %v — it is invisible", kindsOf(evs)) } if evs[0].Reason != deltaReasonAuthKeys { t.Errorf("reason = %q, want %q", evs[0].Reason, deltaReasonAuthKeys) } if got := deltaLevel(evs[0]); got != deltaHigh { t.Errorf("a swapped key levelled %q, want high", got) } if w := identityChangeWords(was[0], now[0]); !strings.Contains(w, "not the same keys") { t.Errorf("the sentence %q does not say the keys differ at the same count", w) } } // The digest must not churn on things that grant nothing, or the class becomes // noise and people stop reading it. func TestTheKeyDigestIgnoresOrderCommentsAndBlankLines(t *testing.T) { a := identityKeyDigest([]string{ "ssh-ed25519 AAAAKEY1 erin@laptop", "ssh-rsa AAAAKEY2 erin@desktop", }) reordered := identityKeyDigest([]string{ "ssh-rsa AAAAKEY2 erin@desktop", "ssh-ed25519 AAAAKEY1 erin@laptop", }) if a != reordered { t.Error("reordering authorized_keys changed the digest — every config-management rewrite would be a finding") } recommented := identityKeyDigest([]string{ "ssh-ed25519 AAAAKEY1 a-new-comment", "# a comment line", "", "ssh-rsa AAAAKEY2 erin@desktop", }) if a != recommented { t.Error("renaming a key's comment changed the digest") } if n := identityCountKeys([]string{"ssh-rsa AAAAK x", "# no", "", " "}); n != 1 { t.Errorf("counted %d keys, want 1 — comments and blanks are not grants", n) } // A REAL CHANGE STILL MOVES IT, or none of the above is worth anything. if a == identityKeyDigest([]string{"ssh-ed25519 AAAAKEY1 x", "ssh-rsa AAAADIFFERENT y"}) { t.Error("a different key produced the same digest") } } // THE PRIVACY PROPERTY, pinned. The accounting holds the shape of an identity // and never its content; baseline_identity.go's header states it and this is // what would catch someone widening it. func TestTheAccountingNeverHoldsKeyMaterial(t *testing.T) { const secret = "AAAAC3NzaC1lZDI1NTE5AAAAISECRETKEYBODY" keys := []string{"ssh-ed25519 " + secret + " erin@laptop"} e := baselineEntryOfIdentity(identityObs{ Name: "erin", UID: 1000, Shell: "/bin/bash", Sudo: true, AuthKeys: identityCountKeys(keys), KeyDigest: identityKeyDigest(keys), }) blob := e.FP + e.Kind + e.Unit + e.User + e.ArgvShape + e.Origin + e.DigestNote + e.Signature if strings.Contains(blob, secret) { t.Fatal("the key body is in the accounting") } n, nok := e.identityKeys() sudo, sok := e.identitySudo() if !nok || n != 1 || !sok || !sudo { t.Errorf("the shape did not survive the round trip: keys=%d known=%v sudo=%v known=%v", n, nok, sudo, sok) } } // A system account is not a way onto the machine, and fifty of them would bury // the one that is. func TestOnlyAccountsWithALoginShellAreAccounted(t *testing.T) { for _, s := range []string{"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", ""} { if identityIsLoginShell(s) { t.Errorf("%q counted as a login shell", s) } } for _, s := range []string{"/bin/bash", "/bin/zsh", "/usr/bin/fish", "/opt/weird/shell"} { if !identityIsLoginShell(s) { t.Errorf("%q did not count as a login shell — an unusual shell is MORE interesting, not less", s) } } } // ── the class guard ───────────────────────────────────────────────────────── // // The two false-finding floods deltaComparableClasses exists to stop. Both are // reachable on a real machine and neither is a fact about the machine — they // are facts about what was readable, which is the distinction this whole plane // is built on. func withReaders(t *testing.T, listeners bool, identities bool) { t.Helper() oL, oI := deltaListenerReader, deltaIdentityReader deltaListenerReader = func() ([]listenSocket, bool, string) { if !listeners { return nil, false, "no ss on this box" } return []listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}, true, "" } deltaIdentityReader = func() identityScanReport { if !identities { return identityScanReport{Reason: "cannot read the account database"} } return identityScanReport{Supported: true, Accounts: []identityObs{{Name: "erin", UID: 1000, Shell: "/bin/bash"}}} } t.Cleanup(func() { deltaListenerReader, deltaIdentityReader = oL, oI }) } // FLOOD ONE: the accounting was taken before this box could see listeners. // Every listener on it would compare against nothing and come out as new — on a // machine where nothing changed at all. func TestAListenerClassMissingFromTheAccountingIsNotReportedAsAllNew(t *testing.T) { base := &baselineFile{ Coverage: baselineCoverage(true, true, false, true), // listeners=unsupported Entries: []baselineEntry{procEntry("d", "root", "svc", "x")}, } liveListeners, _ := baselineFromListeners([]listenSocket{ sock("tcp", "0.0.0.0", 22, "sshd", 1), sock("tcp", "0.0.0.0", 443, "nginx", 2), }) live := append(liveList(liveListeners), deltaLiveEntry{Entry: base.Entries[0]}) keptBase, keptLive, notes, _ := deltaComparableClasses(base, live, deltaSight{Listeners: true, Identities: true}, nil) for _, e := range deltaCompare(keptBase, keptLive) { if strings.HasPrefix(e.Kind, "listener") { t.Fatalf("a listener event fired against an accounting that never saw listeners: %s", e.Kind) } } if !strings.Contains(strings.Join(notes, " "), "listeners are not in the accounting") { t.Errorf("the pass did not SAY it cannot report listeners; notes = %v", notes) } } // FLOOD TWO, and the worse of the two: the accounting HAS the class and this // pass went blind. Every entry would come out `_gone` — "the listeners all // stopped" reads as an incident rather than as a blindfold. func TestAClassThisPassCannotReadIsNotReportedAsAllGone(t *testing.T) { known, _ := baselineFromListeners([]listenSocket{ sock("tcp", "0.0.0.0", 22, "sshd", 1), sock("tcp", "0.0.0.0", 443, "nginx", 2), }) base := &baselineFile{ Coverage: baselineCoverage(true, true, true, true), Entries: append(known, idEntry("erin", false, 1, "d1", "/bin/bash")), } // The live side saw neither class, which is exactly what a blind pass looks // like: no entries, and no way to tell that from "they are all gone". keptBase, keptLive, notes, _ := deltaComparableClasses(base, nil, deltaSight{}, nil) for _, e := range deltaCompare(keptBase, keptLive) { if strings.HasPrefix(e.Kind, "listener") || strings.HasPrefix(e.Kind, "identity") { t.Fatalf("a blind pass reported %s — that is a blindfold being read as an incident", e.Kind) } } joined := strings.Join(notes, " ") for _, want := range []string{"listeners are in the accounting", "identities are in the accounting"} { if !strings.Contains(joined, want) { t.Errorf("the pass did not say which class it could not read; notes = %v", notes) } } } // AND WHEN BOTH SIDES CAN SEE, THE CLASS IS COMPARED NORMALLY — without this // the two tests above would pass on a plane that had simply stopped working. func TestAClassBothSidesCanSeeIsStillCompared(t *testing.T) { was, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}) base := &baselineFile{Coverage: baselineCoverage(true, true, true, true), Entries: was} now, _ := baselineFromListeners([]listenSocket{ sock("tcp", "0.0.0.0", 22, "sshd", 1), sock("tcp", "0.0.0.0", 4444, "nc", 9), }) keptBase, keptLive, notes, _ := deltaComparableClasses(base, liveList(now), deltaSight{Listeners: true, Identities: true}, nil) evs := deltaCompare(keptBase, keptLive) if len(evs) != 1 || evs[0].Kind != deltaListenerNew { t.Fatalf("want one listener_new when both sides can see, got %v (notes %v)", kindsOf(evs), notes) } if len(notes) != 0 { t.Errorf("a fully-sighted pass still carried a cannot-compare note: %v", notes) } } // A baseline taken before either class existed says nothing about them, and // that is the ordinary upgrade path — every box in the fleet is in this state // the first time it runs a bastion carrying these classes. func TestAnAccountingFromBeforeTheseClassesAsksForARetakeRatherThanFlooding(t *testing.T) { base := &baselineFile{ Coverage: "processes=all,schedulers=all", // what earlier versions wrote Entries: []baselineEntry{procEntry("d", "root", "svc", "x")}, } listeners, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}) live := append(liveList(listeners), deltaLiveEntry{Entry: base.Entries[0]}) keptBase, keptLive, notes, _ := deltaComparableClasses(base, live, deltaSight{Listeners: true, Identities: true}, nil) if evs := deltaCompare(keptBase, keptLive); len(evs) != 0 { t.Fatalf("upgrading the bastion produced findings on an unchanged machine: %v", kindsOf(evs)) } if !strings.Contains(strings.Join(notes, " "), "take it again") { t.Errorf("the note does not tell the operator what to do; notes = %v", notes) } } // THE SIGHT MUST COME FROM THE READERS, and until this test nothing checked // that — the guard tests hand deltaComparableClasses a deltaSight directly, so // a deltaCollectLive that always reported "I saw both" would have passed every // one of them while reintroducing the exact flood the guard exists to stop. func TestTheSightIsWhatTheReadersActuallySaid(t *testing.T) { for _, tc := range []struct{ listeners, identities bool }{ {true, true}, {true, false}, {false, true}, {false, false}, } { withReaders(t, tc.listeners, tc.identities) _, _, _, sight, _ := deltaCollectLive(func(string) (string, string) { return "", "" }) if sight.Listeners != tc.listeners { t.Errorf("readers said listeners=%v, sight says %v", tc.listeners, sight.Listeners) } if sight.Identities != tc.identities { t.Errorf("readers said identities=%v, sight says %v", tc.identities, sight.Identities) } } } // AND A BLIND READER CONTRIBUTES NO ENTRIES, which is the other half: a sight // of false with entries in the list would mean the guard dropped real // observations, and a sight of true with none is the state that reports // everything gone. func TestABlindReaderContributesNoEntriesOfItsClass(t *testing.T) { withReaders(t, false, true) live, _, _, sight, _ := deltaCollectLive(func(string) (string, string) { return "", "" }) if sight.Listeners { t.Fatal("a blind listener reader reported as sighted") } for _, l := range live { if l.Entry.Kind == "listener" { t.Fatal("a blind listener reader still produced listener entries") } } } // The identity fold, which nothing exercised until a coverage sweep noticed — // baselineFromIdentities is what both sides call and only the single-entry // constructor beneath it was being tested. func TestADuplicateAccountLineProducesOneEntry(t *testing.T) { dup := identityObs{Name: "erin", UID: 1000, Shell: "/bin/bash"} out := baselineFromIdentities([]identityObs{dup, dup}) if len(out) != 1 { t.Fatalf("a duplicated passwd line produced %d entries", len(out)) } // Two DIFFERENT accounts still produce two, or the fold is eating them. out = baselineFromIdentities([]identityObs{dup, {Name: "dave", UID: 1001, Shell: "/bin/bash"}}) if len(out) != 2 { t.Fatalf("two accounts folded into %d entries", len(out)) } }