llm-security/scanners/lib/owasp-map.mjs
Kjell Tore Guttormsen 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

70 lines
3.3 KiB
JavaScript

// owasp-map.mjs — Scanner-prefix to OWASP LLM Top 10 map, built from vendored commons.
//
// v8 Phase 5 step 4, second consumer swap. `OWASP_MAP` was a hardcoded
// constant in severity.mjs; it is now built once, here, from
// `mapping/owasp-map.json` in the vendored llm-security-commons subtree.
// Verified byte-equal to the pre-swap constant before the swap: the same 16
// prefixes, in the same order, with the same code arrays.
//
// commons publishes four parallel maps in that one artifact. This module
// builds ONE of them, and the omission is a measurement, not an oversight:
//
// - `taxonomies.llm` (OWASP_MAP) has a production consumer —
// `owaspCategorize()` reads it as the per-scanner fallback, and that
// function reaches real report output via output.mjs's `owasp_breakdown`.
// - `taxonomies.agentic` / `.skills` / `.mcp` (OWASP_AGENTIC_MAP,
// OWASP_SKILLS_MAP, OWASP_MCP_MAP) have none. Measured tree-wide at
// b1ba1fb: every reference is a test or a golden artifact. They stay
// source literals in severity.mjs — loading data no runtime consumes
// would move it into the load path for nothing, the same call already
// made for `cyrillic_confusables` in the first swap.
//
// Graceful-empty, matching commons-loader.mjs's contract: severity.mjs is on
// the import path of output.mjs and of every orchestrated scanner, so a
// module-load throw here would abort a scan rather than degrade it. An empty
// map degrades `owaspCategorize` to 'Unmapped' for findings that carry no
// explicit `owasp` field — visible in a report, not fatal. The cost of that
// choice is that a lost commons is silent at runtime, so the loud half lives
// in `tests/lib/owasp-map.test.mjs`, which asserts the exact prefix count and
// named entries through the real default root.
//
// Zero external dependencies — Node.js builtins only.
import { loadArtifact } from './commons-loader.mjs';
/**
* Build the OWASP LLM prefix map from a commons root.
*
* 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. A malformed entry is dropped, not
* published.
*
* @param {object} [opts]
* @param {string} [opts.commonsRoot] - explicit commons root (tests, dev checkout).
* @returns {Readonly<Record<string, readonly string[]>>} prefix -> OWASP LLM codes
*/
export function buildOwaspMap(opts = {}) {
const artifact = loadArtifact('mapping/owasp-map', { fallback: {}, commonsRoot: opts.commonsRoot });
const source = artifact?.taxonomies?.llm?.map;
const map = {};
if (source !== null && typeof source === 'object') {
for (const [prefix, codes] of Object.entries(source)) {
if (!Array.isArray(codes)) continue;
if (!codes.every((code) => typeof code === 'string')) continue;
// Array order is semantic — it reaches report output in this order — so
// the codes are copied, never sorted.
map[prefix] = Object.freeze([...codes]);
}
}
return Object.freeze(map);
}
/**
* Scanner prefix to OWASP LLM Top 10 category mapping.
*
* An empty array is data, not a gap: it records that the seed implementation
* deliberately maps that prefix to nothing in this taxonomy.
*/
export const OWASP_MAP = buildOwaspMap();