// 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>} */ 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>} */ 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} - 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); } }