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 +1 -1
- package/src/index.js +3 -2
- package/src/sandbox/analyzer.js +20 -2
- package/src/scanner/ast-detectors.js +1 -1
- package/src/scanner/dataflow.js +98 -0
- package/src/scanner/deobfuscate.js +68 -1
- package/src/scanner/module-graph.js +33 -7
- package/src/scoring.js +17 -5
- package/src/shared/download.js +87 -49
package/package.json
CHANGED
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
|
|
package/src/sandbox/analyzer.js
CHANGED
|
@@ -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
|
|
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 = [
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
77
|
-
|
|
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 <
|
|
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.
|
|
287
|
-
|
|
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
|
-
//
|
|
301
|
+
// 7. Old global score for comparison (sum of ALL findings)
|
|
290
302
|
const globalRiskScore = computeGroupScore(deduped);
|
|
291
303
|
|
|
292
|
-
//
|
|
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
|
}
|
package/src/shared/download.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
reject(
|
|
175
|
+
req.on('error', reject);
|
|
176
|
+
req.on('timeout', () => {
|
|
177
|
+
req.destroy();
|
|
178
|
+
reject(new Error(`Timeout downloading ${requestUrl}`));
|
|
138
179
|
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
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
|
};
|