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