muaddib-scanner 2.5.6 → 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.
@@ -101,6 +101,8 @@ function analyzeFile(content, filePath, basePath) {
101
101
  ideConfigPathVars: new Map(),
102
102
  // Wave 4: compound detection — fetch + decrypt + eval chain
103
103
  hasRemoteFetch: /\bhttps?\.(get|request)\b/.test(content) || /\bfetch\s*\(/.test(content),
104
+ // Safe domain exclusion: if ALL URLs in file are from known registries, suppress download_exec_binary
105
+ fetchOnlySafeDomains: false, // computed below after URL extraction
104
106
  hasCryptoDecipher: /\bcreateDecipher(iv)?\s*\(/.test(content),
105
107
  // Wave 4: native addon camouflage signals
106
108
  hasRequireNodeFile: false,
@@ -111,10 +113,28 @@ function analyzeFile(content, filePath, basePath) {
111
113
  hasRunOnInContent: /\brunOn\b|\bfolderOpen\b/.test(content),
112
114
  hasWriteFileSyncInContent: /\bwriteFileSync\b|\bwriteFile\s*\(/.test(content),
113
115
  // Wave 4: MCP content keyword detection (must also have writeFileSync in same file)
116
+ // Content-level MCP detection: MCP keyword + writeFileSync + MCP config path in same file
117
+ // Path co-occurrence prevents FPs where a file reads MCP config but writes elsewhere.
118
+ // Read-only pattern (readFileSync without writeFileSync to MCP) is not injection.
114
119
  hasMcpContentKeywords: (/\bmcpServers\b/.test(content) || /\bmcp\.json\b/.test(content) || /\bclaude_desktop_config\b/.test(content)) &&
115
- /\bwriteFileSync\b|\bwriteFile\s*\(/.test(content)
120
+ /\bwriteFileSync\b|\bwriteFile\s*\(/.test(content) &&
121
+ (/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content))
116
122
  };
117
123
 
124
+ // Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
125
+ if (ctx.hasRemoteFetch) {
126
+ const urlMatches = content.match(/https?:\/\/[^\s'"`)]+/g) || [];
127
+ const SAFE_FETCH_DOMAINS = [
128
+ 'registry.npmjs.org', 'npmjs.com',
129
+ 'github.com', 'objects.githubusercontent.com', 'raw.githubusercontent.com',
130
+ 'nodejs.org', 'yarnpkg.com',
131
+ 'pypi.org', 'files.pythonhosted.org'
132
+ ];
133
+ if (urlMatches.length > 0 && urlMatches.every(u => SAFE_FETCH_DOMAINS.some(d => u.includes(d)))) {
134
+ ctx.fetchOnlySafeDomains = true;
135
+ }
136
+ }
137
+
118
138
  walk.simple(ast, {
119
139
  VariableDeclarator(node) { handleVariableDeclarator(node, ctx); },
120
140
  CallExpression(node) { handleCallExpression(node, ctx); },
@@ -108,7 +108,11 @@ const WHITELIST = new Set([
108
108
  'docdash', // resembles lodash
109
109
  'yarpm', // resembles yargs
110
110
  'canvg', // resembles canvas
111
- 'obug' // internal sub-dependency
111
+ 'obug', // internal sub-dependency
112
+
113
+ // FPR P4: Benign packages falsely flagged as typosquat in evaluation
114
+ 'mocks', // karma dep, resembles mocha (wrong_char)
115
+ 'reactor' // stencil dep, resembles react (suffix)
112
116
  ]);
113
117
 
114
118
 
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
  }