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