muaddib-scanner 2.3.1 → 2.3.2
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/README.md +17 -16
- package/package.json +1 -1
- package/src/commands/evaluate.js +1 -1
- package/src/scanner/dataflow.js +347 -336
- package/src/scanner/entropy.js +246 -242
- package/src/scanner/obfuscation.js +2 -1
- package/src/scanner/package.js +166 -161
- package/src/scoring.js +18 -0
package/src/scanner/entropy.js
CHANGED
|
@@ -1,242 +1,246 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
|
-
|
|
5
|
-
const ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
|
|
6
|
-
|
|
7
|
-
// File patterns to skip (compiled/minified/bundled)
|
|
8
|
-
const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
*
|
|
200
|
-
* @
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
|
+
|
|
5
|
+
const ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
|
|
6
|
+
|
|
7
|
+
// File patterns to skip (compiled/minified/bundled)
|
|
8
|
+
const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
|
|
9
|
+
|
|
10
|
+
// Files containing encoding/character tables have legitimately high entropy
|
|
11
|
+
const ENCODING_TABLE_RE = /(?:encoding|tables|unicode|charmap|codepage)/i;
|
|
12
|
+
|
|
13
|
+
// Minimum string length to analyze (short strings naturally have low entropy)
|
|
14
|
+
const MIN_STRING_LENGTH = 50;
|
|
15
|
+
|
|
16
|
+
// Thresholds (string-level only — file-level entropy removed, see design notes)
|
|
17
|
+
const STRING_ENTROPY_MEDIUM = 5.5;
|
|
18
|
+
const STRING_ENTROPY_HIGH = 6.5;
|
|
19
|
+
|
|
20
|
+
// Long base64 threshold (chars) — base64 payloads >200 chars outside source maps are suspicious
|
|
21
|
+
const LONG_BASE64_THRESHOLD = 200;
|
|
22
|
+
|
|
23
|
+
// Whitelist patterns for non-malicious high-entropy strings
|
|
24
|
+
const SOURCE_MAP_REGEX = /^data:application\/json;base64,/;
|
|
25
|
+
const SHA256_HEX_REGEX = /^[0-9a-fA-F]{64}$/;
|
|
26
|
+
const MD5_HEX_REGEX = /^[0-9a-fA-F]{32}$/;
|
|
27
|
+
const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
28
|
+
const JWT_REGEX = /^eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
29
|
+
|
|
30
|
+
// Obfuscation pattern detection
|
|
31
|
+
const HEX_VAR_REGEX = /_0x[a-f0-9]{4,6}/g;
|
|
32
|
+
const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/=]+$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if a string matches a known non-malicious pattern.
|
|
36
|
+
* @param {string} str - The string to check
|
|
37
|
+
* @param {string} filePath - The file path (for context-dependent checks)
|
|
38
|
+
* @returns {boolean} true if the string is whitelisted
|
|
39
|
+
*/
|
|
40
|
+
function isWhitelistedString(str, filePath) {
|
|
41
|
+
if (SOURCE_MAP_REGEX.test(str)) return true;
|
|
42
|
+
if (SHA256_HEX_REGEX.test(str)) return true;
|
|
43
|
+
if (MD5_HEX_REGEX.test(str)) return true;
|
|
44
|
+
if (UUID_REGEX.test(str)) return true;
|
|
45
|
+
|
|
46
|
+
// JWT tokens in test files
|
|
47
|
+
if (JWT_REGEX.test(str)) {
|
|
48
|
+
const lowerPath = filePath.toLowerCase();
|
|
49
|
+
if (lowerPath.includes('test') || lowerPath.includes('spec') || lowerPath.includes('mock') || lowerPath.includes('fixture')) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Calculate Shannon entropy of a string.
|
|
59
|
+
* @param {string} str - Input string
|
|
60
|
+
* @returns {number} Entropy in bits (0-8)
|
|
61
|
+
*/
|
|
62
|
+
function calculateShannonEntropy(str) {
|
|
63
|
+
if (!str || str.length === 0) return 0;
|
|
64
|
+
|
|
65
|
+
const freq = {};
|
|
66
|
+
for (let i = 0; i < str.length; i++) {
|
|
67
|
+
const ch = str[i];
|
|
68
|
+
freq[ch] = (freq[ch] || 0) + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const len = str.length;
|
|
72
|
+
let entropy = 0;
|
|
73
|
+
for (const ch in freq) {
|
|
74
|
+
const p = freq[ch] / len;
|
|
75
|
+
if (p > 0) {
|
|
76
|
+
entropy -= p * Math.log2(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return entropy;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract string literals from JS source code via regex.
|
|
85
|
+
* @param {string} content - JS source code
|
|
86
|
+
* @returns {string[]} Array of string contents (without quotes)
|
|
87
|
+
*/
|
|
88
|
+
function extractStringLiterals(content) {
|
|
89
|
+
const strings = [];
|
|
90
|
+
const regex = /(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|`([^`\\]*(?:\\.[^`\\]*)*)`)/g;
|
|
91
|
+
let match;
|
|
92
|
+
while ((match = regex.exec(content)) !== null) {
|
|
93
|
+
const str = match[1] || match[2] || match[3];
|
|
94
|
+
if (str) strings.push(str);
|
|
95
|
+
}
|
|
96
|
+
return strings;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if a file should be skipped based on path patterns.
|
|
101
|
+
* @param {string} filePath - Absolute file path
|
|
102
|
+
* @returns {boolean} true if the file should be skipped
|
|
103
|
+
*/
|
|
104
|
+
function shouldSkipFile(filePath) {
|
|
105
|
+
const basename = path.basename(filePath);
|
|
106
|
+
for (const pattern of SKIP_FILE_PATTERNS) {
|
|
107
|
+
if (basename.endsWith(pattern)) return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if file content contains a source map reference.
|
|
114
|
+
* @param {string} content - File content
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function hasSourceMap(content) {
|
|
118
|
+
return content.includes('//# sourceMappingURL=') || content.includes('//@ sourceMappingURL=');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Detect JS obfuscation patterns that are signatures of real malware.
|
|
123
|
+
* Returns an array of threats for patterns found in the file content.
|
|
124
|
+
* @param {string} content - File content
|
|
125
|
+
* @param {string} relativePath - Relative file path for threat reporting
|
|
126
|
+
* @returns {Array} threats
|
|
127
|
+
*/
|
|
128
|
+
function detectObfuscationPatterns(content, relativePath) {
|
|
129
|
+
const threats = [];
|
|
130
|
+
|
|
131
|
+
// 1. Hex variable names: _0x[a-f0-9]{4,6} — classic JS obfuscator signature
|
|
132
|
+
const hexVarMatches = content.match(HEX_VAR_REGEX);
|
|
133
|
+
if (hexVarMatches && hexVarMatches.length >= 3) {
|
|
134
|
+
const uniqueVars = new Set(hexVarMatches);
|
|
135
|
+
if (uniqueVars.size >= 3) {
|
|
136
|
+
threats.push({
|
|
137
|
+
type: 'js_obfuscation_pattern',
|
|
138
|
+
severity: 'HIGH',
|
|
139
|
+
message: `JS obfuscator hex variables detected (${uniqueVars.size} unique _0x* vars) — signature of javascript-obfuscator/obfuscator.io`,
|
|
140
|
+
file: relativePath
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. Encoded string arrays: arrays of 20+ string literals that look like base64/hex
|
|
146
|
+
const strings = extractStringLiterals(content);
|
|
147
|
+
const encodedStrings = strings.filter(s => {
|
|
148
|
+
if (s.length < 8) return false;
|
|
149
|
+
return BASE64_CHARS_REGEX.test(s) && calculateShannonEntropy(s) > 4.5;
|
|
150
|
+
});
|
|
151
|
+
if (encodedStrings.length >= 20) {
|
|
152
|
+
threats.push({
|
|
153
|
+
type: 'js_obfuscation_pattern',
|
|
154
|
+
severity: 'HIGH',
|
|
155
|
+
message: `Encoded string array detected (${encodedStrings.length} base64/hex strings) — typical of string array rotation obfuscation`,
|
|
156
|
+
file: relativePath
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 3. eval() or Function() called with high-entropy content
|
|
161
|
+
// Match: eval("...high entropy...") or Function("...high entropy...")
|
|
162
|
+
const evalFuncRegex = /(?:eval|Function)\s*\(\s*(?:"([^"]{50,})"|'([^']{50,})'|`([^`]{50,})`)/g;
|
|
163
|
+
let evalMatch;
|
|
164
|
+
while ((evalMatch = evalFuncRegex.exec(content)) !== null) {
|
|
165
|
+
const arg = evalMatch[1] || evalMatch[2] || evalMatch[3];
|
|
166
|
+
if (arg) {
|
|
167
|
+
const argEntropy = calculateShannonEntropy(arg);
|
|
168
|
+
if (argEntropy > STRING_ENTROPY_MEDIUM) {
|
|
169
|
+
threats.push({
|
|
170
|
+
type: 'js_obfuscation_pattern',
|
|
171
|
+
severity: 'HIGH',
|
|
172
|
+
message: `eval/Function called with high-entropy argument (${argEntropy.toFixed(2)} bits, ${arg.length} chars) — likely executing obfuscated payload`,
|
|
173
|
+
file: relativePath
|
|
174
|
+
});
|
|
175
|
+
break; // One finding per file is enough
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 4. Long base64 strings (>200 chars) outside source maps
|
|
181
|
+
for (const str of strings) {
|
|
182
|
+
if (str.length > LONG_BASE64_THRESHOLD && BASE64_CHARS_REGEX.test(str)) {
|
|
183
|
+
// Skip source map data URLs
|
|
184
|
+
if (SOURCE_MAP_REGEX.test(str)) continue;
|
|
185
|
+
threats.push({
|
|
186
|
+
type: 'js_obfuscation_pattern',
|
|
187
|
+
severity: 'HIGH',
|
|
188
|
+
message: `Long base64 payload detected (${str.length} chars) — possible encoded malicious code`,
|
|
189
|
+
file: relativePath
|
|
190
|
+
});
|
|
191
|
+
break; // One finding per file is enough
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return threats;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Scan JavaScript files for high-entropy strings and JS obfuscation patterns.
|
|
200
|
+
* @param {string} targetPath - Directory to scan
|
|
201
|
+
* @param {object} [options] - Options
|
|
202
|
+
* @param {number} [options.entropyThreshold] - Custom string-level entropy threshold (default: 5.5)
|
|
203
|
+
* @returns {Array} threats
|
|
204
|
+
*/
|
|
205
|
+
function scanEntropy(targetPath, options = {}) {
|
|
206
|
+
const threats = [];
|
|
207
|
+
const stringThreshold = options.entropyThreshold || STRING_ENTROPY_MEDIUM;
|
|
208
|
+
const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
|
|
209
|
+
|
|
210
|
+
const safeFiles = files.filter(f => !shouldSkipFile(f));
|
|
211
|
+
forEachSafeFile(safeFiles, (file, content) => {
|
|
212
|
+
// Skip files containing source maps (legitimate compiled output)
|
|
213
|
+
if (hasSourceMap(content)) return;
|
|
214
|
+
|
|
215
|
+
const relativePath = path.relative(targetPath, file);
|
|
216
|
+
|
|
217
|
+
// Obfuscation pattern detection (MUADDIB-ENTROPY-003)
|
|
218
|
+
const obfuscationThreats = detectObfuscationPatterns(content, relativePath);
|
|
219
|
+
threats.push(...obfuscationThreats);
|
|
220
|
+
|
|
221
|
+
// String-level entropy check (MUADDIB-ENTROPY-001)
|
|
222
|
+
const strings = extractStringLiterals(content);
|
|
223
|
+
for (const str of strings) {
|
|
224
|
+
if (str.length < MIN_STRING_LENGTH) continue;
|
|
225
|
+
|
|
226
|
+
// Skip whitelisted patterns
|
|
227
|
+
if (isWhitelistedString(str, relativePath)) continue;
|
|
228
|
+
|
|
229
|
+
const strEntropy = calculateShannonEntropy(str);
|
|
230
|
+
if (strEntropy > stringThreshold) {
|
|
231
|
+
const isEncodingTable = ENCODING_TABLE_RE.test(relativePath);
|
|
232
|
+
const severity = isEncodingTable ? 'LOW' : (strEntropy > STRING_ENTROPY_HIGH ? 'HIGH' : 'MEDIUM');
|
|
233
|
+
threats.push({
|
|
234
|
+
type: 'high_entropy_string',
|
|
235
|
+
severity,
|
|
236
|
+
message: `High entropy string (${strEntropy.toFixed(2)} bits, ${str.length} chars) — possible base64/hex/encrypted payload`,
|
|
237
|
+
file: relativePath
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return threats;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = { scanEntropy, calculateShannonEntropy };
|
|
@@ -19,7 +19,8 @@ function detectObfuscation(targetPath) {
|
|
|
19
19
|
const isBundled = basename.endsWith('.bundle.js');
|
|
20
20
|
const pathParts = relativePath.split(path.sep);
|
|
21
21
|
const isInDistOrBuild = pathParts.some(p => p === 'dist' || p === 'build');
|
|
22
|
-
const
|
|
22
|
+
const isLargeCjsMjs = (basename.endsWith('.cjs') || basename.endsWith('.mjs')) && content.length > 100 * 1024;
|
|
23
|
+
const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs;
|
|
23
24
|
|
|
24
25
|
// 1. Ratio code sur une seule ligne (skip .min.js — minification, not obfuscation)
|
|
25
26
|
if (!isMinified) {
|