DEV Community

DarkEdges
DarkEdges

Posted on

Four ways a baseline quietly destroys the anomaly detector built on it

Every anomaly detector answers one question: compared to what?

That comparison, the baseline, is where I lost the most time on this project,
and every failure had the same signature. Nothing errored. No test went red. The
numbers stayed plausible. The detector just quietly stopped detecting.

Four of them, in the order I found them.

1. The peer group contained the client it was judging

Cold-start clients have no history, so they're compared against a pool of other
clients' recent benign windows. Reasonable.

The pool was keyed by feature:

private readonly peer = new Map<FeatureKey, number[]>();
Enter fullscreen mode Exit fullscreen mode

Every benign window every client produced went into the pool that client was
later compared against. Including itself.

So a client could define its own normality. Feed in enough windows and any
behaviour becomes unremarkable, which is precisely the cold-start attacker the
layer exists to catch.

What made me look was not reasoning, it was an experiment that wouldn't sit
still. I was trying to build a demo client that reliably landed in the middle of
the response ladder, and holding the traffic shape fixed while changing only the
request interval flipped the outcome between allow and step_up:

gap=500ms  origins=5  →  allow  (peak 0)
gap=700ms  origins=5  →  step_up (peak 83)
gap=800ms  origins=5  →  allow  (peak 0)
Enter fullscreen mode Exit fullscreen mode

A knife edge like that is never a tuning problem. The outcome depended on a race
between a client's own samples reaching the pool and the pool being consulted.

Fix: key the pool per client, and exclude the client under evaluation.

for (const [clientId, values] of byClient) {
  if (clientId === excludeClientId) continue;   // this is what "peer" means
  
}
Enter fullscreen mode Exit fullscreen mode

Afterwards the behaviour became monotone in the actual evidence, and identical at
every request interval:

origins 1 3 4 5 6
peak score 17 35 59 83 100
tier allow log throttle step_up deny

Lesson: if a parameter that shouldn't matter changes the outcome, stop tuning
and go find the defect. Knife edges are symptoms.

2. Baselines weren't keyed by window size

Same investigation, second defect. The per-client history was keyed by
(clientId, feature), with no window size.

req_rate over a minute and req_rate over an hour are not the same quantity.
Neither is distinct_src_ips, or anything else. All three window sizes were
pooling into one array, producing a baseline that described nothing.

It also filled the "enough samples to use own history" threshold three times too
fast, so clients switched off the peer baseline long before they had a meaningful
one of their own.

An unglamorous bug, and the kind that hides indefinitely because every individual
number still looks sane.

3. The boiling frog

This one is my favourite, because the code documented the correct behaviour and
then didn't implement it.

scorer.ts said:

// A window is treated as benign (and folded into baselines) only well below the
// throttle tier, so attack windows never poison the peer baseline, a client's
// own history, or the isolation forest's training set.
const BENIGN_MAX_SCORE = 40;
Enter fullscreen mode Exit fullscreen mode

And learn() did this:

learn(fv: FeatureVector, benign: boolean): void {
  for (const feature of ) {
    ownHist.push(value);          // ← unconditional
    if (benign) { peerHist.push(value); }
  }
}
Enter fullscreen mode Exit fullscreen mode

Own history was updated regardless. The peer pool was protected; the client's own
baseline was not.

The consequence is a textbook boiling frog. A sustained anomaly gets absorbed
into the client's own history within MIN_OWN_SAMPLES windows, the robust
z-score collapses toward zero, and the layer falls silent on exactly the thing
it was watching
.

Measured before and after, on an anomalous client run for 26 windows:

  • before: flagged for 6 windows, then never again
  • after: flagged for 23 of 26

The fix is one line, if (!benign) return;, and the tradeoff is real and worth
stating: a persistently flagged client now never rebuilds its own baseline and
keeps being measured against its peers until it behaves normally again. For a
false positive that means staying flagged. That's survivable here only because
the response is graduated: throttle and step_up are recoverable, so a
wrongly-flagged client is slowed and challenged, not cut off.

Lesson: when a comment states an invariant, that's a test waiting to be
written. This one was a lie for months.

4. The all-time set that should have been a trailing window

novelty_ratio, the share of object IDs a client hasn't seen before, needs a
set of previously-seen IDs. Mine was a Set<string> that only ever grew.

It was wrong twice over.

Unbounded memory. One entry per distinct resource ID, per client, per window
size. The sequential attacker alone adds ~4,800 in a single demo run. Every
other baseline in the pipeline was capped: the route mix, the seasonal history,
the peer pool, the z-score history, the token tracker's eviction. This one, the
largest of them, was not.

Wrong semantics. The spec defines novelty against the trailing 7 days. Mine
was all-time, so nothing ever became novel again and novelty decayed monotonically
toward zero.

The second one is the dangerous one, and it's a slow-acting poison:
novelty_run_length is the only thing that catches mimicry, and it requires
novelty_ratio ≥ 0.15. On a long-running deployment that threshold quietly stops
being reachable, and mimicry detection degrades to nothing. A thirty-minute demo
cannot show it. Nothing would ever have raised a complaint.

Fix: a trailing horizon with a hard cap behind it.

And writing the test for it immediately found a second bug: the horizon was
applied after the window was scored, so a window could be measured against IDs
that had already expired. It's applied first now; the question a window asks is
"what has this client seen in the trailing 7d as of now".

That's worth pausing on. The test didn't just confirm the fix; it found a
different bug in the fix. The behaviour was subtle enough that I'd have shipped it.

5. (Bonus) The feedback loop I created myself

Not a baseline exactly, but the same family, and the most instructive because I
built it deliberately and it took a measurement to notice.

The step_up tier forces a client to re-authenticate. Re-authentication raises
token_issuance_rate. The scorer weights token_issuance_rate. A higher score
escalates the client to deny.

So: the system challenges a client → the client complies → the system convicts it
for complying.

The demo's ambiguous automation was being denied at a score of 88, with +21 of
it coming from the token issuance its own challenge had caused.

The fix is to mark issuances made under an outstanding challenge and exclude
them: the same reasoning that keeps the policy's own decision out of the
detection projection. Feeding your own output back in as a feature lets the
detector confirm its own suspicions: one spurious escalation justifies the next,
and the score drifts away from the traffic it's supposed to describe.

After the fix that client peaked at 66 instead of 88, and stopped being denied.

What these have in common

None of the five raised an error. None turned a test red. In every case the
system kept producing scores that looked entirely reasonable.

Three practical habits came out of it:

Measure before you tune. Three of these were found because a number moved when
it shouldn't have, or didn't move when it should. Every time I reached for a
threshold first, the threshold was the wrong tool.

Treat comments as unwritten tests. The boiling frog was documented correctly
and implemented wrongly, and lived for months in a file I'd read a dozen times.

Ask what the baseline contains. Almost every failure here was the baseline
absorbing the thing it was meant to measure against: the client itself, its own
anomalies, its own history without bound, or the system's own output. If you build
one of these, write down explicitly what the comparison population is, what it
excludes, and how it ages. Then check the code agrees.


Next: honeytokens: decoys derived so they recognise themselves and attribute
their own trips, why recognition lives in the gateway, and what an allowlist
does and doesn't buy.

Top comments (0)