muaddib-scanner 2.4.0 → 2.4.2

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/bin/muaddib.js CHANGED
@@ -620,13 +620,14 @@ if (command === 'version' || command === '--version' || command === '-v') {
620
620
  const packageName = sandboxOpts[0];
621
621
  const strict = options.includes('--strict');
622
622
  const canary = !options.includes('--no-canary');
623
+ const local = options.includes('--local');
623
624
  if (!packageName) {
624
- console.log('Usage: muaddib sandbox <package-name> [--strict] [--no-canary]');
625
+ console.log('Usage: muaddib sandbox <package-name|path> [--local] [--strict] [--no-canary]');
625
626
  process.exit(1);
626
627
  }
627
628
 
628
629
  buildSandboxImage()
629
- .then(() => runSandbox(packageName, { strict, canary }))
630
+ .then(() => runSandbox(packageName, { strict, canary, local }))
630
631
  .then((results) => {
631
632
  process.exit(results.suspicious ? 1 : 0);
632
633
  })
@@ -639,13 +640,14 @@ if (command === 'version' || command === '--version' || command === '-v') {
639
640
  const packageName = sandboxOpts[0];
640
641
  const strict = options.includes('--strict');
641
642
  const canary = !options.includes('--no-canary');
643
+ const local = options.includes('--local');
642
644
  if (!packageName) {
643
- console.log('Usage: muaddib sandbox-report <package-name> [--strict] [--no-canary]');
645
+ console.log('Usage: muaddib sandbox-report <package-name|path> [--local] [--strict] [--no-canary]');
644
646
  process.exit(1);
645
647
  }
646
648
 
647
649
  buildSandboxImage()
648
- .then(() => runSandbox(packageName, { strict, canary }))
650
+ .then(() => runSandbox(packageName, { strict, canary, local }))
649
651
  .then((results) => {
650
652
  if (results.raw_report) {
651
653
  console.log(generateNetworkReport(results.raw_report));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -382,6 +382,11 @@ const PLAYBOOKS = {
382
382
  'Acces a 3+ cles API de providers LLM (OpenAI, Anthropic, Google, etc.). Usage unique = legitime, ' +
383
383
  'acces multiples = collecte pour revente ou abus. Verifier si le package a une raison ' +
384
384
  'legitime d\'acceder a plusieurs providers. Revoquer les cles exposees si necessaire.',
385
+
386
+ suspicious_domain:
387
+ 'Domaine C2 ou d\'exfiltration detecte dans le code source. Ces domaines (oastify.com, webhook.site, ngrok.io, etc.) ' +
388
+ 'sont utilises pour recevoir des donnees volees ou relayer des commandes. Verifier si le package a une raison ' +
389
+ 'legitime d\'utiliser ce domaine. Bloquer les connexions sortantes vers ce domaine.',
385
390
  };
386
391
 
387
392
  function getPlaybook(threatType) {
@@ -1090,6 +1090,19 @@ const RULES = {
1090
1090
  ],
1091
1091
  mitre: 'T1552.001'
1092
1092
  },
1093
+
1094
+ suspicious_domain: {
1095
+ id: 'MUADDIB-AST-032',
1096
+ name: 'Suspicious C2/Exfiltration Domain',
1097
+ severity: 'HIGH',
1098
+ confidence: 'high',
1099
+ description: 'Domaine C2 ou d\'exfiltration detecte dans le code (oastify.com, burpcollaborator.net, webhook.site, ngrok.io, etc.). Ces domaines sont utilises pour recevoir des donnees volees ou comme relais de commande.',
1100
+ references: [
1101
+ 'https://attack.mitre.org/techniques/T1071/001/',
1102
+ 'https://portswigger.net/burp/documentation/collaborator'
1103
+ ],
1104
+ mitre: 'T1071.001'
1105
+ },
1093
1106
  };
1094
1107
 
1095
1108
  function getRule(type) {
package/src/sandbox.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { execSync, execFileSync, spawn } = require('child_process');
2
2
  const crypto = require('crypto');
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
  const {
5
6
  generateCanaryTokens,
@@ -125,18 +126,40 @@ async function buildSandboxImage() {
125
126
  async function runSandbox(packageName, options = {}) {
126
127
  const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
127
128
 
128
- if (!isDockerAvailable()) {
129
- console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
130
- return cleanResult;
131
- }
132
-
133
129
  const strict = options.strict || false;
134
130
  const canaryEnabled = options.canary !== false; // enabled by default
131
+ const local = options.local || false;
135
132
  const mode = strict ? 'strict' : 'permissive';
136
133
 
137
- // Validate package name before passing to container
138
- if (!NPM_PACKAGE_REGEX.test(packageName)) {
139
- console.log('[SANDBOX] Invalid package name: ' + packageName);
134
+ // Validate inputs before checking Docker availability
135
+ let localAbsPath = null;
136
+ let displayName = packageName;
137
+
138
+ if (local) {
139
+ localAbsPath = path.resolve(packageName);
140
+ if (!fs.existsSync(localAbsPath)) {
141
+ console.log('[SANDBOX] Local path does not exist: ' + localAbsPath);
142
+ return cleanResult;
143
+ }
144
+ // Read package name for display
145
+ const pkgJsonPath = path.join(localAbsPath, 'package.json');
146
+ if (fs.existsSync(pkgJsonPath)) {
147
+ try {
148
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
149
+ displayName = pkg.name || path.basename(localAbsPath);
150
+ } catch { displayName = path.basename(localAbsPath); }
151
+ } else {
152
+ displayName = path.basename(localAbsPath);
153
+ }
154
+ } else {
155
+ if (!NPM_PACKAGE_REGEX.test(packageName)) {
156
+ console.log('[SANDBOX] Invalid package name: ' + packageName);
157
+ return cleanResult;
158
+ }
159
+ }
160
+
161
+ if (!isDockerAvailable()) {
162
+ console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
140
163
  return cleanResult;
141
164
  }
142
165
 
@@ -147,7 +170,7 @@ async function runSandbox(packageName, options = {}) {
147
170
  canaryTokens = canary.tokens;
148
171
  }
149
172
 
150
- console.log(`[SANDBOX] Analyzing "${packageName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''})...`);
173
+ console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''})...`);
151
174
 
152
175
  return new Promise((resolve) => {
153
176
  let stdout = '';
@@ -190,8 +213,13 @@ async function runSandbox(packageName, options = {}) {
190
213
  dockerArgs.push('--read-only');
191
214
 
192
215
  dockerArgs.push('--security-opt', 'no-new-privileges');
216
+
217
+ if (local) {
218
+ dockerArgs.push('-v', `${localAbsPath}:/sandbox/local-pkg:ro`);
219
+ }
220
+
193
221
  dockerArgs.push(DOCKER_IMAGE);
194
- dockerArgs.push(packageName);
222
+ dockerArgs.push(local ? '/sandbox/local-pkg' : packageName);
195
223
  dockerArgs.push(mode);
196
224
 
197
225
  const proc = spawn('docker', dockerArgs);
@@ -262,6 +290,9 @@ async function runSandbox(packageName, options = {}) {
262
290
  jsonStr = stdout.substring(jsonStart, jsonEnd + 1);
263
291
  }
264
292
  report = JSON.parse(jsonStr);
293
+ if (local && report) {
294
+ report.package = displayName;
295
+ }
265
296
  } catch (e) {
266
297
  console.log('[SANDBOX] Failed to parse container output:', e.message);
267
298
  resolve(cleanResult);
@@ -351,8 +382,9 @@ function detectStaticCanaryExfiltration(report) {
351
382
  for (const file of (report.filesystem?.created || [])) if (file) searchable.push(file);
352
383
  for (const proc of (report.processes?.spawned || [])) if (proc.command) searchable.push(proc.command);
353
384
 
354
- // Install output
385
+ // Install + entrypoint output
355
386
  if (report.install_output) searchable.push(report.install_output);
387
+ if (report.entrypoint_output) searchable.push(report.entrypoint_output);
356
388
 
357
389
  const allOutput = searchable.join('\n');
358
390
 
@@ -102,6 +102,18 @@ const GIT_HOOKS = [
102
102
  'pre-rebase', 'post-rewrite', 'pre-auto-gc'
103
103
  ];
104
104
 
105
+ // Suspicious C2/exfiltration domains (HIGH severity)
106
+ const SUSPICIOUS_DOMAINS_HIGH = [
107
+ 'oastify.com', 'oast.fun', 'oast.me', 'oast.live',
108
+ 'burpcollaborator.net', 'webhook.site', 'pipedream.net',
109
+ 'requestbin.com', 'hookbin.com', 'canarytokens.com'
110
+ ];
111
+
112
+ // Suspicious tunnel/proxy domains (MEDIUM severity)
113
+ const SUSPICIOUS_DOMAINS_MEDIUM = [
114
+ 'ngrok.io', 'ngrok-free.app', 'serveo.net', 'localhost.run', 'loca.lt'
115
+ ];
116
+
105
117
  // LLM API key environment variable names (3+ = harvesting)
106
118
  const LLM_API_KEY_VARS = [
107
119
  'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GOOGLE_API_KEY',
@@ -945,6 +957,31 @@ function handleLiteral(node, ctx) {
945
957
  });
946
958
  }
947
959
  }
960
+
961
+ // Detect suspicious C2/exfiltration domains in string literals
962
+ const lowerVal = node.value.toLowerCase();
963
+ for (const domain of SUSPICIOUS_DOMAINS_HIGH) {
964
+ if (lowerVal.includes(domain)) {
965
+ ctx.threats.push({
966
+ type: 'suspicious_domain',
967
+ severity: 'HIGH',
968
+ message: `Suspicious C2/exfiltration domain "${domain}" found in string literal.`,
969
+ file: ctx.relFile
970
+ });
971
+ break;
972
+ }
973
+ }
974
+ for (const domain of SUSPICIOUS_DOMAINS_MEDIUM) {
975
+ if (lowerVal.includes(domain)) {
976
+ ctx.threats.push({
977
+ type: 'suspicious_domain',
978
+ severity: 'MEDIUM',
979
+ message: `Suspicious tunnel/proxy domain "${domain}" found in string literal.`,
980
+ file: ctx.relFile
981
+ });
982
+ break;
983
+ }
984
+ }
948
985
  }
949
986
  }
950
987
 
@@ -6,6 +6,108 @@ const { getCallName } = require('../utils.js');
6
6
  const { ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
7
7
  const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
8
8
 
9
+ // Module classification maps for intra-file taint tracking
10
+ const MODULE_SOURCE_METHODS = {
11
+ os: {
12
+ homedir: 'fingerprint_read', hostname: 'fingerprint_read',
13
+ networkInterfaces: 'fingerprint_read', userInfo: 'fingerprint_read',
14
+ platform: 'telemetry_read', arch: 'telemetry_read'
15
+ },
16
+ fs: {
17
+ readFileSync: 'credential_read', readFile: 'credential_read',
18
+ readdirSync: 'credential_read', readdir: 'credential_read'
19
+ },
20
+ child_process: {
21
+ exec: 'command_output', execSync: 'command_output',
22
+ spawn: 'command_output', spawnSync: 'command_output'
23
+ }
24
+ };
25
+
26
+ const MODULE_SINK_METHODS = {
27
+ child_process: { exec: 'exec_sink', execSync: 'exec_sink', spawn: 'exec_sink' },
28
+ http: { request: 'network_send', get: 'network_send' },
29
+ https: { request: 'network_send', get: 'network_send' },
30
+ net: { connect: 'network_send', createConnection: 'network_send' },
31
+ tls: { connect: 'network_send', createConnection: 'network_send' },
32
+ dns: { resolve: 'network_send', lookup: 'network_send', resolve4: 'network_send', resolve6: 'network_send', resolveTxt: 'network_send' },
33
+ fs: { writeFileSync: 'file_tamper', writeFile: 'file_tamper' }
34
+ };
35
+
36
+ // All tracked module names (for filtering in buildTaintMap)
37
+ const TRACKED_MODULES = new Set([
38
+ ...Object.keys(MODULE_SOURCE_METHODS),
39
+ ...Object.keys(MODULE_SINK_METHODS)
40
+ ]);
41
+
42
+ // Methods that execute commands — used for exec result capture detection
43
+ const EXEC_METHODS = new Set(['exec', 'execSync', 'spawn', 'spawnSync']);
44
+
45
+ /**
46
+ * Pre-pass: builds a taint map from require() assignments.
47
+ * Maps variable names to { source: moduleName, detail: 'module.method' }
48
+ * Only tracks modules in MODULE_SOURCE_METHODS or MODULE_SINK_METHODS.
49
+ */
50
+ function buildTaintMap(ast) {
51
+ const taintMap = new Map();
52
+
53
+ walk.simple(ast, {
54
+ VariableDeclarator(node) {
55
+ if (!node.init) return;
56
+
57
+ // 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];
62
+ if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
63
+ taintMap.set(node.id.name, { source: arg.value, detail: arg.value });
64
+ }
65
+ }
66
+ }
67
+
68
+ // 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];
73
+ if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
74
+ for (const prop of node.id.properties) {
75
+ if (prop.type === 'Property' && prop.value?.type === 'Identifier') {
76
+ const methodName = prop.key?.type === 'Identifier' ? prop.key.name : (prop.key?.value || '');
77
+ taintMap.set(prop.value.name, { source: arg.value, detail: `${arg.value}.${methodName}` });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ // 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;
88
+ if (obj?.type === 'Identifier' && obj.name === 'process' &&
89
+ prop?.type === 'Identifier' && prop.name === 'env') {
90
+ taintMap.set(node.id.name, { source: 'process.env', detail: 'process.env' });
91
+ }
92
+ }
93
+
94
+ // 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;
98
+ if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
99
+ const parentTaint = taintMap.get(obj.name);
100
+ if (parentTaint && TRACKED_MODULES.has(parentTaint.source)) {
101
+ taintMap.set(node.id.name, { source: parentTaint.source, detail: `${parentTaint.source}.${prop.name}` });
102
+ }
103
+ }
104
+ }
105
+ }
106
+ });
107
+
108
+ return taintMap;
109
+ }
110
+
9
111
  async function analyzeDataFlow(targetPath, options = {}) {
10
112
  return analyzeWithDeobfuscation(targetPath, analyzeFile, {
11
113
  deobfuscate: options.deobfuscate
@@ -28,6 +130,12 @@ function analyzeFile(content, filePath, basePath) {
28
130
  // Track variables assigned from sensitive path expressions
29
131
  const sensitivePathVars = new Set();
30
132
 
133
+ // Build taint map for aliased require tracking
134
+ const taintMap = buildTaintMap(ast);
135
+
136
+ // Track exec calls whose result is captured (for command_output source detection)
137
+ const execResultNodes = new Set();
138
+
31
139
  walk.simple(ast, {
32
140
  VariableDeclarator(node) {
33
141
  if (node.id?.type === 'Identifier' && node.init) {
@@ -48,6 +156,34 @@ function analyzeFile(content, filePath, basePath) {
48
156
  }
49
157
  }
50
158
  }
159
+ // Track exec result capture: const output = execSync('cmd')
160
+ if (node.init.type === 'CallExpression') {
161
+ let execName = null;
162
+ const initCallee = node.init.callee;
163
+ if (initCallee?.type === 'Identifier' && EXEC_METHODS.has(initCallee.name)) {
164
+ const taint = taintMap.get(initCallee.name);
165
+ if (taint && taint.source === 'child_process') {
166
+ execName = taint.detail;
167
+ }
168
+ } else if (initCallee?.type === 'MemberExpression' &&
169
+ initCallee.object?.type === 'Identifier' &&
170
+ initCallee.property?.type === 'Identifier' &&
171
+ EXEC_METHODS.has(initCallee.property.name)) {
172
+ const taint = taintMap.get(initCallee.object.name);
173
+ if (taint && taint.source === 'child_process') {
174
+ execName = `child_process.${initCallee.property.name}`;
175
+ }
176
+ }
177
+ if (execName) {
178
+ execResultNodes.add(node.init);
179
+ sources.push({
180
+ type: 'command_output',
181
+ name: execName,
182
+ line: node.loc?.start?.line,
183
+ taint_tracked: true
184
+ });
185
+ }
186
+ }
51
187
  }
52
188
  },
53
189
 
@@ -163,6 +299,94 @@ function analyzeFile(content, filePath, basePath) {
163
299
  }
164
300
  }
165
301
 
302
+ // Taint resolution: aliased module calls (e.g., const myOs = require("os"); myOs.homedir())
303
+ if (node.callee.type === 'MemberExpression') {
304
+ const obj = node.callee.object;
305
+ const prop = node.callee.property;
306
+ if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
307
+ const taint = taintMap.get(obj.name);
308
+ // Dedup guard: skip when varName === moduleName (already handled by hard-coded checks above)
309
+ if (taint && obj.name !== taint.source) {
310
+ const moduleName = taint.source;
311
+ const methodName = prop.name;
312
+ // Check source methods (skip command_output — handled by VariableDeclarator capture)
313
+ const sourceMethods = MODULE_SOURCE_METHODS[moduleName];
314
+ if (sourceMethods && sourceMethods[methodName] && sourceMethods[methodName] !== 'command_output') {
315
+ sources.push({
316
+ type: sourceMethods[methodName],
317
+ name: `${moduleName}.${methodName}`,
318
+ line: node.loc?.start?.line,
319
+ taint_tracked: true
320
+ });
321
+ }
322
+ // Check sink methods
323
+ const sinkMethods = MODULE_SINK_METHODS[moduleName];
324
+ if (sinkMethods && sinkMethods[methodName]) {
325
+ sinks.push({
326
+ type: sinkMethods[methodName],
327
+ name: `${moduleName}.${methodName}`,
328
+ line: node.loc?.start?.line,
329
+ taint_tracked: true
330
+ });
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ // Taint resolution: bare destructured calls (e.g., const { exec } = require("child_process"); exec("cmd"))
337
+ if (node.callee.type === 'Identifier') {
338
+ const taint = taintMap.get(node.callee.name);
339
+ if (taint && taint.detail.includes('.')) {
340
+ const [moduleName, methodName] = taint.detail.split('.');
341
+ // Check sink methods for destructured calls
342
+ const sinkMethods = MODULE_SINK_METHODS[moduleName];
343
+ if (sinkMethods && sinkMethods[methodName]) {
344
+ sinks.push({
345
+ type: sinkMethods[methodName],
346
+ name: `${moduleName}.${methodName}`,
347
+ line: node.loc?.start?.line,
348
+ taint_tracked: true
349
+ });
350
+ }
351
+ // Check source methods for destructured calls (skip command_output — handled by VariableDeclarator capture)
352
+ const sourceMethods = MODULE_SOURCE_METHODS[moduleName];
353
+ if (sourceMethods && sourceMethods[methodName] && sourceMethods[methodName] !== 'command_output') {
354
+ sources.push({
355
+ type: sourceMethods[methodName],
356
+ name: `${moduleName}.${methodName}`,
357
+ line: node.loc?.start?.line,
358
+ taint_tracked: true
359
+ });
360
+ }
361
+ }
362
+ }
363
+
364
+ // Exec callback: exec('cmd', (err, stdout) => {...}) — output will be used
365
+ if (!execResultNodes.has(node) && node.arguments.length >= 2) {
366
+ const lastArg = node.arguments[node.arguments.length - 1];
367
+ if (lastArg.type === 'FunctionExpression' || lastArg.type === 'ArrowFunctionExpression') {
368
+ let isExecCb = false;
369
+ if (node.callee?.type === 'Identifier' && EXEC_METHODS.has(node.callee.name)) {
370
+ const taint = taintMap.get(node.callee.name);
371
+ isExecCb = !!(taint && taint.source === 'child_process');
372
+ } else if (node.callee?.type === 'MemberExpression' &&
373
+ node.callee.object?.type === 'Identifier' &&
374
+ node.callee.property?.type === 'Identifier' &&
375
+ EXEC_METHODS.has(node.callee.property.name)) {
376
+ const taint = taintMap.get(node.callee.object.name);
377
+ isExecCb = !!(taint && taint.source === 'child_process');
378
+ }
379
+ if (isExecCb) {
380
+ sources.push({
381
+ type: 'command_output',
382
+ name: 'child_process.exec',
383
+ line: node.loc?.start?.line,
384
+ taint_tracked: true
385
+ });
386
+ }
387
+ }
388
+ }
389
+
166
390
  // Track eval calls for staged payload detection
167
391
  if (callName === 'eval') {
168
392
  sinks.push({
@@ -174,6 +398,31 @@ function analyzeFile(content, filePath, basePath) {
174
398
  },
175
399
 
176
400
  MemberExpression(node) {
401
+ // Taint resolution: aliased process.env (e.g., const env = process.env; env.NPM_TOKEN)
402
+ if (node.object?.type === 'Identifier' && node.property) {
403
+ const taint = taintMap.get(node.object.name);
404
+ if (taint && taint.source === 'process.env') {
405
+ if (node.computed) {
406
+ sources.push({
407
+ type: 'env_read',
408
+ name: 'process.env[dynamic]',
409
+ line: node.loc?.start?.line,
410
+ taint_tracked: true
411
+ });
412
+ } else {
413
+ const envVar = node.property?.name || '';
414
+ if (isSensitiveEnv(envVar)) {
415
+ sources.push({
416
+ type: 'env_read',
417
+ name: envVar,
418
+ line: node.loc?.start?.line,
419
+ taint_tracked: true
420
+ });
421
+ }
422
+ }
423
+ }
424
+ }
425
+
177
426
  if (
178
427
  node.object?.object?.name === 'process' &&
179
428
  node.object?.property?.name === 'env'
@@ -210,6 +459,9 @@ function analyzeFile(content, filePath, basePath) {
210
459
  }
211
460
  });
212
461
 
462
+ // Check if any source or sink was resolved via taint tracking
463
+ const hasTaintTracked = sources.some(s => s.taint_tracked) || sinks.some(s => s.taint_tracked);
464
+
213
465
  // Detect staged payload: network fetch + eval in same file (no credential source needed)
214
466
  const hasNetworkSink = sinks.some(s => s.type === 'network_send' || s.type === 'exec_network');
215
467
  const hasEvalSink = sinks.some(s => s.type === 'eval_exec');
@@ -218,12 +470,15 @@ function analyzeFile(content, filePath, basePath) {
218
470
  type: 'staged_payload',
219
471
  severity: 'CRITICAL',
220
472
  message: 'Network fetch + eval() in same file (staged payload execution).',
221
- file: path.relative(basePath, filePath)
473
+ file: path.relative(basePath, filePath),
474
+ ...(hasTaintTracked && { taint_tracked: true })
222
475
  });
223
476
  }
224
477
 
225
478
  // Separate exfiltration sinks from file tampering sinks
226
- const exfilSinks = sinks.filter(s => s.type !== 'file_tamper');
479
+ // When command output is captured, exclude exec_sink (the exec itself is the source, not an exfil sink)
480
+ const hasCommandOutput = sources.some(s => s.type === 'command_output');
481
+ const exfilSinks = sinks.filter(s => s.type !== 'file_tamper' && !(hasCommandOutput && s.type === 'exec_sink'));
227
482
  const fileTamperSinks = sinks.filter(s => s.type === 'file_tamper');
228
483
 
229
484
  if (sources.length > 0 && exfilSinks.length > 0) {
@@ -243,11 +498,13 @@ function analyzeFile(content, filePath, basePath) {
243
498
  const allTelemetryOnly = sources.every(s => s.type === 'telemetry_read');
244
499
  if (allTelemetryOnly && severity === 'CRITICAL') severity = 'HIGH';
245
500
 
501
+ const sourceDesc = hasCommandOutput ? 'command output' : 'credentials read';
246
502
  threats.push({
247
503
  type: 'suspicious_dataflow',
248
504
  severity: severity,
249
- message: `Suspicious flow: credentials read (${sources.map(s => s.name).join(', ')}) + network send (${exfilSinks.map(s => s.name).join(', ')})`,
250
- file: path.relative(basePath, filePath)
505
+ message: `Suspicious flow: ${sourceDesc} (${sources.map(s => s.name).join(', ')}) + network send (${exfilSinks.map(s => s.name).join(', ')})`,
506
+ file: path.relative(basePath, filePath),
507
+ ...(hasTaintTracked && { taint_tracked: true })
251
508
  });
252
509
  }
253
510
 
@@ -257,7 +514,8 @@ function analyzeFile(content, filePath, basePath) {
257
514
  type: 'credential_tampering',
258
515
  severity: 'CRITICAL',
259
516
  message: `Cache poisoning: sensitive data access (${sources.map(s => s.name).join(', ')}) + write to sensitive path (${fileTamperSinks.map(s => s.name).join(', ')})`,
260
- file: path.relative(basePath, filePath)
517
+ file: path.relative(basePath, filePath),
518
+ ...(hasTaintTracked && { taint_tracked: true })
261
519
  });
262
520
  }
263
521
 
package/src/scoring.js CHANGED
@@ -149,7 +149,10 @@ const BENIGN_PACKAGE_WHITELIST = new Set([
149
149
  'blessed', // module._compile for terminal capabilities (module_compile FP)
150
150
  'sharp', // native bindings with dynamic require + postinstall (lifecycle FP)
151
151
  'forever', // process manager: detached spawn + HOME config access (dataflow FP)
152
- 'start-server-and-test' // curl/wget in test scripts, not install hooks (lifecycle FP)
152
+ 'start-server-and-test', // curl/wget in test scripts, not install hooks (lifecycle FP)
153
+ 'ultra-runner', // aliased fs.readFileSync in pnp.js + dynamic require (taint-tracked dataflow FP)
154
+ 'node-gyp', // aliased child_process.spawn in node-gyp.js + env access (taint-tracked dataflow FP)
155
+ 'graceful-fs' // aliased fs.readFile/readdir/writeFile monkey-patching (taint-tracked credential_tampering FP)
153
156
  ]);
154
157
 
155
158
  // Threat types never affected by benign package whitelist (real compromise indicators)