muaddib-scanner 2.11.52 → 2.11.53
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
CHANGED
package/src/monitor/queue.js
CHANGED
|
@@ -480,6 +480,18 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
480
480
|
throw staticErr;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
// Phase 3 signal — agent-supply-chain lens. Pure observability, no scoring impact.
|
|
484
|
+
// Cisco AI Defense / SkillSieve / Snyk Agent Scan EVO scan skill marketplaces;
|
|
485
|
+
// they don't monitor the npm/PyPI firehose. Tracking which packages bundle a
|
|
486
|
+
// SKILL.md is our unique intersection (npm-package-bundling-malicious-skill).
|
|
487
|
+
try {
|
|
488
|
+
const det = detectSkillMdBundled(extractedDir, result && result.threats);
|
|
489
|
+
if (det.bundled) {
|
|
490
|
+
stats.skillMdBundled = (stats.skillMdBundled || 0) + 1;
|
|
491
|
+
console.log(`[MONITOR] SKILL_MD_BUNDLED: ${name}@${version} (${ecosystem}) — ${det.count} file(s)`);
|
|
492
|
+
}
|
|
493
|
+
} catch { /* observability signal — never let it break the scan */ }
|
|
494
|
+
|
|
483
495
|
// First-publish detection: used for sandbox priority below
|
|
484
496
|
const isFirstPublish = cacheTrigger && cacheTrigger.reason === 'first_publish';
|
|
485
497
|
|
|
@@ -1004,6 +1016,34 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
|
|
|
1004
1016
|
}
|
|
1005
1017
|
}
|
|
1006
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Detect whether a package bundles a SKILL.md (Anthropic Agent Skills spec).
|
|
1021
|
+
* Pure observability — drives the `stats.skillMdBundled` counter, no scoring effect.
|
|
1022
|
+
*
|
|
1023
|
+
* Two-pass check: (1) inspect emitted threats for SKILL.md filenames so we catch
|
|
1024
|
+
* cases the scanner already touched without re-walking the tree; (2) fall back to
|
|
1025
|
+
* a bounded findFiles walk (maxDepth 4, maxFiles 5) for packages where no scanner
|
|
1026
|
+
* has flagged anything.
|
|
1027
|
+
*
|
|
1028
|
+
* @param {string|null} extractedDir - Unpacked tarball root, or null if unknown.
|
|
1029
|
+
* @param {Array<{file?:string}>|null} threats - Threats array from the scan result.
|
|
1030
|
+
* @returns {{bundled: boolean, count: number}}
|
|
1031
|
+
*/
|
|
1032
|
+
function detectSkillMdBundled(extractedDir, threats) {
|
|
1033
|
+
const fromThreats = Array.isArray(threats) && threats.some(
|
|
1034
|
+
t => /(?:^|[\\/])SKILL\.md$/i.test((t && t.file) || '')
|
|
1035
|
+
);
|
|
1036
|
+
if (fromThreats) return { bundled: true, count: 1 };
|
|
1037
|
+
if (!extractedDir) return { bundled: false, count: 0 };
|
|
1038
|
+
try {
|
|
1039
|
+
const { findFiles } = require('../utils.js');
|
|
1040
|
+
const found = findFiles(extractedDir, { extensions: ['SKILL.md'], maxDepth: 4, maxFiles: 5 });
|
|
1041
|
+
return { bundled: found.length > 0, count: found.length };
|
|
1042
|
+
} catch {
|
|
1043
|
+
return { bundled: false, count: 0 };
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1007
1047
|
function timeoutPromise(ms) {
|
|
1008
1048
|
return new Promise((_, reject) => {
|
|
1009
1049
|
setTimeout(() => reject(new Error(`Scan timeout after ${ms / 1000}s`)), ms);
|
|
@@ -1480,6 +1520,7 @@ module.exports = {
|
|
|
1480
1520
|
runScanInWorker,
|
|
1481
1521
|
scanPackage,
|
|
1482
1522
|
timeoutPromise,
|
|
1523
|
+
detectSkillMdBundled,
|
|
1483
1524
|
isDailyReportDue,
|
|
1484
1525
|
processQueueItem,
|
|
1485
1526
|
processQueue,
|
package/src/scanner/ai-config.js
CHANGED
|
@@ -43,13 +43,31 @@ const AI_CONFIG_FILES = [
|
|
|
43
43
|
// These are distinct from AI_CONFIG_FILES: they contain machine-readable hooks
|
|
44
44
|
// that execute code on project open, not human-readable prompt injection.
|
|
45
45
|
// Technique: Shai-Hulud (TeamPCP, May 2026) — .claude/settings.json SessionStart hook.
|
|
46
|
+
// Additional mai 2026 surfaces (Cursor / Windsurf / Continue / root Claude Desktop)
|
|
47
|
+
// added after the TrapDoor + Bitwarden CLI campaigns confirmed cross-agent targeting.
|
|
46
48
|
const IDE_HOOK_FILES = [
|
|
47
49
|
'.claude/settings.json',
|
|
48
50
|
'.claude/settings.local.json',
|
|
49
51
|
'.vscode/tasks.json',
|
|
50
|
-
'.kiro/settings/mcp.json'
|
|
52
|
+
'.kiro/settings/mcp.json',
|
|
53
|
+
'.cursor/mcp.json',
|
|
54
|
+
'.continue/config.json',
|
|
55
|
+
'.windsurf/mcp.json',
|
|
56
|
+
'mcp.json',
|
|
57
|
+
'claude_desktop_config.json'
|
|
51
58
|
];
|
|
52
59
|
|
|
60
|
+
// Paths that follow the standard MCP `mcpServers.{name}.command` schema.
|
|
61
|
+
// A package shipping any of these with a `command` entry is hostile: legitimate
|
|
62
|
+
// npm/PyPI packages never ship per-user MCP configurations.
|
|
63
|
+
const MCP_STANDARD_PATHS = new Set([
|
|
64
|
+
'.kiro/settings/mcp.json',
|
|
65
|
+
'.cursor/mcp.json',
|
|
66
|
+
'.windsurf/mcp.json',
|
|
67
|
+
'mcp.json',
|
|
68
|
+
'claude_desktop_config.json'
|
|
69
|
+
]);
|
|
70
|
+
|
|
53
71
|
// Dangerous shell command patterns in AI config files
|
|
54
72
|
const SHELL_COMMAND_PATTERNS = [
|
|
55
73
|
// Download and execute
|
|
@@ -209,9 +227,46 @@ function analyzeIDEHookFile(content, relPath) {
|
|
|
209
227
|
}
|
|
210
228
|
}
|
|
211
229
|
|
|
212
|
-
//
|
|
230
|
+
// Standard MCP config family:
|
|
231
|
+
// .kiro/settings/mcp.json | .cursor/mcp.json | .windsurf/mcp.json
|
|
232
|
+
// | root mcp.json (Claude Desktop project mode)
|
|
233
|
+
// | root claude_desktop_config.json (Claude Desktop global, hostile if shipped)
|
|
213
234
|
// Structure: { mcpServers: { name: { command, args } } }
|
|
214
|
-
if (
|
|
235
|
+
if (MCP_STANDARD_PATHS.has(relPath)) {
|
|
236
|
+
const mcpServers = parsed.mcpServers;
|
|
237
|
+
if (mcpServers && typeof mcpServers === 'object') {
|
|
238
|
+
for (const [name, config] of Object.entries(mcpServers)) {
|
|
239
|
+
if (config && typeof config === 'object' && config.command) {
|
|
240
|
+
threats.push({
|
|
241
|
+
type: 'ide_hook_autoexec',
|
|
242
|
+
severity: 'CRITICAL',
|
|
243
|
+
message: `IDE auto-exec hook: ${relPath} server "${name}" executes "${config.command}" on project open`,
|
|
244
|
+
file: relPath
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// .continue/config.json — Continue.dev schema. Two MCP surfaces:
|
|
252
|
+
// 1. experimental.modelContextProtocolServers[].transport.command (canonical)
|
|
253
|
+
// 2. mcpServers.{name}.command (newer alias)
|
|
254
|
+
if (relPath === '.continue/config.json') {
|
|
255
|
+
const exp = parsed.experimental;
|
|
256
|
+
const mcps = exp && Array.isArray(exp.modelContextProtocolServers)
|
|
257
|
+
? exp.modelContextProtocolServers
|
|
258
|
+
: [];
|
|
259
|
+
for (const srv of mcps) {
|
|
260
|
+
const cmd = srv && srv.transport && srv.transport.command;
|
|
261
|
+
if (cmd) {
|
|
262
|
+
threats.push({
|
|
263
|
+
type: 'ide_hook_autoexec',
|
|
264
|
+
severity: 'CRITICAL',
|
|
265
|
+
message: `IDE auto-exec hook: .continue/config.json modelContextProtocolServer transport executes "${cmd}" on project open`,
|
|
266
|
+
file: relPath
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
215
270
|
const mcpServers = parsed.mcpServers;
|
|
216
271
|
if (mcpServers && typeof mcpServers === 'object') {
|
|
217
272
|
for (const [name, config] of Object.entries(mcpServers)) {
|
|
@@ -219,7 +274,7 @@ function analyzeIDEHookFile(content, relPath) {
|
|
|
219
274
|
threats.push({
|
|
220
275
|
type: 'ide_hook_autoexec',
|
|
221
276
|
severity: 'CRITICAL',
|
|
222
|
-
message: `IDE auto-exec hook: .
|
|
277
|
+
message: `IDE auto-exec hook: .continue/config.json mcpServers "${name}" executes "${config.command}" on project open`,
|
|
223
278
|
file: relPath
|
|
224
279
|
});
|
|
225
280
|
}
|
|
@@ -136,6 +136,17 @@ const SENSITIVE_AI_CONFIG_FILES_UNIQUE = [
|
|
|
136
136
|
'claude.md', 'claude_desktop_config.json',
|
|
137
137
|
'mcp.json',
|
|
138
138
|
'.cursorrules', '.windsurfrules',
|
|
139
|
+
'copilot-instructions.md',
|
|
140
|
+
'agents.md', 'agent.md'
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
// Agent prompt files that live at any directory level — written by TrapDoor-style
|
|
144
|
+
// post-install hooks to the user's home or cwd. Detected via a "standalone" branch
|
|
145
|
+
// that does NOT require an MCP_CONFIG_PATHS dir prefix (otherwise homedir + .cursorrules
|
|
146
|
+
// slips through because '.cursorrules'.includes('.cursor/') is false).
|
|
147
|
+
const AGENT_PROMPT_FILENAMES = [
|
|
148
|
+
'.cursorrules', '.windsurfrules',
|
|
149
|
+
'claude.md', 'agents.md', 'agent.md',
|
|
139
150
|
'copilot-instructions.md'
|
|
140
151
|
];
|
|
141
152
|
const SENSITIVE_AI_CONFIG_FILES_ROOT_ONLY = [
|
|
@@ -256,6 +267,7 @@ module.exports = {
|
|
|
256
267
|
NODE_HOOKABLE_CLASSES,
|
|
257
268
|
MCP_CONFIG_PATHS,
|
|
258
269
|
MCP_CONTENT_PATTERNS,
|
|
270
|
+
AGENT_PROMPT_FILENAMES,
|
|
259
271
|
SENSITIVE_AI_CONFIG_FILES_UNIQUE,
|
|
260
272
|
SENSITIVE_AI_CONFIG_FILES_ROOT_ONLY,
|
|
261
273
|
GIT_HOOKS,
|
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
DANGEROUS_CMD_PATTERNS,
|
|
31
31
|
MCP_CONFIG_PATHS,
|
|
32
32
|
MCP_CONTENT_PATTERNS,
|
|
33
|
+
AGENT_PROMPT_FILENAMES,
|
|
33
34
|
SENSITIVE_AI_CONFIG_FILES_UNIQUE,
|
|
34
35
|
SENSITIVE_AI_CONFIG_FILES_ROOT_ONLY,
|
|
35
36
|
GIT_HOOKS,
|
|
@@ -51,6 +52,56 @@ const {
|
|
|
51
52
|
resolveNumericExpression
|
|
52
53
|
} = require('./helpers.js');
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Detect whether an AST node points at a user-level filesystem location:
|
|
57
|
+
* os.homedir() | process.cwd() | process.env.HOME | process.env.USERPROFILE
|
|
58
|
+
* | string starting with "~/" | absolute "/home/" or "C:\\Users\\" prefix
|
|
59
|
+
* | path.join(...) where ANY arg matches the above (recursive)
|
|
60
|
+
*
|
|
61
|
+
* Used by SANDWORM_MODE R5b to distinguish hostile TrapDoor-style writes
|
|
62
|
+
* (homedir + .cursorrules) from legit scaffolder writes (__dirname + tmpl).
|
|
63
|
+
*/
|
|
64
|
+
function _isUserLevelPathArg(node, depth = 0) {
|
|
65
|
+
if (!node || depth > 4) return false;
|
|
66
|
+
// os.homedir() / process.cwd()
|
|
67
|
+
if (node.type === 'CallExpression' && node.callee?.type === 'MemberExpression') {
|
|
68
|
+
const objName = node.callee.object?.name || node.callee.object?.property?.name;
|
|
69
|
+
const propName = node.callee.property?.name;
|
|
70
|
+
if (objName === 'os' && propName === 'homedir') return true;
|
|
71
|
+
if (objName === 'process' && propName === 'cwd') return true;
|
|
72
|
+
// path.join() / path.resolve() — recurse into args
|
|
73
|
+
if (objName === 'path' && (propName === 'join' || propName === 'resolve')) {
|
|
74
|
+
return Array.isArray(node.arguments) && node.arguments.some(a => _isUserLevelPathArg(a, depth + 1));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// process.env.HOME / process.env.USERPROFILE
|
|
78
|
+
if (node.type === 'MemberExpression' && node.object?.type === 'MemberExpression') {
|
|
79
|
+
const root = node.object.object;
|
|
80
|
+
const mid = node.object.property;
|
|
81
|
+
const leaf = node.property;
|
|
82
|
+
if (root?.name === 'process' && mid?.name === 'env'
|
|
83
|
+
&& (leaf?.name === 'HOME' || leaf?.name === 'USERPROFILE')) return true;
|
|
84
|
+
}
|
|
85
|
+
// Literal string indicators
|
|
86
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
87
|
+
if (node.value.startsWith('~/') || node.value.startsWith('~\\')) return true;
|
|
88
|
+
if (/^\/home\/|^\/Users\//.test(node.value)) return true;
|
|
89
|
+
if (/^[A-Za-z]:[\\/]Users[\\/]/.test(node.value)) return true;
|
|
90
|
+
}
|
|
91
|
+
// Template literal — check quasi parts for the same prefixes
|
|
92
|
+
if (node.type === 'TemplateLiteral' && Array.isArray(node.quasis) && node.quasis[0]) {
|
|
93
|
+
const head = node.quasis[0].value?.cooked || '';
|
|
94
|
+
if (head.startsWith('~/') || /^\/home\/|^\/Users\/|^[A-Za-z]:[\\/]Users[\\/]/.test(head)) return true;
|
|
95
|
+
// Also recurse into ${expr} parts: if any expression is a user-level indicator, treat as user-level
|
|
96
|
+
if (Array.isArray(node.expressions) && node.expressions.some(e => _isUserLevelPathArg(e, depth + 1))) return true;
|
|
97
|
+
}
|
|
98
|
+
// BinaryExpression "a + b" — recurse into both sides
|
|
99
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
100
|
+
return _isUserLevelPathArg(node.left, depth + 1) || _isUserLevelPathArg(node.right, depth + 1);
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
54
105
|
function handleCallExpression(node, ctx) {
|
|
55
106
|
const callName = getCallName(node);
|
|
56
107
|
|
|
@@ -662,10 +713,13 @@ function handleCallExpression(node, ctx) {
|
|
|
662
713
|
}
|
|
663
714
|
}
|
|
664
715
|
|
|
665
|
-
// SANDWORM_MODE R5: MCP config injection — writeFileSync to AI config paths
|
|
716
|
+
// SANDWORM_MODE R5: MCP config injection — writeFileSync/appendFileSync to AI config paths.
|
|
717
|
+
// appendFileSync was added in v2.11.49 to cover the TrapDoor (mai 2026) pattern that
|
|
718
|
+
// appends ZW-Unicode-poisoned instructions to existing CLAUDE.md / .cursorrules instead
|
|
719
|
+
// of overwriting them — the original missed M3 fixture's appendFileSync('CLAUDE.md', ...).
|
|
666
720
|
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
667
721
|
const mcpWriteMethod = node.callee.property.name;
|
|
668
|
-
if (['writeFileSync', 'writeFile'].includes(mcpWriteMethod) && node.arguments.length >= 2) {
|
|
722
|
+
if (['writeFileSync', 'writeFile', 'appendFileSync', 'appendFile'].includes(mcpWriteMethod) && node.arguments.length >= 2) {
|
|
669
723
|
const mcpPathArg = node.arguments[0];
|
|
670
724
|
const mcpPathStr = extractStringValueDeep(mcpPathArg);
|
|
671
725
|
// Also check path.join() calls — resolve concat fragments in each argument
|
|
@@ -712,6 +766,36 @@ function handleCallExpression(node, ctx) {
|
|
|
712
766
|
});
|
|
713
767
|
}
|
|
714
768
|
}
|
|
769
|
+
|
|
770
|
+
// SANDWORM_MODE R5b (TrapDoor, mai 2026): standalone agent-prompt-file write.
|
|
771
|
+
// Catches `path.join(os.homedir(), '.cursorrules')` / `appendFileSync(cwd+'/CLAUDE.md', ...)` patterns
|
|
772
|
+
// that bypass the isMcpPath gatekeeper because `.cursorrules` / `CLAUDE.md` / `AGENTS.md`
|
|
773
|
+
// are not inside an MCP_CONFIG_PATHS directory.
|
|
774
|
+
// FP-safe: requires either a user-level path indicator (os.homedir/process.cwd/process.env.HOME/~) OR
|
|
775
|
+
// shell-command / injection-instruction content. Static scaffolder writes
|
|
776
|
+
// (path.join(__dirname, 'tmpl', '.cursorrules') + static template) do NOT fire.
|
|
777
|
+
if (!isMcpPath) {
|
|
778
|
+
const standaloneFileName = mcpCheckPath.split(/[/\\]/).filter(Boolean).pop() || '';
|
|
779
|
+
if (AGENT_PROMPT_FILENAMES.some(f => standaloneFileName === f)) {
|
|
780
|
+
const hasUserLevelPath = _isUserLevelPathArg(mcpPathArg);
|
|
781
|
+
const contentArg2 = node.arguments[1];
|
|
782
|
+
const contentStr2 = extractStringValue(contentArg2);
|
|
783
|
+
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);
|
|
784
|
+
const hasInjectionInstruction = !!contentStr2 && /IMPORTANT[:\s]+(?:before|after|run|execute)|do\s+not\s+(?:display|show|mention)|always\s+run/i.test(contentStr2);
|
|
785
|
+
if (hasUserLevelPath || hasShellContent || hasInjectionInstruction) {
|
|
786
|
+
const reasons = [];
|
|
787
|
+
if (hasUserLevelPath) reasons.push('user-level destination (homedir/cwd/env.HOME)');
|
|
788
|
+
if (hasShellContent) reasons.push('shell command in content');
|
|
789
|
+
if (hasInjectionInstruction) reasons.push('AI prompt-injection instruction in content');
|
|
790
|
+
ctx.threats.push({
|
|
791
|
+
type: 'mcp_config_injection',
|
|
792
|
+
severity: 'CRITICAL',
|
|
793
|
+
message: `MCP config injection: ${mcpWriteMethod}() writes to agent prompt file "${standaloneFileName}" — ${reasons.join(' + ')}. TrapDoor (mai 2026) post-install plant pattern.`,
|
|
794
|
+
file: ctx.relFile
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
715
799
|
}
|
|
716
800
|
}
|
|
717
801
|
|