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.
- package/README.md +3 -9
- 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 +2 -2
- package/src/commands/evaluate.js +5 -1
- package/src/index.js +33 -589
- package/src/ioc/bootstrap.js +5 -4
- package/src/output-formatter.js +192 -0
- package/src/scanner/ast-detectors.js +933 -0
- package/src/scanner/ast.js +43 -936
- package/src/scanner/dataflow.js +7 -59
- package/src/scanner/deobfuscate.js +4 -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/scanner/typosquat.js +6 -0
- package/src/scoring.js +213 -0
- 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/temporal-runner.js +139 -0
- package/src/utils.js +89 -4
package/src/scanner/dataflow.js
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
7
|
-
const
|
|
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
|
-
|
|
11
|
-
|
|
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
|
}
|
package/src/scanner/entropy.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 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))
|
|
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) {
|
package/src/scanner/hash.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
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
|
}
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -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 };
|
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 };
|