bastion 0.53.2
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user