<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DarkEdges</title>
    <description>The latest articles on DEV Community by DarkEdges (@darkedges).</description>
    <link>https://dev.to/darkedges</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1396307%2F1b5a1a60-77b1-40cd-89e3-0cfb704caf5a.jpeg</url>
      <title>DEV Community: DarkEdges</title>
      <link>https://dev.to/darkedges</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/darkedges"/>
    <language>en</language>
    <item>
      <title>My detector caught the attacker and never once stopped it, and reported PASS</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:57:39 +0000</pubDate>
      <link>https://dev.to/darkedges/my-detector-caught-the-attacker-and-never-once-stopped-it-and-reported-pass-1eng</link>
      <guid>https://dev.to/darkedges/my-detector-caught-the-attacker-and-never-once-stopped-it-and-reported-pass-1eng</guid>
      <description>&lt;p&gt;The most consequential bug in this project had been there since the beginning,&lt;br&gt;
survived several full end-to-end runs, and was reported as a &lt;strong&gt;PASS&lt;/strong&gt; every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  slow-and-low detected within 30m (7.3m), never exceeding legit rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is true. The scorer flagged it correctly, well inside the bound. What&lt;br&gt;
the line doesn't say is that the attacker was &lt;strong&gt;served every single request it&lt;br&gt;
ever made&lt;/strong&gt;. Zero non-allow decisions, across the entire scenario. Detected and&lt;br&gt;
never once stopped.&lt;/p&gt;

&lt;p&gt;This is one instance of a pattern that accounts for more real bugs in this&lt;br&gt;
project than every other cause combined: &lt;strong&gt;a component contributes nothing, no&lt;br&gt;
error is raised, and every surrounding number stays plausible.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The scorer computes windows at 1m, 5m and 1h, and publishes each result to a&lt;br&gt;
per-client key in Redis and OPA.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each&lt;/em&gt; result. To the &lt;em&gt;same&lt;/em&gt; key.&lt;/p&gt;

&lt;p&gt;So the last writer won. And 1-minute windows close most often, so they always&lt;br&gt;
won.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;slow-and-low&lt;/code&gt; issues about two requests a minute. Its 1m windows fall below the&lt;br&gt;
minimum request count and score zero. Its 5m and 1h windows accumulate the miss&lt;br&gt;
ratio that earns a &lt;code&gt;deny&lt;/code&gt;. Every one of those zeroes immediately overwrote the&lt;br&gt;
deny.&lt;/p&gt;

&lt;p&gt;The entire premise of a multi-scale pipeline, that different attacks are visible&lt;br&gt;
at different scales, was silently violated by the publication step. Any detection&lt;br&gt;
that only appeared at a coarser scale was discarded.&lt;/p&gt;

&lt;p&gt;The fix is a roll-up: publish the most severe verdict across window sizes within&lt;br&gt;
the freshness horizon the policy already uses. Afterwards, the same attacker is&lt;br&gt;
denied on 24–31 of its 44 requests.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it survived so long
&lt;/h2&gt;

&lt;p&gt;Because &lt;strong&gt;the report could not express it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Detection latency was computed as the &lt;em&gt;earlier&lt;/em&gt; of two very different facts: the&lt;br&gt;
scorer's first non-allow window, and the gateway's first non-allow decision.&lt;br&gt;
Printed under one heading, &lt;code&gt;detected&lt;/code&gt;, a client that was noticed but never&lt;br&gt;
touched looked identical to one that was noticed and blocked.&lt;/p&gt;

&lt;p&gt;A report that averages over the distinction you are trying to verify cannot&lt;br&gt;
verify it.&lt;/p&gt;

&lt;p&gt;The report now derives two facts from two sources and asserts both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;detected&lt;/strong&gt;: the scorer's first non-allow window, from &lt;code&gt;risk_scores&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;enforced&lt;/strong&gt;: the first request the gateway applied a non-allow tier to, from
the access log, which is the record of what actually happened
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  slow-and-low responded within 30.0m (6.3m; scorer 6.3m, gateway 6.5m)
✓ PASS  slow-and-low ENFORCED — 31/44 requests stopped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Splitting them paid for itself immediately, and not only on the bug it was built&lt;br&gt;
for. Look at the dictionary profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  dictionary responded within 60s (9s; scorer 76s, gateway 9s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Enforced at 9 seconds, detected at 76.&lt;/strong&gt; The gateway's fast path stopped it long&lt;br&gt;
before the windowed scorer produced a single verdict. Enforcement &lt;em&gt;preceding&lt;/em&gt;&lt;br&gt;
detection is not an anomaly: it's the fast path doing its job, and the old&lt;br&gt;
report was structurally incapable of showing it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same shape, five more times
&lt;/h2&gt;

&lt;p&gt;Once I had a name for it, it was everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables with no writer.&lt;/strong&gt; &lt;code&gt;access_events&lt;/code&gt;: the raw log the whole "every decision&lt;br&gt;
is reconstructable" claim rests on, was a schema definition and a materialized&lt;br&gt;
view with no producer. Discovered only when I built a dataset export on top of it&lt;br&gt;
and it came back empty. Then &lt;code&gt;detection_labels&lt;/code&gt;, same thing. Then&lt;br&gt;
&lt;code&gt;client_minute_card&lt;/code&gt;, a materialized view aggregating cardinality per client per&lt;br&gt;
minute, populating on every single event, queried by nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An environment-dependent derivation.&lt;/strong&gt; The label-joining query took its window&lt;br&gt;
length from &lt;code&gt;WINDOW_MS&lt;/code&gt;, which is divided by &lt;code&gt;DEMO_SPEED&lt;/code&gt;. The demo sets that to&lt;br&gt;
8; the export script doesn't set it at all. So the export bucketed events into&lt;br&gt;
60-second windows against 7.5-second data and matched almost nothing: &lt;strong&gt;172 of 197&lt;br&gt;
labels silently vanished&lt;/strong&gt;, and the CSV still looked perfectly well-formed, just&lt;br&gt;
smaller.&lt;/p&gt;

&lt;p&gt;The fix generalises. Derive window lengths &lt;strong&gt;from the data&lt;/strong&gt;, since&lt;br&gt;
&lt;code&gt;client_features&lt;/code&gt; records both ends of every window, and never from the&lt;br&gt;
environment that happens to be reading it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Untyped tests.&lt;/strong&gt; The package builds exclude &lt;code&gt;**/*.test.ts&lt;/code&gt;, correctly, so tests&lt;br&gt;
aren't emitted into &lt;code&gt;dist/&lt;/code&gt;. But that also excluded them from &lt;em&gt;type checking&lt;/em&gt;.&lt;br&gt;
Three times a test fixture went stale after a field was added: the missing&lt;br&gt;
property became &lt;code&gt;undefined&lt;/code&gt;, &lt;code&gt;Number(undefined)&lt;/code&gt; became &lt;code&gt;NaN&lt;/code&gt;, and the failure&lt;br&gt;
surfaced as an apparent detector regression.&lt;/p&gt;

&lt;p&gt;The verification is the part worth keeping. Delete one field from one fixture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tsc &lt;span class="nt"&gt;-p&lt;/span&gt; tsconfig.tests.json
&lt;span class="go"&gt;ml.test.ts(17,3): error TS2719: Types of property 'route_profile' are
  incompatible. Type 'string | undefined' is not assignable to type 'string'.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vitest run packages/scorer/src/ml.test.ts
&lt;span class="go"&gt;      Tests  15 passed (15)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type checker names the field and the file. The test suite reports fifteen&lt;br&gt;
passes. &lt;strong&gt;A test asserting on a &lt;code&gt;NaN&lt;/code&gt; that flows through the arithmetic without&lt;br&gt;
throwing is not a test failure: it's a test quietly measuring nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A component that produced nothing at all.&lt;/strong&gt; At &lt;code&gt;DEMO_SPEED=8&lt;/code&gt; a 1-hour window is&lt;br&gt;
450 real seconds and the scenario runs for 30 scenario-minutes, half of one&lt;br&gt;
window. Whether &lt;em&gt;any&lt;/em&gt; 1h window closes depends on where the run falls relative to&lt;br&gt;
a boundary. Most runs produce zero. The isolation forest can never fit a 1h model.&lt;br&gt;
A third of the advertised &lt;code&gt;1m/5m/1h&lt;/code&gt; pipeline contributes nothing, and said&lt;br&gt;
nothing about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A duplicated definition that drifted.&lt;/strong&gt; The dashboard keeps its own list of&lt;br&gt;
which clients are hostile, because it's a separate build. That copy drifted twice&lt;br&gt;
while I was adding clients, and the failure is quiet in the dangerous direction:&lt;br&gt;
an unlisted attacker defaults to "legitimate", so its every escalation is scored&lt;br&gt;
as a false positive and the confusion matrix reports numbers that never happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this class specifically
&lt;/h2&gt;

&lt;p&gt;Two structural reasons, and both are common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline swallows errors.&lt;/strong&gt; Insert failures are caught and dropped, because&lt;br&gt;
a detection layer should not take down the API it protects. That's the right&lt;br&gt;
call, and it converts every write failure into silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The report was derived from the components it was meant to verify.&lt;/strong&gt; Detection&lt;br&gt;
latency came from the scorer's own records. If the scorer says it flagged&lt;br&gt;
something, the report said it was detected. The one thing that could have&lt;br&gt;
contradicted it, what the gateway actually did, wasn't consulted.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually helped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make absence visible.&lt;/strong&gt; The report now prints how many windows each size&lt;br&gt;
produced, and says so explicitly when one produced none:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;labelled windows (detection_labels): 197   [1mx160  5mx37  1hx0]
  note: 1h produced no verdicts — the scenario is 30 scenario-minutes long,
  shorter than those windows. Run a longer one with e.g. SCENARIO_MIN=130.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Silent absence is indistinguishable from silent breakage. A component that&lt;br&gt;
contributes nothing should say so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert against the independent record.&lt;/strong&gt; Not the detector's own output. The&lt;br&gt;
access log is what the gateway did; &lt;code&gt;risk_scores&lt;/code&gt; is what the scorer concluded.&lt;br&gt;
They are different facts and deserve different assertions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard the guard.&lt;/strong&gt; The report is now only as trustworthy as the access log it&lt;br&gt;
reads: a new single point of failure. So the agents keep their own request counts&lt;br&gt;
purely to cross-check it, and the report warns when the two disagree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin duplication you can't remove.&lt;/strong&gt; The dashboard's copy of ground truth can't&lt;br&gt;
be eliminated without restructuring the build graph. The &lt;em&gt;drift&lt;/em&gt; can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lists exactly the attack clients, no more and no fewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dashboardAttackers&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;ATTACK_CLIENT_IDS&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Type-check the code you excluded from the build.&lt;/strong&gt; &lt;code&gt;tsconfig.tests.json&lt;/code&gt;, with&lt;br&gt;
&lt;code&gt;noEmit&lt;/code&gt;, wired into &lt;code&gt;pnpm test&lt;/code&gt;. It has caught a stale fixture in every round&lt;br&gt;
since.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question worth asking first
&lt;/h2&gt;

&lt;p&gt;The habit I'd most like to pass on is a change in first instinct.&lt;/p&gt;

&lt;p&gt;When something looks off in a system like this, the reflex is "where's the logic&lt;br&gt;
error?" For six consecutive bugs here, the productive question was different:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Which component is silently doing nothing?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not &lt;em&gt;what is wrong with this calculation&lt;/em&gt;, but &lt;em&gt;is this calculation happening at&lt;br&gt;
all&lt;/em&gt;. Every one of these bugs produced plausible output. Several produced output&lt;br&gt;
that was internally consistent. One reported PASS on the exact scenario it was&lt;br&gt;
failing.&lt;/p&gt;

&lt;p&gt;The corollary, if you build detection systems: your instrumentation is a component&lt;br&gt;
too, and it fails the same way. Ask what your report &lt;em&gt;cannot express&lt;/em&gt;, because&lt;br&gt;
that's the shape of the bug you won't find.&lt;/p&gt;




&lt;p&gt;That's the series. The code is a demonstration artifact, deliberately readable,&lt;br&gt;
deliberately argued with, and honest about the parts that don't work. The&lt;br&gt;
&lt;code&gt;docs/DECISIONS.md&lt;/code&gt; file in the repo is 28 sections, and most of them record&lt;br&gt;
something that was wrong and how it was found. That turned out to be the most&lt;br&gt;
useful thing in it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>debugging</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A graduated response ladder where every rung is invisible</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:57:18 +0000</pubDate>
      <link>https://dev.to/darkedges/a-graduated-response-ladder-where-every-rung-is-invisible-46bc</link>
      <guid>https://dev.to/darkedges/a-graduated-response-ladder-where-every-rung-is-invisible-46bc</guid>
      <description>&lt;p&gt;Detection produces a number. Something has to turn that number into a response,&lt;br&gt;
and the response has two hard constraints that pull against each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It must be &lt;strong&gt;proportionate&lt;/strong&gt;. A score of 55 is not a score of 95, and treating
them the same means either blocking clients you shouldn't or serving attackers
you shouldn't.&lt;/li&gt;
&lt;li&gt;It must be &lt;strong&gt;invisible&lt;/strong&gt;. An attacker who learns they were detected changes
tactics, and you've converted a detection into a training signal for them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second constraint rules out most of the obvious implementations of the first.&lt;/p&gt;
&lt;h2&gt;
  
  
  The ladder
&lt;/h2&gt;

&lt;p&gt;Six tiers, evaluated in Rego, driven by the pushed risk score plus the gateway's&lt;br&gt;
own fast-path signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow → log → throttle → step_up → deny → revoke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy is small enough to read in full, and mirrors the scorer's thresholds&lt;br&gt;
exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"deny"&lt;/span&gt;     &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"step_up"&lt;/span&gt;  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"throttle"&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"log"&lt;/span&gt;      &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;final_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fast_tier&lt;/span&gt;  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fast_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;score_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;final_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;score_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fast_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final decision is the &lt;em&gt;more severe&lt;/em&gt; of two independent signals: the scorer's&lt;br&gt;
composite, and the gateway's own sub-second guardrails. The second exists because&lt;br&gt;
windowed scoring cannot react faster than one window, and a flood needs stopping&lt;br&gt;
before then.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fail open, deliberately
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FAIL-OPEN: when the risk-score data is missing or stale we allow (with a&lt;/span&gt;
&lt;span class="c1"&gt;# logged reason) rather than deny.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the choice most likely to get an argument, so here's the reasoning.&lt;/p&gt;

&lt;p&gt;This is a detection layer bolted onto a production API. If the scorer, the&lt;br&gt;
pipeline, or the data path between them breaks, failing &lt;em&gt;closed&lt;/em&gt; takes the real&lt;br&gt;
API down for every legitimate integration: a self-inflicted outage far more&lt;br&gt;
damaging than the marginal enumeration an attacker achieves during the gap.&lt;/p&gt;

&lt;p&gt;The tradeoff is explicit and bounded: the gateway's fast-path guardrails&lt;br&gt;
(cardinality, miss storm, honeytokens) are independent of the score data and keep&lt;br&gt;
working when it's stale. Failing open on the &lt;em&gt;statistical&lt;/em&gt; layer is not failing&lt;br&gt;
open entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  Covert enforcement
&lt;/h2&gt;

&lt;p&gt;Here's where the second constraint bites.&lt;/p&gt;

&lt;p&gt;Every blocking decision returns a response &lt;strong&gt;byte-identical to a normal upstream&lt;br&gt;
404&lt;/strong&gt;: same status, same body, same headers. The attacker sees the objects they&lt;br&gt;
request "not existing".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MISS_BODY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not_found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One body, used for a genuine 404, a scope-violating 403, and a covert block&lt;br&gt;
alike. This reaches deeper than it first appears:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The resource API's &lt;code&gt;404&lt;/code&gt; and &lt;code&gt;403&lt;/code&gt; are &lt;strong&gt;byte- and timing-identical to each
other&lt;/strong&gt;, with a test that measures both distributions and fails if they're
distinguishable. A 403 that's distinguishable from a 404 is an existence oracle:
"this object exists but you can't see it" is exactly the information an
enumerator wants.&lt;/li&gt;
&lt;li&gt;Honeytoken recognition happens gateway-side (&lt;strong&gt;part 6&lt;/strong&gt;) so the resource API's
response never varies at all.&lt;/li&gt;
&lt;li&gt;Ownership metadata is returned on hits &lt;strong&gt;and&lt;/strong&gt; misses, empty when absent, for
the same reason.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The two rungs that did nothing
&lt;/h2&gt;

&lt;p&gt;For a long time this system decided &lt;code&gt;throttle&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt;, recorded them,&lt;br&gt;
displayed them on a dashboard, and then served the request anyway. Only &lt;code&gt;deny&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;revoke&lt;/code&gt; were enforced.&lt;/p&gt;

&lt;p&gt;A six-rung ladder with two decorative rungs is not a graduated response. It's a&lt;br&gt;
binary one with extra logging.&lt;/p&gt;

&lt;p&gt;Implementing them meant answering: what do these mean when the client must not&lt;br&gt;
notice?&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;throttle&lt;/code&gt; is a budget, not a delay
&lt;/h3&gt;

&lt;p&gt;The instinct is to slow the client down. That's wrong here for two reasons: added&lt;br&gt;
latency is directly measurable by an attacker timing their own requests, and it&lt;br&gt;
defeats the constant-time work in the resource API.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;throttle&lt;/code&gt; serves a budget of 60 requests per rolling minute and returns the&lt;br&gt;
same byte-identical miss beyond it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;throttle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reqCount1m&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;THROTTLE_BUDGET_PER_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;miss&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The budget sits comfortably above the legitimate integration's ~60 req/min,&lt;br&gt;
because &lt;code&gt;throttle&lt;/code&gt; is the middle of the ladder where a false positive is still&lt;br&gt;
plausible. It cuts an enumerator's extraction rate by an order of magnitude while&lt;br&gt;
barely touching a client that landed there by mistake.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;step_up&lt;/code&gt; is indistinguishable from token expiry
&lt;/h3&gt;

&lt;p&gt;A step-up challenge that announces itself is useless. So instead: require a token&lt;br&gt;
minted &lt;em&gt;after&lt;/em&gt; the challenge was raised, and treat anything older as an ordinary&lt;br&gt;
expired token.&lt;/p&gt;

&lt;p&gt;Re-authentication is routine for a &lt;code&gt;client_credentials&lt;/code&gt; integration, tokens live&lt;br&gt;
300 seconds here, so a 401 at this point is indistinguishable from the expiry the&lt;br&gt;
client already handles. The legitimate client re-mints and never notices it was&lt;br&gt;
challenged.&lt;/p&gt;

&lt;p&gt;What step-up buys is a distinction no other tier can draw: between &lt;strong&gt;holding a&lt;br&gt;
stolen bearer token&lt;/strong&gt; and &lt;strong&gt;holding the credentials&lt;/strong&gt;. A token thief cannot mint a&lt;br&gt;
replacement and stops dead.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; stop an attacker with the client secret, and every attacker in&lt;br&gt;
this demo is modelled that way: they simply re-authenticate. Worth stating&lt;br&gt;
plainly rather than dressing up. The residual value is that forced re-issuance&lt;br&gt;
drives the token-issuance rate up, which the scorer weights: the mitigation feeds&lt;br&gt;
the detector even when it fails to block.&lt;/p&gt;

&lt;p&gt;That claim went untested for far too long, which was the wrong way round for the&lt;br&gt;
argument the whole tier rests on. It's now pinned directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lets the credential holder through and stops the thief permanently&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;step_up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;tokenIssuedAtMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stolenTokenIssuedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// challenge raised&lt;/span&gt;

  &lt;span class="nx"&gt;tokenIssuedAtMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stolenTokenIssuedAt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// re-authenticated&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;tokenIssuedAtMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stolenTokenIssuedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// thief, same token&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus one more: the stolen token stays refused &lt;strong&gt;even after the client's tier drops&lt;br&gt;
back&lt;/strong&gt;, or a thief could simply wait out the score.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two ordering bugs, both found by getting them wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The challenge cleared itself.&lt;/strong&gt; JWT &lt;code&gt;iat&lt;/code&gt; has one-second resolution, so "was&lt;br&gt;
this token minted after the challenge?" is ambiguous within the raising second.&lt;br&gt;
My first implementation granted a second of grace, which accepted the very token&lt;br&gt;
the challenge was meant to retire. The challenge raised and satisfied itself on&lt;br&gt;
the same request.&lt;/p&gt;

&lt;p&gt;The fix is to round the challenge timestamp &lt;em&gt;up&lt;/em&gt; to the next whole second.&lt;br&gt;
Ambiguity resolves in the safe direction: a token from the challenge's own second&lt;br&gt;
is treated as too old, and the client mints another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A recoverable 401 pre-empted a hard block.&lt;/strong&gt; An earlier version answered an&lt;br&gt;
outstanding challenge immediately, before the upstream call. That both leaked&lt;br&gt;
timing: a challenged request returned faster than a served one, and let a&lt;br&gt;
recoverable 401 take precedence over a &lt;code&gt;deny&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now nothing short-circuits. Every post-authentication request takes the same&lt;br&gt;
path, proxy upstream, then overwrite the response, so served, throttled, denied&lt;br&gt;
and challenged requests are indistinguishable by timing. That's the same pattern&lt;br&gt;
&lt;code&gt;deny&lt;/code&gt; already used; I'd introduced an inconsistency rather than following it.&lt;/p&gt;
&lt;h2&gt;
  
  
  A challenge is not a block
&lt;/h2&gt;

&lt;p&gt;One measurement distinction that turned out to matter a lot.&lt;/p&gt;

&lt;p&gt;The demo's ambiguous automation reaches &lt;code&gt;step_up&lt;/code&gt;, is challenged, re-authenticates&lt;br&gt;
transparently, and carries on, 597 of 604 requests served. Early on my assertion&lt;br&gt;
counted &lt;code&gt;step_up&lt;/code&gt; responses alongside &lt;code&gt;deny&lt;/code&gt; as "blocked", so this reported as a&lt;br&gt;
&lt;strong&gt;failure&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It was reporting the ladder &lt;em&gt;working correctly&lt;/em&gt; as though it had blocked an&lt;br&gt;
innocent client.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;denied&lt;/code&gt; and &lt;code&gt;challenged&lt;/code&gt; are now separate columns, and the verdict asserts the&lt;br&gt;
client was never &lt;em&gt;denied&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  ambiguous automation escalated to log/throttle/step_up but was never denied
        (5 challenges, all recovered), and still had 598/603 requests served
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being challenged and recovering is the ladder succeeding. If your metrics can't&lt;br&gt;
express that difference, a graduated response will look like a false-positive&lt;br&gt;
generator and someone will simplify it back into a binary one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ladder is for
&lt;/h2&gt;

&lt;p&gt;The demo's ambiguous client is legitimate. Rigid machine timing, several egress&lt;br&gt;
hosts, scaling out. Genuinely ambiguous evidence.&lt;/p&gt;

&lt;p&gt;It climbs &lt;code&gt;log → throttle → step_up&lt;/code&gt;, keeps every request served, and is never&lt;br&gt;
hard-blocked. It's an honest false positive, and the demo counts it as one.&lt;/p&gt;

&lt;p&gt;That's the argument for graduated response in one client: &lt;strong&gt;you will be wrong&lt;br&gt;
about someone, and the design question is what happens when you are.&lt;/strong&gt; A binary&lt;br&gt;
detector answers "cut them off". A ladder answers "slow them down, ask them to&lt;br&gt;
prove it, and let them carry on when they do", which is survivable for both&lt;br&gt;
sides.&lt;/p&gt;




&lt;p&gt;Next, and last: &lt;strong&gt;when your detector lies to you&lt;/strong&gt;: the bug class that dominated&lt;br&gt;
this project, where a component does nothing, raises no error, and leaves every&lt;br&gt;
number looking plausible.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>architecture</category>
      <category>opa</category>
    </item>
    <item>
      <title>Honeytokens that recognise themselves: stateless decoys with automatic attribution</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:57:06 +0000</pubDate>
      <link>https://dev.to/darkedges/honeytokens-that-recognise-themselves-stateless-decoys-with-automatic-attribution-f2a</link>
      <guid>https://dev.to/darkedges/honeytokens-that-recognise-themselves-stateless-decoys-with-automatic-attribution-f2a</guid>
      <description>&lt;p&gt;Every signal in the previous articles is statistical. They weigh evidence, they&lt;br&gt;
have thresholds, they can be argued with.&lt;/p&gt;

&lt;p&gt;Honeytokens are different in kind. A honeytoken is a record that &lt;strong&gt;does not&lt;br&gt;
exist and was never given to anyone&lt;/strong&gt;. Nothing legitimate can ask for it, because&lt;br&gt;
nothing legitimate has ever held a reference to it. A request for one isn't&lt;br&gt;
suspicious, it's proof that someone is guessing or working from a stolen list.&lt;/p&gt;

&lt;p&gt;That makes it the highest-confidence signal available, and worth building&lt;br&gt;
carefully.&lt;/p&gt;
&lt;h2&gt;
  
  
  Derivation: make the decoy recognise itself
&lt;/h2&gt;

&lt;p&gt;The obvious implementation is a table. Generate decoy IDs, store them, and check&lt;br&gt;
every miss against the table.&lt;/p&gt;

&lt;p&gt;That has two problems. It puts a database lookup in the request path on every&lt;br&gt;
miss, and misses are exactly what a flood produces. And it doesn't tell you&lt;br&gt;
&lt;em&gt;whose&lt;/em&gt; decoy was tripped without another join.&lt;/p&gt;

&lt;p&gt;Instead, derive them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;honeytokenFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;honeytokenSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;uuidFromBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties fall out of this, and they're the whole design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recognition is stateless.&lt;/strong&gt; Given any ID and any client, recompute the client's&lt;br&gt;
decoy set and check membership. No lookup, no cache, no round trip. The gateway&lt;br&gt;
precomputes each known client's set at startup into a &lt;code&gt;Set&lt;/code&gt; and membership is&lt;br&gt;
O(1).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attribution is automatic.&lt;/strong&gt; The client ID is &lt;em&gt;inside&lt;/em&gt; the derivation. There is&lt;br&gt;
no "which client did this decoy belong to?" question, a decoy for&lt;br&gt;
&lt;code&gt;integration-acme&lt;/code&gt; is not a decoy for anyone else, and cannot be. If a decoy&lt;br&gt;
seeded into acme's scope is requested by a different credential, that's&lt;br&gt;
information too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They're format-identical to real IDs.&lt;/strong&gt; The output is shaped as a v4 UUID,&lt;br&gt;
with correct version and variant nibbles, so it is indistinguishable from a real&lt;br&gt;
&lt;code&gt;documents&lt;/code&gt; identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uuidFromBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// version 4&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x3f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// variant 10&lt;/span&gt;
  &lt;span class="err"&gt;…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your decoys are distinguishable from real identifiers, they are not decoys.&lt;br&gt;
An attacker who can filter them out gets a free map of what to avoid.&lt;/p&gt;
&lt;h2&gt;
  
  
  Recognition happens at the gateway, and only on a miss
&lt;/h2&gt;

&lt;p&gt;Two placement decisions, both load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only on a miss.&lt;/strong&gt; The check runs after the upstream call, only when the&lt;br&gt;
response is ≥ 400. A honeytoken is not in the dataset, so requesting one produces&lt;br&gt;
an ordinary miss from the resource API. Checking before would mean checking every&lt;br&gt;
request; checking after means checking only the ones that could possibly be a&lt;br&gt;
decoy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At the gateway, not in the resource API.&lt;/strong&gt; This is the subtle one, and it comes&lt;br&gt;
out of the covert-enforcement requirement from &lt;strong&gt;part 1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the resource API recognised honeytokens, its response would have to &lt;em&gt;do&lt;/em&gt;&lt;br&gt;
something different, and any difference, even in timing, is observable. By&lt;br&gt;
keeping recognition in the gateway, a honeytoken request is, to the resource API,&lt;br&gt;
an ordinary miss. Its response never varies. The gateway then makes a sub-second&lt;br&gt;
deterministic decision on top of a response that carries no signal at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;isMiss&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;documents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isAllowlisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;honeyIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;honeytokenHit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hit is an immediate &lt;code&gt;revoke&lt;/code&gt;, regardless of accumulated score. The demo's breach&lt;br&gt;
client has a score of essentially zero when it trips one, that's the point. This&lt;br&gt;
is the one signal that doesn't need history.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rotation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rotated_at&lt;/code&gt; sat in the schema for a long time with nothing writing to it. That's&lt;br&gt;
worth fixing, because rotation isn't hygiene theatre, it addresses a specific&lt;br&gt;
failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A decoy list leaks.&lt;/strong&gt; Through a backup, a support ticket, a former employee, a&lt;br&gt;
misconfigured export. And a decoy an attacker can identify is &lt;em&gt;worse than none&lt;/em&gt;,&lt;br&gt;
because they route around it silently and you now have a tripwire you believe in&lt;br&gt;
that will never fire.&lt;/p&gt;

&lt;p&gt;Generations make retirement wholesale: bump &lt;code&gt;HONEYTOKEN_GENERATION&lt;/code&gt; and every&lt;br&gt;
decoy changes, because the generation is inside the HMAC input.&lt;/p&gt;

&lt;p&gt;The design decision worth arguing with is what happens to the &lt;em&gt;old&lt;/em&gt; set. The&lt;br&gt;
gateway keeps recognising the &lt;strong&gt;previous&lt;/strong&gt; generation even though it is no longer&lt;br&gt;
seeded anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;activeHoneytokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;honeytokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;honeytokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reasoning: nothing legitimate ever requests a retired decoy. It was never a&lt;br&gt;
real record, and no client holds a reference to one. Something asking for one is&lt;br&gt;
replaying a stolen list or guessing: both worth knowing about. Retiring&lt;br&gt;
recognition at the same instant as seeding would hand an attacker a window in&lt;br&gt;
which yesterday's decoys are free to probe.&lt;/p&gt;

&lt;p&gt;Recognition stops at two generations, so it can't grow without bound.&lt;/p&gt;
&lt;h2&gt;
  
  
  The allowlist, and what it does not buy
&lt;/h2&gt;

&lt;p&gt;Here's a failure mode that's easy to miss until it happens to you.&lt;/p&gt;

&lt;p&gt;Your security team runs a scanner. A scanner's job is to probe for exactly the&lt;br&gt;
things honeytokens are: identifiers that shouldn't resolve. Without an&lt;br&gt;
exemption, its first sweep trips a decoy and &lt;strong&gt;revokes your own security team's&lt;br&gt;
credential&lt;/strong&gt;, and the failure looks like a successful detection, so nobody&lt;br&gt;
investigates.&lt;/p&gt;

&lt;p&gt;So recognition is allowlisted for scanner identities. Verified on a real run: 21&lt;br&gt;
decoy requests, &lt;strong&gt;0 recognised, 0 honeytoken hits, never revoked&lt;/strong&gt;, while a&lt;br&gt;
non-allowlisted client tripped 3 and was revoked immediately.&lt;/p&gt;

&lt;p&gt;But, and this is the part I got wrong first, the exemption is &lt;strong&gt;much narrower&lt;br&gt;
than its name suggests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I added a scanner agent and asserted it was never blocked, the assertion&lt;br&gt;
failed. Twice. The scanner was still denied: first by the miss-ratio guardrail&lt;br&gt;
when it probed aggressively, then, after I made its behaviour more realistic, by&lt;br&gt;
&lt;code&gt;novelty_run_length&lt;/code&gt;, because sweeping an ID range &lt;em&gt;is&lt;/em&gt; the mimicry signature.&lt;/p&gt;

&lt;p&gt;Both are correct. A scanner sweeping for non-existent records is genuinely&lt;br&gt;
indistinguishable from an enumerator. There is no clever feature that separates&lt;br&gt;
them, because there is no difference in the traffic.&lt;/p&gt;

&lt;p&gt;So the demo now asserts what the allowlist actually promises, decoy probes are&lt;br&gt;
not recognised and never cause a revoke, and prints a note when the scanner is&lt;br&gt;
denied on other grounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  internal scanner's decoy probes were not recognised (0 honeytoken hits, never revoked)
        note: it was still denied on other grounds (113/418 requests) — sweeping an ID
        range looks like enumeration, and the allowlist does not cover that
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operational lesson is worth more than a green tick: &lt;strong&gt;allowlisting an identity&lt;br&gt;
for honeytokens buys it no blanket immunity.&lt;/strong&gt; Scanners need exempting at the&lt;br&gt;
policy level too, or they need to not look like attackers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Honeytokens as training labels
&lt;/h2&gt;

&lt;p&gt;One last use, which is the most valuable and the least obvious.&lt;/p&gt;

&lt;p&gt;If you're exporting a dataset to train a model on, you have a labelling problem:&lt;br&gt;
in production you don't know which windows were attacks. Simulation labels are&lt;br&gt;
available only in simulation and are useless for a model you intend to deploy.&lt;/p&gt;

&lt;p&gt;Honeytoken hits are different in kind. They are a &lt;strong&gt;high-confidence positive label&lt;br&gt;
that a real deployment also has&lt;/strong&gt;, because nothing but an enumerator ever requests&lt;br&gt;
an ID that was never issued to anyone.&lt;/p&gt;

&lt;p&gt;So the exported dataset carries them as a separate column from the simulation&lt;br&gt;
label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;window&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;predicted&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ml&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;anomaly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;honeytoken&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;positive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;label&lt;/code&gt; is the simulation's ground truth. &lt;code&gt;honeytoken_positive&lt;/code&gt; is the one you'd&lt;br&gt;
actually have. A model trained on the second is a model you could ship.&lt;/p&gt;

&lt;p&gt;That reframes what honeytokens are for. They're not only a tripwire, they're the&lt;br&gt;
one source of ground truth an anomaly detector in production can get for free.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;enforcement&lt;/strong&gt;, a six-rung graduated ladder where every rung has to be&lt;br&gt;
invisible to the client it's applied to, and the discovery that two of them were&lt;br&gt;
doing nothing at all.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>cryptography</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Four ways a baseline quietly destroys the anomaly detector built on it</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:56:56 +0000</pubDate>
      <link>https://dev.to/darkedges/four-ways-a-baseline-quietly-destroys-the-anomaly-detector-built-on-it-3n4j</link>
      <guid>https://dev.to/darkedges/four-ways-a-baseline-quietly-destroys-the-anomaly-detector-built-on-it-3n4j</guid>
      <description>&lt;p&gt;Every anomaly detector answers one question: &lt;em&gt;compared to what?&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Four of them, in the order I found them.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. The peer group contained the client it was judging
&lt;/h2&gt;

&lt;p&gt;Cold-start clients have no history, so they're compared against a pool of other&lt;br&gt;
clients' recent benign windows. Reasonable.&lt;/p&gt;

&lt;p&gt;The pool was keyed by feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every benign window every client produced went into the pool that client was&lt;br&gt;
later compared against. Including itself.&lt;/p&gt;

&lt;p&gt;So a client could &lt;strong&gt;define its own normality&lt;/strong&gt;. Feed in enough windows and any&lt;br&gt;
behaviour becomes unremarkable, which is precisely the cold-start attacker the&lt;br&gt;
layer exists to catch.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gap=500ms  origins=5  →  allow  (peak 0)
gap=700ms  origins=5  →  step_up (peak 83)
gap=800ms  origins=5  →  allow  (peak 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Fix: key the pool per client, and exclude the client under evaluation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;byClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;excludeClientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// this is what "peer" means&lt;/span&gt;
  &lt;span class="err"&gt;…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards the behaviour became monotone in the actual evidence, and identical at&lt;br&gt;
every request interval:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;origins&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;5&lt;/th&gt;
&lt;th&gt;6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;peak score&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tier&lt;/td&gt;
&lt;td&gt;allow&lt;/td&gt;
&lt;td&gt;log&lt;/td&gt;
&lt;td&gt;throttle&lt;/td&gt;
&lt;td&gt;step_up&lt;/td&gt;
&lt;td&gt;deny&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if a parameter that shouldn't matter changes the outcome, stop tuning&lt;br&gt;
and go find the defect. Knife edges are symptoms.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Baselines weren't keyed by window size
&lt;/h2&gt;

&lt;p&gt;Same investigation, second defect. The per-client history was keyed by&lt;br&gt;
&lt;code&gt;(clientId, feature)&lt;/code&gt;, with no window size.&lt;/p&gt;

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

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

&lt;p&gt;An unglamorous bug, and the kind that hides indefinitely because every individual&lt;br&gt;
number still looks sane.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The boiling frog
&lt;/h2&gt;

&lt;p&gt;This one is my favourite, because the code documented the correct behaviour and&lt;br&gt;
then didn't implement it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scorer.ts&lt;/code&gt; said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A window is treated as benign (and folded into baselines) only well below the&lt;/span&gt;
&lt;span class="c1"&gt;// throttle tier, so attack windows never poison the peer baseline, a client's&lt;/span&gt;
&lt;span class="c1"&gt;// own history, or the isolation forest's training set.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BENIGN_MAX_SCORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;learn()&lt;/code&gt; did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;learn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeatureVector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;benign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ownHist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// ← unconditional&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;benign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;peerHist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Own history was updated regardless. The peer pool was protected; the client's own&lt;br&gt;
baseline was not.&lt;/p&gt;

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

&lt;p&gt;Measured before and after, on an anomalous client run for 26 windows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;before: flagged for &lt;strong&gt;6&lt;/strong&gt; windows, then never again&lt;/li&gt;
&lt;li&gt;after: flagged for &lt;strong&gt;23&lt;/strong&gt; of 26&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when a comment states an invariant, that's a test waiting to be&lt;br&gt;
written. This one was a lie for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The all-time set that should have been a trailing window
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;novelty_ratio&lt;/code&gt;, the share of object IDs a client hasn't seen before, needs a&lt;br&gt;
set of previously-seen IDs. Mine was a &lt;code&gt;Set&amp;lt;string&amp;gt;&lt;/code&gt; that only ever grew.&lt;/p&gt;

&lt;p&gt;It was wrong twice over.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Wrong semantics.&lt;/strong&gt; The spec defines novelty against the &lt;em&gt;trailing 7 days&lt;/em&gt;. Mine&lt;br&gt;
was all-time, so nothing ever became novel again and novelty decayed monotonically&lt;br&gt;
toward zero.&lt;/p&gt;

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

&lt;p&gt;Fix: a trailing horizon with a hard cap behind it.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  5. (Bonus) The feedback loop I created myself
&lt;/h2&gt;

&lt;p&gt;Not a baseline exactly, but the same family, and the most instructive because I&lt;br&gt;
built it deliberately and it took a measurement to notice.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;step_up&lt;/code&gt; tier forces a client to re-authenticate. Re-authentication raises&lt;br&gt;
&lt;code&gt;token_issuance_rate&lt;/code&gt;. The scorer weights &lt;code&gt;token_issuance_rate&lt;/code&gt;. A higher score&lt;br&gt;
escalates the client to &lt;code&gt;deny&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So: the system challenges a client → the client complies → the system convicts it&lt;br&gt;
for complying.&lt;/p&gt;

&lt;p&gt;The demo's ambiguous automation was being denied at a score of 88, with &lt;strong&gt;+21 of&lt;br&gt;
it coming from the token issuance its own challenge had caused.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;After the fix that client peaked at 66 instead of 88, and stopped being denied.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these have in common
&lt;/h2&gt;

&lt;p&gt;None of the five raised an error. None turned a test red. In every case the&lt;br&gt;
system kept producing scores that looked entirely reasonable.&lt;/p&gt;

&lt;p&gt;Three practical habits came out of it:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Treat comments as unwritten tests.&lt;/strong&gt; The boiling frog was documented correctly&lt;br&gt;
and implemented wrongly, and lived for months in a file I'd read a dozen times.&lt;/p&gt;

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




&lt;p&gt;Next: &lt;strong&gt;honeytokens&lt;/strong&gt;: decoys derived so they recognise themselves and attribute&lt;br&gt;
their own trips, why recognition lives in the gateway, and what an allowlist&lt;br&gt;
does and doesn't buy.&lt;/p&gt;

</description>
      <category>security</category>
      <category>machinelearning</category>
      <category>debugging</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Three detection layers that disagree usefully, and why they combine by max, not sum</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:56:37 +0000</pubDate>
      <link>https://dev.to/darkedges/three-detection-layers-that-disagree-usefully-and-why-they-combine-by-max-not-sum-24gf</link>
      <guid>https://dev.to/darkedges/three-detection-layers-that-disagree-usefully-and-why-they-combine-by-max-not-sum-24gf</guid>
      <description>&lt;p&gt;Features get you a vector per window. Turning that into a decision is where the&lt;br&gt;
design choices are.&lt;/p&gt;

&lt;p&gt;This system scores every window three independent ways and takes the strongest&lt;br&gt;
single case. Each layer covers a failure mode of the others.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 1: guardrails
&lt;/h2&gt;

&lt;p&gt;Deterministic thresholds, no baseline of any kind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Honeytoken hit — highest-confidence signal. Immediate revoke.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;honeytoken_hits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;honeytoken_hits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// High miss ratio — guessing IDs that mostly do not exist.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;miss_ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;miss_ratio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Sequential walk — near-adjacent IDs in order.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id_sequentiality&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distinct_resource_ids&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id_sequentiality&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Working set that expands and never stops — the mimicry signature.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;window_size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;novelty_run_length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;novelty_run_length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Per client, per feature, per window size: keep a bounded history and score new&lt;br&gt;
values with a &lt;strong&gt;median/MAD robust z-score&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;Three things make this work in practice, and each was added after watching it&lt;br&gt;
misbehave without them.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;robustZFloored&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;med&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;medianAbsoluteDeviation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;med&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;Directions are explicit per feature, because they aren't uniform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DIRECTION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Dir&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;interarrival_cv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// LOW is suspicious&lt;/span&gt;
  &lt;span class="na"&gt;id_sequentiality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;miss_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;             &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;subject_mismatch_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;distinct_src_ips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Peer groups
&lt;/h3&gt;

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

&lt;p&gt;Clients are grouped by &lt;strong&gt;route profile&lt;/strong&gt;: the smallest set of routes covering&lt;br&gt;
most of their traffic, sorted. Two candidate keys were rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Request volume&lt;/strong&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full route set&lt;/strong&gt;: too sparse. Nearly every client ends up alone, and a
peer group of one is the client itself under another name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The profile is taken from a client's &lt;em&gt;benign history&lt;/em&gt;, not the window being&lt;br&gt;
scored, so an attacker can't change groups by changing endpoints mid-attack.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 3: an extended isolation forest
&lt;/h2&gt;

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

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

&lt;p&gt;I measured it on this project's own feature space rather than assuming:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;inliers&lt;/th&gt;
&lt;th&gt;jointly-novel window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;axis-parallel splits&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.51&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hyperplane splits&lt;/td&gt;
&lt;td&gt;0.44&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.66&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The fix is the &lt;strong&gt;Extended Isolation Forest&lt;/strong&gt; (Hariri et al.): split on a random&lt;br&gt;
&lt;em&gt;hyperplane&lt;/em&gt;, a random normal vector and a random intercept, so one cut can&lt;br&gt;
carve an oblique region.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Draw a random hyperplane. Only dimensions that vary in this subsample get a&lt;/span&gt;
&lt;span class="c1"&gt;// non-zero coefficient — a constant dimension would just shift the intercept.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gaussian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because a hyperplane mixes dimensions, features must be &lt;strong&gt;standardised&lt;/strong&gt; first.&lt;br&gt;
Without that, &lt;code&gt;req_count&lt;/code&gt; (in the thousands) dominates every dot product and the&lt;br&gt;
forest collapses back into a one-dimensional detector.&lt;/p&gt;
&lt;h3&gt;
  
  
  Calibration, not constants
&lt;/h3&gt;

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trainScores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;forest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trainScores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;GATE_FLOOR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saturation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MIN_RAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p99&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p50&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 1% of benign windows clear it, whatever the absolute numbers turn out to&lt;br&gt;
be.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two deliberate constraints
&lt;/h3&gt;

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

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

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


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;composite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score=88 tier=deny
  miss ratio 0.90 over 280 reqs
  miss_ratio anomalous (z=18.1, +64)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;The composite maps to a graduated ladder, mirrored exactly in the Rego policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0–29  allow
 30–49  log
 50–69  throttle
 70–84  step_up
 85+    deny
        revoke   (never from score alone — reserved for hard signals)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;revoke&lt;/code&gt; is unreachable by score. It's reserved for honeytoken hits and explicit&lt;br&gt;
revocation, because those are categorically different kinds of evidence.&lt;/p&gt;

&lt;p&gt;What each rung actually &lt;em&gt;does&lt;/em&gt;, and the discovery that two of them did nothing at&lt;br&gt;
all for months, is &lt;strong&gt;article 7&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;the four ways a baseline quietly destroyed the detector built on it&lt;/strong&gt;.&lt;br&gt;
The most transferable article in the series.&lt;/p&gt;

</description>
      <category>security</category>
      <category>machinelearning</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The most obvious signals for detecting enumeration don't work</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:55:42 +0000</pubDate>
      <link>https://dev.to/darkedges/the-most-obvious-signals-for-detecting-enumeration-dont-work-4fo6</link>
      <guid>https://dev.to/darkedges/the-most-obvious-signals-for-detecting-enumeration-dont-work-4fo6</guid>
      <description>&lt;p&gt;Every window of traffic, per credential, this system computes about twenty&lt;br&gt;
features. Six of them drive decisions. The rest are computed, stored, displayed,&lt;br&gt;
and deliberately never allowed to escalate anything.&lt;/p&gt;

&lt;p&gt;That split is the most useful thing in this article, so let's get to why.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;You want to detect someone reading your whole user table. The obvious signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;distinct_resource_ids&lt;/code&gt;&lt;/strong&gt;, how many distinct objects were touched&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;novelty_ratio&lt;/code&gt;&lt;/strong&gt;, what share had never been touched by this client before&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;repeat_ratio&lt;/code&gt;&lt;/strong&gt;, what share had been&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An enumerator maxes all three: thousands of objects, nearly all new, almost no&lt;br&gt;
repeats. It looks damning.&lt;/p&gt;

&lt;p&gt;Now run a legitimate post-deploy backfill through the same features. Thousands of&lt;br&gt;
objects. Nearly all new. Almost no repeats.&lt;/p&gt;

&lt;p&gt;They are &lt;strong&gt;identical&lt;/strong&gt;. Not similar, identical in shape, because both are "read a&lt;br&gt;
large number of records this client has not read before". Cardinality is a measure&lt;br&gt;
of &lt;em&gt;how much&lt;/em&gt; was read, and how much is exactly the thing the two have in common.&lt;/p&gt;

&lt;p&gt;I tried it anyway, with corroboration from other signals, because it felt like it&lt;br&gt;
should be salvageable. It fired on backfill windows whenever the timing jitter&lt;br&gt;
happened to dip. There is no threshold that separates them, because the&lt;br&gt;
distributions overlap by construction.&lt;/p&gt;
&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Stop asking how much was read. Ask &lt;strong&gt;how it was accessed&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A backfill reads records &lt;strong&gt;in its own key order&lt;/strong&gt;, whatever order its job
queue produced. An enumerator walks the ID space.&lt;/li&gt;
&lt;li&gt;A backfill reads records that &lt;strong&gt;exist&lt;/strong&gt;; it has a list. An enumerator guesses,
and guesses wrong most of the time.&lt;/li&gt;
&lt;li&gt;A backfill is driven by a system with queues and retries, so its timing has
&lt;strong&gt;jitter&lt;/strong&gt;. A tight loop does not.&lt;/li&gt;
&lt;li&gt;A backfill runs from &lt;strong&gt;one place&lt;/strong&gt;. A credential being replayed doesn't.&lt;/li&gt;
&lt;li&gt;A backfill &lt;strong&gt;ends&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those is a feature, and each carries the distinction that cardinality&lt;br&gt;
doesn't.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;id_sequentiality&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The fraction of consecutive requested IDs whose absolute gap is ≤ 2, in arrival&lt;br&gt;
order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;idSequentiality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idsInOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;idsInOrder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;adjacent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;adjacent&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;adjacent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Near 1.0 for an ID walk, near 0 for random access or hot-object reads. Note it&lt;br&gt;
only applies to integer-like IDs, which is the whole argument for opaque&lt;br&gt;
identifiers, and why the demo's dataset deliberately has both integer (&lt;code&gt;users&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;orders&lt;/code&gt;) and UUID (&lt;code&gt;documents&lt;/code&gt;) resource types.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;miss_ratio&lt;/code&gt; and &lt;code&gt;not_found_ratio&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Share of responses ≥ 400, and share that are 404. An integration reading records&lt;br&gt;
it has references to has a miss ratio near zero. A guesser's is near one. This is&lt;br&gt;
the single most reliable signal in the system and it's almost embarrassingly&lt;br&gt;
simple.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;interarrival_cv&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Coefficient of variation (σ/μ) of the gaps between requests. Low CV means&lt;br&gt;
machine-regular. Legitimate traffic sits around 0.5–0.7; a tight loop is&lt;br&gt;
essentially 0.&lt;/p&gt;

&lt;p&gt;Note the direction is &lt;strong&gt;inverted&lt;/strong&gt; here: &lt;em&gt;low&lt;/em&gt; is suspicious. That's worth&lt;br&gt;
flagging because most anomaly-detection plumbing assumes high-is-bad, and this&lt;br&gt;
feature is a reminder to make direction an explicit per-feature property rather&lt;br&gt;
than an assumption.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;distinct_src_ips&lt;/code&gt; / &lt;code&gt;distinct_asns&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;One credential appearing from many origins. Catches the &lt;code&gt;distributed&lt;/code&gt; profile,&lt;br&gt;
where per-origin rate limiting sees nothing because each IP issues a trickle.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;endpoint_kl_divergence&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Kullback–Leibler divergence between this window's route mix and the client's own&lt;br&gt;
trailing route mix, in bits. A dictionary attack pivots traffic onto the search&lt;br&gt;
endpoint, and KL measures exactly that pivot.&lt;/p&gt;

&lt;p&gt;The implementation detail that matters is &lt;strong&gt;smoothing&lt;/strong&gt;. A route the client has&lt;br&gt;
never used before would produce infinite divergence, which would swamp every&lt;br&gt;
other feature forever. Additive smoothing on the baseline makes it large but&lt;br&gt;
finite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()].&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An integration hitting a brand-new endpoint should register as &lt;em&gt;surprising&lt;/em&gt;, not&lt;br&gt;
as an unbounded outlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;subject_mismatch_ratio&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The one that catches an attack nothing else sees.&lt;/p&gt;

&lt;p&gt;Consider a compromised integration holding a genuine delegation for one user.&lt;br&gt;
Every request carries a valid token, valid scopes, and a &lt;code&gt;subject&lt;/code&gt; it really is&lt;br&gt;
entitled to act for. Per-request authorization has nothing to object to: a token&lt;br&gt;
that may act for Alice is acting for Alice.&lt;/p&gt;

&lt;p&gt;What it cannot see is that Alice's delegation is being used to read &lt;em&gt;everyone&lt;br&gt;
else's&lt;/em&gt; records.&lt;/p&gt;

&lt;p&gt;So the resource API reports who owns each record, the gateway records that beside&lt;br&gt;
the subject, and the feature is the share of delegated requests where the two&lt;br&gt;
differ. An honest on-behalf-of integration reads the record of the person it's&lt;br&gt;
acting for, so this sits at zero.&lt;/p&gt;

&lt;p&gt;Measured on the first window of the &lt;code&gt;obo-sweep&lt;/code&gt; profile: &lt;code&gt;miss_ratio 0&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;subject_mismatch_ratio 1.0&lt;/code&gt;, caught on &lt;code&gt;subject_mismatch_ratio (z=20.0, +72)&lt;/code&gt;&lt;br&gt;
alone, with no other signal contributing anything.&lt;/p&gt;

&lt;p&gt;One implementation note with a security edge: the owner header is set on hits&lt;br&gt;
&lt;strong&gt;and&lt;/strong&gt; misses, empty when there's nothing to report. Present only on hits, it&lt;br&gt;
would turn the response shape into an existence oracle.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;novelty_run_length&lt;/code&gt;, the feature about time
&lt;/h3&gt;

&lt;p&gt;This is the one that finally caught &lt;code&gt;mimicry&lt;/code&gt;, and it's the most interesting&lt;br&gt;
feature in the system because it isn't about the shape of a window at all.&lt;/p&gt;

&lt;p&gt;Mimicry hides enumeration inside replayed legitimate traffic. Normal rate, normal&lt;br&gt;
timing, valid IDs, no ordering, miss ratio diluted below every threshold. Against&lt;br&gt;
the feature set above it scored &lt;strong&gt;zero on every window&lt;/strong&gt;, because every one of&lt;br&gt;
those features describes a single window, and in any single window mimicry &lt;em&gt;is&lt;/em&gt;&lt;br&gt;
legitimate traffic.&lt;/p&gt;

&lt;p&gt;What separates it from a benign bulk read is not shape but &lt;strong&gt;duration&lt;/strong&gt;. A&lt;br&gt;
backfill ends. An ongoing compromise does not.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;novelty_run_length&lt;/code&gt; counts consecutive windows in which the client's working&lt;br&gt;
set kept growing, and a guardrail fires at 20: far beyond any plausible backfill&lt;br&gt;
episode. Detection is correspondingly slow, and the project claims no latency&lt;br&gt;
bound for it.&lt;/p&gt;

&lt;p&gt;Two design details worth arguing with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is deliberately &lt;strong&gt;not&lt;/strong&gt; corroborated by a low repeat ratio. Mimicry's method
is to keep re-reading the hot set as cover, which holds repeat ratio &lt;em&gt;high&lt;/em&gt;;
requiring a low one would exclude precisely the traffic it exists to catch. (I
had this wrong first, and the guardrail excluded its own target.)&lt;/li&gt;
&lt;li&gt;It is evaluated &lt;strong&gt;only on the 1-minute window&lt;/strong&gt;. Over longer windows a
legitimate client's occasional stale references accumulate, each is an ID never
seen before, until its novelty ratio never returns to baseline and the run
stops distinguishing anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest limitation: an integration whose legitimate job is to page through an&lt;br&gt;
ever-growing corpus trips this, and needs an explicit exemption rather than a&lt;br&gt;
cleverer threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The features that are computed and never scored
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most like to convince you of.&lt;/p&gt;

&lt;p&gt;Several features are computed every window, written to ClickHouse, exported in&lt;br&gt;
the training set, and shown in the attribution breakdown, but are &lt;strong&gt;excluded from&lt;br&gt;
the score&lt;/strong&gt;. That is a design decision, not an unfinished TODO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distinct_resource_ids&lt;/code&gt;, &lt;code&gt;novelty_ratio&lt;/code&gt;, &lt;code&gt;repeat_ratio&lt;/code&gt;&lt;/strong&gt;, the trap from the&lt;br&gt;
top of this article. They establish that a bulk access &lt;em&gt;is happening&lt;/em&gt;, which is&lt;br&gt;
useful context for a human, and they cannot separate the two hypotheses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hour_of_day_zscore&lt;/code&gt;&lt;/strong&gt;, how unusual this window's volume is for this client at&lt;br&gt;
this time of week. Tempting, and wrong: an off-schedule volume spike is precisely&lt;br&gt;
what a legitimate post-deploy backfill looks like. Weighting it would buy nothing&lt;br&gt;
but false positives on the one case that most needs to be right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distinct_subjects&lt;/code&gt;&lt;/strong&gt;: how many principals a credential acted for. Saturates&lt;br&gt;
for any legitimate bulk job, exactly as cardinality does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;replayed_jti&lt;/code&gt;&lt;/strong&gt;: the share of traffic riding a token seen from more than one&lt;br&gt;
host. This is the shape of a stolen bearer token, and I very much wanted it to&lt;br&gt;
work. It's excluded because I measured it: weighted at all, a perfectly benign&lt;br&gt;
client, one that mints a single token and shares it across its own workers,&lt;br&gt;
scored &lt;strong&gt;100 and was denied, with +53 of that coming from this feature alone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One token from several hosts is the shape of theft &lt;em&gt;and&lt;/em&gt; the shape of a shared&lt;br&gt;
token cache. Separating them needs origin-relationship data (AS ownership,&lt;br&gt;
geography) this system doesn't model. So it's recorded for a human to act on,&lt;br&gt;
which is not the same as the system acting on it.&lt;/p&gt;

&lt;p&gt;That last one is the general pattern, and it's worth stating plainly: &lt;strong&gt;the&lt;br&gt;
signals that look most damning are often the ones that saturate for some entirely&lt;br&gt;
ordinary behaviour.&lt;/strong&gt; The only reason this one didn't ship as a false-positive&lt;br&gt;
generator is that the weight was measured against a benign client before being&lt;br&gt;
kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows
&lt;/h2&gt;

&lt;p&gt;Everything above is computed at 1m, 5m and 1h, per client, in tumbling windows.&lt;/p&gt;

&lt;p&gt;Multiple scales are not redundancy. &lt;code&gt;slow-and-low&lt;/code&gt; issues about two requests a&lt;br&gt;
minute, so its 1m windows fall below the minimum request count and score nothing&lt;br&gt;
at all, its signal only exists at 5m and 1h. Conversely &lt;code&gt;novelty_run_length&lt;/code&gt; is&lt;br&gt;
only meaningful at 1m.&lt;/p&gt;

&lt;p&gt;Different attacks are visible at different scales, and that is the entire&lt;br&gt;
premise of the pipeline. It's also the premise that a bug quietly violated for&lt;br&gt;
months, which is article 8.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;three scoring layers&lt;/strong&gt;, why they combine by maximum rather than sum, and&lt;br&gt;
why per-feature attribution is the point rather than a nice-to-have.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>datascience</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Simulating attackers is easy. Simulating legitimate users is the hard part</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:55:05 +0000</pubDate>
      <link>https://dev.to/darkedges/simulating-attackers-is-easy-simulating-legitimate-users-is-the-hard-part-bjk</link>
      <guid>https://dev.to/darkedges/simulating-attackers-is-easy-simulating-legitimate-users-is-the-hard-part-bjk</guid>
      <description>&lt;p&gt;In &lt;strong&gt;part 1&lt;/strong&gt; I argued that enumeration from a valid credential can only be&lt;br&gt;
caught from the shape of the request sequence, and that the hard requirement is&lt;br&gt;
not catching attackers but &lt;em&gt;not catching everyone else&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That requirement has a consequence people skip: to have any confidence in a false&lt;br&gt;
positive rate, you need legitimate traffic that is genuinely hard to distinguish&lt;br&gt;
from an attack. Traffic that is obviously benign proves nothing. The detector in&lt;br&gt;
this project drives ten synthetic clients through one gateway, all holding real&lt;br&gt;
tokens with identical scopes, and only five of them are attackers.&lt;/p&gt;

&lt;p&gt;Here is the whole cast, and what each one is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimate integration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;integration-acme&lt;/code&gt; models a real third-party integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zipf-distributed reads&lt;/strong&gt; of a hot object set: real integrations re-read the
same handful of records constantly, they don't sample uniformly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;human-ish jitter&lt;/strong&gt; between requests (300–1700ms), because a real system has
queues and retries and network variance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a small burst at the top of each hour&lt;/strong&gt; from a batch sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;occasional genuine 404s&lt;/strong&gt; from stale references it still holds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;on-behalf-of reads&lt;/strong&gt;: when it fetches user 812's record it does so on behalf
of user 812&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters and I'll come back to it in article 3.&lt;/p&gt;

&lt;p&gt;And then, deliberately, the thing that makes the whole project difficult:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;a benign bulk backfill&lt;/strong&gt;, a post-deploy job that reads 9,000 records it has
never touched, over five scenario-minutes, as fast as the API will serve them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the false-positive stress test, and every detection rule has to survive&lt;br&gt;
it. As shown in part 1, on cardinality, novelty ratio and repeat ratio a backfill&lt;br&gt;
is &lt;em&gt;indistinguishable&lt;/em&gt; from an attacker dumping the same table.&lt;/p&gt;

&lt;p&gt;The demo asserts &lt;code&gt;integration-acme&lt;/code&gt; stays in &lt;code&gt;allow&lt;/code&gt; for the entire run, backfill&lt;br&gt;
included. Zero blocked requests. If that assertion ever fails, the detector is&lt;br&gt;
worthless regardless of what it catches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attackers
&lt;/h2&gt;

&lt;p&gt;Five profiles, all using a credential of the same type and scopes as the real&lt;br&gt;
integration. None of them exploit a bug; every one is using access it legitimately&lt;br&gt;
holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sequential&lt;/code&gt;&lt;/strong&gt;: walks integer IDs in order, fast and machine-regular. The&lt;br&gt;
naive attacker. Trips the fast-path cardinality guardrail and the windowed&lt;br&gt;
sequentiality guardrail within seconds. It's in the cast mainly as a control: if&lt;br&gt;
your detector can't catch this, stop reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dictionary&lt;/code&gt;&lt;/strong&gt;: probes &lt;code&gt;/users?email=&lt;/code&gt; from a wordlist of plausible corporate&lt;br&gt;
addresses. Structurally different: there is no ID ordering to detect, so&lt;br&gt;
&lt;code&gt;id_sequentiality&lt;/code&gt; never fires. What gives it away is the miss ratio (almost every&lt;br&gt;
guess is wrong) and the route mix pivoting onto the search endpoint. About one&lt;br&gt;
guess in twenty lands on a real account, which is exactly why the profile is worth&lt;br&gt;
running: a blind guesser still harvests real data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;slow-and-low&lt;/code&gt;&lt;/strong&gt;: one request every 30 seconds, random IDs across a large&lt;br&gt;
range so ~99% miss. Its whole design is to stay below every rate threshold. It&lt;br&gt;
must be caught by accumulation over the coarser windows, never by rate. This&lt;br&gt;
profile later exposed the most consequential bug in the project (article 8).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distributed&lt;/code&gt;&lt;/strong&gt;: one credential replayed from six IPs across six ASNs,&lt;br&gt;
re-authenticating from each. Per-origin rate limiting sees nothing: each IP&lt;br&gt;
issues a trickle. The credential is the only thing tying the requests together,&lt;br&gt;
which is precisely the axis the detector aggregates on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mimicry&lt;/code&gt;&lt;/strong&gt;: the hard one. Enumeration hidden inside replayed legitimate&lt;br&gt;
traffic on the same credential: for every probe it issues several requests that&lt;br&gt;
look exactly like the real integration's work, with the same Zipf re-reads, the&lt;br&gt;
same jitter, the same route mix. Rate rules see nothing, the miss ratio is diluted&lt;br&gt;
below the guardrail threshold, and interleaved hot reads destroy the ordering&lt;br&gt;
signal.&lt;/p&gt;

&lt;p&gt;Measured against the detector as it stood before I designed for it: &lt;strong&gt;mimicry&lt;br&gt;
scored zero on every single window.&lt;/strong&gt; Not "low": zero. Every feature described&lt;br&gt;
the shape of one window, and in any one window mimicry &lt;em&gt;is&lt;/em&gt; legitimate traffic.&lt;br&gt;
Article 3 covers the feature that finally caught it, which had to be about&lt;br&gt;
duration rather than shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;obo-sweep&lt;/code&gt;&lt;/strong&gt;: a compromised integration holding one genuine delegation. Every&lt;br&gt;
request carries a valid token, valid scopes, and a &lt;code&gt;subject&lt;/code&gt; it really is entitled&lt;br&gt;
to act for. It uses that single delegation to read everybody else's records. No&lt;br&gt;
enumeration signal fires at all: the IDs are valid so there's no miss ratio, they&lt;br&gt;
are strided so there's no ordering, the rate is ordinary. This one is caught by a&lt;br&gt;
signal nothing else in the system provides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;honeytoken breach&lt;/code&gt;&lt;/strong&gt;: requests a decoy object from a cold start, with zero&lt;br&gt;
accumulated score. Tests the instant-revoke path (article 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that aren't attackers, and aren't quite normal either
&lt;/h2&gt;

&lt;p&gt;These are the interesting ones, and both were added late, after the detector&lt;br&gt;
started behaving in ways I couldn't explain.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ambiguous automation
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;integration-paced&lt;/code&gt; is &lt;strong&gt;legitimate&lt;/strong&gt;. It re-reads a small fixed set of records it&lt;br&gt;
is entitled to, never probes a missing ID, never walks IDs in order. What makes it&lt;br&gt;
look wrong is that it is obviously a machine, rigid timing, zero jitter, running&lt;br&gt;
from several egress hosts at once, and gradually adding more of them as it scales&lt;br&gt;
out. A monitoring bot, or a sync worker being horizontally scaled.&lt;/p&gt;

&lt;p&gt;That is genuinely ambiguous evidence, and it exists to answer a question the rest&lt;br&gt;
of the cast doesn't ask: &lt;strong&gt;what should the system do when it isn't sure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer should not be a block. It climbs &lt;code&gt;log → throttle → step_up&lt;/code&gt;, keeps&lt;br&gt;
working the whole time, and is never hard-blocked. When it reaches &lt;code&gt;step_up&lt;/code&gt; it&lt;br&gt;
answers the challenge by re-authenticating: transparently, because that's what&lt;br&gt;
any competent OAuth2 client does when its token is rejected, and carries on.&lt;/p&gt;

&lt;p&gt;It is also, honestly, a &lt;strong&gt;false positive&lt;/strong&gt;, and the demo counts it as one. A&lt;br&gt;
detector that never admits to any is not being measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  The internal scanner
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;internal-scanner&lt;/code&gt; is your own security team's tooling, and it exists because of&lt;br&gt;
a specific failure mode: a scanner's job is to probe for exactly the things&lt;br&gt;
honeytokens are: identifiers that shouldn't resolve. Without an exemption, its&lt;br&gt;
first sweep trips a decoy and &lt;strong&gt;revokes your own security team's credential&lt;/strong&gt;, and&lt;br&gt;
the failure looks like a successful detection.&lt;/p&gt;

&lt;p&gt;So honeytoken recognition is allowlisted for scanner identities. Verified in a&lt;br&gt;
run: 21 decoy requests, &lt;strong&gt;0 recognised, 0 honeytoken hits, never revoked&lt;/strong&gt;, while&lt;br&gt;
the non-allowlisted breach client tripped 3 hits and was revoked immediately.&lt;/p&gt;

&lt;p&gt;But the exemption turned out to be narrower than its name suggests, and the demo&lt;br&gt;
now says so out loud. The scanner is still &lt;em&gt;denied&lt;/em&gt;, for sweeping an ID range,&lt;br&gt;
which is the mimicry signature. That's correct: a scanner sweeping for&lt;br&gt;
non-existent records is genuinely indistinguishable from an enumerator. The&lt;br&gt;
operational lesson is worth more than a green tick: allowlisting an identity for&lt;br&gt;
honeytokens buys it no blanket immunity, and scanners need exempting at the policy&lt;br&gt;
level too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the boring clients matter more
&lt;/h2&gt;

&lt;p&gt;If I'd built only the attackers, I would have shipped a detector that scores 100%&lt;br&gt;
recall and is unusable.&lt;/p&gt;

&lt;p&gt;Three of this project's most significant bugs were found only because a&lt;br&gt;
non-attacking client was in the cast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;ambiguous automation&lt;/strong&gt; existing at all is what forced me to check whether
the middle tiers of the response ladder did anything. They didn't, &lt;code&gt;throttle&lt;/code&gt;
and &lt;code&gt;step_up&lt;/code&gt; were being decided, recorded, displayed on a dashboard, and then
the request was served anyway.&lt;/li&gt;
&lt;li&gt;Trying to make that client land reliably in the middle band exposed &lt;strong&gt;four
separate baseline defects&lt;/strong&gt; (article 5), including one where changing nothing
but the request interval flipped the outcome between &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;scanner&lt;/strong&gt; exposed that a defensive feature wired into the gateway since
the beginning had no test and no traffic exercising it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are findable with attackers alone. Attackers make the detector fire;&lt;br&gt;
only realistic non-attackers tell you whether firing meant anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism
&lt;/h2&gt;

&lt;p&gt;Everything is seeded. Every agent takes an explicit RNG seed, the dataset is&lt;br&gt;
generated from a fixed seed, and the isolation forest is seeded per window size.&lt;br&gt;
Two runs of the same scenario produce the same traffic.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. When you are chasing a detector that behaves&lt;br&gt;
differently between runs, you need to know the &lt;em&gt;traffic&lt;/em&gt; is not the variable.&lt;br&gt;
That confidence is what let me establish, in article 5, that an inexplicable&lt;br&gt;
flip-flop between &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt; was a defect in the baseline rather than&lt;br&gt;
noise in the input.&lt;/p&gt;

&lt;p&gt;What is &lt;em&gt;not&lt;/em&gt; deterministic is timing under load, and I'll be honest about it here&lt;br&gt;
because it bit repeatedly: all ten agents share one Node event loop. On an idle&lt;br&gt;
machine the &lt;code&gt;sequential&lt;/code&gt; profile is caught in 29–50 scenario-seconds; on a box at&lt;br&gt;
load average 8.5 the same code reports 74–76 and fails its bound. That's the&lt;br&gt;
harness, not the detector, but it means "did my change cause a regression?" has&lt;br&gt;
to be answered on a quiet machine.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;which features actually discriminate&lt;/strong&gt;, why the most obvious ones don't,&lt;br&gt;
and the ones this system computes and deliberately never scores.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your API is being enumerated by a client with a perfectly valid token</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:53:09 +0000</pubDate>
      <link>https://dev.to/darkedges/your-api-is-being-enumerated-by-a-client-with-a-perfectly-valid-token-fk1</link>
      <guid>https://dev.to/darkedges/your-api-is-being-enumerated-by-a-client-with-a-perfectly-valid-token-fk1</guid>
      <description>&lt;p&gt;Here is a request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /users/4821
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is valid. It is not expired, the signature verifies, the issuer is&lt;br&gt;
yours. The client it belongs to: a third-party integration your customer&lt;br&gt;
installed eighteen months ago, has the &lt;code&gt;users:read&lt;/code&gt; scope, and this endpoint&lt;br&gt;
requires exactly that. The user record &lt;code&gt;4821&lt;/code&gt; exists and is one your customer's&lt;br&gt;
organisation is entitled to see.&lt;/p&gt;

&lt;p&gt;Every authorization check you have passes. They should pass. The request is,&lt;br&gt;
individually, correct.&lt;/p&gt;

&lt;p&gt;Now here is the problem: that integration was compromised last Tuesday, and this&lt;br&gt;
is request 40,000 of a sweep through your entire user table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization is the wrong tool, structurally
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for authz. Tighten the scopes. Add object-level checks.&lt;br&gt;
Adopt whatever the latest OWASP API list calls it this year.&lt;/p&gt;

&lt;p&gt;None of it helps, and it's worth being precise about why. Authorization is a&lt;br&gt;
&lt;strong&gt;per-request&lt;/strong&gt; predicate: given this principal, this action, this object, yes or&lt;br&gt;
no? A compromised-but-authenticated integration satisfies that predicate on every&lt;br&gt;
single request, because it is using exactly the access it was legitimately&lt;br&gt;
granted. There is no individual request you can point at and call wrong.&lt;/p&gt;

&lt;p&gt;What's wrong is the &lt;strong&gt;sequence&lt;/strong&gt;. Forty thousand individually-correct requests,&lt;br&gt;
in a particular order, at a particular rate, hitting a particular distribution of&lt;br&gt;
object IDs, is an exfiltration. But "the sequence is wrong" is not a statement&lt;br&gt;
per-request authorization is capable of making. It doesn't have the shape.&lt;/p&gt;

&lt;p&gt;This is a general principle worth internalising: &lt;em&gt;you cannot detect an attack&lt;br&gt;
whose signal lives at a scale your check doesn't operate at.&lt;/em&gt; Rate limiting&lt;br&gt;
operates per-client-per-minute, so it catches bursts. Authorization operates&lt;br&gt;
per-request, so it catches privilege violations. Enumeration by a valid&lt;br&gt;
credential lives at neither scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's left: the shape of the sequence
&lt;/h2&gt;

&lt;p&gt;If you can't judge requests individually, judge them collectively. Aggregate&lt;br&gt;
requests by credential over a window of time, compute features describing the&lt;br&gt;
&lt;em&gt;shape&lt;/em&gt; of that window, and score the shape.&lt;/p&gt;

&lt;p&gt;Concretely, the thing I built (&lt;a href="https://github.com/darkedges/apileak" rel="noopener noreferrer"&gt;apileak&lt;/a&gt;, TypeScript, runs under&lt;br&gt;
&lt;code&gt;docker compose&lt;/code&gt;) aggregates every request by &lt;code&gt;client_id&lt;/code&gt; into tumbling windows&lt;br&gt;
of 1 minute, 5 minutes and 1 hour, and computes ~20 features per window:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how many distinct object IDs were touched&lt;/li&gt;
&lt;li&gt;what fraction of them had never been touched before&lt;/li&gt;
&lt;li&gt;what fraction of requests were misses&lt;/li&gt;
&lt;li&gt;whether the IDs walked in order&lt;/li&gt;
&lt;li&gt;the coefficient of variation of the inter-request gaps&lt;/li&gt;
&lt;li&gt;how many source IPs and ASNs the credential appeared from&lt;/li&gt;
&lt;li&gt;whether any decoy object was touched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...and so on. A score comes out; a policy turns the score into a decision.&lt;/p&gt;

&lt;p&gt;That is the whole thesis. Everything else is detail, but the detail is where all&lt;br&gt;
the interesting failures live, which is why this series is eight articles and not&lt;br&gt;
one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, briefly
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  agents ──► api-gateway ──► resource-api
               │  ├──► OPA          (decision; reads pushed risk scores as data)
               │  └──► Redis        (fast-path counters, sub-second guardrails)
               └──► Redpanda: access.events / token.events
                         │
                   feature-pipeline   (windowed aggregation, 1m/5m/1h)
                         │  └──► ClickHouse (events, features, scores)
                   scorer (guardrails + statistical + isolation forest)
                         ├──► OPA data push
                         └──► WebSocket ──► CLI + dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties of this shape matter more than the component choices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scoring is asynchronous.&lt;/strong&gt; No machine learning runs in the request path. The&lt;br&gt;
gateway consults a &lt;em&gt;precomputed&lt;/em&gt; score plus its own cheap in-Redis counters, so&lt;br&gt;
its p99 overhead stays under 15ms: asserted in a load test, because a detection&lt;br&gt;
layer that adds 200ms to every request will be switched off by whoever is on call&lt;br&gt;
the first time latency alerts fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gateway has a fast path of its own.&lt;/strong&gt; Windowed scoring cannot react faster&lt;br&gt;
than one window. A client that opens with 600 requests in fifteen seconds needs&lt;br&gt;
stopping before the first window closes, so the gateway keeps rolling per-minute&lt;br&gt;
counters (a request count, a miss count, a HyperLogLog of distinct IDs) in Redis&lt;br&gt;
and the policy can fire on those alone. In practice this matters enormously: in&lt;br&gt;
one run the dictionary-attack profile was &lt;strong&gt;enforced at 9 seconds by the fast&lt;br&gt;
path and only flagged at 44 seconds by the windowed scorer&lt;/strong&gt;. Enforcement&lt;br&gt;
preceding detection is not an anomaly, it's the fast path doing its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes this hard is not the attackers
&lt;/h2&gt;

&lt;p&gt;If the only requirement were "catch someone reading the whole user table", this&lt;br&gt;
would be a weekend project. Set a threshold on distinct object IDs per hour, page&lt;br&gt;
someone, go home.&lt;/p&gt;

&lt;p&gt;The requirement that makes it hard is the &lt;em&gt;other&lt;/em&gt; one: &lt;strong&gt;a legitimate bulk&lt;br&gt;
operation must not trip it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Consider a post-deploy backfill. Your customer's integration ships a new feature&lt;br&gt;
and needs to populate a cache, so it reads 9,000 user records it has never read&lt;br&gt;
before, over five minutes, as fast as your API will serve them.&lt;/p&gt;

&lt;p&gt;Now compare it to an attacker dumping the same table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;backfill&lt;/th&gt;
&lt;th&gt;enumeration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;distinct objects touched&lt;/td&gt;
&lt;td&gt;thousands&lt;/td&gt;
&lt;td&gt;thousands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;share never seen before&lt;/td&gt;
&lt;td&gt;~100%&lt;/td&gt;
&lt;td&gt;~100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repeat ratio&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;request rate&lt;/td&gt;
&lt;td&gt;elevated&lt;/td&gt;
&lt;td&gt;elevated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scope used&lt;/td&gt;
&lt;td&gt;exactly as granted&lt;/td&gt;
&lt;td&gt;exactly as granted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On every metric you'd reach for first, they are &lt;strong&gt;identical&lt;/strong&gt;. I tried blocking&lt;br&gt;
on cardinality anyway, with corroboration from other signals. It fired on backfill&lt;br&gt;
windows whenever the timing jitter happened to dip. That approach is not&lt;br&gt;
salvageable by tuning; the signals genuinely do not carry the distinction.&lt;/p&gt;

&lt;p&gt;Finding signals that &lt;em&gt;do&lt;/em&gt; carry it is the subject of article 3, and it turns on a&lt;br&gt;
simple reframing: stop asking &lt;em&gt;how much&lt;/em&gt; was read and start asking &lt;em&gt;how&lt;/em&gt; it was&lt;br&gt;
accessed. A backfill reads records in its own key order and they all exist. An&lt;br&gt;
enumerator walks IDs, or guesses them, or probes for ones that don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "detection" has to mean
&lt;/h2&gt;

&lt;p&gt;One more constraint, and it changes the design more than anything else.&lt;/p&gt;

&lt;p&gt;Detecting the attack is useless if the attacker can tell they were detected. An&lt;br&gt;
attacker who learns that request 40,000 was blocked simply changes tactics,&lt;br&gt;
slows down, rotates credentials, switches to a different ID scheme, and you have&lt;br&gt;
converted a detection into a training signal for them.&lt;/p&gt;

&lt;p&gt;So every blocking decision in this system returns a response &lt;strong&gt;byte-identical to&lt;br&gt;
an ordinary 404&lt;/strong&gt;: same status, same body, same headers, same timing. The&lt;br&gt;
attacker's requests just start coming back as "that object doesn't exist". This&lt;br&gt;
constraint reaches surprisingly deep: it's why the &lt;code&gt;404&lt;/code&gt; and &lt;code&gt;403&lt;/code&gt; in the&lt;br&gt;
resource API are constant-time and byte-identical to each other (there's a test&lt;br&gt;
that measures both distributions and fails if they're distinguishable), and it's&lt;br&gt;
why honeytoken recognition happens at the gateway rather than in the resource API,&lt;br&gt;
so that the resource API's response never varies at all.&lt;/p&gt;

&lt;p&gt;Article 7 covers what covert enforcement does to a graduated response ladder,&lt;br&gt;
including the two rungs that turned out to be doing nothing for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this series covers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This article&lt;/strong&gt;, why authorization can't help, and what's left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cast&lt;/strong&gt;: the attacker profiles, and the much harder problem of modelling
legitimate traffic convincingly enough to trust your own false-positive rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Features&lt;/strong&gt;, which signals discriminate, which only look like they do, and
the ones deliberately computed but never scored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoring&lt;/strong&gt;: three layers, why they combine by maximum rather than sum, and
why per-feature attribution is the whole point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baselines&lt;/strong&gt;: four separate ways the baseline quietly destroyed the detector
built on top of it. The most transferable article in the series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honeytokens&lt;/strong&gt;: decoys derived so they recognise themselves and attribute
their own trips, plus rotation and the limits of allowlisting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforcement&lt;/strong&gt;, a six-rung ladder where every rung has to be invisible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When your detector lies to you&lt;/strong&gt;: the bug class that dominated this project:
components that do nothing, raise no error, and leave every number looking
plausible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A warning about what this is: a &lt;strong&gt;demonstration artifact&lt;/strong&gt;, built to be read and&lt;br&gt;
argued with. It is not a product. Where it doesn't work I've said so, in the&lt;br&gt;
articles as well as the repo: the failures turned out to be the most useful&lt;br&gt;
material.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Cross-App Access Without Leaking Credentials: A Hands-On ID-JAG POC</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:47:50 +0000</pubDate>
      <link>https://dev.to/darkedges/cross-app-access-without-leaking-credentials-a-hands-on-id-jag-poc-4kea</link>
      <guid>https://dev.to/darkedges/cross-app-access-without-leaking-credentials-a-hands-on-id-jag-poc-4kea</guid>
      <description>&lt;p&gt;Most "identity federation" demos stop at login. The harder problem is what happens &lt;em&gt;after&lt;/em&gt; login: how does App B get a token to call App C on a user's behalf, without App B ever holding the user's original credentials — and without the user re-authenticating at every hop?&lt;/p&gt;

&lt;p&gt;That's the problem &lt;a href="https://github.com/darkedges/xaa-id-jag-poc" rel="noopener noreferrer"&gt;xaa-id-jag-poc&lt;/a&gt; is built to explore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;A working proof-of-concept for &lt;strong&gt;cross-app access (XAA)&lt;/strong&gt; using the emerging &lt;strong&gt;ID-JAG (Identity Assertion JWT Authorization Grant)&lt;/strong&gt; draft spec, layered on top of standard OAuth 2.0 building blocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RFC 8693&lt;/strong&gt; — OAuth 2.0 Token Exchange&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC 7523&lt;/strong&gt; — JWT Bearer Grant for OAuth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC 7636&lt;/strong&gt; — PKCE&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ID-JAG (draft)&lt;/strong&gt; — exchanging an ID Token from a trusted IdP for an access token scoped to a &lt;em&gt;downstream&lt;/em&gt; Resource Authorization Server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In plain terms: a user authenticates once at an Identity Provider, and their identity assertion can then be exchanged — hop by hop — for scoped access tokens at other services, without those services ever seeing a password or a long-lived credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spring Authorization Server&lt;/strong&gt; — acts as both the Identity Provider and the Resource Authorization Server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring Boot&lt;/strong&gt; — protected Resource API validating the exchanged tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js + TypeScript&lt;/strong&gt; — the web UI driving the flows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx&lt;/strong&gt; — reverse proxy handling hostname routing between services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose / Helm&lt;/strong&gt; — local orchestration and a full Kubernetes deployment path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also &lt;strong&gt;MCP (Model Context Protocol) resource URI support&lt;/strong&gt;, which lets you point the same token-exchange machinery at MCP-style endpoints — relevant if you're thinking about how AI agents should authenticate to tools and downstream APIs rather than being handed a static bearer token.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's been shaking out recently
&lt;/h2&gt;

&lt;p&gt;The repo has been getting hardened with negative-path test coverage — the boring but critical part of any auth system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Correct propagation of MCP auth failures as &lt;strong&gt;HTTP 401/403&lt;/strong&gt; (instead of leaking through as 500s or ambiguous errors)&lt;/li&gt;
&lt;li&gt;Explicit &lt;strong&gt;incorrect-scope&lt;/strong&gt; test cases across both the MCP and web test suites&lt;/li&gt;
&lt;li&gt;Fixes to JWT bearer &lt;strong&gt;issuer extraction&lt;/strong&gt;, plus stabilized REST paths for the positive-flow tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've ever shipped a token-exchange flow, you know the negative cases are where the real bugs live — a wrong scope or a bad issuer claim needs to fail loudly and specifically, not silently degrade into "well, it kind of works."&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything runs locally via Docker Compose with pre-configured dev credentials and hostname routing, so you can watch the full token-exchange chain happen end-to-end without standing up real infrastructure. A Helm chart is included if you want to push it onto Kubernetes with a private registry.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/darkedges/xaa-id-jag-poc" rel="noopener noreferrer"&gt;https://github.com/darkedges/xaa-id-jag-poc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're working on agent-to-agent auth, MCP security, or just want to see RFC 8693 token exchange actually wired up rather than described in a spec doc, it's worth a clone and a poke around. Issues and PRs welcome.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>security</category>
      <category>springboot</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Proving Zero Trust Actually Works: Entra ID + Cloudflare Access over Both OIDC and SAML</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 26 Jul 2026 22:32:19 +0000</pubDate>
      <link>https://dev.to/darkedges/proving-zero-trust-actually-works-entra-id-cloudflare-access-over-both-oidc-and-saml-4f7d</link>
      <guid>https://dev.to/darkedges/proving-zero-trust-actually-works-entra-id-cloudflare-access-over-both-oidc-and-saml-4f7d</guid>
      <description>&lt;p&gt;Most Zero Trust write-ups stop at "user signs in, user gets access." That's not&lt;br&gt;
where these integrations actually break. They break on group membership that&lt;br&gt;
almost, but doesn't quite, satisfy a policy. They break on admin consent that&lt;br&gt;
was never granted, silently, until the first real sign-in. They break on the&lt;br&gt;
one country rule that was supposed to be an OR and was actually an AND.&lt;/p&gt;

&lt;p&gt;I built a small, fully Terraform-managed demo to make those failure modes&lt;br&gt;
visible instead of theoretical: Microsoft Entra ID driving Cloudflare Access&lt;br&gt;
authorisation over both OIDC and SAML, against the same three users, with the&lt;br&gt;
resulting identity provably verified at the origin, not just echoed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Everything is provisioned by Terraform: the Entra app registrations, the demo&lt;br&gt;
users and groups, the admin consent grant, both Cloudflare identity providers,&lt;br&gt;
a dedicated tunnel with Cloudflare-managed ingress, DNS, and the Access&lt;br&gt;
applications and policies themselves. One &lt;code&gt;make apply&lt;/code&gt; and it's all live.&lt;/p&gt;

&lt;p&gt;Entra is registered with Cloudflare twice. Once through the native &lt;code&gt;azureAD&lt;/code&gt;&lt;br&gt;
OIDC connector, where Cloudflare resolves group membership with a live&lt;br&gt;
Microsoft Graph call at sign-in. Once as a generic SAML 2.0 app, where group&lt;br&gt;
membership rides inside the signed assertion instead. Two applications point&lt;br&gt;
at OIDC, one points at SAML, so the protocols sit side by side against&lt;br&gt;
identical identities instead of being described in the abstract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three users, three apps, one deliberate trap
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user&lt;/th&gt;
&lt;th&gt;Entra groups&lt;/th&gt;
&lt;th&gt;echo-basic&lt;/th&gt;
&lt;th&gt;echo-secure&lt;/th&gt;
&lt;th&gt;echo-saml&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;engineering, oncall, finance&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;carol&lt;/td&gt;
&lt;td&gt;engineering&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;contractors&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Alice and bob are the boring cases: pass everything, or fail everything. If&lt;br&gt;
that's all you test, a single-group policy and a compound one look identical&lt;br&gt;
from the outside. Carol is the point of the whole demo. She satisfies&lt;br&gt;
&lt;code&gt;echo-secure&lt;/code&gt;'s &lt;code&gt;include&lt;/code&gt; block because she's in engineering, and she's still&lt;br&gt;
refused, because its &lt;code&gt;require&lt;/code&gt; block also demands on-call membership and she&lt;br&gt;
doesn't have it. That's the only way to actually show compound policy logic&lt;br&gt;
rather than just assert it in a blog post.&lt;/p&gt;

&lt;p&gt;That maps onto the three operators Cloudflare Access policies are built from,&lt;br&gt;
and where hand-built policies most often go wrong:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;block&lt;/th&gt;
&lt;th&gt;logic&lt;/th&gt;
&lt;th&gt;in echo-secure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;include&lt;/td&gt;
&lt;td&gt;OR, match at least one&lt;/td&gt;
&lt;td&gt;engineering or finance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;require&lt;/td&gt;
&lt;td&gt;AND, match every one&lt;/td&gt;
&lt;td&gt;and on-call, and in-country&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exclude&lt;/td&gt;
&lt;td&gt;NOT, match none&lt;/td&gt;
&lt;td&gt;and not a contractor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's also a &lt;code&gt;deny&lt;/code&gt; policy at precedence 1 that short-circuits the chain&lt;br&gt;
before the allow rule is ever evaluated, a purpose-justification prompt, a&lt;br&gt;
30-minute session, and a path-scoped &lt;code&gt;bypass&lt;/code&gt; policy that lets &lt;code&gt;/health&lt;/code&gt;&lt;br&gt;
through unauthenticated on a hostname that's otherwise fully protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity you can actually verify, not just see
&lt;/h2&gt;

&lt;p&gt;The brief for the origin was simple: echo the request headers back. But raw&lt;br&gt;
echo only proves a request arrived, it can't distinguish a request that&lt;br&gt;
passed policy from one that reached the origin some other way, and the&lt;br&gt;
&lt;code&gt;Cf-Access-Jwt-Assertion&lt;/code&gt; header stays an opaque blob unless something&lt;br&gt;
decodes it.&lt;/p&gt;

&lt;p&gt;So the origin does three things instead of one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verifies the JWT signature against the team's JWKS, with both issuer and
audience pinned, audience being that specific application's AUD tag. A
valid token minted for a different Access application in the same account
gets rejected here. This is the check any real origin sitting behind
Access should be doing.&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;/cdn-cgi/access/get-identity&lt;/code&gt; with the session cookie, but only
after verification succeeds. An unproven session shouldn't cause the
origin to make upstream calls on the user's behalf.&lt;/li&gt;
&lt;li&gt;Resolves the group GUIDs both protocols return into readable names, and
labels where each one came from: the identity endpoint for OIDC, the SAML
assertion for SAML. That provenance label is the visible difference
between the two protocols on the page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A &lt;code&gt;/raw&lt;/code&gt; route still gives the literal, unverified header dump for scripting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No circular dependency between Entra and Cloudflare.&lt;/strong&gt; Both integrations&lt;br&gt;
key off one URL, &lt;code&gt;https://&amp;lt;team&amp;gt;.cloudflareaccess.com/cdn-cgi/access/callback&lt;/code&gt;,&lt;br&gt;
which is derivable from the team name alone, before either side of the&lt;br&gt;
integration exists. For OIDC it's the redirect URI. For generic SAML,&lt;br&gt;
Cloudflare uses the same URL as both the Entity ID and the ACS endpoint.&lt;br&gt;
Nothing has to exist before anything else, so the whole configuration applies&lt;br&gt;
in a single Terraform pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admin consent is configuration, not a portal click.&lt;/strong&gt; &lt;code&gt;support_groups =&lt;br&gt;
true&lt;/code&gt; makes Cloudflare call Microsoft Graph after the code exchange, which&lt;br&gt;
needs &lt;code&gt;Directory.Read.All&lt;/code&gt; and &lt;code&gt;GroupMember.Read.All&lt;/code&gt; with tenant-wide&lt;br&gt;
consent. This is the step most often missed when an integration like this is&lt;br&gt;
built by hand in the portal, and it fails at user sign-in, not at setup, so&lt;br&gt;
the integration looks fine right up until someone actually tries to use it.&lt;br&gt;
&lt;code&gt;azuread_service_principal_delegated_permission_grant&lt;/code&gt; grants it&lt;br&gt;
declaratively, followed by a 30 second &lt;code&gt;time_sleep&lt;/code&gt;, because Entra's&lt;br&gt;
directory is eventually consistent and an IdP created the instant the grant&lt;br&gt;
returns can fail its first few sign-ins for no visible reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Group identity is always a GUID, never a display name&lt;/strong&gt;, on both&lt;br&gt;
protocols. Policies reference &lt;code&gt;azuread_group.demo[...].object_id&lt;/code&gt; directly&lt;br&gt;
rather than hand-copied GUIDs, and the origin gets a Terraform-generated&lt;br&gt;
GUID to name map purely for display. Nothing security-relevant depends on&lt;br&gt;
that map.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;required_country&lt;/code&gt; is deliberately a single string, not a list.&lt;/strong&gt; Every&lt;br&gt;
entry in an Access &lt;code&gt;require&lt;/code&gt; block is ANDed together. Two country rules&lt;br&gt;
wouldn't permit either country, they'd make the policy unsatisfiable. That's&lt;br&gt;
a genuinely easy trap to fall into by hand, which is why the Terraform&lt;br&gt;
variable has a validation rule enforcing a single value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SAML email claim is &lt;code&gt;name&lt;/code&gt;, not &lt;code&gt;emailaddress&lt;/code&gt;.&lt;/strong&gt; Entra sources the&lt;br&gt;
&lt;code&gt;emailaddress&lt;/code&gt; claim from the &lt;code&gt;mail&lt;/code&gt; attribute, which is empty for cloud-only&lt;br&gt;
users that have never been assigned a mailbox, exactly the users this project&lt;br&gt;
creates. Pointing Cloudflare at it produces a session with a blank identity.&lt;br&gt;
The &lt;code&gt;name&lt;/code&gt; claim comes from &lt;code&gt;userPrincipalName&lt;/code&gt; instead and is always&lt;br&gt;
populated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this demo is not
&lt;/h2&gt;

&lt;p&gt;It's a demonstration, not a production template, and it says so out loud:&lt;br&gt;
Terraform state is local and unencrypted, the tunnel token lands in&lt;br&gt;
&lt;code&gt;.env.generated&lt;/code&gt; in plaintext, the three demo users share a password with no&lt;br&gt;
forced rotation, there's no SCIM provisioning, and the SAML IdP doesn't sign&lt;br&gt;
AuthnRequests. Every one of those is a deliberate trade for a demo that&lt;br&gt;
stands up in one command instead of an afternoon, and &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt;&lt;br&gt;
in the repo spells out what each one would need to become for anything&lt;br&gt;
longer lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Zero Trust vendors sell the diagram. What actually separates a policy that&lt;br&gt;
works from one that only looks like it works is whether you tested the case&lt;br&gt;
that's supposed to fail for a specific reason, not just any reason. Carol is&lt;br&gt;
that case. If your own Access policies don't have a Carol, they haven't&lt;br&gt;
really been tested yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Github Repository
&lt;/h2&gt;

&lt;p&gt;The code is available at &lt;a href="https://github.com/darkedges/cloudflare-zerotrust-entra" rel="noopener noreferrer"&gt;https://github.com/darkedges/cloudflare-zerotrust-entra&lt;/a&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>terraform</category>
      <category>security</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>From vexctl scripts to a governed VEX platform: building vex-ui with Next.js, keyless signing, and a Trivy-consumable repo</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Tue, 07 Jul 2026 21:17:17 +0000</pubDate>
      <link>https://dev.to/darkedges/from-vexctl-scripts-to-a-governed-vex-platform-building-vex-ui-with-nextjs-keyless-signing-and-1jkm</link>
      <guid>https://dev.to/darkedges/from-vexctl-scripts-to-a-governed-vex-platform-building-vex-ui-with-nextjs-keyless-signing-and-1jkm</guid>
      <description>&lt;p&gt;un Trivy against almost any vendor container image and you'll get a wall of&lt;br&gt;
findings. Most of them don't matter, the vulnerable code path is never&lt;br&gt;
executed in your deployment. The problem is that &lt;em&gt;knowing&lt;/em&gt; that has&lt;br&gt;
traditionally lived in spreadsheets and Jira comments, where no scanner can&lt;br&gt;
see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VEX (Vulnerability Exploitability eXchange)&lt;/strong&gt; turns that triage into&lt;br&gt;
machine-readable statements that scanners apply automatically. This post&lt;br&gt;
walks through a complete, runnable workflow, baseline scan → OpenVEX&lt;br&gt;
generation → a hosted VEX repository → suppressed re-scan, and covers three&lt;br&gt;
gotchas I hit that the docs don't mention.&lt;/p&gt;

&lt;p&gt;Everything here is in a one-script demo repo:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/darkedges/trivy-vex-demo" rel="noopener noreferrer"&gt;github.com/darkedges/trivy-vex-demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ The demo marks every CVE &lt;code&gt;not_affected&lt;/code&gt; mechanically to exercise the&lt;br&gt;
&lt;em&gt;plumbing&lt;/em&gt;. In real life the assessment is the valuable part, don't ship&lt;br&gt;
VEX statements you can't defend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Target: &lt;code&gt;pingidentity/pingaccess:8.3.4-edge&lt;/code&gt; (digest&lt;br&gt;
&lt;code&gt;sha256:51689e8c…&lt;/code&gt;). Tools, pinned and current at time of writing: Trivy&lt;br&gt;
v0.71.0, vexctl v0.4.1, OpenVEX v0.2.0, and the&lt;br&gt;
&lt;a href="https://github.com/aquasecurity/vex-repo-spec" rel="noopener noreferrer"&gt;VEX Repository Specification&lt;/a&gt; v0.1.&lt;/p&gt;

&lt;p&gt;A small Alpine-based toolchain image carries everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:latest&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; TRIVY_VERSION=0.71.0&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; VEXCTL_VERSION=0.4.1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apk upgrade &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; ca-certificates curl jq
&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/trivy.tar.gz &lt;span class="se"&gt;\
&lt;/span&gt;      &lt;span class="s2"&gt;"https://github.com/aquasecurity/trivy/releases/download/v&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TRIVY_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/trivy_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TRIVY_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_Linux-64bit.tar.gz"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; /tmp/trivy.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /usr/local/bin trivy &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/trivy.tar.gz
&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/local/bin/vexctl &lt;span class="se"&gt;\
&lt;/span&gt;      &lt;span class="s2"&gt;"https://github.com/openvex/vexctl/releases/download/v&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VEXCTL_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/vexctl-linux-amd64"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/vexctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1, Baseline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy image &lt;span class="nt"&gt;--format&lt;/span&gt; json &lt;span class="nt"&gt;--output&lt;/span&gt; baseline-report.json &lt;span class="se"&gt;\&lt;/span&gt;
  pingidentity/pingaccess:8.3.4-edge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;70 findings (2 CRITICAL / 18 HIGH / 42 MEDIUM / 8 LOW)&lt;/strong&gt; across 51&lt;br&gt;
unique CVE/GHSA IDs, all in bundled Java jars and git-lfs. The Alpine OS&lt;br&gt;
layer itself: zero.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2, Generate OpenVEX statements
&lt;/h2&gt;

&lt;p&gt;One statement per CVE with &lt;code&gt;vexctl&lt;/code&gt;. The critical detail is the &lt;strong&gt;product&lt;br&gt;
identifier&lt;/strong&gt;: a &lt;code&gt;pkg:oci&lt;/code&gt; purl pinned to the image &lt;strong&gt;digest&lt;/strong&gt;, never the tag,&lt;br&gt;
tags move, VEX assertions shouldn't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vexctl create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--product&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pkg:oci/pingaccess@sha256:51689e8ccf1ec6bef28c855a2f2fafdd3556f753609adad2e258580e3bc9397c"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vuln&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CVE-2022-46337"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"not_affected"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--justification&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"vulnerable_code_not_in_execute_path"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--status-note&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"DEMO assessment for a suppression-workflow POC."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--author&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"DarkEdges Security &amp;lt;nirving@darkedges.com&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CVE-2022-46337.openvex.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://openvex.dev/ns/v0.2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://darkedges.com/vex/demo/pingaccess/CVE-2022-46337"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DarkEdges Security &amp;lt;nirving@darkedges.com&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"vulnerability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CVE-2022-46337"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"products"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pkg:oci/pingaccess@sha256:51689e8c…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_affected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"justification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vulnerable_code_not_in_execute_path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEMO assessment for a suppression-workflow POC."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop that over the 51 IDs from the baseline JSON, then consolidate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vexctl merge statements/&lt;span class="k"&gt;*&lt;/span&gt;.openvex.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pingaccess-8.3.4-edge.openvex.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tip: leave the purl &lt;strong&gt;qualifiers off&lt;/strong&gt; in statement products. Per Trivy's&lt;br&gt;
matching rules, a purl without qualifiers matches regardless of &lt;code&gt;arch&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;repository_url&lt;/code&gt;; with qualifiers, you're signing up for exact matching.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3, The instant win: &lt;code&gt;--vex&lt;/code&gt; flag
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy image &lt;span class="nt"&gt;--show-suppressed&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vex&lt;/span&gt; pingaccess-8.3.4-edge.openvex.json &lt;span class="se"&gt;\&lt;/span&gt;
  pingidentity/pingaccess:8.3.4-edge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;70 findings → 0 reported, 70 suppressed.&lt;/strong&gt; Each suppression is recorded in&lt;br&gt;
the JSON report under &lt;code&gt;Results[].ExperimentalModifiedFindings&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;Type: ignored&lt;/code&gt; and the justification, auditable, not deleted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4, Publish via a VEX repository
&lt;/h2&gt;

&lt;p&gt;A VEX repository is just static files: a manifest at&lt;br&gt;
&lt;code&gt;/.well-known/vex-repository.json&lt;/code&gt; and a tar.gz archive containing an index&lt;br&gt;
plus the documents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vex-repo/                       # webroot
├── .well-known/vex-repository.json
└── v0.1/vex-data.tar.gz        # contains: index.json + pkg/oci/pingaccess/vex.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DarkEdges Demo VEX Repository"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"versions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"spec_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"locations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://vex-server/v0.1/vex-data.tar.gz"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"update_interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1h"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in &lt;code&gt;~/.trivy/vex/repository.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repositories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;darkedges-demo&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://vex-server&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;trivy vex repo download&lt;/code&gt; fetches it, and scanning with &lt;code&gt;--vex repo&lt;/code&gt;&lt;br&gt;
gives the same result: &lt;strong&gt;0 findings, 70 suppressed&lt;/strong&gt;, except now the&lt;br&gt;
statements are centrally hosted, versioned, and every Trivy in your org picks&lt;br&gt;
them up automatically on the manifest's update interval.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three gotchas
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. The repository index needs &lt;code&gt;repository_url&lt;/code&gt; for OCI purls
&lt;/h3&gt;

&lt;p&gt;This one cost me an hour. The vex-repo-spec says index &lt;code&gt;id&lt;/code&gt;s are purls&lt;br&gt;
&lt;em&gt;"without version and qualifiers"&lt;/em&gt;, so &lt;code&gt;pkg:oci/pingaccess&lt;/code&gt;, right?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No match. Silently.&lt;/strong&gt; Trivy's index lookup (&lt;code&gt;pkg/vex/repo.go&lt;/code&gt;) makes an&lt;br&gt;
exception for OCI purls and &lt;strong&gt;keeps the &lt;code&gt;repository_url&lt;/code&gt; qualifier&lt;/strong&gt; as part&lt;br&gt;
of the package identity. The index entry must be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pkg:oci/pingaccess?repository_url=index.docker.io%2Fpingidentity%2Fpingaccess"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pkg/oci/pingaccess/vex.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openvex"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your repo downloads fine but suppresses nothing, check this first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Embedding VEX in the image does nothing (for scanning)
&lt;/h3&gt;

&lt;p&gt;Docker's Hardened Images article mentions copying VEX documents into the&lt;br&gt;
image at build time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; pingidentity/pingaccess:8.3.4-edge&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pingaccess-8.3.4-edge.openvex.json /usr/share/vex/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested it: &lt;strong&gt;Trivy does not auto-discover VEX from the image filesystem.&lt;/strong&gt;&lt;br&gt;
A plain scan of the derived image still reports all 70 findings. Embedding is&lt;br&gt;
a &lt;em&gt;distribution&lt;/em&gt; mechanism, the consumer must extract the file and pass it&lt;br&gt;
via &lt;code&gt;--vex&lt;/code&gt;. (What Trivy &lt;em&gt;can&lt;/em&gt; auto-discover is VEX attached as a signed OCI&lt;br&gt;
&lt;strong&gt;registry attestation&lt;/strong&gt;, &lt;code&gt;trivy image --vex oci&lt;/code&gt;, which requires pushing.)&lt;/p&gt;
&lt;h3&gt;
  
  
  3. VEX binds to the digest, derived images don't inherit it
&lt;/h3&gt;

&lt;p&gt;The statements identify the product by the base image's digest. My derived&lt;br&gt;
image (built locally, never pushed) has &lt;strong&gt;no repo digest at all&lt;/strong&gt;, so neither&lt;br&gt;
the local file nor the repository suppressed anything on it. Expected, but&lt;br&gt;
worth internalizing: &lt;strong&gt;issue VEX for the digest you actually ship&lt;/strong&gt;, and&lt;br&gt;
regenerate when it changes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mechanism&lt;/th&gt;
&lt;th&gt;original image (digest-pinned)&lt;/th&gt;
&lt;th&gt;derived image&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--vex &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ 70/70 suppressed&lt;/td&gt;
&lt;td&gt;❌ 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--vex repo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ 70/70 suppressed&lt;/td&gt;
&lt;td&gt;❌ 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;embedded file alone&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;❌ 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  What about Wiz?
&lt;/h2&gt;

&lt;p&gt;Wiz ingests OpenVEX &lt;strong&gt;automatically, zero config&lt;/strong&gt;, but from &lt;strong&gt;registry&lt;br&gt;
attestations&lt;/strong&gt;, not files or repositories. The hand-off from this workflow is&lt;br&gt;
one command against the image in a Wiz-scanned registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker scout attestation add &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; pingaccess-8.3.4-edge.openvex.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--predicate-type&lt;/span&gt; https://openvex.dev/ns/v0.2.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;registry&amp;gt;/&amp;lt;org&amp;gt;/pingaccess:8.3.4-edge
&lt;span class="c"&gt;# or: cosign attest --type openvex --predicate &amp;lt;file&amp;gt; &amp;lt;image@digest&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same OpenVEX documents, two consumers: Trivy via the repository, Wiz via the&lt;br&gt;
attestation. That's the point of a standard.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The repo runs the whole thing, toolchain build, baseline, 51 statements,&lt;br&gt;
hosted repository, all the re-scans, with one command and colored output&lt;br&gt;
(there's a GIF of the full run in the README):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/darkedges/trivy-vex-demo
&lt;span class="nb"&gt;cd &lt;/span&gt;trivy-vex-demo
./run.sh        &lt;span class="c"&gt;# pauses after each step; ./run.sh -y to run unattended&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is pushed anywhere; the only network traffic is Docker Hub pulls and&lt;br&gt;
Trivy DB downloads. PowerShell users get an equivalent &lt;code&gt;run.ps1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've hit other VEX edge cases, CycloneDX VEX, CSAF, Grype's matching&lt;br&gt;
behaviour, I'd love to hear about them in the comments.&lt;/p&gt;

</description>
      <category>security</category>
      <category>nextjs</category>
      <category>devops</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Building CIS-Hardened, SBOM-Attested CentOS 9 Golden Images with Packer, QEMU and PingAccess - entirely on WSL2</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 28 Jun 2026 22:50:53 +0000</pubDate>
      <link>https://dev.to/darkedges/uilding-cis-hardened-sbom-attested-centos-9-golden-images-with-packer-qemu-and-pingaccess--348n</link>
      <guid>https://dev.to/darkedges/uilding-cis-hardened-sbom-attested-centos-9-golden-images-with-packer-qemu-and-pingaccess--348n</guid>
      <description>&lt;p&gt;Most "golden image" pipelines assume a cloud builder or a beefy Linux box. This one runs end-to-end on a Windows laptop, inside &lt;strong&gt;WSL2 with nested KVM&lt;/strong&gt;, and produces a CentOS 9 Stream QCOW2 image that is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CIS Level 1 hardened&lt;/strong&gt; (via the official &lt;code&gt;ansible-lockdown&lt;/code&gt; role)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-loaded with PingAccess 8.3.5&lt;/strong&gt; on a JRE 17 runtime&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipped with a signed SBOM and VEX attestations&lt;/strong&gt; so a Trivy scan tells you what's &lt;em&gt;actually&lt;/em&gt; exploitable - not just what's &lt;em&gt;theoretically&lt;/em&gt; vulnerable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is driven by a single &lt;code&gt;./build.sh&lt;/code&gt;. Here's how it fits together, and the WSL2-specific gotchas I had to solve along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the pipeline
&lt;/h2&gt;

&lt;p&gt;The build is split into two Packer sources that run &lt;strong&gt;sequentially&lt;/strong&gt;, because the second one boots the artifact the first one produced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CentOS 9 Stream ISO
        │  Build 1 - "harden"
        ▼
  kickstart install ──► CIS Level 1 hardening (Ansible) ──► base SBOM
        │
        ▼
  output/base/centos9-base.qcow2
        │  Build 2 - "pingaccess"
        ▼
  JRE 17 + PingAccess ──► VEX/SBOM artifacts ──► app SBOM
        │
        ▼
  output/app/centos9-app.qcow2
        │  "sbom" target
        ▼
  Trivy scan (VEX-suppressed) + cosign-signed VEX + CVE report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build.sh&lt;/code&gt; exposes each stage as a target so you can iterate on just the part you're changing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build.sh harden        &lt;span class="c"&gt;# ISO → CIS-hardened base image&lt;/span&gt;
./build.sh pingaccess    &lt;span class="c"&gt;# base image → Java + PingAccess + security artifacts&lt;/span&gt;
./build.sh sbom          &lt;span class="c"&gt;# scan + sign an existing app image&lt;/span&gt;
./build.sh               &lt;span class="c"&gt;# all of the above, end to end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build 1: a CIS-compliant base image from a kickstart
&lt;/h2&gt;

&lt;p&gt;The foundation is an automated CentOS 9 Stream install. The interesting part is the &lt;strong&gt;partition layout&lt;/strong&gt; - CIS expects several mount points to be isolated filesystems with restrictive flags, so the kickstart lays them out as separate LVM logical volumes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;part /boot            xfs   512 MiB
volgroup centos_vg
  lv_root      /                6 GiB   xfs
  lv_tmp       /tmp            512 MiB  xfs   nodev,nosuid,noexec
  lv_var       /var              2 GiB  xfs
  lv_varlog    /var/log        512 MiB  xfs
  lv_audit     /var/log/audit  512 MiB  xfs
  lv_home      /home           512 MiB  xfs   nodev,nosuid
  lv_swap      swap              1 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SELinux is left &lt;strong&gt;enforcing&lt;/strong&gt;, the firewall is enabled with only SSH open, and unused network services are stripped out.&lt;/p&gt;

&lt;p&gt;Hardening itself is delegated to the maintained &lt;code&gt;ansible-lockdown.rhel9_cis&lt;/code&gt; role rather than a hand-rolled script. A few overrides keep it Packer-friendly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rhel9cis_level_1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;rhel9cis_level_2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;os_check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;                 &lt;span class="c1"&gt;# role asserts != CentOS; Stream == RHEL 9 controls&lt;/span&gt;
&lt;span class="na"&gt;skip_reboot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;               &lt;span class="c1"&gt;# Packer powers off after all provisioners&lt;/span&gt;
&lt;span class="na"&gt;rhel9cis_rule_5_2_2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;      &lt;span class="c1"&gt;# "Defaults use_pty" hangs sudo under pipelining&lt;/span&gt;
&lt;span class="na"&gt;rhel9cis_sudoers_exclude_nopasswd_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;packer&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# keep build sudo mid-play&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last group is the kind of thing you only learn by watching a build hang at 80%: CIS rule 5.2.2 adds &lt;code&gt;Defaults use_pty&lt;/code&gt; to sudoers, which deadlocks &lt;code&gt;sudo&lt;/code&gt; when Ansible pipelining is on and no PTY is allocated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build 2: PingAccess, the noexec-/tmp problem, and a lean image
&lt;/h2&gt;

&lt;p&gt;Build 2 boots the hardened base over SSH (key-based now - password auth is gone) and layers on Java 17 and PingAccess.&lt;/p&gt;

&lt;p&gt;The first surprise: &lt;strong&gt;&lt;code&gt;/tmp&lt;/code&gt; is 512 MiB and mounted &lt;code&gt;noexec&lt;/code&gt;&lt;/strong&gt; thanks to the CIS layout. That breaks two assumptions at once - the PingAccess archive doesn't fit, and you can't execute staged binaries from there. The fix is to stage and extract under &lt;code&gt;/opt&lt;/code&gt; (which lives on the 6 GiB root LV):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PA&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Extract&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;PingAccess&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;archive"&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.unarchive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/pa_staging.zip&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/pa_extract/&lt;/span&gt;
    &lt;span class="na"&gt;remote_src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To keep the image lean, the role deletes the PingAccess SDK samples after install (nobody runs the Maven sample projects in production), and the final Packer step does &lt;code&gt;dnf clean all&lt;/code&gt; + &lt;code&gt;fstrim -av&lt;/code&gt;. Combined with &lt;code&gt;disk_discard = "unmap"&lt;/code&gt; and &lt;code&gt;disk_compression = true&lt;/code&gt;, the TRIM is the single biggest lever on final image size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part: shipping security context &lt;em&gt;inside&lt;/em&gt; the image
&lt;/h2&gt;

&lt;p&gt;A plain vulnerability scan of any real product image is mostly noise - hundreds of CVEs in transitive dependencies that aren't reachable, aren't loaded, or are already mitigated. The goal here was an image that carries its own answer to "is this actually exploitable?"&lt;/p&gt;

&lt;h3&gt;
  
  
  SBOMs generated &lt;em&gt;inside&lt;/em&gt; the VM
&lt;/h3&gt;

&lt;p&gt;Rather than scanning the QCOW2 from the host (which gives you the host's view, and made my base and app SBOMs come out identical at one point), SBOM generation runs &lt;strong&gt;inside each VM during its build&lt;/strong&gt; via a small &lt;code&gt;generate_sbom&lt;/code&gt; Ansible role. It downloads &lt;code&gt;syft&lt;/code&gt;, scans &lt;code&gt;/&lt;/code&gt;, writes the SBOM to &lt;code&gt;/opt/security/sbom/&lt;/code&gt;, then fetches a copy back to the host and removes the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SBOM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Generate&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SBOM"&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;syft / --output spdx-json&lt;/span&gt;
      &lt;span class="s"&gt;--file {{ sbom_output_dir }}/{{ sbom_filename }}&lt;/span&gt;
      &lt;span class="s"&gt;{% for e in sbom_excludes %}--exclude {{ e }} {% endfor %}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SBOM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Fetch&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SBOM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;host"&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.fetch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sbom_output_dir&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sbom_filename&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sbom_host_dest&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;flat&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you get a true base SBOM (OS only) and a true app SBOM (OS + JRE + PingAccess), and both are &lt;em&gt;embedded in the image&lt;/em&gt; for later audit.&lt;/p&gt;

&lt;h3&gt;
  
  
  VEX: turning "vulnerable" into "not affected"
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/openvex/vexctl" rel="noopener noreferrer"&gt;OpenVEX&lt;/a&gt; statements describe whether a product is actually affected by a given CVE. The pipeline collects &lt;code&gt;*.openvex.json&lt;/code&gt; statements, merges them with &lt;code&gt;vexctl&lt;/code&gt;, signs the consolidated document with &lt;code&gt;cosign&lt;/code&gt;, and embeds it at &lt;code&gt;/opt/security/vex/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cosign generate-key-pair &lt;span class="nt"&gt;--output-key-prefix&lt;/span&gt; cosign   &lt;span class="c"&gt;# COSIGN_PASSWORD="" for unattended&lt;/span&gt;
cosign sign-blob &lt;span class="nt"&gt;--key&lt;/span&gt; cosign.key &lt;span class="nt"&gt;--yes&lt;/span&gt; consolidated.openvex.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Keyless OIDC signing is tempting but hangs in an unattended build - a local key with an empty password is the right call for CI.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Trivy + VEX = signal, not noise
&lt;/h3&gt;

&lt;p&gt;Finally, Trivy scans the mounted image filesystem with the VEX document applied, so suppressed findings are &lt;em&gt;shown but explained&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy fs &lt;span class="nt"&gt;--vex&lt;/span&gt; consolidated.openvex.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--severity&lt;/span&gt; CRITICAL,HIGH,MEDIUM,LOW &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--show-suppressed&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; json image-root/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small Python summariser (no &lt;code&gt;jq&lt;/code&gt; dependency) turns the JSON into a per-severity CVE count, including how many were suppressed by VEX. There's a &lt;code&gt;NO_VEX=1&lt;/code&gt; switch to see the raw, unsuppressed picture when you want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mounting a QCOW2 offline without a running VM
&lt;/h2&gt;

&lt;p&gt;To scan the image's &lt;em&gt;filesystem&lt;/em&gt; (not just packages), &lt;code&gt;build.sh&lt;/code&gt; mounts the QCOW2 read-only via &lt;code&gt;qemu-nbd&lt;/code&gt;. Two tricks make this reliable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-nbd &lt;span class="nt"&gt;--connect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/nbd0 &lt;span class="nt"&gt;--snapshot&lt;/span&gt; app.qcow2   &lt;span class="c"&gt;# CoW overlay → no exclusive lock&lt;/span&gt;
kpartx &lt;span class="nt"&gt;-av&lt;/span&gt; /dev/nbd0
pvscan &lt;span class="nt"&gt;--cache&lt;/span&gt;
vgchange &lt;span class="nt"&gt;-ay&lt;/span&gt; centos_vg
udevadm settle
mount &lt;span class="nt"&gt;-t&lt;/span&gt; xfs &lt;span class="nt"&gt;-o&lt;/span&gt; ro,norecovery /dev/centos_vg/lv_root /mnt/packer-scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--snapshot&lt;/code&gt; writes changes to a throwaway overlay, so the original file is never locked exclusively - you can even scan while being careful about a running VM. And because the root filesystem lives on &lt;strong&gt;LVM&lt;/strong&gt;, you can't just mount the partition; you have to &lt;code&gt;pvscan&lt;/code&gt;/&lt;code&gt;vgchange&lt;/code&gt; to activate the volume group first. The &lt;code&gt;norecovery&lt;/code&gt; mount option avoids XFS log replay on a read-only device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;A companion &lt;code&gt;run.sh&lt;/code&gt; boots either image under QEMU/KVM with port forwarding wired up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./run.sh           &lt;span class="c"&gt;# boot the app image (PingAccess)&lt;/span&gt;
./run.sh ssh       &lt;span class="c"&gt;# boot + drop into an SSH session&lt;/span&gt;
./run.sh stop      &lt;span class="c"&gt;# graceful shutdown over SSH, SIGTERM fallback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2222  → 22     SSH
19000 → 9000   PingAccess Admin UI
13000 → 3000   PingAccess Engine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  WSL2 lessons worth stealing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;-cpu host&lt;/code&gt;.&lt;/strong&gt; The default &lt;code&gt;qemu64&lt;/code&gt; vCPU lacks SSE4.2/XSAVE and the CentOS 9 kernel panics on boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build to an ext4 path, not &lt;code&gt;/mnt/c&lt;/code&gt; or &lt;code&gt;/mnt/e&lt;/code&gt;.&lt;/strong&gt; QCOW2 I/O over the 9P/NTFS bridge is unreliable; build on the Linux filesystem and copy out afterward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;q35&lt;/code&gt; machine type&lt;/strong&gt; is required - CentOS 9's initramfs uses AHCI for CD-ROM access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scp_extra_args = -O&lt;/code&gt;&lt;/strong&gt; forces legacy SCP mode, which Ansible's file transfers need against the hardened sshd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trivy reads embedded SBOM/SPDX files&lt;/strong&gt; and will report CVEs for components listed there even after you've deleted them from disk. Skip those directories in the scan, or it'll look like your cleanup didn't work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The result is a reproducible, single-command build that turns an ISO into a hardened, application-ready image whose security posture travels &lt;em&gt;with&lt;/em&gt; it: an embedded SBOM that says what's inside, signed VEX attestations that say what's actually exploitable, and a Trivy report that separates signal from noise - all on a laptop.&lt;/p&gt;

&lt;p&gt;If you're building golden images, consider making the SBOM and VEX first-class build artifacts rather than an afterthought bolted on at scan time. The difference between "412 CVEs" and "412 CVEs, 6 actually relevant, here's the signed proof" is the difference between a report nobody reads and one a security team can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub
&lt;/h2&gt;

&lt;p&gt;The repository is at &lt;a href="https://github.com/darkedges/centos9-qemu" rel="noopener noreferrer"&gt;https://github.com/darkedges/centos9-qemu&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devsecops</category>
      <category>packer</category>
      <category>supplychainsecurity</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
