muaddib-scanner 2.5.15 → 2.5.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.15",
3
+ "version": "2.5.16",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -23,7 +23,7 @@ const { ensureIOCs } = require('./ioc/bootstrap.js');
23
23
  const { scanEntropy } = require('./scanner/entropy.js');
24
24
  const { scanAIConfig } = require('./scanner/ai-config.js');
25
25
  const { deobfuscate } = require('./scanner/deobfuscate.js');
26
- const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
26
+ const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows, annotateSinkExports, detectCallbackCrossFileFlows } = require('./scanner/module-graph.js');
27
27
  const { computeReachableFiles } = require('./scanner/reachability.js');
28
28
  const { runTemporalAnalyses } = require('./temporal-runner.js');
29
29
  const { formatOutput } = require('./output-formatter.js');
@@ -362,6 +362,10 @@ async function run(targetPath, options = {}) {
362
362
  const graph = await yieldThen(() => buildModuleGraph(targetPath));
363
363
  const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
364
364
  crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
365
+ // Callback-based cross-file flow detection
366
+ const sinkAnnotations = await yieldThen(() => annotateSinkExports(graph, targetPath));
367
+ const callbackFlows = await yieldThen(() => detectCallbackCrossFileFlows(graph, tainted, sinkAnnotations, targetPath));
368
+ crossFileFlows = crossFileFlows.concat(callbackFlows);
365
369
  } catch (e) {
366
370
  // Graceful fallback — module graph is best-effort
367
371
  debugLog('[MODULE-GRAPH] Error:', e && e.message);
@@ -461,6 +461,31 @@ const PLAYBOOKS = {
461
461
  fragmented_high_entropy_cluster:
462
462
  'Cluster de chaines courtes a haute entropie detecte. Possible fragmentation de payload pour eviter la detection. ' +
463
463
  'Reconstituer les fragments et analyser le contenu combine. Verifier si les chaines sont concatenees ou reassemblees a l\'execution.',
464
+
465
+ wasm_host_sink:
466
+ 'CRITIQUE: Module WebAssembly charge avec des imports host contenant des sinks reseau. Le flux de controle est cache dans le binaire WASM, ' +
467
+ 'rendant l\'analyse statique impossible. Le WASM peut lire des fichiers sensibles et exfiltrer via les callbacks host. ' +
468
+ 'Supprimer le package immediatement. Analyser le fichier WASM avec wasm2wat pour comprendre le flux. Regenerer tous les secrets.',
469
+ credential_regex_harvest:
470
+ 'Code contient des regex de detection de credentials (Bearer, password, token, API key) combine avec un appel reseau. ' +
471
+ 'Technique de harvesting: scanne les donnees en transit (streams HTTP, fichiers) pour extraire des secrets et les exfiltrer. ' +
472
+ 'Supprimer le package. Auditer le trafic reseau sortant.',
473
+ builtin_override_exfil:
474
+ 'Code remplace une methode built-in (console.log/warn/error, Object.defineProperty) et contient un appel reseau. ' +
475
+ 'Technique de monkey-patching: intercepte les donnees passant par les APIs natives pour les exfiltrer. ' +
476
+ 'Supprimer le package. Verifier si d\'autres methodes natives ont ete modifiees.',
477
+ stream_credential_intercept:
478
+ 'Classe stream (Transform/Duplex/Writable) avec regex de credentials et appel reseau. ' +
479
+ 'Technique de wiretap: le stream intercepte les donnees en transit, scanne pour des secrets (Bearer, password, token) ' +
480
+ 'et les exfiltre via un appel reseau. Supprimer le package.',
481
+ remote_code_load:
482
+ 'CRITIQUE: Fetch reseau + eval/new Function() dans le meme fichier. ' +
483
+ 'Technique multi-stage: le package telecharge un payload depuis un serveur distant (SVG, HTML, JSON) puis l\'execute. ' +
484
+ 'Supprimer le package. Bloquer le domaine C2 au niveau firewall.',
485
+ proxy_data_intercept:
486
+ 'CRITIQUE: Un Proxy JavaScript avec trap set/get/apply est combine avec un appel reseau. ' +
487
+ 'Technique d\'interception: le Proxy capture toutes les ecritures de proprietes (credentials, tokens, config) ' +
488
+ 'et les exfiltre via HTTPS/fetch/dgram. Supprimer le package. Auditer tous les modules qui importent ce package.',
464
489
  };
465
490
 
466
491
  function getPlaybook(threatType) {
@@ -1285,6 +1285,78 @@ const RULES = {
1285
1285
  ],
1286
1286
  mitre: 'T1059'
1287
1287
  },
1288
+ wasm_host_sink: {
1289
+ id: 'MUADDIB-AST-042',
1290
+ name: 'WASM Host Import Sink',
1291
+ severity: 'CRITICAL',
1292
+ confidence: 'high',
1293
+ description: 'Module WebAssembly charge avec des callbacks host contenant des sinks reseau (fetch/http.request). Le WASM peut invoquer ces callbacks pour exfiltrer des donnees tout en cachant le flux de controle. Aucun package npm legitime ne combine WASM + callbacks reseau host.',
1294
+ references: [
1295
+ 'https://attack.mitre.org/techniques/T1059/',
1296
+ 'https://attack.mitre.org/techniques/T1027/'
1297
+ ],
1298
+ mitre: 'T1059'
1299
+ },
1300
+ credential_regex_harvest: {
1301
+ id: 'MUADDIB-AST-041',
1302
+ name: 'Credential Regex Harvesting',
1303
+ severity: 'HIGH',
1304
+ confidence: 'high',
1305
+ description: 'Regex de detection de credentials (token/password/secret/Bearer) combine avec un appel reseau. Technique de harvesting: le code scanne les donnees de flux (streams, requetes) a la recherche de credentials et les exfiltre.',
1306
+ references: [
1307
+ 'https://attack.mitre.org/techniques/T1552/',
1308
+ 'https://attack.mitre.org/techniques/T1041/'
1309
+ ],
1310
+ mitre: 'T1552'
1311
+ },
1312
+ builtin_override_exfil: {
1313
+ id: 'MUADDIB-AST-044',
1314
+ name: 'Built-in Method Override Exfiltration',
1315
+ severity: 'HIGH',
1316
+ confidence: 'high',
1317
+ description: 'Override de methode built-in (console.log/warn/error, Object.defineProperty) combine avec un appel reseau. Technique de monkey-patching: le code remplace une API native pour intercepter les donnees en transit et les exfiltrer.',
1318
+ references: [
1319
+ 'https://attack.mitre.org/techniques/T1557/',
1320
+ 'https://attack.mitre.org/techniques/T1041/'
1321
+ ],
1322
+ mitre: 'T1557'
1323
+ },
1324
+ stream_credential_intercept: {
1325
+ id: 'MUADDIB-AST-045',
1326
+ name: 'Stream Credential Interception',
1327
+ severity: 'HIGH',
1328
+ confidence: 'high',
1329
+ description: 'Classe stream (Transform/Duplex/Writable) avec regex de credentials et appel reseau. Technique de wiretap: le stream intercepte les donnees en transit, scanne pour des credentials (Bearer, password, token) et les exfiltre.',
1330
+ references: [
1331
+ 'https://attack.mitre.org/techniques/T1557/',
1332
+ 'https://attack.mitre.org/techniques/T1552/'
1333
+ ],
1334
+ mitre: 'T1557'
1335
+ },
1336
+ remote_code_load: {
1337
+ id: 'MUADDIB-AST-040',
1338
+ name: 'Remote Code Loading',
1339
+ severity: 'CRITICAL',
1340
+ confidence: 'high',
1341
+ description: 'Fetch reseau + eval/Function dans le meme fichier. Technique multi-stage: le code telecharge un payload distant (SVG, HTML, JSON) et l\'execute dynamiquement. Aucun package npm legitime ne combine fetch + eval/Function.',
1342
+ references: [
1343
+ 'https://attack.mitre.org/techniques/T1105/',
1344
+ 'https://attack.mitre.org/techniques/T1059/'
1345
+ ],
1346
+ mitre: 'T1105'
1347
+ },
1348
+ proxy_data_intercept: {
1349
+ id: 'MUADDIB-AST-043',
1350
+ name: 'Proxy Data Interception',
1351
+ severity: 'CRITICAL',
1352
+ confidence: 'high',
1353
+ description: 'Proxy trap (set/get/apply) combine avec un appel reseau dans le meme fichier. Technique d\'interception de donnees: le Proxy capture toutes les ecritures/lectures de proprietes et les exfiltre via le reseau. Utilise pour voler des credentials passees via module.exports.',
1354
+ references: [
1355
+ 'https://attack.mitre.org/techniques/T1557/',
1356
+ 'https://attack.mitre.org/techniques/T1041/'
1357
+ ],
1358
+ mitre: 'T1557'
1359
+ },
1288
1360
  };
1289
1361
 
1290
1362
  function getRule(type) {
@@ -26,17 +26,56 @@ const SAFE_ENV_VARS = [
26
26
  'LANG', 'TERM', 'CI', 'DEBUG', 'VERBOSE', 'LOG_LEVEL',
27
27
  'SHELL', 'USER', 'LOGNAME', 'EDITOR', 'TZ',
28
28
  'NODE_DEBUG', 'NODE_PATH', 'NODE_OPTIONS',
29
- 'DISPLAY', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM'
29
+ 'DISPLAY', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM',
30
+ // CI environment metadata (non-sensitive)
31
+ 'GITHUB_REPOSITORY', 'GITHUB_SHA', 'GITHUB_REF', 'GITHUB_WORKSPACE',
32
+ 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_ACTOR', 'GITHUB_EVENT_NAME',
33
+ 'GITHUB_WORKFLOW', 'GITHUB_ACTION', 'GITHUB_JOB', 'GITHUB_SERVER_URL',
34
+ 'GITLAB_CI', 'TRAVIS', 'CIRCLECI', 'JENKINS_URL',
35
+ // Build tool config
36
+ 'NODE_TLS_REJECT_UNAUTHORIZED', 'BABEL_ENV', 'WEBPACK_MODE'
30
37
  ];
31
38
 
32
- // Env var prefixes that are safe (npm metadata, locale settings)
33
- const SAFE_ENV_PREFIXES = ['npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_'];
39
+ // Env var prefixes that are safe (npm metadata, locale settings, framework public vars)
40
+ const SAFE_ENV_PREFIXES = [
41
+ 'npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_',
42
+ 'next_public_', 'vite_', 'react_app_'
43
+ ];
34
44
 
35
45
  // Env var keywords to detect sensitive environment access (separate from SENSITIVE_STRINGS)
36
46
  const ENV_SENSITIVE_KEYWORDS = [
37
47
  'TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH'
38
48
  ];
39
49
 
50
+ // Non-sensitive qualifiers: when a keyword is preceded by one of these in the env var name,
51
+ // it is config metadata, not a real secret (e.g., PUBLIC_KEY, CACHE_KEY, SORT_KEY)
52
+ const ENV_NON_SENSITIVE_QUALIFIERS = new Set([
53
+ 'PUBLIC', 'CACHE', 'PRIMARY', 'FOREIGN', 'SORT', 'PARTITION', 'INDEX', 'ENCRYPTION'
54
+ ]);
55
+
56
+ /**
57
+ * Check if an env var name contains a sensitive keyword as a full _-delimited segment,
58
+ * not preceded by a non-sensitive qualifier.
59
+ * e.g., NPM_TOKEN → TOKEN is full segment → true
60
+ * PUBLIC_KEY → KEY preceded by PUBLIC → false
61
+ * CACHE_KEY → KEY preceded by CACHE → false
62
+ * GITHUB_TOKEN → TOKEN is full segment, preceded by GITHUB (not a qualifier) → true
63
+ */
64
+ function isEnvSensitive(envVar) {
65
+ const upper = envVar.toUpperCase();
66
+ const segments = upper.split('_');
67
+ for (let i = 0; i < segments.length; i++) {
68
+ if (ENV_SENSITIVE_KEYWORDS.includes(segments[i])) {
69
+ // Check if preceded by a non-sensitive qualifier
70
+ if (i > 0 && ENV_NON_SENSITIVE_QUALIFIERS.has(segments[i - 1])) {
71
+ continue;
72
+ }
73
+ return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
40
79
  // AI agent dangerous flags — disable security controls (s1ngularity/Nx, Aug 2025)
41
80
  const AI_AGENT_DANGEROUS_FLAGS = [
42
81
  '--dangerously-skip-permissions',
@@ -85,10 +124,11 @@ const HOOKABLE_NATIVES = [
85
124
  ];
86
125
 
87
126
  // Node.js core module classes targeted for prototype hooking
88
- const NODE_HOOKABLE_MODULES = ['http', 'https', 'net', 'tls', 'stream'];
127
+ const NODE_HOOKABLE_MODULES = ['http', 'https', 'net', 'tls', 'stream', 'events', 'dgram'];
89
128
  const NODE_HOOKABLE_CLASSES = [
90
129
  'IncomingMessage', 'ServerResponse', 'ClientRequest',
91
- 'OutgoingMessage', 'Socket', 'Server', 'Agent'
130
+ 'OutgoingMessage', 'Socket', 'Server', 'Agent',
131
+ 'EventEmitter'
92
132
  ];
93
133
 
94
134
  // AI/MCP config paths targeted for config injection (SANDWORM_MODE)
@@ -423,7 +463,7 @@ function handleVariableDeclarator(node, ctx) {
423
463
  if (SAFE_ENV_VARS.includes(envVar)) continue;
424
464
  const envLower = envVar.toLowerCase();
425
465
  if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) continue;
426
- if (ENV_SENSITIVE_KEYWORDS.some(s => envVar.toUpperCase().includes(s))) {
466
+ if (isEnvSensitive(envVar)) {
427
467
  ctx.threats.push({
428
468
  type: 'env_access',
429
469
  severity: 'HIGH',
@@ -538,7 +578,7 @@ function handleCallExpression(node, ctx) {
538
578
  }
539
579
  }
540
580
  if (!resolved) {
541
- ctx.threats.push({ type: 'dynamic_require', severity: 'HIGH',
581
+ ctx.threats.push({ type: 'dynamic_require', severity: 'MEDIUM',
542
582
  message: 'Dynamic require() with member expression argument (object property obfuscation).',
543
583
  file: ctx.relFile });
544
584
  }
@@ -985,9 +1025,9 @@ function handleCallExpression(node, ctx) {
985
1025
 
986
1026
  if (callName === 'eval') {
987
1027
  ctx.hasEvalInFile = true;
988
- ctx.hasDynamicExec = true;
989
1028
  // Detect staged eval decode
990
1029
  if (node.arguments.length === 1 && hasDecodeArg(node.arguments[0])) {
1030
+ ctx.hasDynamicExec = true;
991
1031
  ctx.threats.push({
992
1032
  type: 'staged_eval_decode',
993
1033
  severity: 'CRITICAL',
@@ -1007,9 +1047,15 @@ function handleCallExpression(node, ctx) {
1007
1047
  if (/\b(require|import|exec|execSync|spawn|child_process|\.readFile|\.writeFile|process\.env|\.homedir)\b/.test(val)) {
1008
1048
  severity = 'HIGH';
1009
1049
  message = `eval() with dangerous API in string literal: "${val.substring(0, 100)}"`;
1050
+ ctx.hasDynamicExec = true;
1010
1051
  }
1011
1052
  }
1012
1053
 
1054
+ // Only set hasDynamicExec for non-constant (dynamic) eval
1055
+ if (!isConstant) {
1056
+ ctx.hasDynamicExec = true;
1057
+ }
1058
+
1013
1059
  ctx.threats.push({
1014
1060
  type: 'dangerous_call_eval',
1015
1061
  severity,
@@ -1039,6 +1085,25 @@ function handleCallExpression(node, ctx) {
1039
1085
  }
1040
1086
  }
1041
1087
 
1088
+ // setTimeout/setInterval with string argument = eval equivalent
1089
+ // setTimeout("require('child_process').exec('whoami')", 100) executes the string as code
1090
+ // Only string Literal and TemplateLiteral are eval-equivalent; Identifier/MemberExpression
1091
+ // are function references (callbacks), not code strings.
1092
+ if ((callName === 'setTimeout' || callName === 'setInterval') && node.arguments.length >= 1) {
1093
+ const firstArg = node.arguments[0];
1094
+ if ((firstArg.type === 'Literal' && typeof firstArg.value === 'string') ||
1095
+ firstArg.type === 'TemplateLiteral') {
1096
+ ctx.hasEvalInFile = true;
1097
+ ctx.hasDynamicExec = true;
1098
+ ctx.threats.push({
1099
+ type: 'dangerous_call_eval',
1100
+ severity: 'HIGH',
1101
+ message: `${callName}() with string argument — eval equivalent, executes the string as code.`,
1102
+ file: ctx.relFile
1103
+ });
1104
+ }
1105
+ }
1106
+
1042
1107
  // Detect eval.call(null, code) / eval.apply(null, [code]) / Function.call/apply
1043
1108
  if (node.callee.type === 'MemberExpression' && !node.callee.computed &&
1044
1109
  node.callee.property?.type === 'Identifier' &&
@@ -1447,6 +1512,20 @@ function handleNewExpression(node, ctx) {
1447
1512
  file: ctx.relFile
1448
1513
  });
1449
1514
  }
1515
+ // Detect new Proxy(obj, handler) where handler has set/get traps — data interception
1516
+ // Real-world technique: export a Proxy that intercepts all property sets/gets to exfiltrate
1517
+ // data flowing through the module. Combined with network (hasNetworkInFile) → credential theft.
1518
+ if (!target.type?.includes('MemberExpression') || target.property?.name !== 'env') {
1519
+ const handler = node.arguments[1];
1520
+ if (handler?.type === 'ObjectExpression') {
1521
+ const hasTrap = handler.properties?.some(p =>
1522
+ p.key?.type === 'Identifier' && ['set', 'get', 'apply', 'construct'].includes(p.key.name)
1523
+ );
1524
+ if (hasTrap) {
1525
+ ctx.hasProxyTrap = true;
1526
+ }
1527
+ }
1528
+ }
1450
1529
  }
1451
1530
 
1452
1531
  // Batch 2: new Worker(code, { eval: true }) — worker_threads code execution
@@ -1630,6 +1709,19 @@ function handleAssignmentExpression(node, ctx) {
1630
1709
  }
1631
1710
  }
1632
1711
 
1712
+ // JSON.stringify = ... or JSON.parse = ... — global API hooking
1713
+ // Real-world technique: override JSON.stringify to intercept all serialization and exfiltrate data
1714
+ if (left.object?.type === 'Identifier' && left.object.name === 'JSON' &&
1715
+ left.property?.type === 'Identifier' &&
1716
+ ['stringify', 'parse'].includes(left.property.name)) {
1717
+ ctx.threats.push({
1718
+ type: 'prototype_hook',
1719
+ severity: 'HIGH',
1720
+ message: `JSON.${left.property.name} overridden — global API hooking to intercept all JSON serialization/deserialization.`,
1721
+ file: ctx.relFile
1722
+ });
1723
+ }
1724
+
1633
1725
  // XMLHttpRequest.prototype.send = ... or Response.prototype.json = ...
1634
1726
  if (left.object?.type === 'MemberExpression' &&
1635
1727
  left.object.property?.type === 'Identifier' &&
@@ -1723,7 +1815,7 @@ function handleMemberExpression(node, ctx) {
1723
1815
  if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) {
1724
1816
  return;
1725
1817
  }
1726
- if (ENV_SENSITIVE_KEYWORDS.some(s => envVar.toUpperCase().includes(s))) {
1818
+ if (isEnvSensitive(envVar)) {
1727
1819
  ctx.threats.push({
1728
1820
  type: 'env_access',
1729
1821
  severity: 'HIGH',
@@ -1828,6 +1920,17 @@ function handlePostWalk(ctx) {
1828
1920
  });
1829
1921
  }
1830
1922
 
1923
+ // Remote code loading: fetch + eval/Function in same file = multi-stage payload
1924
+ // Distinct from fetch_decrypt_exec which also requires crypto. This catches SVG/HTML payload extraction.
1925
+ if (ctx.hasRemoteFetch && ctx.hasDynamicExec && !ctx.hasCryptoDecipher) {
1926
+ ctx.threats.push({
1927
+ type: 'remote_code_load',
1928
+ severity: 'CRITICAL',
1929
+ message: 'Remote code loading: network fetch + dynamic eval/Function in same file — multi-stage payload execution.',
1930
+ file: ctx.relFile
1931
+ });
1932
+ }
1933
+
1831
1934
  // Wave 4: Remote fetch + crypto decrypt + dynamic eval = steganographic payload chain
1832
1935
  if (ctx.hasRemoteFetch && ctx.hasCryptoDecipher && ctx.hasDynamicExec) {
1833
1936
  ctx.threats.push({
@@ -1861,6 +1964,67 @@ function handlePostWalk(ctx) {
1861
1964
  });
1862
1965
  }
1863
1966
 
1967
+ // WASM payload detection: WebAssembly.compile/instantiate + readFileSync/https in same file
1968
+ // WASM host import objects can contain callback functions that read credentials and exfiltrate.
1969
+ // This pattern is never legitimate in npm packages — WASM should use pure computation, not host I/O.
1970
+ if (ctx.hasWasmLoad && ctx.hasNetworkCallInFile) {
1971
+ ctx.threats.push({
1972
+ type: 'wasm_host_sink',
1973
+ severity: 'CRITICAL',
1974
+ message: 'WebAssembly module with network-capable host imports. WASM can invoke host callbacks to exfiltrate data while hiding control flow.',
1975
+ file: ctx.relFile
1976
+ });
1977
+ }
1978
+
1979
+ // Credential regex harvesting: credential-matching regex + network call in same file
1980
+ // Real-world pattern: Transform/stream that scans data for tokens/passwords and exfiltrates
1981
+ if (ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
1982
+ ctx.threats.push({
1983
+ type: 'credential_regex_harvest',
1984
+ severity: 'HIGH',
1985
+ message: 'Credential regex patterns (token/password/secret/Bearer) + network call in same file — stream data credential harvesting.',
1986
+ file: ctx.relFile
1987
+ });
1988
+ }
1989
+
1990
+ // Built-in method override + network: console.X = function or Object.defineProperty = function
1991
+ // combined with network calls. Monkey-patching built-in APIs for data interception.
1992
+ if (ctx.hasBuiltinOverride && ctx.hasNetworkCallInFile) {
1993
+ ctx.threats.push({
1994
+ type: 'builtin_override_exfil',
1995
+ severity: 'HIGH',
1996
+ message: 'Built-in method override (console/Object.defineProperty) + network call — runtime API hijacking for data interception and exfiltration.',
1997
+ file: ctx.relFile
1998
+ });
1999
+ }
2000
+
2001
+ // Stream credential interception: Transform/Duplex/Writable stream + credential regex + network
2002
+ // Wiretap pattern: intercepts data in transit, scans for credentials, exfiltrates matches.
2003
+ if (ctx.hasStreamInterceptor && ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
2004
+ ctx.threats.push({
2005
+ type: 'stream_credential_intercept',
2006
+ severity: 'HIGH',
2007
+ message: 'Stream class (Transform/Duplex/Writable) with credential regex scanning + network call — data-in-transit credential wiretap.',
2008
+ file: ctx.relFile
2009
+ });
2010
+ }
2011
+
2012
+ // Proxy data interception: new Proxy(obj, { set/get }) + network in same file
2013
+ // Real-world pattern: export a Proxy that exfiltrates all property assignments via network
2014
+ // CRITICAL only when credential signals co-occur (env_access, suspicious_dataflow),
2015
+ // otherwise HIGH — bare Proxy + fetch is insufficient evidence.
2016
+ if (ctx.hasProxyTrap && ctx.hasNetworkCallInFile) {
2017
+ const hasCredentialSignal = ctx.threats.some(t =>
2018
+ t.type === 'env_access' || t.type === 'suspicious_dataflow'
2019
+ );
2020
+ ctx.threats.push({
2021
+ type: 'proxy_data_intercept',
2022
+ severity: hasCredentialSignal ? 'CRITICAL' : 'HIGH',
2023
+ message: 'Proxy trap (set/get/apply) with network call in same file — data interception and exfiltration via Proxy handler.',
2024
+ file: ctx.relFile
2025
+ });
2026
+ }
2027
+
1864
2028
  // Wave 4: MCP content keywords in file with writeFileSync = MCP injection signal
1865
2029
  if (ctx.hasMcpContentKeywords && !ctx.threats.some(t => t.type === 'mcp_config_injection')) {
1866
2030
  ctx.threats.push({
@@ -15,6 +15,24 @@ const {
15
15
  handlePostWalk
16
16
  } = require('./ast-detectors.js');
17
17
 
18
+ // Check if credential keywords appear INSIDE regex literals or new RegExp() patterns.
19
+ // Only true when the keyword is part of the regex pattern itself, not just a string elsewhere in the file.
20
+ const CREDENTIAL_REGEX_KEYWORDS = /bearer|password|secret|token|credential|api.?key/i;
21
+ function hasCredentialInsideRegex(content) {
22
+ // Check regex literals: /...pattern.../flags
23
+ const regexLiteralRe = /\/(?!\*)(?:[^/\\]|\\.)+\/[gimsuy]*/g;
24
+ let m;
25
+ while ((m = regexLiteralRe.exec(content)) !== null) {
26
+ if (CREDENTIAL_REGEX_KEYWORDS.test(m[0])) return true;
27
+ }
28
+ // Check new RegExp('pattern') — keyword must be in the string argument
29
+ const newRegExpRe = /new\s+RegExp\s*\(\s*(['"`])((?:[^\\]|\\.)*?)\1/g;
30
+ while ((m = newRegExpRe.exec(content)) !== null) {
31
+ if (CREDENTIAL_REGEX_KEYWORDS.test(m[2])) return true;
32
+ }
33
+ return false;
34
+ }
35
+
18
36
  const EXCLUDED_FILES = [
19
37
  'src/scanner/ast.js',
20
38
  'src/scanner/shell.js',
@@ -93,6 +111,15 @@ function analyzeFile(content, filePath, basePath) {
93
111
  hasEnvEnumeration: false, // Object.entries/keys/values(process.env)
94
112
  hasEnvHarvestPattern: /\b(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|NPM|AWS|SSH|WEBHOOK)\b/.test(content),
95
113
  hasNetworkCallInFile: /\b(fetch|https?\.request|https?\.get|dns\.resolve)\b/.test(content),
114
+ // Credential regex harvesting: regex literals or new RegExp() whose PATTERN contains credential keywords
115
+ // Must check that the keyword is inside the regex, not just anywhere in the file
116
+ hasCredentialRegex: hasCredentialInsideRegex(content),
117
+ // Built-in method override: console.X = function or Object.defineProperty = function
118
+ hasBuiltinOverride: /\bconsole\s*\.\s*\w+\s*=\s*function/.test(content) ||
119
+ /\bconsole\s*\[\s*\w+\s*\]\s*=\s*function/.test(content) ||
120
+ /\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
121
+ // Stream interceptor: class extending Transform/Duplex/Writable (data wiretap pattern)
122
+ hasStreamInterceptor: /\bextends\s+(Transform|Duplex|Writable)\b/.test(content),
96
123
  // SANDWORM_MODE P2: DNS exfiltration co-occurrence
97
124
  hasDnsRequire: /\brequire\s*\(\s*['"]dns['"]\s*\)/.test(content) || /\bdns\s*\.\s*resolve/.test(content),
98
125
  hasBase64Encode: /\.toString\s*\(\s*['"]base64(url)?['"]\s*\)/.test(content),
@@ -123,7 +150,11 @@ function analyzeFile(content, filePath, basePath) {
123
150
  hasModuleImport: /require\s*\(\s*['"]module['"]\s*\)/.test(content) || /module\.constructor/.test(content),
124
151
  hasMcpContentKeywords: (/\bmcpServers\b/.test(content) || /\bmcp\.json\b/.test(content) || /\bclaude_desktop_config\b/.test(content)) &&
125
152
  /\bwriteFileSync\b|\bwriteFile\s*\(/.test(content) &&
126
- (/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content))
153
+ (/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content)),
154
+ // WASM payload detection: WebAssembly.compile/instantiate with host import sinks
155
+ hasWasmLoad: /\bWebAssembly\s*\.\s*(compile|instantiate|compileStreaming|instantiateStreaming)\b/.test(content),
156
+ hasWasmHostSink: false, // set in handleCallExpression when WASM import object contains network/fs sinks
157
+ hasProxyTrap: false // set in handleNewExpression when Proxy has set/get/apply trap
127
158
  };
128
159
 
129
160
  // Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
@@ -9,9 +9,11 @@ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
9
9
  // Module classification maps for intra-file taint tracking
10
10
  const MODULE_SOURCE_METHODS = {
11
11
  os: {
12
- homedir: 'fingerprint_read', hostname: 'fingerprint_read',
12
+ homedir: 'fingerprint_read',
13
13
  networkInterfaces: 'fingerprint_read', userInfo: 'fingerprint_read',
14
- platform: 'telemetry_read', arch: 'telemetry_read'
14
+ hostname: 'telemetry_read', platform: 'telemetry_read', arch: 'telemetry_read',
15
+ type: 'telemetry_read', release: 'telemetry_read',
16
+ cpus: 'telemetry_read', totalmem: 'telemetry_read', freemem: 'telemetry_read'
15
17
  },
16
18
  fs: {
17
19
  readFileSync: 'credential_read', readFile: 'credential_read',
@@ -356,21 +358,17 @@ function analyzeFile(content, filePath, basePath) {
356
358
  }
357
359
  }
358
360
 
359
- // os.hostname(), os.networkInterfaces(), os.userInfo(), os.homedir() as fingerprint sources
360
- // os.platform(), os.arch() as telemetry sources (lower severity)
361
+ // os.* methods classified via MODULE_SOURCE_METHODS for consistent categorization
362
+ // fingerprint_read: homedir, networkInterfaces, userInfo (real exfil targets)
363
+ // telemetry_read: hostname, platform, arch, type, release, cpus, totalmem, freemem
361
364
  if (node.callee.type === 'MemberExpression') {
362
365
  const obj = node.callee.object;
363
366
  const prop = node.callee.property;
364
367
  if (obj?.type === 'Identifier' && obj.name === 'os' && prop?.type === 'Identifier') {
365
- if (['hostname', 'networkInterfaces', 'userInfo', 'homedir'].includes(prop.name)) {
368
+ const osClassification = MODULE_SOURCE_METHODS.os?.[prop.name];
369
+ if (osClassification) {
366
370
  sources.push({
367
- type: 'fingerprint_read',
368
- name: `os.${prop.name}`,
369
- line: node.loc?.start?.line
370
- });
371
- } else if (['platform', 'arch'].includes(prop.name)) {
372
- sources.push({
373
- type: 'telemetry_read',
371
+ type: osClassification,
374
372
  name: `os.${prop.name}`,
375
373
  line: node.loc?.start?.line
376
374
  });
@@ -266,7 +266,7 @@ function scanEntropy(targetPath, options = {}) {
266
266
  }
267
267
 
268
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;
269
+ const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT = 10, FRAG_ENTROPY = 5.0;
270
270
  const frags = strings.filter(s =>
271
271
  s.length >= FRAG_MIN && s.length <= FRAG_MAX &&
272
272
  !SOURCE_MAP_REGEX.test(s) && !SHA256_HEX_REGEX.test(s) && !MD5_HEX_REGEX.test(s) &&
@@ -5,7 +5,7 @@ const { findFiles, EXCLUDED_DIRS } = require('../utils');
5
5
  const { ACORN_OPTIONS: BASE_ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
6
6
 
7
7
  // --- Sensitive source patterns ---
8
- const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
8
+ const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os', 'dgram']);
9
9
 
10
10
  const ACORN_OPTIONS = {
11
11
  ...BASE_ACORN_OPTIONS,
@@ -151,10 +151,15 @@ function analyzeExports(filePath) {
151
151
 
152
152
  // Track class declarations: class Foo { ... }
153
153
  const classDefs = {};
154
+ // Track function declarations: function foo() { ... }
155
+ const funcDefs = {};
154
156
  walkAST(ast, (node) => {
155
157
  if (node.type === 'ClassDeclaration' && node.id && node.id.name) {
156
158
  classDefs[node.id.name] = node;
157
159
  }
160
+ if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
161
+ funcDefs[node.id.name] = node;
162
+ }
158
163
  });
159
164
 
160
165
  // First pass: collect require assignments, ES imports, and tainted variable assignments
@@ -309,6 +314,16 @@ function analyzeExports(filePath) {
309
314
  } else if (prop.value.type === 'Identifier' && taintedVars[prop.value.name]) {
310
315
  const t = taintedVars[prop.value.name];
311
316
  exports[propName] = { tainted: true, source: t.source, detail: t.detail };
317
+ } else if (prop.value.type === 'Identifier' && funcDefs[prop.value.name]) {
318
+ // Shorthand property referencing a FunctionDeclaration: { readConfig }
319
+ const fnNode = funcDefs[prop.value.name];
320
+ const fnBody = fnNode.body && fnNode.body.type === 'BlockStatement' ? fnNode.body.body : null;
321
+ if (fnBody) {
322
+ const bodyTaint = scanBodyForTaint(fnBody, moduleVars, taintedVars);
323
+ if (bodyTaint) {
324
+ exports[propName] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
325
+ }
326
+ }
312
327
  }
313
328
  }
314
329
  }
@@ -1081,8 +1096,283 @@ function toRel(abs, packagePath) {
1081
1096
  return path.relative(packagePath, abs).replace(/\\/g, '/');
1082
1097
  }
1083
1098
 
1099
+ // =============================================================================
1100
+ // STEP 4 — Sink export annotation (for callback-based cross-file detection)
1101
+ // =============================================================================
1102
+
1103
+ /**
1104
+ * Annotate exports that contain network/exec sinks in their function body.
1105
+ * This is the inverse of annotateTaintedExports — finds "where data goes out".
1106
+ * Used to detect callback-based cross-file exfiltration:
1107
+ * reader.js exports readConfig() (tainted source)
1108
+ * sender.js exports sendData() (sink export)
1109
+ * index.js connects them via callback: readConfig((data) => sendData(data))
1110
+ */
1111
+ function annotateSinkExports(graph, packagePath) {
1112
+ const result = {};
1113
+ for (const relFile of Object.keys(graph)) {
1114
+ const absFile = path.resolve(packagePath, relFile);
1115
+ result[relFile] = analyzeSinkExports(absFile);
1116
+ }
1117
+ return result;
1118
+ }
1119
+
1120
+ function analyzeSinkExports(filePath) {
1121
+ const ast = parseFile(filePath);
1122
+ if (!ast) return {};
1123
+
1124
+ const sinkExports = {};
1125
+
1126
+ // Track function declarations for shorthand property resolution
1127
+ const localFuncDefs = {};
1128
+ walkAST(ast, (node) => {
1129
+ if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
1130
+ localFuncDefs[node.id.name] = node;
1131
+ }
1132
+ });
1133
+
1134
+ // Collect require assignments for sink module detection
1135
+ const sinkModuleVars = {};
1136
+ walkAST(ast, (node) => {
1137
+ if (node.type === 'VariableDeclaration') {
1138
+ for (const decl of node.declarations) {
1139
+ if (!decl.init || !decl.id || decl.id.type !== 'Identifier') continue;
1140
+ if (isRequireCall(decl.init)) {
1141
+ const mod = decl.init.arguments[0].value;
1142
+ if (mod === 'http' || mod === 'https' || mod === 'net' || mod === 'dgram') {
1143
+ sinkModuleVars[decl.id.name] = mod;
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
1149
+ const mod = node.source.value;
1150
+ if (mod === 'http' || mod === 'https' || mod === 'net' || mod === 'dgram') {
1151
+ for (const spec of node.specifiers) {
1152
+ sinkModuleVars[spec.local.name] = mod;
1153
+ }
1154
+ }
1155
+ }
1156
+ });
1157
+
1158
+ function bodyHasSink(body) {
1159
+ let found = null;
1160
+ walkAST({ type: 'Program', body }, (node) => {
1161
+ if (found) return;
1162
+ if (node.type === 'CallExpression') {
1163
+ // fetch(), eval()
1164
+ if (node.callee.type === 'Identifier' && SINK_CALLEE_NAMES.has(node.callee.name)) {
1165
+ found = node.callee.name + '()';
1166
+ return;
1167
+ }
1168
+ // https.request(), http.get()
1169
+ if (node.callee.type === 'MemberExpression') {
1170
+ const chain = getMemberChain(node.callee);
1171
+ if (SINK_MEMBER_METHODS.has(chain)) {
1172
+ found = chain + '()';
1173
+ return;
1174
+ }
1175
+ // Variable-based: const h = require('https'); h.request()
1176
+ if (node.callee.object.type === 'Identifier' && sinkModuleVars[node.callee.object.name]) {
1177
+ const method = node.callee.property.name || node.callee.property.value;
1178
+ if (method === 'request' || method === 'get') {
1179
+ found = sinkModuleVars[node.callee.object.name] + '.' + method + '()';
1180
+ return;
1181
+ }
1182
+ }
1183
+ // .write(), .send(), .connect()
1184
+ const method = node.callee.property.name || node.callee.property.value;
1185
+ if (SINK_INSTANCE_METHODS.has(method)) {
1186
+ found = method + '()';
1187
+ return;
1188
+ }
1189
+ }
1190
+ }
1191
+ });
1192
+ return found;
1193
+ }
1194
+
1195
+ // Check module.exports = { fn: function() { ... sink ... } }
1196
+ walkAST(ast, (node) => {
1197
+ if (isModuleExportsAssign(node)) {
1198
+ const value = node.expression.right;
1199
+ const exportName = getExportName(node.expression.left);
1200
+
1201
+ if (value.type === 'ObjectExpression' && exportName === 'default') {
1202
+ for (const prop of value.properties) {
1203
+ if (!prop.key) continue;
1204
+ const propName = prop.key.name || prop.key.value || 'unknown';
1205
+ let funcBody = getFunctionBody(prop.value);
1206
+ // Shorthand property referencing a FunctionDeclaration: { reportData }
1207
+ if (!funcBody && prop.value.type === 'Identifier' && localFuncDefs[prop.value.name]) {
1208
+ const fnNode = localFuncDefs[prop.value.name];
1209
+ funcBody = fnNode.body && fnNode.body.type === 'BlockStatement' ? fnNode.body.body : null;
1210
+ }
1211
+ if (funcBody) {
1212
+ const sink = bodyHasSink(funcBody);
1213
+ if (sink) {
1214
+ sinkExports[propName] = { hasSink: true, sink };
1215
+ }
1216
+ }
1217
+ }
1218
+ } else {
1219
+ const funcBody = getFunctionBody(value);
1220
+ if (funcBody) {
1221
+ const sink = bodyHasSink(funcBody);
1222
+ if (sink) {
1223
+ sinkExports[exportName] = { hasSink: true, sink };
1224
+ }
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ // export function foo() { ... sink ... }
1230
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
1231
+ const decl = node.declaration;
1232
+ if (decl.type === 'FunctionDeclaration' && decl.id) {
1233
+ const funcBody = decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : null;
1234
+ if (funcBody) {
1235
+ const sink = bodyHasSink(funcBody);
1236
+ if (sink) {
1237
+ sinkExports[decl.id.name] = { hasSink: true, sink };
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ });
1243
+
1244
+ return sinkExports;
1245
+ }
1246
+
1247
+ /**
1248
+ * Detect callback-based cross-file flows.
1249
+ * Pattern: file imports tainted source fn + sink fn, connects them via callback.
1250
+ * Example: readConfig((err, data) => { sendData(data); })
1251
+ * Also: const data = readConfig(); sendData(data);
1252
+ */
1253
+ function detectCallbackCrossFileFlows(graph, taintedExports, sinkExports, packagePath) {
1254
+ const expandedTaint = expandTaintThroughReexports(graph, taintedExports, packagePath);
1255
+ const flows = [];
1256
+
1257
+ for (const relFile of Object.keys(graph)) {
1258
+ const absFile = path.resolve(packagePath, relFile);
1259
+ const ast = parseFile(absFile);
1260
+ if (!ast) continue;
1261
+
1262
+ const fileDir = path.dirname(absFile);
1263
+
1264
+ // Collect imported tainted source functions and imported sink functions
1265
+ const importedSources = {}; // varName → { sourceFile, source, detail }
1266
+ const importedSinks = {}; // varName → { sinkFile, sink }
1267
+
1268
+ walkAST(ast, (node) => {
1269
+ if (node.type !== 'VariableDeclaration') return;
1270
+ for (const decl of node.declarations) {
1271
+ if (!decl.init || !decl.id) continue;
1272
+
1273
+ // const { readConfig } = require('./reader')
1274
+ if (isRequireCall(decl.init) && isLocalImport(decl.init.arguments[0].value)) {
1275
+ const spec = decl.init.arguments[0].value;
1276
+ const resolved = resolveLocal(fileDir, spec, packagePath);
1277
+ if (!resolved) continue;
1278
+
1279
+ if (decl.id.type === 'ObjectPattern') {
1280
+ for (const prop of decl.id.properties) {
1281
+ const key = prop.key && (prop.key.name || prop.key.value);
1282
+ const localName = prop.value && prop.value.name;
1283
+ if (!key || !localName) continue;
1284
+
1285
+ // Check if this is a tainted source export
1286
+ if (expandedTaint[resolved] && expandedTaint[resolved][key] && expandedTaint[resolved][key].tainted) {
1287
+ const t = expandedTaint[resolved][key];
1288
+ importedSources[localName] = {
1289
+ sourceFile: t.sourceFile || resolved,
1290
+ source: t.source,
1291
+ detail: t.detail || ''
1292
+ };
1293
+ }
1294
+
1295
+ // Check if this is a sink export
1296
+ if (sinkExports[resolved] && sinkExports[resolved][key] && sinkExports[resolved][key].hasSink) {
1297
+ importedSinks[localName] = {
1298
+ sinkFile: resolved,
1299
+ sink: sinkExports[resolved][key].sink
1300
+ };
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ if (decl.id.type === 'Identifier') {
1306
+ // Whole module import: const reader = require('./reader')
1307
+ // Check default taint
1308
+ if (expandedTaint[resolved] && expandedTaint[resolved]['default'] && expandedTaint[resolved]['default'].tainted) {
1309
+ const t = expandedTaint[resolved]['default'];
1310
+ importedSources[decl.id.name] = {
1311
+ sourceFile: t.sourceFile || resolved,
1312
+ source: t.source,
1313
+ detail: t.detail || ''
1314
+ };
1315
+ }
1316
+ // Check default sink
1317
+ if (sinkExports[resolved] && sinkExports[resolved]['default'] && sinkExports[resolved]['default'].hasSink) {
1318
+ importedSinks[decl.id.name] = {
1319
+ sinkFile: resolved,
1320
+ sink: sinkExports[resolved]['default'].sink
1321
+ };
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+ });
1327
+
1328
+ // If we have both imported sources and sinks, check for callback connections
1329
+ if (Object.keys(importedSources).length === 0 || Object.keys(importedSinks).length === 0) continue;
1330
+
1331
+ // Pattern 1: sourceFn(function(err, data) { sinkFn(data); })
1332
+ // Pattern 2: const result = sourceFn(); sinkFn(result);
1333
+ walkAST(ast, (node) => {
1334
+ if (node.type !== 'CallExpression') return;
1335
+
1336
+ // Check if the call is to an imported source
1337
+ const calleeName = node.callee.type === 'Identifier' ? node.callee.name : null;
1338
+ if (!calleeName || !importedSources[calleeName]) return;
1339
+
1340
+ // Check if any argument is a callback that calls an imported sink
1341
+ for (const arg of node.arguments) {
1342
+ if (arg.type === 'FunctionExpression' || arg.type === 'ArrowFunctionExpression') {
1343
+ const body = arg.body.type === 'BlockStatement' ? arg.body.body : [arg.body];
1344
+ walkAST({ type: 'Program', body }, (inner) => {
1345
+ if (inner.type !== 'CallExpression') return;
1346
+ const innerCallee = inner.callee.type === 'Identifier' ? inner.callee.name : null;
1347
+ if (innerCallee && importedSinks[innerCallee]) {
1348
+ const src = importedSources[calleeName];
1349
+ const snk = importedSinks[innerCallee];
1350
+ // Avoid duplicates
1351
+ const key = `${src.sourceFile}→${relFile}→${snk.sinkFile}`;
1352
+ if (!flows.some(f => `${f.sourceFile}→${f.sinkFile}→${snk.sinkFile}` === key)) {
1353
+ flows.push({
1354
+ severity: 'CRITICAL',
1355
+ type: 'cross_file_dataflow',
1356
+ sourceFile: src.sourceFile,
1357
+ source: `${src.source}${src.detail ? '(' + src.detail + ')' : ''}`,
1358
+ sinkFile: relFile,
1359
+ sink: snk.sink,
1360
+ description: `Credential read in ${src.sourceFile} passed via callback to network sink (${snk.sink}) imported from ${snk.sinkFile} in ${relFile}`,
1361
+ });
1362
+ }
1363
+ }
1364
+ });
1365
+ }
1366
+ }
1367
+ });
1368
+ }
1369
+
1370
+ return flows;
1371
+ }
1372
+
1084
1373
  module.exports = {
1085
1374
  buildModuleGraph, annotateTaintedExports, detectCrossFileFlows,
1375
+ annotateSinkExports, detectCallbackCrossFileFlows,
1086
1376
  resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
1087
1377
  tryResolveConcatRequire
1088
1378
  };
package/src/scoring.js CHANGED
@@ -118,7 +118,9 @@ const FP_COUNT_THRESHOLDS = {
118
118
  // P4: hash algorithms contain bit manipulation that triggers obfuscation heuristics
119
119
  js_obfuscation_pattern: { maxCount: 1, from: 'HIGH', to: 'LOW' },
120
120
  // P4: bundled credential_tampering from minified alias resolution (jspdf, lerna)
121
- credential_tampering: { maxCount: 5, to: 'LOW' }
121
+ credential_tampering: { maxCount: 5, to: 'LOW' },
122
+ // B1 FP reduction: bundled code aliases eval/Function (sinon, storybook, vitest)
123
+ dangerous_call_eval: { maxCount: 3, from: 'MEDIUM', to: 'LOW' }
122
124
  };
123
125
 
124
126
  // Types exempt from dist/ downgrade — IOC matches, lifecycle scripts, and
@@ -133,12 +135,25 @@ const DIST_EXEMPT_TYPES = new Set([
133
135
  'download_exec_binary', // download + chmod + exec (binary dropper)
134
136
  'cross_file_dataflow', // credential read → network exfil across files
135
137
  'staged_eval_decode', // eval(atob(...)) (explicit payload staging)
136
- 'reverse_shell' // net.Socket + connect + pipe (always malicious)
138
+ 'reverse_shell', // net.Socket + connect + pipe (always malicious)
139
+ 'remote_code_load', // fetch + eval/Function (multi-stage payload)
140
+ 'proxy_data_intercept' // Proxy trap + network (data interception)
137
141
  ]);
138
142
 
139
143
  // Regex matching dist/build/minified/bundled file paths
140
144
  const DIST_FILE_RE = /(?:^|[/\\])(?:dist|build)[/\\]|\.min\.js$|\.bundle\.js$/i;
141
145
 
146
+ // Bundler artifact types: get two-notch downgrade in dist/ files (CRITICAL→MEDIUM, HIGH→LOW).
147
+ // These are individual pattern signals that bundlers routinely produce (eval for globalThis,
148
+ // dynamic require for code-splitting, minification obfuscation, etc.)
149
+ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
150
+ 'dangerous_call_eval', 'dangerous_call_function',
151
+ 'dynamic_require', 'dynamic_import',
152
+ 'obfuscation_detected', 'high_entropy_string', 'possible_obfuscation',
153
+ 'js_obfuscation_pattern', 'vm_code_execution',
154
+ 'module_compile', 'module_compile_dynamic'
155
+ ]);
156
+
142
157
  // Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.
143
158
  // NOTE: Uses the base IOC/lifecycle exempt set, NOT full DIST_EXEMPT_TYPES.
144
159
  // Compound detections (zlib_inflate_eval, staged_eval_decode, etc.) should still be
@@ -244,13 +259,23 @@ function applyFPReductions(threats, reachableFiles, packageName) {
244
259
  }
245
260
  }
246
261
 
247
- // Dist/build/minified files: bundler artifacts get severity downgraded one notch.
248
- // Reduced from two-notch (audit fix): 2-notch made dist/ attacks invisible (CRITICAL→MEDIUM=3pts).
262
+ // Dist/build/minified files: severity downgrade for bundler output.
249
263
  // Compound detections are exempt (DIST_EXEMPT_TYPES).
264
+ // Bundler artifact types (eval, dynamic_require, obfuscation) get two-notch downgrade
265
+ // (CRITICAL→MEDIUM, HIGH→LOW) since bundlers routinely produce these patterns.
266
+ // Other non-exempt types keep one-notch downgrade.
250
267
  if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
251
- if (t.severity === 'CRITICAL') t.severity = 'HIGH';
252
- else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
253
- else if (t.severity === 'MEDIUM') t.severity = 'LOW';
268
+ if (DIST_BUNDLER_ARTIFACT_TYPES.has(t.type)) {
269
+ // Two-notch downgrade for bundler artifacts
270
+ if (t.severity === 'CRITICAL') t.severity = 'MEDIUM';
271
+ else if (t.severity === 'HIGH') t.severity = 'LOW';
272
+ else if (t.severity === 'MEDIUM') t.severity = 'LOW';
273
+ } else {
274
+ // One-notch downgrade for other non-exempt types
275
+ if (t.severity === 'CRITICAL') t.severity = 'HIGH';
276
+ else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
277
+ else if (t.severity === 'MEDIUM') t.severity = 'LOW';
278
+ }
254
279
  }
255
280
 
256
281
  // Reachability: findings in files not reachable from entry points → LOW