feat(llm-security): add SIG signature scanner with decode-pipeline matching
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
This commit is contained in:
parent
9f1156866c
commit
9e0a09ab1f
7 changed files with 346 additions and 0 deletions
145
scanners/signature-scanner.mjs
Normal file
145
scanners/signature-scanner.mjs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// 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 } 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;
|
||||
|
||||
/**
|
||||
* Load and compile signatures.json. Each rule's `pattern` is compiled to a
|
||||
* case-insensitive RegExp; rules whose pattern fails to compile are dropped.
|
||||
* 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');
|
||||
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;
|
||||
} catch {
|
||||
_rules = []; // graceful: no ruleset -> no findings
|
||||
}
|
||||
return _rules;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
add('decoded-trimmed', normalizeForScan(content.trim()));
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue