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
163 lines
7.3 KiB
JavaScript
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({}), []);
|
|
});
|
|
});
|
|
});
|