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:
parent
44e5e39f67
commit
c67bad3752
1 changed files with 137 additions and 0 deletions
137
tests/lib/conformance-corpus.test.mjs
Normal file
137
tests/lib/conformance-corpus.test.mjs
Normal 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`);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue