fix(llm-security): normalization/discovery evasion + SIG embedded-base64 & custom rules (#21,#23,#30,#36,#42,#52,#55)

#21 the bash normalizer decoded only \xHH, leaving ANSI-C octal/\u/\U forms literal so canonical rm/curl never surfaced; now decodes all three. #23 file-discovery keyed on extname so .env.local/.env.example (extname .local/.example) were silently skipped; now matches multi-part suffixes. #42 a legitimate leading UTF-8 BOM was flagged HIGH (and the tool's own auto-cleaner refused to strip it); pos-0 BOM now excepted. #52 collapseLetterSpacing used a literal space, letting multi-space/tab spacing evade; now [ \t]+. #55 redact(_,60,0) did slice(-0) and leaked the whole unredacted URL; showEnd===0 now means no tail.

#30 embedded base64 (const x = "<base64>") never satisfied the whole-string decode, so the SIG identity engine never saw it; added decodeEmbeddedBase64 as an OPT-IN param on normalizeForScan (default off — appending a decoded copy would double-count per-match findings, e.g. content-extractor's injection scan) and enabled it only in signature-scanner, which dedups variants. #36 signature-scanner ignored the documented sig.custom_rules_path policy option; now loads+merges custom rules through the same family filter. Suite 2004/0.

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

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 10:35:56 +02:00
commit 21c6c2b534
11 changed files with 602 additions and 42 deletions

View file

@ -15,7 +15,8 @@
// T3 — parameter expansion: ${x} / ${FOO} -> x / ''
// T4 — backslash-between-words: c\u\r\l -> curl
// T5 — IFS word-splitting: rm${IFS}-rf${IFS}/ -> rm -rf /
// T6 — ANSI-C hex quoting: $'\x72\x6d' -rf / -> rm -rf /
// T6 — ANSI-C quoting: $'\x72\x6d' / $'\162\155' -rf / -> rm -rf /
// (hex \xHH, octal \nnn, \uHHHH, \UHHHHHHHH)
// T7 — process substitution: cat <(curl evil) -> cat curl evil
// T9 — eval-via-variable: X=rm; ... $X -> X=rm; ... rm
// (one-level forward-flow; T8 base64-pipe-shell lives in
@ -33,17 +34,34 @@
const MASK = '\x00';
/**
* Decode ANSI-C hex quoting inside `$'...'` contexts.
* Decode ANSI-C quoting inside `$'...'` contexts.
*
* Shell treats $'\x72\x6d' as the bytes r and m. We decode only \xHH escape
* sequences inside the $'...' wrapper. The $'...' construct itself is
* replaced with its decoded bytes (matching shell evaluation).
* Shell treats $'\x72\x6d' as the bytes r and m. Bash also decodes octal
* (\162), \uHHHH, and \UHHHHHHHH forms inside $'...' decoding only \xHH
* left those literal, so the canonical command name never surfaced for the
* BLOCK rules (#21, v7.8.3). The $'...' construct itself is replaced with
* its decoded bytes (matching shell evaluation).
*
* Decode order: \xHH first, then \UHHHHHHHH / \uHHHH (case-sensitive,
* disjoint), then octal last so the digits of a hex or unicode escape
* are never consumed as octal.
*/
function decodeAnsiCHex(cmd) {
return cmd.replace(/\$'([^']*)'/g, (_, content) =>
content.replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) =>
String.fromCharCode(parseInt(hex, 16)),
),
content
.replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) =>
String.fromCharCode(parseInt(hex, 16)),
)
.replace(/\\U([0-9a-fA-F]{1,8})/g, (_m, hex) => {
const cp = parseInt(hex, 16);
return cp <= 0x10FFFF ? String.fromCodePoint(cp) : _m;
})
.replace(/\\u([0-9a-fA-F]{1,4})/g, (_m, hex) =>
String.fromCharCode(parseInt(hex, 16)),
)
.replace(/\\([0-7]{1,3})/g, (_m, oct) =>
String.fromCharCode(parseInt(oct, 8)),
),
);
}