llm-security/scanners/trigger-scanner.mjs
Kjell Tore Guttormsen f3aaf5479f fix(llm-security): scanner false-positives/negatives — trigger, toxic-flow, policy-loader (#38-#41,#57,#26)
#38 toxic-flow matched trifecta-leg keywords with bare includes(), so substrings ('url' in 'curl', 'key' in 'monkey', 'auth' in 'author') fabricated CRITICAL trifectas on benign components; now word-boundary matched. #40 TRG-broad fired HIGH on a bare any/all/every anywhere ('fix any lint errors'); the universal-claim regex now requires genuine universal phrasing. #41 TRG-baiting substring-matched ('any file' in 'many files'); now boundary-anchored. #39 the broad-name list missed multi-char generic names (helper/assistant/auto/general/agent/tool); widened coherently so it does not reintroduce #40. #57 the '(recovered from obfuscation)' label compared raw against a lowercased normal form, firing on any uppercase char; now gated on an explicit decode-changed flag.

#26 (same file) getPolicyValue used 'key in sectionObj' with no type guard, so a scalar section override in policy.json (e.g. {"injection":"block"}) threw an uncaught TypeError; now guarded to fall back to the default. Suite 1931/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
2026-07-18 10:15:10 +02:00

213 lines
9.5 KiB
JavaScript

// trigger-scanner.mjs — TRG: trigger/activation-abuse scanner
//
// Inspects command/agent/skill `name` + `description` frontmatter for three
// activation-surface abuses:
// - TRG-shadow : name collides with a built-in command/tool (intercepts it)
// - TRG-baiting : description uses maximally-activating phrases ("anything",
// "always", "all files", …) to bait indiscriminate invocation
// - TRG-broad : a broad/generic name AND a universal-applicability claim,
// so the unit auto-activates far beyond its stated purpose
//
// Skills/agents auto-activate on their description, so this is a real,
// skill-native attack surface not covered by the permission or taint scanners.
//
// Descriptions are run through the decode pipeline (zero-width strip ->
// homoglyph fold -> normalizeForScan) before phrase matching, so obfuscated
// baiting (zero-width / homoglyph / entity / base64) still trips.
//
// OWASP coverage: LLM06 (excessive agency); AST04 (skills framework).
// Zero external dependencies.
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { readTextFile } from './lib/file-discovery.mjs';
import { parseFrontmatter } from './lib/yaml-frontmatter.mjs';
import { normalizeForScan, foldHomoglyphs } from './lib/string-utils.mjs';
import { getPolicyValue } from './lib/policy-loader.mjs';
// ---------------------------------------------------------------------------
// Defaults — mirrored into DEFAULT_POLICY.trg (policy-loader.mjs) so an
// operator can override any of these via .llm-security/policy.json.
// ---------------------------------------------------------------------------
const DEFAULT_BAITING_PHRASES = [
'anything', 'everything', 'always', 'whenever', 'no matter what',
'any request', 'any task', 'any file', 'every time',
'all files', 'all messages', 'all requests',
];
const DEFAULT_BUILTIN_NAMES = [
'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task',
'webfetch', 'websearch', 'notebookedit', 'todowrite',
'ls', 'cat', 'agent', 'search', 'fetch',
];
const DEFAULT_BROAD_SINGLE_WORDS = [
'run', 'do', 'go', 'help', 'fix', 'use', 'get', 'set', 'all', 'any', 'it', 'this', 'that',
'helper', 'assistant', 'auto', 'general', 'agent', 'tool',
];
// Universal-applicability claim used only for the TRG-broad combo. A bare
// quantifier inside a scoped noun phrase ("any lint errors", "all tests") is
// NOT a universal claim (#40) — the quantifier must pair with a universal
// noun ("any request"), an unbounded pronoun ("anything"), or an
// unconditional-invocation phrase ("always invoke", "no matter what").
const UNIVERSAL_CLAIM_RE = new RegExp(
'\\b(?:anything|everything|no matter what|universal(?:ly)?'
+ '|(?:any|all|every)\\s+(?:request|task|prompt|question|message|input|situation|scenario|purpose|context|case|time|user)s?'
+ '|always\\s+(?:use|invoke|activate|apply|run|trigger)'
+ '|whenever\\s+(?:the\\s+user|you|asked|possible)'
+ ')\\b', 'i');
// Invisible / zero-width characters used to split keywords (ZWSP, ZWNJ, ZWJ,
// word-joiner, BOM/zero-width-no-break-space).
const ZERO_WIDTH_RE = /[\u200B-\u200D\u2060\uFEFF]/g;
/** True if relPath is a command, agent, or skill definition file. */
function isTriggerFile(relPath) {
const p = String(relPath).replace(/\\/g, '/');
return /(^|\/)commands\/[^/]+\.md$/i.test(p)
|| /(^|\/)agents\/[^/]+\.md$/i.test(p)
|| /(^|\/)skills\/.+\/SKILL\.md$/i.test(p);
}
/** Coarse type label for messaging. */
function fileTypeOf(relPath) {
const p = String(relPath).replace(/\\/g, '/');
if (/(^|\/)commands\//i.test(p)) return 'command';
if (/(^|\/)agents\//i.test(p)) return 'agent';
return 'skill';
}
/** Strip zero-width chars, fold homoglyphs, decode obfuscation layers, lowercase. */
function normalizeText(s) {
if (!s) return '';
const noZeroWidth = String(s).replace(ZERO_WIDTH_RE, '');
return normalizeForScan(foldHomoglyphs(noZeroWidth)).toLowerCase();
}
/**
* Compile a baiting phrase into a word-boundary-anchored regex (#41), so
* "any file" no longer matches inside "many files" nor "all files" inside
* "install files". Boundary anchors are only applied where the phrase edge
* is a word character; internal whitespace matches flexibly.
*/
function phraseToRegex(phrase) {
const trimmed = String(phrase).trim();
const escaped = trimmed.split(/\s+/)
.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\s+');
const lead = /^[a-z0-9]/i.test(trimmed) ? '\\b' : '';
const trail = /[a-z0-9]$/i.test(trimmed) ? '\\b' : '';
return new RegExp(lead + escaped + trail, 'i');
}
/**
* Scan command/agent/skill definitions for trigger/activation abuse.
*
* @param {string} targetPath - Absolute root path being scanned
* @param {{ files: import('./lib/file-discovery.mjs').FileInfo[] }} discovery
* @returns {Promise<object>} - scannerResult envelope
*/
export async function scan(targetPath, discovery) {
const startMs = Date.now();
const findings = [];
let filesScanned = 0;
try {
const baitingPhrases = (getPolicyValue('trg', 'baiting_phrases', DEFAULT_BAITING_PHRASES, targetPath) || [])
.map(p => String(p).toLowerCase());
const builtinNames = (getPolicyValue('trg', 'builtin_names', DEFAULT_BUILTIN_NAMES, targetPath) || [])
.map(n => String(n).toLowerCase());
const broadWords = (getPolicyValue('trg', 'broad_single_words', DEFAULT_BROAD_SINGLE_WORDS, targetPath) || [])
.map(w => String(w).toLowerCase());
const baitingMatchers = baitingPhrases
.filter(Boolean)
.map(p => ({ phrase: p, re: phraseToRegex(p) }));
for (const fileInfo of discovery.files) {
if (!isTriggerFile(fileInfo.relPath)) continue;
const content = await readTextFile(fileInfo.absPath);
if (content === null) continue;
const fm = parseFrontmatter(content);
if (!fm || !fm.name) continue;
filesScanned++;
const name = String(fm.name).trim();
const nameLower = name.toLowerCase();
const rawDesc = typeof fm.description === 'string' ? fm.description : '';
const normDesc = normalizeText(rawDesc);
const plainDesc = rawDesc.toLowerCase();
const fileType = fileTypeOf(fileInfo.relPath);
// Broad name: a listed generic word, a very short name, or a compound
// name made entirely of generic words ("helper-agent") (#39).
const nameTokens = nameLower.split(/[^a-z0-9]+/).filter(Boolean);
const isBroadName = broadWords.includes(nameLower)
|| nameLower.length <= 2
|| (nameTokens.length > 0 && nameTokens.every(t => broadWords.includes(t)));
const hasUniversalClaim = UNIVERSAL_CLAIM_RE.test(normDesc);
// TRG-shadow — name collides with a built-in command/tool.
if (builtinNames.includes(nameLower)) {
findings.push(finding({
scanner: 'TRG',
severity: SEVERITY.MEDIUM,
title: `Built-in shadowing: trigger name "${name}"`,
description: `The ${fileType} "${name}" uses a name that collides with a built-in command/tool. `
+ `A shadowing trigger can intercept invocations intended for the built-in.`,
file: fileInfo.relPath,
evidence: `name: ${name}`,
owasp: 'LLM06',
recommendation: 'Rename the command/agent/skill so its name does not collide with a built-in tool.',
}));
}
// TRG-broad — broad/generic name AND a universal-applicability claim (HIGH).
if (isBroadName && hasUniversalClaim) {
findings.push(finding({
scanner: 'TRG',
severity: SEVERITY.HIGH,
title: `Overly broad trigger: "${name}"`,
description: `The ${fileType} "${name}" pairs a broad/generic name with a description claiming `
+ `universal applicability, so it may auto-activate far beyond its stated purpose.`,
file: fileInfo.relPath,
evidence: `name: ${name}`,
owasp: 'LLM06',
recommendation: 'Give the unit a specific name and scope the description to a single well-bounded task.',
}));
}
// TRG-baiting — maximally-activating phrases in the (decoded) description.
const matched = baitingMatchers.filter(m => m.re.test(normDesc)).map(m => m.phrase);
// "(recovered from obfuscation)" only when a matched phrase is NOT
// findable in the plain lowercased raw text — i.e. the decode pipeline
// actually recovered it (#57). Mere case differences do not count.
const recovered = baitingMatchers.some(m =>
m.re.test(normDesc) && !m.re.test(plainDesc));
if (matched.length > 0) {
findings.push(finding({
scanner: 'TRG',
severity: SEVERITY.MEDIUM,
title: `Activation baiting phrase in ${fileType} description`,
description: `The ${fileType} "${name}" description uses maximally-activating phrasing that baits the `
+ `model into invoking it indiscriminately${recovered ? ' (recovered from obfuscation)' : ''}.`,
file: fileInfo.relPath,
evidence: `phrases: ${matched.join(', ')}`,
owasp: 'LLM06',
recommendation: 'Remove universal/maximal activation phrasing; describe concrete, scoped trigger conditions.',
}));
}
}
const durationMs = Date.now() - startMs;
return scannerResult('trigger-scanner', 'ok', findings, filesScanned, durationMs);
} catch (err) {
const durationMs = Date.now() - startMs;
return scannerResult('trigger-scanner', 'error', findings, filesScanned, durationMs, err.message);
}
}