hub R-435: each clean-up window explains one fall only; a window stuck open past its deadline explains nothing (security review)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -46,6 +46,9 @@ type OffsiteChecker struct {
|
||||
// R-435: when the hub RECEIVED the report that set lastCounts — the start of the interval whose
|
||||
// clean-up windows may explain the next fall (pinned tiers only, see snapshotDropped).
|
||||
lastCountAt map[string]time.Time
|
||||
// R-435: clean-up windows already spent explaining a fall, per customer — each window explains one
|
||||
// fall only, so the slack before the interval cannot let it excuse a second one (security review).
|
||||
usedWindows map[string]map[int64]bool
|
||||
}
|
||||
|
||||
const defaultOffsiteStaleAfter = 48 * time.Hour
|
||||
@@ -84,6 +87,7 @@ func NewOffsiteChecker(s *store.Store, staleAfter time.Duration, onEvent EventNo
|
||||
store: s, logger: logger, onEvent: onEvent, staleAfter: staleAfter, now: time.Now,
|
||||
fillStates: make(map[string]string), staleStates: make(map[string]string),
|
||||
dropStates: make(map[string]string), lastCounts: make(map[string]int), lastCountAt: make(map[string]time.Time),
|
||||
usedWindows: make(map[string]map[int64]bool),
|
||||
}
|
||||
customers, err := s.GetCustomers()
|
||||
if err != nil {
|
||||
@@ -221,22 +225,22 @@ func (oc *OffsiteChecker) isStale(customerID string, off *offsiteReport) bool {
|
||||
// THE THRESHOLD, AND THE MEASUREMENT IT CAME FROM. Measured over the hub's own `reports` table on
|
||||
// 2026-09-01: 12 898 reports, 4 customers, 2026-06-05 → 2026-09-01.
|
||||
//
|
||||
// * In the whole history there are NINE decreases, and EVERY ONE of them lands exactly on ZERO
|
||||
// - In the whole history there are NINE decreases, and EVERY ONE of them lands exactly on ZERO
|
||||
// (36→0, 18→0 ×2, 15→0, 12→0, 8→0, 3→0). There is not one gradual retention decrease anywhere.
|
||||
// * Every one of those nine predates `stats_known`, i.e. they are the R-331 shape — a zero that
|
||||
// - Every one of those nine predates `stats_known`, i.e. they are the R-331 shape — a zero that
|
||||
// means "could not measure", not "nothing is there". Several carry a declared State
|
||||
// (`needs_credential`, `awaiting_recovery_key`), which says so outright.
|
||||
// * In the window where `stats_known` is TRUE (380 reports across both live boxes) there are ZERO
|
||||
// - In the window where `stats_known` is TRUE (380 reports across both live boxes) there are ZERO
|
||||
// decreases: demo-felhom sat flat at 10, demo-hp moved 67→68→69. Only rises.
|
||||
//
|
||||
// So observed retention churn gives NOTHING to calibrate against, and saying so is the honest answer
|
||||
// rather than inventing a number (R-401's lesson). The threshold is therefore reasoned from what
|
||||
// retention CAN do, not from what it was seen to do:
|
||||
//
|
||||
// the box runs `forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --group-by host,tags`
|
||||
// over ~8 apps. On a boundary day several groups can expire at once, so a legitimate pass can
|
||||
// plausibly remove low double digits. **What it can NEVER do is halve the total**: keeping 7 daily
|
||||
// + 4 weekly + 6 monthly per group is a floor, and a mass deletion goes to ~0.
|
||||
// the box runs `forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --group-by host,tags`
|
||||
// over ~8 apps. On a boundary day several groups can expire at once, so a legitimate pass can
|
||||
// plausibly remove low double digits. **What it can NEVER do is halve the total**: keeping 7 daily
|
||||
// + 4 weekly + 6 monthly per group is a floor, and a mass deletion goes to ~0.
|
||||
//
|
||||
// Hence: **a fall of MORE THAN HALF the previous count, and at least 5 snapshots.** The 50% cannot be
|
||||
// reached by retention; the floor of 5 stops a tiny-count box alarming on ordinary ageing. It is
|
||||
@@ -318,7 +322,19 @@ func (oc *OffsiteChecker) snapshotDropped(customerID string, off *offsiteReport,
|
||||
drop := prev - off.SnapshotCount
|
||||
if drop > 0 && oc.pinnedTier(customerID) {
|
||||
from := oc.lastCountAt[customerID].Add(-windowSlack)
|
||||
removed, unknown := oc.store.RemovedByWindowsBetween(customerID, from, reportAt)
|
||||
credits, unknown := oc.store.WindowCreditsBetween(customerID, from, reportAt)
|
||||
used := oc.usedWindows[customerID]
|
||||
if used == nil {
|
||||
used = make(map[int64]bool)
|
||||
oc.usedWindows[customerID] = used
|
||||
}
|
||||
removed := 0
|
||||
for _, c := range credits {
|
||||
if !used[c.ID] {
|
||||
removed += c.Explains
|
||||
used[c.ID] = true // spent on this fall, explained or not
|
||||
}
|
||||
}
|
||||
if !unknown {
|
||||
if drop > removed {
|
||||
return true, prev, off.SnapshotCount, true
|
||||
@@ -449,6 +465,7 @@ func (oc *OffsiteChecker) Check() {
|
||||
delete(oc.dropStates, c.CustomerID)
|
||||
delete(oc.lastCounts, c.CustomerID) // no baseline survives a vanished object
|
||||
delete(oc.lastCountAt, c.CustomerID)
|
||||
delete(oc.usedWindows, c.CustomerID)
|
||||
continue
|
||||
}
|
||||
if oc.store.IsCustomerBlocked(c.CustomerID) {
|
||||
@@ -457,6 +474,7 @@ func (oc *OffsiteChecker) Check() {
|
||||
delete(oc.dropStates, c.CustomerID)
|
||||
delete(oc.lastCounts, c.CustomerID)
|
||||
delete(oc.lastCountAt, c.CustomerID)
|
||||
delete(oc.usedWindows, c.CustomerID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -524,6 +542,7 @@ func (oc *OffsiteChecker) Check() {
|
||||
delete(oc.dropStates, k)
|
||||
delete(oc.lastCounts, k)
|
||||
delete(oc.lastCountAt, k)
|
||||
delete(oc.usedWindows, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,38 @@ func TestR435_LyingWindowExplainsOnlyItsCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Security review 2026-10-08: one window explains ONE fall. A window that removed 9 explains 69 → 60; a
|
||||
// second fall 60 → 55 in the next report (inside the 2-h slack, no new window) alarms.
|
||||
// RED-PROOF: stop marking windows as spent (usedWindows) → the second fall is explained again → 0 alarms → FAILS.
|
||||
func TestR435_OneWindowExplainsOneFall(t *testing.T) {
|
||||
st := newDiskStore(t)
|
||||
cust := "p10"
|
||||
if err := st.RecordOffsiteKeyInstalled(cust, "fp-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := st.RecordOffsiteKeyConfirmed(cust, "fp-1"); err != nil || !ok {
|
||||
t.Fatalf("confirm: %v %v", ok, err)
|
||||
}
|
||||
var msgs []string
|
||||
saveOffsiteReport(t, st, cust, dropJSON(69, true, "", "ok"))
|
||||
oc := NewOffsiteChecker(st, 48*time.Hour, func(_, et, _, msg, _, _ string) {
|
||||
if et == "offsite_snapshots_dropped" {
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
}, quietLog())
|
||||
closedWindow(t, cust, 69, 60)(st)
|
||||
saveOffsiteReport(t, st, cust, dropJSON(60, true, "", "ok"))
|
||||
oc.Check()
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("the window explains 69 -> 60; got %d alarm(s)", len(msgs))
|
||||
}
|
||||
saveOffsiteReport(t, st, cust, dropJSON(55, true, "", "ok"))
|
||||
oc.Check()
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("a spent window must not explain the next fall 60 -> 55; got %d alarm(s)", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// Controls: the half-rule still governs a non-pinned tier, and an installed-but-unconfirmed key.
|
||||
func TestR435_NotPinnedKeepsHalfRule(t *testing.T) {
|
||||
if msgs := r435Run(t, "n1", r435Setup{next: dropJSON(60, true, "", "ok")}); len(msgs) != 0 {
|
||||
|
||||
Reference in New Issue
Block a user