// 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} 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();