Commit graph

4 commits

Author SHA1 Message Date
359066a3f7 refactor(llm-security): v8 Phase 5 step 4 - swap OWASP_MAP to commons
Second consumer swap of step 4. OWASP_MAP stops being a hardcoded constant in
severity.mjs and is built from the vendored commons artifact
mapping/owasp-map.json by a new scanners/lib/owasp-map.mjs, re-exported from
severity.mjs so the published surface (which the golden gate walks as
severity:OWASP_MAP) is unchanged.

Scope is one of the four maps commons publishes, and the omission is measured,
not incidental. OWASP_MAP has a production consumer: owaspCategorize() reads it
as the per-scanner fallback, and that reaches real report output through
output.mjs's owasp_breakdown. OWASP_AGENTIC_MAP, OWASP_SKILLS_MAP and
OWASP_MCP_MAP have none - every reference tree-wide is a test or a golden
artifact - so they stay source literals, the same call already made for
cyrillic_confusables in the first swap. Porting them would move data no runtime
reads into the load path.

Measured byte-likeness before the swap, all four taxonomies: same 16 prefixes,
same insertion order, same code arrays. Loadable verbatim, unlike the injection
table.

Content preservation proven the same way as the codepoint swap: the golden dump
differs in exactly one record, the sha256 of severity.mjs, which changes by
construction when a table leaves the file. All 83 regex records and all 7 table
records including severity:OWASP_MAP are byte-identical; reference-run.json
unchanged at 61/61. patterns.json re-blessed for the file digest only.

New property, not just preservation: the golden gate now pins the vendored
commons data transitively for this table too. Mutation-proven in both
directions - changing one code value and deleting a whole prefix each turn
three independent gates red (golden table digest, the new owasp-map gate by
name, and the pre-existing severity behaviour tests).

Entries are validated rather than trusted: commons is vendored data, and a
value that is not an array of strings would be spread straight into
owaspCategorize's category list, so a malformed entry is dropped. Graceful-empty
on an unresolvable commons, matching commons-loader's contract - severity.mjs is
on the import path of output.mjs and every orchestrated scanner, so a load throw
would abort a scan rather than degrade it.

Suite 2164 -> 2173, all green.
2026-08-11 12:53:48 +02:00
b1ba1fbdc6 refactor(llm-security): v8 Phase 5 step 4 - swap codepoint tables to commons
First consumer swap of step 4. ZERO_WIDTH_CHARS (5), the Unicode Tag range,
BIDI_CHARS (9) and HOMOGLYPH_MAP (28) stop being hardcoded constants in
unicode-scanner.mjs and string-utils.mjs and are built from the vendored
commons artifact codepoints/carriers.json by the new lib/codepoints.mjs.

Started here rather than at injection-patterns, which the plan ordered first:
that table is the one table that cannot be loaded verbatim (the
hybrid-xss:script-tag divergence is directional, and loading the lexicon as-is
would reverse the 90f576f recall fix). The codepoint tables were measured
byte-equal to the source constants BEFORE the swap - same members, same
values, same insertion order on HOMOGLYPH_MAP - so they load verbatim.

Proof the swap is content-preserving: the golden dump differs in exactly one
record, the sha256 of string-utils.mjs, which changes by construction when a
table leaves the file. All 83 regex records and the
table:string-utils:HOMOGLYPH_MAP digest are byte-identical, and
reference-run.json is unchanged at 61/61. patterns.json is re-blessed for the
file digest alone.

The gate is proven red-capable against the SUBJECT, both directions:
- dropping U+00AD from the vendored zero_width table fails the new
  codepoints gate by name, twice;
- altering one homoglyph value reddens the golden table digest AND a
  behavioural homoglyph test.
That second direction is a property the swap creates rather than preserves:
the golden gate now transitively pins the vendored commons data, where before
it pinned a source literal and a commons mutation was invisible to it.

NOT ported: commons carries cyrillic_confusables (13), and unicode-scanner.mjs
declares a set by that name - but nothing reads it. The homoglyph-mixing
detector tests isCyrillic(cp), the whole U+0400-U+04FF block. Loading it would
move dead data into the load path, so the dead const stays where it is and is
recorded instead. The recorded v8.x-B i/x drift between that set and the
lexicon class is therefore latent, not live. commons' private_use table has no
constant behind it here at all.

Graceful-empty is kept deliberately: codepoints.mjs is on string-utils'
import path and hooks import string-utils in fresh per-tool-call processes, so
a module-load throw would break the tool call rather than degrade the scan.
The loud half is the test, which asserts exact per-table counts through the
real default commons root - the same shape as the lexicon load-assertion.

Drive-by, unavoidable: the deleted JSDoc carried the "~25 entries" claim for a
28-entry table (v8.x-C). It needed a re-bless of the same file digest this
swap already forces, so it closes here at no extra cost.

Suite 2158 -> 2164, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7XEEFrAJsREqa9N4tpfm8
2026-08-10 21:28:09 +02:00
90f576f056 fix(llm-security): v8.x-A - close <script> recall hole, add whole-table ReDoS gate
Two of the three confirmed v8.x-A evasions are closed; the third (attribute
padding) stays open by operator decision and is documented, not silently left.

Recall fix. hybrid-xss required a closing </script>, so `<script>alert(1)` and
`<script src=x.js>` both passed scanForInjection() with found: false, while the
closed form returned high. A src= tag has no body to close in the first place.
The opening tag alone is the signal, and matching it is strictly linear: one
negated-class run whose excluded character is its own terminator, so there is
no backtracking surface that could re-introduce v7.8.3 #24.

ReDoS gate. The #24 test covered six html-obfuscation patterns against the two
shapes that defect was found on. A catastrophically backtracking regex added
anywhere else in the four tables would have failed no test at all - which is
why #24 had to be found by hand. The new gate times every exported pattern and
asserts its own coverage, so it cannot be narrowed silently.

It has two classes because the blowup shapes need opposite inputs, and because
a synchronous RegExp.test() cannot be interrupted: an exponential pattern met
with a 512KB input would HANG the run rather than fail it. Class 1 uses a
28-char ambiguous-run ladder, where exponential costs ~1s and anything sane
costs microseconds; on a hit the 512KB sweeps refuse to run. Class 2 uses the
hook's real read cap to catch the polynomial #24 class. Proven to fire: an
injected exponential regex was named by label and the hang guard held.

Measured clean: worst single pattern 17ms, full large sweep 218ms.

Golden gate went red as expected and was re-blessed - diff is exactly two
lines, the regex source and the source-file digest. The 61-payload reference
run is byte-identical, so no showcase behaviour moved.

Suite 2073/2073 (was 2063; +5 recall tests, +5 gate tests).

Still open, unchanged: all 7 bounded HTML patterns evade on >256 chars of
padding, in two positions - before the attribute and inside the style value.
Wider than STATE recorded, which named only aria-label.
2026-08-10 14:53:29 +02:00
8d990e06d3 test(llm-security): v8 Phase 5 step 1 - golden baseline before any table swap
Records the reference artifacts Phase 5 swaps will be measured against, and
the gate that reads them. No extraction yet: this is the "before" picture,
and it had to land first or every later comparison would be confounded.
Typed test/ rather than feat/ deliberately - the only production change is
one widened export; the rest is gate, artifacts and generator.

Written failing-first (7 red on missing artifacts), then generated.

Three layers, because the plan's "assert .source/.flags of every regex" is
necessary but not sufficient:

  1. regex records - .source/.flags off the COMPILED object. After a swap a
     pattern is new RegExp(jsonString, flags), so the plan's named hazard
     (JSON backslash-doubling on 83+18 regexes) is visible here and nowhere
     else. Source-text comparison cannot see it.
  2. table records - key/value digests. Most of what Phase 4 moves is not a
     regex at all: HOMOGLYPH_MAP (x3, AS-IS), the typosquat tokens and the
     four OWASP maps are char->char and string->string data. A regex-only
     dump is blind to a broken homoglyph swap, i.e. to the bulk of the
     payload. Operator decision: widen the dump.
  3. file records - sha256 of the five moving-set sources. This dissolves
     STATE's open question (how to enumerate every regex): it is complete by
     construction, covering inline regexes in function bodies that no export
     walk reaches, with no JS parser in a zero-dep repo. A lexical count
     would have pinned a lie - severity.mjs scores 4 "regexes" that way and
     exports none.

Both layers were proven to fire, not assumed to: mutating one HOMOGLYPH_MAP
entry reddens the table layer, and widening an inline regex inside
decodeHexEscapes (unreachable by any export walk) reddens the file layer.

HOMOGLYPH_MAP is now exported from string-utils.mjs. That export is a source
change the plan already flags as a surface hazard ("private tables become
loaded"), so it is pre-paid here rather than confounding the before/after.

Reference run: the 61 showcase payloads through the real hook entry points,
sequentially - array order is semantic and the plan forbids key-sorting, so
concurrency is removed rather than sorted away. 61/61 match expectation,
which also settles the plan's open assumption that payloads.json expectations
match current behaviour. Coverage is recorded, not assumed: 47/83 patterns,
with the other 36 listed by key so the gate never implies coverage it lacks.

Suite counts are per-file, each file run alone - a total is unattributable,
and the three known timing-sensitive files flake only under concurrency.
2045 pass / 0 fail across 91 files, matching the pre-existing count exactly.
The gate's own file is excluded (it reads the artifact the run produces) and
that exclusion is named in the artifact.

Suite: 2053/2053, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
2026-08-09 12:57:51 +02:00