Scanning this repository with the SIG scanner produced 7 findings, 4 of them
on our own detection data: scanners/commons/CHANGELOG.md and
scanners/commons/signatures/malware-signatures.json. The ruleset that describes
xmrig and webshells is, byte for byte, a document containing those strings, so
the engine matched it as malware. EXCLUDED_PATH_RE already carried
knowledge/, tests/, docs/ and node_modules/ for exactly this reason; the
vendored commons arrived in v8 Phase 5 (bbada84) without being added.
One alternation branch closes it. Tests first: two cases added to
describe('signature-scanner: path exclusions'), both verified red against the
real scan() entry point before the regex changed.
Stated plainly, because it is a real cost and not a technicality: the branch is
`scanners\/commons` behind the existing `(^|\/)` prefix, so it matches that
two-segment path ANYWHERE in a target's relative path, not only at its root. A
webshell planted at vendor/scanners/commons/shell.php in a hostile repository is
therefore invisible to SIG. The second new test asserts that blind spot
deliberately, so it can never be discovered by accident. It is accepted because
anchoring at ^scanners/commons/ would miss the same payload one directory
deeper while re-opening the self-flag whenever the plugin is scanned from a
parent directory. TRG, AST, entropy and supply-chain still read these files;
only SIG identity-matching is blinded.
scanners/lib/supply-chain-data.mjs is NOT excluded. Its finding is a true
positive against real blocklist data.
Measured before: 7 findings. After: 3 (2 on STATE.md, 1 on
supply-chain-data.mjs). signature-scanner.test.mjs 23/23; custom-rules + e2e
54/54; golden-baseline 8/8 with suite-counts.json untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBJVYzwch63Rpk1hii1cNM
136 lines
6.4 KiB
JavaScript
136 lines
6.4 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 { isAbsolute, resolve } from 'node:path';
|
|
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';
|
|
import { SIGNATURE_RULES, compileRules } from './lib/malware-signatures.mjs';
|
|
|
|
// Paths excluded from signature scanning when present under the scan root:
|
|
// test fixtures and docs legitimately contain patterns that would otherwise
|
|
// self-flag. `knowledge/` is kept even though the ruleset itself moved to the
|
|
// vendored commons in v8 Phase 5 — a scanned target's own knowledge/ directory
|
|
// is as likely to hold ruleset-shaped prose as ours was.
|
|
//
|
|
// `scanners/commons/` is the vendored ruleset itself (v8 Phase 5). Its CHANGELOG
|
|
// and malware-signatures.json describe the malware they detect, so SIG matched
|
|
// them as malware: scanning this repo yielded 4 findings on our own detection
|
|
// data. Note the alternation matches the two-segment path ANYWHERE in the
|
|
// relative path, not only at the root — so a webshell planted at
|
|
// `vendor/scanners/commons/shell.php` in a hostile repo is invisible to SIG.
|
|
// That blind spot is accepted knowingly: anchoring at `^scanners/commons/`
|
|
// would miss the same payload one directory deeper anyway, while re-opening the
|
|
// self-flag whenever the plugin is scanned from a parent directory. Other
|
|
// scanners still read these files; only SIG identity-matching is blinded.
|
|
const EXCLUDED_PATH_RE = /(^|\/)(knowledge|tests|docs|node_modules|scanners\/commons)\//i;
|
|
|
|
const DEFAULT_FAMILIES = ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'];
|
|
|
|
/**
|
|
* 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 = [...SIGNATURE_RULES, ...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);
|
|
}
|
|
}
|