muaddib-scanner 2.6.3 → 2.6.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/README.md +8 -3
- package/package.json +2 -3
- package/src/index.js +38 -5
- package/src/intent-graph.js +13 -2
- package/src/ioc/scraper.js +43 -1
- package/src/ioc/updater.js +29 -1
- package/src/scanner/dataflow.js +38 -0
- package/src/scanner/module-graph.js +18 -11
- package/src/scoring.js +6 -10
- package/src/shared/download.js +27 -6
package/README.md
CHANGED
|
@@ -284,9 +284,14 @@ repos:
|
|
|
284
284
|
| **Wild TPR** (Datadog 17K) | **88.2%** raw / **~100%** adjusted | 17,922 real malware. 2,077 out-of-scope (phishing, binaries, corrected) |
|
|
285
285
|
| **TPR** (Ground Truth) | **93.9%** (46/49) | 51 real attacks. 3 out-of-scope: browser-only |
|
|
286
286
|
| **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
|
|
287
|
-
| **ADR** (Adversarial + Holdout) | **
|
|
287
|
+
| **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
|
|
288
288
|
|
|
289
|
-
**
|
|
289
|
+
**1974 tests** across 44 files, 86% code coverage. **129 rules** (124 RULES + 5 PARANOID).
|
|
290
|
+
|
|
291
|
+
> **Methodology caveats:**
|
|
292
|
+
> - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
|
|
293
|
+
> - FPR measured on 529 curated popular npm packages (not a random sample)
|
|
294
|
+
> - ADR measured with global threshold (score >= 20) as of v2.6.5
|
|
290
295
|
|
|
291
296
|
See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol, holdout history, and Datadog benchmark details.
|
|
292
297
|
|
|
@@ -322,7 +327,7 @@ npm test
|
|
|
322
327
|
|
|
323
328
|
### Testing
|
|
324
329
|
|
|
325
|
-
- **
|
|
330
|
+
- **1974 tests** across 44 modular test files - 86% code coverage
|
|
326
331
|
- **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
|
|
327
332
|
- **Datadog 17K benchmark** - 17,922 real malware samples
|
|
328
333
|
- **Ground truth validation** - 51 real-world attacks (93.9% TPR)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.5",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,8 +48,7 @@
|
|
|
48
48
|
"acorn": "8.16.0",
|
|
49
49
|
"acorn-walk": "8.3.5",
|
|
50
50
|
"adm-zip": "0.5.16",
|
|
51
|
-
"js-yaml": "4.1.1"
|
|
52
|
-
"muaddib-scanner": "^2.6.2"
|
|
51
|
+
"js-yaml": "4.1.1"
|
|
53
52
|
},
|
|
54
53
|
"overrides": {
|
|
55
54
|
"loadash": "0.0.0-security"
|
package/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ const fs = require('fs');
|
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const { scanGitHubActions } = require('./scanner/github-actions.js');
|
|
20
20
|
const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
|
|
21
|
-
const { loadCachedIOCs } = require('./ioc/updater.js');
|
|
21
|
+
const { loadCachedIOCs, checkIOCStaleness } = require('./ioc/updater.js');
|
|
22
22
|
const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
23
23
|
const { scanEntropy } = require('./scanner/entropy.js');
|
|
24
24
|
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
@@ -92,18 +92,32 @@ function scanParanoid(targetPath) {
|
|
|
92
92
|
|
|
93
93
|
const found = new Set(); // deduplicate: one finding per rule per file
|
|
94
94
|
|
|
95
|
+
// v2.6.5: Track aliases of eval, Function, require for bypass detection
|
|
96
|
+
// e.g., const e = eval; e(code) — or — const F = Function; new F(code)
|
|
97
|
+
const ALIAS_TARGETS = new Set(['eval', 'Function', 'require']);
|
|
98
|
+
const aliases = new Map(); // aliasName → originalName
|
|
99
|
+
|
|
95
100
|
walk.simple(ast, {
|
|
101
|
+
VariableDeclarator(node) {
|
|
102
|
+
// const e = eval / const F = Function / const r = require
|
|
103
|
+
if (node.id?.type === 'Identifier' && node.init?.type === 'Identifier' &&
|
|
104
|
+
ALIAS_TARGETS.has(node.init.name)) {
|
|
105
|
+
aliases.set(node.id.name, node.init.name);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
96
108
|
CallExpression(node) {
|
|
97
109
|
// Direct calls: eval(), exec(), fetch(), etc.
|
|
98
110
|
if (node.callee.type === 'Identifier') {
|
|
99
|
-
|
|
111
|
+
// Resolve alias to original name if applicable
|
|
112
|
+
const name = aliases.get(node.callee.name) || node.callee.name;
|
|
100
113
|
for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
|
|
101
114
|
if (detector.callNames && detector.callNames.has(name) && !found.has(ruleKey)) {
|
|
102
115
|
found.add(ruleKey);
|
|
103
116
|
const rule = PARANOID_RULES[ruleKey];
|
|
104
117
|
threats.push({
|
|
105
118
|
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
106
|
-
message: `${rule.message}: "${name}"
|
|
119
|
+
message: `${rule.message}: "${node.callee.name}"${aliases.has(node.callee.name) ? ` (alias of ${name})` : ''}`,
|
|
120
|
+
file: relFile, mitre: rule.mitre
|
|
107
121
|
});
|
|
108
122
|
}
|
|
109
123
|
}
|
|
@@ -130,14 +144,16 @@ function scanParanoid(targetPath) {
|
|
|
130
144
|
},
|
|
131
145
|
NewExpression(node) {
|
|
132
146
|
if (node.callee.type === 'Identifier') {
|
|
133
|
-
const
|
|
147
|
+
// Resolve alias: const F = Function; new F(code)
|
|
148
|
+
const name = aliases.get(node.callee.name) || node.callee.name;
|
|
134
149
|
for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
|
|
135
150
|
if (detector.newNames && detector.newNames.has(name) && !found.has(ruleKey)) {
|
|
136
151
|
found.add(ruleKey);
|
|
137
152
|
const rule = PARANOID_RULES[ruleKey];
|
|
138
153
|
threats.push({
|
|
139
154
|
type: rule.id, severity: rule.severity.toUpperCase(),
|
|
140
|
-
message: `${rule.message}: "new ${name}"
|
|
155
|
+
message: `${rule.message}: "new ${node.callee.name}"${aliases.has(node.callee.name) ? ` (alias of ${name})` : ''}`,
|
|
156
|
+
file: relFile, mitre: rule.mitre
|
|
141
157
|
});
|
|
142
158
|
}
|
|
143
159
|
}
|
|
@@ -333,6 +349,9 @@ async function run(targetPath, options = {}) {
|
|
|
333
349
|
// Ensure IOCs are downloaded (first run only, graceful failure)
|
|
334
350
|
await ensureIOCs();
|
|
335
351
|
|
|
352
|
+
// Check IOC freshness — warn if database is older than 30 days
|
|
353
|
+
const iocStalenessWarning = checkIOCStaleness(30);
|
|
354
|
+
|
|
336
355
|
// Apply --exclude dirs for this scan
|
|
337
356
|
if (options.exclude && options.exclude.length > 0) {
|
|
338
357
|
setExtraExcludes(options.exclude, targetPath);
|
|
@@ -359,10 +378,16 @@ async function run(targetPath, options = {}) {
|
|
|
359
378
|
// Wrapped in yieldThen to unblock spinner animation
|
|
360
379
|
// Bounded: 5s timeout to prevent DoS on large/adversarial packages
|
|
361
380
|
const MODULE_GRAPH_TIMEOUT_MS = 5000;
|
|
381
|
+
const warnings = [];
|
|
382
|
+
if (iocStalenessWarning) warnings.push(iocStalenessWarning);
|
|
362
383
|
let crossFileFlows = [];
|
|
363
384
|
if (!options.noModuleGraph) {
|
|
364
385
|
const moduleGraphWork = async () => {
|
|
365
386
|
const graph = await yieldThen(() => buildModuleGraph(targetPath));
|
|
387
|
+
if (Object.keys(graph).length === 0) {
|
|
388
|
+
// buildModuleGraph returns empty when MAX_GRAPH_NODES exceeded
|
|
389
|
+
warnings.push('Module graph skipped: package exceeds 100 files limit');
|
|
390
|
+
}
|
|
366
391
|
const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
|
|
367
392
|
const sinkAnnotations = await yieldThen(() => annotateSinkExports(graph, targetPath));
|
|
368
393
|
crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, sinkAnnotations, targetPath));
|
|
@@ -381,6 +406,9 @@ async function run(targetPath, options = {}) {
|
|
|
381
406
|
} catch (e) {
|
|
382
407
|
// Graceful fallback — module graph is best-effort
|
|
383
408
|
debugLog('[MODULE-GRAPH] Error:', e && e.message);
|
|
409
|
+
if (e && e.message === 'Module graph timeout') {
|
|
410
|
+
warnings.push(`Module graph analysis timed out (${MODULE_GRAPH_TIMEOUT_MS / 1000}s) — cross-file flows may be incomplete`);
|
|
411
|
+
}
|
|
384
412
|
}
|
|
385
413
|
}
|
|
386
414
|
|
|
@@ -593,6 +621,10 @@ async function run(targetPath, options = {}) {
|
|
|
593
621
|
threats: pythonThreats.length + pypiTyposquatThreats.length
|
|
594
622
|
} : null;
|
|
595
623
|
|
|
624
|
+
// Track deobfuscation failures
|
|
625
|
+
// (deobfuscate returns {deobfuscatedThreats, failures} but failures aren't surfaced)
|
|
626
|
+
// We detect this via scannerErrors for now
|
|
627
|
+
|
|
596
628
|
const result = {
|
|
597
629
|
target: targetPath,
|
|
598
630
|
timestamp: new Date().toISOString(),
|
|
@@ -614,6 +646,7 @@ async function run(targetPath, options = {}) {
|
|
|
614
646
|
breakdown
|
|
615
647
|
},
|
|
616
648
|
sandbox: sandboxData,
|
|
649
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
617
650
|
scannerErrors: scannerErrors.length > 0 ? scannerErrors : undefined
|
|
618
651
|
};
|
|
619
652
|
|
package/src/intent-graph.js
CHANGED
|
@@ -24,11 +24,14 @@ const SOURCE_TYPES = {
|
|
|
24
24
|
credential_regex_harvest: 'credential_read', // regex patterns for tokens/passwords
|
|
25
25
|
llm_api_key_harvest: 'credential_read', // OPENAI_API_KEY, ANTHROPIC_API_KEY
|
|
26
26
|
credential_cli_steal: 'credential_read', // gh auth token, gcloud auth
|
|
27
|
-
// env_access
|
|
27
|
+
// env_access: conditionally classified — see classifySource()
|
|
28
28
|
// suspicious_dataflow EXCLUDED — already compound detection
|
|
29
29
|
// cross_file_dataflow EXCLUDED — already scored CRITICAL by module-graph
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
// Sensitive env var patterns — env_access referencing these is credential theft, not config
|
|
33
|
+
const SENSITIVE_ENV_PATTERNS = /TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API_KEY|AUTH/i;
|
|
34
|
+
|
|
32
35
|
// ============================================
|
|
33
36
|
// SINK CLASSIFICATION (from existing threats only)
|
|
34
37
|
// ============================================
|
|
@@ -105,9 +108,17 @@ const CROSS_FILE_MULTIPLIER = 0.5;
|
|
|
105
108
|
function classifySource(threat) {
|
|
106
109
|
if (SOURCE_TYPES[threat.type]) return SOURCE_TYPES[threat.type];
|
|
107
110
|
|
|
111
|
+
// env_access: only classify as credential_read if accessing sensitive vars
|
|
112
|
+
// Standard config (NODE_ENV, PORT, DEBUG) → null (no pairing)
|
|
113
|
+
if (threat.type === 'env_access') {
|
|
114
|
+
if (threat.message && SENSITIVE_ENV_PATTERNS.test(threat.message)) {
|
|
115
|
+
return 'credential_read';
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
108
120
|
// Explicitly excluded types
|
|
109
121
|
if (threat.type === 'suspicious_dataflow') return null;
|
|
110
|
-
if (threat.type === 'env_access') return null;
|
|
111
122
|
if (threat.type === 'cross_file_dataflow') return null;
|
|
112
123
|
|
|
113
124
|
// Message-based: only for threats referencing sensitive file paths
|
package/src/ioc/scraper.js
CHANGED
|
@@ -10,6 +10,38 @@ const HOME_IOC_FILE = path.join(os.homedir(), '.muaddib', 'data', 'iocs.json');
|
|
|
10
10
|
const STATIC_IOCS_FILE = path.join(__dirname, '../../data/static-iocs.json');
|
|
11
11
|
const { generateCompactIOCs } = require('./updater.js');
|
|
12
12
|
const { Spinner } = require('../utils.js');
|
|
13
|
+
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
14
|
+
|
|
15
|
+
// Version format validation (semver-like + wildcard)
|
|
16
|
+
const VERSION_RE = /^(\*|0|[1-9]\d*(\.\d+){0,2}(-[\w.]+)?(\+[\w.]+)?)$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate an IOC package entry before insertion.
|
|
20
|
+
* Returns true if valid, false if should be skipped.
|
|
21
|
+
*/
|
|
22
|
+
function validateIOCEntry(pkgName, version, ecosystem) {
|
|
23
|
+
if (!pkgName || typeof pkgName !== 'string') return false;
|
|
24
|
+
// npm: validate with NPM_PACKAGE_REGEX
|
|
25
|
+
if (ecosystem === 'npm' || !ecosystem) {
|
|
26
|
+
if (!NPM_PACKAGE_REGEX.test(pkgName)) {
|
|
27
|
+
console.warn(`[WARN] Invalid ${ecosystem || 'npm'} package name skipped: ${pkgName}`);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// PyPI: basic check — no path traversal, no slashes
|
|
32
|
+
if (ecosystem === 'pypi') {
|
|
33
|
+
if (/[/\\]|\.\./.test(pkgName)) {
|
|
34
|
+
console.warn(`[WARN] Invalid PyPI package name skipped: ${pkgName}`);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Version validation
|
|
39
|
+
if (version && !VERSION_RE.test(version)) {
|
|
40
|
+
console.warn(`[WARN] Invalid version skipped: ${version} for ${pkgName}`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
13
45
|
|
|
14
46
|
// Allowed domains for redirections (SSRF security)
|
|
15
47
|
const ALLOWED_REDIRECT_DOMAINS = [
|
|
@@ -1110,10 +1142,15 @@ async function runScraper() {
|
|
|
1110
1142
|
dedupMap.set(key, pkg);
|
|
1111
1143
|
}
|
|
1112
1144
|
|
|
1113
|
-
// Merge new IOCs with smart replacement
|
|
1145
|
+
// Merge new IOCs with smart replacement (with input validation)
|
|
1114
1146
|
let addedPackages = 0;
|
|
1115
1147
|
let upgradedPackages = 0;
|
|
1148
|
+
let skippedInvalid = 0;
|
|
1116
1149
|
for (const pkg of allPackages) {
|
|
1150
|
+
if (!validateIOCEntry(pkg.name, pkg.version, 'npm')) {
|
|
1151
|
+
skippedInvalid++;
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1117
1154
|
const key = pkg.name + '@' + pkg.version;
|
|
1118
1155
|
if (!dedupMap.has(key)) {
|
|
1119
1156
|
dedupMap.set(key, pkg);
|
|
@@ -1148,6 +1185,10 @@ async function runScraper() {
|
|
|
1148
1185
|
}
|
|
1149
1186
|
let addedPyPIPackages = 0;
|
|
1150
1187
|
for (const pkg of pypiPackages) {
|
|
1188
|
+
if (!validateIOCEntry(pkg.name, pkg.version, 'pypi')) {
|
|
1189
|
+
skippedInvalid++;
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1151
1192
|
const key = pkg.name + '@' + pkg.version;
|
|
1152
1193
|
if (!pypiDedupMap.has(key)) {
|
|
1153
1194
|
pypiDedupMap.set(key, pkg);
|
|
@@ -1308,6 +1349,7 @@ module.exports = {
|
|
|
1308
1349
|
// Pure utility functions (exported for testing)
|
|
1309
1350
|
parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
|
|
1310
1351
|
createFreshness, isAllowedRedirect, loadStaticIOCs,
|
|
1352
|
+
validateIOCEntry,
|
|
1311
1353
|
CONFIDENCE_ORDER, ALLOWED_REDIRECT_DOMAINS
|
|
1312
1354
|
};
|
|
1313
1355
|
|
package/src/ioc/updater.js
CHANGED
|
@@ -463,6 +463,34 @@ function invalidateCache() {
|
|
|
463
463
|
cachedIOCsTime = 0;
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Check IOC freshness based on cached file mtime.
|
|
468
|
+
* Returns a warning string if IOCs are older than maxAgeDays, null otherwise.
|
|
469
|
+
* @param {number} maxAgeDays - Maximum acceptable age in days (default: 30)
|
|
470
|
+
* @returns {string|null} Warning message or null
|
|
471
|
+
*/
|
|
472
|
+
function checkIOCStaleness(maxAgeDays = 30) {
|
|
473
|
+
const filesToCheck = [CACHE_IOC_FILE, LOCAL_IOC_FILE, LOCAL_COMPACT_FILE];
|
|
474
|
+
let newestMtime = 0;
|
|
475
|
+
|
|
476
|
+
for (const f of filesToCheck) {
|
|
477
|
+
try {
|
|
478
|
+
const stat = fs.statSync(f);
|
|
479
|
+
if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
|
|
480
|
+
} catch {
|
|
481
|
+
// File doesn't exist — skip
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (newestMtime === 0) return null; // No IOC files found — bootstrap will handle
|
|
486
|
+
|
|
487
|
+
const ageDays = (Date.now() - newestMtime) / (1000 * 60 * 60 * 24);
|
|
488
|
+
if (ageDays > maxAgeDays) {
|
|
489
|
+
return `IOC database is ${Math.floor(ageDays)} days old (threshold: ${maxAgeDays}d). Run "muaddib update" for latest threat data.`;
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
466
494
|
// ============================================
|
|
467
495
|
// IOC INTEGRITY: HMAC-SHA256 signing/verification
|
|
468
496
|
// ============================================
|
|
@@ -510,4 +538,4 @@ function verifyIOCHMAC(data, hmac) {
|
|
|
510
538
|
}
|
|
511
539
|
}
|
|
512
540
|
|
|
513
|
-
module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs, generateIOCHMAC, verifyIOCHMAC, NEVER_WILDCARD };
|
|
541
|
+
module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs, generateIOCHMAC, verifyIOCHMAC, checkIOCStaleness, NEVER_WILDCARD };
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -110,6 +110,32 @@ function buildTaintMap(ast) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// B8 fix: const fn = tools.read — resolve object property alias to tainted method
|
|
114
|
+
if (node.id.type === 'Identifier' && init.type === 'MemberExpression' &&
|
|
115
|
+
init.object?.type === 'Identifier' && init.property?.type === 'Identifier') {
|
|
116
|
+
const aliasKey = `${init.object.name}.${init.property.name}`;
|
|
117
|
+
const aliasTaint = taintMap.get(aliasKey);
|
|
118
|
+
if (aliasTaint && TRACKED_MODULES.has(aliasTaint.source)) {
|
|
119
|
+
taintMap.set(node.id.name, aliasTaint);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// B9 fix: const [x] = [fs.readFileSync(...)] — array destructuring taint
|
|
124
|
+
if (node.id.type === 'ArrayPattern' && init.type === 'ArrayExpression') {
|
|
125
|
+
for (let i = 0; i < node.id.elements.length && i < init.elements.length; i++) {
|
|
126
|
+
const elem = node.id.elements[i];
|
|
127
|
+
const val = init.elements[i];
|
|
128
|
+
if (!elem || elem.type !== 'Identifier' || !val) continue;
|
|
129
|
+
if (val.type === 'CallExpression' && val.callee?.type === 'MemberExpression' &&
|
|
130
|
+
val.callee.object?.type === 'Identifier' && val.callee.property?.type === 'Identifier') {
|
|
131
|
+
const parentTaint = taintMap.get(val.callee.object.name);
|
|
132
|
+
if (parentTaint && TRACKED_MODULES.has(parentTaint.source)) {
|
|
133
|
+
taintMap.set(elem.name, { source: parentTaint.source, detail: `${parentTaint.source}.${val.callee.property.name}` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
// B5 fix: const tools = { read: fs.readFileSync, home: os.homedir }
|
|
114
140
|
// Track object properties that reference tainted module methods as tainted aliases
|
|
115
141
|
if (node.id.type === 'Identifier' && init.type === 'ObjectExpression') {
|
|
@@ -193,6 +219,18 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
193
219
|
},
|
|
194
220
|
|
|
195
221
|
VariableDeclarator(node) {
|
|
222
|
+
// B9: Array destructuring taint propagation: const [data] = [fs.readFileSync('.npmrc')]
|
|
223
|
+
if (node.id?.type === 'ArrayPattern' && node.init?.type === 'ArrayExpression') {
|
|
224
|
+
for (let i = 0; i < node.id.elements.length && i < node.init.elements.length; i++) {
|
|
225
|
+
const elem = node.id.elements[i];
|
|
226
|
+
const val = node.init.elements[i];
|
|
227
|
+
if (!elem || elem.type !== 'Identifier' || !val) continue;
|
|
228
|
+
if (containsSensitiveLiteral(val)) {
|
|
229
|
+
sensitivePathVars.add(elem.name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
196
234
|
if (node.id?.type === 'Identifier' && node.init) {
|
|
197
235
|
let initNode = node.init;
|
|
198
236
|
if (initNode.type === 'AwaitExpression') initNode = initNode.argument;
|
|
@@ -5,9 +5,10 @@ const { findFiles, EXCLUDED_DIRS, debugLog } = require('../utils');
|
|
|
5
5
|
const { ACORN_OPTIONS: BASE_ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
|
|
6
6
|
|
|
7
7
|
// --- Bounded path limits ---
|
|
8
|
-
const MAX_GRAPH_NODES =
|
|
9
|
-
const MAX_GRAPH_EDGES =
|
|
8
|
+
const MAX_GRAPH_NODES = 100; // Max files in dependency graph (covers ~86% of npm packages)
|
|
9
|
+
const MAX_GRAPH_EDGES = 400; // Max total import edges
|
|
10
10
|
const MAX_FLOWS = 20; // Max cross-file flow findings per package
|
|
11
|
+
const MAX_TAINT_DEPTH = 50; // Max AST recursion depth (DoS guard)
|
|
11
12
|
|
|
12
13
|
// --- Sensitive source patterns ---
|
|
13
14
|
const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os', 'dgram']);
|
|
@@ -103,7 +104,9 @@ function tryResolveConcatRequire(node, depth) {
|
|
|
103
104
|
return null;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function walkForRequires(node, fileDir, packagePath, imports) {
|
|
107
|
+
function walkForRequires(node, fileDir, packagePath, imports, depth) {
|
|
108
|
+
if (depth === undefined) depth = 0;
|
|
109
|
+
if (depth > MAX_TAINT_DEPTH) return;
|
|
107
110
|
if (!node || typeof node !== 'object') return;
|
|
108
111
|
if (
|
|
109
112
|
node.type === 'CallExpression' &&
|
|
@@ -130,11 +133,11 @@ function walkForRequires(node, fileDir, packagePath, imports) {
|
|
|
130
133
|
if (Array.isArray(child)) {
|
|
131
134
|
for (const item of child) {
|
|
132
135
|
if (item && typeof item === 'object' && item.type) {
|
|
133
|
-
walkForRequires(item, fileDir, packagePath, imports);
|
|
136
|
+
walkForRequires(item, fileDir, packagePath, imports, depth + 1);
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
} else if (child && typeof child === 'object' && child.type) {
|
|
137
|
-
walkForRequires(child, fileDir, packagePath, imports);
|
|
140
|
+
walkForRequires(child, fileDir, packagePath, imports, depth + 1);
|
|
138
141
|
}
|
|
139
142
|
}
|
|
140
143
|
}
|
|
@@ -1462,7 +1465,9 @@ function parseFile(filePath) {
|
|
|
1462
1465
|
return safeParse(content, { allowReturnOutsideFunction: true, allowImportExportEverywhere: true });
|
|
1463
1466
|
}
|
|
1464
1467
|
|
|
1465
|
-
function walkAST(node, visitor) {
|
|
1468
|
+
function walkAST(node, visitor, depth) {
|
|
1469
|
+
if (depth === undefined) depth = 0;
|
|
1470
|
+
if (depth > MAX_TAINT_DEPTH) return;
|
|
1466
1471
|
if (!node || typeof node !== 'object') return;
|
|
1467
1472
|
if (node.type) visitor(node);
|
|
1468
1473
|
for (const key of Object.keys(node)) {
|
|
@@ -1470,10 +1475,10 @@ function walkAST(node, visitor) {
|
|
|
1470
1475
|
const child = node[key];
|
|
1471
1476
|
if (Array.isArray(child)) {
|
|
1472
1477
|
for (const item of child) {
|
|
1473
|
-
if (item && typeof item === 'object' && item.type) walkAST(item, visitor);
|
|
1478
|
+
if (item && typeof item === 'object' && item.type) walkAST(item, visitor, depth + 1);
|
|
1474
1479
|
}
|
|
1475
1480
|
} else if (child && typeof child === 'object' && child.type) {
|
|
1476
|
-
walkAST(child, visitor);
|
|
1481
|
+
walkAST(child, visitor, depth + 1);
|
|
1477
1482
|
}
|
|
1478
1483
|
}
|
|
1479
1484
|
}
|
|
@@ -1536,10 +1541,12 @@ function getFunctionBody(node) {
|
|
|
1536
1541
|
return null;
|
|
1537
1542
|
}
|
|
1538
1543
|
|
|
1539
|
-
function getMemberChain(node) {
|
|
1544
|
+
function getMemberChain(node, depth) {
|
|
1545
|
+
if (depth === undefined) depth = 0;
|
|
1546
|
+
if (depth > MAX_TAINT_DEPTH) return '';
|
|
1540
1547
|
if (node.type === 'Identifier') return node.name;
|
|
1541
1548
|
if (node.type === 'MemberExpression') {
|
|
1542
|
-
const obj = getMemberChain(node.object);
|
|
1549
|
+
const obj = getMemberChain(node.object, depth + 1);
|
|
1543
1550
|
const prop = node.property.name || node.property.value || '';
|
|
1544
1551
|
return `${obj}.${prop}`;
|
|
1545
1552
|
}
|
|
@@ -2084,5 +2091,5 @@ module.exports = {
|
|
|
2084
2091
|
annotateSinkExports, detectCallbackCrossFileFlows, detectEventEmitterFlows,
|
|
2085
2092
|
resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
|
|
2086
2093
|
tryResolveConcatRequire,
|
|
2087
|
-
MAX_GRAPH_NODES, MAX_GRAPH_EDGES, MAX_FLOWS
|
|
2094
|
+
MAX_GRAPH_NODES, MAX_GRAPH_EDGES, MAX_FLOWS, MAX_TAINT_DEPTH
|
|
2088
2095
|
};
|
package/src/scoring.js
CHANGED
|
@@ -232,17 +232,13 @@ function applyFPReductions(threats, reachableFiles, packageName) {
|
|
|
232
232
|
const rule = FP_COUNT_THRESHOLDS[t.type];
|
|
233
233
|
if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
|
|
234
234
|
const typeRatio = typeCounts[t.type] / totalThreats;
|
|
235
|
-
// suspicious_dataflow:
|
|
236
|
-
// findings are always legitimate SDKs
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// where dataflow was the dominant finding type (e.g. @darajs/core, addio-admin-sdk).
|
|
240
|
-
// vm_code_execution: full bypass — packages with only vm.Script calls (cassandra-driver,
|
|
241
|
-
// webpack, jest) are legitimate. Real malware using vm always has other signals
|
|
242
|
-
// (network, fs, obfuscation). The >3 count threshold is sufficient protection.
|
|
235
|
+
// suspicious_dataflow: bypass percentage guard when count exceeds threshold.
|
|
236
|
+
// Packages with >3 suspicious_dataflow findings are always legitimate SDKs.
|
|
237
|
+
// But a single suspicious_dataflow at 50% ratio should NOT be downgraded.
|
|
238
|
+
// vm_code_execution: same logic — bypass only when count exceeds threshold.
|
|
243
239
|
if (typeRatio < 0.4 ||
|
|
244
|
-
t.type === 'suspicious_dataflow' ||
|
|
245
|
-
t.type === 'vm_code_execution') {
|
|
240
|
+
(t.type === 'suspicious_dataflow' && typeCounts[t.type] > rule.maxCount) ||
|
|
241
|
+
(t.type === 'vm_code_execution' && typeCounts[t.type] > rule.maxCount)) {
|
|
246
242
|
t.severity = rule.to;
|
|
247
243
|
}
|
|
248
244
|
}
|
package/src/shared/download.js
CHANGED
|
@@ -41,13 +41,27 @@ function normalizeHostname(hostname) {
|
|
|
41
41
|
return ipv4Part;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
// Convert
|
|
45
|
-
if (
|
|
46
|
-
const num = parseInt(hostname, 10);
|
|
44
|
+
// Convert integer IP notation (decimal or hex): 2130706433 or 0x7f000001 → 127.0.0.1
|
|
45
|
+
if (/^(0x[\da-f]+|\d+)$/i.test(hostname)) {
|
|
46
|
+
const num = hostname.startsWith('0x') ? parseInt(hostname, 16) : parseInt(hostname, 10);
|
|
47
47
|
if (num > 0 && num < 4294967296) {
|
|
48
48
|
return [(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.');
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
// Convert dotted IP with octal/hex octets: 0177.0.0.01 or 0x7f.0.0.1 → 127.0.0.1
|
|
52
|
+
if (/^[\da-fox.]+$/i.test(hostname)) {
|
|
53
|
+
const parts = hostname.split('.');
|
|
54
|
+
if (parts.length === 4) {
|
|
55
|
+
const octets = parts.map(p => {
|
|
56
|
+
if (/^0x[\da-f]+$/i.test(p)) return parseInt(p, 16);
|
|
57
|
+
if (/^0\d+$/.test(p)) return parseInt(p, 8);
|
|
58
|
+
return parseInt(p, 10);
|
|
59
|
+
});
|
|
60
|
+
if (octets.every(o => !isNaN(o) && o >= 0 && o <= 255)) {
|
|
61
|
+
return octets.join('.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
51
65
|
return hostname;
|
|
52
66
|
}
|
|
53
67
|
|
|
@@ -121,12 +135,18 @@ async function safeDnsResolve(hostname) {
|
|
|
121
135
|
* @param {number} [timeoutMs] - Download timeout in ms (default: DOWNLOAD_TIMEOUT)
|
|
122
136
|
* @returns {Promise<number>} Number of bytes downloaded
|
|
123
137
|
*/
|
|
138
|
+
const MAX_REDIRECTS = 5;
|
|
139
|
+
|
|
124
140
|
function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
|
|
125
141
|
// DNS rebinding protection: validate hostname before connecting
|
|
126
142
|
const parsedUrl = new URL(url);
|
|
127
143
|
return safeDnsResolve(parsedUrl.hostname).then(() => {
|
|
128
144
|
return new Promise((resolve, reject) => {
|
|
129
|
-
const doRequest = (requestUrl) => {
|
|
145
|
+
const doRequest = (requestUrl, redirectCount) => {
|
|
146
|
+
if (redirectCount === undefined) redirectCount = 0;
|
|
147
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
148
|
+
return reject(new Error(`Too many redirects (${MAX_REDIRECTS}) for ${url}`));
|
|
149
|
+
}
|
|
130
150
|
const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
|
|
131
151
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
132
152
|
res.resume();
|
|
@@ -138,7 +158,7 @@ function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
|
|
|
138
158
|
if (!check.allowed) {
|
|
139
159
|
return reject(new Error(check.error));
|
|
140
160
|
}
|
|
141
|
-
return doRequest(absoluteLocation);
|
|
161
|
+
return doRequest(absoluteLocation, redirectCount + 1);
|
|
142
162
|
}
|
|
143
163
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
144
164
|
res.resume();
|
|
@@ -246,5 +266,6 @@ module.exports = {
|
|
|
246
266
|
isPrivateIP,
|
|
247
267
|
safeDnsResolve,
|
|
248
268
|
ALLOWED_DOWNLOAD_DOMAINS,
|
|
249
|
-
PRIVATE_IP_PATTERNS
|
|
269
|
+
PRIVATE_IP_PATTERNS,
|
|
270
|
+
MAX_REDIRECTS
|
|
250
271
|
};
|