llm-security/tests/lib/malware-signatures.test.mjs
Kjell Tore Guttormsen bbada84e9f 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
2026-08-13 21:28:14 +02:00

163 lines
7.3 KiB
JavaScript

// malware-signatures.test.mjs — Tests for the commons-backed SIG ruleset.
//
// v8 Phase 5 step 4, fifth and last consumer swap: the seven known-bad-identity
// rules 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 — against commons malware-signatures
// 0.1.0: zero divergences, in order. The commons copy was extracted from this
// repository's own file at commit b0de0ca and has not drifted since.
//
// What this file covers is what the other two layers cannot see:
//
// - The golden gate walks `malware-signatures:SIGNATURE_RULES` and pins every
// compiled pattern's source and flags, so byte-fidelity is ITS job and is
// not re-asserted here. That anchor REPLACES the `knowledge/signatures.json`
// file pin the swap retires, and it is strictly stronger: the pin covered
// the bytes on disk, the walk covers what `new RegExp` actually made of
// them.
// - tests/scanners/signature-scanner.test.mjs drives every rule through the
// real `scan()` entry point, so BEHAVIOUR is its job.
// - Left over, and asserted here: that the ruleset came from commons at all,
// that the engine's unconditional case-insensitivity survived the move, and
// that a corrupt or missing commons degrades instead of throwing.
//
// The loud half matters for the same reason it did for the other four tables:
// an empty ruleset means the SIG scanner returns status `ok` with zero findings
// for every file it is handed — a malware gate reporting success without
// running, which is the v7.8.2 defect class.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
buildSignatureRules,
compileRules,
SIGNATURE_RULES,
} from '../../scanners/lib/malware-signatures.mjs';
const MALFORMED_ROOT = new URL(
'../fixtures/commons-malformed-signatures/',
import.meta.url,
).pathname;
// The identity of the table, spelled out independently of the JSON the module
// reads. Comparing the module against its own source file would be tautological
// — it would pass just as well if both sides were empty.
const EXPECTED = [
['SIG-WEBSHELL-001', 'webshell', 'critical'],
['SIG-WEBSHELL-002', 'webshell', 'high'],
['SIG-REVSHELL-001', 'reverse_shell', 'critical'],
['SIG-REVSHELL-002', 'reverse_shell', 'critical'],
['SIG-MINER-001', 'cryptominer', 'high'],
['SIG-MINER-002', 'cryptominer', 'high'],
['SIG-HACKTOOL-001', 'hacktool', 'high'],
];
describe('malware-signatures (commons known-bad-identity ruleset)', () => {
describe('positive load through the real default commons root', () => {
it('publishes the ruleset at its declared size', () => {
// A count that drifts means either commons changed the table or the
// vendored copy is partial; both must be looked at, not adjusted away.
assert.equal(
SIGNATURE_RULES.length, 7,
'SIGNATURE_RULES lost rules — is scanners/commons vendored?',
);
});
it('publishes every rule in the declared order, with its family and severity', () => {
assert.deepEqual(
SIGNATURE_RULES.map((r) => [r.id, r.family, r.severity]),
EXPECTED,
);
});
it('publishes compiled RegExp objects, not pattern strings', () => {
// The scanner calls `rule.re.test(text)` directly. A string would throw
// there, not here, and only once a file's content reached that line.
for (const rule of SIGNATURE_RULES) {
assert.ok(rule.re instanceof RegExp, `${rule.id}: re is not a RegExp`);
}
});
it('compiles every pattern case-insensitively, as the artifact declares', () => {
// `i` is engine behaviour applied to the whole table, not per-rule data:
// the artifact carries no `flags` field at all. A builder that dropped
// the flag would still pass the count and ordering checks above while
// silently under-matching all seven rules.
for (const rule of SIGNATURE_RULES) {
assert.equal(rule.re.flags, 'i', `${rule.id}: expected the unconditional 'i' flag`);
}
});
it('carries a description and a provenance for every rule', () => {
for (const rule of SIGNATURE_RULES) {
assert.equal(typeof rule.description, 'string', `${rule.id}: no description`);
assert.ok(rule.description.length > 0, `${rule.id}: empty description`);
assert.equal(typeof rule.provenance, 'string', `${rule.id}: no provenance`);
}
});
it('freezes the published ruleset', () => {
assert.ok(Object.isFrozen(SIGNATURE_RULES));
assert.throws(() => { SIGNATURE_RULES.push({ id: 'x', re: /x/ }); }, TypeError);
});
});
describe('graceful degradation', () => {
it('yields an empty ruleset when commons is unresolvable, without throwing', () => {
const rules = buildSignatureRules({ commonsRoot: '/nonexistent/commons-root' });
assert.deepEqual(rules, []);
});
it('drops malformed rules instead of publishing them', () => {
// commons is vendored data, not code. An uncompilable pattern string
// would throw inside `new RegExp` at module load — for a scanner that is
// an aborted run rather than a degraded one.
const rules = buildSignatureRules({ commonsRoot: MALFORMED_ROOT });
assert.deepEqual(rules.map((r) => r.id), ['FIX-OK-001', 'FIX-DEFAULTS-001']);
});
it('applies the loader defaults to a rule carrying only id and pattern', () => {
// The artifact records these defaults under `missing-field-defaults` as
// loader tolerance, not as an optional-field contract. They are asserted
// so a swap cannot quietly change what an under-specified rule becomes.
const rules = buildSignatureRules({ commonsRoot: MALFORMED_ROOT });
const defaulted = rules.find((r) => r.id === 'FIX-DEFAULTS-001');
assert.equal(defaulted.family, 'unknown');
assert.equal(defaulted.severity, 'high');
assert.equal(defaulted.description, 'FIX-DEFAULTS-001');
assert.equal(defaulted.provenance, null);
});
it('preserves every field of the well-formed rule beside a malformed one', () => {
const rules = buildSignatureRules({ commonsRoot: MALFORMED_ROOT });
const ok = rules.find((r) => r.id === 'FIX-OK-001');
assert.equal(ok.family, 'webshell');
assert.equal(ok.severity, 'critical');
assert.equal(ok.re.source, 'fixture-shape-a');
assert.equal(ok.re.flags, 'i');
assert.equal(ok.provenance, 'fixture');
});
});
describe('compileRules (shared with the operator custom-rules path)', () => {
// Exported so the built-in ruleset and `sig.custom_rules_path` keep ONE
// implementation. Two copies of this defaulting logic would drift, and the
// custom path is the one an operator can get wrong.
it('drops rules lacking an id or a pattern', () => {
const compiled = compileRules({
rules: [
{ id: 'A', pattern: 'a' },
{ pattern: 'no-id' },
{ id: 'no-pattern' },
],
});
assert.deepEqual(compiled.map((r) => r.id), ['A']);
});
it('returns an empty array for a ruleset with no rules array', () => {
assert.deepEqual(compileRules({}), []);
});
});
});