llm-security/scanners/lib/commons-loader.mjs
Kjell Tore Guttormsen bbada84e9f refactor(llm-security): build the SIG ruleset from vendored commons (malware-signatures 0.1.0)
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
2026-08-13 21:28:14 +02:00

106 lines
4.7 KiB
JavaScript

// commons-loader.mjs — Reads vendored llm-security-commons JSON artifacts.
//
// v8 Phase 5 step 2: this loader is built and unit-tested now, against a
// local fixture, ahead of Phase 4 (llm-security-commons repo creation +
// vendoring — see docs/commons-extraction-plan.local.md). Consumers
// (injection-patterns.mjs, severity.mjs, string-utils.mjs, ...) switch from
// hardcoded tables to this loader in Phase 5 step 4, table-by-table, behind
// the golden gate.
//
// Modeled on signature-scanner.mjs's loadRules(): hooks run per-tool-call in
// fresh zero-dep processes, so resolution must be a fast synchronous read of
// the vendored copy, never network. Cached once per process. A
// missing/unvendored commons dir degrades to the caller's fallback rather
// than crashing a hook — the same graceful-empty contract the SIG scanner's
// loadRules() used for a missing knowledge/signatures.json before that
// ruleset itself moved here in Phase 5 step 4.
//
// Deliberately NOT policy-driven, unlike loadCustomRules()'s
// sig.custom_rules_path: this plugin scans untrusted cloned repos, and
// `.llm-security/policy.json` lives in the *scanned target*. A
// custom_rules_path override can only ever add findings a hostile target
// supplies; a commons-root override would let that target *replace* this
// plugin's own detection corpus wholesale, with graceful-empty fallback
// making the substitution silent. The commons location is this plugin's own
// concern, not the scan target's — so the only override is the explicit
// `commonsRoot` option (tests, a local commons dev checkout), resolved from
// this file's own location, never from policy or the scan target.
//
// Zero external dependencies — Node.js builtins only.
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Default vendored location: llm-security-commons is pulled in as a
// pull-only subtree under scanners/, which is the only source directory
// `package.json`'s `files` allowlist ships (bin/, scanners/, knowledge/ —
// scripts/ and tests/ do not publish, and neither would a repo-root dir).
const DEFAULT_COMMONS_ROOT = join(__dirname, '..', 'commons');
// Cached parsed artifacts, keyed by resolved absolute file path. A failed
// load caches this sentinel (not the caller's fallback value) so that two
// callers requesting the same missing artifact with different fallback
// shapes (`[]` vs `{}`) each still get their own fallback back, rather than
// the first caller's shape leaking to the second.
const _cache = new Map();
const LOAD_FAILED = Symbol('commons-loader:load-failed');
/** Recursively freeze a parsed JSON value so callers cannot mutate a shared cached table. */
function deepFreeze(value) {
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
for (const key of Object.keys(value)) deepFreeze(value[key]);
}
return value;
}
/**
* Load and parse one commons JSON artifact.
* Graceful fallback on any read/parse error — an unvendored, missing, or
* invalid commons artifact must never crash a hook or scanner. The returned
* value is deep-frozen: every caller shares the same cached object, so
* mutating it would silently leak across callers/targets; freezing makes
* that throw instead.
*
* @param {string} artifactPath - relative path under the commons root,
* without the `.json` extension (e.g. `'lexicon/injection-lexicon'`).
* @param {object} [opts]
* @param {*} [opts.fallback] - value returned on any load/parse failure
* (default: `null`). Callers pick the shape-appropriate empty value
* (`[]`, `{}`, ...) the same way loadRules() falls back to `[]`.
* @param {string} [opts.commonsRoot] - explicit commons root, overriding the
* default vendored path (for tests and a local commons dev checkout).
* @returns {*} Parsed, frozen JSON, or `opts.fallback` on failure.
*/
export function loadArtifact(artifactPath, opts = {}) {
const { fallback = null, commonsRoot } = opts;
const root = commonsRoot || DEFAULT_COMMONS_ROOT;
const filePath = join(root, `${artifactPath}.json`);
if (_cache.has(filePath)) {
const cached = _cache.get(filePath);
return cached === LOAD_FAILED ? fallback : cached;
}
let result;
try {
const raw = readFileSync(filePath, 'utf8');
result = deepFreeze(JSON.parse(raw));
} catch {
_cache.set(filePath, LOAD_FAILED); // graceful: unvendored/missing/invalid -> caller's fallback
return fallback;
}
_cache.set(filePath, result);
return result;
}
/**
* Reset the artifact cache (for testing only).
*/
export function _resetCacheForTest() {
_cache.clear();
}