test(llm-security): v8 Phase 5 step 3 - run the 83-case commons corpus

Vendored commons v0.1.0 carries a cross-runtime conformance corpus. Until now
it was measured by a throwaway script, which makes 83/83 a claim rather than a
gate - and step 4 swaps the very tables it constrains, so the measurement has
to survive into that step or it protects nothing.

Comparison is exact-within-scope per spec section 4: every listed finding must
be raised and no other lexicon finding may be. Findings are named by commons
pattern_id, which scanForInjection() does not carry - it returns our labels.
Section 3.1 permits a runtime registered in the lexicon's aliases object to
compare through it, and we are registered. Measured first, not assumed: the
map is a total bijection, 83 labels to 83 ids, no duplicates, family membership
agreeing throughout. Nothing here restates a pattern's id, severity or label,
so nothing here can drift from the lexicon.

Deliberately NOT done: adding an id field to our 83 table entries. It would
change the source file the golden gate pins by sha256, forcing a re-bless in
the middle of a behaviour-preservation measurement, and duplicate what step 4
does anyway when the table itself starts loading from commons JSON.

Case discovery is driven by manifest.cases and cross-checked against the
directories on disk, because section 1 requires every case to run and a
deleted case dir would otherwise shrink the gate silently. An unimplemented
match or scope throws rather than skips (section 4). Input bytes and sha256 are
both verified before scanning - two fixtures carry characters invisible on
screen.

Proven red-capable in both directions by mutating the subject, not the harness:
neutering one pattern failed exactly override__disregard; widening one to
[aeiou] failed 82 cases on extra findings. Source restored byte-identical after
each.

Suite 2158, 85 new. The one red in the parallel run is the known
pre-compact-scan size-cap flake (366 ms alone, 1060 ms under load).
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 21:00:59 +02:00
commit c67bad3752

View file

@ -0,0 +1,137 @@
// 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`);
});
}
});