#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
119 lines
5 KiB
JavaScript
119 lines
5 KiB
JavaScript
// signature-scanner-custom-rules.test.mjs — Regression for #36 (LOW, v7.8.3).
|
|
//
|
|
// The scanner hardcoded knowledge/signatures.json and never read the documented
|
|
// `sig.custom_rules_path` policy option (while it DID read the sibling
|
|
// `enabled_families`), so operators could not supply custom signatures despite
|
|
// the policy-loader default advertising the key. Custom rules supplied via
|
|
// policy.json must be loaded and merged; a missing/invalid file must fail
|
|
// gracefully (built-in ruleset still applies, status stays ok).
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
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';
|
|
|
|
/** Write a policy.json under dir/.llm-security. */
|
|
function writePolicy(dir, policy) {
|
|
mkdirSync(join(dir, '.llm-security'), { recursive: true });
|
|
writeFileSync(join(dir, '.llm-security', 'policy.json'), JSON.stringify(policy));
|
|
}
|
|
|
|
describe('signature-scanner: custom_rules_path (#36)', () => {
|
|
it('loads and applies custom rules supplied via policy', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
|
|
try {
|
|
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
|
|
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
|
|
rules: [{
|
|
id: 'CUSTOM-WS-001',
|
|
family: 'webshell',
|
|
severity: 'high',
|
|
pattern: 'EVILCUSTOMMARKER_[0-9]+',
|
|
description: 'Operator-supplied custom webshell marker',
|
|
}],
|
|
}));
|
|
writeFileSync(join(dir, 'payload.txt'), 'prefix EVILCUSTOMMARKER_42 suffix\n');
|
|
|
|
resetCounter();
|
|
const discovery = await discoverFiles(dir);
|
|
const result = await scan(dir, discovery);
|
|
assert.equal(result.status, 'ok');
|
|
const custom = result.findings.find(f => f.evidence && f.evidence.includes('CUSTOM-WS-001'));
|
|
assert.ok(
|
|
custom,
|
|
`expected the custom rule CUSTOM-WS-001 to fire, got: ${result.findings.map(f => f.evidence).join('; ') || '(none)'}`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('custom rules merge with (not replace) the built-in ruleset', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
|
|
try {
|
|
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
|
|
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
|
|
rules: [{
|
|
id: 'CUSTOM-WS-002',
|
|
family: 'webshell',
|
|
severity: 'high',
|
|
pattern: 'EVILCUSTOMMARKER_[0-9]+',
|
|
description: 'Operator-supplied custom webshell marker',
|
|
}],
|
|
}));
|
|
// A built-in webshell signature target
|
|
writeFileSync(join(dir, 'shell.php'), "<?php @eval($_POST['cmd']); ?>\n");
|
|
|
|
resetCounter();
|
|
const discovery = await discoverFiles(dir);
|
|
const result = await scan(dir, discovery);
|
|
assert.equal(result.status, 'ok');
|
|
const builtin = result.findings.find(f => f.file === 'shell.php');
|
|
assert.ok(
|
|
builtin,
|
|
`built-in webshell signature should still fire alongside custom rules, got: ${result.findings.map(f => f.file).join('; ') || '(none)'}`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails gracefully when custom_rules_path points at a missing file', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
|
|
try {
|
|
writePolicy(dir, { sig: { custom_rules_path: 'does-not-exist.json' } });
|
|
writeFileSync(join(dir, 'shell.php'), "<?php @eval($_POST['cmd']); ?>\n");
|
|
|
|
resetCounter();
|
|
const discovery = await discoverFiles(dir);
|
|
const result = await scan(dir, discovery);
|
|
assert.equal(result.status, 'ok', 'missing custom ruleset must not error the scan');
|
|
const builtin = result.findings.find(f => f.file === 'shell.php');
|
|
assert.ok(builtin, 'built-in ruleset should still apply when custom file is missing');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails gracefully when the custom ruleset is invalid JSON', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
|
|
try {
|
|
writePolicy(dir, { sig: { custom_rules_path: 'broken.json' } });
|
|
writeFileSync(join(dir, 'broken.json'), '{ not json');
|
|
writeFileSync(join(dir, 'shell.php'), "<?php @eval($_POST['cmd']); ?>\n");
|
|
|
|
resetCounter();
|
|
const discovery = await discoverFiles(dir);
|
|
const result = await scan(dir, discovery);
|
|
assert.equal(result.status, 'ok', 'invalid custom ruleset must not error the scan');
|
|
const builtin = result.findings.find(f => f.file === 'shell.php');
|
|
assert.ok(builtin, 'built-in ruleset should still apply when custom file is invalid');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|