// 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({}), []); }); }); });