254 lines
7.8 KiB
Go
254 lines
7.8 KiB
Go
package main
|
|
|
|
// quarantine.go — the box notices abuse of its management lane and closes the
|
|
// lane itself.
|
|
//
|
|
// Two records, both on the enforcing report every beat:
|
|
//
|
|
// - a RING of the last refusals on the management lane (not only the last
|
|
// directive refusal directive_refusal.go keeps): who asked, for what, why
|
|
// it was refused, when. The host turns each into an oversight finding.
|
|
// - a QUARANTINE per pairing: five refused WRITE requests from one pairing
|
|
// inside ten minutes close that pairing's write lanes on this box for ten
|
|
// minutes, doubling on every repeat. fail2ban's defaults (5 tries / 10 min
|
|
// / 10 min ban), not numbers invented here. Reads stay open, so the host
|
|
// can still see the finding that explains the refusals; the person at the
|
|
// machine can clear it early at the console.
|
|
//
|
|
// The probe pattern of 2026-09-24 — refused a read, try a machine, try another
|
|
// lane — becomes something the machine itself notices and stops, rather than
|
|
// only something the host is told not to do.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
quarantineStrikes = 5
|
|
quarantineWindow = 10 * time.Minute
|
|
quarantineBase = 10 * time.Minute
|
|
quarantineMaxHold = 24 * time.Hour // the doubling stops here
|
|
quarantineForgive = 24 * time.Hour // a clean day resets the doubling
|
|
refusalRingMax = 50
|
|
quarantineFileName = "quarantine.json"
|
|
)
|
|
|
|
var quarantineMu sync.Mutex
|
|
|
|
type refusalRecord struct {
|
|
TS string `json:"ts"`
|
|
Pairing string `json:"pairing"`
|
|
Op string `json:"op"`
|
|
Why string `json:"why"`
|
|
Write bool `json:"write"`
|
|
}
|
|
|
|
type quarantineEntry struct {
|
|
Until string `json:"until"` // RFC3339; "" = not quarantined
|
|
Strikes int `json:"strikes"` // how many holds so far (doubling)
|
|
Refusals []string `json:"refusals,omitempty"` // RFC3339 of recent write refusals
|
|
// LastHold is when the last hold ENDED. A pairing that has behaved for
|
|
// quarantineForgive since then starts again at the base hold — strikes
|
|
// that never decayed would make a hold months later a day long.
|
|
LastHold string `json:"last_hold,omitempty"`
|
|
}
|
|
|
|
type quarantineFile struct {
|
|
Ring []refusalRecord `json:"ring"`
|
|
Pairing map[string]*quarantineEntry `json:"pairing"`
|
|
}
|
|
|
|
func quarantinePath() (string, error) {
|
|
d, err := configDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(d, quarantineFileName), nil
|
|
}
|
|
|
|
func quarantineLoad() *quarantineFile {
|
|
f := &quarantineFile{Pairing: map[string]*quarantineEntry{}}
|
|
p, err := quarantinePath()
|
|
if err != nil {
|
|
return f
|
|
}
|
|
raw, err := os.ReadFile(p)
|
|
if err == nil {
|
|
_ = json.Unmarshal(raw, f)
|
|
}
|
|
if f.Pairing == nil {
|
|
f.Pairing = map[string]*quarantineEntry{}
|
|
}
|
|
return f
|
|
}
|
|
|
|
func quarantineSave(f *quarantineFile) error {
|
|
p, err := quarantinePath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := json.MarshalIndent(f, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(p, raw)
|
|
}
|
|
|
|
// refusalCounts: does this refusal count as a strike? A refused WRITE that
|
|
// the host could have known better than to ask for — an approval that did
|
|
// not check out, a family it does not hold, a switched-off lane. NOT a read;
|
|
// NOT an op this build does not know (version skew between host and box is
|
|
// not the host probing); NOT "this box holds no way to take an approval",
|
|
// which is the box's own state and would otherwise let a fresh fleet box
|
|
// quarantine its anchor on the first five writes after the roll.
|
|
func refusalCounts(op, why string) bool {
|
|
if !opWrites(op) {
|
|
return false
|
|
}
|
|
if strings.Contains(why, noWayToApprove) || strings.HasSuffix(why, "not permitted on this host") ||
|
|
strings.Contains(why, noSandboxHere) || strings.Contains(why, noVerbToolHere) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// noteRefusal records one refusal on the lane. A counting refusal (see
|
|
// refusalCounts) from a named pairing goes toward that pairing's quarantine;
|
|
// everything else is recorded and never counted.
|
|
func noteRefusal(pairing, op, why string, write bool, now time.Time) {
|
|
quarantineMu.Lock()
|
|
defer quarantineMu.Unlock()
|
|
f := quarantineLoad()
|
|
f.Ring = append(f.Ring, refusalRecord{TS: now.UTC().Format(time.RFC3339),
|
|
Pairing: pairing, Op: truncate(op, mgmtAuditOpMax), Why: truncate(why, mgmtAuditWhyMax), Write: write})
|
|
if len(f.Ring) > refusalRingMax {
|
|
f.Ring = f.Ring[len(f.Ring)-refusalRingMax:]
|
|
}
|
|
if write && pairing != "" {
|
|
e := f.Pairing[pairing]
|
|
if e == nil {
|
|
e = &quarantineEntry{}
|
|
f.Pairing[pairing] = e
|
|
}
|
|
e.strike(now)
|
|
}
|
|
_ = quarantineSave(f)
|
|
}
|
|
|
|
// strike counts one refusal against e and starts a hold at the
|
|
// quarantineStrikes-th inside the window. Reports whether a hold started.
|
|
// Shared with the local askers' holds (askquarantine.go).
|
|
func (e *quarantineEntry) strike(now time.Time) bool {
|
|
// A refusal DURING a hold is the hold working, not a new strike: the
|
|
// quarantine refusal itself comes through here, and counting it would
|
|
// re-arm the hold the moment it lapsed.
|
|
if quarantinedAt(e, now) {
|
|
return false
|
|
}
|
|
// Recent refusals only — the window slides.
|
|
kept := e.Refusals[:0]
|
|
for _, ts := range e.Refusals {
|
|
if t, err := time.Parse(time.RFC3339, ts); err == nil && now.Sub(t) < quarantineWindow {
|
|
kept = append(kept, ts)
|
|
}
|
|
}
|
|
e.Refusals = append(kept, now.UTC().Format(time.RFC3339))
|
|
if len(e.Refusals) < quarantineStrikes {
|
|
return false
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, e.LastHold); err == nil && now.Sub(t) > quarantineForgive {
|
|
e.Strikes = 0
|
|
}
|
|
hold := quarantineBase
|
|
for i := 0; i < e.Strikes && hold < quarantineMaxHold; i++ {
|
|
hold *= 2
|
|
}
|
|
if hold > quarantineMaxHold {
|
|
hold = quarantineMaxHold
|
|
}
|
|
e.Strikes++
|
|
e.Until = now.Add(hold).UTC().Format(time.RFC3339)
|
|
e.LastHold = e.Until
|
|
e.Refusals = nil
|
|
return true
|
|
}
|
|
|
|
func quarantinedAt(e *quarantineEntry, now time.Time) bool {
|
|
if e == nil || e.Until == "" {
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, e.Until)
|
|
return err == nil && t.After(now)
|
|
}
|
|
|
|
// quarantinedUntil: when this pairing's write lanes reopen, or the zero time.
|
|
func quarantinedUntil(pairing string, now time.Time) time.Time {
|
|
if pairing == "" {
|
|
return time.Time{}
|
|
}
|
|
quarantineMu.Lock()
|
|
defer quarantineMu.Unlock()
|
|
e := quarantineLoad().Pairing[pairing]
|
|
if !quarantinedAt(e, now) {
|
|
return time.Time{}
|
|
}
|
|
t, _ := time.Parse(time.RFC3339, e.Until)
|
|
return t
|
|
}
|
|
|
|
// quarantineClear — the person at the machine lifts the hold (and the strike
|
|
// count with it: a cleared hold is forgiven, not merely paused).
|
|
func quarantineClear(pairing string) {
|
|
quarantineMu.Lock()
|
|
defer quarantineMu.Unlock()
|
|
f := quarantineLoad()
|
|
delete(f.Pairing, pairing)
|
|
_ = quarantineSave(f)
|
|
}
|
|
|
|
// quarantineReport is the enforcing-report fragment: the ring and the live
|
|
// holds. nil when there is nothing to say, so a quiet box says nothing.
|
|
func quarantineReport(now time.Time) map[string]any {
|
|
quarantineMu.Lock()
|
|
defer quarantineMu.Unlock()
|
|
f := quarantineLoad()
|
|
holds := map[string]any{}
|
|
for p, e := range f.Pairing {
|
|
if quarantinedAt(e, now) {
|
|
holds[p] = map[string]any{"until": e.Until, "strikes": e.Strikes}
|
|
}
|
|
}
|
|
if len(f.Ring) == 0 && len(holds) == 0 {
|
|
return nil
|
|
}
|
|
ring := f.Ring
|
|
if len(ring) > 20 { // the beat carries the newest twenty; audit_fetch has the rest
|
|
ring = ring[len(ring)-20:]
|
|
}
|
|
out := map[string]any{"refusals": ring}
|
|
if len(holds) > 0 {
|
|
names := make([]string, 0, len(holds))
|
|
for p := range holds {
|
|
names = append(names, p)
|
|
}
|
|
sort.Strings(names)
|
|
out["quarantine"] = holds
|
|
out["quarantined"] = names
|
|
}
|
|
return out
|
|
}
|
|
|
|
func quarantineRefusal(until time.Time) string {
|
|
return fmt.Sprintf("refused: this host's write lanes are quarantined on this machine until %s — "+
|
|
"%d refused writes inside %s. Reads still answer; the person at the machine can lift it "+
|
|
"(privatae-bastion quarantine clear <host>).", until.UTC().Format(time.RFC3339),
|
|
quarantineStrikes, quarantineWindow)
|
|
}
|