DEV Community

DarkEdges
DarkEdges

Posted on

Three detection layers that disagree usefully, and why they combine by max, not sum

Features get you a vector per window. Turning that into a decision is where the
design choices are.

This system scores every window three independent ways and takes the strongest
single case. Each layer covers a failure mode of the others.

Layer 1: guardrails

Deterministic thresholds, no baseline of any kind:

// Honeytoken hit — highest-confidence signal. Immediate revoke.
if (fv.honeytoken_hits > 0) add(100, 'honeytoken_hits', '');

// High miss ratio — guessing IDs that mostly do not exist.
if (fv.miss_ratio >= 0.4 && fv.req_count >= 10) add(88, 'miss_ratio', '');

// Sequential walk — near-adjacent IDs in order.
if (fv.id_sequentiality >= 0.8 && fv.distinct_resource_ids >= 10)
  add(90, 'id_sequentiality', '');

// Working set that expands and never stops — the mimicry signature.
if (fv.window_size === '1m' && fv.novelty_run_length >= 20)
  add(86, 'novelty_run_length', '');
Enter fullscreen mode Exit fullscreen mode

Being baseline-free is the point: they fire on a client's first window. A
statistical layer needs history to say anything, so a brand-new compromised
integration, one that never had a quiet period to learn from, is invisible to
it. Guardrails cover exactly that gap.

The deliberate omission is cardinality. There is no "distinct IDs > N" guardrail
in the scorer, for the reasons in part 3: it false-positives on legitimate
bulk reads and no threshold fixes that. (The gateway's fast path does have a
cardinality rule, at 150 distinct/minute: well above any realistic backfill, and
it exists to stop a flood before the first window closes.)

Layer 2: robust statistics

Per client, per feature, per window size: keep a bounded history and score new
values with a median/MAD robust z-score.

Median and MAD rather than mean and standard deviation, because mean and σ are
themselves distorted by the outliers you're hunting. One 5,000-request window
drags a mean enough to make the next one look normal.

Three things make this work in practice, and each was added after watching it
misbehave without them.

A scale floor per feature. A client whose interarrival_cv has been exactly
0.55 for a week has a MAD of ~0, so any deviation divides by nearly zero and
produces an enormous z. The floor is the smallest deviation worth treating as
meaningful:

export function robustZFloored(x: number, history: number[], floor: number): number {
  if (history.length < 2) return 0;
  const med = median(history);
  const scale = Math.max(medianAbsoluteDeviation(history), floor);
  return scale > 0 ? (x - med) / scale : 0;
}
Enter fullscreen mode Exit fullscreen mode

Plain robustZ returns 0 when the history has no spread at all, which is exactly
backwards for the case you care about: a perfectly regular client that jumps 10×
is the strongest possible signal, not the weakest.

A gate and a cap. Deviations below |z| = 2.5 contribute nothing; above 8 they
stop growing. Normal variation is not evidence, and one absurd outlier shouldn't
dominate.

A single-feature cap. If only one feature is anomalous, the total is capped
below the escalation threshold. One lone signal doesn't escalate; it needs
corroboration.

Directions are explicit per feature, because they aren't uniform:

const DIRECTION: Partial<Record<FeatureKey, Dir>> = {
  interarrival_cv:        { sign: -1, weight: 10, floor: 0.1  }, // LOW is suspicious
  id_sequentiality:       { sign:  1, weight:  8, floor: 0.15 },
  miss_ratio:             { sign:  1, weight:  8, floor: 0.05 },
  subject_mismatch_ratio: { sign:  1, weight:  9, floor: 0.05 },
  distinct_src_ips:       { sign:  1, weight:  6, floor: 0.5  },
  // …
};
Enter fullscreen mode Exit fullscreen mode

Peer groups

A client with no history of its own gets compared against its peers. But "peers"
has to mean something, pooling every other client means a search integration
is judged against a bulk sync and both look odd.

Clients are grouped by route profile: the smallest set of routes covering
most of their traffic, sorted. Two candidate keys were rejected:

  • Request volume, it would place a flooding attacker among the busiest clients, which is exactly where it looks least remarkable. Grouping by something the attacker controls hands them the choice of jury.
  • The full route set: too sparse. Nearly every client ends up alone, and a peer group of one is the client itself under another name.

The profile is taken from a client's benign history, not the window being
scored, so an attacker can't change groups by changing endpoints mid-attack.

Layer 3: an extended isolation forest

The first two layers each ask a one-dimensional question. Both are structurally
blind to a window that is unremarkable on every axis individually but sits
somewhere no legitimate client has ever been jointly. That joint structure is
the only reason to add a third layer.

The obvious choice is a classic isolation forest (Liu et al.): build trees that
split on one randomly-chosen feature at a random value, and measure how quickly a
point gets isolated. Anomalies are few and different, so they isolate near the
root.

I measured it on this project's own feature space rather than assuming:

inliers jointly-novel window
axis-parallel splits 0.50 0.51
hyperplane splits 0.44 0.66

The classic algorithm gave no separation at all on the case the layer exists
for. That's not a tuning failure, it's structural: isolating a point between
two clusters requires a specific conjunction of axis cuts that a random tree
rarely finds, so it scores no higher than the edges of the clusters themselves.

The fix is the Extended Isolation Forest (Hariri et al.): split on a random
hyperplane, a random normal vector and a random intercept, so one cut can
carve an oblique region.

// Draw a random hyperplane. Only dimensions that vary in this subsample get a
// non-zero coefficient — a constant dimension would just shift the intercept.
const normal = new Array<number>(dims).fill(0);
for (const d of active) {
  if (max > min) { normal[d] = gaussian(rand); any = true; }
}
Enter fullscreen mode Exit fullscreen mode

Because a hyperplane mixes dimensions, features must be standardised first.
Without that, req_count (in the thousands) dominates every dot product and the
forest collapses back into a one-dimensional detector.

Calibration, not constants

An isolation-forest score has no absolute meaning. It shifts with dimensionality,
subsample size, the depth limit, and the shape of whatever benign population
exists. My first implementation used a literal threshold of 0.62 and was simply
wrong for this feature space: every real anomaly landed below it.

So the threshold is read off the model's own training scores: the 99th
percentile, with a floor at 0.5 (the forest's own reference point, below which a
window isolates more slowly than average and cannot be an outlier).

const trainScores = rows.map((r) => forest.score(r)).sort((a, b) => a - b);
const gate = Math.max(quantile(trainScores, 0.99), GATE_FLOOR);
const saturation = gate + Math.max(MIN_RAMP, 1.5 * (p99 - p50));
Enter fullscreen mode Exit fullscreen mode

Roughly 1% of benign windows clear it, whatever the absolute numbers turn out to
be.

Two deliberate constraints

The model is frozen once fitted. Continuously refitting on whatever currently
looks benign is how an anomaly detector gets poisoned: a patient attacker whose
early windows score below the benign threshold gets absorbed into the definition
of normal, and the model then defends their behaviour. The cost is drift, in
production this needs a periodic, human-reviewed refresh against a vetted clean
period.

The ML score is capped below the deny tier. A forest tells you how strange,
never why. It may escalate for attention; a blocking decision should rest on a
signal a human can read off the attribution.

And an honest blind spot: the forest cannot learn "this feature must stay
constant", because a zero-variance dimension offers nothing to split on.
distinct_src_ips is 1 for every window of a well-behaved integration, so 25 of
them is invisible here. That's a division of labour rather than a gap,
constant-in-baseline features are exactly where a median/MAD z-score with a
scale floor excels, and layer 2 weights them.

Combining: max, not sum

const composite = clamp(Math.max(g.score, s.score, m.score), 0, 100);
Enter fullscreen mode Exit fullscreen mode

This surprises people, so: the three layers are three readings of the same
evidence, not three independent pieces of it
. A sequential walk shows up as a
guardrail trip, a timing anomaly, and an isolation-forest outlier, all
describing one fact. Summing them triple-counts it.

Taking the max means the composite is always "the strongest case any single layer
can make". That's also what keeps attribution honest: the score and the reason for
it come from the same place. With a sum you get 94 points from three partial
explanations and nothing you can put in front of a human.

Attribution is the point

Every score carries per-feature attribution: which features contributed how many
points, with their values and z-scores. It's persisted to ClickHouse so any past
decision can be reconstructed.

score=88 tier=deny
  miss ratio 0.90 over 280 reqs
  miss_ratio anomalous (z=18.1, +64)
Enter fullscreen mode Exit fullscreen mode

For the forest, which has no native notion of per-feature contribution, this is
done by ablation: replace one feature with its training median ("what if this
had been typical?") and measure how much of the anomaly disappears. Contributions
are normalised to the points actually awarded, so the attribution always adds up
to the score it explains.

The reason this matters isn't UX. An unexplained score cannot be argued with, and
a detection nobody can argue with is a detection nobody will act on. The first
time an on-call engineer is paged at 3am by "client X scored 87", the only useful
next question is why, and if the answer is "the model said so", the system gets
turned off.

Tiers

The composite maps to a graduated ladder, mirrored exactly in the Rego policy:

  0–29  allow
 30–49  log
 50–69  throttle
 70–84  step_up
 85+    deny
        revoke   (never from score alone — reserved for hard signals)
Enter fullscreen mode Exit fullscreen mode

revoke is unreachable by score. It's reserved for honeytoken hits and explicit
revocation, because those are categorically different kinds of evidence.

What each rung actually does, and the discovery that two of them did nothing at
all for months, is article 7.


Next: the four ways a baseline quietly destroyed the detector built on it.
The most transferable article in the series.

Top comments (0)