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:
parent
b224e18b42
commit
21c6c2b534
11 changed files with 602 additions and 42 deletions
|
|
@ -13,7 +13,7 @@
|
|||
// Zero external dependencies — Node.js builtins only.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, isAbsolute, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
|
|
@ -33,8 +33,36 @@ const DEFAULT_FAMILIES = ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'
|
|||
let _rules = null;
|
||||
|
||||
/**
|
||||
* Load and compile signatures.json. Each rule's `pattern` is compiled to a
|
||||
* case-insensitive RegExp; rules whose pattern fails to compile are dropped.
|
||||
* Compile a parsed ruleset object ({ rules: [...] }) into executable rules.
|
||||
* Each rule's `pattern` is compiled to a case-insensitive RegExp; rules whose
|
||||
* pattern fails to compile (or that lack id/pattern) are dropped.
|
||||
* @param {object} parsed
|
||||
* @returns {Array<{id,family,severity,re,description,provenance}>}
|
||||
*/
|
||||
function compileRules(parsed) {
|
||||
const compiled = [];
|
||||
for (const rule of parsed.rules || []) {
|
||||
if (!rule || !rule.id || !rule.pattern) continue;
|
||||
let re;
|
||||
try {
|
||||
re = new RegExp(rule.pattern, 'i');
|
||||
} catch {
|
||||
continue; // skip uncompilable patterns
|
||||
}
|
||||
compiled.push({
|
||||
id: rule.id,
|
||||
family: rule.family || 'unknown',
|
||||
severity: rule.severity || 'high',
|
||||
re,
|
||||
description: rule.description || rule.id,
|
||||
provenance: rule.provenance || null,
|
||||
});
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and compile signatures.json.
|
||||
* Graceful fallback to an empty ruleset on any load/parse error.
|
||||
* @returns {Promise<Array<{id,family,severity,re,description,provenance}>>}
|
||||
*/
|
||||
|
|
@ -43,32 +71,33 @@ async function loadRules() {
|
|||
const rulesetPath = join(__dirname, '..', 'knowledge', 'signatures.json');
|
||||
try {
|
||||
const raw = await readFile(rulesetPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const compiled = [];
|
||||
for (const rule of parsed.rules || []) {
|
||||
if (!rule || !rule.id || !rule.pattern) continue;
|
||||
let re;
|
||||
try {
|
||||
re = new RegExp(rule.pattern, 'i');
|
||||
} catch {
|
||||
continue; // skip uncompilable patterns
|
||||
}
|
||||
compiled.push({
|
||||
id: rule.id,
|
||||
family: rule.family || 'unknown',
|
||||
severity: rule.severity || 'high',
|
||||
re,
|
||||
description: rule.description || rule.id,
|
||||
provenance: rule.provenance || null,
|
||||
});
|
||||
}
|
||||
_rules = compiled;
|
||||
_rules = compileRules(JSON.parse(raw));
|
||||
} catch {
|
||||
_rules = []; // graceful: no ruleset -> no findings
|
||||
}
|
||||
return _rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* v7.8.3 (#36): load operator-supplied rules from the documented
|
||||
* `sig.custom_rules_path` policy option. Relative paths resolve against the
|
||||
* scan root (where .llm-security/policy.json lives). Never cached — policy is
|
||||
* per-target. Fails gracefully (empty array) on a missing/invalid file.
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<Array<{id,family,severity,re,description,provenance}>>}
|
||||
*/
|
||||
async function loadCustomRules(targetPath) {
|
||||
const customPath = getPolicyValue('sig', 'custom_rules_path', null, targetPath);
|
||||
if (!customPath || typeof customPath !== 'string') return [];
|
||||
const rulesetPath = isAbsolute(customPath) ? customPath : resolve(targetPath, customPath);
|
||||
try {
|
||||
const raw = await readFile(rulesetPath, 'utf8');
|
||||
return compileRules(JSON.parse(raw));
|
||||
} catch {
|
||||
return []; // graceful: unreadable/invalid custom ruleset -> built-ins only
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the decode-variant set for one file's content (de-duplicated). */
|
||||
function variantsOf(content) {
|
||||
const variants = [{ label: 'raw', text: content }];
|
||||
|
|
@ -78,8 +107,8 @@ function variantsOf(content) {
|
|||
};
|
||||
// Trimming before decode lets a base64/hex blob with surrounding whitespace
|
||||
// (a trailing newline, indentation) still decode — common in real payloads.
|
||||
add('decoded', normalizeForScan(content));
|
||||
add('decoded-trimmed', normalizeForScan(content.trim()));
|
||||
add('decoded', normalizeForScan(content, { decodeEmbedded: true }));
|
||||
add('decoded-trimmed', normalizeForScan(content.trim(), { decodeEmbedded: true }));
|
||||
add('homoglyph-folded', foldHomoglyphs(content));
|
||||
add('rot13', rot13(content));
|
||||
return variants;
|
||||
|
|
@ -98,7 +127,7 @@ export async function scan(targetPath, discovery) {
|
|||
let filesScanned = 0;
|
||||
|
||||
try {
|
||||
const rules = await loadRules();
|
||||
const rules = [...await loadRules(), ...await loadCustomRules(targetPath)];
|
||||
const enabledFamilies = new Set(
|
||||
(getPolicyValue('sig', 'enabled_families', DEFAULT_FAMILIES, targetPath) || []).map(f => String(f)),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue