muaddib-scanner 2.9.5 → 2.9.6
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/bin/muaddib.js +3 -2
- package/package.json +1 -1
- package/src/index.js +8 -7
- package/src/scanner/ast-detectors.js +7 -2
- package/src/scanner/deobfuscate.js +21 -0
- package/src/scanner/shell.js +4 -4
- package/src/scoring.js +30 -4
package/bin/muaddib.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const {
|
|
2
|
+
const { execFile } = require('child_process');
|
|
3
3
|
const { run } = require('../src/index.js');
|
|
4
4
|
const { updateIOCs } = require('../src/ioc/updater.js');
|
|
5
5
|
const { watch } = require('../src/watch.js');
|
|
@@ -160,7 +160,8 @@ for (let i = 0; i < options.length; i++) {
|
|
|
160
160
|
if (!jsonOutput && !sarifOutput && command !== 'feed' && command !== 'serve') {
|
|
161
161
|
try {
|
|
162
162
|
const currentVersion = require('../package.json').version;
|
|
163
|
-
|
|
163
|
+
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
164
|
+
execFile(npmBin, ['view', 'muaddib-scanner', 'version'], { timeout: 5000 }, (err, stdout) => {
|
|
164
165
|
if (err) return; // No network or npm unavailable
|
|
165
166
|
const latest = (stdout || '').toString().trim();
|
|
166
167
|
if (!latest || latest === currentVersion) return;
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -186,8 +186,8 @@ function scanParanoid(targetPath) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
|
-
} catch {
|
|
190
|
-
|
|
189
|
+
} catch (e) {
|
|
190
|
+
debugLog('[PARANOID] AST parse error:', e?.message);
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
@@ -218,8 +218,8 @@ function scanParanoid(targetPath) {
|
|
|
218
218
|
const relFile = path.relative(targetPath, filePath);
|
|
219
219
|
scanFileContent(filePath, content, relFile);
|
|
220
220
|
}
|
|
221
|
-
} catch {
|
|
222
|
-
|
|
221
|
+
} catch (e) {
|
|
222
|
+
debugLog('[PARANOID] file read error:', e?.message);
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
@@ -247,8 +247,8 @@ function scanParanoid(targetPath) {
|
|
|
247
247
|
scanFile(fullPath);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
-
} catch {
|
|
251
|
-
|
|
250
|
+
} catch (e) {
|
|
251
|
+
debugLog('[PARANOID] walkDir error:', e?.message);
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
@@ -550,7 +550,8 @@ async function run(targetPath, options = {}) {
|
|
|
550
550
|
if (!reachability.skipped) {
|
|
551
551
|
reachableFiles = reachability.reachableFiles;
|
|
552
552
|
}
|
|
553
|
-
} catch {
|
|
553
|
+
} catch (e) {
|
|
554
|
+
debugLog('[REACHABILITY] error:', e?.message);
|
|
554
555
|
// Graceful fallback — treat all files as reachable
|
|
555
556
|
}
|
|
556
557
|
}
|
|
@@ -1495,10 +1495,15 @@ function handleCallExpression(node, ctx) {
|
|
|
1495
1495
|
if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
|
|
1496
1496
|
(ctx.globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
|
|
1497
1497
|
ctx.hasEvalInFile = true;
|
|
1498
|
+
// Resolve variable value via stringVarValues (e.g., const f = 'eval'; globalThis[f]())
|
|
1499
|
+
const resolvedValue = ctx.stringVarValues.get(prop.name);
|
|
1500
|
+
const isEvalOrFunction = resolvedValue === 'eval' || resolvedValue === 'Function';
|
|
1498
1501
|
ctx.threats.push({
|
|
1499
1502
|
type: 'dangerous_call_eval',
|
|
1500
|
-
severity: 'HIGH',
|
|
1501
|
-
message:
|
|
1503
|
+
severity: isEvalOrFunction ? 'CRITICAL' : 'HIGH',
|
|
1504
|
+
message: isEvalOrFunction
|
|
1505
|
+
? `Resolved indirect ${resolvedValue}() via computed property (${obj.name}[${prop.name}="${resolvedValue}"]) — confirmed eval evasion.`
|
|
1506
|
+
: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
|
|
1502
1507
|
file: ctx.relFile
|
|
1503
1508
|
});
|
|
1504
1509
|
}
|
|
@@ -154,6 +154,27 @@ function deobfuscate(sourceCode) {
|
|
|
154
154
|
const hexResult = tryResolveHexArrayMap(node, sourceCode);
|
|
155
155
|
if (hexResult !== null) {
|
|
156
156
|
replacements.push(hexResult);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- 5. ARRAY JOIN ----
|
|
161
|
+
// ['e','v','a','l'].join('') → "eval"
|
|
162
|
+
if (node.callee?.type === 'MemberExpression' &&
|
|
163
|
+
node.callee.property?.name === 'join' &&
|
|
164
|
+
node.callee.object?.type === 'ArrayExpression' &&
|
|
165
|
+
node.arguments?.length === 1 &&
|
|
166
|
+
node.arguments[0]?.type === 'Literal' &&
|
|
167
|
+
node.arguments[0].value === '') {
|
|
168
|
+
const elements = node.callee.object.elements;
|
|
169
|
+
if (elements.length > 0 && elements.every(el => el?.type === 'Literal' && typeof el.value === 'string')) {
|
|
170
|
+
const joined = elements.map(el => el.value).join('');
|
|
171
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
172
|
+
replacements.push({
|
|
173
|
+
start: node.start, end: node.end,
|
|
174
|
+
value: quoteString(joined),
|
|
175
|
+
type: 'array_join', before
|
|
176
|
+
});
|
|
177
|
+
}
|
|
157
178
|
}
|
|
158
179
|
}
|
|
159
180
|
});
|
package/src/scanner/shell.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile, debugLog } = require('../utils.js');
|
|
4
4
|
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
5
5
|
|
|
6
6
|
const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
@@ -56,7 +56,7 @@ function scanFileContent(file, content, targetPath, threats) {
|
|
|
56
56
|
function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
|
|
57
57
|
if (depth > 20) return results;
|
|
58
58
|
let items;
|
|
59
|
-
try { items = fs.readdirSync(dir); } catch { return results; }
|
|
59
|
+
try { items = fs.readdirSync(dir); } catch (e) { debugLog('[SHELL] readdirSync error:', e?.message); return results; }
|
|
60
60
|
|
|
61
61
|
for (const item of items) {
|
|
62
62
|
if (excludedDirs.includes(item)) continue;
|
|
@@ -69,7 +69,7 @@ function findExtensionlessFiles(dir, excludedDirs, results = [], depth = 0) {
|
|
|
69
69
|
} else if (lstat.isFile() && !path.extname(item) && lstat.size <= MAX_FILE_SIZE) {
|
|
70
70
|
results.push(fullPath);
|
|
71
71
|
}
|
|
72
|
-
} catch {
|
|
72
|
+
} catch (e) { debugLog('[SHELL] stat error:', e?.message); }
|
|
73
73
|
}
|
|
74
74
|
return results;
|
|
75
75
|
}
|
|
@@ -94,7 +94,7 @@ async function scanShellScripts(targetPath) {
|
|
|
94
94
|
if (SHEBANG_RE.test(firstLine)) {
|
|
95
95
|
scanFileContent(file, content, targetPath, threats);
|
|
96
96
|
}
|
|
97
|
-
} catch {
|
|
97
|
+
} catch (e) { debugLog('[SHELL] readFile error:', e?.message); }
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
return threats;
|
package/src/scoring.js
CHANGED
|
@@ -279,11 +279,11 @@ function applyCompoundBoosts(threats) {
|
|
|
279
279
|
|
|
280
280
|
// Check all required types are present
|
|
281
281
|
if (compound.requires.every(req => typeSet.has(req))) {
|
|
282
|
-
// Severity gate: at least one component must have severity >= MEDIUM
|
|
283
|
-
//
|
|
284
|
-
//
|
|
282
|
+
// Severity gate: at least one component must have had original severity >= MEDIUM.
|
|
283
|
+
// Uses originalSeverity (pre-FP-reduction) to prevent attackers from
|
|
284
|
+
// manipulating compound gates via count-threshold or dist-file downgrades.
|
|
285
285
|
const hasSignificantComponent = compound.requires.some(req =>
|
|
286
|
-
threats.some(t => t.type === req && t.severity !== 'LOW')
|
|
286
|
+
threats.some(t => t.type === req && (t.originalSeverity || t.severity) !== 'LOW')
|
|
287
287
|
);
|
|
288
288
|
if (!hasSignificantComponent) continue;
|
|
289
289
|
|
|
@@ -323,8 +323,11 @@ const FRAMEWORK_PROTO_RE = new RegExp(
|
|
|
323
323
|
|
|
324
324
|
function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
325
325
|
// Initialize reductions audit trail on each threat
|
|
326
|
+
// Store original severity before any FP reductions, so compound
|
|
327
|
+
// severity gates can check pre-reduction severity (GAP 4b).
|
|
326
328
|
for (const t of threats) {
|
|
327
329
|
t.reductions = [];
|
|
330
|
+
t.originalSeverity = t.severity;
|
|
328
331
|
}
|
|
329
332
|
|
|
330
333
|
// Count occurrences of each threat type (package-level, across all files)
|
|
@@ -384,6 +387,29 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
|
|
|
384
387
|
// The READ/WRITE distinction in ast-detectors already handles the FP case:
|
|
385
388
|
// READ-only → LOW (hot-reload, introspection), WRITE → CRITICAL (malicious replacement).
|
|
386
389
|
// A single cache WRITE is genuinely malicious — no downgrade needed.
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Dilution floor: retain at least one instance at original severity per type
|
|
393
|
+
// to prevent complete count-threshold dilution by injected benign patterns.
|
|
394
|
+
// Only applies to types with low maxCount (≤3) and a severity constraint (from field),
|
|
395
|
+
// where injection of benign patterns is feasible. High-count types (dynamic_require,
|
|
396
|
+
// env_access) and unconstrained types (suspicious_dataflow) represent legitimate
|
|
397
|
+
// framework patterns and should allow full downgrade.
|
|
398
|
+
const restoredTypes = new Set();
|
|
399
|
+
for (const t of threats) {
|
|
400
|
+
const lastReduction = t.reductions?.find(r => r.rule === 'count_threshold');
|
|
401
|
+
if (lastReduction && !restoredTypes.has(t.type)) {
|
|
402
|
+
const rule = FP_COUNT_THRESHOLDS[t.type];
|
|
403
|
+
if (rule && rule.from && rule.maxCount <= 3) {
|
|
404
|
+
t.severity = lastReduction.from;
|
|
405
|
+
t.reductions = t.reductions.filter(r => r.rule !== 'count_threshold');
|
|
406
|
+
t.reductions.push({ rule: 'count_threshold_floor', note: 'retained one instance at original severity' });
|
|
407
|
+
restoredTypes.add(t.type);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const t of threats) {
|
|
387
413
|
|
|
388
414
|
// Prototype hook: framework class prototypes → MEDIUM
|
|
389
415
|
// Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
|