llm-security/scanners/signature-scanner.mjs
Kjell Tore Guttormsen 21c6c2b534 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
2026-07-18 10:35:56 +02:00

174 lines
6.9 KiB
JavaScript

// signature-scanner.mjs — SIG: known-bad-identity signature engine
//
// Detects known-malware *identity* (webshells, reverse shells, cryptominers,
// hacktools) — complementary to the shape-based scanners (entropy/taint).
//
// Differentiator vs raw signature engines (e.g. YARA): every signature regex
// is tested not only against the raw file bytes but also against the project's
// decode pipeline (normalizeForScan -> base64/hex/url/entity/unicode decode,
// foldHomoglyphs, rot13). Obfuscated known-malware that a byte-matcher misses
// is therefore still caught.
//
// OWASP coverage: LLM03 (supply chain) primary; LLM02 (sensitive disclosure).
// Zero external dependencies — Node.js builtins only.
import { readFile } from 'node:fs/promises';
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';
import { normalizeForScan, foldHomoglyphs, rot13 } from './lib/string-utils.mjs';
import { getPolicyValue } from './lib/policy-loader.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Paths excluded from signature scanning when present under the scan root:
// our own ruleset, test fixtures, and docs all legitimately contain patterns
// that would otherwise self-flag.
const EXCLUDED_PATH_RE = /(^|\/)(knowledge|tests|docs|node_modules)\//i;
const DEFAULT_FAMILIES = ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'];
// Cached, compiled ruleset (loaded once per process).
let _rules = null;
/**
* 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}>>}
*/
async function loadRules() {
if (_rules) return _rules;
const rulesetPath = join(__dirname, '..', 'knowledge', 'signatures.json');
try {
const raw = await readFile(rulesetPath, 'utf8');
_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 }];
const seen = new Set([content]);
const add = (label, text) => {
if (text && !seen.has(text)) { seen.add(text); variants.push({ label, text }); }
};
// 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, { decodeEmbedded: true }));
add('decoded-trimmed', normalizeForScan(content.trim(), { decodeEmbedded: true }));
add('homoglyph-folded', foldHomoglyphs(content));
add('rot13', rot13(content));
return variants;
}
/**
* Scan all discovered files for known-bad signatures.
*
* @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 rules = [...await loadRules(), ...await loadCustomRules(targetPath)];
const enabledFamilies = new Set(
(getPolicyValue('sig', 'enabled_families', DEFAULT_FAMILIES, targetPath) || []).map(f => String(f)),
);
const activeRules = rules.filter(r => enabledFamilies.has(r.family));
if (activeRules.length === 0) {
return scannerResult('signature-scanner', 'ok', findings, filesScanned, Date.now() - startMs);
}
for (const fileInfo of discovery.files) {
const relPath = String(fileInfo.relPath).replace(/\\/g, '/');
if (EXCLUDED_PATH_RE.test('/' + relPath)) continue;
const content = await readTextFile(fileInfo.absPath);
if (content === null) continue;
filesScanned++;
const variants = variantsOf(content);
const seen = new Set(); // de-dup per (file, rule)
for (const rule of activeRules) {
if (seen.has(rule.id)) continue;
const hit = variants.find(v => rule.re.test(v.text));
if (!hit) continue;
seen.add(rule.id);
const viaDecode = hit.label !== 'raw';
findings.push(finding({
scanner: 'SIG',
severity: rule.severity,
title: `Known-bad signature: ${rule.family} (${rule.id})`,
description: `${rule.description}${viaDecode ? ` — matched after decoding (${hit.label} variant), i.e. obfuscated.` : '.'}`,
file: fileInfo.relPath,
evidence: `${rule.id} [${rule.family}]${rule.provenance ? `${rule.provenance}` : ''}`,
owasp: 'LLM03',
recommendation: `Remove the ${rule.family} payload, or if this is an intentional security sample, exclude its path or disable the "${rule.family}" family in .llm-security/policy.json.`,
}));
}
}
return scannerResult('signature-scanner', 'ok', findings, filesScanned, Date.now() - startMs);
} catch (err) {
return scannerResult('signature-scanner', 'error', findings, filesScanned, Date.now() - startMs, err.message);
}
}