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