// conformance-corpus.test.mjs — runs the vendored llm-security-commons // conformance corpus through the real scanForInjection() entry point. // // v8 Phase 5 step 3. The corpus is a cross-runtime contract: the same 83 cases // are run by llm-security and by llm-ingestion-pipeline-security, so a table // swap in step 4 that quietly changes what we detect shows up here as a named // failing case rather than as nothing at all. Normative semantics live in // scanners/commons/spec/conformance-corpus.md; where this file and that // document disagree, the document wins. // // Three spec rules shape the structure below and are easy to violate by accident: // // §1 A runtime MUST run every case, and a case that cannot be run MUST be // reported as an error, not as a pass. So case discovery is driven by // the manifest's declared list, cross-checked against the directories on // disk — a dropped case dir must fail loudly, not shrink the gate. // // §1.1 A runtime MUST declare the set of commons data files it implements and // publish that set with its result. A case scoped to a file outside the // declared set is `not-applicable` — a third verdict, distinct from §1's // error: §1 means *we tried and could not*, not-applicable means *this // question was never addressed to us*. Such a case MUST still be // enumerated, MUST NOT count as a pass, and MUST NOT leave the // denominator. `83/83 passed, 6 not-applicable` is the shape. // // §4 A runtime MUST reject a `match` it does not implement rather than // degrade to a weaker comparison. Hence the explicit throw. // // §1.1 has a gate of its own: a runtime MUST NOT narrow its declared set to // turn failures into not-applicable. That is enforced here by construction // rather than remembered — DECLARED_TABLES is the *same* constant the runner // uses to accept a scope, so narrowing the declaration narrows what this suite // will run at all, and shows up as a code change. A hand-maintained // declaration would be a claim; a derived one is a measurement. // // Findings are named by commons `pattern_id`, which scanForInjection() does not // carry: it returns our own labels. The lexicon publishes the mapping per // pattern in `aliases.llm_security`, and spec §3.1 permits a runtime that is // registered there — as we are — to compare through it. Measured before this // file was written: the map is a total bijection, 83 labels to 83 ids, no // duplicates, family membership agreeing on every entry. It is therefore a // lookup, not a private translation table: nothing here restates a pattern's // id, severity or label, so nothing here can drift from the lexicon. import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const COMMONS_ROOT = join(__dirname, '..', '..', 'scanners', 'commons'); const CORPUS_ROOT = join(COMMONS_ROOT, 'conformance'); const SUPPORTED_MATCH = 'exact-within-scope'; /** * Spec §1.1: the set of commons data files this runtime implements. * * This is the ONLY scope authority in the file. It is what gets published as * our declaration, and it is what decides whether a case is run — one constant, * two uses, so the two cannot disagree. `signatures/active-content.json` is * absent because we do not implement that table, not because its cases fail; * see the note on the not-applicable verdict below. */ const DECLARED_TABLES = Object.freeze(['lexicon/injection-lexicon.json']); const manifest = JSON.parse(readFileSync(join(CORPUS_ROOT, 'manifest.json'), 'utf8')); /** * case_id -> parsed expected.json, read once at load. * * Eager on purpose: a case the manifest declares but which has no directory is * a §1 error, and erroring here makes the whole file fail loudly rather than * letting the suite shrink to the cases that happen to still exist. */ const caseSpecs = new Map(manifest.cases.map((caseId) => { const specPath = join(CORPUS_ROOT, caseId, 'expected.json'); try { return [caseId, JSON.parse(readFileSync(specPath, 'utf8'))]; } catch (err) { throw new Error(`case ${caseId}: declared by the manifest but unreadable at ${specPath} — spec §1 requires an error, not a shrunken gate (${err.message})`); } })); /** * §1.1 verdict for one case, decided by its scope alone. * * Note what this deliberately cannot express: there is no per-case opt-out. * The verdict attaches to the TABLE, so a case scoped to a table we declare is * run even when it fails. That is the point — per-case opt-out is exactly the * silent skip §1 forbids. */ function isApplicable(caseId) { const scope = caseSpecs.get(caseId).scope || []; if (scope.length === 0) { throw new Error(`case ${caseId}: empty scope — spec §3 requires a scope, and an unscoped case cannot be compared`); } return scope.every((table) => DECLARED_TABLES.includes(table)); } const applicableCases = manifest.cases.filter(isApplicable); const notApplicableCases = manifest.cases.filter((c) => !isApplicable(c)); /** * §1.1's anti-narrowing constraint, as a gate rather than an intention. * * Measured, not assumed: narrowing DECLARED_TABLES to `[]` turns all 89 cases * not-applicable and leaves this suite GREEN with zero cases run — precisely * the exit §1.1 forbids. "Narrowing is visible as a code change" describes a * reviewer, not a gate, so the floor below is derived and asserted instead. * * The floor is deliberately NOT a second hand-maintained list of tables; that * would be the parallel declaration this runtime promised commons it would not * keep, and it would rot against DECLARED_TABLES the first time one moved. It * is read out of the vendored tables themselves: a table whose `aliases` name * `llm_security` has registered this runtime as a consumer (spec §3.1), and a * registered consumer that stops declaring the table is withdrawing a * published claim rather than describing what it implements. */ /** Every data file the corpus can scope a case to — from the manifest alone. */ function corpusTables() { return [...new Set([ ...(manifest.scope_covered || []), ...Object.keys(manifest.scope_planned || {}), ])].filter((key) => key.includes('/') && key.endsWith('.json')); } /** Does this vendored table register `llm_security` as a consumer? */ function registersUs(table) { const tablePath = join(COMMONS_ROOT, table); if (!existsSync(tablePath)) return false; let found = false; (function walk(node) { if (found || node === null || typeof node !== 'object') return; if (!Array.isArray(node) && node.aliases !== null && typeof node.aliases === 'object' && Object.hasOwn(node.aliases, 'llm_security')) { found = true; return; } for (const child of Array.isArray(node) ? node : Object.values(node)) walk(child); })(JSON.parse(readFileSync(tablePath, 'utf8'))); return found; } const REGISTERED_TABLES = corpusTables().filter(registersUs); /** label -> pattern_id, read from the lexicon's published aliases. */ function buildAliasMap() { const lexicon = JSON.parse(readFileSync(join(COMMONS_ROOT, 'lexicon', 'injection-lexicon.json'), 'utf8')); const map = new Map(); for (const family of lexicon.families) { for (const pattern of family.patterns) { const alias = pattern.aliases?.llm_security; if (alias !== undefined) map.set(alias, pattern.id); } } return map; } const aliasMap = buildAliasMap(); /** * Run one case and return { missing, extra } within the case's scope. * Anything the runtime raises that is not a lexicon pattern is out of scope and * ignored by the comparison — spec §4: neither required nor forbidden. */ function runCase(caseId) { const caseDir = join(CORPUS_ROOT, caseId); const expected = caseSpecs.get(caseId); assert.equal(expected.case_id, caseId, 'expected.json case_id must equal its directory name'); if (expected.match !== SUPPORTED_MATCH) { throw new Error(`case ${caseId}: unimplemented match "${expected.match}" — spec §4 forbids degrading to a weaker comparison`); } // Scope is not re-checked here: §1.1 decided it before the case was routed to // this function, and re-deciding it would be the second authority the single // DECLARED_TABLES constant exists to prevent. assert.ok(isApplicable(caseId), `case ${caseId}: routed to the runner despite an undeclared scope`); // §2: bytes, not text. Both integrity fields are checked because two cases // carry characters that are invisible on screen — a zero-width space and a // Cyrillic homoglyph — so an editor that "tidies" a fixture can change what // it tests without changing how it looks. const raw = readFileSync(join(caseDir, expected.input.file)); assert.equal(raw.length, expected.input.bytes, `case ${caseId}: input byte length differs — fixture altered`); assert.equal(createHash('sha256').update(raw).digest('hex'), expected.input.sha256, `case ${caseId}: input sha256 differs — fixture altered, the scan result means nothing`); const result = scanForInjection(raw.toString('utf8')); const observed = new Set(); for (const label of [...result.critical, ...result.high, ...result.medium]) { const patternId = aliasMap.get(label); if (patternId !== undefined) observed.add(patternId); } const expectedIds = new Set((expected.findings || []).map(f => f.pattern_id)); return { missing: [...expectedIds].filter(id => !observed.has(id)), extra: [...observed].filter(id => !expectedIds.has(id)), }; } describe('commons conformance corpus — exact-within-scope through scanForInjection()', () => { before(() => { assert.ok(existsSync(CORPUS_ROOT), 'scanners/commons/conformance is not vendored — run the subtree add'); }); // Self-coverage. Without this, deleting case directories would silently // shrink the gate to nothing while every remaining case still passed. it('runs every case the manifest declares, and no case the manifest omits', () => { const onDisk = readdirSync(CORPUS_ROOT) .filter(entry => statSync(join(CORPUS_ROOT, entry)).isDirectory()) .sort(); assert.deepEqual(onDisk, [...manifest.cases].sort(), 'case directories on disk disagree with manifest.cases'); assert.equal(onDisk.length, manifest.count, 'manifest.count disagrees with its own case list'); assert.equal(manifest.count, 89, 'corpus size changed — re-verify before adjusting this number'); }); it('maps every one of our injection labels onto a lexicon pattern_id', () => { // The comparison is only as exact as the mapping. A label the lexicon does // not name would silently drop out of scope and turn a real extra finding // into a pass, so the bijection is asserted rather than assumed. assert.equal(aliasMap.size, 83, 'lexicon no longer publishes 83 llm_security aliases'); assert.equal(manifest.match_semantics, SUPPORTED_MATCH); }); it('cannot withdraw a table it is registered against (spec §1.1 anti-narrowing)', () => { // Without this, DECLARED_TABLES = [] is a GREEN suite that runs zero cases. // Measured — it was, before this test existed. assert.ok(REGISTERED_TABLES.length > 0, 'no vendored table names llm_security in its aliases — the floor has nothing to stand on, so narrowing the declaration would go undetected again'); for (const table of REGISTERED_TABLES) { assert.ok(DECLARED_TABLES.includes(table), `${table} registers llm_security as a consumer but is not in the declared set — §1.1 forbids withdrawing a table to convert failures into not-applicable; if this runtime genuinely dropped the table, the registration in commons must go first`); } }); it('declares its table set, and the declaration partitions the corpus (spec §1.1)', () => { // 1. The declaration is meaningful only against tables the corpus actually // covers. Declaring a table the corpus does not scope any case to would // be an unfalsifiable claim. for (const table of DECLARED_TABLES) { assert.ok(manifest.scope_covered.includes(table), `declared table ${table} is not in the corpus's scope_covered — the declaration claims something the corpus cannot check`); } // 2. The partition is total and disjoint: every declared case gets exactly // one verdict, so nothing leaves the denominator. assert.deepEqual( [...applicableCases, ...notApplicableCases].sort(), [...manifest.cases].sort(), 'applicable + not-applicable is not the whole corpus — a case has escaped the denominator'); assert.equal(applicableCases.length + notApplicableCases.length, manifest.count); // 3. The counts are DERIVED, not restated. manifest.count_by_scope is the // corpus's own tally per table; our applicable count must equal the sum // over the tables we declare. This is what makes narrowing the // declaration visible: drop a table and this number drops with it. const expectedApplicable = DECLARED_TABLES .reduce((sum, table) => sum + (manifest.count_by_scope[table] ?? 0), 0); assert.equal(applicableCases.length, expectedApplicable, 'applicable count disagrees with manifest.count_by_scope for the declared tables'); // 4. Every not-applicable case is scoped ONLY to undeclared tables. Without // this, a case scoped to a table we declare could slip into the // not-applicable bucket — the per-case opt-out §1.1 forbids. for (const caseId of notApplicableCases) { const scope = caseSpecs.get(caseId).scope; assert.ok(scope.some((t) => !DECLARED_TABLES.includes(t)), `case ${caseId} is marked not-applicable but every table it scopes is declared`); } }); for (const caseId of applicableCases) { it(`${caseId}`, () => { const { missing, extra } = runCase(caseId); assert.deepEqual(missing, [], `case ${caseId}: expected findings the runtime did not raise`); assert.deepEqual(extra, [], `case ${caseId}: lexicon findings the runtime raised but the case does not list`); }); } // §1.1: enumerated by name, reported as skipped rather than passed. The // runner's own `# skipped` tally is the third verdict — these cases are // neither counted as passes nor dropped from the corpus total. for (const caseId of notApplicableCases) { const scope = caseSpecs.get(caseId).scope.join(', '); it.skip(`${caseId} — not-applicable: scoped to ${scope}, which this runtime does not declare`, () => {}); } after(() => { // The declaration §1.1 requires us to publish alongside the result. Pass // and fail counts are the runner's to report; what belongs here is the // declared set and the enumerated not-applicable cases. const lines = [ '', ` commons conformance — ${manifest.id} v${manifest.version} (${manifest.count} cases)`, ` declared tables (spec §1.1): ${DECLARED_TABLES.join(', ')}`, ` applicable: ${applicableCases.length} not-applicable: ${notApplicableCases.length}`, ...notApplicableCases.map((c) => ` not-applicable ${c} [${caseSpecs.get(c).scope.join(', ')}]`), '', ]; console.log(lines.join('\n')); }); });