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.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-11 12:53:48 +02:00
commit 359066a3f7
6 changed files with 207 additions and 20 deletions

View file

@ -0,0 +1,4 @@
{
"version": "0.2.0",
"id": "owasp-map"
}

View file

@ -0,0 +1,17 @@
{
"version": "0.2.0",
"id": "owasp-map",
"$comment": "Fixture: one well-formed entry among malformed ones. Every non-conforming value below must be dropped by the builder, not published to owaspCategorize.",
"taxonomies": {
"llm": {
"source_export": "OWASP_MAP",
"map": {
"UNI": ["LLM01"],
"ENT": "LLM03",
"PRM": [42],
"DEP": null,
"TNT": {}
}
}
}
}

View file

@ -565,7 +565,7 @@
{
"kind": "file",
"key": "scanners/lib/severity.mjs",
"sha256": "7c9a5b0ca9cd99e96af24960a7fb2efe9b1073346f7e1edd61dc7c717a4bac3d"
"sha256": "a2dc3db21a3db3bfd4dd678ae20722b94850ac6b7153015893fd7ba2a2e71896"
},
{
"kind": "file",

View file

@ -0,0 +1,105 @@
// owasp-map.test.mjs — Tests for the commons-backed OWASP LLM prefix map.
//
// v8 Phase 5 step 4, second consumer swap: OWASP_MAP stops being a hardcoded
// constant in severity.mjs and is built from the vendored commons artifact
// `mapping/owasp-map.json` instead.
//
// Scope is deliberately one of the four maps commons publishes, not all four.
// `OWASP_MAP` has a production consumer: `owaspCategorize()` reads it as the
// per-scanner fallback (severity.mjs), and that function reaches real report
// output through `scanners/lib/output.mjs` (`owasp_breakdown`). The other
// three — OWASP_AGENTIC_MAP, OWASP_SKILLS_MAP, OWASP_MCP_MAP — are referenced
// only by tests and golden artifacts; measured tree-wide, no runtime reads
// them. Porting those would move data no runtime consumes into the load path,
// which is the same call already made for `cyrillic_confusables` in the first
// swap.
//
// Two things asserted here that no other gate covers:
//
// 1. A POSITIVE load through the real DEFAULT_COMMONS_ROOT. The loader's
// graceful-empty contract means a lost commons is indistinguishable from
// a legitimately empty map — `owaspCategorize` would simply file every
// finding under 'Unmapped' and stay green everywhere else. Exact counts
// and named entries make that failure loud.
// 2. The graceful direction itself, asserted through the CONSUMER rather
// than only the table: an unresolvable commons must degrade
// `owaspCategorize` to 'Unmapped', not throw. severity.mjs is on the
// import path of output.mjs and of every orchestrated scanner.
//
// Array order inside an entry is semantic (it reaches report output in this
// order), so the values are compared with deepEqual, never as sets.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildOwaspMap, OWASP_MAP } from '../../scanners/lib/owasp-map.mjs';
import { OWASP_MAP as SEVERITY_OWASP_MAP, owaspCategorize } from '../../scanners/lib/severity.mjs';
describe('owasp-map (commons mapping)', () => {
describe('positive load through the real default commons root', () => {
it('builds all 16 scanner prefixes', () => {
assert.equal(Object.keys(OWASP_MAP).length, 16,
'OWASP_MAP lost prefixes — is scanners/commons vendored?');
});
it('preserves the prefix order the pre-swap constant declared', () => {
// Insertion order is not decorative here: it is what a reader of the
// module saw, and re-sorting it in commons would be an invisible change.
assert.deepEqual(Object.keys(OWASP_MAP), [
'UNI', 'ENT', 'PRM', 'DEP', 'TNT', 'GIT', 'NET', 'TFA',
'MCI', 'MEM', 'SCR', 'PST', 'WFL', 'TRG', 'SIG', 'AST',
]);
});
it('carries the entries that would expose a truncated or reordered table', () => {
// TFA is the only three-code entry; SIG is the one entry whose codes are
// NOT in ascending order, so a table rebuilt by sorting breaks here.
assert.deepEqual(OWASP_MAP.TFA, ['LLM01', 'LLM02', 'LLM06']);
assert.deepEqual(OWASP_MAP.SIG, ['LLM03', 'LLM02']);
assert.deepEqual(OWASP_MAP.TRG, ['LLM06']);
assert.deepEqual(OWASP_MAP.AST, ['LLM01', 'LLM02']);
});
it('keeps the map frozen, as the pre-swap constant was', () => {
assert.ok(Object.isFrozen(OWASP_MAP));
assert.throws(() => { OWASP_MAP.UNI = ['LLM09']; }, TypeError);
});
});
describe('severity re-export', () => {
it('exports the same table object the mapping module built', () => {
// severity.mjs's OWASP_MAP is published surface — the golden gate walks
// it as `severity:OWASP_MAP`. The swap must not fork it into a copy.
assert.equal(SEVERITY_OWASP_MAP, OWASP_MAP);
});
it('still resolves a finding through the prefix fallback', () => {
const cats = owaspCategorize([{ scanner: 'TFA', severity: 'high' }]);
assert.deepEqual(Object.keys(cats).sort(), ['LLM01', 'LLM02', 'LLM06']);
assert.equal(cats.LLM01.high, 1);
});
});
describe('graceful degradation', () => {
it('yields an empty map when commons is unresolvable', () => {
assert.deepEqual(buildOwaspMap({ commonsRoot: '/nonexistent/commons-root' }), {});
});
it('tolerates an artifact whose taxonomies key is missing entirely', () => {
// A truncated-but-valid JSON artifact must degrade the same way a missing
// file does, rather than throwing on a property of undefined.
const map = buildOwaspMap({
commonsRoot: new URL('../fixtures/commons-empty/', import.meta.url).pathname,
});
assert.deepEqual(map, {});
});
it('drops a malformed entry instead of publishing it', () => {
// commons is vendored data, not code: an entry whose value is not an
// array of strings must not reach owaspCategorize, which spreads it.
const map = buildOwaspMap({
commonsRoot: new URL('../fixtures/commons-malformed-owasp/', import.meta.url).pathname,
});
assert.deepEqual(map, { UNI: ['LLM01'] });
});
});
});