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
123 lines
5.7 KiB
JavaScript
123 lines
5.7 KiB
JavaScript
// malware-signatures.mjs — SIG known-bad-identity ruleset, built from vendored commons.
|
|
//
|
|
// v8 Phase 5 step 4, fifth and last consumer swap. The seven signatures lived
|
|
// in `knowledge/signatures.json`; they are now built once, here, from
|
|
// `signatures/malware-signatures.json` in the vendored llm-security-commons
|
|
// subtree. The commons copy was extracted from this repository's own file at
|
|
// commit b0de0ca; verified before the swap by a differential 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.
|
|
//
|
|
// `knowledge/signatures.json` is REMOVED rather than left in place. Keeping it
|
|
// would leave 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 covers what
|
|
// `new RegExp` made of the patterns rather than the bytes they arrived as.
|
|
//
|
|
// Three properties carried over from the earlier swaps, holding for the same
|
|
// reasons:
|
|
//
|
|
// 1. FAILURE IS LOUD. An empty ruleset means `scan()` returns status `ok`
|
|
// with zero findings for every file it is handed — a malware gate that
|
|
// reports success without running, which is the v7.8.2 defect class. So
|
|
// an unresolvable commons writes one line to stderr. It still does not
|
|
// throw: this module is imported by a scanner that runs inside the deep
|
|
// scan orchestrator, and a module-load throw aborts that whole run rather
|
|
// than degrading one scanner. The warning fires only for the DEFAULT
|
|
// root — an explicit `commonsRoot` is a test or a dev checkout pointing
|
|
// elsewhere on purpose, and warning there trains the reader to ignore the
|
|
// line.
|
|
//
|
|
// 2. RULES ARE COMPILED DEFENSIVELY. commons is vendored data, not code:
|
|
// `new RegExp` throws on an uncompilable pattern, and it would throw at
|
|
// module load. A malformed rule is dropped rather than published.
|
|
//
|
|
// 3. CASE-INSENSITIVITY IS ENGINE BEHAVIOUR, NOT DATA. The artifact carries
|
|
// no `flags` field on any rule and says so explicitly: every pattern is
|
|
// compiled with `i`, unconditionally. A consumer that compiled these
|
|
// case-sensitively would silently under-match all seven.
|
|
//
|
|
// Zero external dependencies — Node.js builtins only.
|
|
|
|
import { loadArtifact } from './commons-loader.mjs';
|
|
|
|
/**
|
|
* Compile a parsed ruleset object (`{ rules: [...] }`) into executable rules.
|
|
*
|
|
* Exported because the built-in ruleset and the operator's
|
|
* `sig.custom_rules_path` ruleset must share ONE implementation: two copies of
|
|
* this defaulting logic would drift, and the custom path is the one an
|
|
* operator can get wrong.
|
|
*
|
|
* Each rule's `pattern` is compiled case-insensitively; rules lacking `id`,
|
|
* lacking a non-empty string `pattern`, or whose pattern fails to compile, are
|
|
* dropped. The string check matters on its own: `new RegExp` does not throw on
|
|
* a non-string `pattern` (e.g. `{source: 'x'}`) — it coerces the value via
|
|
* ToString first, so a vendored or operator-supplied rule with a malformed
|
|
* (non-string) pattern would otherwise compile into a real, unintended regex
|
|
* instead of being caught by the try/catch below. The defaults below are
|
|
* loader tolerance, recorded in the artifact under `missing-field-defaults` —
|
|
* not an optional-field contract.
|
|
*
|
|
* @param {object} parsed
|
|
* @returns {Array<{id: string, family: string, severity: string, re: RegExp,
|
|
* description: string, provenance: string|null}>}
|
|
*/
|
|
export function compileRules(parsed) {
|
|
const compiled = [];
|
|
for (const rule of parsed?.rules || []) {
|
|
if (!rule || !rule.id || typeof rule.pattern !== 'string' || !rule.pattern) continue;
|
|
let re;
|
|
try {
|
|
re = new RegExp(rule.pattern, 'i');
|
|
} catch {
|
|
continue; // skip uncompilable patterns
|
|
}
|
|
compiled.push({
|
|
id: rule.id,
|
|
family: rule.family || 'unknown',
|
|
severity: rule.severity || 'high',
|
|
re,
|
|
description: rule.description || rule.id,
|
|
provenance: rule.provenance || null,
|
|
});
|
|
}
|
|
return compiled;
|
|
}
|
|
|
|
/**
|
|
* Build the built-in signature ruleset from a commons root.
|
|
*
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.commonsRoot] - explicit commons root (tests, dev
|
|
* checkout). Suppresses the unresolvable-commons warning, which is meant for
|
|
* the default root only.
|
|
* @returns {ReadonlyArray<object>} Always an array, so a lost commons yields an
|
|
* empty ruleset rather than an undefined one the scanner would spread and
|
|
* throw on.
|
|
*/
|
|
export function buildSignatureRules(opts = {}) {
|
|
const artifact = loadArtifact('signatures/malware-signatures', {
|
|
fallback: null,
|
|
commonsRoot: opts.commonsRoot,
|
|
});
|
|
|
|
if (artifact === null && opts.commonsRoot === undefined) {
|
|
// See note 1 above: silent is the one thing this failure must not be.
|
|
process.stderr.write(
|
|
'[llm-security] malware-signatures ruleset unresolvable at '
|
|
+ 'scanners/commons/signatures/malware-signatures.json — known-malware detection is '
|
|
+ 'DISABLED for this process. Reinstall the plugin or re-vendor the commons subtree.\n',
|
|
);
|
|
}
|
|
|
|
// Array order is the declared order: unlike signatures/secret-egress.json
|
|
// there is no `order` field, and no rule's report depends on another rule
|
|
// having matched first — each fires at most once per file, independently.
|
|
return Object.freeze(compileRules(artifact ?? {}).map((r) => Object.freeze(r)));
|
|
}
|
|
|
|
/** The known-bad-identity signatures the SIG scanner matches files against. */
|
|
export const SIGNATURE_RULES = buildSignatureRules();
|