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:
parent
b1ba1fbdc6
commit
359066a3f7
6 changed files with 207 additions and 20 deletions
70
scanners/lib/owasp-map.mjs
Normal file
70
scanners/lib/owasp-map.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// 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();
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
// severity.mjs — Constants, risk score calculation, verdict logic
|
// severity.mjs — Constants, risk score calculation, verdict logic
|
||||||
// Zero dependencies. Used by all scanners and the orchestrator.
|
// Used by all scanners and the orchestrator. No external dependencies; the one
|
||||||
|
// internal import is the commons-backed OWASP_MAP (v8 Phase 5 step 4).
|
||||||
|
|
||||||
|
import { OWASP_MAP } from './owasp-map.mjs';
|
||||||
|
|
||||||
export const SEVERITY = Object.freeze({
|
export const SEVERITY = Object.freeze({
|
||||||
CRITICAL: 'critical',
|
CRITICAL: 'critical',
|
||||||
|
|
@ -107,25 +110,13 @@ export function gradeFromPassRate(passRate, failsInCritCats = 0, critCount = 0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scanner prefix to OWASP LLM Top 10 category mapping.
|
* Scanner prefix to OWASP LLM Top 10 category mapping.
|
||||||
|
*
|
||||||
|
* v8 Phase 5 step 4: built from vendored commons (`mapping/owasp-map.json`)
|
||||||
|
* rather than declared here, and re-exported so this module's published
|
||||||
|
* surface is unchanged. The three maps below are NOT swapped — they have no
|
||||||
|
* production consumer, only tests and golden artifacts; see owasp-map.mjs.
|
||||||
*/
|
*/
|
||||||
export const OWASP_MAP = Object.freeze({
|
export { OWASP_MAP };
|
||||||
UNI: ['LLM01'],
|
|
||||||
ENT: ['LLM01', 'LLM03'],
|
|
||||||
PRM: ['LLM06'],
|
|
||||||
DEP: ['LLM03'],
|
|
||||||
TNT: ['LLM01', 'LLM02'],
|
|
||||||
GIT: ['LLM03'],
|
|
||||||
NET: ['LLM02', 'LLM03'],
|
|
||||||
TFA: ['LLM01', 'LLM02', 'LLM06'],
|
|
||||||
MCI: ['LLM01', 'LLM02'],
|
|
||||||
MEM: ['LLM01'],
|
|
||||||
SCR: ['LLM03'],
|
|
||||||
PST: ['LLM01', 'LLM06'],
|
|
||||||
WFL: ['LLM02', 'LLM06'],
|
|
||||||
TRG: ['LLM06'],
|
|
||||||
SIG: ['LLM03', 'LLM02'],
|
|
||||||
AST: ['LLM01', 'LLM02'],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scanner prefix to OWASP Agentic AI Top 10 (ASI) category mapping.
|
* Scanner prefix to OWASP Agentic AI Top 10 (ASI) category mapping.
|
||||||
|
|
|
||||||
4
tests/fixtures/commons-empty/mapping/owasp-map.json
vendored
Normal file
4
tests/fixtures/commons-empty/mapping/owasp-map.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"id": "owasp-map"
|
||||||
|
}
|
||||||
17
tests/fixtures/commons-malformed-owasp/mapping/owasp-map.json
vendored
Normal file
17
tests/fixtures/commons-malformed-owasp/mapping/owasp-map.json
vendored
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -565,7 +565,7 @@
|
||||||
{
|
{
|
||||||
"kind": "file",
|
"kind": "file",
|
||||||
"key": "scanners/lib/severity.mjs",
|
"key": "scanners/lib/severity.mjs",
|
||||||
"sha256": "7c9a5b0ca9cd99e96af24960a7fb2efe9b1073346f7e1edd61dc7c717a4bac3d"
|
"sha256": "a2dc3db21a3db3bfd4dd678ae20722b94850ac6b7153015893fd7ba2a2e71896"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "file",
|
"kind": "file",
|
||||||
|
|
|
||||||
105
tests/lib/owasp-map.test.mjs
Normal file
105
tests/lib/owasp-map.test.mjs
Normal 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'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue