muaddib-scanner 2.11.92 → 2.11.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/{self-scan-v2.11.92.json → self-scan-v2.11.93.json} +1 -1
- package/src/scanner/ast-detectors/handle-call-expression.js +42 -2
- package/src/scanner/ast-detectors/handle-post-walk.js +13 -0
- package/src/scanner/ast-detectors/mcp-write-classifier.js +71 -0
- package/src/scanner/ast.js +4 -0
package/package.json
CHANGED
|
@@ -49,6 +49,31 @@ const {
|
|
|
49
49
|
containsDecodePattern,
|
|
50
50
|
resolveNumericExpression
|
|
51
51
|
} = require('./helpers.js');
|
|
52
|
+
const { countInvisibleUnicode } = require('../../shared/unicode-invisibles.js');
|
|
53
|
+
const { classifyMcpWrite } = require('./mcp-write-classifier.js');
|
|
54
|
+
const { isShadowEnabled, recordShadowDivergence } = require('../../shared/shadow.js');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SHADOW 3-tier classification for mcp_config_injection emissions (R5 + R5b).
|
|
58
|
+
* Computes the candidate class (template / shell_exec / instruction_injection)
|
|
59
|
+
* and logs a divergence ONLY when the candidate semantics would downgrade the
|
|
60
|
+
* verdict (template → MEDIUM). Zero effect on the threat emitted by the caller
|
|
61
|
+
* — the live severity stays CRITICAL until the shadow data adjudicates the flip.
|
|
62
|
+
* The package identity is not available at AST level; evidence carries the file.
|
|
63
|
+
*/
|
|
64
|
+
function _shadowClassifyMcpWrite(contentStr, checkPath, rule, ctx) {
|
|
65
|
+
try {
|
|
66
|
+
if (!isShadowEnabled()) return;
|
|
67
|
+
const { cls, signals } = classifyMcpWrite(contentStr, checkPath);
|
|
68
|
+
if (cls !== 'template') return; // shell_exec / instruction_injection keep CRITICAL — no divergence
|
|
69
|
+
recordShadowDivergence({
|
|
70
|
+
detector: 'mcp_config_injection_3tier',
|
|
71
|
+
oldVerdict: 'CRITICAL',
|
|
72
|
+
newVerdict: 'MEDIUM',
|
|
73
|
+
evidence: { cls, signals, path: checkPath, rule, file: ctx.relFile }
|
|
74
|
+
});
|
|
75
|
+
} catch { /* shadow must never affect the scan */ }
|
|
76
|
+
}
|
|
52
77
|
|
|
53
78
|
/**
|
|
54
79
|
* Detect whether an AST node points at a user-level filesystem location:
|
|
@@ -756,6 +781,10 @@ function handleCallExpression(node, ctx) {
|
|
|
756
781
|
? MCP_CONTENT_PATTERNS.some(p => contentStr.includes(p.replace(/"/g, '')))
|
|
757
782
|
: isSensitiveConfigFile; // dynamic content only suspicious for known config files
|
|
758
783
|
if (hasContentPattern) {
|
|
784
|
+
// SHADOW 3-tier classification (zero effect on the emitted severity):
|
|
785
|
+
// template-class writes are the scaffolder FP under adjudication —
|
|
786
|
+
// log the would-be CRITICAL→MEDIUM divergence for `shadow-report`.
|
|
787
|
+
_shadowClassifyMcpWrite(contentStr, mcpCheckPath, 'R5', ctx);
|
|
759
788
|
ctx.threats.push({
|
|
760
789
|
type: 'mcp_config_injection',
|
|
761
790
|
severity: 'CRITICAL',
|
|
@@ -780,11 +809,20 @@ function handleCallExpression(node, ctx) {
|
|
|
780
809
|
const contentStr2 = extractStringValue(contentArg2);
|
|
781
810
|
const hasShellContent = !!contentStr2 && /(?:curl|wget)\s+[^\n]*\|\s*(?:sh|bash|zsh)\b|\beval\s*\(|\bsh\s+-c\s+|\bbash\s+-c\s+|\bnode\s+-e\s+/i.test(contentStr2);
|
|
782
811
|
const hasInjectionInstruction = !!contentStr2 && /IMPORTANT[:\s]+(?:before|after|run|execute)|do\s+not\s+(?:display|show|mention)|always\s+run/i.test(contentStr2);
|
|
783
|
-
|
|
812
|
+
// 3d (additive, v2.11.91): zero-width/bidi Unicode in the written
|
|
813
|
+
// content — the TrapDoor hidden-instruction encoding (Socket,
|
|
814
|
+
// 2026-05-25: instructions invisible in an editor, word-broken so
|
|
815
|
+
// the 3b/3c plain-text regexes can't match). A legitimate generator
|
|
816
|
+
// never emits invisible codepoints into a rules file. Strictly
|
|
817
|
+
// additive: can only ADD detections to the 3a/3b/3c OR.
|
|
818
|
+
const hasInvisibleContent = !!contentStr2 && countInvisibleUnicode(contentStr2) > 0;
|
|
819
|
+
if (hasUserLevelPath || hasShellContent || hasInjectionInstruction || hasInvisibleContent) {
|
|
784
820
|
const reasons = [];
|
|
785
821
|
if (hasUserLevelPath) reasons.push('user-level destination (homedir/cwd/env.HOME)');
|
|
786
822
|
if (hasShellContent) reasons.push('shell command in content');
|
|
787
823
|
if (hasInjectionInstruction) reasons.push('AI prompt-injection instruction in content');
|
|
824
|
+
if (hasInvisibleContent) reasons.push('zero-width/bidi Unicode in content (hidden-instruction encoding)');
|
|
825
|
+
_shadowClassifyMcpWrite(contentStr2, mcpCheckPath, 'R5b', ctx);
|
|
788
826
|
ctx.threats.push({
|
|
789
827
|
type: 'mcp_config_injection',
|
|
790
828
|
severity: 'CRITICAL',
|
|
@@ -2047,4 +2085,6 @@ function handleCallExpression(node, ctx) {
|
|
|
2047
2085
|
}
|
|
2048
2086
|
|
|
2049
2087
|
|
|
2050
|
-
|
|
2088
|
+
// _shadowClassifyMcpWrite is shared with handle-post-walk.js (the Wave-4
|
|
2089
|
+
// keyword-co-occurrence emitter — the third mcp_config_injection site).
|
|
2090
|
+
module.exports = { handleCallExpression, _shadowClassifyMcpWrite };
|
|
@@ -274,6 +274,19 @@ function handlePostWalk(ctx) {
|
|
|
274
274
|
|
|
275
275
|
// Wave 4: MCP content keywords in file with writeFileSync = MCP injection signal
|
|
276
276
|
if (ctx.hasMcpContentKeywords && !ctx.threats.some(t => t.type === 'mcp_config_injection')) {
|
|
277
|
+
// SHADOW 3-tier classification (zero effect on the emitted severity). The
|
|
278
|
+
// 2026-06-11 backtest showed this keyword-co-occurrence rule emits ~85% of
|
|
279
|
+
// historical mcp_config_injection alerts (100/118 packages) — every
|
|
280
|
+
// legitimate MCP server installer carries mcpServers keywords + writes —
|
|
281
|
+
// so the adjudication MUST cover this site, not just R5/R5b. The classifier
|
|
282
|
+
// runs on the FILE source (the written content is not extractable here):
|
|
283
|
+
// a file whose code carries shell-exec or hidden-instruction markers keeps
|
|
284
|
+
// CRITICAL silently; an inert config-writer logs the CRITICAL→MEDIUM
|
|
285
|
+
// candidate divergence, tagged rule:'W4' so the report splits it out.
|
|
286
|
+
try {
|
|
287
|
+
const { _shadowClassifyMcpWrite } = require('./handle-call-expression.js');
|
|
288
|
+
_shadowClassifyMcpWrite(typeof ctx._content === 'string' ? ctx._content : null, '(file-level keyword co-occurrence)', 'W4', ctx);
|
|
289
|
+
} catch { /* shadow must never affect the scan */ }
|
|
277
290
|
ctx.threats.push({
|
|
278
291
|
type: 'mcp_config_injection',
|
|
279
292
|
severity: 'CRITICAL',
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-write-classifier.js — pure 3-tier classifier for mcp_config_injection
|
|
5
|
+
* candidates (SHADOW adjudication + the future severity flip).
|
|
6
|
+
*
|
|
7
|
+
* Empirical classes (web research 2026-06-11, calibrated on real campaigns):
|
|
8
|
+
* (a) template — write with inert content: the scaffolder shape
|
|
9
|
+
* (ruler, rulesync, cursor-rules, cursor-tools all legitimately write
|
|
10
|
+
* .cursorrules/CLAUDE.md/AGENTS.md). Candidate MEDIUM after adjudication.
|
|
11
|
+
* (b) shell_exec — content carries a shell command or an
|
|
12
|
+
* agent-hook exec (SafeDep campaign, 2026-05-13: .claude/settings.json
|
|
13
|
+
* SessionStart hook → ELF). Stays CRITICAL.
|
|
14
|
+
* (c) instruction_injection — content carries hidden instructions: zero-
|
|
15
|
+
* width/bidi Unicode (TrapDoor encoding — Socket 2026-05-25; GitHub
|
|
16
|
+
* flags the same) or agent-addressed directives ("do not tell the
|
|
17
|
+
* user…"). Stays CRITICAL.
|
|
18
|
+
*
|
|
19
|
+
* The classifier is PURE (no I/O, no ctx) so it is unit-testable per class and
|
|
20
|
+
* is exactly what gets promoted when the flip lands. Until then it feeds the
|
|
21
|
+
* shadow log: oldVerdict CRITICAL vs newVerdict (template→MEDIUM).
|
|
22
|
+
*
|
|
23
|
+
* Honest default: content that cannot be extracted statically classifies as
|
|
24
|
+
* `template` with signal `dynamic_content` — we don't know, so the shadow
|
|
25
|
+
* numbers must not pretend we do. (The live R5/R5b severity is unaffected
|
|
26
|
+
* either way — this module emits no threats.)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { countInvisibleUnicode } = require('../../shared/unicode-invisibles.js');
|
|
30
|
+
|
|
31
|
+
// (c) — agent-addressed directives. Superset of the live R5b 3c regex
|
|
32
|
+
// (IMPORTANT/do-not-display/always-run) with the additions calibrated on the
|
|
33
|
+
// Rules-File-Backdoor / Mini-Shai-Hulud wording. Word-boundaried enough not to
|
|
34
|
+
// match benign docs ("important: run tests before committing" matches — by
|
|
35
|
+
// design, that wording addressed to an agent IS the attack shape; the
|
|
36
|
+
// difference is made by the write target, which the caller already gated on).
|
|
37
|
+
const INJECTION_DIRECTIVE_RE = /IMPORTANT[:\s]+(?:before|after|run|execute)|do\s+not\s+(?:display|show|mention|tell)|never\s+(?:mention|reveal|disclose)|hide\s+this\s+from|always\s+run/i;
|
|
38
|
+
|
|
39
|
+
// (b) — shell command in content. Same expression as the live R5b 3b gate.
|
|
40
|
+
const SHELL_CONTENT_RE = /(?:curl|wget)\s+[^\n]*\|\s*(?:sh|bash|zsh)\b|\beval\s*\(|\bsh\s+-c\s+|\bbash\s+-c\s+|\bnode\s+-e\s+/i;
|
|
41
|
+
|
|
42
|
+
// (b) — agent-hook exec in JSON content: a "hooks" structure carrying a
|
|
43
|
+
// "command" (the SafeDep .claude/settings.json SessionStart shape). Order-
|
|
44
|
+
// insensitive containment — the content is config the attacker controls, a
|
|
45
|
+
// strict JSON parse would be evadable with trailing garbage.
|
|
46
|
+
const HOOKS_COMMAND_RE = /"hooks"[\s\S]{0,400}"command"|"command"[\s\S]{0,400}"hooks"/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string|null|undefined} contentStr statically-extracted write content
|
|
50
|
+
* (null/undefined = dynamic, not extractable)
|
|
51
|
+
* @param {string} [checkPath] lowercased destination path (reserved for future
|
|
52
|
+
* signals; not used for class decision today)
|
|
53
|
+
* @returns {{cls: 'template'|'shell_exec'|'instruction_injection', signals: string[]}}
|
|
54
|
+
*/
|
|
55
|
+
function classifyMcpWrite(contentStr, checkPath) { // eslint-disable-line no-unused-vars
|
|
56
|
+
if (contentStr === null || contentStr === undefined || typeof contentStr !== 'string') {
|
|
57
|
+
return { cls: 'template', signals: ['dynamic_content'] };
|
|
58
|
+
}
|
|
59
|
+
const signals = [];
|
|
60
|
+
if (countInvisibleUnicode(contentStr) > 0) signals.push('zero_width_unicode');
|
|
61
|
+
if (INJECTION_DIRECTIVE_RE.test(contentStr)) signals.push('injection_directive');
|
|
62
|
+
if (signals.length > 0) return { cls: 'instruction_injection', signals };
|
|
63
|
+
|
|
64
|
+
if (SHELL_CONTENT_RE.test(contentStr)) signals.push('shell_command');
|
|
65
|
+
if (HOOKS_COMMAND_RE.test(contentStr)) signals.push('hooks_command_json');
|
|
66
|
+
if (signals.length > 0) return { cls: 'shell_exec', signals };
|
|
67
|
+
|
|
68
|
+
return { cls: 'template', signals: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { classifyMcpWrite, INJECTION_DIRECTIVE_RE, SHELL_CONTENT_RE, HOOKS_COMMAND_RE };
|
package/src/scanner/ast.js
CHANGED
|
@@ -111,6 +111,10 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
111
111
|
const ctx = {
|
|
112
112
|
threats,
|
|
113
113
|
relFile: path.relative(basePath, filePath),
|
|
114
|
+
// File source reference for the post-walk shadow classifier (Wave-4 MCP
|
|
115
|
+
// site has no extractable written-content string — it classifies the file).
|
|
116
|
+
// A reference to the already-held string: no copy, freed with the ctx.
|
|
117
|
+
_content: content,
|
|
114
118
|
dynamicRequireVars: new Set(),
|
|
115
119
|
staticAssignments: new Set(),
|
|
116
120
|
// v2.10.73 P2: AST-006 source qualification — tracks WHERE a variable's value came from.
|