bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+387
View File
@@ -0,0 +1,387 @@
//go:build linux
package main
// Each defect found in the listener and identity classes, pinned. Each test name is the false
// sentence the machine used to be able to produce.
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// `ss` installed and exiting non-zero produced Supported=true with an empty
// table, and 54 of 54 listeners reported gone on a machine where every one was
// up — with no note.
func TestAFailedSocketReadIsBlindNotEmpty(t *testing.T) {
// THE REAL listenerScan, through the real report shape. The first version
// of this test reimplemented the branch inline and a mutation proved it
// tested a copy: breaking listenerScan left the suite green.
withSockets(t, socketsReport{Supported: true, Failed: "tcp: exit status 1"})
if _, ok, why := listenerScan(); ok {
t.Error("a failed ss reads as a readable table")
} else if !strings.Contains(why, "could not be read") {
t.Errorf("the reason %q does not say the box could not look", why)
}
// A half table is refused too, and for a reason listenerScan states: a
// partial read must not become the definition of normal.
withSockets(t, socketsReport{Supported: true, Partial: "udp: timeout",
Sockets: []listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}})
if _, ok, _ := listenerScan(); ok {
t.Error("half a socket table reads as a whole one")
}
// And a good read still works, or the two above pass on a broken reader.
withSockets(t, socketsReport{Supported: true,
Sockets: []listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}})
if got, ok, _ := listenerScan(); !ok || len(got) != 1 {
t.Errorf("a clean socket table read as unusable: ok=%v n=%d", ok, len(got))
}
// THE SHORT-LIVED RULE'S EXEMPTION USES THE SAME TABLE, and an exemption
// that cannot be checked is not an exemption.
withSockets(t, socketsReport{Supported: true, Failed: "tcp: exit status 1"})
if _, ok := deltaListeningPIDs(); ok {
t.Error("deltaListeningPIDs accepted a failed socket table")
}
// And the guard then refuses to compare the class, in BOTH directions.
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: known}
keptBase, keptLive, notes, _ := deltaComparableClasses(base, nil, deltaSight{Identities: true}, nil)
for _, e := range deltaCompare(keptBase, keptLive) {
if e.Kind == deltaListenerGone {
t.Fatal("a blind socket read still reports every listener gone")
}
}
if !strings.Contains(strings.Join(notes, " "), "could not be read on this pass") {
t.Errorf("notes do not say the class was unreadable: %v", notes)
}
}
// The guard read the machine a SECOND time, so a reader that flipped
// between the two reads either invented findings or discarded real ones. The
// live entries and the comparability decision now come from one read.
func TestTheGuardUsesTheReadThatBuiltTheLiveEntries(t *testing.T) {
base := &baselineFile{
Coverage: baselineCoverage(true, true, true, true),
Entries: []baselineEntry{},
}
// The pass SAW listeners and found a backdoor. Under the old shape a
// second read that failed would have thrown this away.
found, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 4444, "nc", 9)})
keptBase, keptLive, _, _ := deltaComparableClasses(base, liveList(found),
deltaSight{Listeners: true, Identities: true}, nil)
evs := deltaCompare(keptBase, keptLive)
if len(evs) != 1 || evs[0].Kind != deltaListenerNew {
t.Fatalf("an observed backdoor was discarded: %v", kindsOf(evs))
}
// And a pass that did NOT see the class does not compare it, whatever a
// later read would have said.
_, keptLive2, _, _ := deltaComparableClasses(base, liveList(found), deltaSight{Identities: true}, nil)
for _, l := range keptLive2 {
if l.Entry.Kind == "listener" {
t.Fatal("a class the pass did not see was still compared")
}
}
}
// An unreadable key file fingerprinted identically to an account with no
// keys, so the person read "authorized keys 1 → 0" — keys were removed — about
// a box that never looked.
func TestUnreadableKeysAreNotReportedAsKeysRemoved(t *testing.T) {
was := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", AuthKeys: 1, KeyDigest: "d1"})}
blind := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", KeysUnread: "permission denied"})}
if was[0].FP == blind[0].FP {
t.Fatal("an unreadable key file fingerprints the same as no keys")
}
evs := deltaCompare(was, liveList(blind))
if len(evs) != 1 || evs[0].Kind != deltaIdentityChanged {
t.Fatalf("want one identity_changed, got %v", kindsOf(evs))
}
if evs[0].Reason != deltaReasonUnreadable {
t.Errorf("reason = %q, want %q — a visibility change, not a machine change",
evs[0].Reason, deltaReasonUnreadable)
}
if got := deltaLevel(evs[0]); got != deltaNotice {
t.Errorf("a blind read levelled %q, want notice", got)
}
if w := identityChangeWords(was[0], blind[0]); !strings.Contains(w, "could not read") {
t.Errorf("the sentence %q asserts something about the account anyway", w)
}
// AND THE SAME FOR SUDO. Unreadable sudo rules recorded sudo=false for
// every account, so the next readable pass fired sudoers_changed at
// CRITICAL for each one.
sudoBlind := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", AuthKeys: 1, KeyDigest: "d1", SudoUnknown: true})}
evs = deltaCompare(was, liveList(sudoBlind))
if len(evs) != 1 || deltaLevel(evs[0]) != deltaNotice {
t.Fatalf("unreadable sudo rules produced %v at %v, want one notice",
kindsOf(evs), deltaLevel(evs[0]))
}
}
// `erin,dave ALL=(ALL:ALL) ALL` is ordinary sudoers. It recorded a
// principal literally named "erin,dave" and left both accounts sudo=false — a
// missed privilege grant, and a false critical when a name was added.
func TestACommaSeparatedUserListNamesEveryAccountInIt(t *testing.T) {
out, groups := map[string]bool{}, map[string]bool{}
sudoParse([]string{
"erin,dave ALL=(ALL:ALL) ALL",
"%wheel,%admin ALL=(ALL) NOPASSWD: ALL",
"root ALL=(ALL:ALL) ALL",
"Defaults env_reset",
"# a comment",
}, out, groups)
for _, want := range []string{"erin", "dave", "root"} {
if !out[want] {
t.Errorf("%q is not recognised as holding sudo — a missed privilege grant", want)
}
}
if out["erin,dave"] {
t.Error("a literal \"erin,dave\" principal was recorded")
}
for _, want := range []string{"wheel", "admin"} {
if !groups[want] {
t.Errorf("group %q in a comma list was not expanded", want)
}
}
if out["Defaults"] || out["#"] {
t.Error("a Defaults or comment line produced a principal")
}
}
// A second uid-0 account is not an account, it is a root login under
// another name — and it graded below "a key was added".
func TestASecondRootAccountIsCritical(t *testing.T) {
newRoot := baselineEntryOfIdentity(identityObs{Name: "toor", UID: 0, Shell: "/bin/bash"})
ev := deltaEvent{Kind: deltaIdentityNew, Entry: newRoot}
if got := deltaLevel(ev); got != deltaCritical {
t.Errorf("a new uid-0 account levelled %q, want critical", got)
}
was := baselineEntryOfIdentity(identityObs{Name: "erin", UID: 1000, Shell: "/bin/bash"})
now := baselineEntryOfIdentity(identityObs{Name: "erin", UID: 0, Shell: "/bin/bash"})
ch := deltaEvent{Kind: deltaIdentityChanged, Reason: identityChangeReason(was, now), Entry: now}
if got := deltaLevel(ch); got != deltaCritical {
t.Errorf("an account moved to uid 0 levelled %q, want critical", got)
}
}
// `apt install postgresql` creates a service account with a login shell.
// At high, every package install pages someone, and a plane that pages for
// ordinary work gets ignored.
func TestAServiceAccountFromAPackageInstallDoesNotPage(t *testing.T) {
svc := baselineEntryOfIdentity(identityObs{Name: "postgres", UID: 114, Shell: "/bin/bash"})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: svc}); got != deltaWarn {
t.Errorf("a new service account levelled %q, want warn", got)
}
// But a service-uid account arriving WITH sudo or keys is still the
// persistence move, and stays critical.
armed := baselineEntryOfIdentity(identityObs{Name: "postgres", UID: 114, Shell: "/bin/bash", Sudo: true})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: armed}); got != deltaCritical {
t.Errorf("a service-uid account with sudo levelled %q, want critical", got)
}
// And a real person's account is unaffected.
person := baselineEntryOfIdentity(identityObs{Name: "intern", UID: 1001, Shell: "/bin/bash"})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: person}); got != deltaHigh {
t.Errorf("a new login account levelled %q, want high", got)
}
}
// 15 of 54 listeners on a real box are pinned to a specific address,
// including a DHCP lease. Every address change was a fresh high that repeated
// on every pass, because nothing retakes the accounting.
func TestAReAddressedListenerIsNotGradedAsANewOne(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "192.168.1.5", 8080, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "192.168.9.9", 8080, "app", 5)})
evs := deltaCompare(was, liveList(now))
var newEv *deltaEvent
for i := range evs {
if evs[i].Kind == deltaListenerNew {
newEv = &evs[i]
}
}
if newEv == nil {
t.Fatal("a re-addressed listener produced no listener_new at all")
}
if newEv.Reason != deltaReasonAddressMoved {
t.Errorf("reason = %q, want %q", newEv.Reason, deltaReasonAddressMoved)
}
if got := deltaLevel(*newEv); got != deltaWarn {
t.Errorf("a DHCP re-address levelled %q, want warn", got)
}
// THE EXCLUSION THAT KEEPS THE CLASS WORTH HAVING: loopback to routable is
// the exposure change this exists to catch and must stay high.
wasLocal, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 8080, "app", 5)})
nowOpen, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 8080, "app", 5)})
for _, e := range deltaCompare(wasLocal, liveList(nowOpen)) {
if e.Kind != deltaListenerNew {
continue
}
if e.Reason == deltaReasonAddressMoved {
t.Error("loopback → routable was written off as a re-addressing")
}
if got := deltaLevel(e); got != deltaHigh {
t.Errorf("a service opening itself to the network levelled %q, want high", got)
}
}
}
// A `systemctl restart` landing inside a pass reported the port gone
// immediately while the process behind it got ten minutes.
func TestEveryClassGetsTheGraceNotJustProcessesAndSchedulers(t *testing.T) {
for _, kind := range []string{deltaProcGone, deltaSchedGone, deltaListenerGone, deltaIdentityGone} {
if !deltaIsGone(kind) {
t.Errorf("%s is not treated as an absence, so it bypasses the grace", kind)
}
st := freshState(t)
ev := deltaEvent{Kind: kind, FP: "fp-" + kind}
if out := deltaApplyGrace([]deltaEvent{ev}, st, time.Now()); len(out) != 0 {
t.Errorf("%s was reported on the FIRST scan that missed it", kind)
}
}
}
// A box with AuthorizedKeysFile pointing elsewhere recorded "no keys" for
// everyone, silently — the class went narrow with no note.
func TestANonDefaultAuthorizedKeysLocationIsReadAndSaidOutLoud(t *testing.T) {
home := t.TempDir()
central := filepath.Join(home, "central")
if err := os.MkdirAll(central, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(central, "erin"), []byte("ssh-ed25519 AAAAK x\n"), 0o600); err != nil {
t.Fatal(err)
}
keys, unread := authorizedKeysOf(home, "erin", "1000", []string{filepath.Join(central, "%u")})
if unread != "" {
t.Fatalf("a readable central key file reported unreadable: %s", unread)
}
if identityCountKeys(keys) != 1 {
t.Errorf("central keys not read: %d", identityCountKeys(keys))
}
// The %u token and an absolute path both have to work, and a relative one
// still resolves under the home.
if got := authorizedKeysPath(".ssh/authorized_keys", "/home/erin", "erin", "1000"); got != "/home/erin/.ssh/authorized_keys" {
t.Errorf("relative path resolved to %q", got)
}
if got := authorizedKeysPath("/etc/ssh/keys/%u", "/home/erin", "erin", "1000"); got != "/etc/ssh/keys/erin" {
t.Errorf("%%u expanded to %q", got)
}
}
// A home directory that is not there is a box that did not look — an unmounted
// /home, a network home not yet available — and reading it as "no keys" made
// every account report authkeys_changed the moment the mount appeared.
func TestAnAbsentHomeDirectoryIsBlindNotKeyless(t *testing.T) {
_, unread := authorizedKeysOf("/definitely/not/here", "ghost", "1002",
[]string{".ssh/authorized_keys", ".ssh/authorized_keys2"})
if unread == "" {
t.Fatal("an absent home directory read as an account with no keys")
}
// A home that IS there with no .ssh is genuinely none, and says nothing.
if _, u := authorizedKeysOf(t.TempDir(), "erin", "1000",
[]string{".ssh/authorized_keys", ".ssh/authorized_keys2"}); u != "" {
t.Errorf("an account with no keys reported %q", u)
}
}
// withSockets points BOTH socket readers at one report — listenerScan for the
// accounting and deltaListeningPIDs for the noise filter's exemption.
func withSockets(t *testing.T, r socketsReport) {
t.Helper()
oL, oD := listenerSockets, deltaSockets
listenerSockets = func() socketsReport { return r }
deltaSockets = func() socketsReport { return r }
t.Cleanup(func() { listenerSockets, deltaSockets = oL, oD })
}
func withSshdConfig(t *testing.T, body string) {
t.Helper()
old := sshdConfigPath
p := filepath.Join(t.TempDir(), "sshd_config")
if body != "" {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
sshdConfigPath = p
t.Cleanup(func() { sshdConfigPath = old })
}
// The AuthorizedKeysFile override, through the parse. A path-expansion test
// alone exercised the EXPANSION but never the
// sshd_config parse, so a mutation that ignored the override entirely left the
// suite green — the class would silently narrow to the default pair.
func TestTheAuthorizedKeysLocationComesFromSshdConfig(t *testing.T) {
withSshdConfig(t, "Port 22\n# a comment\nAuthorizedKeysFile /etc/ssh/keys/%u\n")
paths, note := authorizedKeysPaths()
if len(paths) != 1 || paths[0] != "/etc/ssh/keys/%u" {
t.Fatalf("the override was ignored: %v", paths)
}
if note == "" {
t.Error("a narrowed key lookup was not said out loud at all")
}
// AND THE NOTE CARRIES NO PATH. It reaches deltaSummary.Note, which rides
// the heartbeat, so the first version of it put a line of the user's own
// sshd configuration on the wire every pass.
if strings.Contains(note, "/etc/ssh/keys/") || strings.Contains(note, "%u") {
t.Errorf("the note carries the user's sshd configuration off their machine: %q", note)
}
// `none` means no account has keys to account for, which is a different
// fact from "this box found none".
withSshdConfig(t, "AuthorizedKeysFile none\n")
if paths, note := authorizedKeysPaths(); len(paths) != 0 || !strings.Contains(note, "none") {
t.Errorf("AuthorizedKeysFile none gave paths=%v note=%q", paths, note)
}
// No sshd on the box: the default pair is the right guess and needs no note.
withSshdConfig(t, "")
if paths, note := authorizedKeysPaths(); len(paths) != 2 || note != "" {
t.Errorf("a box with no sshd_config gave paths=%v note=%q", paths, note)
}
// Silent by default: a stock config must not produce a note, or the note
// stops meaning anything.
withSshdConfig(t, "Port 22\nPermitRootLogin no\n")
if paths, note := authorizedKeysPaths(); len(paths) != 2 || note != "" {
t.Errorf("a stock sshd_config gave paths=%v note=%q", paths, note)
}
}
// A NARROWED READ MUST SAY SO. identityScanReport.Partial was set by the reader and read NOWHERE,
// so a class that went narrow — sudo rules unopenable, keys read from somewhere
// this does not look — reported a confident answer over the gap. The entries it
// produced are real and still compared; what was missing was the sentence
// saying the answer covers less than it looks.
func TestAPartiallyReadIdentityClassSaysSoOnThePass(t *testing.T) {
oI := deltaIdentityReader
deltaIdentityReader = func() identityScanReport {
return identityScanReport{Supported: true,
Partial: "sudo rules not readable: /etc/sudoers",
Accounts: []identityObs{{Name: "erin", UID: 1000, Shell: "/bin/bash", SudoUnknown: true}}}
}
oL := deltaListenerReader
deltaListenerReader = func() ([]listenSocket, bool, string) { return nil, false, "no ss" }
t.Cleanup(func() { deltaIdentityReader, deltaListenerReader = oI, oL })
_, _, _, sight, _ := deltaCollectLive(func(string) (string, string) { return "", "" })
if !sight.Identities {
t.Fatal("a partially-read class reported as blind — its real entries would be dropped")
}
if len(sight.Notes) != 1 || !strings.Contains(sight.Notes[0], "sudo rules not readable") {
t.Fatalf("the reader's partial note did not reach the pass: %v", sight.Notes)
}
}