muaddib-scanner 2.2.28 → 2.3.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/bin/muaddib.js +6 -1
- package/package.json +1 -1
- package/src/index.js +15 -1
- package/src/scanner/ast-detectors.js +13 -20
- package/src/scanner/ast.js +31 -1
- package/src/scanner/module-graph.js +886 -883
- package/src/scanner/reachability.js +329 -0
- package/src/scanner/typosquat.js +535 -511
- package/src/scoring.js +253 -213
package/bin/muaddib.js
CHANGED
|
@@ -33,6 +33,7 @@ let temporalFullMode = false;
|
|
|
33
33
|
let breakdownMode = false;
|
|
34
34
|
let noDeobfuscate = false;
|
|
35
35
|
let noModuleGraph = false;
|
|
36
|
+
let noReachability = false;
|
|
36
37
|
let feedLimit = null;
|
|
37
38
|
let feedSeverity = null;
|
|
38
39
|
let feedSince = null;
|
|
@@ -122,6 +123,8 @@ for (let i = 0; i < options.length; i++) {
|
|
|
122
123
|
noDeobfuscate = true;
|
|
123
124
|
} else if (options[i] === '--no-module-graph') {
|
|
124
125
|
noModuleGraph = true;
|
|
126
|
+
} else if (options[i] === '--no-reachability') {
|
|
127
|
+
noReachability = true;
|
|
125
128
|
} else if (options[i] === '--temporal') {
|
|
126
129
|
temporalMode = true;
|
|
127
130
|
} else if (options[i] === '--limit') {
|
|
@@ -415,6 +418,7 @@ const helpText = `
|
|
|
415
418
|
--no-canary Disable honey token injection in sandbox
|
|
416
419
|
--no-deobfuscate Disable deobfuscation pre-processing
|
|
417
420
|
--no-module-graph Disable cross-file dataflow analysis
|
|
421
|
+
--no-reachability Disable entry-point reachability analysis
|
|
418
422
|
--exclude [dir] Exclude directory from scan (repeatable)
|
|
419
423
|
--limit [n] Limit feed entries (default: 50)
|
|
420
424
|
--severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
|
|
@@ -461,7 +465,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
461
465
|
entropyThreshold: entropyThreshold,
|
|
462
466
|
breakdown: breakdownMode,
|
|
463
467
|
noDeobfuscate: noDeobfuscate,
|
|
464
|
-
noModuleGraph: noModuleGraph
|
|
468
|
+
noModuleGraph: noModuleGraph,
|
|
469
|
+
noReachability: noReachability
|
|
465
470
|
}).then(exitCode => {
|
|
466
471
|
process.exit(exitCode);
|
|
467
472
|
}).catch(err => {
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const { scanEntropy } = require('./scanner/entropy.js');
|
|
|
19
19
|
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
20
20
|
const { deobfuscate } = require('./scanner/deobfuscate.js');
|
|
21
21
|
const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
|
|
22
|
+
const { computeReachableFiles } = require('./scanner/reachability.js');
|
|
22
23
|
const { runTemporalAnalyses } = require('./temporal-runner.js');
|
|
23
24
|
const { formatOutput } = require('./output-formatter.js');
|
|
24
25
|
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache } = require('./utils.js');
|
|
@@ -333,9 +334,22 @@ async function run(targetPath, options = {}) {
|
|
|
333
334
|
}
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
// Reachability analysis: determine which files are reachable from entry points
|
|
338
|
+
let reachableFiles = null;
|
|
339
|
+
if (!options.noReachability) {
|
|
340
|
+
try {
|
|
341
|
+
const reachability = computeReachableFiles(targetPath);
|
|
342
|
+
if (!reachability.skipped) {
|
|
343
|
+
reachableFiles = reachability.reachableFiles;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
// Graceful fallback — treat all files as reachable
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
336
350
|
// FP reduction: legitimate frameworks produce high volumes of certain threat types.
|
|
337
351
|
// A malware package typically has 1-3 occurrences, not dozens.
|
|
338
|
-
applyFPReductions(deduped);
|
|
352
|
+
applyFPReductions(deduped, reachableFiles);
|
|
339
353
|
|
|
340
354
|
// Enrich each threat with rules
|
|
341
355
|
const enrichedThreats = deduped.map(t => {
|
|
@@ -808,7 +808,7 @@ function handleCallExpression(node, ctx) {
|
|
|
808
808
|
});
|
|
809
809
|
}
|
|
810
810
|
// Module._compile counts as temp file exec for write-execute-delete pattern
|
|
811
|
-
ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.
|
|
811
|
+
ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasDevShmInContent;
|
|
812
812
|
}
|
|
813
813
|
|
|
814
814
|
// SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
|
|
@@ -816,13 +816,13 @@ function handleCallExpression(node, ctx) {
|
|
|
816
816
|
const arg = node.arguments && node.arguments[0];
|
|
817
817
|
if (arg) {
|
|
818
818
|
const strVal = extractStringValue(arg);
|
|
819
|
-
if (strVal &&
|
|
819
|
+
if (strVal && /\/dev\/shm\b/.test(strVal)) {
|
|
820
820
|
ctx.hasTempFileWrite = true;
|
|
821
821
|
}
|
|
822
|
-
// Variable reference to
|
|
822
|
+
// Variable reference to /dev/shm path
|
|
823
823
|
if (!strVal && (arg.type === 'Identifier' || arg.type === 'CallExpression' || arg.type === 'MemberExpression')) {
|
|
824
|
-
// Dynamic path — check if file content involves
|
|
825
|
-
ctx.hasTempFileWrite = ctx.hasTempFileWrite || ctx.
|
|
824
|
+
// Dynamic path — check if file content involves /dev/shm
|
|
825
|
+
ctx.hasTempFileWrite = ctx.hasTempFileWrite || ctx.hasDevShmInContent;
|
|
826
826
|
}
|
|
827
827
|
}
|
|
828
828
|
}
|
|
@@ -837,10 +837,10 @@ function handleCallExpression(node, ctx) {
|
|
|
837
837
|
if (callName === 'require' && node.arguments.length > 0) {
|
|
838
838
|
const arg = node.arguments[0];
|
|
839
839
|
const strVal = extractStringValue(arg);
|
|
840
|
-
if (strVal &&
|
|
840
|
+
if (strVal && /\/dev\/shm\b/.test(strVal)) {
|
|
841
841
|
ctx.hasTempFileExec = true;
|
|
842
|
-
} else if (!strVal && ctx.
|
|
843
|
-
// Variable argument in a file that references
|
|
842
|
+
} else if (!strVal && ctx.hasDevShmInContent) {
|
|
843
|
+
// Variable argument in a file that references /dev/shm
|
|
844
844
|
ctx.hasTempFileExec = true;
|
|
845
845
|
}
|
|
846
846
|
}
|
|
@@ -860,16 +860,7 @@ function handleCallExpression(node, ctx) {
|
|
|
860
860
|
}
|
|
861
861
|
}
|
|
862
862
|
|
|
863
|
-
// SANDWORM_MODE R8:
|
|
864
|
-
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
865
|
-
const dnsPropName = node.callee.property.name;
|
|
866
|
-
if (['resolve', 'resolve4', 'resolveTxt', 'resolveCname'].includes(dnsPropName)) {
|
|
867
|
-
// Set hasDnsLoop if file has dns require + base64 encoding (co-occurrence checked in postWalk)
|
|
868
|
-
if (ctx.hasDnsRequire && ctx.hasBase64Encode) {
|
|
869
|
-
ctx.hasDnsLoop = true;
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
863
|
+
// SANDWORM_MODE R8: dns.resolve detection moved to walk.ancestor() in ast.js (FIX 5)
|
|
873
864
|
}
|
|
874
865
|
|
|
875
866
|
function handleImportExpression(node, ctx) {
|
|
@@ -1086,9 +1077,11 @@ function handleMemberExpression(node, ctx) {
|
|
|
1086
1077
|
function handlePostWalk(ctx) {
|
|
1087
1078
|
// SANDWORM_MODE: zlib inflate + base64 decode + eval/Function/Module._compile = obfuscated payload
|
|
1088
1079
|
if (ctx.hasZlibInflate && ctx.hasBase64Decode && ctx.hasDynamicExec) {
|
|
1080
|
+
// FIX 4: dist/build files get LOW severity (bundlers legitimately use zlib+base64+eval)
|
|
1081
|
+
const isDistFile = /^(dist|build)[/\\]/i.test(ctx.relFile) || /\.bundle\.js$/i.test(ctx.relFile);
|
|
1089
1082
|
ctx.threats.push({
|
|
1090
1083
|
type: 'zlib_inflate_eval',
|
|
1091
|
-
severity: 'CRITICAL',
|
|
1084
|
+
severity: isDistFile ? 'LOW' : 'CRITICAL',
|
|
1092
1085
|
message: 'Obfuscated payload: zlib inflate + base64 decode + dynamic execution. No legitimate package uses this pattern.',
|
|
1093
1086
|
file: ctx.relFile
|
|
1094
1087
|
});
|
|
@@ -1105,7 +1098,7 @@ function handlePostWalk(ctx) {
|
|
|
1105
1098
|
}
|
|
1106
1099
|
|
|
1107
1100
|
// SANDWORM_MODE R7: env harvesting = Object.entries/keys/values(process.env) + sensitive pattern in file
|
|
1108
|
-
if (ctx.hasEnvEnumeration && ctx.hasEnvHarvestPattern) {
|
|
1101
|
+
if (ctx.hasEnvEnumeration && ctx.hasEnvHarvestPattern && ctx.hasNetworkCallInFile) {
|
|
1109
1102
|
ctx.threats.push({
|
|
1110
1103
|
type: 'env_harvesting_dynamic',
|
|
1111
1104
|
severity: 'HIGH',
|
package/src/scanner/ast.js
CHANGED
|
@@ -84,10 +84,11 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
84
84
|
hasTempFileWrite: false,
|
|
85
85
|
hasTempFileExec: false,
|
|
86
86
|
hasFileDelete: false,
|
|
87
|
-
|
|
87
|
+
hasDevShmInContent: /\/dev\/shm\b/.test(content),
|
|
88
88
|
// SANDWORM_MODE P2: env harvesting co-occurrence
|
|
89
89
|
hasEnvEnumeration: false, // Object.entries/keys/values(process.env)
|
|
90
90
|
hasEnvHarvestPattern: /\b(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|NPM|AWS|SSH|WEBHOOK)\b/.test(content),
|
|
91
|
+
hasNetworkCallInFile: /\b(fetch|https?\.request|https?\.get|dns\.resolve)\b/.test(content),
|
|
91
92
|
// SANDWORM_MODE P2: DNS exfiltration co-occurrence
|
|
92
93
|
hasDnsRequire: /\brequire\s*\(\s*['"]dns['"]\s*\)/.test(content) || /\bdns\s*\.\s*resolve/.test(content),
|
|
93
94
|
hasBase64Encode: /\.toString\s*\(\s*['"]base64(url)?['"]\s*\)/.test(content),
|
|
@@ -106,6 +107,35 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
106
107
|
MemberExpression(node) { handleMemberExpression(node, ctx); }
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
// FIX 5: DNS chunk exfiltration — verify dns.resolve* is inside a loop body
|
|
111
|
+
if (ctx.hasDnsRequire && ctx.hasBase64Encode && !ctx.hasDnsLoop) {
|
|
112
|
+
walk.ancestor(ast, {
|
|
113
|
+
CallExpression(node, _state, ancestors) {
|
|
114
|
+
if (ctx.hasDnsLoop) return;
|
|
115
|
+
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
116
|
+
const name = node.callee.property.name;
|
|
117
|
+
if (['resolve', 'resolve4', 'resolveTxt', 'resolveCname'].includes(name)) {
|
|
118
|
+
for (const anc of ancestors) {
|
|
119
|
+
if (['ForStatement', 'WhileStatement', 'ForOfStatement',
|
|
120
|
+
'ForInStatement', 'DoWhileStatement'].includes(anc.type)) {
|
|
121
|
+
ctx.hasDnsLoop = true;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// forEach/map callback = implicit loop
|
|
125
|
+
if (anc.type === 'CallExpression' && anc.callee?.type === 'MemberExpression') {
|
|
126
|
+
const m = anc.callee.property?.name;
|
|
127
|
+
if (['forEach', 'map', 'reduce', 'filter'].includes(m)) {
|
|
128
|
+
ctx.hasDnsLoop = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
handlePostWalk(ctx);
|
|
110
140
|
|
|
111
141
|
return threats;
|