package main // Second-bastion DETECTION — the second-bastion finding on the heartbeat. // // The singleton (singleton.go) refuses a second bastion going FORWARD: a `pair` // whose state directory disagrees with the machine marker is turned away. That // leaves two holes it cannot close by itself, and this file is what sees them: // // 1. Installs that PREDATE the marker. Every pairing made before the singleton // shipped is already on disk, and refusing new ones does nothing about the // ones already there. On existing installs this is the only case that exists. // 2. Anything that got around the refusal — a build with the guard removed, a // state directory restored from a backup, a marker deleted and rewritten. // // The rule: the anchor host is alerted when a new bastion is installed on // the machine. So the daemon LOOKS, and what it finds rides the // heartbeat to every host the box serves. // // WHAT THIS CAN AND CANNOT SEE, stated here because the host's wording depends // on it and a detection feature that overstates its reach is worse than none: // // - It reads home directories out of /etc/passwd and looks for the default // config location under each. A user who sets XDG_CONFIG_HOME somewhere // else keeps their state dir somewhere this scan does not look. // - /etc/passwd ONLY. Users that exist through NSS alone — LDAP, SSSD, an AD // join — are never enumerated, so on a directory-joined machine this sees // the local accounts and nothing else. That is the common enterprise case // and it is a real hole, not a corner: getent would close it and is not // what this does today. // - A bastion RUNNING from a non-default directory is invisible to a scan of // default paths. The answer to the non-cooperating case is process // observation plus the observe signature table; this is // the filesystem half only, and it carries no posture degrade. // - So a finding is EVIDENCE, and an empty result is NOT PROOF of absence. // The heartbeat key is therefore omitted entirely on a box that could not // look (non-root, or not Linux), rather than reported as "none" — the same // watching/blind/unknown distinction the agent scanner already draws. A host // must never read "no second bastion" from a box that never looked. // // Root is required because the whole point is reading OTHER users' directories; // a non-root scan would find nothing and report a clean machine, which is the // exact lie this is here to prevent. import ( "bufio" "context" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" ) // passwdPath is the user list. Read-only, and only the name/uid/home fields are // ever taken off a line. const passwdPath = "/etc/passwd" // otherBastionKind ranks a finding by how far along it is. The anchor needs the // difference: a binary someone downloaded is not the same event as a state // directory already serving a host nobody approved. const ( // obPaired — a state directory holding at least one pairing. This machine is // serving a host the anchor does not know about. The finding that degrades // posture. obPaired = "paired" // obState — a state directory with no pairings yet. Installed and initialised, // not yet attached to any host. obState = "state" // obBinary — a bastion binary in a user's own bin directory with no state // directory beside it. Nothing is running; somebody is part-way. obBinary = "binary" ) // otherBastion is one bastion installation on this machine that is not the one // sending this heartbeat. Paths and usernames only — never file contents, and // never anything out of the other bastion's pairing files, which hold another // host's credentials and are none of this host's business. type otherBastion struct { Kind string `json:"kind"` User string `json:"user"` UID int `json:"uid"` StateDir string `json:"state_dir,omitempty"` BinaryPath string `json:"binary_path,omitempty"` Pairings int `json:"pairings"` LastChange string `json:"last_change,omitempty"` } // passwdUser is one line of /etc/passwd, narrowed to what the scan uses. type passwdUser struct { Name string UID int Home string } // readPasswdUsers parses /etc/passwd. A malformed line is skipped rather than // failing the scan: one bad line in a file this scan does not own must not blind // the machine. func readPasswdUsers(path string) ([]passwdUser, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var out []passwdUser seen := map[string]bool{} sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.Split(line, ":") if len(parts) < 6 { continue } uid, err := strconv.Atoi(parts[2]) if err != nil { continue } home := strings.TrimSpace(parts[5]) if home == "" || home == "/" || home == "/nonexistent" || home == "/dev/null" { continue } // Several system accounts share one home (/var/run/foo and friends). Scan // each directory once; the first account named for it is the one reported. if seen[home] { continue } seen[home] = true out = append(out, passwdUser{Name: parts[0], UID: uid, Home: home}) } if err := sc.Err(); err != nil { return nil, err } return out, nil } // ownStateDirs are the directories that must never be reported as somebody // else's bastion: what the marker names AND this process's own config dir. // // Both, not either. The marker is not repointed once written (singleton.go // maybeWriteMarker), so a root daemon whose own dir stops matching the marker — // XDG_CONFIG_HOME set in root's environment, a config directory renamed — would // otherwise skip the dir the marker names and report ITSELF as a second bastion // "serving Hosts you never approved", to its own hosts. An alert about the // reader's own bastion is worse than no alert: it is a red finding that cannot // be acted on and that teaches the user to ignore the next one. // // A read error on either is not fatal; the other still excludes. func ownStateDirs() []string { var out []string if m, err := readMarker(); err == nil && m != nil && strings.TrimSpace(m.StateDir) != "" { out = append(out, strings.TrimRight(m.StateDir, "/")) } if d, err := configDirPath(); err == nil { out = append(out, strings.TrimRight(d, "/")) } return out } // isOwnDir reports whether `dir` is one of ours. String comparison (sameDir), // which is exactly why the symlink check in statRealDir has to exist: a link is // a different string pointing at the same inode. func isOwnDir(dir string, mine []string) bool { for _, m := range mine { if m != "" && sameDir(dir, m) { return true } } return false } // statRealDir stats a path WITHOUT following symlinks, and returns it only when // it is a real directory. // // os.Stat follows links, and the ownership exclusion is a string compare, so a // link cannot be seen through. Measured in a temp tree: an unprivileged user who // runs // // ln -s /root/.config/ ~/.config/ // // makes Stat report a directory and makes countPairings return ROOT's pairing // count — which reaches the anchor's inbox as "eve is running a second bastion // and it is serving 2 Hosts of its own. Your machine is answering to a host you // never approved." One command, from any local account, forging a red security // alert that is indistinguishable from a real one. Lstat is what tells them // apart. // // A symlink here is not reported at all rather than reported as a lesser // finding: the thing it points at is either ours (already excluded) or a real // directory under some user's home, which this scan finds on its own pass. func statRealDir(path string) (os.FileInfo, bool) { fi, err := os.Lstat(path) if err != nil || fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { return nil, false } return fi, true } // countPairings reports how many pairing files a state directory holds. A // directory it cannot read counts zero — the DIRECTORY's existence is the // finding, and the count only sharpens it. // // Lstat'd for the same reason as the state dir itself: `pairings` being a link // to root's own is the same forgery one level down. func countPairings(stateDir string) int { dir := filepath.Join(stateDir, "pairings") if _, ok := statRealDir(dir); !ok { return 0 } ents, err := os.ReadDir(dir) if err != nil { return 0 } n := 0 for _, e := range ents { if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") { n++ } } return n } // bastionBinaryNames are the binary names an installation can carry. A single // entry today; kept as a list so the scan below stays a loop when a second // name ever exists. var bastionBinaryNames = []string{"privatae-bastion"} // scanOtherBastions walks the machine's users and returns every bastion // installation that is not ours. // // nil means WE DID NOT LOOK and the caller must omit the heartbeat key. An empty // (non-nil) slice means we looked and found none, which is a real state that has // to travel so the host can clear a finding that has been dealt with — the same // nil-vs-empty contract peers.go and unapprovedAttempts already use, for the // same reason. func scanOtherBastions() []otherBastion { if !singletonSupported() || !processIsRoot() { return nil } return scanOtherBastionsIn(passwdPath, ownStateDirs()) } // scanOtherBastionsIn is the scan itself, with the two machine-scoped inputs // passed in. Split out so the tests can exercise it against a temp tree: this // binary's tests run as root on Linux, so a test calling scanOtherBastions() // would read the REAL /etc/passwd and the real home directories of the machine // it runs on. Two earlier test passes in this repo wrote to live state; the // shape that prevents it is passing the paths rather than reaching for them. func scanOtherBastionsIn(passwdFile string, mine []string) []otherBastion { users, err := readPasswdUsers(passwdFile) if err != nil { // Could not enumerate users ⇒ could not look. Say nothing. return nil } out := make([]otherBastion, 0) for _, u := range users { stateDir := filepath.Join(u.Home, ".config", "privatae-bastion") if isOwnDir(stateDir, mine) { continue } if st, ok := statRealDir(stateDir); ok { n := countPairings(stateDir) kind := obState if n > 0 { kind = obPaired } out = append(out, otherBastion{ Kind: kind, User: u.Name, UID: u.UID, StateDir: stateDir, Pairings: n, LastChange: st.ModTime().UTC().Format(time.RFC3339), }) continue } // No state directory. A binary on its own is the early warning. // Lstat again: a link named privatae-bastion proves nothing about what // it points at, and the finding is "this user has one", not "a path // resolves somewhere". for _, name := range bastionBinaryNames { bin := filepath.Join(u.Home, ".local", "bin", name) st, err := os.Lstat(bin) if err != nil || st.IsDir() || st.Mode()&os.ModeSymlink != 0 { continue } out = append(out, otherBastion{ Kind: obBinary, User: u.Name, UID: u.UID, BinaryPath: bin, LastChange: st.ModTime().UTC().Format(time.RFC3339), }) break } } // Most serious first, then by user, so the host reads the worst finding at the // top and the order does not churn between beats over a map iteration. rank := map[string]int{obPaired: 0, obState: 1, obBinary: 2} sort.SliceStable(out, func(i, j int) bool { if rank[out[i].Kind] != rank[out[j].Kind] { return rank[out[i].Kind] < rank[out[j].Kind] } return out[i].User < out[j].User }) return out } // ── the heartbeat's cached view ──────────────────────────────────────────── // // The scan stats a handful of paths per user, so it is far cheaper than the apt // simulation posturePayload guards against — but it is still filesystem work on // a box that may have a large passwd, and the heartbeat may never wait on disk: // a late beat is what flips this box stale on the host and can trigger quarantine // (observe_ops.go:787-795 sets out the same budget). So the beat publishes the // last completed scan and a background ticker refreshes it, exactly as posture // does. const otherBastionRefreshEvery = 5 * time.Minute var ( otherBastionMu sync.Mutex otherBastionLast []otherBastion otherBastionRan bool otherBastionAt time.Time ) // otherBastionsPayload is what buildHeartbeatBody folds in: the last completed // scan, or nil before the first one finishes (and forever on a box that cannot // look). nil ⇒ the caller omits the key. func otherBastionsPayload() []otherBastion { otherBastionMu.Lock() defer otherBastionMu.Unlock() if !otherBastionRan { return nil } return otherBastionLast } // otherBastionsScannedAt is WHEN the published answer was produced, sent beside // it on every beat. // // Without it the list has no age, and a scan that stops completing publishes its // last answer for ever — including a `[]`, which the platform and the UI both // read as "looked, and the machine is clean". A frozen all-clear is the failure // this whole feature is built to avoid, so the freshness has to travel with the // finding and the reader has to be able to age it out. Empty before the first // scan completes; the caller omits the key then, exactly as it omits the list. func otherBastionsScannedAt() string { otherBastionMu.Lock() defer otherBastionMu.Unlock() if !otherBastionRan || otherBastionAt.IsZero() { return "" } return otherBastionAt.UTC().Format(time.RFC3339) } // refreshOtherBastions runs the scan once and publishes it. // // One refresher is started per PAIRING (run.go starts it beside the heartbeat, // and a box serving three hosts runs three heartbeats), so this skips a scan that // another goroutine has just done. A plain sync.Once would be wrong: the // refreshers are tied to their pairing's context, so the first one to be // cancelled would take the scan down for the whole box. func refreshOtherBastions() { otherBastionMu.Lock() fresh := otherBastionRan && time.Since(otherBastionAt) < otherBastionRefreshEvery/2 otherBastionMu.Unlock() if fresh { return } found := scanOtherBastions() otherBastionMu.Lock() defer otherBastionMu.Unlock() if found == nil { // Could not look. Leave any previous answer alone rather than retracting // it — a transient read failure must not clear a real finding. return } otherBastionLast = found otherBastionRan = true otherBastionAt = time.Now() } // runOtherBastionRefresh is started by the heartbeat goroutine and lives as long // as it does: one scan now, then one per window. func runOtherBastionRefresh(ctx context.Context) { refreshOtherBastions() t := time.NewTicker(otherBastionRefreshEvery) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: refreshOtherBastions() } } } // describeOtherBastion is the one-line form used in the daemon's own log, so an // operator reading journalctl sees the same finding the host was sent. func describeOtherBastion(o otherBastion) string { switch o.Kind { case obPaired: return fmt.Sprintf("a second bastion under user %s serves %d host(s) of its own (%s)", o.User, o.Pairings, o.StateDir) case obState: return fmt.Sprintf("a second bastion under user %s is set up but not paired (%s)", o.User, o.StateDir) default: return fmt.Sprintf("user %s has a bastion binary installed (%s)", o.User, o.BinaryPath) } }