muaddib-scanner 2.2.13 → 2.2.15

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.
@@ -2,61 +2,14 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
- const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
-
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5
+ const { getCallName } = require('../utils.js');
6
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
7
+ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
8
8
 
9
9
  async function analyzeDataFlow(targetPath, options = {}) {
10
- const threats = [];
11
- const files = findJsFiles(targetPath);
12
-
13
- for (const file of files) {
14
- const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
15
-
16
- if (isDevFile(relativePath)) {
17
- continue;
18
- }
19
-
20
- try {
21
- const stat = fs.statSync(file);
22
- if (stat.size > MAX_FILE_SIZE) continue;
23
- } catch { continue; }
24
-
25
- let content;
26
- try {
27
- content = fs.readFileSync(file, 'utf8');
28
- } catch {
29
- continue;
30
- }
31
-
32
- // Respect // muaddib-ignore directive in first 5 lines (like eslint-disable)
33
- const headerLines = content.slice(0, 1024).split('\n').slice(0, 5);
34
- if (headerLines.some(line => line.includes('muaddib-ignore'))) {
35
- continue;
36
- }
37
-
38
- // Analyze original code first (preserves obfuscation-detection rules)
39
- const fileThreats = analyzeFile(content, file, targetPath);
40
- threats.push(...fileThreats);
41
-
42
- // Also analyze deobfuscated code for additional findings hidden by obfuscation
43
- if (typeof options.deobfuscate === 'function') {
44
- try {
45
- const result = options.deobfuscate(content);
46
- if (result.transforms.length > 0) {
47
- const deobThreats = analyzeFile(result.code, file, targetPath);
48
- const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
49
- for (const dt of deobThreats) {
50
- if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
51
- threats.push(dt);
52
- }
53
- }
54
- }
55
- } catch { /* deobfuscation failed — skip */ }
56
- }
57
- }
58
-
59
- return threats;
10
+ return analyzeWithDeobfuscation(targetPath, analyzeFile, {
11
+ deobfuscate: options.deobfuscate
12
+ });
60
13
  }
61
14
 
62
15
  function analyzeFile(content, filePath, basePath) {
@@ -64,12 +17,7 @@ function analyzeFile(content, filePath, basePath) {
64
17
  let ast;
65
18
 
66
19
  try {
67
- ast = acorn.parse(content, {
68
- ecmaVersion: 2024,
69
- sourceType: 'module',
70
- allowHashBang: true,
71
- locations: true
72
- });
20
+ ast = acorn.parse(content, { ...ACORN_OPTIONS, locations: true });
73
21
  } catch {
74
22
  return threats;
75
23
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
5
6
 
6
7
  /**
7
8
  * Lightweight static deobfuscation pre-processor.
@@ -16,12 +17,7 @@ function deobfuscate(sourceCode) {
16
17
  // Parse AST — if parsing fails, return source unchanged (fail-safe)
17
18
  let ast;
18
19
  try {
19
- ast = acorn.parse(sourceCode, {
20
- ecmaVersion: 2024,
21
- sourceType: 'module',
22
- allowHashBang: true,
23
- ranges: true
24
- });
20
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
25
21
  } catch {
26
22
  return { code: sourceCode, transforms };
27
23
  }
@@ -197,12 +193,7 @@ function propagateConsts(sourceCode) {
197
193
  const transforms = [];
198
194
  let ast;
199
195
  try {
200
- ast = acorn.parse(sourceCode, {
201
- ecmaVersion: 2024,
202
- sourceType: 'module',
203
- allowHashBang: true,
204
- ranges: true
205
- });
196
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
206
197
  } catch {
207
198
  return { code: sourceCode, transforms };
208
199
  }
@@ -316,12 +307,7 @@ function foldConcatsOnly(sourceCode) {
316
307
  const transforms = [];
317
308
  let ast;
318
309
  try {
319
- ast = acorn.parse(sourceCode, {
320
- ecmaVersion: 2024,
321
- sourceType: 'module',
322
- allowHashBang: true,
323
- ranges: true
324
- });
310
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
325
311
  } catch {
326
312
  return { code: sourceCode, transforms };
327
313
  }
@@ -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 ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
6
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
7
6
 
8
7
  // File patterns to skip (compiled/minified/bundled)
9
8
  const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
@@ -203,29 +202,12 @@ function detectObfuscationPatterns(content, relativePath) {
203
202
  function scanEntropy(targetPath, options = {}) {
204
203
  const threats = [];
205
204
  const stringThreshold = options.entropyThreshold || STRING_ENTROPY_MEDIUM;
206
- const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
207
-
208
- for (const file of files) {
209
- // Skip files matching compiled/minified patterns
210
- if (shouldSkipFile(file)) continue;
211
-
212
- // Size guard
213
- try {
214
- const stat = fs.statSync(file);
215
- if (stat.size > MAX_FILE_SIZE) continue;
216
- } catch {
217
- continue;
218
- }
219
-
220
- let content;
221
- try {
222
- content = fs.readFileSync(file, 'utf8');
223
- } catch {
224
- continue;
225
- }
205
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
226
206
 
207
+ const safeFiles = files.filter(f => !shouldSkipFile(f));
208
+ forEachSafeFile(safeFiles, (file, content) => {
227
209
  // Skip files containing source maps (legitimate compiled output)
228
- if (hasSourceMap(content)) continue;
210
+ if (hasSourceMap(content)) return;
229
211
 
230
212
  const relativePath = path.relative(targetPath, file);
231
213
 
@@ -252,7 +234,7 @@ function scanEntropy(targetPath, options = {}) {
252
234
  });
253
235
  }
254
236
  }
255
- }
237
+ });
256
238
 
257
239
  return threats;
258
240
  }
@@ -1,8 +1,9 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
5
+
4
6
  const YAML_EXTENSIONS = ['.yml', '.yaml'];
5
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
6
7
  const MAX_DEPTH = 10;
7
8
 
8
9
  function scanGitHubActions(targetPath) {
@@ -3,11 +3,11 @@ const path = require('path');
3
3
  const nodeCrypto = require('crypto');
4
4
  const { loadCachedIOCs } = require('../ioc/updater.js');
5
5
  const { findFiles } = require('../utils.js');
6
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
6
7
 
7
8
  // Hash cache: filePath -> { hash, mtime }
8
9
  const hashCache = new Map();
9
10
  const MAX_CACHE_SIZE = 10000;
10
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
11
11
 
12
12
  async function scanHashes(targetPath) {
13
13
  const threats = [];
@@ -2,13 +2,13 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const { findFiles } = require('../utils');
5
+ const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../shared/constants.js');
5
6
 
6
7
  // --- Sensitive source patterns ---
7
8
  const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
8
9
 
9
10
  const ACORN_OPTIONS = {
10
- ecmaVersion: 'latest',
11
- sourceType: 'module',
11
+ ...BASE_ACORN_OPTIONS,
12
12
  allowReturnOutsideFunction: true,
13
13
  allowImportExportEverywhere: true,
14
14
  };
@@ -32,7 +32,7 @@ const SINK_INSTANCE_METHODS = new Set(['connect', 'write', 'send']);
32
32
  function buildModuleGraph(packagePath) {
33
33
  const graph = {};
34
34
  const files = findFiles(packagePath, {
35
- extensions: ['.js'],
35
+ extensions: ['.js', '.mjs', '.cjs'],
36
36
  excludedDirs: ['node_modules', '.git'],
37
37
  });
38
38
  for (const absFile of files) {
@@ -1,4 +1,5 @@
1
1
  const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
2
+ const { debugLog } = require('../utils.js');
2
3
 
3
4
  const REGISTRY_URL = 'https://registry.npmjs.org';
4
5
  const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
@@ -44,13 +45,13 @@ async function fetchWithRetry(url) {
44
45
  // 404 = package doesn't exist
45
46
  if (response.status === 404) {
46
47
  // Drain response body to free resources
47
- try { await response.text(); } catch {}
48
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
48
49
  return null;
49
50
  }
50
51
 
51
52
  // 429 = rate limit, respect Retry-After header (capped at 30s)
52
53
  if (response.status === 429) {
53
- try { await response.text(); } catch {}
54
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
54
55
  const retryAfter = parseInt(response.headers.get('retry-after'), 10);
55
56
  const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
56
57
  await new Promise(r => setTimeout(r, delay));
@@ -59,7 +60,7 @@ async function fetchWithRetry(url) {
59
60
 
60
61
  if (!response.ok) {
61
62
  // Drain response body on errors
62
- try { await response.text(); } catch {}
63
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
63
64
  return null;
64
65
  }
65
66
 
@@ -1,30 +1,15 @@
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
  // node_modules NOT excluded: detect obfuscated code in dependencies
6
6
  const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
8
7
 
9
8
  function detectObfuscation(targetPath) {
10
9
  const threats = [];
11
- const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: OBF_EXCLUDED_DIRS });
12
-
13
- for (const file of files) {
14
- // Skip files exceeding MAX_FILE_SIZE to avoid memory issues
15
- try {
16
- const stat = fs.statSync(file);
17
- if (stat.size > MAX_FILE_SIZE) continue;
18
- } catch {
19
- continue;
20
- }
10
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
21
11
 
22
- let content;
23
- try {
24
- content = fs.readFileSync(file, 'utf8');
25
- } catch {
26
- continue; // Skip unreadable files
27
- }
12
+ forEachSafeFile(files, (file, content) => {
28
13
  const relativePath = path.relative(targetPath, file);
29
14
 
30
15
  const signals = [];
@@ -96,7 +81,7 @@ function detectObfuscation(targetPath) {
96
81
  file: relativePath
97
82
  });
98
83
  }
99
- }
84
+ });
100
85
 
101
86
  return threats;
102
87
  }
@@ -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
  }
@@ -311,6 +311,9 @@ function findTyposquatMatch(name) {
311
311
  // Ignore si le package populaire est trop court
312
312
  if (popular.length < MIN_PACKAGE_LENGTH) continue;
313
313
 
314
+ // Length pre-filter: Levenshtein distance >= |len(a) - len(b)|
315
+ if (Math.abs(nameLower.length - popularLower.length) > 2) continue;
316
+
314
317
  const distance = levenshteinDistance(nameLower, popularLower);
315
318
 
316
319
  // Distance de 1 = tres suspect (une seule lettre de difference)
@@ -479,6 +482,9 @@ function findPyPITyposquatMatch(name) {
479
482
  // Skip short popular packages
480
483
  if (popularNorm.length < MIN_PYPI_LENGTH) continue;
481
484
 
485
+ // Length pre-filter: Levenshtein distance >= |len(a) - len(b)|
486
+ if (Math.abs(normalized.length - popularNorm.length) > 2) continue;
487
+
482
488
  const distance = levenshteinDistance(normalized, popularNorm);
483
489
 
484
490
  // Distance 1 = very suspect (one char difference)
package/src/scoring.js ADDED
@@ -0,0 +1,213 @@
1
+ // ============================================
2
+ // SCORING CONSTANTS
3
+ // ============================================
4
+ // Severity weights for risk score calculation (0-100)
5
+ // These values determine the impact of each threat type on the final score.
6
+ // Example: 4 CRITICAL threats = 100 (max score), 10 HIGH threats = 100
7
+ const SEVERITY_WEIGHTS = {
8
+ // CRITICAL: Threats with immediate impact (active malware, data exfiltration)
9
+ // High weight because a single critical threat justifies immediate action
10
+ CRITICAL: 25,
11
+
12
+ // HIGH: Serious threats (dangerous code, known malicious dependencies)
13
+ // 10 HIGH threats reach the maximum score
14
+ HIGH: 10,
15
+
16
+ // MEDIUM: Potential threats (suspicious patterns, light obfuscation)
17
+ // Moderate impact, requires investigation but not necessarily malicious
18
+ MEDIUM: 3,
19
+
20
+ // LOW: Informational findings, minimal impact on risk score
21
+ LOW: 1
22
+ };
23
+
24
+ // Thresholds for determining the overall risk level
25
+ const RISK_THRESHOLDS = {
26
+ CRITICAL: 75, // >= 75: Immediate action required
27
+ HIGH: 50, // >= 50: Priority investigation
28
+ MEDIUM: 25 // >= 25: Monitor
29
+ // < 25 && > 0: LOW
30
+ // === 0: SAFE
31
+ };
32
+
33
+ // Maximum score (capped)
34
+ const MAX_RISK_SCORE = 100;
35
+
36
+ // Cap MEDIUM prototype_hook contribution (frameworks like Restify have 50+ extensions)
37
+ const PROTO_HOOK_MEDIUM_CAP = 15;
38
+
39
+ // ============================================
40
+ // PER-FILE MAX SCORING (v2.2.11)
41
+ // ============================================
42
+ // Threat types classified as package-level (not tied to a specific source file).
43
+ // These are added to the package score, not grouped by file.
44
+ const PACKAGE_LEVEL_TYPES = new Set([
45
+ 'lifecycle_script', 'lifecycle_shell_pipe',
46
+ 'lifecycle_added_critical', 'lifecycle_added_high', 'lifecycle_modified',
47
+ 'known_malicious_package', 'typosquat_detected',
48
+ 'shai_hulud_marker', 'suspicious_file',
49
+ 'pypi_malicious_package', 'pypi_typosquat_detected',
50
+ 'dangerous_api_added_critical', 'dangerous_api_added_high', 'dangerous_api_added_medium',
51
+ 'publish_burst', 'publish_dormant_spike', 'publish_rapid_succession',
52
+ 'maintainer_new_suspicious', 'maintainer_sole_change',
53
+ 'sandbox_network_activity', 'sandbox_file_changes', 'sandbox_process_spawns',
54
+ 'sandbox_canary_exfiltration'
55
+ ]);
56
+
57
+ /**
58
+ * Classify a threat as package-level or file-level.
59
+ * Package-level: metadata findings (package.json, node_modules, sandbox)
60
+ * File-level: code-level findings in specific source files
61
+ */
62
+ function isPackageLevelThreat(threat) {
63
+ if (PACKAGE_LEVEL_TYPES.has(threat.type)) return true;
64
+ if (threat.file === 'package.json') return true;
65
+ if (threat.file && (threat.file.startsWith('node_modules/') || threat.file.startsWith('node_modules\\'))) return true;
66
+ if (threat.file && threat.file.startsWith('[SANDBOX]')) return true;
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Compute a risk score for a group of threats using standard weights.
72
+ * Handles prototype_hook MEDIUM cap per group.
73
+ * @param {Array} threats - array of threat objects (after FP reductions)
74
+ * @returns {number} score 0-100
75
+ */
76
+ function computeGroupScore(threats) {
77
+ const criticalCount = threats.filter(t => t.severity === 'CRITICAL').length;
78
+ const highCount = threats.filter(t => t.severity === 'HIGH').length;
79
+ const mediumCount = threats.filter(t => t.severity === 'MEDIUM').length;
80
+ const lowCount = threats.filter(t => t.severity === 'LOW').length;
81
+
82
+ const mediumProtoHookCount = threats.filter(
83
+ t => t.type === 'prototype_hook' && t.severity === 'MEDIUM'
84
+ ).length;
85
+ const protoHookPoints = Math.min(mediumProtoHookCount * SEVERITY_WEIGHTS.MEDIUM, PROTO_HOOK_MEDIUM_CAP);
86
+ const otherMediumCount = mediumCount - mediumProtoHookCount;
87
+
88
+ let score = 0;
89
+ score += criticalCount * SEVERITY_WEIGHTS.CRITICAL;
90
+ score += highCount * SEVERITY_WEIGHTS.HIGH;
91
+ score += otherMediumCount * SEVERITY_WEIGHTS.MEDIUM;
92
+ score += protoHookPoints;
93
+ score += lowCount * SEVERITY_WEIGHTS.LOW;
94
+ return Math.min(MAX_RISK_SCORE, score);
95
+ }
96
+
97
+ // ============================================
98
+ // FP REDUCTION POST-PROCESSING
99
+ // ============================================
100
+ // Legitimate frameworks produce high volumes of certain threat types that
101
+ // malware never does. This function downgrades severity when the count
102
+ // exceeds thresholds only seen in legitimate codebases.
103
+ const FP_COUNT_THRESHOLDS = {
104
+ dynamic_require: { maxCount: 10, from: 'HIGH', to: 'LOW' },
105
+ dangerous_call_function: { maxCount: 5, from: 'MEDIUM', to: 'LOW' },
106
+ require_cache_poison: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
107
+ suspicious_dataflow: { maxCount: 5, to: 'LOW' },
108
+ obfuscation_detected: { maxCount: 3, to: 'LOW' }
109
+ };
110
+
111
+ // Custom class prototypes that HTTP frameworks legitimately extend.
112
+ // Distinguished from dangerous core Node.js prototype hooks.
113
+ const FRAMEWORK_PROTOTYPES = ['Request', 'Response', 'App', 'Router'];
114
+ const FRAMEWORK_PROTO_RE = new RegExp(
115
+ '^(' + FRAMEWORK_PROTOTYPES.join('|') + ')\\.prototype\\.'
116
+ );
117
+
118
+ function applyFPReductions(threats) {
119
+ // Count occurrences of each threat type (package-level, across all files)
120
+ const typeCounts = {};
121
+ for (const t of threats) {
122
+ typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
123
+ }
124
+
125
+ for (const t of threats) {
126
+ // Count-based downgrade: if a threat type appears too many times,
127
+ // it's a framework/plugin system, not malware
128
+ const rule = FP_COUNT_THRESHOLDS[t.type];
129
+ if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
130
+ t.severity = rule.to;
131
+ }
132
+
133
+ // Prototype hook: framework class prototypes → MEDIUM
134
+ // Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
135
+ // Browser/native APIs (globalThis.fetch, XMLHttpRequest) stay HIGH
136
+ if (t.type === 'prototype_hook' && t.severity === 'HIGH' &&
137
+ FRAMEWORK_PROTO_RE.test(t.message)) {
138
+ t.severity = 'MEDIUM';
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Calculate per-file max risk score from deduplicated threats.
145
+ * Formula: riskScore = min(100, max(file_scores) + package_level_score)
146
+ * @param {Array} deduped - deduplicated threat array
147
+ * @returns {Object} { riskScore, riskLevel, globalRiskScore, maxFileScore, packageScore, mostSuspiciousFile, fileScores, criticalCount, highCount, mediumCount, lowCount }
148
+ */
149
+ function calculateRiskScore(deduped) {
150
+ // 1. Separate deduped threats into package-level and file-level
151
+ const packageLevelThreats = [];
152
+ const fileLevelThreats = [];
153
+ for (const t of deduped) {
154
+ if (isPackageLevelThreat(t)) {
155
+ packageLevelThreats.push(t);
156
+ } else {
157
+ fileLevelThreats.push(t);
158
+ }
159
+ }
160
+
161
+ // 2. Group file-level threats by file
162
+ const fileGroups = new Map();
163
+ for (const t of fileLevelThreats) {
164
+ const key = t.file || '(unknown)';
165
+ if (!fileGroups.has(key)) fileGroups.set(key, []);
166
+ fileGroups.get(key).push(t);
167
+ }
168
+
169
+ // 3. Compute per-file scores and find the most suspicious file
170
+ let maxFileScore = 0;
171
+ let mostSuspiciousFile = null;
172
+ const fileScores = {};
173
+ for (const [file, fileThreats] of fileGroups) {
174
+ const score = computeGroupScore(fileThreats);
175
+ fileScores[file] = score;
176
+ if (score > maxFileScore) {
177
+ maxFileScore = score;
178
+ mostSuspiciousFile = file;
179
+ }
180
+ }
181
+
182
+ // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
183
+ const packageScore = computeGroupScore(packageLevelThreats);
184
+
185
+ // 5. Final score = max file score + package-level score, capped at 100
186
+ const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + packageScore);
187
+
188
+ // 6. Old global score for comparison (sum of ALL findings)
189
+ const globalRiskScore = computeGroupScore(deduped);
190
+
191
+ // 7. Severity counts (global, for summary display)
192
+ const criticalCount = deduped.filter(t => t.severity === 'CRITICAL').length;
193
+ const highCount = deduped.filter(t => t.severity === 'HIGH').length;
194
+ const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
195
+ const lowCount = deduped.filter(t => t.severity === 'LOW').length;
196
+
197
+ const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
198
+ : riskScore >= RISK_THRESHOLDS.HIGH ? 'HIGH'
199
+ : riskScore >= RISK_THRESHOLDS.MEDIUM ? 'MEDIUM'
200
+ : riskScore > 0 ? 'LOW'
201
+ : 'SAFE';
202
+
203
+ return {
204
+ riskScore, riskLevel, globalRiskScore,
205
+ maxFileScore, packageScore, mostSuspiciousFile, fileScores,
206
+ criticalCount, highCount, mediumCount, lowCount
207
+ };
208
+ }
209
+
210
+ module.exports = {
211
+ SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE,
212
+ isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore
213
+ };
@@ -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 };