muaddib-scanner 2.4.19 → 2.5.0
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 +5 -3
- package/src/index.js +38 -22
- package/src/ioc/updater.js +62 -5
- package/src/sandbox/index.js +4 -0
- package/src/scanner/deobfuscate.js +8 -3
- package/src/scoring.js +9 -2
- package/src/shared/download.js +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,8 +48,10 @@
|
|
|
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
|
-
|
|
51
|
+
"js-yaml": "4.1.1"
|
|
52
|
+
},
|
|
53
|
+
"overrides": {
|
|
54
|
+
"loadash": "0.0.0-security"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@eslint/js": "10.0.1",
|
package/src/index.js
CHANGED
|
@@ -32,6 +32,10 @@ const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat,
|
|
|
32
32
|
|
|
33
33
|
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
34
34
|
|
|
35
|
+
// Timeout constants for scan safety
|
|
36
|
+
const SCANNER_TIMEOUT = 15000; // 15s per individual scanner
|
|
37
|
+
const SCAN_TIMEOUT = 60000; // 60s global scan timeout
|
|
38
|
+
|
|
35
39
|
// Paranoid mode scanner
|
|
36
40
|
function scanParanoid(targetPath) {
|
|
37
41
|
const threats = [];
|
|
@@ -222,27 +226,38 @@ async function run(targetPath, options = {}) {
|
|
|
222
226
|
// Sequential execution of scanners with event loop yields between each.
|
|
223
227
|
// All scanners (even "async" ones) are effectively synchronous (readFileSync, readdirSync).
|
|
224
228
|
// Running them via yieldThen ensures the spinner animates between each scanner.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
229
|
+
// Uses Promise.allSettled so one scanner crash doesn't kill the entire scan.
|
|
230
|
+
const SCANNER_NAMES = [
|
|
231
|
+
'scanPackageJson', 'scanShellScripts', 'analyzeAST', 'detectObfuscation',
|
|
232
|
+
'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
|
|
233
|
+
'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
|
|
234
|
+
'scanEntropy', 'scanAIConfig'
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
const settledResults = await Promise.allSettled([
|
|
238
|
+
yieldThen(() => scanPackageJson(targetPath)),
|
|
239
|
+
yieldThen(() => scanShellScripts(targetPath)),
|
|
240
|
+
yieldThen(() => analyzeAST(targetPath, { deobfuscate: deobfuscateFn })),
|
|
241
|
+
yieldThen(() => detectObfuscation(targetPath)),
|
|
242
|
+
yieldThen(() => scanDependencies(targetPath)),
|
|
243
|
+
yieldThen(() => scanHashes(targetPath)),
|
|
244
|
+
yieldThen(() => analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn })),
|
|
245
|
+
yieldThen(() => scanTyposquatting(targetPath)),
|
|
246
|
+
yieldThen(() => scanGitHubActions(targetPath)),
|
|
247
|
+
yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
|
|
248
|
+
yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
|
|
249
|
+
yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
|
|
250
|
+
yieldThen(() => scanAIConfig(targetPath))
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
// Extract results: use empty array for rejected scanners, log errors
|
|
254
|
+
const scannerErrors = [];
|
|
255
|
+
const scanResult = settledResults.map((r, i) => {
|
|
256
|
+
if (r.status === 'fulfilled') return r.value;
|
|
257
|
+
scannerErrors.push({ scanner: SCANNER_NAMES[i], error: r.reason });
|
|
258
|
+
console.error(`[WARN] Scanner ${SCANNER_NAMES[i]} failed: ${r.reason?.message || r.reason}`);
|
|
259
|
+
return [];
|
|
260
|
+
});
|
|
246
261
|
|
|
247
262
|
const [
|
|
248
263
|
packageThreats,
|
|
@@ -420,7 +435,8 @@ async function run(targetPath, options = {}) {
|
|
|
420
435
|
fileScores,
|
|
421
436
|
breakdown
|
|
422
437
|
},
|
|
423
|
-
sandbox: sandboxData
|
|
438
|
+
sandbox: sandboxData,
|
|
439
|
+
scannerErrors: scannerErrors.length > 0 ? scannerErrors : undefined
|
|
424
440
|
};
|
|
425
441
|
|
|
426
442
|
// _capture mode: return result directly without printing (used by diff.js)
|
package/src/ioc/updater.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
|
|
5
6
|
const HOME_DATA_PATH = path.join(os.homedir(), '.muaddib', 'data');
|
|
6
7
|
const CACHE_IOC_FILE = path.join(HOME_DATA_PATH, 'iocs.json');
|
|
@@ -100,9 +101,14 @@ async function updateIOCs() {
|
|
|
100
101
|
|
|
101
102
|
// Atomic write: write to .tmp then rename (UP-001)
|
|
102
103
|
const tmpFile = CACHE_IOC_FILE + '.tmp';
|
|
103
|
-
|
|
104
|
+
const jsonData = JSON.stringify(baseIOCs);
|
|
105
|
+
fs.writeFileSync(tmpFile, jsonData);
|
|
104
106
|
fs.renameSync(tmpFile, CACHE_IOC_FILE);
|
|
105
107
|
|
|
108
|
+
// Write HMAC signature alongside the cache file
|
|
109
|
+
const hmac = generateIOCHMAC(jsonData);
|
|
110
|
+
fs.writeFileSync(CACHE_IOC_FILE + '.hmac', hmac);
|
|
111
|
+
|
|
106
112
|
const totalNpm = baseIOCs.packages.length;
|
|
107
113
|
const totalPyPI = (baseIOCs.pypi_packages || []).length;
|
|
108
114
|
console.log('[4/4] Saved to cache: ' + CACHE_IOC_FILE);
|
|
@@ -217,11 +223,22 @@ function loadCachedIOCs() {
|
|
|
217
223
|
}
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
// Priority 3: Cached IOCs (from previous update)
|
|
226
|
+
// Priority 3: Cached IOCs (from previous update) — verify HMAC integrity
|
|
221
227
|
if (fs.existsSync(CACHE_IOC_FILE)) {
|
|
222
228
|
try {
|
|
223
|
-
const
|
|
224
|
-
|
|
229
|
+
const cachedData = fs.readFileSync(CACHE_IOC_FILE, 'utf8');
|
|
230
|
+
const hmacFile = CACHE_IOC_FILE + '.hmac';
|
|
231
|
+
if (fs.existsSync(hmacFile)) {
|
|
232
|
+
const storedHmac = fs.readFileSync(hmacFile, 'utf8').trim();
|
|
233
|
+
if (!verifyIOCHMAC(cachedData, storedHmac)) {
|
|
234
|
+
console.log('[WARN] IOC cache HMAC verification failed — possible tampering. Skipping cache.');
|
|
235
|
+
} else {
|
|
236
|
+
mergeIOCs(merged, JSON.parse(cachedData));
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// No HMAC file yet (first run or pre-HMAC version) — load but warn
|
|
240
|
+
mergeIOCs(merged, JSON.parse(cachedData));
|
|
241
|
+
}
|
|
225
242
|
} catch (e) {
|
|
226
243
|
console.log('[WARN] Failed to load cached IOCs: ' + e.message);
|
|
227
244
|
}
|
|
@@ -421,4 +438,44 @@ function invalidateCache() {
|
|
|
421
438
|
cachedIOCsTime = 0;
|
|
422
439
|
}
|
|
423
440
|
|
|
424
|
-
|
|
441
|
+
// ============================================
|
|
442
|
+
// IOC INTEGRITY: HMAC-SHA256 signing/verification
|
|
443
|
+
// ============================================
|
|
444
|
+
// Key is derived from a stable machine-specific seed + hardcoded salt.
|
|
445
|
+
// This protects against local file tampering by unauthorized processes.
|
|
446
|
+
const IOC_HMAC_SALT = 'muaddib-ioc-integrity-v1';
|
|
447
|
+
|
|
448
|
+
function getIOCHMACKey() {
|
|
449
|
+
// Derive key from salt + hostname (machine-specific but stable)
|
|
450
|
+
const seed = IOC_HMAC_SALT + ':' + os.hostname();
|
|
451
|
+
return crypto.createHash('sha256').update(seed).digest();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Generate HMAC-SHA256 for IOC data string.
|
|
456
|
+
* @param {string} data - JSON string of IOC data
|
|
457
|
+
* @returns {string} Hex-encoded HMAC
|
|
458
|
+
*/
|
|
459
|
+
function generateIOCHMAC(data) {
|
|
460
|
+
const key = getIOCHMACKey();
|
|
461
|
+
return crypto.createHmac('sha256', key).update(data).digest('hex');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Verify HMAC-SHA256 of IOC data.
|
|
466
|
+
* @param {string} data - JSON string of IOC data
|
|
467
|
+
* @param {string} hmac - Expected HMAC hex string
|
|
468
|
+
* @returns {boolean} True if HMAC matches
|
|
469
|
+
*/
|
|
470
|
+
function verifyIOCHMAC(data, hmac) {
|
|
471
|
+
if (!hmac || typeof hmac !== 'string') return false;
|
|
472
|
+
const expected = generateIOCHMAC(data);
|
|
473
|
+
// Constant-time comparison to prevent timing attacks
|
|
474
|
+
try {
|
|
475
|
+
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex'));
|
|
476
|
+
} catch {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs, generateIOCHMAC, verifyIOCHMAC };
|
package/src/sandbox/index.js
CHANGED
|
@@ -188,6 +188,10 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
188
188
|
dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
|
|
189
189
|
dockerArgs.push('--read-only');
|
|
190
190
|
|
|
191
|
+
// Mount fake /proc/uptime to prevent time-based sandbox evasion (T1497.003)
|
|
192
|
+
// Malware reads /proc/uptime to detect sandboxes (low uptime = sandbox)
|
|
193
|
+
dockerArgs.push('--tmpfs', '/proc/uptime:ro,size=4k');
|
|
194
|
+
|
|
191
195
|
dockerArgs.push('--security-opt', 'no-new-privileges');
|
|
192
196
|
|
|
193
197
|
if (local && localAbsPath) {
|
|
@@ -339,15 +339,20 @@ function foldConcatsOnly(sourceCode) {
|
|
|
339
339
|
/**
|
|
340
340
|
* Recursively fold string concat BinaryExpression.
|
|
341
341
|
* Returns the concatenated string, or null if any part is not a string literal.
|
|
342
|
+
* Depth limit prevents stack overflow DoS on deeply nested expressions.
|
|
342
343
|
*/
|
|
343
|
-
|
|
344
|
+
const MAX_FOLD_DEPTH = 100;
|
|
345
|
+
|
|
346
|
+
function tryFoldConcat(node, depth) {
|
|
347
|
+
if (depth === undefined) depth = 0;
|
|
348
|
+
if (depth > MAX_FOLD_DEPTH) return null;
|
|
344
349
|
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
345
350
|
return node.value;
|
|
346
351
|
}
|
|
347
352
|
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
348
|
-
const left = tryFoldConcat(node.left);
|
|
353
|
+
const left = tryFoldConcat(node.left, depth + 1);
|
|
349
354
|
if (left === null) return null;
|
|
350
|
-
const right = tryFoldConcat(node.right);
|
|
355
|
+
const right = tryFoldConcat(node.right, depth + 1);
|
|
351
356
|
if (right === null) return null;
|
|
352
357
|
return left + right;
|
|
353
358
|
}
|
package/src/scoring.js
CHANGED
|
@@ -180,12 +180,19 @@ function applyFPReductions(threats, reachableFiles, packageName) {
|
|
|
180
180
|
typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
const totalThreats = threats.length;
|
|
184
|
+
|
|
183
185
|
for (const t of threats) {
|
|
184
186
|
// Count-based downgrade: if a threat type appears too many times,
|
|
185
|
-
// it's a framework/plugin system, not malware
|
|
187
|
+
// it's a framework/plugin system, not malware.
|
|
188
|
+
// Percentage guard: only downgrade if the type is < 50% of total threats.
|
|
189
|
+
// When a type dominates findings (> 50%), it may be real malware, not framework noise.
|
|
186
190
|
const rule = FP_COUNT_THRESHOLDS[t.type];
|
|
187
191
|
if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
|
|
188
|
-
t.
|
|
192
|
+
const typeRatio = typeCounts[t.type] / totalThreats;
|
|
193
|
+
if (typeRatio < 0.5) {
|
|
194
|
+
t.severity = rule.to;
|
|
195
|
+
}
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
// require_cache_poison: single hit → HIGH (plugin dedup/hot-reload, not malware)
|
package/src/shared/download.js
CHANGED
|
@@ -26,9 +26,35 @@ const PRIVATE_IP_PATTERNS = [
|
|
|
26
26
|
/^fe80:/
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Normalize a hostname by unwrapping IPv6-mapped IPv4 addresses
|
|
31
|
+
* and converting decimal IP notation to dotted notation.
|
|
32
|
+
* @param {string} hostname - Raw hostname from URL
|
|
33
|
+
* @returns {string} Normalized hostname for SSRF validation
|
|
34
|
+
*/
|
|
35
|
+
function normalizeHostname(hostname) {
|
|
36
|
+
hostname = hostname.toLowerCase();
|
|
37
|
+
// Unwrap IPv6-mapped IPv4: ::ffff:192.168.1.1 → 192.168.1.1
|
|
38
|
+
if (hostname.startsWith('::ffff:')) {
|
|
39
|
+
const ipv4Part = hostname.slice(7);
|
|
40
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ipv4Part)) {
|
|
41
|
+
return ipv4Part;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Convert decimal IP notation: 2130706433 → 127.0.0.1
|
|
45
|
+
if (/^\d+$/.test(hostname)) {
|
|
46
|
+
const num = parseInt(hostname, 10);
|
|
47
|
+
if (num > 0 && num < 4294967296) {
|
|
48
|
+
return [(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return hostname;
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
/**
|
|
30
55
|
* Validates that a redirect URL is allowed (SSRF protection).
|
|
31
56
|
* Only HTTPS to whitelisted domains is permitted.
|
|
57
|
+
* Normalizes IPv6-mapped IPv4 and decimal IP notation before validation.
|
|
32
58
|
* @param {string} redirectUrl - The redirect target URL
|
|
33
59
|
* @returns {{allowed: boolean, error?: string}}
|
|
34
60
|
*/
|
|
@@ -38,10 +64,11 @@ function isAllowedDownloadRedirect(redirectUrl) {
|
|
|
38
64
|
if (urlObj.protocol !== 'https:') {
|
|
39
65
|
return { allowed: false, error: `Redirect blocked: non-HTTPS protocol ${urlObj.protocol}` };
|
|
40
66
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
const rawHostname = urlObj.hostname.toLowerCase();
|
|
68
|
+
const hostname = normalizeHostname(rawHostname);
|
|
69
|
+
// Block private IP addresses (check both raw and normalized)
|
|
70
|
+
if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname) || p.test(rawHostname))) {
|
|
71
|
+
return { allowed: false, error: `Redirect blocked: private IP ${rawHostname}` };
|
|
45
72
|
}
|
|
46
73
|
const domainAllowed = ALLOWED_DOWNLOAD_DOMAINS.some(domain =>
|
|
47
74
|
hostname === domain || hostname.endsWith('.' + domain)
|
|
@@ -176,6 +203,7 @@ module.exports = {
|
|
|
176
203
|
extractTarGz,
|
|
177
204
|
sanitizePackageName,
|
|
178
205
|
isAllowedDownloadRedirect,
|
|
206
|
+
normalizeHostname,
|
|
179
207
|
ALLOWED_DOWNLOAD_DOMAINS,
|
|
180
208
|
PRIVATE_IP_PATTERNS
|
|
181
209
|
};
|