muaddib-scanner 2.9.1 → 2.9.3
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/package.json +1 -1
- package/src/index.js +10 -1
- package/src/response/playbooks.js +20 -0
- package/src/rules/index.js +51 -0
- package/src/scanner/ast-detectors.js +7 -3
- package/src/scanner/obfuscation.js +8 -6
- package/src/scanner/package.js +5 -0
- package/src/scoring.js +116 -6
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -28,7 +28,7 @@ const { computeReachableFiles } = require('./scanner/reachability.js');
|
|
|
28
28
|
const { runTemporalAnalyses } = require('./temporal-runner.js');
|
|
29
29
|
const { formatOutput } = require('./output-formatter.js');
|
|
30
30
|
const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache, debugLog } = require('./utils.js');
|
|
31
|
-
const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore } = require('./scoring.js');
|
|
31
|
+
const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore } = require('./scoring.js');
|
|
32
32
|
const { buildIntentPairs } = require('./intent-graph.js');
|
|
33
33
|
|
|
34
34
|
const { MAX_FILE_SIZE, safeParse } = require('./shared/constants.js');
|
|
@@ -570,6 +570,7 @@ async function run(targetPath, options = {}) {
|
|
|
570
570
|
// Cross-scanner compound: detached_process + suspicious_dataflow in same file
|
|
571
571
|
// Catches cases where credential flow is detected by dataflow scanner, not AST scanner
|
|
572
572
|
{
|
|
573
|
+
const DIST_RE = /(?:^|[/\\])(?:dist|build|out|output)[/\\]|\.min\.js$|\.bundle\.js$/i;
|
|
573
574
|
const fileMap = Object.create(null);
|
|
574
575
|
for (const t of deduped) {
|
|
575
576
|
if (t.file) {
|
|
@@ -578,6 +579,9 @@ async function run(targetPath, options = {}) {
|
|
|
578
579
|
}
|
|
579
580
|
}
|
|
580
581
|
for (const file of Object.keys(fileMap)) {
|
|
582
|
+
// Skip dist/build files — bundler aggregation creates coincidental co-occurrence
|
|
583
|
+
// of detached_process + suspicious_dataflow. Real DPRK attacks target root files.
|
|
584
|
+
if (DIST_RE.test(file)) continue;
|
|
581
585
|
const fileThreats = fileMap[file];
|
|
582
586
|
const hasDetached = fileThreats.some(t => t.type === 'detached_process');
|
|
583
587
|
const hasCredFlow = fileThreats.some(t => t.type === 'suspicious_dataflow');
|
|
@@ -598,6 +602,11 @@ async function run(targetPath, options = {}) {
|
|
|
598
602
|
// A malware package typically has 1-3 occurrences, not dozens.
|
|
599
603
|
applyFPReductions(deduped, reachableFiles, packageName, packageDeps);
|
|
600
604
|
|
|
605
|
+
// Compound scoring: inject synthetic CRITICAL threats when co-occurring types
|
|
606
|
+
// indicate unambiguous malice. Applied AFTER FP reductions to recover signals
|
|
607
|
+
// that were individually downgraded (count-based, dist, reachability).
|
|
608
|
+
applyCompoundBoosts(deduped);
|
|
609
|
+
|
|
601
610
|
// Intent coherence analysis: detect source→sink pairs within files
|
|
602
611
|
// Pass targetPath for destination-aware SDK pattern detection
|
|
603
612
|
const intentResult = buildIntentPairs(deduped, targetPath);
|
|
@@ -537,6 +537,26 @@ const PLAYBOOKS = {
|
|
|
537
537
|
'Dans un package non-crypto, cela indique un potentiel canal C2 via blockchain. ' +
|
|
538
538
|
'Verifier le contexte: si le package n\'a rien a voir avec la blockchain, supprimer immediatement.',
|
|
539
539
|
|
|
540
|
+
crypto_staged_payload:
|
|
541
|
+
'CRITIQUE: Chaine steganographique complete detectee — fichier binaire (.png/.jpg/.wasm) avec eval() + dechiffrement crypto. ' +
|
|
542
|
+
'Le payload malveillant est cache dans un fichier binaire et dechiffre a runtime. Supprimer le package immediatement. ' +
|
|
543
|
+
'Analyser le fichier binaire dans un sandbox pour extraire le payload.',
|
|
544
|
+
|
|
545
|
+
lifecycle_typosquat:
|
|
546
|
+
'CRITIQUE: Package avec nom similaire a un package populaire ET scripts lifecycle. ' +
|
|
547
|
+
'Vecteur classique de dependency confusion: le code s\'execute a l\'installation. ' +
|
|
548
|
+
'NE PAS installer. Verifier le nom exact du package. Signaler sur npm.',
|
|
549
|
+
|
|
550
|
+
lifecycle_inline_exec:
|
|
551
|
+
'CRITIQUE: Script lifecycle avec node -e (execution inline). Le code s\'execute automatiquement a npm install. ' +
|
|
552
|
+
'NE PAS installer. Si deja installe: considerer la machine compromise. ' +
|
|
553
|
+
'Auditer les modifications systeme recentes.',
|
|
554
|
+
|
|
555
|
+
lifecycle_remote_require:
|
|
556
|
+
'CRITIQUE: Script lifecycle avec require(http/https) pour charger du code distant. ' +
|
|
557
|
+
'Le payload est telecharge et execute automatiquement a l\'installation. ' +
|
|
558
|
+
'NE PAS installer. Bloquer les connexions sortantes. Supprimer le package.',
|
|
559
|
+
|
|
540
560
|
bin_field_hijack:
|
|
541
561
|
'CRITIQUE: Le champ "bin" de package.json shadow une commande systeme (node, npm, git, bash, etc.). ' +
|
|
542
562
|
'A l\'installation, npm cree un symlink dans node_modules/.bin/ qui intercepte la commande reelle. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -1594,6 +1594,57 @@ const RULES = {
|
|
|
1594
1594
|
],
|
|
1595
1595
|
mitre: 'T1102'
|
|
1596
1596
|
},
|
|
1597
|
+
|
|
1598
|
+
// Compound scoring rules (v2.9.2)
|
|
1599
|
+
// Injected by applyCompoundBoosts() when co-occurring threat types indicate unambiguous malice.
|
|
1600
|
+
crypto_staged_payload: {
|
|
1601
|
+
id: 'MUADDIB-COMPOUND-001',
|
|
1602
|
+
name: 'Steganographic Payload + Crypto Decryption',
|
|
1603
|
+
severity: 'CRITICAL',
|
|
1604
|
+
confidence: 'high',
|
|
1605
|
+
description: 'Reference a un fichier binaire (.png/.jpg/.wasm) avec eval() combinee avec dechiffrement crypto (createDecipher). Chaine steganographique complete: payload cache dans un fichier binaire, dechiffre a runtime.',
|
|
1606
|
+
references: [
|
|
1607
|
+
'https://attack.mitre.org/techniques/T1140/',
|
|
1608
|
+
'https://attack.mitre.org/techniques/T1027/003/'
|
|
1609
|
+
],
|
|
1610
|
+
mitre: 'T1140'
|
|
1611
|
+
},
|
|
1612
|
+
lifecycle_typosquat: {
|
|
1613
|
+
id: 'MUADDIB-COMPOUND-002',
|
|
1614
|
+
name: 'Lifecycle Hook on Typosquat Package',
|
|
1615
|
+
severity: 'CRITICAL',
|
|
1616
|
+
confidence: 'high',
|
|
1617
|
+
description: 'Script lifecycle (preinstall/postinstall) sur un package avec nom similaire a un package populaire. Vecteur classique de dependency confusion: le code s\'execute automatiquement a l\'installation.',
|
|
1618
|
+
references: [
|
|
1619
|
+
'https://attack.mitre.org/techniques/T1195/002/',
|
|
1620
|
+
'https://snyk.io/blog/typosquatting-attacks/'
|
|
1621
|
+
],
|
|
1622
|
+
mitre: 'T1195.002'
|
|
1623
|
+
},
|
|
1624
|
+
lifecycle_inline_exec: {
|
|
1625
|
+
id: 'MUADDIB-COMPOUND-004',
|
|
1626
|
+
name: 'Lifecycle Hook + Inline Node Execution',
|
|
1627
|
+
severity: 'CRITICAL',
|
|
1628
|
+
confidence: 'high',
|
|
1629
|
+
description: 'Script lifecycle avec execution inline Node.js (node -e). Le code s\'execute automatiquement a npm install avec un payload inline.',
|
|
1630
|
+
references: [
|
|
1631
|
+
'https://attack.mitre.org/techniques/T1059/007/',
|
|
1632
|
+
'https://attack.mitre.org/techniques/T1195/002/'
|
|
1633
|
+
],
|
|
1634
|
+
mitre: 'T1059.007'
|
|
1635
|
+
},
|
|
1636
|
+
lifecycle_remote_require: {
|
|
1637
|
+
id: 'MUADDIB-COMPOUND-005',
|
|
1638
|
+
name: 'Lifecycle Hook + Remote Code Loading',
|
|
1639
|
+
severity: 'CRITICAL',
|
|
1640
|
+
confidence: 'high',
|
|
1641
|
+
description: 'Script lifecycle avec require(http/https) pour charger du code distant. Le payload est telecharge et execute automatiquement a l\'installation.',
|
|
1642
|
+
references: [
|
|
1643
|
+
'https://attack.mitre.org/techniques/T1105/',
|
|
1644
|
+
'https://attack.mitre.org/techniques/T1195/002/'
|
|
1645
|
+
],
|
|
1646
|
+
mitre: 'T1105'
|
|
1647
|
+
},
|
|
1597
1648
|
};
|
|
1598
1649
|
|
|
1599
1650
|
function getRule(type) {
|
|
@@ -2360,7 +2360,7 @@ function handlePostWalk(ctx) {
|
|
|
2360
2360
|
t.file === ctx.relFile && t.type === 'detached_process'
|
|
2361
2361
|
);
|
|
2362
2362
|
const hasSensitiveEnvInFile = ctx.threats.some(t =>
|
|
2363
|
-
t.file === ctx.relFile && t.type === 'env_access'
|
|
2363
|
+
t.file === ctx.relFile && t.type === 'env_access' && t.severity === 'HIGH'
|
|
2364
2364
|
);
|
|
2365
2365
|
if (hasDetachedInFile && hasSensitiveEnvInFile && ctx.hasNetworkCallInFile) {
|
|
2366
2366
|
ctx.threats.push({
|
|
@@ -2372,11 +2372,15 @@ function handlePostWalk(ctx) {
|
|
|
2372
2372
|
}
|
|
2373
2373
|
|
|
2374
2374
|
// GlassWorm: Unicode variation selector decoder = .codePointAt + variation selector constants
|
|
2375
|
+
// CRITICAL if combined with eval/exec (GlassWorm always uses dynamic execution),
|
|
2376
|
+
// MEDIUM otherwise (.codePointAt + 0xFE00 is legitimate Unicode processing in fonts/text libs)
|
|
2375
2377
|
if (ctx.hasCodePointAt && ctx.hasVariationSelectorConst) {
|
|
2376
2378
|
ctx.threats.push({
|
|
2377
2379
|
type: 'unicode_variation_decoder',
|
|
2378
|
-
severity: 'CRITICAL',
|
|
2379
|
-
message:
|
|
2380
|
+
severity: ctx.hasDynamicExec ? 'CRITICAL' : 'MEDIUM',
|
|
2381
|
+
message: ctx.hasDynamicExec
|
|
2382
|
+
? 'Unicode variation selector decoder: .codePointAt() + 0xFE00/0xE0100 constants + dynamic execution — GlassWorm payload reconstruction from invisible characters.'
|
|
2383
|
+
: 'Unicode variation selector decoder: .codePointAt() + 0xFE00/0xE0100 constants — likely legitimate Unicode processing (text formatting, font rendering).',
|
|
2380
2384
|
file: ctx.relFile
|
|
2381
2385
|
});
|
|
2382
2386
|
}
|
|
@@ -23,7 +23,9 @@ function detectObfuscation(targetPath) {
|
|
|
23
23
|
// P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name.
|
|
24
24
|
// Real obfuscated malware is typically small (<50KB). Catches prettier plugins/, svelte compiler/, etc.
|
|
25
25
|
const isLargeJs = basename.endsWith('.js') && content.length > 100 * 1024;
|
|
26
|
-
|
|
26
|
+
// Locale/i18n files legitimately contain invisible Unicode (e.g. Persian ZWNJ U+200C)
|
|
27
|
+
const isLocaleFile = /(?:^|[/\\])(?:locale|locales|i18n|intl|lang|languages|translations)[/\\]/i.test(relativePath);
|
|
28
|
+
const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs || isLargeJs || isLocaleFile;
|
|
27
29
|
|
|
28
30
|
// 1. Ratio code sur une seule ligne (skip .min.js — minification, not obfuscation)
|
|
29
31
|
if (!isMinified) {
|
|
@@ -73,11 +75,11 @@ function detectObfuscation(targetPath) {
|
|
|
73
75
|
// 7. Unicode invisible character injection (GlassWorm — mars 2026)
|
|
74
76
|
// Detects zero-width chars, variation selectors, tag characters embedded in source
|
|
75
77
|
const invisibleCount = countInvisibleUnicode(content);
|
|
76
|
-
if (invisibleCount >=
|
|
78
|
+
if (invisibleCount >= 10) {
|
|
77
79
|
threats.push({
|
|
78
80
|
type: 'unicode_invisible_injection',
|
|
79
81
|
severity: isPackageOutput ? 'LOW' : 'CRITICAL',
|
|
80
|
-
message: `${invisibleCount} invisible Unicode characters detected (zero-width, variation selectors, tag chars).
|
|
82
|
+
message: `${invisibleCount} invisible Unicode characters detected (zero-width, variation selectors, tag chars). Possible hidden payload encoded via invisible codepoints.`,
|
|
81
83
|
file: relativePath
|
|
82
84
|
});
|
|
83
85
|
}
|
|
@@ -151,7 +153,7 @@ function hasLargeStringArray(content) {
|
|
|
151
153
|
* - U+200B, U+200C, U+200D (zero-width space/joiner/non-joiner)
|
|
152
154
|
* - U+FEFF (BOM — only if position > 0; pos 0 is legitimate BOM)
|
|
153
155
|
* - U+2060 (word joiner), U+180E (Mongolian vowel separator)
|
|
154
|
-
* - U+FE00-U+
|
|
156
|
+
* - U+FE00-U+FE0E (variation selectors — excludes U+FE0F emoji presentation selector)
|
|
155
157
|
* - U+E0100-U+E01EF (variation selectors supplement)
|
|
156
158
|
* - U+E0001-U+E007F (tag characters)
|
|
157
159
|
*/
|
|
@@ -168,8 +170,8 @@ function countInvisibleUnicode(content) {
|
|
|
168
170
|
else if (cp === 0xFEFF && i > 0) {
|
|
169
171
|
count++;
|
|
170
172
|
}
|
|
171
|
-
// BMP variation selectors (U+FE00-U+FE0F)
|
|
172
|
-
else if (cp >= 0xFE00 && cp <=
|
|
173
|
+
// BMP variation selectors (U+FE00-U+FE0E) — excludes U+FE0F (emoji presentation selector)
|
|
174
|
+
else if (cp >= 0xFE00 && cp <= 0xFE0E) {
|
|
173
175
|
count++;
|
|
174
176
|
}
|
|
175
177
|
// Supplementary plane: variation selectors supplement (U+E0100-U+E01EF)
|
package/src/scanner/package.js
CHANGED
|
@@ -137,6 +137,11 @@ async function scanPackageJson(targetPath) {
|
|
|
137
137
|
: pkg.bin;
|
|
138
138
|
for (const [cmdName, cmdPath] of Object.entries(binEntries || {})) {
|
|
139
139
|
if (SHADOWED_COMMANDS.has(cmdName)) {
|
|
140
|
+
// Skip when the package IS the legitimate provider of the command:
|
|
141
|
+
// 1. Self-name: npm→bin.npm, yarn→bin.yarn
|
|
142
|
+
// 2. Sibling commands: npm also provides npx → pkg.name in SHADOWED_COMMANDS
|
|
143
|
+
// Typosquats still caught: 'nmp' declaring bin.npm → 'nmp' not in SHADOWED_COMMANDS → fires
|
|
144
|
+
if (cmdName === pkg.name || SHADOWED_COMMANDS.has(pkg.name)) continue;
|
|
140
145
|
threats.push({
|
|
141
146
|
type: 'bin_field_hijack',
|
|
142
147
|
severity: 'CRITICAL',
|
package/src/scoring.js
CHANGED
|
@@ -62,7 +62,9 @@ const PACKAGE_LEVEL_TYPES = new Set([
|
|
|
62
62
|
'publish_burst', 'publish_dormant_spike', 'publish_rapid_succession',
|
|
63
63
|
'maintainer_new_suspicious', 'maintainer_sole_change',
|
|
64
64
|
'sandbox_network_activity', 'sandbox_file_changes', 'sandbox_process_spawns',
|
|
65
|
-
'sandbox_canary_exfiltration'
|
|
65
|
+
'sandbox_canary_exfiltration',
|
|
66
|
+
// Compound scoring rules — package-level co-occurrences
|
|
67
|
+
'lifecycle_typosquat', 'lifecycle_inline_exec', 'lifecycle_remote_require'
|
|
66
68
|
]);
|
|
67
69
|
|
|
68
70
|
/**
|
|
@@ -154,9 +156,16 @@ const DIST_EXEMPT_TYPES = new Set([
|
|
|
154
156
|
'cross_file_dataflow', // credential read → network exfil across files
|
|
155
157
|
'staged_eval_decode', // eval(atob(...)) (explicit payload staging)
|
|
156
158
|
'reverse_shell', // net.Socket + connect + pipe (always malicious)
|
|
157
|
-
|
|
159
|
+
// detached_credential_exfil removed from DIST_EXEMPT: in dist/ files, co-occurrence of
|
|
160
|
+
// detached_process + env_access + network is coincidental bundler aggregation.
|
|
161
|
+
// Kept in REACHABILITY_EXEMPT_TYPES (lifecycle invocation is valid).
|
|
158
162
|
'node_modules_write', // writeFile to node_modules/ (worm propagation)
|
|
159
|
-
'npm_publish_worm'
|
|
163
|
+
'npm_publish_worm', // exec("npm publish") (worm propagation)
|
|
164
|
+
// Dangerous shell commands in dist/ are real threats, never bundler output
|
|
165
|
+
'dangerous_exec',
|
|
166
|
+
// Compound scoring rules — co-occurrence signals, never FP
|
|
167
|
+
'crypto_staged_payload', 'lifecycle_typosquat',
|
|
168
|
+
'lifecycle_inline_exec', 'lifecycle_remote_require'
|
|
160
169
|
// P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
|
|
161
170
|
// fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
|
|
162
171
|
// fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
|
|
@@ -174,7 +183,7 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
|
|
|
174
183
|
'dynamic_require', 'dynamic_import',
|
|
175
184
|
'obfuscation_detected', 'high_entropy_string', 'possible_obfuscation',
|
|
176
185
|
'js_obfuscation_pattern', 'vm_code_execution',
|
|
177
|
-
'module_compile', 'module_compile_dynamic',
|
|
186
|
+
'module_compile', 'module_compile_dynamic', 'unicode_variation_decoder',
|
|
178
187
|
// P7: env_access in dist/ is bundled SDK config reading, not credential theft
|
|
179
188
|
'env_access',
|
|
180
189
|
// P8: Proxy traps in dist/ are state management frameworks (MobX, Vue reactivity, Immer),
|
|
@@ -182,7 +191,12 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
|
|
|
182
191
|
'proxy_data_intercept',
|
|
183
192
|
// P9: fetch+eval in dist/ is Vite/Webpack code splitting (lazy chunk loading),
|
|
184
193
|
// not remote code execution. Two-notch downgrade (CRITICAL→MEDIUM, HIGH→LOW).
|
|
185
|
-
'remote_code_load'
|
|
194
|
+
'remote_code_load',
|
|
195
|
+
// P10: In dist/ bundles, binary file refs + crypto are coincidental bundler aggregation
|
|
196
|
+
// (webpack bundles crypto utils alongside image processing). Real steganographic attacks
|
|
197
|
+
// (flatmap-stream) have these at package root, not dist/. Compound (crypto_staged_payload)
|
|
198
|
+
// is in DIST_EXEMPT_TYPES so the overall signal is preserved when truly malicious.
|
|
199
|
+
'staged_binary_payload', 'crypto_decipher'
|
|
186
200
|
]);
|
|
187
201
|
|
|
188
202
|
// Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.
|
|
@@ -203,6 +217,102 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
|
|
|
203
217
|
'detached_credential_exfil' // DPRK/Lazarus: invoked via lifecycle, not require/import
|
|
204
218
|
]);
|
|
205
219
|
|
|
220
|
+
// ============================================
|
|
221
|
+
// COMPOUND SCORING RULES (v2.9.2)
|
|
222
|
+
// ============================================
|
|
223
|
+
// Co-occurrences of threat types that NEVER appear in benign packages.
|
|
224
|
+
// Applied AFTER FP reductions to recover signals that were individually downgraded.
|
|
225
|
+
// Each compound injects a new CRITICAL threat when all required types are present.
|
|
226
|
+
const SCORING_COMPOUNDS = [
|
|
227
|
+
{
|
|
228
|
+
type: 'crypto_staged_payload',
|
|
229
|
+
requires: ['staged_binary_payload', 'crypto_decipher'],
|
|
230
|
+
severity: 'CRITICAL',
|
|
231
|
+
message: 'Binary file reference + crypto decryption — steganographic payload chain (scoring compound).',
|
|
232
|
+
fileFrom: 'staged_binary_payload',
|
|
233
|
+
sameFile: true // Real steganographic attacks (flatmap-stream) have crypto+binary in the SAME file
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
type: 'lifecycle_typosquat',
|
|
237
|
+
requires: ['lifecycle_script', 'typosquat_detected'],
|
|
238
|
+
severity: 'CRITICAL',
|
|
239
|
+
message: 'Lifecycle hook on typosquat package — dependency confusion attack vector (scoring compound).',
|
|
240
|
+
fileFrom: 'typosquat_detected'
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
type: 'lifecycle_inline_exec',
|
|
244
|
+
requires: ['lifecycle_script', 'node_inline_exec'],
|
|
245
|
+
severity: 'CRITICAL',
|
|
246
|
+
message: 'Lifecycle hook with inline Node execution (node -e) — install-time code execution (scoring compound).',
|
|
247
|
+
fileFrom: 'node_inline_exec'
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
type: 'lifecycle_remote_require',
|
|
251
|
+
requires: ['lifecycle_script', 'network_require'],
|
|
252
|
+
severity: 'CRITICAL',
|
|
253
|
+
message: 'Lifecycle hook loading remote code (require http/https) — supply chain payload delivery (scoring compound).',
|
|
254
|
+
fileFrom: 'network_require'
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Apply compound boost rules: inject synthetic CRITICAL threats when
|
|
260
|
+
* co-occurring threat types indicate unambiguous malice.
|
|
261
|
+
* Called AFTER applyFPReductions to recover individually-downgraded signals.
|
|
262
|
+
* @param {Array} threats - deduplicated threat array (mutated in place)
|
|
263
|
+
*/
|
|
264
|
+
function applyCompoundBoosts(threats) {
|
|
265
|
+
const typeSet = new Set(threats.map(t => t.type));
|
|
266
|
+
|
|
267
|
+
// Build map of type → first file encountered (for file assignment)
|
|
268
|
+
const typeFileMap = Object.create(null);
|
|
269
|
+
for (const t of threats) {
|
|
270
|
+
if (!typeFileMap[t.type]) {
|
|
271
|
+
typeFileMap[t.type] = t.file || '(unknown)';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const compound of SCORING_COMPOUNDS) {
|
|
276
|
+
// Skip if compound already present (e.g. from a scanner)
|
|
277
|
+
if (typeSet.has(compound.type)) continue;
|
|
278
|
+
|
|
279
|
+
// Check all required types are present
|
|
280
|
+
if (compound.requires.every(req => typeSet.has(req))) {
|
|
281
|
+
// Severity gate: at least one component must have severity >= MEDIUM
|
|
282
|
+
// after FP reductions. If all components were downgraded to LOW,
|
|
283
|
+
// the compound signal is not strong enough to justify a CRITICAL boost.
|
|
284
|
+
const hasSignificantComponent = compound.requires.some(req =>
|
|
285
|
+
threats.some(t => t.type === req && t.severity !== 'LOW')
|
|
286
|
+
);
|
|
287
|
+
if (!hasSignificantComponent) continue;
|
|
288
|
+
|
|
289
|
+
// Same-file constraint: all required types must appear in at least one common file.
|
|
290
|
+
// Prevents cross-file coincidental matches (e.g. next.js: staged_binary_payload in
|
|
291
|
+
// dist/compiled/@vercel/nft/index.js + crypto_decipher in a different file).
|
|
292
|
+
if (compound.sameFile) {
|
|
293
|
+
const filesByType = compound.requires.map(req =>
|
|
294
|
+
new Set(threats.filter(t => t.type === req).map(t => t.file))
|
|
295
|
+
);
|
|
296
|
+
// Find intersection of all file sets
|
|
297
|
+
const commonFiles = [...filesByType[0]].filter(f =>
|
|
298
|
+
filesByType.every(s => s.has(f))
|
|
299
|
+
);
|
|
300
|
+
if (commonFiles.length === 0) continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
threats.push({
|
|
304
|
+
type: compound.type,
|
|
305
|
+
severity: compound.severity,
|
|
306
|
+
message: compound.message,
|
|
307
|
+
file: typeFileMap[compound.fileFrom] || '(unknown)',
|
|
308
|
+
count: 1,
|
|
309
|
+
compound: true
|
|
310
|
+
});
|
|
311
|
+
typeSet.add(compound.type);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
206
316
|
// Custom class prototypes that HTTP frameworks legitimately extend.
|
|
207
317
|
// Distinguished from dangerous core Node.js prototype hooks.
|
|
208
318
|
const FRAMEWORK_PROTOTYPES = ['Request', 'Response', 'App', 'Router'];
|
|
@@ -463,5 +573,5 @@ function calculateRiskScore(deduped, intentResult) {
|
|
|
463
573
|
|
|
464
574
|
module.exports = {
|
|
465
575
|
SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, CONFIDENCE_FACTORS,
|
|
466
|
-
isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore
|
|
576
|
+
isPackageLevelThreat, computeGroupScore, applyFPReductions, applyCompoundBoosts, calculateRiskScore
|
|
467
577
|
};
|