// 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'] }); }); }); });