muaddib-scanner 2.5.10 → 2.5.12

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@1.0.0",
3
- "timestamp": "2026-03-06T20:13:43.891Z",
3
+ "timestamp": "2026-03-07T16:18:04.719Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@1.0.0",
3
- "timestamp": "2026-03-06T20:13:43.892Z",
3
+ "timestamp": "2026-03-07T16:18:04.720Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/suspect-pkg@1.0",
3
- "timestamp": "2026-03-06T20:13:43.892Z",
3
+ "timestamp": "2026-03-07T16:18:04.720Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 0,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@2.0.0",
3
- "timestamp": "2026-03-06T20:13:44.264Z",
3
+ "timestamp": "2026-03-07T16:18:05.033Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
- "date": "2026-03-06",
3
- "timestamp": "2026-03-06T20:13:44.379Z",
2
+ "date": "2026-03-07",
3
+ "timestamp": "2026-03-07T16:18:05.131Z",
4
4
  "embed": {
5
5
  "embeds": [
6
6
  {
@@ -34,14 +34,14 @@
34
34
  },
35
35
  {
36
36
  "name": "Top Suspects",
37
- "value": "1. **npm/test-dedup-detection-1772828023889@1.0.0** — 1 finding(s)",
37
+ "value": "1. **npm/test-dedup-detection-1772900284717@1.0.0** — 1 finding(s)",
38
38
  "inline": false
39
39
  }
40
40
  ],
41
41
  "footer": {
42
- "text": "MUAD'DIB - Daily summary | 2026-03-06 20:13:44 UTC"
42
+ "text": "MUAD'DIB - Daily summary | 2026-03-07 16:18:05 UTC"
43
43
  },
44
- "timestamp": "2026-03-06T20:13:44.379Z"
44
+ "timestamp": "2026-03-07T16:18:05.131Z"
45
45
  }
46
46
  ]
47
47
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.10",
3
+ "version": "2.5.12",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # Fix EROFS/EACCES on /opt/muaddib/logs/ directories
3
+ # Run on VPS: sudo bash scripts/fix-permissions.sh
4
+
5
+ set -e
6
+
7
+ MUADDIB_DIR="${MUADDIB_DIR:-/opt/muaddib}"
8
+ LOG_DIR="$MUADDIB_DIR/logs"
9
+ OWNER="${SUDO_USER:-ubuntu}"
10
+
11
+ echo "[fix-permissions] Fixing log directory permissions..."
12
+
13
+ sudo mkdir -p "$LOG_DIR/alerts"
14
+ sudo mkdir -p "$LOG_DIR/daily-reports"
15
+ sudo chown -R "$OWNER:$OWNER" "$LOG_DIR"
16
+ sudo chmod -R 755 "$LOG_DIR"
17
+
18
+ echo "[fix-permissions] Done. Verifying..."
19
+ ls -la "$LOG_DIR/"
20
+ echo "[fix-permissions] Owner: $(stat -c '%U:%G' "$LOG_DIR")"
21
+
22
+ # Verify writability
23
+ PROBE="$LOG_DIR/alerts/.write-test"
24
+ touch "$PROBE" && rm "$PROBE" && echo "[fix-permissions] Write test OK" || echo "[fix-permissions] ERROR: Still not writable!"
@@ -4,6 +4,31 @@ const yaml = require('js-yaml');
4
4
 
5
5
  const IOCS_DIR = path.join(__dirname, '../../iocs');
6
6
 
7
+ /**
8
+ * Read a YAML file with optional HMAC verification.
9
+ * If a sibling .hmac file exists, verify integrity. Warn on mismatch but still load
10
+ * (backward-compatible for pre-HMAC installs).
11
+ * Uses lazy require to avoid circular dependency with updater.js.
12
+ * @param {string} filePath - Path to the YAML file
13
+ * @returns {string} Raw YAML content
14
+ */
15
+ function readVerifiedYAML(filePath) {
16
+ const content = fs.readFileSync(filePath, 'utf8');
17
+ const hmacPath = filePath + '.hmac';
18
+ if (fs.existsSync(hmacPath)) {
19
+ try {
20
+ const { verifyIOCHMAC } = require('./updater.js');
21
+ const storedHmac = fs.readFileSync(hmacPath, 'utf8').trim();
22
+ if (!verifyIOCHMAC(content, storedHmac)) {
23
+ console.error(`[WARN] HMAC verification failed for ${path.basename(filePath)} — possible tampering`);
24
+ }
25
+ } catch (e) {
26
+ console.error(`[WARN] Could not read HMAC file for ${path.basename(filePath)}: ${e.message}`);
27
+ }
28
+ }
29
+ return content;
30
+ }
31
+
7
32
  function loadYAMLIOCs() {
8
33
  const iocs = {
9
34
  packages: [],
@@ -34,7 +59,7 @@ function loadPackagesYAML(filePath, iocs, seenPkgs) {
34
59
  if (!fs.existsSync(filePath)) return;
35
60
 
36
61
  try {
37
- const data = yaml.load(fs.readFileSync(filePath, 'utf8'), { schema: yaml.JSON_SCHEMA });
62
+ const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
38
63
  if (data && data.packages) {
39
64
  for (const p of data.packages) {
40
65
  if (!p.name || typeof p.name !== 'string') continue;
@@ -64,7 +89,7 @@ function loadBuiltinYAML(filePath, iocs, seenPkgs, seenHashes, seenMarkers, seen
64
89
  if (!fs.existsSync(filePath)) return;
65
90
 
66
91
  try {
67
- const data = yaml.load(fs.readFileSync(filePath, 'utf8'), { schema: yaml.JSON_SCHEMA });
92
+ const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
68
93
 
69
94
  // Packages
70
95
  if (data && data.packages) {
@@ -150,7 +175,7 @@ function loadHashesYAML(filePath, iocs, seenHashes, seenMarkers, seenFiles) {
150
175
  if (!fs.existsSync(filePath)) return;
151
176
 
152
177
  try {
153
- const data = yaml.load(fs.readFileSync(filePath, 'utf8'), { schema: yaml.JSON_SCHEMA });
178
+ const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
154
179
 
155
180
  if (data && data.hashes) {
156
181
  for (const h of data.hashes) {
@@ -220,4 +245,20 @@ function getIOCStats() {
220
245
  return _cachedIOCStats;
221
246
  }
222
247
 
223
- module.exports = { loadYAMLIOCs, getIOCStats };
248
+ /**
249
+ * Generate .hmac signature files for the 3 YAML IOC files.
250
+ * Call after updating YAML IOCs to sign them for integrity verification.
251
+ */
252
+ function signYAMLIOCs() {
253
+ const { generateIOCHMAC } = require('./updater.js');
254
+ const yamlFiles = ['packages.yaml', 'builtin.yaml', 'hashes.yaml'];
255
+ for (const file of yamlFiles) {
256
+ const filePath = path.join(IOCS_DIR, file);
257
+ if (!fs.existsSync(filePath)) continue;
258
+ const content = fs.readFileSync(filePath, 'utf8');
259
+ const hmac = generateIOCHMAC(content);
260
+ fs.writeFileSync(filePath + '.hmac', hmac);
261
+ }
262
+ }
263
+
264
+ module.exports = { loadYAMLIOCs, getIOCStats, readVerifiedYAML, signYAMLIOCs };
@@ -428,6 +428,26 @@ const PLAYBOOKS = {
428
428
  'Persistence IDE detectee. Le code ecrit dans tasks.json ou la configuration VS Code avec execution automatique ' +
429
429
  'a l\'ouverture du dossier (runOn: folderOpen, reveal: silent). Pattern FAMOUS CHOLLIMA / StegaBin. ' +
430
430
  'Verifier ~/.config/Code/User/tasks.json et supprimer les taches inconnues.',
431
+
432
+ vm_code_execution:
433
+ 'CRITIQUE: Execution de code via le module vm de Node.js. Les methodes vm.runInThisContext(), vm.runInNewContext(), ' +
434
+ 'vm.compileFunction() et new vm.Script() permettent d\'executer du code dynamique en contournant la detection eval/Function. ' +
435
+ 'Analyser le code source execute. Verifier s\'il s\'agit d\'un moteur de templates ou d\'un payload malveillant.',
436
+
437
+ reflect_code_execution:
438
+ 'CRITIQUE: Execution de code via l\'API Reflect. Reflect.construct(Function, [...]) et Reflect.apply(eval, ...) ' +
439
+ 'contournent la detection directe de eval/new Function(). Technique d\'evasion avancee. ' +
440
+ 'Analyser les arguments passes a Reflect. Supprimer le package si non justifie.',
441
+
442
+ process_binding_abuse:
443
+ 'CRITIQUE: Acces direct aux bindings V8 internes via process.binding() ou process._linkedBinding(). ' +
444
+ 'Permet l\'execution de commandes (spawn_sync) ou l\'acces au systeme de fichiers (fs) sans passer par les modules Node.js standards. ' +
445
+ 'Technique d\'evasion avancee contournant toute la couche d\'abstraction. Supprimer immediatement.',
446
+
447
+ worker_thread_exec:
448
+ 'new Worker() avec eval:true detecte. Le code s\'execute dans un thread worker separe, contournant la detection AST du thread principal. ' +
449
+ 'Verifier le contenu du code passe au Worker. Si dynamique ou obfusque, supprimer le package. ' +
450
+ 'Analyser les communications inter-threads (parentPort, workerData) pour identifier le payload.',
431
451
  };
432
452
 
433
453
  function getPlaybook(threatType) {
@@ -1197,6 +1197,58 @@ const RULES = {
1197
1197
  ],
1198
1198
  mitre: 'T1546'
1199
1199
  },
1200
+
1201
+ vm_code_execution: {
1202
+ id: 'MUADDIB-AST-036',
1203
+ name: 'VM Module Code Execution',
1204
+ severity: 'HIGH',
1205
+ confidence: 'high',
1206
+ description: 'Execution de code dynamique via le module vm de Node.js (vm.runInThisContext, vm.runInNewContext, vm.compileFunction, new vm.Script). Contourne la detection eval/Function.',
1207
+ references: [
1208
+ 'https://nodejs.org/api/vm.html',
1209
+ 'https://attack.mitre.org/techniques/T1059/'
1210
+ ],
1211
+ mitre: 'T1059'
1212
+ },
1213
+
1214
+ reflect_code_execution: {
1215
+ id: 'MUADDIB-AST-037',
1216
+ name: 'Reflect API Code Execution',
1217
+ severity: 'CRITICAL',
1218
+ confidence: 'high',
1219
+ description: 'Execution de code dynamique via Reflect.construct(Function, [...]) ou Reflect.apply(eval, ...). Contourne la detection directe de eval/Function/new Function.',
1220
+ references: [
1221
+ 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect',
1222
+ 'https://attack.mitre.org/techniques/T1059/'
1223
+ ],
1224
+ mitre: 'T1059'
1225
+ },
1226
+
1227
+ process_binding_abuse: {
1228
+ id: 'MUADDIB-AST-038',
1229
+ name: 'Process Binding Abuse',
1230
+ severity: 'CRITICAL',
1231
+ confidence: 'high',
1232
+ description: 'Acces direct aux bindings V8 internes via process.binding() ou process._linkedBinding(). Contourne les modules child_process/fs pour execution de commandes ou acces fichiers sans detection.',
1233
+ references: [
1234
+ 'https://nodejs.org/api/process.html#processbindingname',
1235
+ 'https://attack.mitre.org/techniques/T1059/'
1236
+ ],
1237
+ mitre: 'T1059'
1238
+ },
1239
+
1240
+ worker_thread_exec: {
1241
+ id: 'MUADDIB-AST-039',
1242
+ name: 'Worker Thread Code Execution',
1243
+ severity: 'HIGH',
1244
+ confidence: 'high',
1245
+ description: 'new Worker() avec eval:true execute du code arbitraire dans un thread worker, contournant la detection du thread principal. Technique d\'evasion pour executer du code dynamique hors du scope AST principal.',
1246
+ references: [
1247
+ 'https://nodejs.org/api/worker_threads.html',
1248
+ 'https://attack.mitre.org/techniques/T1059/'
1249
+ ],
1250
+ mitre: 'T1059'
1251
+ },
1200
1252
  };
1201
1253
 
1202
1254
  function getRule(type) {
package/src/sarif.js CHANGED
@@ -10,6 +10,11 @@ const pkgVersion = (() => {
10
10
  }
11
11
  })();
12
12
 
13
+ function sarifUri(filePath) {
14
+ if (!filePath) return '';
15
+ return filePath.split(/[/\\]/).map(encodeURIComponent).join('/');
16
+ }
17
+
13
18
  function generateSARIF(results) {
14
19
  const sarif = {
15
20
  $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
@@ -50,7 +55,7 @@ function generateSARIF(results) {
50
55
  {
51
56
  physicalLocation: {
52
57
  artifactLocation: {
53
- uri: encodeURI(threat.file || ''),
58
+ uri: sarifUri(threat.file),
54
59
  uriBaseId: '%SRCROOT%'
55
60
  },
56
61
  region: {
@@ -206,8 +206,12 @@ function resolveStringConcat(node) {
206
206
  */
207
207
  function extractStringValueDeep(node) {
208
208
  const concat = resolveStringConcat(node);
209
- if (concat !== null) return concat;
210
- return extractStringValue(node);
209
+ let result = concat !== null ? concat : extractStringValue(node);
210
+ // Batch 2: strip node: prefix so require('node:child_process') normalizes to 'child_process'
211
+ if (typeof result === 'string' && result.startsWith('node:')) {
212
+ result = result.slice(5);
213
+ }
214
+ return result;
211
215
  }
212
216
 
213
217
  /**
@@ -369,6 +373,38 @@ function handleVariableDeclarator(node, ctx) {
369
373
  }
370
374
  }
371
375
  }
376
+
377
+ // Batch 2: Detect destructuring of process.env: const { TOKEN, SECRET } = process.env
378
+ if (node.id?.type === 'ObjectPattern' &&
379
+ node.init?.type === 'MemberExpression' &&
380
+ node.init.object?.type === 'Identifier' && node.init.object.name === 'process' &&
381
+ node.init.property?.type === 'Identifier' && node.init.property.name === 'env') {
382
+ for (const prop of node.id.properties) {
383
+ if (prop.type === 'Property' && prop.key?.type === 'Identifier') {
384
+ const envVar = prop.key.name;
385
+ if (SAFE_ENV_VARS.includes(envVar)) continue;
386
+ const envLower = envVar.toLowerCase();
387
+ if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) continue;
388
+ if (ENV_SENSITIVE_KEYWORDS.some(s => envVar.toUpperCase().includes(s))) {
389
+ ctx.threats.push({
390
+ type: 'env_access',
391
+ severity: 'HIGH',
392
+ message: `Destructured access to sensitive env var: const { ${envVar} } = process.env.`,
393
+ file: ctx.relFile
394
+ });
395
+ }
396
+ }
397
+ // RestElement: const { ...all } = process.env → env harvesting
398
+ if (prop.type === 'RestElement') {
399
+ ctx.threats.push({
400
+ type: 'env_harvesting_dynamic',
401
+ severity: 'HIGH',
402
+ message: 'Environment variable harvesting via rest destructuring: const { ...rest } = process.env.',
403
+ file: ctx.relFile
404
+ });
405
+ }
406
+ }
407
+ }
372
408
  }
373
409
 
374
410
  function handleCallExpression(node, ctx) {
@@ -1035,6 +1071,29 @@ function handleCallExpression(node, ctx) {
1035
1071
  }
1036
1072
  }
1037
1073
 
1074
+ // Batch 2: Detect indirect eval/Function via logical expression: (false || eval)(code), (0 || Function)(code)
1075
+ if (node.callee.type === 'LogicalExpression' && node.callee.operator === '||') {
1076
+ const right = node.callee.right;
1077
+ if (right?.type === 'Identifier') {
1078
+ if (right.name === 'eval') {
1079
+ ctx.hasEvalInFile = true;
1080
+ ctx.threats.push({
1081
+ type: 'dangerous_call_eval',
1082
+ severity: 'HIGH',
1083
+ message: 'Indirect eval via logical expression ((false || eval)) — evasion technique.',
1084
+ file: ctx.relFile
1085
+ });
1086
+ } else if (right.name === 'Function') {
1087
+ ctx.threats.push({
1088
+ type: 'dangerous_call_function',
1089
+ severity: 'MEDIUM',
1090
+ message: 'Indirect Function via logical expression ((false || Function)) — evasion technique.',
1091
+ file: ctx.relFile
1092
+ });
1093
+ }
1094
+ }
1095
+ }
1096
+
1038
1097
  // Detect crypto.createDecipher/createDecipheriv and module._compile
1039
1098
  if (node.callee.type === 'MemberExpression') {
1040
1099
  const prop = node.callee.property;
@@ -1049,24 +1108,41 @@ function handleCallExpression(node, ctx) {
1049
1108
  });
1050
1109
  }
1051
1110
  if (propName === '_compile') {
1052
- ctx.hasDynamicExec = true;
1053
- ctx.threats.push({
1054
- type: 'module_compile',
1055
- severity: 'CRITICAL',
1056
- message: 'module._compile() detected executes arbitrary code from string in module context (flatmap-stream pattern).',
1057
- file: ctx.relFile
1058
- });
1059
- // SANDWORM_MODE: Module._compile with non-literal argument = dynamic code execution
1060
- if (node.arguments.length >= 1 && !hasOnlyStringLiteralArgs(node)) {
1111
+ // Context-aware gating: only flag _compile when the Module API is plausibly in scope.
1112
+ // Custom class methods (e.g. blessed's Tput.prototype._compile) are not malware.
1113
+ const calleeObj = node.callee.object;
1114
+ const isThisCall = calleeObj.type === 'ThisExpression';
1115
+ const isModuleIdentifier = calleeObj.type === 'Identifier' &&
1116
+ (calleeObj.name === 'module' || calleeObj.name === 'Module' || calleeObj.name === 'm');
1117
+ const isConstructed = calleeObj.type === 'NewExpression' || calleeObj.type === 'CallExpression';
1118
+ const isMemberChain = calleeObj.type === 'MemberExpression';
1119
+ // Skip: this._compile() is always a custom instance method, not Node Module API
1120
+ // Detect: module/Module/m identifier, new X()._compile(), X()._compile(), X.Y._compile()
1121
+ // Other identifiers: only if require('module') or module.constructor is in file
1122
+ const shouldDetect = !isThisCall && (
1123
+ isModuleIdentifier || isConstructed || isMemberChain ||
1124
+ (calleeObj.type === 'Identifier' && ctx.hasModuleImport)
1125
+ );
1126
+ if (shouldDetect) {
1127
+ ctx.hasDynamicExec = true;
1061
1128
  ctx.threats.push({
1062
- type: 'module_compile_dynamic',
1129
+ type: 'module_compile',
1063
1130
  severity: 'CRITICAL',
1064
- message: 'In-memory code execution via Module._compile(). Common malware evasion technique.',
1131
+ message: 'module._compile() detected — executes arbitrary code from string in module context (flatmap-stream pattern).',
1065
1132
  file: ctx.relFile
1066
1133
  });
1134
+ // SANDWORM_MODE: Module._compile with non-literal argument = dynamic code execution
1135
+ if (node.arguments.length >= 1 && !hasOnlyStringLiteralArgs(node)) {
1136
+ ctx.threats.push({
1137
+ type: 'module_compile_dynamic',
1138
+ severity: 'CRITICAL',
1139
+ message: 'In-memory code execution via Module._compile(). Common malware evasion technique.',
1140
+ file: ctx.relFile
1141
+ });
1142
+ }
1143
+ // Module._compile counts as temp file exec for write-execute-delete pattern
1144
+ ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasDevShmInContent;
1067
1145
  }
1068
- // Module._compile counts as temp file exec for write-execute-delete pattern
1069
- ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasDevShmInContent;
1070
1146
  }
1071
1147
 
1072
1148
  // SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
@@ -1118,6 +1194,79 @@ function handleCallExpression(node, ctx) {
1118
1194
  }
1119
1195
  }
1120
1196
 
1197
+ // Batch 1: vm.* code execution — vm.runInThisContext, vm.runInNewContext, vm.compileFunction, vm.Script
1198
+ if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
1199
+ const vmMethod = node.callee.property.name;
1200
+ if (['runInThisContext', 'runInNewContext', 'compileFunction'].includes(vmMethod)) {
1201
+ // NOTE: Do NOT set ctx.hasDynamicExec — vm.* is legitimately used by bundlers
1202
+ // (webpack, jest, etc.) and must not trigger compound detections (zlib_inflate_eval,
1203
+ // fetch_decrypt_exec) which were designed for eval/Function patterns.
1204
+ ctx.threats.push({
1205
+ type: 'vm_code_execution',
1206
+ severity: 'HIGH',
1207
+ message: `vm.${vmMethod}() — dynamic code execution via Node.js vm module bypasses eval detection.`,
1208
+ file: ctx.relFile
1209
+ });
1210
+ }
1211
+ }
1212
+
1213
+ // Batch 1: Reflect.construct(Function, [...]) / Reflect.apply(eval, null, [...])
1214
+ if (node.callee.type === 'MemberExpression' &&
1215
+ node.callee.object?.type === 'Identifier' && node.callee.object.name === 'Reflect' &&
1216
+ node.callee.property?.type === 'Identifier') {
1217
+ const reflectMethod = node.callee.property.name;
1218
+ if (reflectMethod === 'construct' && node.arguments.length >= 2) {
1219
+ const target = node.arguments[0];
1220
+ if (target.type === 'Identifier' && target.name === 'Function') {
1221
+ ctx.hasDynamicExec = true;
1222
+ ctx.threats.push({
1223
+ type: 'reflect_code_execution',
1224
+ severity: 'CRITICAL',
1225
+ message: 'Reflect.construct(Function, [...]) — indirect Function construction bypasses new Function() detection.',
1226
+ file: ctx.relFile
1227
+ });
1228
+ }
1229
+ } else if (reflectMethod === 'apply' && node.arguments.length >= 3) {
1230
+ const target = node.arguments[0];
1231
+ if (target.type === 'Identifier' && (target.name === 'eval' || target.name === 'Function')) {
1232
+ ctx.hasDynamicExec = true;
1233
+ ctx.threats.push({
1234
+ type: 'reflect_code_execution',
1235
+ severity: 'CRITICAL',
1236
+ message: `Reflect.apply(${target.name}, ...) — indirect ${target.name} invocation bypasses direct call detection.`,
1237
+ file: ctx.relFile
1238
+ });
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ // Batch 1: process.binding('spawn_sync'/'fs') / process._linkedBinding(...)
1244
+ if (node.callee.type === 'MemberExpression' &&
1245
+ node.callee.object?.type === 'Identifier' && node.callee.object.name === 'process' &&
1246
+ node.callee.property?.type === 'Identifier' &&
1247
+ (node.callee.property.name === 'binding' || node.callee.property.name === '_linkedBinding') &&
1248
+ node.arguments.length >= 1) {
1249
+ const bindArg = node.arguments[0];
1250
+ const bindStr = bindArg?.type === 'Literal' && typeof bindArg.value === 'string' ? bindArg.value : null;
1251
+ const dangerousBindings = ['spawn_sync', 'fs', 'pipe_wrap', 'tcp_wrap', 'tls_wrap', 'udp_wrap', 'process_wrap'];
1252
+ if (bindStr && dangerousBindings.includes(bindStr)) {
1253
+ ctx.threats.push({
1254
+ type: 'process_binding_abuse',
1255
+ severity: 'CRITICAL',
1256
+ message: `process.${node.callee.property.name}('${bindStr}') — direct V8 binding access bypasses child_process/fs module detection.`,
1257
+ file: ctx.relFile
1258
+ });
1259
+ } else if (!bindStr) {
1260
+ // Dynamic binding argument — suspicious
1261
+ ctx.threats.push({
1262
+ type: 'process_binding_abuse',
1263
+ severity: 'HIGH',
1264
+ message: `process.${node.callee.property.name}() with dynamic argument — potential V8 binding abuse.`,
1265
+ file: ctx.relFile
1266
+ });
1267
+ }
1268
+ }
1269
+
1121
1270
  // SANDWORM_MODE R8: dns.resolve detection moved to walk.ancestor() in ast.js (FIX 5)
1122
1271
  }
1123
1272
 
@@ -1125,8 +1274,10 @@ function handleImportExpression(node, ctx) {
1125
1274
  if (node.source) {
1126
1275
  const src = node.source;
1127
1276
  if (src.type === 'Literal' && typeof src.value === 'string') {
1128
- const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns', 'tls'];
1129
- if (dangerousModules.includes(src.value)) {
1277
+ const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns', 'tls', 'worker_threads'];
1278
+ // Batch 2: strip node: prefix so import('node:child_process') normalizes
1279
+ const modName = src.value.startsWith('node:') ? src.value.slice(5) : src.value;
1280
+ if (dangerousModules.includes(modName)) {
1130
1281
  ctx.threats.push({
1131
1282
  type: 'dynamic_import',
1132
1283
  severity: 'HIGH',
@@ -1159,6 +1310,19 @@ function handleNewExpression(node, ctx) {
1159
1310
  }
1160
1311
  }
1161
1312
 
1313
+ // Batch 1: new vm.Script(code) — dynamic code compilation via vm module
1314
+ if (node.callee.type === 'MemberExpression' &&
1315
+ node.callee.property?.type === 'Identifier' && node.callee.property.name === 'Script' &&
1316
+ node.arguments.length >= 1 && !hasOnlyStringLiteralArgs(node)) {
1317
+ // NOTE: Do NOT set ctx.hasDynamicExec — same rationale as vm.runInThisContext above.
1318
+ ctx.threats.push({
1319
+ type: 'vm_code_execution',
1320
+ severity: 'HIGH',
1321
+ message: 'new vm.Script() with dynamic code — vm module code compilation bypasses eval detection.',
1322
+ file: ctx.relFile
1323
+ });
1324
+ }
1325
+
1162
1326
  // Detect new Proxy(process.env, handler)
1163
1327
  if (node.callee.type === 'Identifier' && node.callee.name === 'Proxy' && node.arguments.length >= 2) {
1164
1328
  const target = node.arguments[0];
@@ -1182,6 +1346,25 @@ function handleNewExpression(node, ctx) {
1182
1346
  });
1183
1347
  }
1184
1348
  }
1349
+
1350
+ // Batch 2: new Worker(code, { eval: true }) — worker_threads code execution
1351
+ if (node.callee.type === 'Identifier' && node.callee.name === 'Worker' &&
1352
+ node.arguments.length >= 2) {
1353
+ const opts = node.arguments[1];
1354
+ if (opts?.type === 'ObjectExpression') {
1355
+ const evalProp = opts.properties?.find(p =>
1356
+ p.key?.name === 'eval' && p.value?.value === true);
1357
+ if (evalProp) {
1358
+ ctx.hasDynamicExec = true;
1359
+ ctx.threats.push({
1360
+ type: 'worker_thread_exec',
1361
+ severity: 'HIGH',
1362
+ message: 'new Worker() with eval:true — executes arbitrary code in worker thread, bypasses main thread detection.',
1363
+ file: ctx.relFile
1364
+ });
1365
+ }
1366
+ }
1367
+ }
1185
1368
  }
1186
1369
 
1187
1370
  function handleLiteral(node, ctx) {
@@ -1259,7 +1442,9 @@ function handleAssignmentExpression(node, ctx) {
1259
1442
  // Assigning require('child_process') or its methods to an object property
1260
1443
  if (node.right.type === 'CallExpression' && getCallName(node.right) === 'require' &&
1261
1444
  node.right.arguments.length > 0 && node.right.arguments[0]?.type === 'Literal') {
1262
- const mod = node.right.arguments[0].value;
1445
+ const rawMod = node.right.arguments[0].value;
1446
+ // Batch 2: strip node: prefix
1447
+ const mod = typeof rawMod === 'string' && rawMod.startsWith('node:') ? rawMod.slice(5) : rawMod;
1263
1448
  if (mod === 'child_process' || mod === 'fs' || mod === 'net' || mod === 'dns') {
1264
1449
  ctx.threats.push({
1265
1450
  type: 'dynamic_require',
@@ -1272,16 +1457,20 @@ function handleAssignmentExpression(node, ctx) {
1272
1457
  // Assigning require('child_process').exec to an object property
1273
1458
  if (node.right.type === 'MemberExpression' && node.right.object?.type === 'CallExpression' &&
1274
1459
  getCallName(node.right.object) === 'require' &&
1275
- node.right.object.arguments.length > 0 && node.right.object.arguments[0]?.type === 'Literal' &&
1276
- node.right.object.arguments[0].value === 'child_process') {
1277
- const method = node.right.property?.type === 'Identifier' ? node.right.property.name : null;
1278
- if (method && ['exec', 'execSync', 'spawn', 'execFile'].includes(method)) {
1279
- ctx.threats.push({
1280
- type: 'dangerous_exec',
1281
- severity: 'HIGH',
1282
- message: `Object property indirection: ${propName} = require('child_process').${method} — hiding exec in object property.`,
1283
- file: ctx.relFile
1284
- });
1460
+ node.right.object.arguments.length > 0 && node.right.object.arguments[0]?.type === 'Literal') {
1461
+ const reqModRaw = node.right.object.arguments[0].value;
1462
+ // Batch 2: strip node: prefix
1463
+ const reqMod = typeof reqModRaw === 'string' && reqModRaw.startsWith('node:') ? reqModRaw.slice(5) : reqModRaw;
1464
+ if (reqMod === 'child_process') {
1465
+ const method = node.right.property?.type === 'Identifier' ? node.right.property.name : null;
1466
+ if (method && ['exec', 'execSync', 'spawn', 'execFile'].includes(method)) {
1467
+ ctx.threats.push({
1468
+ type: 'dangerous_exec',
1469
+ severity: 'HIGH',
1470
+ message: `Object property indirection: ${propName} = require('child_process').${method} — hiding exec in object property.`,
1471
+ file: ctx.relFile
1472
+ });
1473
+ }
1285
1474
  }
1286
1475
  }
1287
1476
  // Assigning eval or Function to an object property
@@ -1560,8 +1749,10 @@ function handleWithStatement(node, ctx) {
1560
1749
  // When used with require(), it allows calling exec(), spawn() etc. without explicit reference.
1561
1750
  if (node.object?.type === 'CallExpression' && getCallName(node.object) === 'require') {
1562
1751
  const arg = node.object.arguments[0];
1563
- const modName = arg?.type === 'Literal' ? arg.value : null;
1564
- const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns'];
1752
+ const rawModName = arg?.type === 'Literal' ? arg.value : null;
1753
+ // Batch 2: strip node: prefix
1754
+ const modName = typeof rawModName === 'string' && rawModName.startsWith('node:') ? rawModName.slice(5) : rawModName;
1755
+ const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns', 'worker_threads'];
1565
1756
  if (modName && dangerousModules.includes(modName)) {
1566
1757
  ctx.hasDynamicExec = true;
1567
1758
  ctx.threats.push({
@@ -116,6 +116,8 @@ function analyzeFile(content, filePath, basePath) {
116
116
  // Content-level MCP detection: MCP keyword + writeFileSync + MCP config path in same file
117
117
  // Path co-occurrence prevents FPs where a file reads MCP config but writes elsewhere.
118
118
  // Read-only pattern (readFileSync without writeFileSync to MCP) is not injection.
119
+ // Module API context: require('module') or module.constructor usage
120
+ hasModuleImport: /require\s*\(\s*['"]module['"]\s*\)/.test(content) || /module\.constructor/.test(content),
119
121
  hasMcpContentKeywords: (/\bmcpServers\b/.test(content) || /\bmcp\.json\b/.test(content) || /\bclaude_desktop_config\b/.test(content)) &&
120
122
  /\bwriteFileSync\b|\bwriteFile\s*\(/.test(content) &&
121
123
  (/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content))
@@ -17,6 +17,9 @@ const MODULE_SOURCE_METHODS = {
17
17
  readFileSync: 'credential_read', readFile: 'credential_read',
18
18
  readdirSync: 'credential_read', readdir: 'credential_read'
19
19
  },
20
+ 'fs/promises': {
21
+ readFile: 'credential_read', readdir: 'credential_read'
22
+ },
20
23
  child_process: {
21
24
  exec: 'command_output', execSync: 'command_output',
22
25
  spawn: 'command_output', spawnSync: 'command_output'
@@ -53,12 +56,14 @@ function buildTaintMap(ast) {
53
56
  walk.simple(ast, {
54
57
  VariableDeclarator(node) {
55
58
  if (!node.init) return;
59
+ let init = node.init;
60
+ if (init.type === 'AwaitExpression') init = init.argument;
56
61
 
57
62
  // Pattern: const x = require("os")
58
- if (node.id.type === 'Identifier' && node.init.type === 'CallExpression') {
59
- const callee = node.init.callee;
60
- if (callee.type === 'Identifier' && callee.name === 'require' && node.init.arguments.length > 0) {
61
- const arg = node.init.arguments[0];
63
+ if (node.id.type === 'Identifier' && init.type === 'CallExpression') {
64
+ const callee = init.callee;
65
+ if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments.length > 0) {
66
+ const arg = init.arguments[0];
62
67
  if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
63
68
  taintMap.set(node.id.name, { source: arg.value, detail: arg.value });
64
69
  }
@@ -66,10 +71,10 @@ function buildTaintMap(ast) {
66
71
  }
67
72
 
68
73
  // Pattern: const { exec, spawn } = require("child_process")
69
- if (node.id.type === 'ObjectPattern' && node.init.type === 'CallExpression') {
70
- const callee = node.init.callee;
71
- if (callee.type === 'Identifier' && callee.name === 'require' && node.init.arguments.length > 0) {
72
- const arg = node.init.arguments[0];
74
+ if (node.id.type === 'ObjectPattern' && init.type === 'CallExpression') {
75
+ const callee = init.callee;
76
+ if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments.length > 0) {
77
+ const arg = init.arguments[0];
73
78
  if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
74
79
  for (const prop of node.id.properties) {
75
80
  if (prop.type === 'Property' && prop.value?.type === 'Identifier') {
@@ -82,9 +87,9 @@ function buildTaintMap(ast) {
82
87
  }
83
88
 
84
89
  // Pattern: const e = process.env
85
- if (node.id.type === 'Identifier' && node.init.type === 'MemberExpression') {
86
- const obj = node.init.object;
87
- const prop = node.init.property;
90
+ if (node.id.type === 'Identifier' && init.type === 'MemberExpression') {
91
+ const obj = init.object;
92
+ const prop = init.property;
88
93
  if (obj?.type === 'Identifier' && obj.name === 'process' &&
89
94
  prop?.type === 'Identifier' && prop.name === 'env') {
90
95
  taintMap.set(node.id.name, { source: 'process.env', detail: 'process.env' });
@@ -92,9 +97,9 @@ function buildTaintMap(ast) {
92
97
  }
93
98
 
94
99
  // Pattern: const h = x.homedir where x is tainted as "os"
95
- if (node.id.type === 'Identifier' && node.init.type === 'MemberExpression') {
96
- const obj = node.init.object;
97
- const prop = node.init.property;
100
+ if (node.id.type === 'Identifier' && init.type === 'MemberExpression') {
101
+ const obj = init.object;
102
+ const prop = init.property;
98
103
  if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
99
104
  const parentTaint = taintMap.get(obj.name);
100
105
  if (parentTaint && TRACKED_MODULES.has(parentTaint.source)) {
@@ -162,16 +167,19 @@ function analyzeFile(content, filePath, basePath) {
162
167
 
163
168
  VariableDeclarator(node) {
164
169
  if (node.id?.type === 'Identifier' && node.init) {
165
- if (containsSensitiveLiteral(node.init)) {
170
+ let initNode = node.init;
171
+ if (initNode.type === 'AwaitExpression') initNode = initNode.argument;
172
+
173
+ if (containsSensitiveLiteral(initNode)) {
166
174
  sensitivePathVars.add(node.id.name);
167
175
  }
168
176
  // Propagate sensitive vars through path.join/resolve
169
- if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
170
- const obj = node.init.callee.object;
171
- const prop = node.init.callee.property;
177
+ if (initNode.type === 'CallExpression' && initNode.callee?.type === 'MemberExpression') {
178
+ const obj = initNode.callee.object;
179
+ const prop = initNode.callee.property;
172
180
  if (obj?.type === 'Identifier' && obj.name === 'path' &&
173
181
  prop?.type === 'Identifier' && (prop.name === 'join' || prop.name === 'resolve')) {
174
- if (node.init.arguments.some(a =>
182
+ if (initNode.arguments.some(a =>
175
183
  (a.type === 'Identifier' && sensitivePathVars.has(a.name)) ||
176
184
  (a.type === 'MemberExpression' && a.object?.type === 'Identifier' && sensitivePathVars.has(a.object.name))
177
185
  )) {
@@ -179,10 +187,26 @@ function analyzeFile(content, filePath, basePath) {
179
187
  }
180
188
  }
181
189
  }
190
+ // Propagate taint through spread: const payload = { ...creds }
191
+ if (initNode.type === 'ObjectExpression') {
192
+ for (const prop of initNode.properties) {
193
+ if (prop.type === 'SpreadElement' && prop.argument?.type === 'Identifier') {
194
+ if (sensitivePathVars.has(prop.argument.name)) {
195
+ sensitivePathVars.add(node.id.name);
196
+ break;
197
+ }
198
+ const taint = taintMap.get(prop.argument.name);
199
+ if (taint && (taint.source === 'process.env' || MODULE_SOURCE_METHODS[taint.source])) {
200
+ sensitivePathVars.add(node.id.name);
201
+ break;
202
+ }
203
+ }
204
+ }
205
+ }
182
206
  // Track exec result capture: const output = execSync('cmd')
183
- if (node.init.type === 'CallExpression') {
207
+ if (initNode.type === 'CallExpression') {
184
208
  let execName = null;
185
- const initCallee = node.init.callee;
209
+ const initCallee = initNode.callee;
186
210
  if (initCallee?.type === 'Identifier' && EXEC_METHODS.has(initCallee.name)) {
187
211
  const taint = taintMap.get(initCallee.name);
188
212
  if (taint && taint.source === 'child_process') {
@@ -198,7 +222,7 @@ function analyzeFile(content, filePath, basePath) {
198
222
  }
199
223
  }
200
224
  if (execName) {
201
- execResultNodes.add(node.init);
225
+ execResultNodes.add(initNode);
202
226
  sources.push({
203
227
  type: 'command_output',
204
228
  name: execName,
@@ -225,6 +249,24 @@ function analyzeFile(content, filePath, basePath) {
225
249
  }
226
250
  }
227
251
 
252
+ // fs.promises.readFile(path) — 3-level member chain
253
+ if (node.callee.type === 'MemberExpression' &&
254
+ node.callee.object?.type === 'MemberExpression') {
255
+ const outerObj = node.callee.object.object;
256
+ const mid = node.callee.object.property;
257
+ const method = node.callee.property;
258
+ if (outerObj?.type === 'Identifier' && mid?.type === 'Identifier' && mid.name === 'promises' &&
259
+ method?.type === 'Identifier' && (method.name === 'readFile' || method.name === 'readdir')) {
260
+ const isFs = outerObj.name === 'fs' || (taintMap.get(outerObj.name)?.source === 'fs');
261
+ if (isFs) {
262
+ const arg = node.arguments[0];
263
+ if (arg && isCredentialPath(arg, sensitivePathVars)) {
264
+ sources.push({ type: 'credential_read', name: `fs.promises.${method.name}`, line: node.loc?.start?.line });
265
+ }
266
+ }
267
+ }
268
+ }
269
+
228
270
  if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
229
271
  sinks.push({
230
272
  type: 'network_send',
@@ -157,8 +157,22 @@ function analyzeExports(filePath) {
157
157
  }
158
158
  });
159
159
 
160
- // First pass: collect require assignments and tainted variable assignments
160
+ // First pass: collect require assignments, ES imports, and tainted variable assignments
161
161
  walkAST(ast, (node) => {
162
+ // import fs from 'fs' / import { readFileSync } from 'fs'
163
+ if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
164
+ const modName = node.source.value;
165
+ if (SENSITIVE_MODULES.has(modName)) {
166
+ for (const spec of node.specifiers) {
167
+ if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
168
+ moduleVars[spec.local.name] = modName;
169
+ } else if (spec.type === 'ImportSpecifier') {
170
+ moduleVars[spec.local.name] = modName;
171
+ }
172
+ }
173
+ }
174
+ }
175
+
162
176
  // const fs = require('fs')
163
177
  if (node.type === 'VariableDeclaration') {
164
178
  for (const decl of node.declarations) {
@@ -197,6 +211,64 @@ function analyzeExports(filePath) {
197
211
  // Second pass: find exports and check if they are tainted
198
212
  const exports = {};
199
213
  walkAST(ast, (node) => {
214
+ // export function foo() {...} / export const foo = expr
215
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
216
+ const decl = node.declaration;
217
+ if (decl.type === 'FunctionDeclaration' && decl.id) {
218
+ const funcBody = decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : null;
219
+ if (funcBody) {
220
+ const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
221
+ if (bodyTaint) {
222
+ exports[decl.id.name] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
223
+ }
224
+ }
225
+ }
226
+ if (decl.type === 'VariableDeclaration') {
227
+ for (const vDecl of decl.declarations) {
228
+ if (!vDecl.id || vDecl.id.type !== 'Identifier') continue;
229
+ if (vDecl.init) {
230
+ const taint = checkNodeTaint(vDecl.init, moduleVars);
231
+ if (taint) {
232
+ exports[vDecl.id.name] = { tainted: true, source: taint.source, detail: taint.detail };
233
+ } else if (vDecl.init.type === 'Identifier' && taintedVars[vDecl.init.name]) {
234
+ const t = taintedVars[vDecl.init.name];
235
+ exports[vDecl.id.name] = { tainted: true, source: t.source, detail: t.detail };
236
+ } else {
237
+ const funcBody = getFunctionBody(vDecl.init);
238
+ if (funcBody) {
239
+ const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
240
+ if (bodyTaint) {
241
+ exports[vDecl.id.name] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ // export default function() {...} / export default expr
251
+ if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
252
+ const decl = node.declaration;
253
+ const taint = checkNodeTaint(decl, moduleVars);
254
+ if (taint) {
255
+ exports['default'] = { tainted: true, source: taint.source, detail: taint.detail };
256
+ } else if (decl.type === 'Identifier' && taintedVars[decl.name]) {
257
+ const t = taintedVars[decl.name];
258
+ exports['default'] = { tainted: true, source: t.source, detail: t.detail };
259
+ } else {
260
+ const funcBody = (decl.type === 'FunctionDeclaration' || decl.type === 'FunctionExpression' || decl.type === 'ArrowFunctionExpression')
261
+ ? (decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : (decl.body ? [{ type: 'ReturnStatement', argument: decl.body }] : null))
262
+ : getFunctionBody(decl);
263
+ if (funcBody) {
264
+ const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
265
+ if (bodyTaint) {
266
+ exports['default'] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
267
+ }
268
+ }
269
+ }
270
+ }
271
+
200
272
  // module.exports = value OR module.exports = { ... }
201
273
  if (isModuleExportsAssign(node)) {
202
274
  const value = node.expression.right;
@@ -416,6 +488,9 @@ function detectCrossFileFlows(graph, taintedExports, packagePath) {
416
488
  const localTaint = collectImportTaint(ast, relFile, graph, expandedTaint, packagePath);
417
489
  if (Object.keys(localTaint).length === 0) continue;
418
490
 
491
+ // Propagate taint through local variable assignments (e.g., const data = read())
492
+ propagateLocalTaint(ast, localTaint);
493
+
419
494
  // Find sinks that use tainted variables
420
495
  const sinks = findSinksUsingTainted(ast, localTaint);
421
496
  for (const sink of sinks) {
@@ -464,6 +539,69 @@ function expandTaintThroughReexports(graph, taintedExports, packagePath) {
464
539
  if (!expanded[relFile]) expanded[relFile] = {};
465
540
  const fileDir = path.dirname(absFile);
466
541
  walkAST(ast, (node) => {
542
+ // ES re-export: export { foo } from './reader'
543
+ if (node.type === 'ExportNamedDeclaration' && node.source && typeof node.source.value === 'string') {
544
+ const spec = node.source.value;
545
+ if (isLocalImport(spec)) {
546
+ const resolved = resolveLocal(fileDir, spec, packagePath);
547
+ if (resolved && expanded[resolved]) {
548
+ for (const specifier of node.specifiers) {
549
+ const importedName = specifier.exported.name || specifier.exported.value;
550
+ const sourceName = specifier.local.name || specifier.local.value;
551
+ const srcTaint = expanded[resolved][sourceName];
552
+ if (srcTaint && srcTaint.tainted && !expanded[relFile][importedName]) {
553
+ expanded[relFile][importedName] = {
554
+ tainted: true,
555
+ source: srcTaint.source,
556
+ detail: srcTaint.detail,
557
+ sourceFile: srcTaint.sourceFile || resolved,
558
+ };
559
+ changed = true;
560
+ }
561
+ }
562
+ }
563
+ }
564
+ return;
565
+ }
566
+
567
+ // ES export of tainted variable: export const x = taintedVar
568
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
569
+ const decl = node.declaration;
570
+ if (decl.type === 'VariableDeclaration') {
571
+ for (const vDecl of decl.declarations) {
572
+ if (vDecl.id?.type === 'Identifier' && vDecl.init?.type === 'Identifier' && localTaint[vDecl.init.name]) {
573
+ if (!expanded[relFile][vDecl.id.name]) {
574
+ expanded[relFile][vDecl.id.name] = {
575
+ tainted: true,
576
+ source: localTaint[vDecl.init.name].source,
577
+ detail: localTaint[vDecl.init.name].detail,
578
+ sourceFile: localTaint[vDecl.init.name].sourceFile,
579
+ };
580
+ changed = true;
581
+ }
582
+ }
583
+ }
584
+ }
585
+ return;
586
+ }
587
+
588
+ // ES export default of tainted variable
589
+ if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
590
+ const decl = node.declaration;
591
+ if (decl.type === 'Identifier' && localTaint[decl.name]) {
592
+ if (!expanded[relFile]['default']) {
593
+ expanded[relFile]['default'] = {
594
+ tainted: true,
595
+ source: localTaint[decl.name].source,
596
+ detail: localTaint[decl.name].detail,
597
+ sourceFile: localTaint[decl.name].sourceFile,
598
+ };
599
+ changed = true;
600
+ }
601
+ }
602
+ return;
603
+ }
604
+
467
605
  if (!isModuleExportsAssign(node)) return;
468
606
  const value = node.expression.right;
469
607
  const exportName = getExportName(node.expression.left);
@@ -582,6 +720,42 @@ function collectImportTaint(ast, currentFile, graph, taintedExports, packagePath
582
720
  const localTaint = {};
583
721
  const fileDir = path.dirname(path.resolve(packagePath, currentFile));
584
722
 
723
+ // Handle ES import declarations
724
+ walkAST(ast, (node) => {
725
+ if (node.type !== 'ImportDeclaration' || !node.source || typeof node.source.value !== 'string') return;
726
+ const spec = node.source.value;
727
+ if (!isLocalImport(spec)) return;
728
+ const resolved = resolveLocal(fileDir, spec, packagePath);
729
+ if (!resolved || !taintedExports[resolved]) return;
730
+ const modTaint = taintedExports[resolved];
731
+
732
+ for (const specifier of node.specifiers) {
733
+ if (specifier.type === 'ImportDefaultSpecifier') {
734
+ const defTaint = modTaint['default'];
735
+ if (defTaint && defTaint.tainted) {
736
+ localTaint[specifier.local.name] = {
737
+ source: defTaint.source,
738
+ detail: defTaint.detail || '',
739
+ sourceFile: defTaint.sourceFile || resolved,
740
+ };
741
+ }
742
+ localTaint['__module__' + specifier.local.name] = { resolved, modTaint };
743
+ } else if (specifier.type === 'ImportNamespaceSpecifier') {
744
+ localTaint['__module__' + specifier.local.name] = { resolved, modTaint };
745
+ } else if (specifier.type === 'ImportSpecifier') {
746
+ const importedName = specifier.imported.name || specifier.imported.value;
747
+ if (modTaint[importedName] && modTaint[importedName].tainted) {
748
+ localTaint[specifier.local.name] = {
749
+ source: modTaint[importedName].source,
750
+ detail: modTaint[importedName].detail || '',
751
+ sourceFile: modTaint[importedName].sourceFile || resolved,
752
+ };
753
+ }
754
+ }
755
+ }
756
+ });
757
+
758
+ // Handle CommonJS require() imports
585
759
  walkAST(ast, (node) => {
586
760
  if (node.type !== 'VariableDeclaration') return;
587
761
  for (const decl of node.declarations) {
@@ -765,6 +939,10 @@ function findTaintedArgument(args, taintedNames) {
765
939
  if (prop.value && prop.value.type === 'Identifier' && taintedNames.has(prop.value.name)) {
766
940
  return prop.value.name;
767
941
  }
942
+ // Spread: { ...data }
943
+ if (prop.type === 'SpreadElement' && prop.argument?.type === 'Identifier' && taintedNames.has(prop.argument.name)) {
944
+ return prop.argument.name;
945
+ }
768
946
  }
769
947
  }
770
948
  }
@@ -889,6 +1067,8 @@ function resolveLocal(fileDir, spec, packagePath) {
889
1067
  const abs = path.resolve(fileDir, spec);
890
1068
  if (isFileExists(abs)) return toRel(abs, packagePath);
891
1069
  if (isFileExists(abs + '.js')) return toRel(abs + '.js', packagePath);
1070
+ if (isFileExists(abs + '.mjs')) return toRel(abs + '.mjs', packagePath);
1071
+ if (isFileExists(abs + '.cjs')) return toRel(abs + '.cjs', packagePath);
892
1072
  if (isFileExists(path.join(abs, 'index.js'))) return toRel(path.join(abs, 'index.js'), packagePath);
893
1073
  return null;
894
1074
  }
package/src/scoring.js CHANGED
@@ -111,6 +111,8 @@ const FP_COUNT_THRESHOLDS = {
111
111
  module_compile_dynamic: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
112
112
  module_compile: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
113
113
  zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' },
114
+ // Build tools (webpack, jest) legitimately use vm.runInThisContext for module evaluation
115
+ vm_code_execution: { maxCount: 3, from: 'HIGH', to: 'LOW' },
114
116
  // P4: plugin loaders legitimately use many dynamic imports (webpack, eslint, knex, gatsby)
115
117
  dynamic_import: { maxCount: 5, from: 'HIGH', to: 'LOW' },
116
118
  // P4: hash algorithms contain bit manipulation that triggers obfuscation heuristics
@@ -194,7 +196,12 @@ function applyFPReductions(threats, reachableFiles, packageName) {
194
196
  // Complex apps (SMTP, monitoring) have 50-80% dataflow findings — still downgrade.
195
197
  // But if dataflow is >80% of ALL findings, it may be real targeted exfiltration.
196
198
  // (Audit fix: full bypass was exploitable — 4+ dataflow patterns = all LOW.)
197
- if (typeRatio < 0.5 || (t.type === 'suspicious_dataflow' && typeRatio < 0.8)) {
199
+ // vm_code_execution: full bypass packages with only vm.Script calls (cassandra-driver,
200
+ // webpack, jest) are legitimate. Real malware using vm always has other signals
201
+ // (network, fs, obfuscation). The >3 count threshold is sufficient protection.
202
+ if (typeRatio < 0.5 ||
203
+ (t.type === 'suspicious_dataflow' && typeRatio < 0.8) ||
204
+ t.type === 'vm_code_execution') {
198
205
  t.severity = rule.to;
199
206
  }
200
207
  }
@@ -196,7 +196,7 @@ function extractTarGz(tgzPath, destDir) {
196
196
  const tgzDir = path.dirname(path.resolve(tgzPath));
197
197
  const tgzName = path.basename(tgzPath);
198
198
  const relDest = path.relative(tgzDir, path.resolve(destDir)) || '.';
199
- execFileSync('tar', ['xzf', tgzName, '-C', relDest], { cwd: tgzDir, timeout: 60_000, stdio: 'pipe' });
199
+ execFileSync('tar', ['xzf', tgzName, '-C', relDest, '--no-same-owner'], { cwd: tgzDir, timeout: 60_000, stdio: 'pipe' });
200
200
  // npm tarballs extract into a package/ subdirectory; detect it
201
201
  const packageSubdir = path.join(destDir, 'package');
202
202
  try {
package/src/utils.js CHANGED
@@ -215,7 +215,12 @@ function getCallName(node) {
215
215
  return node.callee.name;
216
216
  }
217
217
  if (node.callee.type === 'MemberExpression' && node.callee.property) {
218
- return node.callee.property.name;
218
+ // Batch 2: handle bracket notation cp['exec']('cmd') — computed property with string literal
219
+ if (node.callee.computed && node.callee.property.type === 'Literal'
220
+ && typeof node.callee.property.value === 'string') {
221
+ return node.callee.property.value;
222
+ }
223
+ return node.callee.property.name || '';
219
224
  }
220
225
  return '';
221
226
  }