//go:build linux package main // The Linux account reader. Everything here is a read; nothing in this file // writes, and nothing it reads leaves the machine (baseline_identity.go says // what does). import ( "bufio" "os" "path/filepath" "strconv" "strings" ) // identityScan reads the accounts this machine can be logged into as. // // ONLY ACCOUNTS WITH A LOGIN SHELL. A stock Debian has some fifty system // accounts — daemon, bin, sys, www-data — every one of them nologin, none of // them a way in, and all of them noise that would bury the one account that // matters. The filter is on the shell because that is the property that makes // an account a route onto the box. // // A NON-ROOT RUN IS BLIND HERE AND SAYS SO. /etc/passwd is world-readable, so // the account list comes out either way — but the sudo rules and every other // user's authorized_keys do not, and an accounting that silently recorded // "0 keys" for every account would report the truth backwards. deltaPass // already refuses to run as a non-administrator; this is the second guard. // This platform reads both classes, so a failed read while the accounting is // taken is written `unread`, not `unsupported` (baselineCoverageRead). const ( listenersReadableHere = true identitiesReadableHere = true ) func identityScan() identityScanReport { f, err := os.Open(identityPath("/etc/passwd")) if err != nil { return identityScanReport{Reason: "the account database could not be read: " + err.Error()} } defer f.Close() sudoers, sudoPartial := sudoPrincipals() // WHERE SSHD ACTUALLY LOOKS FOR KEYS, which is not always the default pair. // A box with `AuthorizedKeysFile /etc/ssh/authorized_keys/%u` recorded "no // keys" for every account, silently — the class went narrow with no note, // and a new account holding central keys graded high instead of critical. keyPaths, keyNote := authorizedKeysPaths() r := identityScanReport{Supported: true, Partial: strings.TrimSpace(sudoPartial + " " + keyNote)} sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } // name:passwd:uid:gid:gecos:home:shell p := strings.Split(line, ":") if len(p) < 7 { continue } shell := strings.TrimSpace(p[6]) if !identityIsLoginShell(shell) { continue } uid, err := strconv.Atoi(strings.TrimSpace(p[2])) if err != nil { continue } name := strings.TrimSpace(p[0]) // THE PARTIAL IS NOW LOAD-BEARING. It was set here and read NOWHERE, // so unreadable sudo rules recorded sudo=false for // every account — and the next readable pass fired `sudoers_changed` at // CRITICAL for each one, saying a rule had been removed that had never // been seen. id := identityObs{Name: name, UID: uid, Shell: shell, Sudo: sudoers[name], SudoUnknown: sudoPartial != ""} keys, unread := authorizedKeysOf(strings.TrimSpace(p[5]), name, strconv.Itoa(uid), keyPaths) id.AuthKeys, id.KeyDigest, id.KeysUnread = identityCountKeys(keys), identityKeyDigest(keys), unread r.Accounts = append(r.Accounts, id) } if err := sc.Err(); err != nil { // UNSUPPORTED, NOT PARTIAL, and the difference is which half is short. // Partial means "some PROPERTY of these accounts is narrow" and the // entries are still compared. A scanner error drops ENTRIES — measured // with one 70 KB GECOS line, 2 accounts of 5 — and comparing that set // reports the tail as identity_gone and never sees an account created // after the long line at all. Same family as the failed `ss`, and the // same answer listenerScan gives: refuse the class. return identityScanReport{Reason: "the account database could not be read to the end: " + err.Error()} } return r } // authorizedKeysOf reads an account's keys. Returns the lines and, when it // could not look, why — never an empty list standing in for both. func authorizedKeysOf(home, user, uid string, paths []string) (keys []string, unread string) { if home == "" { return nil, "no home directory recorded" } // BOTH CONVENTIONAL PATHS. sshd's default AuthorizedKeysFile is // ".ssh/authorized_keys .ssh/authorized_keys2", and the second is still // honoured on a stock install — a key parked there would be a working way // in that an accounting reading only the first would never see. var missing int for _, rel := range paths { b, err := os.ReadFile(authorizedKeysPath(rel, home, user, uid)) if err != nil { if os.IsNotExist(err) { missing++ continue } // Permission denied is the interesting one: it means this account // HAS a key file this process may not read, which is not the same // as having none. return keys, err.Error() } keys = append(keys, strings.Split(string(b), "\n")...) } if missing == len(paths) && len(keys) == 0 { // BOTH FILES ABSENT MEANS ONE OF TWO THINGS, and they are not the same. // If the home directory itself is not there, this // box did not look — an unmounted /home, a network home not yet // available, an account whose home was never created. Reading that as // "no keys" makes every account report `authkeys_changed` the moment // the mount appears. A home that IS there with no .ssh is genuinely // none, and says nothing. if _, err := os.Stat(home); err != nil { return nil, "home directory not readable: " + err.Error() } return nil, "" } return keys, "" } // authorizedKeysPaths is what sshd_config says AuthorizedKeysFile is, or the // default pair when it says nothing. Tokens are left in place and expanded per // account by authorizedKeysPath. // // Returns a note when the setting is NOT the default, because a narrowed or // relocated key lookup changes what this whole class can see and the person is // entitled to know that rather than reading a confident "0 keys". // sshdConfigPath is a var for the same reason as listenerSockets: a test must // be able to hand this an sshd_config, and the machine's own is not one it may // write. var sshdConfigPath = "/etc/ssh/sshd_config" // identityRoot is "/" in production and a fixture directory in a test. Without // it nothing could execute identityScan itself, so its WIRING — that it sets // SudoUnknown, that it feeds the sshd_config paths through, that it carries the // narrowed-lookup note — was untestable and a mutation sweep found every one of // those unguarded. var identityRoot = "/" func identityPath(p string) string { if identityRoot == "/" || identityRoot == "" { return p } return filepath.Join(identityRoot, p) } func authorizedKeysPaths() ([]string, string) { def := []string{".ssh/authorized_keys", ".ssh/authorized_keys2"} b, err := os.ReadFile(sshdConfigPath) if err != nil { if os.IsNotExist(err) { return def, "" // no sshd here; the default pair is the right guess } return def, "sshd_config not readable, so authorized-key locations are assumed to be the default" } // INCLUDE IS THE DEBIAN 12 / UBUNTU 22.04 DEFAULT LAYOUT, and missing it put // this straight back to the "no keys" failure with no note: a drop-in under // /etc/ssh/sshd_config.d/*.conf sets AuthorizedKeysFile and nothing here saw // it. One level of Include, which is what the // stock layout uses; deeper nesting is named as a limit rather than walked. body := string(b) for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(strings.TrimSpace(line)) if len(f) < 2 || !strings.EqualFold(f[0], "Include") { continue } for _, pat := range f[1:] { if !filepath.IsAbs(pat) { pat = filepath.Join(filepath.Dir(sshdConfigPath), pat) } if hits, err := filepath.Glob(pat); err == nil { for _, h := range hits { if ib, err := os.ReadFile(h); err == nil { body += "\n" + string(ib) } } } } } for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } f := strings.Fields(line) // A Match BLOCK SCOPES EVERYTHING AFTER IT to some users or addresses, // so an AuthorizedKeysFile inside one is NOT the global setting and // applying it to every account would be wrong in the confident // direction. Stop at the first Match and say the lookup may be narrower // than what was read. if strings.EqualFold(f[0], "Match") { break } if len(f) < 2 || !strings.EqualFold(f[0], "AuthorizedKeysFile") { continue } if strings.EqualFold(f[1], "none") { return nil, "sshd is configured with AuthorizedKeysFile none, so no account has ssh keys to account for" } // NO PATH IN THIS STRING. 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. This file's header promises the class adds nothing that leaves // the machine, and it was written when every note was a fixed sentence. // The generic form does the note's whole job: it tells the person the // class is narrowed and to go look. The path is on their box already. return f[1:], "authorized keys are read from a location this platform " + "does not choose, so what this class can see depends on that setting" } return def, "" } // authorizedKeysPath expands sshd's tokens in one AuthorizedKeysFile entry. // A relative path is relative to the account's home, which is sshd's own rule. func authorizedKeysPath(pattern, home, user, uid string) string { // %% FIRST, VIA A PLACEHOLDER, or its own percent gets eaten by the token // that follows it. Measured: `%%h/.ssh/ak` expanded to // `/home/erin/%/home/erin/.ssh/ak`, because %h was substituted before %% // was collapsed. The path then does not exist, which // reads as ENOENT, which reads as "this account has no keys". const esc = "\x00pct\x00" p := strings.ReplaceAll(pattern, "%%", esc) p = strings.ReplaceAll(p, "%u", user) p = strings.ReplaceAll(p, "%U", uid) p = strings.ReplaceAll(p, "%h", home) p = strings.ReplaceAll(p, esc, "%") if filepath.IsAbs(p) { return p } return filepath.Join(home, p) } // sudoPrincipals is the set of account names a sudo rule names, directly or // through a group. // // DELIBERATELY SHALLOW, and this is the honest limit of the class. It reads // every name in the User_List on the left of a rule, comma-separated with or // without spaces; it does not evaluate sudo's grammar, expand // netgroups, follow #includedir recursion beyond one level, or decide what a // rule actually permits. So `sudo=true` means "a rule names this account", not // "this account can become root" — and a rule that grants nothing still sets // it. That direction is the right one to be wrong in: a false true is a person // reading one extra line, a false false is a missed privilege grant. func sudoPrincipals() (map[string]bool, string) { out := map[string]bool{} var unreadable []string files := []string{identityPath("/etc/sudoers")} if ents, err := os.ReadDir(identityPath("/etc/sudoers.d")); err == nil { for _, e := range ents { if e.IsDir() || strings.HasSuffix(e.Name(), "~") || strings.Contains(e.Name(), ".") { // sudo itself ignores names containing a dot or ending in ~. continue } files = append(files, filepath.Join(identityPath("/etc/sudoers.d"), e.Name())) } } else if !os.IsNotExist(err) { unreadable = append(unreadable, "/etc/sudoers.d") } groups := map[string]bool{} var bodies []string for _, path := range files { b, err := os.ReadFile(path) if err != nil { unreadable = append(unreadable, path) continue } bodies = append(bodies, string(b)) } sudoParse(bodies, out, groups) for name := range groupMembers(groups) { out[name] = true } if len(unreadable) > 0 { return out, "sudo rules not readable: " + strings.Join(unreadable, ", ") } return out, "" } // sudoParse is the line reading, split out so it can be tested without an // /etc — which is the only reason the comma-separated User_List bug // could not be caught by a unit test before. func sudoParse(bodies []string, out, groups map[string]bool) { for _, body := range bodies { for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "Defaults") { continue } f := strings.Fields(line) if len(f) < 2 || !strings.Contains(line, "=") { continue } // A User_List IS COMMA-SEPARATED, and taking only the first name // was wrong in both directions. // `erin,dave ALL=(ALL:ALL) ALL` is ordinary sudoers: it recorded a // principal literally named "erin,dave" and left BOTH real // accounts reading sudo=false — a missed privilege grant, which is // the direction this function must never be wrong in. And adding a // second name to an existing rule then flipped the first account // from true to false, which surfaces as `sudoers_changed` at // CRITICAL carrying "no sudo rule names this account any more". // That sentence was untrue and it was on the page rung. // THE WHOLE User_List, WHICH IS NOT ALWAYS ONE FIELD. `visudo -c` // accepts `erin, dave ALL=(ALL:ALL) ALL` — a space after the // comma — and splitting only f[0] found `erin` and missed `dave`. // The list is everything up to the // first field that is not part of it, which is the one containing // the `=`. for _, who := range strings.Split(sudoUserList(f), ",") { who = strings.TrimSpace(who) if who == "" { continue } switch { case strings.HasPrefix(who, "%"): groups[strings.TrimPrefix(who, "%")] = true case strings.HasPrefix(who, "User_Alias"), strings.HasPrefix(who, "Runas_Alias"), strings.HasPrefix(who, "Host_Alias"), strings.HasPrefix(who, "Cmnd_Alias"): // An alias DEFINITION names no principal by itself; its // members are picked up where the alias is used, which this // shallow read does not follow. Named in the doc comment. default: out[who] = true } } } } } // sudoUserList is the principal list at the head of a rule, rejoined across the // spaces a comma-separated list may contain. Everything before the field that // holds the `=` belongs to it. func sudoUserList(f []string) string { var head []string for _, tok := range f { if strings.Contains(tok, "=") { break } head = append(head, tok) } if len(head) == 0 { return f[0] } // "erin, dave" and "erin,dave" and "erin ,dave" all become "erin,dave". return strings.ReplaceAll(strings.Join(head, ""), " ", "") } // groupMembers expands the named groups to their member accounts, from // /etc/group. A group's members also come from each account's primary gid, // which is not read here — see the shallowness note on sudoPrincipals. func groupMembers(want map[string]bool) map[string]bool { out := map[string]bool{} if len(want) == 0 { return out } b, err := os.ReadFile(identityPath("/etc/group")) if err != nil { return out } for _, line := range strings.Split(string(b), "\n") { p := strings.Split(strings.TrimSpace(line), ":") if len(p) < 4 || !want[p[0]] { continue } for _, m := range strings.Split(p[3], ",") { if m = strings.TrimSpace(m); m != "" { out[m] = true } } } return out } // listenerScan is the socket half, read through the observe plane's own reader // so the accounting and `observe:sockets` can never disagree about what is // listening — the same rule deltaListeningPIDs states. // listenerSockets is readSockets as a var, so a test can hand this the states // a real `ss` produces and nothing else can: a non-zero exit, a half table. // The first test written for this reimplemented the logic inline instead, and a // mutation proved it tested a copy rather than the code. var listenerSockets = readSockets func listenerScan() ([]listenSocket, bool, string) { r := listenerSockets() if !r.Supported { return nil, false, r.Reason } if r.Failed != "" { // AN `ss` THAT RAN AND FAILED IS NOT A MACHINE WITH NO LISTENERS. This // is the state that produced 54 false `listener_gone` in review, and // the reverse — an accounting TAKEN in this state records // `listeners=all` with zero entries, and the next healthy pass reports // every listener as new, 30 of them high, repeating every beat until // somebody retakes the accounting. return nil, false, "socket table could not be read (" + r.Failed + ")" } if r.Partial != "" { // HALF A TABLE IS NOT A TABLE, for the reason deltaListeningPIDs gives // and one more that is specific to the accounting: a baseline taken // from a partial table records fewer listeners than the machine has, // and every one it missed becomes a `listener_new` finding on the next // pass that CAN read the table. A partial read must not become the // definition of normal. return nil, false, "socket table read short (" + r.Partial + ")" } return r.Sockets, true, "" }