Files
privatae-bastion/client-go/deltastate.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

315 lines
11 KiB
Go

package main
// deltastate.go — the little bit of memory a delta needs to be about TIME
// rather than about one scan.
//
// Three questions cannot be answered by comparing two lists, and each of them
// is the difference between a useful finding and a noisy one:
//
// how long has this been missing an absence is only a finding once it has
// outlasted a restart (deltaGoneGrace)
// when did we first see this "new since" on the beat, and the age of a
// finding a person is looking at
// did that one survive the short-lived rule counts what came and
// went instead of reporting it
//
// IT IS NOT ONLY BOOKKEEPING ANY MORE, and that changed underneath this
// paragraph. Since the bastion's own (digest, version) pair moved in here, this
// file is what PINS a `binary_digest_changed` critical: a same-version swap
// repeats every pass because the pair is not advanced past it, and deleting
// this file unpins it. Measured: with the state
// removed, the next pass reports the same swap as `high` — an update, because
// the accounting's version is older — and the pass after that reports nothing.
// The next legitimate fleet update clears it the same way.
//
// So its real lifetime is "until a retake, the next version change, OR the loss
// of this file", not the "until somebody retakes the accounting" this and
// deltarun.go used to say. Inside the accepted threat model — a root intruder
// can also stop the daemon — and written down because a load-bearing file
// described as disposable is how the next person deletes it to fix something
// else.
//
// It is still deliberately NOT digest-protected: it lives in the bastion's own
// 0700 state directory, and a digest here would imply a guarantee about
// timestamps that a file in a directory its owner can write cannot make.
//
// IT IS THROWN AWAY WHEN THE BASELINE CHANGES. A retake redefines what normal
// is; grace timers and first-seen stamps measured against the old accounting
// would then be timing things that are no longer being compared.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
)
const deltaStateVersion = "privatae-delta-state-v1"
type deltaState struct {
Version string `json:"version"`
// Baseline is the digest of the accounting this state was kept against.
Baseline string `json:"baseline"`
// MissingSince: entry fingerprint -> when it was first not seen.
MissingSince map[string]string `json:"missing_since,omitempty"`
// FirstSeen / LastSeen: finding fingerprint -> when this box first and last
// saw it. LastSeen is what lets the file be pruned without losing the age
// of anything still current.
FirstSeen map[string]string `json:"first_seen,omitempty"`
LastSeen map[string]string `json:"last_seen,omitempty"`
// SeenKind: finding fingerprint -> the entry kind it belongs to, so a
// prune on a pass that could not see that class does not forget it. A
// fingerprint with no kind predates the listener and identity classes, so
// it is a process or a scheduler, which are never left out one class at a
// time (a failed walk skips the prune altogether).
SeenKind map[string]string `json:"seen_kind,omitempty"`
// Held: fingerprint -> when the short-lived rule first held it back.
Held map[string]string `json:"held,omitempty"`
// ShortLived is the running count of held entries that were gone by the
// next pass — the storm counter, never reset by a report, so a box
// spawning thousands of brief processes says so even though none of them
// is a finding.
ShortLived int `json:"short_lived"`
// BastionDigest / BastionVersion: the bastion binary as the LAST PASS saw
// it. Seeded from the accounting and moved forward on every pass that has
// nothing to report or reports an update — never past a same-version swap,
// which is what pins that critical. The accounting's own copy is frozen at
// the moment it was taken and the fleet updates itself; see deltaBinaryEvent
// for what comparing against a frozen version does to the rung.
BastionDigest string `json:"bastion_digest,omitempty"`
BastionVersion string `json:"bastion_version,omitempty"`
dirty bool
// settledThisPass is how many were counted by THIS pass. Not persisted:
// it is a property of one run, and the running total above is the part
// that outlives it.
settledThisPass int
// blindKinds and blindFPs are the classes THIS pass did not compare, and
// the accounting's fingerprints in them. Not persisted: set by deltaPass
// before the clocks run, a property of one pass. Every clock below reads
// "absent from this pass" as "came back", "settled" or "stale", which is
// only true of a class the pass actually looked at.
blindKinds map[string]bool
blindFPs map[string]bool
}
// deltaStateForget is how long a fingerprint nobody has seen is kept. A week is
// long enough that a laptop shut for the weekend does not come back with every
// finding reading as brand new, and short enough that the file cannot grow
// without bound on a box that churns.
const deltaStateForget = 7 * 24 * time.Hour
func deltaStatePath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "delta-state.json"), nil
}
// deltaStateLoad reads the state, or returns an empty one. An unreadable or
// corrupt file is NOT an error: refusing to run the detection plane because a
// bookkeeping file was truncated would trade the whole feature for the thing it
// is least sure about.
//
// What it costs is one pass with no memory — a gone-grace restarted, a
// first-seen re-stamped — AND, since the bastion's own digest moved in here, an
// unpinned binary critical: the next pass re-reads that swap against the
// accounting's older version, calls it an update, and goes quiet. That is the
// price of failing open here, and it is written down rather than implied.
func deltaStateLoad(baselineDigest string) *deltaState {
fresh := &deltaState{Version: deltaStateVersion, Baseline: baselineDigest, dirty: true}
p, err := deltaStatePath()
if err != nil {
return fresh
}
b, err := os.ReadFile(p)
if err != nil {
return fresh
}
var st deltaState
if json.Unmarshal(b, &st) != nil {
return fresh
}
if st.Version != deltaStateVersion || st.Baseline != baselineDigest {
return fresh
}
return &st
}
func (s *deltaState) save() error {
if !s.dirty {
return nil
}
p, err := deltaStatePath()
if err != nil {
return err
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
if err := os.WriteFile(p, b, 0o600); err != nil {
return err
}
// Re-chmod for the reason baselineSave does (baseline.go:359-362): WriteFile
// applies its mode only on create, so a file first written under a loose
// umask keeps those permissions for ever.
_ = os.Chmod(p, 0o600)
s.dirty = false
return nil
}
func parseStamp(s string) (time.Time, bool) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, false
}
return t, true
}
func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339) }
func (s *deltaState) missingSince(fp string) (time.Time, bool) {
t, ok := parseStamp(s.MissingSince[fp])
return t, ok
}
func (s *deltaState) setMissingSince(fp string, at time.Time) {
if s.MissingSince == nil {
s.MissingSince = map[string]string{}
}
s.MissingSince[fp] = stamp(at)
s.dirty = true
}
// keepMissing drops every absence not in the set — the entries that came back.
func (s *deltaState) keepMissing(still map[string]bool) {
for fp := range s.MissingSince {
if !still[fp] && !s.blindFPs[fp] {
delete(s.MissingSince, fp)
s.dirty = true
}
}
}
// seen records a sighting and returns when this box FIRST saw this fingerprint.
func (s *deltaState) seen(fp string, at time.Time) time.Time {
if s.FirstSeen == nil {
s.FirstSeen = map[string]string{}
}
if s.LastSeen == nil {
s.LastSeen = map[string]string{}
}
first, ok := parseStamp(s.FirstSeen[fp])
if !ok {
first = at
s.FirstSeen[fp] = stamp(at)
}
s.LastSeen[fp] = stamp(at)
s.dirty = true
return first
}
// noteKind records which class a finding fingerprint belongs to (see SeenKind).
func (s *deltaState) noteKind(fp, kind string) {
if kind == "" || s.SeenKind[fp] == kind {
return
}
if s.SeenKind == nil {
s.SeenKind = map[string]string{}
}
s.SeenKind[fp] = kind
s.dirty = true
}
// setBlind tells this pass's clocks which classes it did not compare, and
// which of the accounting's fingerprints belong to them.
func (s *deltaState) setBlind(kinds []string, accounting []baselineEntry) {
s.blindKinds, s.blindFPs = map[string]bool{}, map[string]bool{}
for _, k := range kinds {
s.blindKinds[k] = true
}
for _, e := range accounting {
if s.blindKinds[e.Kind] {
s.blindFPs[e.FP] = true
}
}
}
func (s *deltaState) heldSince(fp string) (time.Time, bool) {
t, ok := parseStamp(s.Held[fp])
return t, ok
}
func (s *deltaState) hold(fp string, at time.Time) {
if s.Held == nil {
s.Held = map[string]string{}
}
if _, already := s.Held[fp]; !already {
s.Held[fp] = stamp(at)
s.dirty = true
}
}
// settleHeld closes out the hold list for one pass: everything still held that
// was NOT seen this time around lived and died between two scans, which is the
// short-lived count. Returns how many were counted this pass.
func (s *deltaState) settleHeld(seenThisPass map[string]bool) int {
n := 0
for fp := range s.Held {
if seenThisPass[fp] {
continue
}
// A listener hold on a pass that could not read the socket table was
// not seen because nothing looked, not because it went away.
if s.blindKinds["listener"] && strings.HasPrefix(fp, deltaListenerHoldPrefix) {
continue
}
delete(s.Held, fp)
s.ShortLived++
n++
s.dirty = true
}
s.settledThisPass = n
return n
}
func (s *deltaState) setBastion(digest, version string) {
if s.BastionDigest == digest && s.BastionVersion == version {
return
}
s.BastionDigest, s.BastionVersion = digest, version
s.dirty = true
}
// release stops holding a fingerprint — it survived, so it is a finding now.
func (s *deltaState) release(fp string) {
if _, held := s.Held[fp]; held {
delete(s.Held, fp)
s.dirty = true
}
}
// prune forgets fingerprints nobody has seen for deltaStateForget.
func (s *deltaState) prune(now time.Time) {
for fp, last := range s.LastSeen {
t, ok := parseStamp(last)
if ok && now.Sub(t) < deltaStateForget {
continue
}
if s.blindKinds[s.SeenKind[fp]] {
continue // not refreshed because its class was not looked at
}
delete(s.LastSeen, fp)
delete(s.FirstSeen, fp)
delete(s.SeenKind, fp)
delete(s.Held, fp)
s.dirty = true
}
}