Anomaly Detection
Rule/statistics detection of unusual per-identity behavior — rate
spikes, novel tool usage, and deny-rate spikes — plus a fourth,
independently-toggleable ml_score heuristic: a combined z-score over
four per-identity, per-window features (call rate, distinct-tool count,
deny ratio, mean inter-arrival time), each scored against its own
running mean/variance baseline (Welford’s algorithm — no stored history,
no training data). All four are logged for review by default; ml_score
can additionally drive auto_block, which rejects every one of an
identity’s calls for a configured duration once its score clears a
separate, stricter threshold. Enable with:
features:
anomaly_detection: true
anomaly:
output: stdout
window_seconds: 60
rate_spike: {enabled: true, rate_multiplier: 5, min_calls: 10}
novel_tool: {enabled: true}
deny_rate_spike: {enabled: true, threshold: 0.5, min_calls: 10}
ml_score: {enabled: true, score_threshold: 3.0, min_calls: 5}
auto_block: {enabled: true, score_threshold: 8.0, block_duration_seconds: 300}
ml_score.score_threshold must be less than or equal to
auto_block.score_threshold (config validation enforces this) — an
operator can log at a lower sensitivity than they block at, never the
reverse. ml_score needs at least 8 completed windows of history per
identity before it can score anything at all: a 2- or 3-sample stddev is
statistical noise, not signal, and treating it as signal is exactly what
caused ordinary traffic to auto-block early on.
ml_score.min_calls is the matching floor on the window being scored,
rather than on the history behind it: a window with fewer calls than this
is neither scored nor folded into any baseline. A near-empty window drives
a feature to a range extreme for reasons unrelated to behavior — a single
call has no inter-arrival gap at all, so that feature reads as maximally
bursty — so scoring such a window is how an identity that simply went
quiet ends up blocked. min_calls must be at least 2; config validation
rejects 1.
Every identity’s baseline state is garbage-collected on
anomaly.gc_interval_seconds (an entry idle for more than 2x that
interval is dropped, reappearing as “novel” on its next call — the same
conservative posture as a restart). gc_interval_seconds is a
three-way-coupled knob once postgres_storage is also on: it
simultaneously sets (a) this eviction cutoff (2x interval), (b) the
ceiling auto_block.block_duration_seconds may validate against (must
stay <= 2x gc_interval_seconds, so a block always expires before its
identity can go stale enough to be evicted), and (c) checkpoint
frequency (below). Lowering it to shrink the crash-loss window also
makes eviction more aggressive and may force block_duration_seconds
shorter too — tune with all three effects in mind, not just whichever
one motivated the change.
When features.postgres_storage is also on, that same GC tick doubles
as a checkpoint: the full set of per-identity baselines still in memory
is upserted into a shared Postgres table (in batches, each its own
transaction, so an arbitrarily large identity population doesn’t risk
one all-or-nothing transaction timing out) and reloaded once at
startup — so a restart no longer wipes every identity’s history at
once. The same GC pass also deletes the Postgres row for every identity
just evicted from memory, so an evicted identity doesn’t resurrect a
stale baseline from a leftover row on the next restart. Because the save
only happens on the GC tick, not on every call or on shutdown, a
baseline can be up to one gc_interval_seconds stale relative to the
most recent traffic, whether the process ends in a crash or a graceful
stop. This is single-instance persistence, not cross-instance sharing —
every row is keyed on (instance_id, tenant, identity), not just
(tenant, identity), where instance_id defaults to this replica’s own
hostname (the same derivation federation’s own instance ID uses), so
each replica still checkpoints and reloads only the traffic it itself
has seen even when every replica shares one Postgres database (see
“Known limitations” below for what that does and doesn’t cover).
When features.web_ui is also on, the dashboard’s Anomalies panel
gives this feature’s GET /dashboard/api/anomalies a live view in the
UI — see Observability’s “Live dashboard”
section.
Known limitations
- Low-and-slow evasion. Because the baseline is self-learned per
identity (Welford, unsupervised), an attacker who ramps activity
gradually — staying within a few standard deviations of the moving
baseline each window — is never auto-blocked: the baseline adapts
upward and absorbs the ramp. Wardline blocks abrupt deviations, not
patient ones. This is an inherent tradeoff of unsupervised baselining,
not a tunable threshold (tightening it would raise the false-positive
rate the detector is regression-guarded to keep near zero). Both the
abrupt-spike block and the low-and-slow evasion are pinned by tests
(
TestDetector_AutoBlock_AbruptSpikeIsBlockedandTestDetector_AutoBlock_LowAndSlowEvades). Pair anomaly detection with explicit policy and budget limits, which bound absolute behavior regardless of ramp speed. - Scoped to a single identity’s history on a single Wardline instance — no cross-identity or cross-instance correlation. Federation has already shipped (see Roadmap’s “v2.0 (shipped)” section; it has no dedicated docs page yet), but it doesn’t close this gap: it correlates alerts — a fingerprint-count threshold across instances — not raw per-identity call history, so a correlated alert across instances never shares or merges the underlying baseline state itself.
- Baseline state (rate/novel-tool/
ml_scorehistory) resets on restart, in-memory only — UNLESSfeatures.postgres_storageis also on: baselines then persist to a shared Postgres table and reload at startup, checkpointed on the same interval as GC (anomaly.gc_interval_seconds), the same way credential revocation and refresh tokens already do for their own state. See HA deployment and Credential issuance’s sibling Postgres-backed features for the general pattern. Persistence is still per-instance, not per-fleet: it does not share baselines across replicas (each replica keeps learning only from the traffic it itself sees, enforced by keying every row on(instance_id, tenant, identity)— see HA deployment’s per-replica limitations), and there’s no schema-migration mechanism for the persisted JSON shape if it ever needs to change.instance_iddefaults to the replica’s own hostname, so a hostname change (pod recreation on a rolling deploy) orphans that replica’s old rows under the previous hostname — never reloaded again, harmless (same “reappears as novel” fallback as any other eviction). Such orphaned rows, and those of any permanently scaled-down replica, are cleaned up automatically: every live replica’s GC tick prunes baseline rows not re-checkpointed in the last few GC intervals (any instance’s, not just its own). Because a live replica re-upserts all of its own rows every tick, only rows belonging to an instance that has stopped checkpointing entirely ever fall past that cutoff. The one residual limitation is that there’s no schema-migration mechanism for the persisted JSON shape if it ever needs to change. - Currently-blocked identities are surfaced both as the
GET /dashboard/api/anomalies/blockedJSON API and, whenweb_uiis on, a dedicated Blocked panel in the dashboard. A block can be cleared early viaDELETE /dashboard/api/anomalies/blocked/{identity}, gated by the samecredential:revokepermission as credential revocation (whenrbacis on) — otherwise it simply expires once its TTL elapses. This is a shipped capability, listed here only to note the one residual gap: the block store is per-replica unlesspostgres_storageis also on (see HA deployment).