muaddib-scanner 2.2.28 → 2.3.0

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/src/scoring.js CHANGED
@@ -1,213 +1,253 @@
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
- };
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
+ module_compile_dynamic: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
110
+ zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' }
111
+ };
112
+
113
+ // Types exempt from dist/ downgrade — IOC matches and lifecycle scripts are always real
114
+ const DIST_EXEMPT_TYPES = new Set([
115
+ 'ioc_match', 'known_malicious_package', 'pypi_malicious_package', 'shai_hulud_marker',
116
+ 'lifecycle_script', 'lifecycle_shell_pipe',
117
+ 'lifecycle_added_critical', 'lifecycle_added_high', 'lifecycle_modified'
118
+ ]);
119
+
120
+ // Regex matching dist/build/minified/bundled file paths
121
+ const DIST_FILE_RE = /(?:^|[/\\])(?:dist|build)[/\\]|\.min\.js$|\.bundle\.js$/i;
122
+
123
+ // Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types
124
+ const REACHABILITY_EXEMPT_TYPES = new Set([
125
+ ...DIST_EXEMPT_TYPES,
126
+ 'cross_file_dataflow',
127
+ 'typosquat_detected', 'pypi_typosquat_detected',
128
+ 'pypi_malicious_package',
129
+ 'ai_config_injection', 'ai_config_injection_compound'
130
+ ]);
131
+
132
+ // Custom class prototypes that HTTP frameworks legitimately extend.
133
+ // Distinguished from dangerous core Node.js prototype hooks.
134
+ const FRAMEWORK_PROTOTYPES = ['Request', 'Response', 'App', 'Router'];
135
+ const FRAMEWORK_PROTO_RE = new RegExp(
136
+ '^(' + FRAMEWORK_PROTOTYPES.join('|') + ')\\.prototype\\.'
137
+ );
138
+
139
+ function applyFPReductions(threats, reachableFiles) {
140
+ // Count occurrences of each threat type (package-level, across all files)
141
+ const typeCounts = {};
142
+ for (const t of threats) {
143
+ typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
144
+ }
145
+
146
+ for (const t of threats) {
147
+ // Count-based downgrade: if a threat type appears too many times,
148
+ // it's a framework/plugin system, not malware
149
+ const rule = FP_COUNT_THRESHOLDS[t.type];
150
+ if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
151
+ t.severity = rule.to;
152
+ }
153
+
154
+ // Prototype hook: framework class prototypes → MEDIUM
155
+ // Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
156
+ // Browser/native APIs (globalThis.fetch, XMLHttpRequest) stay HIGH
157
+ if (t.type === 'prototype_hook' && t.severity === 'HIGH' &&
158
+ FRAMEWORK_PROTO_RE.test(t.message)) {
159
+ t.severity = 'MEDIUM';
160
+ }
161
+
162
+ // Dist/build/minified files: bundler artifacts get severity downgraded one notch.
163
+ // Real malware injects payloads in source files, not in dist/ output.
164
+ if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
165
+ if (t.severity === 'CRITICAL') t.severity = 'HIGH';
166
+ else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
167
+ else if (t.severity === 'MEDIUM') t.severity = 'LOW';
168
+ }
169
+
170
+ // Reachability: findings in files not reachable from entry points → LOW
171
+ if (reachableFiles && reachableFiles.size > 0 && t.file &&
172
+ !REACHABILITY_EXEMPT_TYPES.has(t.type) &&
173
+ !isPackageLevelThreat(t)) {
174
+ const normalizedFile = t.file.replace(/\\/g, '/');
175
+ if (!reachableFiles.has(normalizedFile)) {
176
+ t.severity = 'LOW';
177
+ t.unreachable = true;
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Calculate per-file max risk score from deduplicated threats.
185
+ * Formula: riskScore = min(100, max(file_scores) + package_level_score)
186
+ * @param {Array} deduped - deduplicated threat array
187
+ * @returns {Object} { riskScore, riskLevel, globalRiskScore, maxFileScore, packageScore, mostSuspiciousFile, fileScores, criticalCount, highCount, mediumCount, lowCount }
188
+ */
189
+ function calculateRiskScore(deduped) {
190
+ // 1. Separate deduped threats into package-level and file-level
191
+ const packageLevelThreats = [];
192
+ const fileLevelThreats = [];
193
+ for (const t of deduped) {
194
+ if (isPackageLevelThreat(t)) {
195
+ packageLevelThreats.push(t);
196
+ } else {
197
+ fileLevelThreats.push(t);
198
+ }
199
+ }
200
+
201
+ // 2. Group file-level threats by file
202
+ const fileGroups = new Map();
203
+ for (const t of fileLevelThreats) {
204
+ const key = t.file || '(unknown)';
205
+ if (!fileGroups.has(key)) fileGroups.set(key, []);
206
+ fileGroups.get(key).push(t);
207
+ }
208
+
209
+ // 3. Compute per-file scores and find the most suspicious file
210
+ let maxFileScore = 0;
211
+ let mostSuspiciousFile = null;
212
+ const fileScores = {};
213
+ for (const [file, fileThreats] of fileGroups) {
214
+ const score = computeGroupScore(fileThreats);
215
+ fileScores[file] = score;
216
+ if (score > maxFileScore) {
217
+ maxFileScore = score;
218
+ mostSuspiciousFile = file;
219
+ }
220
+ }
221
+
222
+ // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
223
+ const packageScore = computeGroupScore(packageLevelThreats);
224
+
225
+ // 5. Final score = max file score + package-level score, capped at 100
226
+ const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + packageScore);
227
+
228
+ // 6. Old global score for comparison (sum of ALL findings)
229
+ const globalRiskScore = computeGroupScore(deduped);
230
+
231
+ // 7. Severity counts (global, for summary display)
232
+ const criticalCount = deduped.filter(t => t.severity === 'CRITICAL').length;
233
+ const highCount = deduped.filter(t => t.severity === 'HIGH').length;
234
+ const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
235
+ const lowCount = deduped.filter(t => t.severity === 'LOW').length;
236
+
237
+ const riskLevel = riskScore >= RISK_THRESHOLDS.CRITICAL ? 'CRITICAL'
238
+ : riskScore >= RISK_THRESHOLDS.HIGH ? 'HIGH'
239
+ : riskScore >= RISK_THRESHOLDS.MEDIUM ? 'MEDIUM'
240
+ : riskScore > 0 ? 'LOW'
241
+ : 'SAFE';
242
+
243
+ return {
244
+ riskScore, riskLevel, globalRiskScore,
245
+ maxFileScore, packageScore, mostSuspiciousFile, fileScores,
246
+ criticalCount, highCount, mediumCount, lowCount
247
+ };
248
+ }
249
+
250
+ module.exports = {
251
+ SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE,
252
+ isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore
253
+ };