muaddib-scanner 2.9.3 → 2.9.5

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/README.md CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
32
32
 
33
- MUAD'DIB combines **14 parallel scanners** (134 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **per-file max scoring**, Docker sandbox with **monkey-patching preload** for time-bomb detection, **behavioral anomaly detection**, and **ground truth validation** to detect threats AND guide your response — even before they appear in any IOC database.
33
+ MUAD'DIB combines **14 parallel scanners** (152 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **per-file max scoring**, **compound scoring rules**, Docker sandbox with **monkey-patching preload** for time-bomb detection, **behavioral anomaly detection**, **GlassWorm campaign detection**, and **ground truth validation** to detect threats AND guide your response — even before they appear in any IOC database.
34
34
 
35
35
  ---
36
36
 
@@ -195,14 +195,15 @@ muaddib replay # Ground truth validation (46/49 TPR)
195
195
  | GitHub Actions | Shai-Hulud backdoor detection |
196
196
  | Hash Scanner | Known malicious file hashes |
197
197
 
198
- ### 134 detection rules
198
+ ### 152 detection rules
199
199
 
200
- All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v262) for the complete rules reference.
200
+ All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v294) for the complete rules reference.
201
201
 
202
202
  ### Detected campaigns
203
203
 
204
204
  | Campaign | Status |
205
205
  |----------|--------|
206
+ | GlassWorm (2026, 433+ packages) | Detected |
206
207
  | Shai-Hulud v1/v2/v3 (2025) | Detected |
207
208
  | event-stream (2018) | Detected |
208
209
  | eslint-scope (2018) | Detected |
@@ -281,12 +282,12 @@ repos:
281
282
 
282
283
  | Metric | Result | Details |
283
284
  |--------|--------|---------|
284
- | **Wild TPR** (Datadog 17K) | **88.2%** raw / **~100%** adjusted | 17,922 real malware. 2,077 out-of-scope (phishing, binaries, corrected) |
285
+ | **Wild TPR** (Datadog 17K) | **92.5%** (13,486/14,587 in-scope) | 17,922 packages. 3,335 skipped (no JS). By category: compromised_lib 97.8%, malicious_intent 92.1% |
285
286
  | **TPR** (Ground Truth) | **93.9%** (46/49) | 51 real attacks. 3 out-of-scope: browser-only |
286
- | **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
287
- | **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
287
+ | **FPR** (Benign) | **12.9%** (68/529) | 529 npm packages, real source via `npm pack` |
288
+ | **ADR** (Adversarial + Holdout) | **96.3%** (103/107) | 67 adversarial + 40 holdout (107 available on disk), global threshold=20 |
288
289
 
289
- **2166 tests** across 49 files. **134 rules** (129 RULES + 5 PARANOID).
290
+ **2336 tests** across 50 files. **152 rules** (147 RULES + 5 PARANOID).
290
291
 
291
292
  > **Methodology caveats:**
292
293
  > - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
@@ -327,11 +328,11 @@ npm test
327
328
 
328
329
  ### Testing
329
330
 
330
- - **2166 tests** across 49 modular test files
331
+ - **2336 tests** across 50 modular test files
331
332
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
332
333
  - **Datadog 17K benchmark** - 17,922 real malware samples
333
334
  - **Ground truth validation** - 51 real-world attacks (93.9% TPR)
334
- - **False positive validation** - 12.1% FPR on 529 real npm packages
335
+ - **False positive validation** - 12.9% FPR on 529 real npm packages
335
336
 
336
337
  ---
337
338
 
@@ -347,7 +348,7 @@ npm test
347
348
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
348
349
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
349
350
  - [Adversarial Evaluation](ADVERSARIAL.md) - Red team samples and ADR results
350
- - [Security Policy](SECURITY.md) - Detection rules reference (134 rules)
351
+ - [Security Policy](SECURITY.md) - Detection rules reference (152 rules)
351
352
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
352
353
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
353
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.9.3",
3
+ "version": "2.9.5",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -349,6 +349,11 @@ const PLAYBOOKS = {
349
349
  'pour eviter la detection statique. Technique de vol de GITHUB_TOKEN, NPM_TOKEN, etc. ' +
350
350
  'Verifier quelles variables sont accedees et si elles sont exfiltrees.',
351
351
 
352
+ lifecycle_hidden_payload:
353
+ 'CRITIQUE: Le script lifecycle pointe vers un fichier cache dans node_modules/. ' +
354
+ 'Ce pattern est utilise par les attaques DPRK/Lazarus pour cacher le payload dans un repertoire ' +
355
+ 'que les scanners excluent par defaut. Examiner le fichier cible immediatement.',
356
+
352
357
  lifecycle_shell_pipe:
353
358
  'CRITIQUE: Le script lifecycle (preinstall/postinstall) pipe du code distant vers un shell (curl | sh). ' +
354
359
  'NE PAS installer. Ceci execute du code arbitraire a l\'installation. ' +
@@ -649,6 +649,19 @@ const RULES = {
649
649
  mitre: 'T1027'
650
650
  },
651
651
 
652
+ lifecycle_hidden_payload: {
653
+ id: 'MUADDIB-PKG-016',
654
+ name: 'Lifecycle Script Targets Hidden Payload',
655
+ severity: 'CRITICAL',
656
+ confidence: 'high',
657
+ description: 'Script lifecycle pointe vers un fichier dans node_modules/ — technique de dissimulation de payload. Les scanners excluent node_modules/ par defaut, rendant le payload invisible. Pattern DPRK/Lazarus interview attack.',
658
+ references: [
659
+ 'https://unit42.paloaltonetworks.com/operation-dream-job/',
660
+ 'https://blog.phylum.io/shai-hulud-npm-worm'
661
+ ],
662
+ mitre: 'T1027.009'
663
+ },
664
+
652
665
  lifecycle_shell_pipe: {
653
666
  id: 'MUADDIB-PKG-010',
654
667
  name: 'Lifecycle Script Pipes to Shell',
@@ -475,6 +475,45 @@ function handleVariableDeclarator(node, ctx) {
475
475
  ctx.stringVarValues.set(node.id.name, strVal);
476
476
  }
477
477
 
478
+ // Track variables assigned from require.cache[...] (module cache references)
479
+ // Used to detect writes to cached module exports (require.cache poisoning)
480
+ if (node.init?.type === 'MemberExpression' && node.init.computed) {
481
+ const obj = node.init.object;
482
+ if (obj?.type === 'MemberExpression' &&
483
+ obj.object?.type === 'Identifier' && obj.object.name === 'require' &&
484
+ obj.property?.type === 'Identifier' && obj.property.name === 'cache') {
485
+ ctx.requireCacheVars.add(node.id.name);
486
+ }
487
+ }
488
+
489
+ // Track variables assigned from BinaryExpression with '+' (string concatenation building)
490
+ // Used to detect setTimeout(concatVar, delay) — eval via timer with built string
491
+ // FP fix: only track when at least one operand is demonstrably a string (literal, template,
492
+ // or known string var). Filters out arithmetic `var e = a + 1` in minified code.
493
+ if (node.init?.type === 'BinaryExpression' && node.init.operator === '+') {
494
+ const left = node.init.left;
495
+ const right = node.init.right;
496
+ const isStringOperand = (n) =>
497
+ (n.type === 'Literal' && typeof n.value === 'string') ||
498
+ n.type === 'TemplateLiteral' ||
499
+ (n.type === 'Identifier' && ctx.stringVarValues?.has(n.name)) ||
500
+ (n.type === 'Identifier' && ctx.stringBuildVars?.has(n.name));
501
+ if (isStringOperand(left) || isStringOperand(right)) {
502
+ ctx.stringBuildVars.add(node.id.name);
503
+ }
504
+ }
505
+
506
+ // Track object variables with Proxy trap properties (set/get/apply/construct)
507
+ // Used to detect new Proxy(target, handlerVar) when handler is not inline
508
+ if (node.init?.type === 'ObjectExpression') {
509
+ const hasTrap = node.init.properties?.some(p =>
510
+ p.key?.type === 'Identifier' && ['set', 'get', 'apply', 'construct'].includes(p.key.name)
511
+ );
512
+ if (hasTrap) {
513
+ ctx.proxyHandlerVars.add(node.id.name);
514
+ }
515
+ }
516
+
478
517
  // Track variables assigned from path.join containing .github/workflows
479
518
  if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
480
519
  const obj = node.init.callee.object;
@@ -1294,6 +1333,29 @@ function handleCallExpression(node, ctx) {
1294
1333
  file: ctx.relFile
1295
1334
  });
1296
1335
  }
1336
+ // BinaryExpression with '+' as first arg = string concatenation for eval via timer
1337
+ else if (firstArg.type === 'BinaryExpression' && firstArg.operator === '+') {
1338
+ ctx.hasEvalInFile = true;
1339
+ ctx.hasDynamicExec = true;
1340
+ ctx.threats.push({
1341
+ type: 'dangerous_call_eval',
1342
+ severity: 'HIGH',
1343
+ message: `${callName}() with concatenated string argument — eval equivalent, dynamically built code string.`,
1344
+ file: ctx.relFile
1345
+ });
1346
+ }
1347
+ // Identifier arg that was tracked as string value or string concatenation result
1348
+ else if (firstArg.type === 'Identifier' &&
1349
+ (ctx.stringVarValues?.has(firstArg.name) || ctx.stringBuildVars?.has(firstArg.name))) {
1350
+ ctx.hasEvalInFile = true;
1351
+ ctx.hasDynamicExec = true;
1352
+ ctx.threats.push({
1353
+ type: 'dangerous_call_eval',
1354
+ severity: 'HIGH',
1355
+ message: `${callName}() with variable "${firstArg.name}" containing built string — eval equivalent, executes the string as code.`,
1356
+ file: ctx.relFile
1357
+ });
1358
+ }
1297
1359
 
1298
1360
  // Static timer bomb: setTimeout/setInterval with delay > 1 hour (PhantomRaven 48h delay)
1299
1361
  if (node.arguments.length >= 2) {
@@ -1757,8 +1819,17 @@ function handleNewExpression(node, ctx) {
1757
1819
  );
1758
1820
  if (hasTrap) {
1759
1821
  ctx.hasProxyTrap = true;
1822
+ const hasSetTrap = handler.properties?.some(p =>
1823
+ p.key?.type === 'Identifier' && p.key.name === 'set'
1824
+ );
1825
+ if (hasSetTrap) ctx.hasProxySetTrap = true;
1760
1826
  }
1761
1827
  }
1828
+ // Also detect when handler is a variable reference that was tracked as having trap properties
1829
+ if (handler?.type === 'Identifier' && ctx.proxyHandlerVars?.has(handler.name)) {
1830
+ ctx.hasProxyTrap = true;
1831
+ ctx.hasProxySetTrap = true; // proxyHandlerVars tracks objects with any trap including set
1832
+ }
1762
1833
  }
1763
1834
  }
1764
1835
 
@@ -1966,6 +2037,29 @@ function handleAssignmentExpression(node, ctx) {
1966
2037
  if (node.left?.type === 'MemberExpression') {
1967
2038
  const left = node.left;
1968
2039
 
2040
+ // require.cache[...].exports = ... — module cache poisoning WRITE (not just read)
2041
+ // This is always malicious: replacing a core module's exports to intercept all usage.
2042
+ // Also detects: mod.exports.X = ... where mod is from require.cache[...]
2043
+ if (left.property?.type === 'Identifier' && left.property.name === 'exports') {
2044
+ // Direct pattern: require.cache[...].exports = ...
2045
+ const obj = left.object;
2046
+ if (obj?.type === 'MemberExpression' && obj.computed) {
2047
+ const deep = obj.object;
2048
+ if (deep?.type === 'MemberExpression' &&
2049
+ deep.object?.type === 'Identifier' && deep.object.name === 'require' &&
2050
+ deep.property?.type === 'Identifier' && deep.property.name === 'cache') {
2051
+ ctx.hasRequireCacheWrite = true;
2052
+ }
2053
+ }
2054
+ }
2055
+ // Indirect pattern: mod.exports.X = ... where mod = require.cache[...]
2056
+ if (left.object?.type === 'MemberExpression' &&
2057
+ left.object.property?.type === 'Identifier' && left.object.property.name === 'exports' &&
2058
+ left.object.object?.type === 'Identifier' &&
2059
+ ctx.requireCacheVars?.has(left.object.object.name)) {
2060
+ ctx.hasRequireCacheWrite = true;
2061
+ }
2062
+
1969
2063
  // globalThis.fetch = ... or globalThis.XMLHttpRequest = ... (B2: include aliases)
1970
2064
  if (left.object?.type === 'Identifier' &&
1971
2065
  (left.object.name === 'globalThis' || left.object.name === 'global' ||
@@ -2045,15 +2139,11 @@ function handleAssignmentExpression(node, ctx) {
2045
2139
  }
2046
2140
 
2047
2141
  function handleMemberExpression(node, ctx) {
2048
- // Detect require.cache access
2142
+ // Detect require.cache access — set flag, defer threat emission to handlePostWalk
2143
+ // FP fix: distinguish READ (hot-reload, delete, introspection) from WRITE (.exports = ...)
2049
2144
  if (node.object?.type === 'Identifier' && node.object.name === 'require' &&
2050
2145
  node.property?.type === 'Identifier' && node.property.name === 'cache') {
2051
- ctx.threats.push({
2052
- type: 'require_cache_poison',
2053
- severity: 'CRITICAL',
2054
- message: 'require.cache accessed — module cache poisoning to hijack or replace core Node.js modules.',
2055
- file: ctx.relFile
2056
- });
2146
+ ctx.hasRequireCacheRead = true;
2057
2147
  }
2058
2148
 
2059
2149
  // GlassWorm: track .codePointAt() calls (variation selector decoder pattern)
@@ -2307,11 +2397,15 @@ function handlePostWalk(ctx) {
2307
2397
 
2308
2398
  // Built-in method override + network: console.X = function or Object.defineProperty = function
2309
2399
  // combined with network calls. Monkey-patching built-in APIs for data interception.
2400
+ // CRITICAL when Object.defineProperty itself is reassigned (global hook on all property defs).
2310
2401
  if (ctx.hasBuiltinOverride && ctx.hasNetworkCallInFile) {
2402
+ const isGlobalHook = ctx.hasBuiltinGlobalHook;
2311
2403
  ctx.threats.push({
2312
2404
  type: 'builtin_override_exfil',
2313
- severity: 'HIGH',
2314
- message: 'Built-in method override (console/Object.defineProperty) + network call — runtime API hijacking for data interception and exfiltration.',
2405
+ severity: isGlobalHook ? 'CRITICAL' : 'HIGH',
2406
+ message: isGlobalHook
2407
+ ? 'Object.defineProperty reassigned + network call — global hook intercepts all property definitions for credential exfiltration.'
2408
+ : 'Built-in method override (console/Object.defineProperty) + network call — runtime API hijacking for data interception and exfiltration.',
2315
2409
  file: ctx.relFile
2316
2410
  });
2317
2411
  }
@@ -2335,10 +2429,15 @@ function handlePostWalk(ctx) {
2335
2429
  const hasCredentialSignal = ctx.threats.some(t =>
2336
2430
  t.type === 'env_access' || t.type === 'suspicious_dataflow'
2337
2431
  );
2432
+ // CRITICAL when: credential signals co-occur, OR set trap (intercepts all property writes)
2433
+ // A set trap with network call = universal data capture + exfiltration
2434
+ const isCritical = hasCredentialSignal || ctx.hasProxySetTrap;
2338
2435
  ctx.threats.push({
2339
2436
  type: 'proxy_data_intercept',
2340
- severity: hasCredentialSignal ? 'CRITICAL' : 'HIGH',
2341
- message: 'Proxy trap (set/get/apply) with network call in same file — data interception and exfiltration via Proxy handler.',
2437
+ severity: isCritical ? 'CRITICAL' : 'HIGH',
2438
+ message: ctx.hasProxySetTrap
2439
+ ? 'Proxy set trap with network call — intercepts ALL property writes for exfiltration via Proxy handler.'
2440
+ : 'Proxy trap (set/get/apply) with network call in same file — data interception and exfiltration via Proxy handler.',
2342
2441
  file: ctx.relFile
2343
2442
  });
2344
2443
  }
@@ -2353,6 +2452,24 @@ function handlePostWalk(ctx) {
2353
2452
  });
2354
2453
  }
2355
2454
 
2455
+ // require.cache: distinguish WRITE (actual poisoning) from READ-only (hot-reload, introspection)
2456
+ // FP fix: READ-only emits LOW (informational), WRITE emits CRITICAL (malicious module replacement).
2457
+ if (ctx.hasRequireCacheWrite) {
2458
+ ctx.threats.push({
2459
+ type: 'require_cache_poison',
2460
+ severity: 'CRITICAL',
2461
+ message: 'require.cache[...].exports = ... — module cache write: replaces core module exports to intercept all callers.',
2462
+ file: ctx.relFile
2463
+ });
2464
+ } else if (ctx.hasRequireCacheRead) {
2465
+ ctx.threats.push({
2466
+ type: 'require_cache_poison',
2467
+ severity: 'LOW',
2468
+ message: 'require.cache accessed — module cache read (hot-reload/introspection pattern).',
2469
+ file: ctx.relFile
2470
+ });
2471
+ }
2472
+
2356
2473
  // DPRK/Lazarus compound: detached background process + credential env access + network
2357
2474
  // Pattern: spawn({detached:true}) reads secrets then exfils via network.
2358
2475
  // This combination is never legitimate — daemons don't read API keys and send them out.
@@ -120,6 +120,8 @@ function analyzeFile(content, filePath, basePath) {
120
120
  hasBuiltinOverride: /\bconsole\s*\.\s*\w+\s*=\s*function/.test(content) ||
121
121
  /\bconsole\s*\[\s*\w+\s*\]\s*=\s*function/.test(content) ||
122
122
  /\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
123
+ // Critical builtin override: Object.defineProperty itself is reassigned (global hook)
124
+ hasBuiltinGlobalHook: /\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
123
125
  // Stream interceptor: class extending Transform/Duplex/Writable (data wiretap pattern)
124
126
  hasStreamInterceptor: /\bextends\s+(Transform|Duplex|Writable)\b/.test(content),
125
127
  // SANDWORM_MODE P2: DNS exfiltration co-occurrence
@@ -157,6 +159,12 @@ function analyzeFile(content, filePath, basePath) {
157
159
  hasWasmLoad: /\bWebAssembly\s*\.\s*(compile|instantiate|compileStreaming|instantiateStreaming)\b/.test(content),
158
160
  hasWasmHostSink: false, // set in handleCallExpression when WASM import object contains network/fs sinks
159
161
  hasProxyTrap: false, // set in handleNewExpression when Proxy has set/get/apply trap
162
+ hasProxySetTrap: false, // set when Proxy specifically has a 'set' trap (data interception)
163
+ hasRequireCacheRead: false, // set when require.cache is accessed (read)
164
+ hasRequireCacheWrite: false, // set when require.cache exports are modified
165
+ requireCacheVars: new Set(), // variables assigned from require.cache[...]
166
+ proxyHandlerVars: new Set(), // variables assigned object literals with set/get/apply/construct traps
167
+ stringBuildVars: new Set(), // variables assigned from BinaryExpression with '+' (string concat)
160
168
  // C10: Hash verification — legitimate binary installers verify checksums
161
169
  // Requires BOTH createHash() call AND .digest() call — false positives from
162
170
  // standalone mentions of 'sha256' or 'integrity' in comments/descriptions
@@ -205,9 +205,17 @@ function analyzeFile(content, filePath, basePath) {
205
205
  // Fix #23: Function param tainting — track function declarations
206
206
  const functionDefs = new Map(); // functionName → { params: [paramNames] }
207
207
 
208
+ // Fix #24: Callback exposure — track function parameters (potential callbacks)
209
+ // When a callback parameter is invoked with tainted data, it's credential exposure.
210
+ const callbackParams = new Set(); // parameter names of enclosing functions
211
+ const callbackExposures = []; // { callbackName, argName, line }
212
+
213
+ // Pre-scan: collect function declarations and callback params BEFORE the main walk.
214
+ // acorn-walk.simple uses post-order traversal (children before parents), so
215
+ // FunctionDeclaration handlers fire AFTER CallExpressions inside the function body.
216
+ // This pre-scan ensures callbackParams and functionDefs are populated before analysis.
208
217
  walk.simple(ast, {
209
218
  FunctionDeclaration(node) {
210
- // Fix #23: Track function declarations for param tainting
211
219
  if (node.id && node.id.name && node.params) {
212
220
  const paramNames = node.params
213
221
  .filter(p => p.type === 'Identifier')
@@ -215,8 +223,16 @@ function analyzeFile(content, filePath, basePath) {
215
223
  if (paramNames.length > 0) {
216
224
  functionDefs.set(node.id.name, { params: paramNames });
217
225
  }
226
+ // FP fix: skip 1-char parameter names (minified code noise: e, t, n, r, a, b, etc.)
227
+ // Real callback exposure attacks use descriptive names (callback, handler, cb, fn, done).
228
+ for (const p of node.params) {
229
+ if (p.type === 'Identifier' && p.name.length > 1) callbackParams.add(p.name);
230
+ }
218
231
  }
219
- },
232
+ }
233
+ });
234
+
235
+ walk.simple(ast, {
220
236
 
221
237
  VariableDeclarator(node) {
222
238
  // B9: Array destructuring taint propagation: const [data] = [fs.readFileSync('.npmrc')]
@@ -268,6 +284,19 @@ function analyzeFile(content, filePath, basePath) {
268
284
  }
269
285
  }
270
286
  }
287
+ // Fix #24: Propagate taint through fs.readFileSync/readFile results
288
+ // const data = fs.readFileSync(npmrc) where npmrc is sensitive → data is tainted
289
+ if (initNode.type === 'CallExpression' && initNode.callee?.type === 'MemberExpression') {
290
+ const callProp = initNode.callee.property;
291
+ if (callProp?.type === 'Identifier' &&
292
+ (callProp.name === 'readFileSync' || callProp.name === 'readFile')) {
293
+ const readArg = initNode.arguments[0];
294
+ if (readArg && isCredentialPath(readArg, sensitivePathVars)) {
295
+ sensitivePathVars.add(node.id.name);
296
+ }
297
+ }
298
+ }
299
+
271
300
  // B7: Taint propagation through data-preserving wrappers
272
301
  if (initNode.type === 'CallExpression') {
273
302
  const callee = initNode.callee;
@@ -653,6 +682,22 @@ function analyzeFile(content, filePath, basePath) {
653
682
  }
654
683
  }
655
684
 
685
+ // Fix #24: Callback exposure — detect callback(taintedData)
686
+ // When a function parameter is called with tainted data, it exposes credentials
687
+ // to the caller (cross-module credential exposure pattern).
688
+ if (node.callee.type === 'Identifier' && callbackParams.has(node.callee.name) &&
689
+ node.arguments.length >= 1) {
690
+ for (const arg of node.arguments) {
691
+ if (arg.type === 'Identifier' && sensitivePathVars.has(arg.name)) {
692
+ callbackExposures.push({
693
+ callbackName: node.callee.name,
694
+ argName: arg.name,
695
+ line: node.loc?.start?.line || 0
696
+ });
697
+ }
698
+ }
699
+ }
700
+
656
701
  // Exec callback: exec('cmd', (err, stdout) => {...}) — output will be used
657
702
  if (!execResultNodes.has(node) && node.arguments.length >= 2) {
658
703
  const lastArg = node.arguments[node.arguments.length - 1];
@@ -755,6 +800,7 @@ function analyzeFile(content, filePath, basePath) {
755
800
  for (const eventName of emitTaintedEvents) {
756
801
  const handler = eventHandlers.get(eventName);
757
802
  if (handler && handler.hasNetworkSink) {
803
+ // Same-file emit→on with network sink: full suspicious_dataflow
758
804
  sources.push({
759
805
  type: 'credential_read',
760
806
  name: `EventEmitter.emit('${eventName}')`,
@@ -767,9 +813,31 @@ function analyzeFile(content, filePath, basePath) {
767
813
  line: 0,
768
814
  taint_tracked: true
769
815
  });
816
+ } else {
817
+ // Cross-file: tainted data emitted on EventEmitter without same-file listener.
818
+ // The data is broadcasted to other modules — credential exposure pattern.
819
+ sinks.push({
820
+ type: 'network_send',
821
+ name: `EventEmitter.emit('${eventName}') [cross-module broadcast]`,
822
+ line: 0,
823
+ taint_tracked: true
824
+ });
770
825
  }
771
826
  }
772
827
 
828
+ // Fix #24: Callback exposure — add sinks for callback invocations with tainted data
829
+ // FP fix: cap at 5 exposures per file. Real attacks have 1-2 targeted callbacks,
830
+ // >5 is minified code noise (jspdf, etc.)
831
+ const cappedExposures = callbackExposures.slice(0, 5);
832
+ for (const exposure of cappedExposures) {
833
+ sinks.push({
834
+ type: 'network_send',
835
+ name: `${exposure.callbackName}(${exposure.argName}) [callback exposure]`,
836
+ line: exposure.line,
837
+ taint_tracked: true
838
+ });
839
+ }
840
+
773
841
  // Check if any source or sink was resolved via taint tracking
774
842
  const hasTaintTracked = sources.some(s => s.taint_tracked) || sinks.some(s => s.taint_tracked);
775
843
 
@@ -804,6 +872,17 @@ function analyzeFile(content, filePath, basePath) {
804
872
  }
805
873
  if (severity === 'CRITICAL') break;
806
874
  }
875
+ // Fix #24: EventEmitter broadcast and callback exposure sinks are always CRITICAL
876
+ // when combined with credential sources — the data is being sent to external consumers
877
+ if (severity !== 'CRITICAL') {
878
+ const hasExposureSink = exfilSinks.some(s =>
879
+ s.name.includes('[cross-module broadcast]') || s.name.includes('[callback exposure]')
880
+ );
881
+ const hasCredentialSource = sources.some(s => s.type === 'credential_read');
882
+ if (hasExposureSink && hasCredentialSource) {
883
+ severity = 'CRITICAL';
884
+ }
885
+ }
807
886
 
808
887
  // Downgrade: if ALL sources are pure telemetry (os.platform, os.arch), cap at HIGH
809
888
  const allTelemetryOnly = sources.every(s => s.type === 'telemetry_read');
@@ -103,6 +103,19 @@ async function scanPackageJson(targetPath) {
103
103
  }
104
104
  }
105
105
 
106
+ // Escalate: lifecycle script targeting node_modules/ — payload hiding technique.
107
+ // Legitimate postinstall scripts run from the package's own directory, not from node_modules/.
108
+ // Lazarus/DPRK interview attacks hide payloads in node_modules/.cache/ or similar paths.
109
+ if (['preinstall', 'install', 'postinstall'].includes(scriptName) &&
110
+ /\bnode_modules[\/\\]/.test(scriptContent)) {
111
+ threats.push({
112
+ type: 'lifecycle_hidden_payload',
113
+ severity: 'CRITICAL',
114
+ message: `Critical: "${scriptName}" targets file inside node_modules/ — payload hiding technique to evade scanners.`,
115
+ file: 'package.json'
116
+ });
117
+ }
118
+
106
119
  // Detect Bun runtime evasion in lifecycle scripts (Shai-Hulud 2.0)
107
120
  if (/\bbun\s+(run|exec|install|x)\b/.test(scriptContent) || /\bbunx\s+/.test(scriptContent)) {
108
121
  threats.push({
package/src/scoring.js CHANGED
@@ -131,7 +131,8 @@ const FP_COUNT_THRESHOLDS = {
131
131
  // P4: bundled credential_tampering from minified alias resolution (jspdf, lerna)
132
132
  credential_tampering: { maxCount: 5, to: 'LOW' },
133
133
  // B1 FP reduction: bundled code aliases eval/Function (sinon, storybook, vitest)
134
- dangerous_call_eval: { maxCount: 3, from: 'MEDIUM', to: 'LOW' },
134
+ // FP fix: also cover HIGH severity (setTimeout+stringBuildVar in minified code)
135
+ dangerous_call_eval: { maxCount: 3, to: 'LOW' },
135
136
  // P6: HTTP client libraries (undici, aws-sdk, nodemailer, jsdom) parse Authorization/Bearer headers
136
137
  // with 3+ credential regexes. Real harvesters use 1-2 targeted regexes.
137
138
  credential_regex_harvest: { maxCount: 2, from: 'HIGH', to: 'LOW' },
@@ -379,13 +380,10 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
379
380
  }
380
381
  }
381
382
 
382
- // require_cache_poison: single hit HIGH (plugin dedup/hot-reload, not malware)
383
- // Malware poisons cache repeatedly; a single access is framework behavior
384
- if (t.type === 'require_cache_poison' && t.severity === 'CRITICAL' &&
385
- typeCounts.require_cache_poison === 1) {
386
- t.reductions.push({ rule: 'cache_poison_single', from: 'CRITICAL', to: 'HIGH' });
387
- t.severity = 'HIGH';
388
- }
383
+ // require_cache_poison: single-hit downgrade removed.
384
+ // The READ/WRITE distinction in ast-detectors already handles the FP case:
385
+ // READ-only LOW (hot-reload, introspection), WRITE CRITICAL (malicious replacement).
386
+ // A single cache WRITE is genuinely malicious — no downgrade needed.
389
387
 
390
388
  // Prototype hook: framework class prototypes → MEDIUM
391
389
  // Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
@@ -432,9 +430,12 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
432
430
  }
433
431
 
434
432
  // Reachability: findings in files not reachable from entry points → LOW
433
+ // Exception: .d.ts files are never require()'d by JS but are executed by ts-node/tsx/bun.
434
+ // Executable code in .d.ts is always malicious — exempt from unreachable downgrade.
435
+ const isDtsFile = t.file && t.file.endsWith('.d.ts');
435
436
  if (reachableFiles && reachableFiles.size > 0 && t.file &&
436
437
  !REACHABILITY_EXEMPT_TYPES.has(t.type) &&
437
- !isPackageLevelThreat(t)) {
438
+ !isPackageLevelThreat(t) && !isDtsFile) {
438
439
  const normalizedFile = t.file.replace(/\\/g, '/');
439
440
  if (!reachableFiles.has(normalizedFile)) {
440
441
  t.reductions.push({ rule: 'unreachable', from: t.severity, to: 'LOW' });
@@ -22,14 +22,29 @@ function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
22
22
  if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
23
23
  if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
24
24
 
25
+ // .d.ts files: strip TypeScript declaration syntax before JS parsing.
26
+ // Legitimate .d.ts files contain only type declarations (no executable code).
27
+ // Any require/exec/network calls in a .d.ts are high-confidence malicious payload hiding.
28
+ let effectiveContent = content;
29
+ if (file.endsWith('.d.ts')) {
30
+ effectiveContent = content.split('\n').map(line => {
31
+ const trimmed = line.trim();
32
+ // Strip lines that are pure TypeScript declarations (Acorn can't parse these)
33
+ if (/^export\s+declare\s+/.test(trimmed)) return '// [ts-stripped]';
34
+ if (/^declare\s+(function|class|const|let|var|type|interface|enum|namespace|module|global)\s/.test(trimmed)) return '// [ts-stripped]';
35
+ if (/^(export\s+)?(type|interface)\s/.test(trimmed)) return '// [ts-stripped]';
36
+ return line;
37
+ }).join('\n');
38
+ }
39
+
25
40
  // Analyze original code first (preserves obfuscation-detection rules)
26
- const fileThreats = analyzeFileFn(content, file, targetPath);
41
+ const fileThreats = analyzeFileFn(effectiveContent, file, targetPath);
27
42
  threats.push(...fileThreats);
28
43
 
29
44
  // Also analyze deobfuscated code for additional findings hidden by obfuscation
30
45
  if (typeof options.deobfuscate === 'function') {
31
46
  try {
32
- const result = options.deobfuscate(content);
47
+ const result = options.deobfuscate(effectiveContent);
33
48
  if (result.transforms.length > 0) {
34
49
  const deobThreats = analyzeFileFn(result.code, file, targetPath);
35
50
  const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
package/src/utils.js CHANGED
@@ -183,7 +183,9 @@ function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visi
183
183
  * @returns {string[]} List of .js file paths
184
184
  */
185
185
  function findJsFiles(dir, results = []) {
186
- return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
186
+ // .d.ts included: legitimate .d.ts files never contain require/exec/network calls,
187
+ // so any executable code in .d.ts is a high-confidence malicious payload hiding technique.
188
+ return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs', '.d.ts'], results });
187
189
  }
188
190
 
189
191
  function clearFileListCache() {