411 lines
15 KiB
Go
411 lines
15 KiB
Go
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/<state dir> ~/.config/<state dir>
|
|
//
|
|
// 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)
|
|
}
|
|
}
|