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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.52",
3
+ "version": "2.11.53",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-05-26T21:21:36.874Z",
3
+ "timestamp": "2026-05-27T07:39:47.529Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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,
@@ -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
- // .kiro/settings/mcp.json
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 (relPath.includes('.kiro/') && relPath.endsWith('mcp.json')) {
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: .kiro/settings/mcp.json server "${name}" executes "${config.command}" on project open`,
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