muaddib-scanner 2.5.2 → 2.5.4
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/sandbox/analyzer.js +23 -1
- package/src/sandbox/index.js +21 -6
- package/src/scanner/ast-detectors.js +160 -0
- package/src/scanner/ast.js +3 -1
package/package.json
CHANGED
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;
|
|
@@ -38,6 +39,7 @@ function analyzePreloadLog(logContent) {
|
|
|
38
39
|
const networkLines = [];
|
|
39
40
|
const execLines = [];
|
|
40
41
|
const envLines = [];
|
|
42
|
+
const nativeAddonLines = [];
|
|
41
43
|
|
|
42
44
|
for (const line of lines) {
|
|
43
45
|
if (line.includes('TIMER:')) {
|
|
@@ -52,6 +54,8 @@ function analyzePreloadLog(logContent) {
|
|
|
52
54
|
execLines.push(line);
|
|
53
55
|
} else if (line.includes('ENV_ACCESS:')) {
|
|
54
56
|
envLines.push(line);
|
|
57
|
+
} else if (line.includes('NATIVE_ADDON:')) {
|
|
58
|
+
nativeAddonLines.push(line);
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -173,6 +177,24 @@ function analyzePreloadLog(logContent) {
|
|
|
173
177
|
});
|
|
174
178
|
}
|
|
175
179
|
|
|
180
|
+
// ── Rule 7: Native addon loading ──
|
|
181
|
+
// Native addons (.node files) can bypass all JS monkey-patches via syscalls.
|
|
182
|
+
// Flag their loading so analysts know time-based evasion may be undetected.
|
|
183
|
+
if (nativeAddonLines.length > 0) {
|
|
184
|
+
const addons = nativeAddonLines.map(l => {
|
|
185
|
+
const m = l.match(/process\.dlopen:\s*(.+?)(?:\s+\(t\+|$)/);
|
|
186
|
+
return m ? m[1].trim() : 'unknown';
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
score += 15;
|
|
190
|
+
findings.push({
|
|
191
|
+
type: 'sandbox_native_addon_load',
|
|
192
|
+
severity: 'MEDIUM',
|
|
193
|
+
detail: `Native addon loaded (${addons.length}): time-based evasion via syscalls possible`,
|
|
194
|
+
evidence: addons.join(', ')
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
176
198
|
return {
|
|
177
199
|
score: Math.min(100, score),
|
|
178
200
|
findings
|
package/src/sandbox/index.js
CHANGED
|
@@ -171,14 +171,18 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
171
171
|
dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Inject time offset
|
|
174
|
+
// Inject time offset (preload.js deferred to entry point in sandbox-runner.sh)
|
|
175
175
|
dockerArgs.push('-e', `MUADDIB_TIME_OFFSET_MS=${timeOffset}`);
|
|
176
|
-
dockerArgs.push('-e', 'NODE_OPTIONS=--require /opt/preload.js');
|
|
177
176
|
|
|
178
177
|
// Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
|
|
179
178
|
// Strict mode also needs NET_ADMIN for iptables network blocking.
|
|
180
179
|
// SYS_PTRACE is not needed: strace traces its own child (npm install via su).
|
|
180
|
+
// SETUID + SETGID required for su (privilege drop to sandboxuser).
|
|
181
|
+
// CHOWN required for chown in sandbox-runner.sh.
|
|
181
182
|
dockerArgs.push('--cap-add=NET_RAW');
|
|
183
|
+
dockerArgs.push('--cap-add=SETUID');
|
|
184
|
+
dockerArgs.push('--cap-add=SETGID');
|
|
185
|
+
dockerArgs.push('--cap-add=CHOWN');
|
|
182
186
|
if (strict) {
|
|
183
187
|
dockerArgs.push('--cap-add=NET_ADMIN');
|
|
184
188
|
}
|
|
@@ -188,9 +192,8 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
188
192
|
dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
|
|
189
193
|
dockerArgs.push('--read-only');
|
|
190
194
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
dockerArgs.push('--tmpfs', '/proc/uptime:ro,size=4k');
|
|
195
|
+
// /proc/uptime evasion (T1497.003) handled by preload.js monkey-patching
|
|
196
|
+
// (process.uptime, Date.now, performance.now, process.hrtime)
|
|
194
197
|
|
|
195
198
|
dockerArgs.push('--security-opt', 'no-new-privileges');
|
|
196
199
|
|
|
@@ -230,9 +233,21 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
230
233
|
}
|
|
231
234
|
});
|
|
232
235
|
|
|
233
|
-
proc.on('close', () => {
|
|
236
|
+
proc.on('close', (code) => {
|
|
234
237
|
clearTimeout(timer);
|
|
235
238
|
|
|
239
|
+
// Docker-level failure: log error and return clean result
|
|
240
|
+
if (code !== 0 && !stdout.includes('---MUADDIB-REPORT-START---')) {
|
|
241
|
+
const errLines = stderr.split(/\r?\n/).filter(l => l && !l.includes('[SANDBOX]'));
|
|
242
|
+
if (errLines.length > 0) {
|
|
243
|
+
console.log(`[SANDBOX] Docker error (exit ${code}): ${errLines[0]}`);
|
|
244
|
+
} else {
|
|
245
|
+
console.log(`[SANDBOX] Container exited with code ${code} (no output)`);
|
|
246
|
+
}
|
|
247
|
+
resolve(cleanResult);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
236
251
|
if (timedOut) {
|
|
237
252
|
const result = {
|
|
238
253
|
score: 100,
|
|
@@ -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
|