Files
privatae-bastion/client-go/baseline_classes_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

470 lines
20 KiB
Go

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))
}
}