// 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. // // Two 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. // // §4 A runtime MUST reject a `match` or `scope` it does not implement // rather than degrade to a weaker comparison. Hence the explicit throws // instead of a skip. // // 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 } 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'; const SUPPORTED_SCOPE = 'lexicon/injection-lexicon.json'; const manifest = JSON.parse(readFileSync(join(CORPUS_ROOT, 'manifest.json'), 'utf8')); /** 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 = JSON.parse(readFileSync(join(caseDir, 'expected.json'), 'utf8')); 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`); } const scope = expected.scope || []; if (scope.length !== 1 || scope[0] !== SUPPORTED_SCOPE) { throw new Error(`case ${caseId}: unimplemented scope ${JSON.stringify(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, 83, '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); assert.deepEqual(manifest.scope_covered, [SUPPORTED_SCOPE]); }); for (const caseId of manifest.cases) { 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`); }); } });