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.
70 lines
3.3 KiB
JavaScript
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();
|