muaddib-scanner 2.11.51 → 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.51",
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-26T20:25:43.730Z",
3
+ "timestamp": "2026-05-27T07:39:47.529Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -141,7 +141,10 @@ async function getWeeklyDownloads(packageName) {
141
141
  }
142
142
  try {
143
143
  const url = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`;
144
- const body = await httpsGet(url, 3000);
144
+ // Routed via _deps so tests can stub the downloads endpoint independently
145
+ // of the registry endpoint (Stage 2.1 added parallel-fetch from
146
+ // preResolveNpmBatch).
147
+ const body = await _deps.httpsGet(url, 3000);
145
148
  const data = JSON.parse(body);
146
149
  const downloads = typeof data.downloads === 'number' ? data.downloads : -1;
147
150
  downloadsCache.set(packageName, { downloads, fetchedAt: Date.now() });
@@ -429,8 +432,23 @@ async function getNpmLatestTarball(packageName) {
429
432
  version: '', tarball: null, unpackedSize: 0, scripts: {},
430
433
  homepage: '', description: '',
431
434
  latestTagVersion: null, recentVersions: [],
435
+ age_days: null, version_count: 0,
432
436
  };
433
437
  }
438
+ // Stage 2.1 — extract reputation signals from the packument we already have,
439
+ // so triageRisk in queue.js doesn't have to refetch metadata via
440
+ // getPackageMetadata. Two fields are derivable from the packument alone:
441
+ // - age_days : time.created (package creation timestamp)
442
+ // - version_count : Object.keys(versions).length (excludes unpublished
443
+ // tombstones kept only in `time`)
444
+ // weekly_downloads requires a separate api.npmjs.org call and is fetched in
445
+ // parallel by preResolveNpmBatch (it has its own cache + no semaphore).
446
+ const createdAt = (packument && packument.time && packument.time.created) || null;
447
+ result.age_days = createdAt
448
+ ? Math.floor((Date.now() - new Date(createdAt).getTime()) / 86_400_000)
449
+ : null;
450
+ result.version_count = (packument && packument.versions)
451
+ ? Object.keys(packument.versions).length : 0;
434
452
  return result;
435
453
  }
436
454
 
@@ -465,15 +483,30 @@ async function preResolveNpmBatch(items, stats, scanQueue) {
465
483
  await Promise.all(chunk.map(async (item) => {
466
484
  if (item.tarballUrl) { alreadyResolved++; return; }
467
485
  try {
468
- const npmInfo = await getNpmLatestTarball(item.name);
486
+ // Stage 2.1 fetch downloads in parallel with the packument. The
487
+ // downloads endpoint (api.npmjs.org) is not on the registry semaphore
488
+ // and has its own internal cache, so this is effectively free in the
489
+ // warm-cache case and adds at most one parallel HTTP otherwise.
490
+ const [npmInfo, weeklyDownloads] = await Promise.all([
491
+ getNpmLatestTarball(item.name),
492
+ getWeeklyDownloads(item.name).catch(() => null)
493
+ ]);
469
494
  if (npmInfo && npmInfo.tarball) {
470
495
  item.tarballUrl = npmInfo.tarball;
471
496
  if (!item.version) item.version = npmInfo.version || '';
472
497
  if (!item.unpackedSize) item.unpackedSize = npmInfo.unpackedSize || 0;
473
498
  if (!item.registryScripts) item.registryScripts = npmInfo.scripts || null;
499
+ // weekly_downloads is best-effort. getWeeklyDownloads returns -1 on
500
+ // failure; normalize that to null so triageRisk treats it as missing
501
+ // (rather than silently biasing the reputation factor toward "suspect").
502
+ npmInfo.weekly_downloads = (typeof weeklyDownloads === 'number' && weeklyDownloads >= 0)
503
+ ? weeklyDownloads : null;
474
504
  // Stash full packument-derived metadata for resolveTarballAndScan so
475
505
  // the worker can run ATO-signature, burst-extras, and fast-track logic
476
- // without a second registry call.
506
+ // without a second registry call. Stage 2.1 enriches this with
507
+ // age_days / version_count (from getNpmLatestTarball) and
508
+ // weekly_downloads (from getWeeklyDownloads) so the triage block in
509
+ // queue.js can read meta directly without re-fetching.
477
510
  item._npmInfo = npmInfo;
478
511
  resolved++;
479
512
  } else {
@@ -128,6 +128,22 @@ const LARGE_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB
128
128
  const FIRST_PUBLISH_SANDBOX_MAX_QUEUE = parseInt(process.env.MUADDIB_FIRST_PUBLISH_SANDBOX_MAX_QUEUE, 10) || 10;
129
129
  const FIRST_PUBLISH_SANDBOX_ENABLED = process.env.MUADDIB_FIRST_PUBLISH_SANDBOX !== '0';
130
130
 
131
+ // Stage 3 — sandbox gate. Static-score threshold below which T1b/T2 packages
132
+ // are NOT sandboxed (static result alone is authoritative). Tightens the prior
133
+ // "T1b sandbox if score >= 25 or queue < 20" to remove low-signal sandbox runs
134
+ // that consume slots without producing actionable findings (the dominant cost
135
+ // in the queue-saturation diagnostic). Validated by axon-enterprise@1.0.0
136
+ // (static 52, sandbox confirmed 100) — gate >= 40 still catches it.
137
+ // T1a (high-confidence malice) bypasses this gate; it's mandatory.
138
+ // Override via env var to widen the gate (lower threshold) for a short
139
+ // rollback window without redeploying. Clamped to [0, 100].
140
+ function computeSandboxScoreThreshold(envValue) {
141
+ const parsed = parseInt(envValue, 10);
142
+ const value = Number.isFinite(parsed) ? parsed : 40;
143
+ return Math.max(0, Math.min(100, value));
144
+ }
145
+ const SANDBOX_SCORE_THRESHOLD = computeSandboxScoreThreshold(process.env.MUADDIB_SANDBOX_SCORE_THRESHOLD);
146
+
131
147
  // --- Bundled tooling false-positive filter ---
132
148
 
133
149
  const KNOWN_BUNDLED_FILES = ['yarn.js', 'webpack.js', 'terser.js', 'esbuild.js', 'polyfills.js'];
@@ -464,6 +480,18 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
464
480
  throw staticErr;
465
481
  }
466
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
+
467
495
  // First-publish detection: used for sandbox priority below
468
496
  const isFirstPublish = cacheTrigger && cacheTrigger.reason === 'first_publish';
469
497
 
@@ -738,14 +766,16 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
738
766
  }
739
767
 
740
768
  // T1a: mandatory sandbox (HC malice types, TIER1_TYPES non-LOW, lifecycle + intent compound)
741
- // T1b: conditional sandbox (HIGH/CRITICAL without HC type bundler FP zone)
742
- // sandbox only if score >= 25 (significant risk) or queue pressure is low
743
- // T2: sandbox if queue < 50 (as before)
769
+ // T1b: conditional sandbox gated by SANDBOX_SCORE_THRESHOLD (Stage 3).
770
+ // Previously gated at >= 25 OR queue < 20; tightened to >= 40 by
771
+ // default because the 25-39 band produced no decisive sandbox
772
+ // findings in 4 months of prod data (axon-enterprise was at 52).
773
+ // T2: conditional sandbox — same score gate AND queue < 50.
744
774
  let sandboxResult = null;
745
775
  const shouldSandbox = !skipSandboxLargePackage && isSandboxEnabled() && sandboxAvailable && (
746
776
  tier === '1a' ||
747
- (tier === '1b' && (riskScore >= 25 || scanQueue.length < 20)) ||
748
- (tier === 2 && scanQueue.length < 50)
777
+ (tier === '1b' && riskScore >= SANDBOX_SCORE_THRESHOLD) ||
778
+ (tier === 2 && riskScore >= SANDBOX_SCORE_THRESHOLD && scanQueue.length < 50)
749
779
  );
750
780
 
751
781
  if (shouldSandbox) {
@@ -813,8 +843,12 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
813
843
  } catch (err) {
814
844
  console.error(`[MONITOR] SANDBOX error for ${name}@${version}: ${err.message}`);
815
845
  }
816
- } else if (tier === '1b' && sandboxAvailable) {
817
- console.log(`[MONITOR] SANDBOX DEFERRED (T1b, score=${riskScore} < 25, queue ${scanQueue.length} >= 20): ${name}@${version}`);
846
+ } else if (tier === '1b' && sandboxAvailable && riskScore >= SANDBOX_SCORE_THRESHOLD) {
847
+ // Stage 3 defer only when the score crosses the gate. Below the
848
+ // threshold, sandbox is skipped entirely (static result is final).
849
+ // This stops the deferred-queue from filling with low-score items
850
+ // that would never produce decisive sandbox findings.
851
+ console.log(`[MONITOR] SANDBOX DEFERRED (T1b, score=${riskScore}, queue ${scanQueue.length}): ${name}@${version}`);
818
852
  enqueueDeferred({
819
853
  name, version, ecosystem, tier, riskScore, tarballUrl,
820
854
  enqueuedAt: Date.now(),
@@ -823,10 +857,14 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
823
857
  retries: 0
824
858
  });
825
859
  stats.sandboxDeferred = (stats.sandboxDeferred || 0) + 1;
860
+ } else if (tier === '1b' && sandboxAvailable) {
861
+ // Below SANDBOX_SCORE_THRESHOLD — no sandbox, no defer.
862
+ console.log(`[MONITOR] SANDBOX GATED (T1b, score=${riskScore} < ${SANDBOX_SCORE_THRESHOLD}): ${name}@${version}`);
863
+ stats.sandboxGated = (stats.sandboxGated || 0) + 1;
826
864
  } else if (tier === '1b') {
827
865
  console.log(`[MONITOR] SANDBOX SKIPPED (T1b, no Docker): ${name}@${version}`);
828
- } else if (tier === 2 && sandboxAvailable) {
829
- console.log(`[MONITOR] SANDBOX DEFERRED (T2, queue ${scanQueue.length} >= 50): ${name}@${version}`);
866
+ } else if (tier === 2 && sandboxAvailable && riskScore >= SANDBOX_SCORE_THRESHOLD) {
867
+ console.log(`[MONITOR] SANDBOX DEFERRED (T2, score=${riskScore}, queue ${scanQueue.length}): ${name}@${version}`);
830
868
  enqueueDeferred({
831
869
  name, version, ecosystem, tier, riskScore, tarballUrl,
832
870
  enqueuedAt: Date.now(),
@@ -835,6 +873,11 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
835
873
  retries: 0
836
874
  });
837
875
  stats.sandboxDeferred = (stats.sandboxDeferred || 0) + 1;
876
+ } else if (tier === 2 && sandboxAvailable) {
877
+ // Below SANDBOX_SCORE_THRESHOLD — T2 was already passive; staying
878
+ // static-only matches the existing T3 behaviour.
879
+ console.log(`[MONITOR] SANDBOX GATED (T2, score=${riskScore} < ${SANDBOX_SCORE_THRESHOLD}): ${name}@${version}`);
880
+ stats.sandboxGated = (stats.sandboxGated || 0) + 1;
838
881
  } else if (tier === 2) {
839
882
  console.log(`[MONITOR] SANDBOX SKIPPED (T2, no Docker): ${name}@${version}`);
840
883
  }
@@ -973,6 +1016,34 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
973
1016
  }
974
1017
  }
975
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
+
976
1047
  function timeoutPromise(ms) {
977
1048
  return new Promise((_, reject) => {
978
1049
  setTimeout(() => reject(new Error(`Scan timeout after ${ms / 1000}s`)), ms);
@@ -1295,10 +1366,23 @@ async function resolveTarballAndScan(item, stats, dailyAlerts, recentlyScanned,
1295
1366
  if (triageMode !== 'off' && !item.fastTrack) {
1296
1367
  let triageMeta = null;
1297
1368
  if (item.ecosystem === 'npm') {
1298
- try {
1299
- const { getPackageMetadata } = require('../scanner/npm-registry.js');
1300
- triageMeta = await getPackageMetadata(item.name);
1301
- } catch { /* metadata unavailable triageRisk will see null and pick 'full' */ }
1369
+ // Stage 2.1 — Stage 1 pre-resolve already fetched the packument and
1370
+ // (Stage 2.1) computed age_days + version_count, plus parallel-fetched
1371
+ // weekly_downloads. Read those directly to skip the second
1372
+ // registry round-trip via getPackageMetadata. Fallback to the lazy
1373
+ // metadata fetch only when _npmInfo is absent (lazy-resolve path).
1374
+ if (item._npmInfo) {
1375
+ triageMeta = {
1376
+ age_days: item._npmInfo.age_days,
1377
+ version_count: item._npmInfo.version_count,
1378
+ weekly_downloads: item._npmInfo.weekly_downloads,
1379
+ };
1380
+ } else {
1381
+ try {
1382
+ const { getPackageMetadata } = require('../scanner/npm-registry.js');
1383
+ triageMeta = await getPackageMetadata(item.name);
1384
+ } catch { /* metadata unavailable → triageRisk will see null and pick 'full' */ }
1385
+ }
1302
1386
  } else if (item.ecosystem === 'pypi') {
1303
1387
  triageMeta = item._pypiInfo || null;
1304
1388
  }
@@ -1413,6 +1497,8 @@ module.exports = {
1413
1497
  LARGE_PACKAGE_SIZE,
1414
1498
  FIRST_PUBLISH_SANDBOX_MAX_QUEUE,
1415
1499
  FIRST_PUBLISH_SANDBOX_ENABLED,
1500
+ SANDBOX_SCORE_THRESHOLD,
1501
+ computeSandboxScoreThreshold,
1416
1502
  KNOWN_BUNDLED_FILES,
1417
1503
  KNOWN_BUNDLED_PATHS,
1418
1504
  ML_EXCLUDED_DIRS,
@@ -1434,6 +1520,7 @@ module.exports = {
1434
1520
  runScanInWorker,
1435
1521
  scanPackage,
1436
1522
  timeoutPromise,
1523
+ detectSkillMdBundled,
1437
1524
  isDailyReportDue,
1438
1525
  processQueueItem,
1439
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