muaddib-scanner 2.9.8 → 2.9.9
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/config.js +43 -15
- package/src/response/playbooks.js +6 -0
- package/src/rules/index.js +13 -0
- package/src/sandbox/index.js +14 -12
- package/src/scanner/ast-detectors.js +125 -15
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -205,12 +205,23 @@ function validateConfig(raw) {
|
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* Resolve which config file to load.
|
|
208
|
-
* Priority: --config <path> > .muaddibrc.json
|
|
208
|
+
* Priority: --config <path> > ~/.muaddibrc.json > CWD/.muaddibrc.json (if CWD ≠ targetPath)
|
|
209
|
+
*
|
|
210
|
+
* SECURITY: NEVER auto-detect config from targetPath (the scanned directory).
|
|
211
|
+
* An attacker can place .muaddibrc.json in their npm package with
|
|
212
|
+
* severityWeights: {critical:0, high:0, medium:0, low:0} to neutralize the scanner.
|
|
213
|
+
* Only load config from trusted locations:
|
|
214
|
+
* - Explicit --config <path>
|
|
215
|
+
* - User home directory (~/.muaddibrc.json)
|
|
216
|
+
* - CWD, but ONLY when CWD is different from the scan target
|
|
217
|
+
*
|
|
209
218
|
* @param {string} targetPath - scan target directory
|
|
210
219
|
* @param {string|null} configPath - explicit --config path (or null)
|
|
211
220
|
* @returns {{ config: object|null, warnings: string[], errors: string[], source: string|null }}
|
|
212
221
|
*/
|
|
213
222
|
function resolveConfig(targetPath, configPath) {
|
|
223
|
+
const warnings = [];
|
|
224
|
+
|
|
214
225
|
// Explicit --config path
|
|
215
226
|
if (configPath) {
|
|
216
227
|
const absPath = path.isAbsolute(configPath) ? configPath : path.resolve(configPath);
|
|
@@ -229,22 +240,39 @@ function resolveConfig(targetPath, configPath) {
|
|
|
229
240
|
return result;
|
|
230
241
|
}
|
|
231
242
|
|
|
232
|
-
//
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
|
|
243
|
+
// SECURITY: Warn if .muaddibrc.json is found INSIDE the scanned package (informational only)
|
|
244
|
+
const targetRcPath = path.join(targetPath, '.muaddibrc.json');
|
|
245
|
+
if (fs.existsSync(targetRcPath)) {
|
|
246
|
+
warnings.push('[SECURITY] .muaddibrc.json found inside scanned package — ignored (potential config neutralization attack)');
|
|
236
247
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
|
|
249
|
+
// Auto-detect ONLY from safe locations (NOT from scan target)
|
|
250
|
+
const cwd = process.cwd();
|
|
251
|
+
const homedir = require('os').homedir();
|
|
252
|
+
const candidates = [
|
|
253
|
+
path.join(homedir, '.muaddibrc.json'),
|
|
254
|
+
// CWD config only if CWD is NOT the scan target (developer scanning an external package)
|
|
255
|
+
...(path.resolve(cwd) !== path.resolve(targetPath) ? [path.join(cwd, '.muaddibrc.json')] : [])
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
for (const rcPath of candidates) {
|
|
259
|
+
if (!fs.existsSync(rcPath)) continue;
|
|
260
|
+
const { raw, error } = loadConfigFile(rcPath);
|
|
261
|
+
if (error) {
|
|
262
|
+
warnings.push(`[CONFIG] ${error} — ${rcPath} ignored`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const result = validateConfig(raw);
|
|
266
|
+
// Prepend any security warnings accumulated before this config was found
|
|
267
|
+
result.warnings = [...warnings, ...result.warnings];
|
|
268
|
+
if (result.config) {
|
|
269
|
+
result.warnings.unshift(`Loaded custom thresholds from ${rcPath}`);
|
|
270
|
+
}
|
|
271
|
+
result.source = rcPath;
|
|
272
|
+
return result;
|
|
245
273
|
}
|
|
246
|
-
|
|
247
|
-
return
|
|
274
|
+
|
|
275
|
+
return { config: null, warnings, errors: [], source: null };
|
|
248
276
|
}
|
|
249
277
|
|
|
250
278
|
module.exports = { DEFAULTS, loadConfigFile, validateConfig, resolveConfig };
|
|
@@ -537,6 +537,12 @@ const PLAYBOOKS = {
|
|
|
537
537
|
'Cout de rotation: 0.000005 SOL par changement d\'adresse C2 — censorship-resistant. ' +
|
|
538
538
|
'Bloquer les connexions vers les RPC Solana. Supprimer le package.',
|
|
539
539
|
|
|
540
|
+
module_load_bypass:
|
|
541
|
+
'CRITIQUE: Module._load() detecte — bypass du module loader interne de Node.js. ' +
|
|
542
|
+
'Permet de charger dynamiquement des modules (child_process, fs, net) sans passer par require(), ' +
|
|
543
|
+
'contournant les restrictions et les hooks de chargement. ' +
|
|
544
|
+
'Supprimer le package immediatement. Auditer les modules charges dynamiquement.',
|
|
545
|
+
|
|
540
546
|
blockchain_rpc_endpoint:
|
|
541
547
|
'Endpoint RPC blockchain hardcode detecte (Solana mainnet, Infura Ethereum). ' +
|
|
542
548
|
'Dans un package non-crypto, cela indique un potentiel canal C2 via blockchain. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -1595,6 +1595,19 @@ const RULES = {
|
|
|
1595
1595
|
],
|
|
1596
1596
|
mitre: 'T1102'
|
|
1597
1597
|
},
|
|
1598
|
+
module_load_bypass: {
|
|
1599
|
+
id: 'MUADDIB-AST-056',
|
|
1600
|
+
name: 'Module._load() Internal Loader Bypass',
|
|
1601
|
+
severity: 'CRITICAL',
|
|
1602
|
+
confidence: 'high',
|
|
1603
|
+
description: 'Module._load() detecte — bypass du module loader interne de Node.js pour charger dynamiquement des modules sans passer par require(). Technique d\'evasion contournant les restrictions de chargement de modules.',
|
|
1604
|
+
references: [
|
|
1605
|
+
'https://nodejs.org/api/modules.html',
|
|
1606
|
+
'https://attack.mitre.org/techniques/T1059/007/'
|
|
1607
|
+
],
|
|
1608
|
+
mitre: 'T1059.007'
|
|
1609
|
+
},
|
|
1610
|
+
|
|
1598
1611
|
blockchain_rpc_endpoint: {
|
|
1599
1612
|
id: 'MUADDIB-AST-055',
|
|
1600
1613
|
name: 'Hardcoded Blockchain RPC Endpoint',
|
package/src/sandbox/index.js
CHANGED
|
@@ -240,18 +240,8 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
240
240
|
proc.on('close', (code) => {
|
|
241
241
|
clearTimeout(timer);
|
|
242
242
|
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
const errLines = stderr.split(/\r?\n/).filter(l => l && !l.includes('[SANDBOX]'));
|
|
246
|
-
if (errLines.length > 0) {
|
|
247
|
-
console.log(`[SANDBOX] Docker error (exit ${code}): ${errLines[0]}`);
|
|
248
|
-
} else {
|
|
249
|
-
console.log(`[SANDBOX] Container exited with code ${code} (no output)`);
|
|
250
|
-
}
|
|
251
|
-
resolve(cleanResult);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
243
|
+
// TIMEOUT FIRST: docker kill causes non-zero exit (code 137/SIGKILL),
|
|
244
|
+
// must check before Docker error handler to avoid returning CLEAN on timeout
|
|
255
245
|
if (timedOut) {
|
|
256
246
|
const result = {
|
|
257
247
|
score: 100,
|
|
@@ -269,6 +259,18 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
269
259
|
return;
|
|
270
260
|
}
|
|
271
261
|
|
|
262
|
+
// Docker-level failure (non-timeout): log error and return clean result
|
|
263
|
+
if (code !== 0 && !stdout.includes('---MUADDIB-REPORT-START---')) {
|
|
264
|
+
const errLines = stderr.split(/\r?\n/).filter(l => l && !l.includes('[SANDBOX]'));
|
|
265
|
+
if (errLines.length > 0) {
|
|
266
|
+
console.log(`[SANDBOX] Docker error (exit ${code}): ${errLines[0]}`);
|
|
267
|
+
} else {
|
|
268
|
+
console.log(`[SANDBOX] Container exited with code ${code} (no output)`);
|
|
269
|
+
}
|
|
270
|
+
resolve(cleanResult);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
272
274
|
// Parse JSON from container stdout using delimiter
|
|
273
275
|
let report;
|
|
274
276
|
try {
|
|
@@ -281,6 +281,40 @@ function resolveStringConcat(node) {
|
|
|
281
281
|
return null;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Like resolveStringConcat, but additionally resolves Identifier nodes via
|
|
286
|
+
* a stringVarValues Map (variable name → known string value).
|
|
287
|
+
* Used for double-indirection patterns: var a='ev',b='al'; globalThis[a+b]()
|
|
288
|
+
*/
|
|
289
|
+
function resolveStringConcatWithVars(node, stringVarValues) {
|
|
290
|
+
if (!node) return null;
|
|
291
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
|
|
292
|
+
if (node.type === 'Identifier' && stringVarValues && stringVarValues.has(node.name)) {
|
|
293
|
+
return stringVarValues.get(node.name);
|
|
294
|
+
}
|
|
295
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
296
|
+
return node.quasis.map(q => q.value.raw).join('');
|
|
297
|
+
}
|
|
298
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length > 0) {
|
|
299
|
+
const parts = [];
|
|
300
|
+
for (let i = 0; i < node.quasis.length; i++) {
|
|
301
|
+
parts.push(node.quasis[i].value.raw);
|
|
302
|
+
if (i < node.expressions.length) {
|
|
303
|
+
const resolved = resolveStringConcatWithVars(node.expressions[i], stringVarValues);
|
|
304
|
+
if (resolved === null) return null;
|
|
305
|
+
parts.push(resolved);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return parts.join('');
|
|
309
|
+
}
|
|
310
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
311
|
+
const left = resolveStringConcatWithVars(node.left, stringVarValues);
|
|
312
|
+
const right = resolveStringConcatWithVars(node.right, stringVarValues);
|
|
313
|
+
if (left !== null && right !== null) return left + right;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
284
318
|
/**
|
|
285
319
|
* Extract string value from a node, including BinaryExpression resolution.
|
|
286
320
|
* Falls back to extractStringValue if concat resolution fails.
|
|
@@ -383,7 +417,7 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
383
417
|
ctx.staticAssignments.add(node.id.name);
|
|
384
418
|
}
|
|
385
419
|
|
|
386
|
-
// Track dynamic require vars
|
|
420
|
+
// Track dynamic require vars + module aliases
|
|
387
421
|
if (node.init?.type === 'CallExpression') {
|
|
388
422
|
const initCallName = getCallName(node.init);
|
|
389
423
|
if (initCallName === 'require' && node.init.arguments.length > 0) {
|
|
@@ -391,6 +425,12 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
391
425
|
if (arg.type !== 'Literal') {
|
|
392
426
|
ctx.dynamicRequireVars.add(node.id.name);
|
|
393
427
|
}
|
|
428
|
+
// Track require('module') or require('node:module') aliases for Module._load detection
|
|
429
|
+
const reqVal = extractStringValueDeep(arg);
|
|
430
|
+
if (reqVal === 'module') {
|
|
431
|
+
if (!ctx.moduleAliases) ctx.moduleAliases = new Set();
|
|
432
|
+
ctx.moduleAliases.add(node.id.name);
|
|
433
|
+
}
|
|
394
434
|
}
|
|
395
435
|
}
|
|
396
436
|
// Track variables assigned dangerous command strings
|
|
@@ -717,6 +757,35 @@ function handleCallExpression(node, ctx) {
|
|
|
717
757
|
}
|
|
718
758
|
}
|
|
719
759
|
|
|
760
|
+
// Detect process.mainModule.require('child_process') — module system bypass
|
|
761
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
762
|
+
node.callee.property?.type === 'Identifier' && node.callee.property.name === 'require' &&
|
|
763
|
+
node.callee.object?.type === 'MemberExpression' &&
|
|
764
|
+
node.callee.object.object?.type === 'Identifier' &&
|
|
765
|
+
node.callee.object.object.name === 'process' &&
|
|
766
|
+
node.callee.object.property?.type === 'Identifier' &&
|
|
767
|
+
node.callee.object.property.name === 'mainModule' &&
|
|
768
|
+
node.arguments.length > 0) {
|
|
769
|
+
const arg = node.arguments[0];
|
|
770
|
+
const modName = extractStringValueDeep(arg);
|
|
771
|
+
const DANGEROUS_MODS = ['child_process', 'fs', 'net', 'dns', 'http', 'https', 'tls'];
|
|
772
|
+
if (modName && DANGEROUS_MODS.includes(modName)) {
|
|
773
|
+
ctx.threats.push({
|
|
774
|
+
type: 'dynamic_require',
|
|
775
|
+
severity: 'CRITICAL',
|
|
776
|
+
message: `process.mainModule.require('${modName}') — bypasses module system restrictions.`,
|
|
777
|
+
file: ctx.relFile
|
|
778
|
+
});
|
|
779
|
+
} else {
|
|
780
|
+
ctx.threats.push({
|
|
781
|
+
type: 'dynamic_require',
|
|
782
|
+
severity: 'HIGH',
|
|
783
|
+
message: `process.mainModule.require() detected — module system bypass.`,
|
|
784
|
+
file: ctx.relFile
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
720
789
|
// Detect exec/execSync with dangerous shell commands (direct or via MemberExpression)
|
|
721
790
|
const execName = callName === 'exec' || callName === 'execSync' ? callName : null;
|
|
722
791
|
const memberExec = !execName && node.callee.type === 'MemberExpression' &&
|
|
@@ -1490,22 +1559,45 @@ function handleCallExpression(node, ctx) {
|
|
|
1490
1559
|
});
|
|
1491
1560
|
}
|
|
1492
1561
|
}
|
|
1493
|
-
// Detect computed call on globalThis/global alias with variable property
|
|
1562
|
+
// Detect computed call on globalThis/global alias with variable or expression property
|
|
1494
1563
|
const obj = node.callee.object;
|
|
1495
|
-
if (
|
|
1564
|
+
if (obj?.type === 'Identifier' &&
|
|
1496
1565
|
(ctx.globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1566
|
+
if (prop.type === 'Identifier') {
|
|
1567
|
+
ctx.hasEvalInFile = true;
|
|
1568
|
+
// Resolve variable value via stringVarValues (e.g., const f = 'eval'; globalThis[f]())
|
|
1569
|
+
const resolvedValue = ctx.stringVarValues.get(prop.name);
|
|
1570
|
+
const isEvalOrFunction = resolvedValue === 'eval' || resolvedValue === 'Function';
|
|
1571
|
+
ctx.threats.push({
|
|
1572
|
+
type: 'dangerous_call_eval',
|
|
1573
|
+
severity: isEvalOrFunction ? 'CRITICAL' : 'HIGH',
|
|
1574
|
+
message: isEvalOrFunction
|
|
1575
|
+
? `Resolved indirect ${resolvedValue}() via computed property (${obj.name}[${prop.name}="${resolvedValue}"]) — confirmed eval evasion.`
|
|
1576
|
+
: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
|
|
1577
|
+
file: ctx.relFile
|
|
1578
|
+
});
|
|
1579
|
+
} else {
|
|
1580
|
+
// BinaryExpression, TemplateLiteral, or other computed expression
|
|
1581
|
+
// Try to resolve via stringVarValues (e.g., var a='ev',b='al'; globalThis[a+b]())
|
|
1582
|
+
const resolvedProp = resolveStringConcatWithVars(prop, ctx.stringVarValues);
|
|
1583
|
+
if (resolvedProp === 'eval' || resolvedProp === 'Function') {
|
|
1584
|
+
ctx.hasEvalInFile = true;
|
|
1585
|
+
ctx.threats.push({
|
|
1586
|
+
type: 'dangerous_call_eval',
|
|
1587
|
+
severity: 'CRITICAL',
|
|
1588
|
+
message: `Resolved indirect ${resolvedProp}() via computed expression (${obj.name}[...="${resolvedProp}"]) — concat evasion.`,
|
|
1589
|
+
file: ctx.relFile
|
|
1590
|
+
});
|
|
1591
|
+
} else if (resolvedProp !== null) {
|
|
1592
|
+
ctx.hasEvalInFile = true;
|
|
1593
|
+
ctx.threats.push({
|
|
1594
|
+
type: 'dangerous_call_eval',
|
|
1595
|
+
severity: 'HIGH',
|
|
1596
|
+
message: `Dynamic global dispatch via computed expression (${obj.name}[...="${resolvedProp}"]).`,
|
|
1597
|
+
file: ctx.relFile
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1509
1601
|
}
|
|
1510
1602
|
}
|
|
1511
1603
|
|
|
@@ -1609,6 +1701,24 @@ function handleCallExpression(node, ctx) {
|
|
|
1609
1701
|
}
|
|
1610
1702
|
}
|
|
1611
1703
|
|
|
1704
|
+
// Module._load() — internal module loader bypass (ANSSI audit v2)
|
|
1705
|
+
if (propName === '_load') {
|
|
1706
|
+
const calleeObj = node.callee.object;
|
|
1707
|
+
const isModuleIdentifier = calleeObj.type === 'Identifier' &&
|
|
1708
|
+
(calleeObj.name === 'Module' || calleeObj.name === 'module' ||
|
|
1709
|
+
(ctx.moduleAliases && ctx.moduleAliases.has(calleeObj.name)));
|
|
1710
|
+
const isMemberChain = calleeObj.type === 'MemberExpression';
|
|
1711
|
+
const isConstructed = calleeObj.type === 'NewExpression' || calleeObj.type === 'CallExpression';
|
|
1712
|
+
if (isModuleIdentifier || isMemberChain || isConstructed || ctx.hasModuleImport) {
|
|
1713
|
+
ctx.threats.push({
|
|
1714
|
+
type: 'module_load_bypass',
|
|
1715
|
+
severity: 'CRITICAL',
|
|
1716
|
+
message: 'Module._load() detected — internal module loader bypass for dynamic code loading.',
|
|
1717
|
+
file: ctx.relFile
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1612
1722
|
// SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
|
|
1613
1723
|
if (propName === 'writeFileSync' || propName === 'writeFile') {
|
|
1614
1724
|
const arg = node.arguments && node.arguments[0];
|