package main // IDENTITIES ENTER THE ACCOUNTING. // // Persistence on a machine is usually an ACCOUNT, not a process: a user with a // shell, a sudo rule, a key appended to authorized_keys. All three survive a // reboot and none of them is a running program, so the process half of this // plane cannot see any of them however well it works. // // WHAT IS RECORDED, AND WHAT DELIBERATELY IS NOT. This runs on the person's own // computer, so the rule is that the accounting holds the SHAPE of an identity // and never its content: // // recorded account name, its shell, uid, whether it holds sudo rights, // how many authorized keys it has, and a digest over the key // set // NOT recorded any key material, any password hash, the text of a sudo rule, // any home directory content // // The digest is what makes `authkeys_changed` mean something a count cannot: a // key swapped for another key is the same count and a different machine. It is // a one-way hash over the key bodies, salted by baselineVersion like every // other fingerprint here, so the accounting cannot be read back into keys. // // AND NONE OF IT LEAVES THE BOX. What rides the heartbeat is deltaSummary // (deltarun.go:75-88) — four counts, the baseline state, timestamps and a note // string. No entry, no name, no digest. The findings themselves stay on the // machine and are read there with `observe:delta`. That was already true for // processes; it is what makes this class safe to add, and it is why nothing in // here may ever be put into a Note. // // THE SLOT IS THE ACCOUNT. Its shell, sudo and keys are in the fingerprint, so // changing any of them is a change to a known account rather than a new one // appearing — see baselineSlot. `identity_new` is then exactly what it says: an // account that was not there before. import ( "crypto/sha256" "encoding/hex" "sort" "strconv" "strings" ) // identityObs is one account as the box observed it. Platform-neutral so the // constructors below are testable without a filesystem. type identityObs struct { Name string UID int Shell string // Sudo is whether this account is named by a sudo rule or sits in a group // that is. The RULE ITSELF IS NOT KEPT — only that one exists. Sudo bool // SudoUnknown is set when the sudo rules could not be read at all. THIS IS // NOT `Sudo == false`: reporting every account as unprivileged because this // process could not open /etc/sudoers would make the next readable pass // fire `sudoers_changed` at CRITICAL for every one of them. It is the same distinction DigestNote draws for a process whose // executable could not be read, and it belongs in the FINGERPRINT rather // than beside it, because only what is fingerprinted is compared. SudoUnknown bool // AuthKeys is how many authorized keys the account has, and KeyDigest is a // one-way digest over their bodies. Both, because a count alone cannot see // a swap and a digest alone cannot be read by a person. AuthKeys int KeyDigest string KeysUnread string // why the key file could not be read, when it could not } // identityScanReport is what a platform's reader returns, including its own // blindness. A reader that cannot look must say so: "no accounts with a shell" // and "I could not read the account database" are different machines and this // plane's whole discipline is not conflating them. type identityScanReport struct { Supported bool Reason string Accounts []identityObs // Partial names a half that was readable in principle and failed in fact — // sudo rules unreadable, a home directory that could not be entered. The // accounting keeps what it got and says which half is short. Partial string } // baselineFromIdentities is the single constructor, shared by BOTH sides for // the reason deltaCollectLiveFrom gives: two spellings of this is how the // accounting and the live scan drift apart. func baselineFromIdentities(ids []identityObs) []baselineEntry { out := make([]baselineEntry, 0, len(ids)) seen := map[string]bool{} for _, id := range ids { e := baselineEntryOfIdentity(id) if seen[e.FP] { continue } seen[e.FP] = true out = append(out, e) } return out } // identityUnknown is what an unreadable property records as. A marker rather // than an absent field, for the reason DigestNote gives: an absent value reads // as "nothing there", and "I could not look" is a different fact. const identityUnknown = "?" func baselineEntryOfIdentity(id identityObs) baselineEntry { // The fingerprinted state of the account, in one field. Ordered and // explicit so a reader can see exactly what a change in this entry means. // UNKNOWN IS A THIRD VALUE AND IT IS IN THE FINGERPRINT. "this box could // not look" compared as "there are none" is the defect this plane's file // headers say it has shipped before, and on this class it is expensive: an // unreadable key file read as keys=0 produced `authkeys_changed` at HIGH // carrying "authorized keys 1 → 0", i.e. the sentence "keys were removed" // about a box that never looked. sudo := strconv.FormatBool(id.Sudo) if id.SudoUnknown { sudo = identityUnknown } keys, digest := strconv.Itoa(id.AuthKeys), id.KeyDigest if id.KeysUnread != "" { keys, digest = identityUnknown, identityUnknown } state := "shell=" + id.Shell + ";uid=" + strconv.Itoa(id.UID) + ";sudo=" + sudo + ";keys=" + keys + ";keydigest=" + digest e := baselineEntry{ Kind: "identity", Unit: id.Name, // the slot: the account User: id.Name, ArgvShape: state, Origin: "account-database", } if id.KeysUnread != "" { // Same rule as DigestNote on a process: an absent number reads as zero, // and "this box could not look" is a different fact. e.DigestNote = "authorized keys not readable: " + id.KeysUnread } e.FP = baselineFP(e.Kind, e.ExeDigest, e.User, e.Unit, e.ArgvShape) return e } // identityKeyDigest is a one-way digest over a key set, order-independent. // // SORTED BEFORE HASHING, because the order of lines in an authorized_keys file // is not a fact about the machine — an editor, a config-management run or a // re-write can reorder it without granting or revoking anything. Unsorted, the // first such rewrite would report `authkeys_changed` on every box that had one, // which is the kind of false finding that teaches people to ignore this plane. // // Salted with baselineVersion like every other fingerprint here. STATED // EXACTLY, because the first version of this line claimed more than it // delivers: the salt is a public constant in an open // repo, so it defeats a GENERIC rainbow table and not one built for this // program, and for an account with a single key the digest is a checkable // guess against a candidate public key. That is acceptable here and it is not // nothing: the file is 0600 and local, and anyone who can read it can read // authorized_keys itself. It is not a reason to put a key body in the file. func identityKeyDigest(keys []string) string { if len(keys) == 0 { return "" } norm := make([]string, 0, len(keys)) for _, k := range keys { k = strings.TrimSpace(k) if k == "" || strings.HasPrefix(k, "#") { continue } // The comment field is the part a person edits freely ("erin@laptop"), // and it grants nothing. Keeping it would report a renamed key as a new // one. Type and body are what authorise. f := strings.Fields(k) if len(f) >= 2 { k = f[0] + " " + f[1] } norm = append(norm, k) } if len(norm) == 0 { return "" } sort.Strings(norm) sum := sha256.Sum256([]byte(baselineVersion + "\x00authkeys\x00" + strings.Join(norm, "\n"))) return hex.EncodeToString(sum[:16]) } // identityCountKeys is the number of real keys in an authorized_keys body — // blanks and comments are not grants. func identityCountKeys(keys []string) int { n := 0 for _, k := range keys { k = strings.TrimSpace(k) if k == "" || strings.HasPrefix(k, "#") { continue } n++ } return n } // identityIsLoginShell answers whether a shell can be logged in with. The list // is the conventional set of "no" shells; anything else counts as yes. // // DEFAULTING TO YES IS THE SAFE DIRECTION and is chosen deliberately. An // unfamiliar shell on an account is more interesting, not less, so an unknown // value must not silently drop the account out of the accounting — which is // what a default-no would do, and it would do it for exactly the account // somebody had reason to make unusual. func identityIsLoginShell(shell string) bool { s := strings.TrimSpace(shell) if s == "" { return false } switch s { case "/usr/sbin/nologin", "/sbin/nologin", "/usr/bin/nologin", "/bin/false", "/usr/bin/false", "/dev/null", "/bin/sync", "/usr/bin/sync": return false } return true } // ── reading an identity entry back ────────────────────────────────────────── // // The fingerprinted state is one string (baselineEntryOfIdentity), so these are // the accessors that turn it back into the facts a level rule and a sentence // need. Parsing what this file wrote is deliberate: the alternative is extra // fields on baselineEntry that only one class uses, and every one of those is // another field the digest rules in baseline.go would have to speak about. func identityField(e baselineEntry, key string) string { for _, part := range strings.Split(e.ArgvShape, ";") { if v, ok := strings.CutPrefix(part, key+"="); ok { return v } } return "" } // identitySudo returns whether a sudo rule names this account and whether that // is KNOWN. Every caller must handle the second value. // // IT DID NOT HAVE ONE, AND identityKeys' comment explains exactly why that was // wrong: "a level rule that reads an unknown count as zero is exactly the bug // this marker exists to stop". The sudo half had no flag and mapped `?` to // false, and the level table read it directly — so an account arriving with // sudo, on a box whose sudoers could not be read, graded high, or WARN if it // took a service uid, which an attacker picks freely. func (e baselineEntry) identitySudo() (bool, bool) { raw := identityField(e, "sudo") if raw == identityUnknown || raw == "" { return false, false } return raw == "true", true } // identitySudoOrFalse is for the two places that are comparing two entries and // have already established both sides are known (sudoKnownBothSides). Nothing // else may use it. func (e baselineEntry) identitySudoOrFalse() bool { v, _ := e.identitySudo() return v } func sudoKnownBothSides(was, now baselineEntry) bool { _, wok := was.identitySudo() _, nok := now.identitySudo() return wok && nok } func keysKnownBothSides(was, now baselineEntry) bool { _, wok := was.identityKeys() _, nok := now.identityKeys() return wok && nok } // identityKeys returns the count and whether it is KNOWN. Every caller must // handle the second value: a level rule that reads an unknown count as zero is // exactly the bug this marker exists to stop. func (e baselineEntry) identityKeys() (int, bool) { raw := identityField(e, "keys") if raw == identityUnknown { return 0, false } n, err := strconv.Atoi(raw) if err != nil { return 0, false } return n, true } // identityUnreadable is true when any fingerprinted property of this account // could not be read on the pass that produced the entry. func (e baselineEntry) identityUnreadable() bool { return identityField(e, "sudo") == identityUnknown || identityField(e, "keys") == identityUnknown } func (e baselineEntry) identityShell() string { return identityField(e, "shell") } func (e baselineEntry) identityKeyDigest() string { return identityField(e, "keydigest") } // identityChangeReason names WHICH property of an account moved, most // consequential first. An account can change in more than one way at once — // granting sudo and adding a key is one administrative act — and the reason // reports the one that matters most while identityChangeWords says all of them. func identityChangeReason(was, now baselineEntry) string { // WHAT WAS READABLE CLASSIFIES FIRST, AND THE ORDER IS THE WHOLE POINT. // // The first version of this put the unreadable case at the top, citing the // process half's deltaReasonUnreadable branch — and copied the wrong half of // it. delta.go:693-700 checks HIGH before it checks unreadable, precisely so // that "this was replaced AND it now talks to a model" cannot be demoted to // a visibility notice by the unreadable shortcut. Putting unreadable first // here did exactly that, one rung worse: measured against the real code, a // sudo grant on an account whose keys could not be read came out // `notice [digest_unreadable]` while the sentence beside it said "a sudo // rule now names this account". The box knew, said so, and graded it as // nothing. `usermod -u 0` with unreadable keys did the same. // // It was also sticky and machine-wide: the `?` lives in the ACCOUNTING, so // one baseline taken with /home unmounted graded every later change to // every one of those accounts a notice until somebody retook it. // // So: a property that MOVED and was readable on both sides is the reason. // Unreadable is what is left when nothing readable moved — which is the // only case where the comparison really is about visibility. switch { case sudoKnownBothSides(was, now) && was.identitySudoOrFalse() != now.identitySudoOrFalse(): return deltaReasonSudoers case keysKnownBothSides(was, now) && (identityField(was, "keys") != identityField(now, "keys") || was.identityKeyDigest() != now.identityKeyDigest()): return deltaReasonAuthKeys case was.identityShell() != now.identityShell(): return deltaReasonShell case identityField(was, "uid") != identityField(now, "uid"): return deltaReasonUID case was.identityUnreadable() || now.identityUnreadable(): // NOTHING READABLE MOVED and one side did not look — so the difference // between these two entries IS the visibility, and nothing about the // account may be asserted from it. return deltaReasonUnreadable } return deltaReasonCommand } // identityChangeWords is the sentence a person reads. It names every property // that moved, never the key material — see this file's header for the rule. func identityChangeWords(was, now baselineEntry) string { var parts []string ws, wsok := was.identitySudo() ns, nsok := now.identitySudo() switch { case !wsok || !nsok: // THE CAVEAT THE KEYS HALF ALREADY HAD AND THIS ONE DID NOT. With the // baseline at `?` and the pass reading true, the sentence was "a sudo // rule now names this account" — asserting a GRANT whose previous state // the box never saw. parts = append(parts, "this machine could not read the sudo rules on one "+ "of the two passes, so this account's sudo rights were not compared") case ws != ns && ns: parts = append(parts, "a sudo rule now names this account") case ws != ns: parts = append(parts, "no sudo rule names this account any more") } wk, wok := was.identityKeys() nk, nok := now.identityKeys() switch { case !wok || !nok: // ONE SIDE DID NOT LOOK, so nothing may be said about the keys. The // sentence that used to come out here was "authorized keys 1 → 0", // i.e. keys were removed, about a box that never read the file. parts = append(parts, "this machine could not read this account's "+ "authorized keys on one of the two passes, so they were not compared") case wk != nk: parts = append(parts, "authorized keys "+strconv.Itoa(wk)+" → "+strconv.Itoa(nk)) case was.identityKeyDigest() != now.identityKeyDigest(): // THE CASE A COUNT CANNOT SEE, and the reason the digest is kept at // all: one key swapped for another is the same number and a different // machine. parts = append(parts, "the same number of authorized keys, but not the same keys") } if was.identityShell() != now.identityShell() { parts = append(parts, "shell "+was.identityShell()+" → "+now.identityShell()) } if u1, u2 := identityField(was, "uid"), identityField(now, "uid"); u1 != u2 { parts = append(parts, "uid "+u1+" → "+u2) } if len(parts) == 0 { return "this account's recorded state changed" } return strings.Join(parts, "; ") } // listenerIsExposed answers whether a listener faces anything but this machine. // It decides high-versus-warn on listener_new, and nothing else. // // LOOPBACK IS THE ONLY THING TREATED AS UNEXPOSED, and unknown counts as // exposed. A bind address this does not recognise is more interesting, not // less — the safe direction is the one that reports. func listenerIsExposed(e baselineEntry) bool { addr := listenerAddr(e) switch addr { case "127.0.0.1", "::1", "localhost": return false } // The whole 127/8 range, which a service is entitled to bind anywhere in. return !strings.HasPrefix(addr, "127.") }