muaddib-scanner 2.5.13 → 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-07T17:50:50.825Z",
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-07T17:50:50.826Z",
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-07T17:50:50.826Z",
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-07T17:50:51.268Z",
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-07T17:50:51.399Z",
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-1772905850823@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 17:50:51 UTC"
42
+ "text": "MUAD'DIB - Daily summary | 2026-03-07 19:15:14 UTC"
43
43
  },
44
- "timestamp": "2026-03-07T17:50:51.399Z"
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.13",
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": {
@@ -455,6 +455,13 @@ function invalidateCache() {
455
455
  // ============================================
456
456
  // Key is derived from a stable machine-specific seed + hardcoded salt.
457
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.
458
465
  const IOC_HMAC_SALT = 'muaddib-ioc-integrity-v1';
459
466
 
460
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;
@@ -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