llm-security/scanners/lib/injection-lexicon.mjs
Kjell Tore Guttormsen be148671ee feat(llm-security): swap injection tables to vendored commons lexicon
Third consumer swap of v8 Phase 5 step 4, after codepoints and OWASP_MAP,
and the last one with a behavioural gate behind it. The 83 regex literals
leave injection-patterns.mjs; the four arrays are now built in
scanners/lib/injection-lexicon.mjs from the vendored
lexicon/injection-lexicon.json and re-exported unchanged, so every
consumer sees the same published surface.

Behaviour-preserving by measurement, not by intent. The proven recipe ran
in order: a differential over all 83 positions (regex source, flags,
label, aliases.llm_security) found 0 divergences BEFORE anything changed;
the golden dump was then diffed post-for-post rather than read as a 9000-
character assertion, and the ONLY changed record was the sha256 of
injection-patterns.mjs itself -- 83 regex posts, 7 table records and all
counts identical. That single file digest is the diff a swap MUST produce,
so the baseline was re-blessed rather than silenced.

Two deliberate departures from the two earlier swaps:

FAILURE IS LOUD. codepoints and owasp-map fail silently on purpose: an
empty codepoint table weakens normalization, an empty OWASP map mislabels
a report. An empty injection table is different in kind -- scanForInjection
returns found:false for every input, and the UserPromptSubmit scan, the
MCP output scan and the pre-compact scan all go blind while reporting
success. That is precisely the v7.8.2 defect class, which bit this plugin
four times in one release. An unresolvable commons therefore writes one
line to stderr naming the disabled capability. It still does not throw:
hooks run per-tool-call, and a module-load throw breaks the tool call
instead of degrading the scan. The warning is suppressed for an explicit
commonsRoot, so tests and dev checkouts stay quiet and the line keeps
meaning something.

ENTRIES COMPILE DEFENSIVELY. commons is vendored data, not code. An
uncompilable pattern or unknown flag would throw inside new RegExp at
module load -- in a hook. Malformed entries are dropped instead, the same
call owasp-map.mjs makes for a non-array value.

Gates proven by mutating the vendored JSON in BOTH directions, five ways,
all firing: re-adding the script-tag tail commons dropped (golden 1,
lexicon 2, corpus 1), dropping a critical pattern (2/1/3), stripping the
`m` flag off a spoofed-header anchor (2/1), adding a pattern commons never
published (2/2/85), and removing commons outright -- which produced the
stderr line, four empty tables and 5 red rather than a green suite over
zero patterns. Lexicon restored byte-identical after each.

Full suite 2191 pass / 0 fail / 6 skipped (2184 -> 2197).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XDdiKC9ZXmcSUQ2m84s6y
2026-08-11 14:13:36 +02:00

134 lines
5.9 KiB
JavaScript

// injection-lexicon.mjs — Prompt-injection pattern tables, built from vendored commons.
//
// v8 Phase 5 step 4, third consumer swap. The four arrays were 83 regex
// literals in injection-patterns.mjs; they are now built once, here, from
// `lexicon/injection-lexicon.json` in the vendored llm-security-commons
// subtree. Verified before the swap by a differential over all 83 positions —
// regex source, flags, label and `aliases.llm_security` — against the live
// source tables at commons v0.3.0: zero divergences, in order.
//
// The lexicon keys each family by `source_export`, which is the name of the
// constant it was extracted from, so the mapping from family to published
// table is data rather than a convention this file restates.
//
// Two things about this swap differ from the first two (codepoints, owasp-map)
// and are deliberate:
//
// 1. FAILURE IS LOUD. Those two degrade a normalization step and a report
// label respectively, and their empty state is legitimately reachable, so
// they fail silently by design. An empty injection table is different in
// kind: `scanForInjection` would return `found: false` for every input
// and every gate built on it — the UserPromptSubmit scan, the MCP output
// scan, the pre-compact scan — would report success without running.
// That is the v7.8.2 defect class, and this plugin has been bitten by it
// four times in one release. So an unresolvable commons writes one line
// to stderr. It still does not throw: hooks run per-tool-call, and a
// module-load throw breaks the tool call rather than degrading the scan.
// The warning fires only for the DEFAULT root — an explicit
// `commonsRoot` is a test or a dev checkout deliberately pointing
// elsewhere, and warning there would train the reader to ignore the line.
//
// 2. ENTRIES ARE COMPILED DEFENSIVELY. commons is vendored data, not code.
// `new RegExp(source, flags)` throws on an uncompilable pattern or an
// unknown flag, and it would throw at module load — inside a hook. A
// malformed entry is therefore dropped rather than published, the same
// call owasp-map.mjs makes for a non-array value.
//
// Array order is preserved and never sorted: it decides both dedup precedence
// in `scanForInjection` and the order findings reach report output.
//
// Zero external dependencies — Node.js builtins only.
import { loadArtifact } from './commons-loader.mjs';
/**
* The `source_export` names this module publishes.
*
* A family the lexicon adds under any other name is ignored rather than
* turned into a table no consumer reads — the same call codepoints.mjs makes
* for `cyrillic_confusables`.
*/
const PUBLISHED_EXPORTS = Object.freeze([
'CRITICAL_PATTERNS',
'HIGH_PATTERNS',
'MEDIUM_PATTERNS',
'HYBRID_PATTERNS',
]);
/**
* Compile one lexicon entry into the `{ pattern, label }` shape the consumers
* expect, or return null if the entry cannot be trusted.
*/
function compileEntry(entry) {
if (entry === null || typeof entry !== 'object') return null;
if (typeof entry.pattern !== 'string' || typeof entry.label !== 'string') return null;
// Absent `flags` means no flags — the lexicon's dialect note is explicit
// that flags are declared per pattern and never inlined as `(?i)`.
const flags = entry.flags ?? '';
if (typeof flags !== 'string') return null;
try {
return Object.freeze({ pattern: new RegExp(entry.pattern, flags), label: entry.label });
} catch {
return null; // uncompilable source or unknown flag — drop, do not throw
}
}
/**
* Build the four injection tables from a commons root.
*
* @param {object} [opts]
* @param {string} [opts.commonsRoot] - explicit commons root (tests, dev checkout).
* Suppresses the unresolvable-commons warning, which is meant for the
* default root only.
* @returns {{
* CRITICAL_PATTERNS: ReadonlyArray<{pattern: RegExp, label: string}>,
* HIGH_PATTERNS: ReadonlyArray<{pattern: RegExp, label: string}>,
* MEDIUM_PATTERNS: ReadonlyArray<{pattern: RegExp, label: string}>,
* HYBRID_PATTERNS: ReadonlyArray<{pattern: RegExp, label: string}>,
* }} Every key is always present, so a lost commons yields empty tables
* rather than undefined ones a consumer would iterate and throw on.
*/
export function buildInjectionTables(opts = {}) {
const artifact = loadArtifact('lexicon/injection-lexicon', {
fallback: null,
commonsRoot: opts.commonsRoot,
});
if (artifact === null && opts.commonsRoot === undefined) {
// See note 1 above: silent is the one thing this failure must not be.
process.stderr.write(
'[llm-security] injection lexicon unresolvable at scanners/commons/lexicon/injection-lexicon.json — '
+ 'prompt-injection detection is DISABLED for this process. Reinstall the plugin or re-vendor the commons subtree.\n',
);
}
const tables = {};
for (const name of PUBLISHED_EXPORTS) tables[name] = [];
for (const family of artifact?.families ?? []) {
const name = family?.source_export;
if (!PUBLISHED_EXPORTS.includes(name)) continue;
if (!Array.isArray(family.patterns)) continue;
for (const entry of family.patterns) {
const compiled = compileEntry(entry);
if (compiled !== null) tables[name].push(compiled);
}
}
for (const name of PUBLISHED_EXPORTS) Object.freeze(tables[name]);
return tables;
}
const _tables = buildInjectionTables();
/** Direct injection attempts — blocked, not warned about. */
export const CRITICAL_PATTERNS = _tables.CRITICAL_PATTERNS;
/** Subtle manipulation: reframing, oversight evasion, HITL traps, HTML obfuscation. */
export const HIGH_PATTERNS = _tables.HIGH_PATTERNS;
/** Obfuscation and indirect manipulation — lower confidence, advisory. */
export const MEDIUM_PATTERNS = _tables.MEDIUM_PATTERNS;
/** Cross-domain injection: P2SQL, recursive injection, XSS in agent context. Reported as HIGH. */
export const HYBRID_PATTERNS = _tables.HYBRID_PATTERNS;