muaddib-scanner 2.5.4 → 2.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.4",
3
+ "version": "2.5.5",
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
@@ -27,7 +27,7 @@ const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = requi
27
27
  const { computeReachableFiles } = require('./scanner/reachability.js');
28
28
  const { runTemporalAnalyses } = require('./temporal-runner.js');
29
29
  const { formatOutput } = require('./output-formatter.js');
30
- const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache } = require('./utils.js');
30
+ const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache, debugLog } = require('./utils.js');
31
31
  const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore } = require('./scoring.js');
32
32
 
33
33
  const { MAX_FILE_SIZE } = require('./shared/constants.js');
@@ -218,8 +218,9 @@ async function run(targetPath, options = {}) {
218
218
  const graph = await yieldThen(() => buildModuleGraph(targetPath));
219
219
  const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
220
220
  crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
221
- } catch {
221
+ } catch (e) {
222
222
  // Graceful fallback — module graph is best-effort
223
+ debugLog('[MODULE-GRAPH] Error:', e && e.message);
223
224
  }
224
225
  }
225
226
 
@@ -22,6 +22,24 @@ const TWENTY_FOUR_HOURS_MS = 24 * ONE_HOUR_MS;
22
22
  * @param {string} logContent - Raw preload log content
23
23
  * @returns {{ score: number, findings: Array<{type: string, severity: string, detail: string, evidence: string}> }}
24
24
  */
25
+ /**
26
+ * Validate that a log line has the expected [PRELOAD] CATEGORY: format.
27
+ * Rejects lines that don't match the expected structure to prevent
28
+ * log injection attacks where malware injects fake preload log lines.
29
+ */
30
+ const VALID_CATEGORIES = new Set([
31
+ 'INIT', 'TIME', 'TIMER', 'NETWORK', 'FS_READ', 'FS_WRITE',
32
+ 'EXEC', 'ENV_ACCESS', 'NATIVE_ADDON', 'WORKER'
33
+ ]);
34
+
35
+ function isValidPreloadLine(line) {
36
+ if (!line || !line.includes('[PRELOAD]')) return false;
37
+ // Must match format: [PRELOAD] CATEGORY: ... (t+NNNms)
38
+ const match = line.match(/^\[PRELOAD\]\s+(\w+):/);
39
+ if (!match) return false;
40
+ return VALID_CATEGORIES.has(match[1]);
41
+ }
42
+
25
43
  function analyzePreloadLog(logContent) {
26
44
  const findings = [];
27
45
  let score = 0;
@@ -30,7 +48,7 @@ function analyzePreloadLog(logContent) {
30
48
  return { score: 0, findings: [] };
31
49
  }
32
50
 
33
- const lines = logContent.split('\n').filter(l => l.includes('[PRELOAD]'));
51
+ const lines = logContent.split('\n').filter(l => isValidPreloadLine(l));
34
52
 
35
53
  // Categorize lines
36
54
  const timerLines = [];
@@ -201,4 +219,4 @@ function analyzePreloadLog(logContent) {
201
219
  };
202
220
  }
203
221
 
204
- module.exports = { analyzePreloadLog };
222
+ module.exports = { analyzePreloadLog, isValidPreloadLine };
@@ -30,7 +30,7 @@ const SAFE_ENV_VARS = [
30
30
  ];
31
31
 
32
32
  // Env var prefixes that are safe (npm metadata, locale settings)
33
- const SAFE_ENV_PREFIXES = ['npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_'];
33
+ const SAFE_ENV_PREFIXES = ['npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_'];
34
34
 
35
35
  // Env var keywords to detect sensitive environment access (separate from SENSITIVE_STRINGS)
36
36
  const ENV_SENSITIVE_KEYWORDS = [
@@ -140,7 +140,26 @@ function analyzeFile(content, filePath, basePath) {
140
140
  // Track exec calls whose result is captured (for command_output source detection)
141
141
  const execResultNodes = new Set();
142
142
 
143
+ // Fix #22: EventEmitter tracking — detect tainted emit → on patterns
144
+ const eventHandlers = new Map(); // eventName → { hasNetworkSink: boolean }
145
+ const emitTaintedEvents = new Set(); // event names emitted with tainted data
146
+
147
+ // Fix #23: Function param tainting — track function declarations
148
+ const functionDefs = new Map(); // functionName → { params: [paramNames] }
149
+
143
150
  walk.simple(ast, {
151
+ FunctionDeclaration(node) {
152
+ // Fix #23: Track function declarations for param tainting
153
+ if (node.id && node.id.name && node.params) {
154
+ const paramNames = node.params
155
+ .filter(p => p.type === 'Identifier')
156
+ .map(p => p.name);
157
+ if (paramNames.length > 0) {
158
+ functionDefs.set(node.id.name, { params: paramNames });
159
+ }
160
+ }
161
+ },
162
+
144
163
  VariableDeclarator(node) {
145
164
  if (node.id?.type === 'Identifier' && node.init) {
146
165
  if (containsSensitiveLiteral(node.init)) {
@@ -373,6 +392,62 @@ function analyzeFile(content, filePath, basePath) {
373
392
  }
374
393
  }
375
394
 
395
+ // Fix #22: EventEmitter tracking
396
+ if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
397
+ const methodName = node.callee.property.name;
398
+
399
+ // Track .on('eventName', handler) — check if handler has network sink
400
+ if (methodName === 'on' && node.arguments.length >= 2) {
401
+ const eventArg = node.arguments[0];
402
+ if (eventArg.type === 'Literal' && typeof eventArg.value === 'string') {
403
+ const handler = node.arguments[1];
404
+ // Check if the handler body contains network sinks
405
+ let handlerHasSink = false;
406
+ if (handler.type === 'FunctionExpression' || handler.type === 'ArrowFunctionExpression') {
407
+ const bodyStr = content.slice(handler.start, handler.end);
408
+ handlerHasSink = /\b(request|fetch|https?\.get|https?\.request|dns\.resolve)\b/.test(bodyStr);
409
+ }
410
+ eventHandlers.set(eventArg.value, { hasNetworkSink: handlerHasSink });
411
+ }
412
+ }
413
+
414
+ // Track .emit('eventName', taintedData) — check if emitted data is tainted
415
+ if (methodName === 'emit' && node.arguments.length >= 2) {
416
+ const eventArg = node.arguments[0];
417
+ if (eventArg.type === 'Literal' && typeof eventArg.value === 'string') {
418
+ const dataArg = node.arguments[1];
419
+ if (dataArg.type === 'Identifier' && sensitivePathVars.has(dataArg.name)) {
420
+ emitTaintedEvents.add(eventArg.value);
421
+ }
422
+ // Also check taintMap
423
+ if (dataArg.type === 'Identifier') {
424
+ const taint = taintMap.get(dataArg.name);
425
+ if (taint && (taint.source === 'process.env' || MODULE_SOURCE_METHODS[taint.source])) {
426
+ emitTaintedEvents.add(eventArg.value);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ // Fix #23: Function param tainting — propagate taint through function calls
434
+ if (node.callee.type === 'Identifier' && functionDefs.has(node.callee.name)) {
435
+ const funcDef = functionDefs.get(node.callee.name);
436
+ for (let i = 0; i < node.arguments.length && i < funcDef.params.length; i++) {
437
+ const arg = node.arguments[i];
438
+ if (arg.type === 'Identifier') {
439
+ // Check if argument is tainted
440
+ const argTaint = taintMap.get(arg.name);
441
+ if (argTaint && (argTaint.source === 'process.env' || MODULE_SOURCE_METHODS[argTaint.source])) {
442
+ sensitivePathVars.add(funcDef.params[i]);
443
+ }
444
+ if (sensitivePathVars.has(arg.name)) {
445
+ sensitivePathVars.add(funcDef.params[i]);
446
+ }
447
+ }
448
+ }
449
+ }
450
+
376
451
  // Exec callback: exec('cmd', (err, stdout) => {...}) — output will be used
377
452
  if (!execResultNodes.has(node) && node.arguments.length >= 2) {
378
453
  const lastArg = node.arguments[node.arguments.length - 1];
@@ -471,6 +546,25 @@ function analyzeFile(content, filePath, basePath) {
471
546
  }
472
547
  });
473
548
 
549
+ // Fix #22: EventEmitter compound detection
550
+ for (const eventName of emitTaintedEvents) {
551
+ const handler = eventHandlers.get(eventName);
552
+ if (handler && handler.hasNetworkSink) {
553
+ sources.push({
554
+ type: 'credential_read',
555
+ name: `EventEmitter.emit('${eventName}')`,
556
+ line: 0,
557
+ taint_tracked: true
558
+ });
559
+ sinks.push({
560
+ type: 'network_send',
561
+ name: `EventEmitter.on('${eventName}') handler`,
562
+ line: 0,
563
+ taint_tracked: true
564
+ });
565
+ }
566
+ }
567
+
474
568
  // Check if any source or sink was resolved via taint tracking
475
569
  const hasTaintTracked = sources.some(s => s.taint_tracked) || sinks.some(s => s.taint_tracked);
476
570
 
@@ -613,9 +707,13 @@ const SYSTEM_IDENTITY_ENVS = new Set([
613
707
  'USERPROFILE', 'COMPUTERNAME', 'WHOAMI'
614
708
  ]);
615
709
 
710
+ // Env var prefixes for tool-internal configuration (not external credentials)
711
+ const SAFE_ENV_PREFIXES = ['MUADDIB_', 'npm_config_', 'npm_lifecycle_', 'npm_package_'];
712
+
616
713
  function isSensitiveEnv(name) {
617
714
  const upper = name.toUpperCase();
618
715
  if (SYSTEM_IDENTITY_ENVS.has(upper)) return true;
716
+ if (SAFE_ENV_PREFIXES.some(p => upper.startsWith(p))) return false;
619
717
  const sensitive = ['TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'NPM', 'AWS', 'AZURE', 'GCP'];
620
718
  return sensitive.some(s => upper.includes(s));
621
719
  }
@@ -581,4 +581,71 @@ function isPrintable(str) {
581
581
  return (controlCount / str.length) < 0.2;
582
582
  }
583
583
 
584
- module.exports = { deobfuscate };
584
+ /**
585
+ * Detect control flow flattening obfuscation pattern.
586
+ * Pattern: while(true/1) { switch(var) { case N: ...; var = M; break; ... } }
587
+ * Returns true if the pattern is detected.
588
+ * @param {string} sourceCode — raw JS source
589
+ * @returns {boolean}
590
+ */
591
+ function detectControlFlowFlattening(sourceCode) {
592
+ const ast = safeParse(sourceCode, { ranges: true });
593
+ if (!ast) return false;
594
+
595
+ let found = false;
596
+ walk.simple(ast, {
597
+ WhileStatement(node) {
598
+ if (found) return;
599
+ // Check for while(true) or while(1)
600
+ const test = node.test;
601
+ const isInfinite = (test.type === 'Literal' && (test.value === true || test.value === 1))
602
+ || (test.type === 'Identifier' && test.name === 'true');
603
+ if (!isInfinite) return;
604
+
605
+ // Body should contain a SwitchStatement
606
+ const body = node.body;
607
+ let switchNode = null;
608
+ if (body.type === 'SwitchStatement') {
609
+ switchNode = body;
610
+ } else if (body.type === 'BlockStatement' && body.body) {
611
+ switchNode = body.body.find(s => s.type === 'SwitchStatement');
612
+ }
613
+ if (!switchNode || !switchNode.cases) return;
614
+
615
+ // Need at least 3 cases for CFF pattern
616
+ if (switchNode.cases.length < 3) return;
617
+
618
+ // Check for state variable reassignment in at least 2 cases
619
+ const discriminant = switchNode.discriminant;
620
+ if (!discriminant) return;
621
+ let stateVarName = null;
622
+ if (discriminant.type === 'Identifier') {
623
+ stateVarName = discriminant.name;
624
+ } else if (discriminant.type === 'MemberExpression' && discriminant.property?.type === 'Identifier') {
625
+ stateVarName = discriminant.property.name;
626
+ }
627
+ if (!stateVarName) return;
628
+
629
+ // Count cases that reassign the state variable
630
+ let reassignCount = 0;
631
+ for (const c of switchNode.cases) {
632
+ if (!c.consequent) continue;
633
+ const caseSource = sourceCode.slice(c.start, c.end);
634
+ // Look for stateVar = <number> pattern
635
+ const reassignRe = new RegExp('\\b' + stateVarName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*\\d+');
636
+ if (reassignRe.test(caseSource)) {
637
+ reassignCount++;
638
+ }
639
+ }
640
+
641
+ // CFF pattern: at least 2 cases reassign the state variable
642
+ if (reassignCount >= 2) {
643
+ found = true;
644
+ }
645
+ }
646
+ });
647
+
648
+ return found;
649
+ }
650
+
651
+ module.exports = { deobfuscate, detectControlFlowFlattening };
@@ -63,18 +63,43 @@ function extractLocalImports(filePath, packagePath) {
63
63
  return [...new Set(imports)];
64
64
  }
65
65
 
66
+ /**
67
+ * Try to resolve string concatenation in require arguments.
68
+ * require('./a' + '/b') → './a/b'
69
+ * @param {Object} node - BinaryExpression AST node
70
+ * @returns {string|null} Resolved string or null
71
+ */
72
+ function tryResolveConcatRequire(node, depth) {
73
+ if (depth === undefined) depth = 0;
74
+ if (depth > 20) return null;
75
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
76
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
77
+ const left = tryResolveConcatRequire(node.left, depth + 1);
78
+ if (left === null) return null;
79
+ const right = tryResolveConcatRequire(node.right, depth + 1);
80
+ if (right === null) return null;
81
+ return left + right;
82
+ }
83
+ return null;
84
+ }
85
+
66
86
  function walkForRequires(node, fileDir, packagePath, imports) {
67
87
  if (!node || typeof node !== 'object') return;
68
88
  if (
69
89
  node.type === 'CallExpression' &&
70
90
  node.callee && node.callee.type === 'Identifier' &&
71
91
  node.callee.name === 'require' &&
72
- node.arguments.length === 1 &&
73
- node.arguments[0].type === 'Literal' &&
74
- typeof node.arguments[0].value === 'string'
92
+ node.arguments.length === 1
75
93
  ) {
76
- const spec = node.arguments[0].value;
77
- if (isLocalImport(spec)) {
94
+ const arg = node.arguments[0];
95
+ let spec = null;
96
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
97
+ spec = arg.value;
98
+ } else if (arg.type === 'BinaryExpression') {
99
+ // Fix #25: Resolve simple string concatenation in require args
100
+ spec = tryResolveConcatRequire(arg);
101
+ }
102
+ if (spec && isLocalImport(spec)) {
78
103
  const resolved = resolveLocal(fileDir, spec, packagePath);
79
104
  if (resolved) imports.push(resolved);
80
105
  }
@@ -420,7 +445,7 @@ function expandTaintThroughReexports(graph, taintedExports, packagePath) {
420
445
  expanded[f] = { ...taintedExports[f] };
421
446
  }
422
447
 
423
- for (let level = 0; level < 2; level++) {
448
+ for (let level = 0; level < 4; level++) {
424
449
  let changed = false;
425
450
  for (const relFile of Object.keys(graph)) {
426
451
  const absFile = path.resolve(packagePath, relFile);
@@ -878,5 +903,6 @@ function toRel(abs, packagePath) {
878
903
 
879
904
  module.exports = {
880
905
  buildModuleGraph, annotateTaintedExports, detectCrossFileFlows,
881
- resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists
906
+ resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
907
+ tryResolveConcatRequire
882
908
  };
package/src/scoring.js CHANGED
@@ -283,13 +283,25 @@ function calculateRiskScore(deduped) {
283
283
  // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
284
284
  const packageScore = computeGroupScore(packageLevelThreats);
285
285
 
286
- // 5. Final score = max file score + package-level score, capped at 100
287
- const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + packageScore);
286
+ // 5. Cross-file bonus: aggregate signal from non-max files
287
+ // A package with 3 files each scoring 20 is more suspicious than 1 file scoring 20.
288
+ // Add 25% of each non-max file's score as a bonus, capped at 25.
289
+ const sortedScores = Object.values(fileScores).sort((a, b) => b - a);
290
+ let crossFileBonus = 0;
291
+ if (sortedScores.length > 1) {
292
+ for (let i = 1; i < sortedScores.length; i++) {
293
+ crossFileBonus += Math.ceil(sortedScores[i] * 0.25);
294
+ }
295
+ crossFileBonus = Math.min(crossFileBonus, 25);
296
+ }
297
+
298
+ // 6. Final score = max file score + cross-file bonus + package-level score, capped at 100
299
+ const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + packageScore);
288
300
 
289
- // 6. Old global score for comparison (sum of ALL findings)
301
+ // 7. Old global score for comparison (sum of ALL findings)
290
302
  const globalRiskScore = computeGroupScore(deduped);
291
303
 
292
- // 7. Severity counts (global, for summary display)
304
+ // 8. Severity counts (global, for summary display)
293
305
  const criticalCount = deduped.filter(t => t.severity === 'CRITICAL').length;
294
306
  const highCount = deduped.filter(t => t.severity === 'HIGH').length;
295
307
  const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
@@ -303,7 +315,7 @@ function calculateRiskScore(deduped) {
303
315
 
304
316
  return {
305
317
  riskScore, riskLevel, globalRiskScore,
306
- maxFileScore, packageScore, mostSuspiciousFile, fileScores,
318
+ maxFileScore, crossFileBonus, packageScore, mostSuspiciousFile, fileScores,
307
319
  criticalCount, highCount, mediumCount, lowCount
308
320
  };
309
321
  }
@@ -82,6 +82,38 @@ function isAllowedDownloadRedirect(redirectUrl) {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Check if an IP address is private/internal.
87
+ */
88
+ function isPrivateIP(ip) {
89
+ const normalized = normalizeHostname(ip);
90
+ return PRIVATE_IP_PATTERNS.some(p => p.test(normalized));
91
+ }
92
+
93
+ /**
94
+ * Resolve hostname to IP and validate it's not a private address.
95
+ * Prevents DNS rebinding attacks where a domain initially resolves to
96
+ * a public IP but later rebinds to a private IP.
97
+ */
98
+ async function safeDnsResolve(hostname) {
99
+ // Skip for IP addresses (already validated in isAllowedDownloadRedirect)
100
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
101
+ if (isPrivateIP(hostname)) throw new Error(`DNS rebinding blocked: ${hostname} is private`);
102
+ return hostname;
103
+ }
104
+ const dns = require('dns');
105
+ const addresses = await dns.promises.resolve4(hostname);
106
+ if (!addresses || addresses.length === 0) {
107
+ throw new Error(`DNS resolution failed for ${hostname}`);
108
+ }
109
+ for (const addr of addresses) {
110
+ if (isPrivateIP(addr)) {
111
+ throw new Error(`DNS rebinding blocked: ${hostname} resolved to private IP ${addr}`);
112
+ }
113
+ }
114
+ return addresses[0];
115
+ }
116
+
85
117
  /**
86
118
  * Download a file from HTTPS URL to disk, with SSRF-safe redirect handling.
87
119
  * @param {string} url - Source URL (must be HTTPS)
@@ -90,60 +122,64 @@ function isAllowedDownloadRedirect(redirectUrl) {
90
122
  * @returns {Promise<number>} Number of bytes downloaded
91
123
  */
92
124
  function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
93
- return new Promise((resolve, reject) => {
94
- const doRequest = (requestUrl) => {
95
- const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
96
- if (res.statusCode === 301 || res.statusCode === 302) {
97
- res.resume();
98
- const location = res.headers.location;
99
- if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
100
- // Resolve relative redirects against the request URL
101
- const absoluteLocation = new URL(location, requestUrl).href;
102
- const check = isAllowedDownloadRedirect(absoluteLocation);
103
- if (!check.allowed) {
104
- return reject(new Error(check.error));
125
+ // DNS rebinding protection: validate hostname before connecting
126
+ const parsedUrl = new URL(url);
127
+ return safeDnsResolve(parsedUrl.hostname).then(() => {
128
+ return new Promise((resolve, reject) => {
129
+ const doRequest = (requestUrl) => {
130
+ const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
131
+ if (res.statusCode === 301 || res.statusCode === 302) {
132
+ res.resume();
133
+ const location = res.headers.location;
134
+ if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
135
+ // Resolve relative redirects against the request URL
136
+ const absoluteLocation = new URL(location, requestUrl).href;
137
+ const check = isAllowedDownloadRedirect(absoluteLocation);
138
+ if (!check.allowed) {
139
+ return reject(new Error(check.error));
140
+ }
141
+ return doRequest(absoluteLocation);
142
+ }
143
+ if (res.statusCode < 200 || res.statusCode >= 300) {
144
+ res.resume();
145
+ return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
105
146
  }
106
- return doRequest(absoluteLocation);
107
- }
108
- if (res.statusCode < 200 || res.statusCode >= 300) {
109
- res.resume();
110
- return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
111
- }
112
- const contentLength = parseInt(res.headers['content-length'], 10);
113
- if (contentLength && contentLength > MAX_TARBALL_SIZE) {
114
- res.resume();
115
- return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
116
- }
117
- const fileStream = fs.createWriteStream(destPath);
118
- let downloadedBytes = 0;
119
- res.on('data', (chunk) => {
120
- downloadedBytes += chunk.length;
121
- if (downloadedBytes > MAX_TARBALL_SIZE) {
122
- res.destroy();
147
+ const contentLength = parseInt(res.headers['content-length'], 10);
148
+ if (contentLength && contentLength > MAX_TARBALL_SIZE) {
149
+ res.resume();
150
+ return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
151
+ }
152
+ const fileStream = fs.createWriteStream(destPath);
153
+ let downloadedBytes = 0;
154
+ res.on('data', (chunk) => {
155
+ downloadedBytes += chunk.length;
156
+ if (downloadedBytes > MAX_TARBALL_SIZE) {
157
+ res.destroy();
158
+ fileStream.destroy();
159
+ try { fs.unlinkSync(destPath); } catch {}
160
+ reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
161
+ }
162
+ });
163
+ res.pipe(fileStream);
164
+ fileStream.on('finish', () => resolve(downloadedBytes));
165
+ fileStream.on('error', (err) => {
166
+ try { fs.unlinkSync(destPath); } catch {}
167
+ reject(err);
168
+ });
169
+ res.on('error', (err) => {
123
170
  fileStream.destroy();
124
171
  try { fs.unlinkSync(destPath); } catch {}
125
- reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
126
- }
127
- });
128
- res.pipe(fileStream);
129
- fileStream.on('finish', () => resolve(downloadedBytes));
130
- fileStream.on('error', (err) => {
131
- try { fs.unlinkSync(destPath); } catch {}
132
- reject(err);
172
+ reject(err);
173
+ });
133
174
  });
134
- res.on('error', (err) => {
135
- fileStream.destroy();
136
- try { fs.unlinkSync(destPath); } catch {}
137
- reject(err);
175
+ req.on('error', reject);
176
+ req.on('timeout', () => {
177
+ req.destroy();
178
+ reject(new Error(`Timeout downloading ${requestUrl}`));
138
179
  });
139
- });
140
- req.on('error', reject);
141
- req.on('timeout', () => {
142
- req.destroy();
143
- reject(new Error(`Timeout downloading ${requestUrl}`));
144
- });
145
- };
146
- doRequest(url);
180
+ };
181
+ doRequest(url);
182
+ });
147
183
  });
148
184
  }
149
185
 
@@ -204,6 +240,8 @@ module.exports = {
204
240
  sanitizePackageName,
205
241
  isAllowedDownloadRedirect,
206
242
  normalizeHostname,
243
+ isPrivateIP,
244
+ safeDnsResolve,
207
245
  ALLOWED_DOWNLOAD_DOMAINS,
208
246
  PRIVATE_IP_PATTERNS
209
247
  };