llm-security/scanners/lib/injection-patterns.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

202 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// injection-patterns.mjs — Shared prompt injection detection patterns
// Used by pre-prompt-inject-scan.mjs (UserPromptSubmit) and post-mcp-verify.mjs (PostToolUse).
//
// Patterns derived from skill-scanner-agent Category 1 (LLM01 Prompt Injection)
// and Category 5 (Hidden Instructions) in knowledge/skill-threat-patterns.md,
// and published since v8 through vendored commons — see the table section below.
//
// Zero external dependencies beyond ./string-utils.mjs and ./injection-lexicon.mjs.
import { normalizeForScan, containsUnicodeTags, decodeUnicodeTags, foldHomoglyphs, rot13 } from './string-utils.mjs';
// ---------------------------------------------------------------------------
// Pattern tables — built from vendored commons, re-exported here
// ---------------------------------------------------------------------------
//
// v8 Phase 5 step 4, third consumer swap. These four arrays were 83 regex
// literals in this file; they now come from `lexicon/injection-lexicon.json`
// in the vendored llm-security-commons subtree, via injection-lexicon.mjs.
// Measured before the swap over all 83 positions — regex source, flags, label
// and `aliases.llm_security` — against commons v0.3.0: zero divergences, in
// order. The tables are re-exported rather than wrapped so that the published
// surface is unchanged for every consumer (content-extractor.mjs spreads them,
// the golden gate walks them as `injection-patterns`, hooks import this file).
//
// Where the data now lives, and why the loud half is not here: see the header
// of injection-lexicon.mjs. In short — an unresolvable commons yields empty
// tables and one line on stderr, because an injection gate that silently stops
// detecting is the failure mode this plugin most needs to not have.
// Imported AND re-exported, not `export ... from`: the two functions below
// read these tables directly, and a bare re-export would not bind them here.
import {
CRITICAL_PATTERNS,
HIGH_PATTERNS,
MEDIUM_PATTERNS,
HYBRID_PATTERNS,
} from './injection-lexicon.mjs';
export { CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS, HYBRID_PATTERNS };
// ---------------------------------------------------------------------------
// HITL cognitive load patterns (MEDIUM, v5.0 S4)
// Injection buried after 2000+ characters in verbose output.
// Checked separately due to length-dependent logic.
// ---------------------------------------------------------------------------
/**
* Check for cognitive load HITL trap: injection payload buried deep in verbose output.
* Only flags if the injection appears after the first 2000 characters.
* @param {string} text
* @returns {{ found: boolean, label: string|null }}
*/
export function checkCognitiveLoadTrap(text) {
if (text.length < 2500) return { found: false, label: null };
const tail = text.slice(2000);
for (const { pattern, label } of CRITICAL_PATTERNS) {
if (pattern.test(tail)) {
return {
found: true,
label: `hitl-trap: cognitive load — injection buried after 2000+ chars (${label})`,
};
}
}
return { found: false, label: null };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Scan text for prompt injection patterns.
* Checks both raw text and normalized (decoded) text to catch obfuscated injections.
* Also checks for Unicode Tag steganography (DeepMind traps kat. 1):
* - CRITICAL if decoded tags contain injection patterns
* - HIGH if Unicode Tags are present at all (suspicious regardless of content)
*
* @param {string} text - the text to scan
* @returns {{ critical: string[], high: string[], medium: string[], found: boolean, severity: string|null, patterns: Array<{label: string, severity: string}> }}
* Arrays of human-readable finding labels per tier, plus convenience fields.
*/
export function scanForInjection(text) {
const normalized = normalizeForScan(text);
// E16 (v7.2.0): homoglyph fold every variant before pattern matching, so
// attacks like "ignоre previous instructions" (Cyrillic о) trigger the
// same patterns as plain "ignore previous instructions". Always-on, not
// advisory-only — the existing MEDIUM_PATTERNS homoglyph-presence entry
// remains separate (different signal: presence vs. normalization).
const folded = foldHomoglyphs(text);
const foldedNormalized = foldHomoglyphs(normalized);
const critical = [];
const high = [];
const medium = [];
// Deduplicate by label (same pattern may match in multiple variants)
const seenLabels = new Set();
// Build the variant set, deduplicating identical strings to skip redundant
// pattern matching. Order: raw text, decoded, folded, decoded+folded.
const variantSet = new Set([text]);
if (normalized !== text) variantSet.add(normalized);
if (folded !== text && folded !== normalized) variantSet.add(folded);
if (foldedNormalized !== text && foldedNormalized !== normalized && foldedNormalized !== folded) {
variantSet.add(foldedNormalized);
}
// E3 — rot13 layer for comment-block injection. Attackers occasionally
// hide imperative phrases ("ignore previous instructions") in rot13
// inside code comments to evade plain-text gates. Apply only to inputs
// long enough to plausibly contain a meaningful sentence (>40 chars) —
// shorter strings hit the rate of FP on accidental rot13-look-alikes.
// base64/hex/URL/HTML decoding is already done by normalizeForScan;
// this is the only genuinely new variant added here.
if (text.length > 40) {
const r1 = rot13(text);
if (r1 !== text && !variantSet.has(r1)) variantSet.add(r1);
if (normalized.length > 40) {
const r2 = rot13(normalized);
if (r2 !== normalized && !variantSet.has(r2)) variantSet.add(r2);
}
}
const variants = [...variantSet];
for (const variant of variants) {
for (const { pattern, label } of CRITICAL_PATTERNS) {
if (seenLabels.has(label)) continue;
if (pattern.test(variant)) {
seenLabels.add(label);
critical.push(label);
}
}
for (const { pattern, label } of HIGH_PATTERNS) {
if (seenLabels.has(label)) continue;
if (pattern.test(variant)) {
seenLabels.add(label);
high.push(label);
}
}
// Hybrid patterns are HIGH severity (v5.0 S6)
for (const { pattern, label } of HYBRID_PATTERNS) {
if (seenLabels.has(label)) continue;
if (pattern.test(variant)) {
seenLabels.add(label);
high.push(label);
}
}
for (const { pattern, label } of MEDIUM_PATTERNS) {
if (seenLabels.has(label)) continue;
if (pattern.test(variant)) {
seenLabels.add(label);
medium.push(label);
}
}
}
// ---------------------------------------------------------------------------
// Unicode Tag steganography check (DeepMind traps kat. 1)
// ---------------------------------------------------------------------------
if (containsUnicodeTags(text)) {
const tagLabel = 'unicode-tags: invisible Unicode Tag characters detected (U+E0000 block steganography)';
if (!seenLabels.has(tagLabel)) {
seenLabels.add(tagLabel);
high.push(tagLabel);
}
const decodedTags = decodeUnicodeTags(text);
for (const { pattern, label } of CRITICAL_PATTERNS) {
const escalatedLabel = `unicode-tags+${label}`;
if (seenLabels.has(escalatedLabel)) continue;
if (pattern.test(decodedTags) && !pattern.test(text)) {
seenLabels.add(escalatedLabel);
critical.push(`${label} (hidden via Unicode Tag steganography)`);
}
}
}
// ---------------------------------------------------------------------------
// HITL cognitive load check (v5.0 S4)
// ---------------------------------------------------------------------------
const cogLoad = checkCognitiveLoadTrap(text);
if (cogLoad.found && !seenLabels.has(cogLoad.label)) {
seenLabels.add(cogLoad.label);
medium.push(cogLoad.label);
}
// Convenience fields
const found = critical.length > 0 || high.length > 0 || medium.length > 0;
const severity = critical.length > 0 ? 'critical' : high.length > 0 ? 'high' : medium.length > 0 ? 'medium' : null;
const patterns = [
...critical.map(label => ({ label, severity: 'critical' })),
...high.map(label => ({ label, severity: 'high' })),
...medium.map(label => ({ label, severity: 'medium' })),
];
return { critical, high, medium, found, severity, patterns };
}