muaddib-scanner 2.5.7 → 2.5.8

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
@@ -106,11 +106,17 @@ const FP_COUNT_THRESHOLDS = {
106
106
  dynamic_require: { maxCount: 10, from: 'HIGH', to: 'LOW' },
107
107
  dangerous_call_function: { maxCount: 5, from: 'MEDIUM', to: 'LOW' },
108
108
  require_cache_poison: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
109
- suspicious_dataflow: { maxCount: 5, to: 'LOW' },
109
+ suspicious_dataflow: { maxCount: 3, to: 'LOW' },
110
110
  obfuscation_detected: { maxCount: 3, to: 'LOW' },
111
111
  module_compile_dynamic: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
112
112
  module_compile: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
113
- zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' }
113
+ zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' },
114
+ // P4: plugin loaders legitimately use many dynamic imports (webpack, eslint, knex, gatsby)
115
+ dynamic_import: { maxCount: 5, from: 'HIGH', to: 'LOW' },
116
+ // P4: hash algorithms contain bit manipulation that triggers obfuscation heuristics
117
+ js_obfuscation_pattern: { maxCount: 1, from: 'HIGH', to: 'LOW' },
118
+ // P4: bundled credential_tampering from minified alias resolution (jspdf, lerna)
119
+ credential_tampering: { maxCount: 5, to: 'LOW' }
114
120
  };
115
121
 
116
122
  // Types exempt from dist/ downgrade — IOC matches and lifecycle scripts are always real
@@ -182,6 +188,18 @@ function applyFPReductions(threats, reachableFiles, packageName) {
182
188
 
183
189
  const totalThreats = threats.length;
184
190
 
191
+ // P4: Plugin loader pattern — packages with 2+ dynamic_require + dynamic_import combined
192
+ // are legitimate plugin systems (webpack, eslint, karma, knex, jasmine, gatsby).
193
+ // Malware uses one pattern, not both. Bypass the per-type percentage guard.
194
+ const pluginLoaderCount = (typeCounts.dynamic_require || 0) + (typeCounts.dynamic_import || 0);
195
+ if (pluginLoaderCount > 1) {
196
+ for (const t of threats) {
197
+ if ((t.type === 'dynamic_require' || t.type === 'dynamic_import') && t.severity === 'HIGH') {
198
+ t.severity = 'LOW';
199
+ }
200
+ }
201
+ }
202
+
185
203
  for (const t of threats) {
186
204
  // Count-based downgrade: if a threat type appears too many times,
187
205
  // it's a framework/plugin system, not malware.
@@ -190,7 +208,10 @@ function applyFPReductions(threats, reachableFiles, packageName) {
190
208
  const rule = FP_COUNT_THRESHOLDS[t.type];
191
209
  if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
192
210
  const typeRatio = typeCounts[t.type] / totalThreats;
193
- if (typeRatio < 0.5) {
211
+ // P4: suspicious_dataflow bypasses the percentage guard — multiple data flow paths
212
+ // indicate a complex application (SMTP client, monitoring agent), not malware.
213
+ // Malware has 1-2 targeted exfiltration flows, not 4+.
214
+ if (typeRatio < 0.5 || t.type === 'suspicious_dataflow') {
194
215
  t.severity = rule.to;
195
216
  }
196
217
  }
@@ -220,11 +241,13 @@ function applyFPReductions(threats, reachableFiles, packageName) {
220
241
  }
221
242
  }
222
243
 
223
- // Dist/build/minified files: bundler artifacts get severity downgraded one notch.
244
+ // Dist/build/minified files: bundler artifacts get severity downgraded two notches.
224
245
  // Real malware injects payloads in source files, not in dist/ output.
246
+ // Two-notch downgrade (P4): cross-file bonus amplifies dist/ noise in large packages.
247
+ // IOC matches and lifecycle scripts are exempt (DIST_EXEMPT_TYPES).
225
248
  if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
226
- if (t.severity === 'CRITICAL') t.severity = 'HIGH';
227
- else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
249
+ if (t.severity === 'CRITICAL') t.severity = 'MEDIUM';
250
+ else if (t.severity === 'HIGH') t.severity = 'LOW';
228
251
  else if (t.severity === 'MEDIUM') t.severity = 'LOW';
229
252
  }
230
253
 
@@ -271,9 +294,11 @@ function calculateRiskScore(deduped) {
271
294
  let maxFileScore = 0;
272
295
  let mostSuspiciousFile = null;
273
296
  const fileScores = {};
297
+ const fileHasMediumPlus = {}; // P4: track files with MEDIUM+ threats for cross-file bonus
274
298
  for (const [file, fileThreats] of fileGroups) {
275
299
  const score = computeGroupScore(fileThreats);
276
300
  fileScores[file] = score;
301
+ fileHasMediumPlus[file] = fileThreats.some(t => t.severity !== 'LOW');
277
302
  if (score > maxFileScore) {
278
303
  maxFileScore = score;
279
304
  mostSuspiciousFile = file;
@@ -286,11 +311,16 @@ function calculateRiskScore(deduped) {
286
311
  // 5. Cross-file bonus: aggregate signal from non-max files
287
312
  // A package with 3 files each scoring 20 is more suspicious than 1 file scoring 20.
288
313
  // Add 25% of each non-max file's score as a bonus, capped at 25.
289
- const sortedScores = Object.values(fileScores).sort((a, b) => b - a);
314
+ // P4: Only count files that have at least one MEDIUM+ threat.
315
+ // Files with only LOW findings are noise in large packages and shouldn't amplify the score.
316
+ const bonusEligibleScores = Object.entries(fileScores)
317
+ .filter(([file]) => fileHasMediumPlus[file])
318
+ .map(([, score]) => score)
319
+ .sort((a, b) => b - a);
290
320
  let crossFileBonus = 0;
291
- if (sortedScores.length > 1) {
292
- for (let i = 1; i < sortedScores.length; i++) {
293
- crossFileBonus += Math.ceil(sortedScores[i] * 0.25);
321
+ if (bonusEligibleScores.length > 1) {
322
+ for (let i = 1; i < bonusEligibleScores.length; i++) {
323
+ crossFileBonus += Math.ceil(bonusEligibleScores[i] * 0.25);
294
324
  }
295
325
  crossFileBonus = Math.min(crossFileBonus, 25);
296
326
  }