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 +1 -1
- package/{self-scan-v2.11.51.json → self-scan-v2.11.53.json} +1 -1
- package/src/monitor/ingestion.js +36 -3
- package/src/monitor/queue.js +100 -13
- package/src/scanner/ai-config.js +59 -4
- package/src/scanner/ast-detectors/constants.js +12 -0
- package/src/scanner/ast-detectors/handle-call-expression.js +86 -2
package/package.json
CHANGED
package/src/monitor/ingestion.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/monitor/queue.js
CHANGED
|
@@ -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
|
|
742
|
-
//
|
|
743
|
-
//
|
|
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' &&
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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,
|
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
|
|