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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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. ' +
@@ -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: 'Unicode variation selector decoder: .codePointAt() + 0xFE00/0xE0100 constants — GlassWorm payload reconstruction from invisible characters.',
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
- const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs || isLargeJs;
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 >= 3) {
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). GlassWorm technique: payload encoded via invisible codepoints.`,
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+FE0F (variation selectors — GlassWorm 256-value encoding)
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 <= 0xFE0F) {
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)
@@ -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
- 'detached_credential_exfil', // detached process + credential exfil (DPRK/Lazarus)
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' // exec("npm publish") (worm propagation)
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
  };