muaddib-scanner 2.9.2 → 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.2",
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
@@ -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');
@@ -547,11 +547,6 @@ const PLAYBOOKS = {
547
547
  'Vecteur classique de dependency confusion: le code s\'execute a l\'installation. ' +
548
548
  'NE PAS installer. Verifier le nom exact du package. Signaler sur npm.',
549
549
 
550
- credential_env_exfil:
551
- 'CRITIQUE: Ecriture dans des chemins sensibles (cache npm/yarn, credentials) + acces aux variables d\'environnement. ' +
552
- 'Double vecteur d\'exfiltration de credentials. Supprimer le package. Regenerer tous les secrets. ' +
553
- 'Nettoyer le cache: npm cache clean --force.',
554
-
555
550
  lifecycle_inline_exec:
556
551
  'CRITIQUE: Script lifecycle avec node -e (execution inline). Le code s\'execute automatiquement a npm install. ' +
557
552
  'NE PAS installer. Si deja installe: considerer la machine compromise. ' +
@@ -562,10 +557,6 @@ const PLAYBOOKS = {
562
557
  'Le payload est telecharge et execute automatiquement a l\'installation. ' +
563
558
  'NE PAS installer. Bloquer les connexions sortantes. Supprimer le package.',
564
559
 
565
- obfuscated_credential_tampering:
566
- 'CRITIQUE: Code obfusque + ecriture dans des chemins sensibles. Dissimulation de vol de credentials. ' +
567
- 'Supprimer le package immediatement. Nettoyer le cache npm/yarn. Regenerer tous les secrets.',
568
-
569
560
  bin_field_hijack:
570
561
  'CRITIQUE: Le champ "bin" de package.json shadow une commande systeme (node, npm, git, bash, etc.). ' +
571
562
  'A l\'installation, npm cree un symlink dans node_modules/.bin/ qui intercepte la commande reelle. ' +
@@ -1621,18 +1621,6 @@ const RULES = {
1621
1621
  ],
1622
1622
  mitre: 'T1195.002'
1623
1623
  },
1624
- credential_env_exfil: {
1625
- id: 'MUADDIB-COMPOUND-003',
1626
- name: 'Credential Tampering + Env Access',
1627
- severity: 'CRITICAL',
1628
- confidence: 'high',
1629
- description: 'Ecriture dans un chemin sensible (cache npm/yarn, credentials) combinee avec acces aux variables d\'environnement. Chaine d\'exfiltration de credentials par double vecteur.',
1630
- references: [
1631
- 'https://attack.mitre.org/techniques/T1552/001/',
1632
- 'https://attack.mitre.org/techniques/T1565/001/'
1633
- ],
1634
- mitre: 'T1552.001'
1635
- },
1636
1624
  lifecycle_inline_exec: {
1637
1625
  id: 'MUADDIB-COMPOUND-004',
1638
1626
  name: 'Lifecycle Hook + Inline Node Execution',
@@ -1657,18 +1645,6 @@ const RULES = {
1657
1645
  ],
1658
1646
  mitre: 'T1105'
1659
1647
  },
1660
- obfuscated_credential_tampering: {
1661
- id: 'MUADDIB-COMPOUND-006',
1662
- name: 'Obfuscated Code + Credential Tampering',
1663
- severity: 'CRITICAL',
1664
- confidence: 'high',
1665
- description: 'Code obfusque combine avec ecriture dans des chemins sensibles (cache npm/yarn, credentials). Dissimulation de vol de credentials.',
1666
- references: [
1667
- 'https://attack.mitre.org/techniques/T1027/',
1668
- 'https://attack.mitre.org/techniques/T1565/001/'
1669
- ],
1670
- mitre: 'T1027'
1671
- },
1672
1648
  };
1673
1649
 
1674
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
@@ -156,14 +156,16 @@ const DIST_EXEMPT_TYPES = new Set([
156
156
  'cross_file_dataflow', // credential read → network exfil across files
157
157
  'staged_eval_decode', // eval(atob(...)) (explicit payload staging)
158
158
  'reverse_shell', // net.Socket + connect + pipe (always malicious)
159
- '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).
160
162
  'node_modules_write', // writeFile to node_modules/ (worm propagation)
161
163
  'npm_publish_worm', // exec("npm publish") (worm propagation)
162
164
  // Dangerous shell commands in dist/ are real threats, never bundler output
163
165
  'dangerous_exec',
164
166
  // Compound scoring rules — co-occurrence signals, never FP
165
- 'crypto_staged_payload', 'lifecycle_typosquat', 'credential_env_exfil',
166
- 'lifecycle_inline_exec', 'lifecycle_remote_require', 'obfuscated_credential_tampering'
167
+ 'crypto_staged_payload', 'lifecycle_typosquat',
168
+ 'lifecycle_inline_exec', 'lifecycle_remote_require'
167
169
  // P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
168
170
  // fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
169
171
  // fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
@@ -181,7 +183,7 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
181
183
  'dynamic_require', 'dynamic_import',
182
184
  'obfuscation_detected', 'high_entropy_string', 'possible_obfuscation',
183
185
  'js_obfuscation_pattern', 'vm_code_execution',
184
- 'module_compile', 'module_compile_dynamic',
186
+ 'module_compile', 'module_compile_dynamic', 'unicode_variation_decoder',
185
187
  // P7: env_access in dist/ is bundled SDK config reading, not credential theft
186
188
  'env_access',
187
189
  // P8: Proxy traps in dist/ are state management frameworks (MobX, Vue reactivity, Immer),
@@ -189,7 +191,12 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
189
191
  'proxy_data_intercept',
190
192
  // P9: fetch+eval in dist/ is Vite/Webpack code splitting (lazy chunk loading),
191
193
  // not remote code execution. Two-notch downgrade (CRITICAL→MEDIUM, HIGH→LOW).
192
- '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'
193
200
  ]);
194
201
 
195
202
  // Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.
@@ -222,7 +229,8 @@ const SCORING_COMPOUNDS = [
222
229
  requires: ['staged_binary_payload', 'crypto_decipher'],
223
230
  severity: 'CRITICAL',
224
231
  message: 'Binary file reference + crypto decryption — steganographic payload chain (scoring compound).',
225
- fileFrom: 'staged_binary_payload'
232
+ fileFrom: 'staged_binary_payload',
233
+ sameFile: true // Real steganographic attacks (flatmap-stream) have crypto+binary in the SAME file
226
234
  },
227
235
  {
228
236
  type: 'lifecycle_typosquat',
@@ -231,13 +239,6 @@ const SCORING_COMPOUNDS = [
231
239
  message: 'Lifecycle hook on typosquat package — dependency confusion attack vector (scoring compound).',
232
240
  fileFrom: 'typosquat_detected'
233
241
  },
234
- {
235
- type: 'credential_env_exfil',
236
- requires: ['credential_tampering', 'env_access'],
237
- severity: 'CRITICAL',
238
- message: 'Credential path tampering + environment variable access — credential exfiltration chain (scoring compound).',
239
- fileFrom: 'credential_tampering'
240
- },
241
242
  {
242
243
  type: 'lifecycle_inline_exec',
243
244
  requires: ['lifecycle_script', 'node_inline_exec'],
@@ -252,13 +253,6 @@ const SCORING_COMPOUNDS = [
252
253
  message: 'Lifecycle hook loading remote code (require http/https) — supply chain payload delivery (scoring compound).',
253
254
  fileFrom: 'network_require'
254
255
  },
255
- {
256
- type: 'obfuscated_credential_tampering',
257
- requires: ['credential_tampering', 'obfuscation_detected'],
258
- severity: 'CRITICAL',
259
- message: 'Obfuscated code + credential path tampering — concealed credential theft (scoring compound).',
260
- fileFrom: 'credential_tampering'
261
- }
262
256
  ];
263
257
 
264
258
  /**
@@ -284,6 +278,28 @@ function applyCompoundBoosts(threats) {
284
278
 
285
279
  // Check all required types are present
286
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
+
287
303
  threats.push({
288
304
  type: compound.type,
289
305
  severity: compound.severity,