388 lines
17 KiB
Go
388 lines
17 KiB
Go
//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)
|
|
}
|
|
}
|