muaddib-scanner 2.2.11 → 2.2.14
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.fr.md +14 -17
- package/README.md +14 -23
- package/datasets/adversarial/indirect-eval-bypass/index.js +27 -0
- package/datasets/adversarial/indirect-eval-bypass/package.json +5 -0
- package/datasets/adversarial/mjs-extension-bypass/package.json +6 -0
- package/datasets/adversarial/mjs-extension-bypass/stealer.mjs +39 -0
- package/datasets/adversarial/muaddib-ignore-bypass/index.js +47 -0
- package/datasets/adversarial/muaddib-ignore-bypass/package.json +5 -0
- package/package.json +3 -2
- package/src/commands/evaluate.js +61 -7
- package/src/index.js +19 -111
- package/src/ioc/bootstrap.js +5 -4
- package/src/response/playbooks.js +10 -0
- package/src/rules/index.js +26 -0
- package/src/scanner/ast.js +103 -57
- package/src/scanner/dataflow.js +32 -61
- package/src/scanner/deobfuscate.js +52 -18
- package/src/scanner/entropy.js +6 -24
- package/src/scanner/github-actions.js +2 -1
- package/src/scanner/hash.js +1 -1
- package/src/scanner/module-graph.js +3 -3
- package/src/scanner/npm-registry.js +4 -3
- package/src/scanner/obfuscation.js +4 -19
- package/src/scanner/shell.js +3 -13
- package/src/shared/analyze-helper.js +49 -0
- package/src/shared/constants.js +5 -1
- package/src/temporal-ast-diff.js +8 -18
- package/src/utils.js +61 -2
package/src/scanner/shell.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
4
|
|
|
5
5
|
const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
6
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
7
6
|
|
|
8
7
|
const MALICIOUS_PATTERNS = [
|
|
9
8
|
{ pattern: /curl.*\|.*sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
|
|
@@ -26,16 +25,7 @@ async function scanShellScripts(targetPath) {
|
|
|
26
25
|
// Cherche les fichiers shell
|
|
27
26
|
const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
let content;
|
|
31
|
-
try {
|
|
32
|
-
const stat = fs.statSync(file);
|
|
33
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
34
|
-
content = fs.readFileSync(file, 'utf8');
|
|
35
|
-
} catch {
|
|
36
|
-
continue; // Skip unreadable files
|
|
37
|
-
}
|
|
38
|
-
|
|
28
|
+
forEachSafeFile(files, (file, content) => {
|
|
39
29
|
// Strip comment lines to avoid false positives on documentation
|
|
40
30
|
const activeContent = content.split('\n')
|
|
41
31
|
.filter(line => !line.trimStart().startsWith('#'))
|
|
@@ -51,7 +41,7 @@ async function scanShellScripts(targetPath) {
|
|
|
51
41
|
});
|
|
52
42
|
}
|
|
53
43
|
}
|
|
54
|
-
}
|
|
44
|
+
});
|
|
55
45
|
|
|
56
46
|
return threats;
|
|
57
47
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { isDevFile, findJsFiles, forEachSafeFile } = require('../utils.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared scanner wrapper: iterates JS files, runs analyzeFileFn on original + deobfuscated code,
|
|
6
|
+
* deduplicates findings by type::message key.
|
|
7
|
+
* @param {string} targetPath - Root directory to scan
|
|
8
|
+
* @param {Function} analyzeFileFn - (content, filePath, basePath) => threats[]
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {Function} [options.deobfuscate] - Deobfuscation function
|
|
11
|
+
* @param {string[]} [options.excludedFiles] - Relative paths to skip
|
|
12
|
+
* @param {boolean} [options.skipDevFiles=true] - Whether to skip dev/test files
|
|
13
|
+
* @returns {Array} Combined threats
|
|
14
|
+
*/
|
|
15
|
+
function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
|
|
16
|
+
const threats = [];
|
|
17
|
+
const files = findJsFiles(targetPath);
|
|
18
|
+
|
|
19
|
+
forEachSafeFile(files, (file, content) => {
|
|
20
|
+
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
21
|
+
|
|
22
|
+
if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
|
|
23
|
+
if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
|
|
24
|
+
|
|
25
|
+
// Analyze original code first (preserves obfuscation-detection rules)
|
|
26
|
+
const fileThreats = analyzeFileFn(content, file, targetPath);
|
|
27
|
+
threats.push(...fileThreats);
|
|
28
|
+
|
|
29
|
+
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
30
|
+
if (typeof options.deobfuscate === 'function') {
|
|
31
|
+
try {
|
|
32
|
+
const result = options.deobfuscate(content);
|
|
33
|
+
if (result.transforms.length > 0) {
|
|
34
|
+
const deobThreats = analyzeFileFn(result.code, file, targetPath);
|
|
35
|
+
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
|
36
|
+
for (const dt of deobThreats) {
|
|
37
|
+
if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
|
|
38
|
+
threats.push(dt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch { /* deobfuscation failed — skip */ }
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return threats;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { analyzeWithDeobfuscation };
|
package/src/shared/constants.js
CHANGED
|
@@ -86,4 +86,8 @@ const NPM_PACKAGE_REGEX = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*
|
|
|
86
86
|
const MAX_TARBALL_SIZE = 50 * 1024 * 1024; // 50MB
|
|
87
87
|
const DOWNLOAD_TIMEOUT = 30_000; // 30 seconds
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// Shared scanner constants
|
|
90
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB — skip files larger than this to avoid memory issues
|
|
91
|
+
const ACORN_OPTIONS = { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true };
|
|
92
|
+
|
|
93
|
+
module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_FILE_SIZE, ACORN_OPTIONS };
|
package/src/temporal-ast-diff.js
CHANGED
|
@@ -4,12 +4,13 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const acorn = require('acorn');
|
|
6
6
|
const walk = require('acorn-walk');
|
|
7
|
-
const { findJsFiles } = require('./utils.js');
|
|
7
|
+
const { findJsFiles, forEachSafeFile, debugLog } = require('./utils.js');
|
|
8
8
|
const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
|
|
9
9
|
const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
|
|
10
10
|
|
|
11
|
+
const { MAX_FILE_SIZE, ACORN_OPTIONS } = require('./shared/constants.js');
|
|
12
|
+
|
|
11
13
|
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
12
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
13
14
|
const METADATA_TIMEOUT = 10_000;
|
|
14
15
|
|
|
15
16
|
const SENSITIVE_PATHS = [
|
|
@@ -101,12 +102,12 @@ async function fetchPackageTarball(packageName, version) {
|
|
|
101
102
|
await downloadToFile(tarballUrl, tgzPath);
|
|
102
103
|
extractedDir = extractTarGz(tgzPath, tmpDir);
|
|
103
104
|
} catch (err) {
|
|
104
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
105
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
|
|
105
106
|
throw err;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
const cleanup = () => {
|
|
109
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
110
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
|
|
110
111
|
};
|
|
111
112
|
|
|
112
113
|
return { dir: extractedDir, cleanup };
|
|
@@ -120,20 +121,9 @@ async function fetchPackageTarball(packageName, version) {
|
|
|
120
121
|
function extractDangerousPatterns(directory) {
|
|
121
122
|
const patterns = new Set();
|
|
122
123
|
const files = findJsFiles(directory);
|
|
123
|
-
|
|
124
|
-
for (const file of files) {
|
|
125
|
-
try {
|
|
126
|
-
const stat = fs.statSync(file);
|
|
127
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
128
|
-
} catch { continue; }
|
|
129
|
-
|
|
130
|
-
let content;
|
|
131
|
-
try { content = fs.readFileSync(file, 'utf8'); }
|
|
132
|
-
catch { continue; }
|
|
133
|
-
|
|
124
|
+
forEachSafeFile(files, (file, content) => {
|
|
134
125
|
extractPatternsFromSource(content, patterns);
|
|
135
|
-
}
|
|
136
|
-
|
|
126
|
+
});
|
|
137
127
|
return patterns;
|
|
138
128
|
}
|
|
139
129
|
|
|
@@ -145,7 +135,7 @@ function extractDangerousPatterns(directory) {
|
|
|
145
135
|
function extractPatternsFromSource(source, patterns) {
|
|
146
136
|
let ast;
|
|
147
137
|
try {
|
|
148
|
-
ast = acorn.parse(source,
|
|
138
|
+
ast = acorn.parse(source, ACORN_OPTIONS);
|
|
149
139
|
} catch { return; }
|
|
150
140
|
|
|
151
141
|
walk.simple(ast, {
|
package/src/utils.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const { MAX_FILE_SIZE } = require('./shared/constants.js');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Directories excluded from scanning.
|
|
@@ -145,7 +146,7 @@ function findFiles(dir, options = {}) {
|
|
|
145
146
|
* @returns {string[]} List of .js file paths
|
|
146
147
|
*/
|
|
147
148
|
function findJsFiles(dir, results = []) {
|
|
148
|
-
return findFiles(dir, { extensions: ['.js'], results });
|
|
149
|
+
return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/**
|
|
@@ -228,6 +229,61 @@ class Spinner {
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Iterates files with size guard and error handling.
|
|
234
|
+
* Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
|
|
235
|
+
*/
|
|
236
|
+
function forEachSafeFile(files, callback) {
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
try {
|
|
239
|
+
const stat = fs.statSync(file);
|
|
240
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
241
|
+
} catch { continue; }
|
|
242
|
+
let content;
|
|
243
|
+
try {
|
|
244
|
+
content = fs.readFileSync(file, 'utf8');
|
|
245
|
+
} catch { continue; }
|
|
246
|
+
callback(file, content);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Lists installed packages in node_modules (handles scoped packages).
|
|
252
|
+
* @param {string} targetPath - Root of the project
|
|
253
|
+
* @returns {string[]} Package names (e.g. ['express', '@babel/core'])
|
|
254
|
+
*/
|
|
255
|
+
function listInstalledPackages(targetPath) {
|
|
256
|
+
const nm = path.join(targetPath, 'node_modules');
|
|
257
|
+
if (!fs.existsSync(nm)) return [];
|
|
258
|
+
const names = [];
|
|
259
|
+
try {
|
|
260
|
+
for (const item of fs.readdirSync(nm)) {
|
|
261
|
+
if (item.startsWith('.')) continue;
|
|
262
|
+
const itemPath = path.join(nm, item);
|
|
263
|
+
try {
|
|
264
|
+
const stat = fs.lstatSync(itemPath);
|
|
265
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
|
|
266
|
+
if (item.startsWith('@')) {
|
|
267
|
+
for (const si of fs.readdirSync(itemPath)) {
|
|
268
|
+
const ss = fs.lstatSync(path.join(itemPath, si));
|
|
269
|
+
if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
names.push(item);
|
|
273
|
+
}
|
|
274
|
+
} catch { /* skip unreadable */ }
|
|
275
|
+
}
|
|
276
|
+
} catch { /* no node_modules readable */ }
|
|
277
|
+
return names;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
|
|
282
|
+
*/
|
|
283
|
+
function debugLog(...args) {
|
|
284
|
+
if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
|
|
285
|
+
}
|
|
286
|
+
|
|
231
287
|
module.exports = {
|
|
232
288
|
EXCLUDED_DIRS,
|
|
233
289
|
DEV_PATTERNS,
|
|
@@ -238,5 +294,8 @@ module.exports = {
|
|
|
238
294
|
getCallName,
|
|
239
295
|
Spinner,
|
|
240
296
|
setExtraExcludes,
|
|
241
|
-
getExtraExcludes
|
|
297
|
+
getExtraExcludes,
|
|
298
|
+
forEachSafeFile,
|
|
299
|
+
listInstalledPackages,
|
|
300
|
+
debugLog
|
|
242
301
|
};
|