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
202 lines
8.4 KiB
JavaScript
202 lines
8.4 KiB
JavaScript
// 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 };
|
||
}
|