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.
@@ -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
- for (const file of files) {
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 };
@@ -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
- module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT };
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 };
@@ -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, { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true });
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
  };