fix(llm-security): SIG self-flagged the vendored commons it detects from

Scanning this repository with the SIG scanner produced 7 findings, 4 of them
on our own detection data: scanners/commons/CHANGELOG.md and
scanners/commons/signatures/malware-signatures.json. The ruleset that describes
xmrig and webshells is, byte for byte, a document containing those strings, so
the engine matched it as malware. EXCLUDED_PATH_RE already carried
knowledge/, tests/, docs/ and node_modules/ for exactly this reason; the
vendored commons arrived in v8 Phase 5 (bbada84) without being added.

One alternation branch closes it. Tests first: two cases added to
describe('signature-scanner: path exclusions'), both verified red against the
real scan() entry point before the regex changed.

Stated plainly, because it is a real cost and not a technicality: the branch is
`scanners\/commons` behind the existing `(^|\/)` prefix, so it matches that
two-segment path ANYWHERE in a target's relative path, not only at its root. A
webshell planted at vendor/scanners/commons/shell.php in a hostile repository is
therefore invisible to SIG. The second new test asserts that blind spot
deliberately, so it can never be discovered by accident. It is accepted because
anchoring at ^scanners/commons/ would miss the same payload one directory
deeper while re-opening the self-flag whenever the plugin is scanned from a
parent directory. TRG, AST, entropy and supply-chain still read these files;
only SIG identity-matching is blinded.

scanners/lib/supply-chain-data.mjs is NOT excluded. Its finding is a true
positive against real blocklist data.

Measured before: 7 findings. After: 3 (2 on STATE.md, 1 on
supply-chain-data.mjs). signature-scanner.test.mjs 23/23; custom-rules + e2e
54/54; golden-baseline 8/8 with suite-counts.json untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBJVYzwch63Rpk1hii1cNM
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 21:41:09 +02:00
commit eceb71bbb3
3 changed files with 59 additions and 2 deletions

View file

@ -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.
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.
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/`, `node_modules/`, and `scanners/commons/` (the vendored ruleset describes the malware it detects, so SIG matched its own detection data) — each matched anywhere in the relative path, which knowingly blinds SIG to a payload planted under such a directory in a scanned repo. 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.

View file

@ -25,7 +25,18 @@ import { SIGNATURE_RULES, compileRules } from './lib/malware-signatures.mjs';
// 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;
//
// `scanners/commons/` is the vendored ruleset itself (v8 Phase 5). Its CHANGELOG
// and malware-signatures.json describe the malware they detect, so SIG matched
// them as malware: scanning this repo yielded 4 findings on our own detection
// data. Note the alternation matches the two-segment path ANYWHERE in the
// relative path, not only at the root — so a webshell planted at
// `vendor/scanners/commons/shell.php` in a hostile repo is invisible to SIG.
// That blind spot is accepted knowingly: anchoring at `^scanners/commons/`
// would miss the same payload one directory deeper anyway, while re-opening the
// self-flag whenever the plugin is scanned from a parent directory. Other
// scanners still read these files; only SIG identity-matching is blinded.
const EXCLUDED_PATH_RE = /(^|\/)(knowledge|tests|docs|node_modules|scanners\/commons)\//i;
const DEFAULT_FAMILIES = ['webshell', 'reverse_shell', 'cryptominer', 'hacktool'];

View file

@ -234,6 +234,52 @@ describe('signature-scanner: path exclusions', () => {
rmSync(dir, { recursive: true, force: true });
}
});
// Self-flag gap measured 2026-08-13: scanning THIS repo produced 4 findings on
// the vendored commons (scanners/commons/CHANGELOG.md and
// signatures/malware-signatures.json) — the ruleset describing malware matched
// as malware. The exclusion is a two-segment path, not a bare directory name,
// so a target's own `commons/` stays scanned.
it('does not scan scanners/commons/ — the vendored ruleset describes what it detects', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-commons-'));
try {
mkdirSync(join(dir, 'scanners', 'commons', 'signatures'), { recursive: true });
// Shaped like the real vendored data: signature prose that is itself a match.
writeFileSync(join(dir, 'scanners', 'commons', 'CHANGELOG.md'), "- added rule for `xmrig --donate-level` miners\n");
writeFileSync(
join(dir, 'scanners', 'commons', 'signatures', 'malware-signatures.json'),
JSON.stringify({ rules: [{ id: 'x', pattern: 'xmrig --donate-level' }] }),
);
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
assert.equal(result.findings.length, 0, `scanners/commons/ should yield 0 findings, got ${result.findings.map(f => f.file).join('; ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The exclusion matches ANYWHERE in the relative path, not just at its root.
// That is a deliberate blind spot with a real cost: a webshell planted at
// `vendor/scanners/commons/shell.php` in a hostile repo is invisible to SIG.
// Accepted because the alternative — anchoring to `^scanners/commons/` — would
// still miss the same payload one directory deeper while adding the failure
// mode where our own vendored copy self-flags whenever the plugin is scanned
// from a parent directory. Other scanners (TRG/AST, entropy, supply-chain)
// still see these files; only SIG identity-matching is blinded.
it('excludes scanners/commons/ anywhere in the path, blinding SIG to a webshell hidden there', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-commons-nested-'));
try {
mkdirSync(join(dir, 'vendor', 'scanners', 'commons'), { recursive: true });
writeFileSync(join(dir, 'vendor', 'scanners', 'commons', 'shell.php'), "<?php @eval($_POST['x']); ?>\n");
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
assert.equal(result.findings.length, 0, `documents the accepted blind spot, got ${result.findings.map(f => f.file).join('; ')}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------