// 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: