// injection-lexicon.test.mjs — Tests for the commons-backed injection tables. // // v8 Phase 5 step 4, third consumer swap: the four injection pattern arrays // stop being 83 regex literals in injection-patterns.mjs and are built from // the vendored commons artifact `lexicon/injection-lexicon.json` instead. // // This swap differs from the first two in one respect that shapes the tests // below. An empty codepoint table degrades normalization; an empty OWASP map // degrades a report label. An empty injection table turns `scanForInjection` // into a function that returns `found: false` for every input — the primary // injection gate reporting success without running, which is exactly the // v7.8.2 defect class. So the loud half matters more here than anywhere else, // and it is asserted in three independent ways: // // 1. Exact per-family counts through the REAL default commons root, so an // unvendored or truncated commons cannot pass as a legitimately small // table. // 2. A behavioural probe through the real `scanForInjection` entry point — // a table that loads but produces non-firing regexes would satisfy (1). // 3. A non-silent failure: an unresolvable DEFAULT root warns on stderr. // An explicit `commonsRoot` (tests, dev checkout) does not, so this // file's own graceful-path cases stay quiet. // // Byte-fidelity to the pre-swap literals is NOT re-asserted here — that is the // golden gate's job (`injection-patterns` regex posts, compared post-for-post) // and it was measured before the swap by a differential over all 83 positions. // What this file adds is what the golden gate cannot see: that the table came // from commons at all, and that losing commons is loud rather than silent. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { buildInjectionTables } from '../../scanners/lib/injection-lexicon.mjs'; import { CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS, HYBRID_PATTERNS, scanForInjection, } from '../../scanners/lib/injection-patterns.mjs'; const MALFORMED_ROOT = new URL('../fixtures/commons-malformed-lexicon/', import.meta.url).pathname; describe('injection-lexicon (commons lexicon)', () => { describe('positive load through the real default commons root', () => { it('builds all four families at their declared sizes', () => { // 21/32/22/8 = 83. A count that drifts means either commons changed the // corpus or the vendored copy is partial; both must be looked at, not // adjusted away. assert.equal(CRITICAL_PATTERNS.length, 21, 'CRITICAL_PATTERNS lost entries — is scanners/commons vendored?'); assert.equal(HIGH_PATTERNS.length, 32, 'HIGH_PATTERNS lost entries — is scanners/commons vendored?'); assert.equal(MEDIUM_PATTERNS.length, 22, 'MEDIUM_PATTERNS lost entries — is scanners/commons vendored?'); assert.equal(HYBRID_PATTERNS.length, 8, 'HYBRID_PATTERNS lost entries — is scanners/commons vendored?'); }); it('publishes compiled RegExp objects, not pattern strings', () => { // The consumers call `pattern.test(variant)` directly. A string would // throw there, not here, and only for inputs that reach that line. for (const table of [CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS, HYBRID_PATTERNS]) { for (const entry of table) { assert.ok(entry.pattern instanceof RegExp, `${entry.label}: pattern is not a RegExp`); assert.equal(typeof entry.label, 'string'); } } }); it('preserves array order, which is semantic', () => { // Order decides dedup precedence and output order, so it is asserted at // the boundaries of each family rather than sorted-compared. assert.equal(CRITICAL_PATTERNS[0].label, 'override: ignore previous instructions'); assert.equal(CRITICAL_PATTERNS.at(-1).label, 'config: disable output filtering'); assert.equal(HYBRID_PATTERNS.at(-1).label, 'hybrid-xss: iframe with executable src (agent context XSS)'); }); it('carries the flags the lexicon declares, per pattern', () => { // Three flag values exist across the whole lexicon: 'i', 'm', and none. // `m` is anchored-header-only; a builder that applied a blanket 'i' // would pass a count check and break every one of these. const multiline = CRITICAL_PATTERNS.filter((p) => p.pattern.flags.includes('m')).map((p) => p.label); assert.deepEqual(multiline, [ 'spoofed header: # SYSTEM:', 'spoofed header: [INST]', 'spoofed header: ### Instructions:', ]); // Three entries carry no flags at all. A builder that defaulted to 'i' // would silently widen them from case-sensitive to case-insensitive. const unflagged = CRITICAL_PATTERNS.filter((p) => p.pattern.flags === '').map((p) => p.label); assert.deepEqual(unflagged, [ 'spoofed header: <|system|>', 'spoofed tag: ', 'spoofed tag: ', ]); }); it('carries commons v0.3.0\'s converged script-tag form', () => { // The one detection value that changed in v0.2.0..v0.3.0. Named here so // a future commons that re-adds the `[\s\S]*?<\/script>` tail — the // recall hole we asked them to drop — fails loudly rather than silently // narrowing what we detect. const scriptTag = HYBRID_PATTERNS.find((p) => p.label.startsWith('hybrid-xss: