Files
privatae-bastion/client-go/quarantine.go
T
Privatae 4f8285ef18
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s
bastion 0.60.0
2026-11-02 00:10:58 +00:00

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)
}