new RegExp(pattern, 'i') never throws when pattern is a truthy non-string
(e.g. an object) — it ToString-coerces it first. The truthy-only guard
(`!rule.pattern`) let such a rule through as a real, compiled RegExp,
bypassing the try/catch meant to drop malformed rules. Worse than a silent
drop: `new RegExp("[object Object]", "i")` is parsed as a character class
over o/b/j/e/c/t/space, so the "dropped" rule instead becomes a
near-universal false-positive matcher. Same path for the built-in
commons-backed ruleset and the operator's sig.custom_rules_path (both
route through compileRules).
Fix: require typeof rule.pattern === 'string' before compiling. Verified
the golden dump pins no rule that exists only because of this coercion —
it regenerates byte-identically after the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iWrdLSVgRhgPzTB29rQGD
179 lines
8.1 KiB
JavaScript
179 lines
8.1 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({}), []);
|
|
});
|
|
|
|
it('drops a rule whose pattern is not a string, instead of stringifying it', () => {
|
|
// `new RegExp(pattern, 'i')` does not throw when `pattern` is a non-string
|
|
// object: the RegExp constructor coerces it via ToString, so
|
|
// `{source: 'x'}` silently becomes the literal pattern "[object Object]"
|
|
// rather than raising the compile error the `try/catch` below is there to
|
|
// catch. A truthy-only guard (`!rule.pattern`) lets that coerced rule
|
|
// through as a real, matching RegExp instead of dropping it.
|
|
const compiled = compileRules({
|
|
rules: [
|
|
{ id: 'OBJ-PATTERN-001', pattern: { source: 'x' } },
|
|
{ id: 'A', pattern: 'a' },
|
|
],
|
|
});
|
|
assert.deepEqual(compiled.map((r) => r.id), ['A']);
|
|
});
|
|
});
|
|
});
|