bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
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.")
|
||||
}
|
||||
Reference in New Issue
Block a user