muaddib-scanner 2.5.3 → 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 +43 -3
- package/src/scanner/ast-detectors.js +161 -1
- package/src/scanner/ast.js +3 -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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* MUAD'DIB Sandbox Preload Log Analyzer
|
|
3
3
|
*
|
|
4
4
|
* Parses [PRELOAD] log lines produced by docker/preload.js and generates
|
|
5
|
-
* scored findings for behavioral analysis.
|
|
5
|
+
* scored findings for behavioral analysis. Seven detection rules:
|
|
6
6
|
*
|
|
7
7
|
* 1. sandbox_timer_delay_suspicious — timer delay > 1h (MEDIUM, +15)
|
|
8
8
|
* 2. sandbox_timer_delay_critical — timer delay > 24h (CRITICAL, +30, supersedes #1)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 4. sandbox_network_after_sensitive_read — network call after sensitive read (CRITICAL, +40)
|
|
11
11
|
* 5. sandbox_exec_suspicious — dangerous command execution (HIGH, +25)
|
|
12
12
|
* 6. sandbox_env_token_access — sensitive env var access (MEDIUM, +10)
|
|
13
|
+
* 7. sandbox_native_addon_load — native .node addon loaded (MEDIUM, +15)
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
const ONE_HOUR_MS = 3600000;
|
|
@@ -21,6 +22,24 @@ const TWENTY_FOUR_HOURS_MS = 24 * ONE_HOUR_MS;
|
|
|
21
22
|
* @param {string} logContent - Raw preload log content
|
|
22
23
|
* @returns {{ score: number, findings: Array<{type: string, severity: string, detail: string, evidence: string}> }}
|
|
23
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
|
+
|
|
24
43
|
function analyzePreloadLog(logContent) {
|
|
25
44
|
const findings = [];
|
|
26
45
|
let score = 0;
|
|
@@ -29,7 +48,7 @@ function analyzePreloadLog(logContent) {
|
|
|
29
48
|
return { score: 0, findings: [] };
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
const lines = logContent.split('\n').filter(l => l
|
|
51
|
+
const lines = logContent.split('\n').filter(l => isValidPreloadLine(l));
|
|
33
52
|
|
|
34
53
|
// Categorize lines
|
|
35
54
|
const timerLines = [];
|
|
@@ -38,6 +57,7 @@ function analyzePreloadLog(logContent) {
|
|
|
38
57
|
const networkLines = [];
|
|
39
58
|
const execLines = [];
|
|
40
59
|
const envLines = [];
|
|
60
|
+
const nativeAddonLines = [];
|
|
41
61
|
|
|
42
62
|
for (const line of lines) {
|
|
43
63
|
if (line.includes('TIMER:')) {
|
|
@@ -52,6 +72,8 @@ function analyzePreloadLog(logContent) {
|
|
|
52
72
|
execLines.push(line);
|
|
53
73
|
} else if (line.includes('ENV_ACCESS:')) {
|
|
54
74
|
envLines.push(line);
|
|
75
|
+
} else if (line.includes('NATIVE_ADDON:')) {
|
|
76
|
+
nativeAddonLines.push(line);
|
|
55
77
|
}
|
|
56
78
|
}
|
|
57
79
|
|
|
@@ -173,10 +195,28 @@ function analyzePreloadLog(logContent) {
|
|
|
173
195
|
});
|
|
174
196
|
}
|
|
175
197
|
|
|
198
|
+
// ── Rule 7: Native addon loading ──
|
|
199
|
+
// Native addons (.node files) can bypass all JS monkey-patches via syscalls.
|
|
200
|
+
// Flag their loading so analysts know time-based evasion may be undetected.
|
|
201
|
+
if (nativeAddonLines.length > 0) {
|
|
202
|
+
const addons = nativeAddonLines.map(l => {
|
|
203
|
+
const m = l.match(/process\.dlopen:\s*(.+?)(?:\s+\(t\+|$)/);
|
|
204
|
+
return m ? m[1].trim() : 'unknown';
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
score += 15;
|
|
208
|
+
findings.push({
|
|
209
|
+
type: 'sandbox_native_addon_load',
|
|
210
|
+
severity: 'MEDIUM',
|
|
211
|
+
detail: `Native addon loaded (${addons.length}): time-based evasion via syscalls possible`,
|
|
212
|
+
evidence: addons.join(', ')
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
176
216
|
return {
|
|
177
217
|
score: Math.min(100, score),
|
|
178
218
|
findings
|
|
179
219
|
};
|
|
180
220
|
}
|
|
181
221
|
|
|
182
|
-
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 = [
|
|
@@ -862,6 +862,81 @@ function handleCallExpression(node, ctx) {
|
|
|
862
862
|
}
|
|
863
863
|
}
|
|
864
864
|
|
|
865
|
+
// Detect eval.call(null, code) / eval.apply(null, [code]) / Function.call/apply
|
|
866
|
+
if (node.callee.type === 'MemberExpression' && !node.callee.computed &&
|
|
867
|
+
node.callee.property?.type === 'Identifier' &&
|
|
868
|
+
(node.callee.property.name === 'call' || node.callee.property.name === 'apply')) {
|
|
869
|
+
const obj = node.callee.object;
|
|
870
|
+
if (obj?.type === 'Identifier' && (obj.name === 'eval' || obj.name === 'Function')) {
|
|
871
|
+
ctx.hasEvalInFile = true;
|
|
872
|
+
ctx.hasDynamicExec = true;
|
|
873
|
+
ctx.threats.push({
|
|
874
|
+
type: obj.name === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
|
|
875
|
+
severity: 'HIGH',
|
|
876
|
+
message: `${obj.name}.${node.callee.property.name}() — indirect execution via call/apply evasion technique.`,
|
|
877
|
+
file: ctx.relFile
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Detect array access pattern: [require][0]('child_process') or [eval][0](code)
|
|
883
|
+
if (node.callee.type === 'MemberExpression' && node.callee.computed &&
|
|
884
|
+
node.callee.object?.type === 'ArrayExpression' &&
|
|
885
|
+
node.callee.property?.type === 'Literal' && typeof node.callee.property.value === 'number') {
|
|
886
|
+
const elements = node.callee.object.elements;
|
|
887
|
+
for (const el of elements) {
|
|
888
|
+
if (el?.type === 'Identifier') {
|
|
889
|
+
if (el.name === 'eval') {
|
|
890
|
+
ctx.hasEvalInFile = true;
|
|
891
|
+
ctx.hasDynamicExec = true;
|
|
892
|
+
ctx.threats.push({
|
|
893
|
+
type: 'dangerous_call_eval',
|
|
894
|
+
severity: 'HIGH',
|
|
895
|
+
message: '[eval][0]() — array access evasion technique for indirect eval execution.',
|
|
896
|
+
file: ctx.relFile
|
|
897
|
+
});
|
|
898
|
+
} else if (el.name === 'require') {
|
|
899
|
+
ctx.threats.push({
|
|
900
|
+
type: 'dynamic_require',
|
|
901
|
+
severity: 'HIGH',
|
|
902
|
+
message: '[require][0]() — array access evasion technique for indirect require.',
|
|
903
|
+
file: ctx.relFile
|
|
904
|
+
});
|
|
905
|
+
} else if (el.name === 'Function') {
|
|
906
|
+
ctx.hasDynamicExec = true;
|
|
907
|
+
ctx.threats.push({
|
|
908
|
+
type: 'dangerous_call_function',
|
|
909
|
+
severity: 'MEDIUM',
|
|
910
|
+
message: '[Function][0]() — array access evasion technique for indirect Function construction.',
|
|
911
|
+
file: ctx.relFile
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Detect new Proxy(require, handler) — proxy wrapping require to intercept module loading
|
|
919
|
+
if (node.callee.type === 'Identifier' && node.callee.name !== 'Proxy') {
|
|
920
|
+
// handled below in handleNewExpression
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Detect template literals in exec/execSync: execSync(`${cmd}`)
|
|
924
|
+
if ((execName || memberExec) && node.arguments.length > 0) {
|
|
925
|
+
const arg = node.arguments[0];
|
|
926
|
+
if (arg.type === 'TemplateLiteral' && arg.expressions.length > 0) {
|
|
927
|
+
// Template literal with dynamic expressions in exec — bypass for string matching
|
|
928
|
+
const staticParts = arg.quasis.map(q => q.value.raw).join('');
|
|
929
|
+
if (DANGEROUS_CMD_PATTERNS.some(p => p.test(staticParts))) {
|
|
930
|
+
ctx.threats.push({
|
|
931
|
+
type: 'dangerous_exec',
|
|
932
|
+
severity: 'CRITICAL',
|
|
933
|
+
message: `Dangerous command in template literal exec(): "${staticParts.substring(0, 80)}" — template literal evasion.`,
|
|
934
|
+
file: ctx.relFile
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
865
940
|
// Detect indirect eval/Function via computed property
|
|
866
941
|
if (node.callee.type === 'MemberExpression' && node.callee.computed) {
|
|
867
942
|
const prop = node.callee.property;
|
|
@@ -1058,6 +1133,15 @@ function handleNewExpression(node, ctx) {
|
|
|
1058
1133
|
file: ctx.relFile
|
|
1059
1134
|
});
|
|
1060
1135
|
}
|
|
1136
|
+
// Detect new Proxy(require, handler) — intercept module loading
|
|
1137
|
+
if (target.type === 'Identifier' && target.name === 'require') {
|
|
1138
|
+
ctx.threats.push({
|
|
1139
|
+
type: 'dynamic_require',
|
|
1140
|
+
severity: 'HIGH',
|
|
1141
|
+
message: 'new Proxy(require) — proxy wrapping require to intercept/redirect module loading.',
|
|
1142
|
+
file: ctx.relFile
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1061
1145
|
}
|
|
1062
1146
|
}
|
|
1063
1147
|
|
|
@@ -1119,6 +1203,54 @@ function handleLiteral(node, ctx) {
|
|
|
1119
1203
|
}
|
|
1120
1204
|
|
|
1121
1205
|
function handleAssignmentExpression(node, ctx) {
|
|
1206
|
+
// Detect object property indirection: obj.exec = require('child_process').exec
|
|
1207
|
+
// or obj.fn = eval — stashing dangerous functions in object properties
|
|
1208
|
+
if (node.left?.type === 'MemberExpression' && node.right) {
|
|
1209
|
+
const propName = node.left.property?.type === 'Identifier' ? node.left.property.name :
|
|
1210
|
+
(node.left.property?.type === 'Literal' ? String(node.left.property.value) : null);
|
|
1211
|
+
|
|
1212
|
+
if (propName) {
|
|
1213
|
+
// Assigning require('child_process') or its methods to an object property
|
|
1214
|
+
if (node.right.type === 'CallExpression' && getCallName(node.right) === 'require' &&
|
|
1215
|
+
node.right.arguments.length > 0 && node.right.arguments[0]?.type === 'Literal') {
|
|
1216
|
+
const mod = node.right.arguments[0].value;
|
|
1217
|
+
if (mod === 'child_process' || mod === 'fs' || mod === 'net' || mod === 'dns') {
|
|
1218
|
+
ctx.threats.push({
|
|
1219
|
+
type: 'dynamic_require',
|
|
1220
|
+
severity: 'HIGH',
|
|
1221
|
+
message: `Object property indirection: ${propName} = require('${mod}') — hiding dangerous module in object property.`,
|
|
1222
|
+
file: ctx.relFile
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
// Assigning require('child_process').exec to an object property
|
|
1227
|
+
if (node.right.type === 'MemberExpression' && node.right.object?.type === 'CallExpression' &&
|
|
1228
|
+
getCallName(node.right.object) === 'require' &&
|
|
1229
|
+
node.right.object.arguments.length > 0 && node.right.object.arguments[0]?.type === 'Literal' &&
|
|
1230
|
+
node.right.object.arguments[0].value === 'child_process') {
|
|
1231
|
+
const method = node.right.property?.type === 'Identifier' ? node.right.property.name : null;
|
|
1232
|
+
if (method && ['exec', 'execSync', 'spawn', 'execFile'].includes(method)) {
|
|
1233
|
+
ctx.threats.push({
|
|
1234
|
+
type: 'dangerous_exec',
|
|
1235
|
+
severity: 'HIGH',
|
|
1236
|
+
message: `Object property indirection: ${propName} = require('child_process').${method} — hiding exec in object property.`,
|
|
1237
|
+
file: ctx.relFile
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
// Assigning eval or Function to an object property
|
|
1242
|
+
if (node.right.type === 'Identifier' && (node.right.name === 'eval' || node.right.name === 'Function')) {
|
|
1243
|
+
ctx.hasDynamicExec = true;
|
|
1244
|
+
ctx.threats.push({
|
|
1245
|
+
type: node.right.name === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
|
|
1246
|
+
severity: 'HIGH',
|
|
1247
|
+
message: `Object property indirection: ${propName} = ${node.right.name} — stashing dangerous function in object property.`,
|
|
1248
|
+
file: ctx.relFile
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1122
1254
|
if (node.left?.type === 'MemberExpression') {
|
|
1123
1255
|
const left = node.left;
|
|
1124
1256
|
|
|
@@ -1375,6 +1507,33 @@ function handlePostWalk(ctx) {
|
|
|
1375
1507
|
}
|
|
1376
1508
|
}
|
|
1377
1509
|
|
|
1510
|
+
function handleWithStatement(node, ctx) {
|
|
1511
|
+
// with(require('child_process')) exec(cmd) — scope injection evasion
|
|
1512
|
+
// The with() statement makes all properties of the object available as local variables.
|
|
1513
|
+
// When used with require(), it allows calling exec(), spawn() etc. without explicit reference.
|
|
1514
|
+
if (node.object?.type === 'CallExpression' && getCallName(node.object) === 'require') {
|
|
1515
|
+
const arg = node.object.arguments[0];
|
|
1516
|
+
const modName = arg?.type === 'Literal' ? arg.value : null;
|
|
1517
|
+
const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns'];
|
|
1518
|
+
if (modName && dangerousModules.includes(modName)) {
|
|
1519
|
+
ctx.hasDynamicExec = true;
|
|
1520
|
+
ctx.threats.push({
|
|
1521
|
+
type: 'dangerous_exec',
|
|
1522
|
+
severity: 'CRITICAL',
|
|
1523
|
+
message: `with(require('${modName}')) — scope injection evasion: all module methods available as local variables.`,
|
|
1524
|
+
file: ctx.relFile
|
|
1525
|
+
});
|
|
1526
|
+
} else if (!modName) {
|
|
1527
|
+
ctx.threats.push({
|
|
1528
|
+
type: 'dynamic_require',
|
|
1529
|
+
severity: 'HIGH',
|
|
1530
|
+
message: 'with(require(...)) — scope injection with dynamic module. Evasion technique.',
|
|
1531
|
+
file: ctx.relFile
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1378
1537
|
module.exports = {
|
|
1379
1538
|
handleVariableDeclarator,
|
|
1380
1539
|
handleCallExpression,
|
|
@@ -1383,5 +1542,6 @@ module.exports = {
|
|
|
1383
1542
|
handleLiteral,
|
|
1384
1543
|
handleAssignmentExpression,
|
|
1385
1544
|
handleMemberExpression,
|
|
1545
|
+
handleWithStatement,
|
|
1386
1546
|
handlePostWalk
|
|
1387
1547
|
};
|
package/src/scanner/ast.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
handleLiteral,
|
|
12
12
|
handleAssignmentExpression,
|
|
13
13
|
handleMemberExpression,
|
|
14
|
+
handleWithStatement,
|
|
14
15
|
handlePostWalk
|
|
15
16
|
} = require('./ast-detectors.js');
|
|
16
17
|
|
|
@@ -121,7 +122,8 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
121
122
|
NewExpression(node) { handleNewExpression(node, ctx); },
|
|
122
123
|
Literal(node) { handleLiteral(node, ctx); },
|
|
123
124
|
AssignmentExpression(node) { handleAssignmentExpression(node, ctx); },
|
|
124
|
-
MemberExpression(node) { handleMemberExpression(node, ctx); }
|
|
125
|
+
MemberExpression(node) { handleMemberExpression(node, ctx); },
|
|
126
|
+
WithStatement(node) { handleWithStatement(node, ctx); }
|
|
125
127
|
});
|
|
126
128
|
|
|
127
129
|
// FIX 5: DNS chunk exfiltration — verify dns.resolve* is inside a loop body
|
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
|
};
|