muaddib-scanner 2.11.4 → 2.11.7

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
@@ -447,7 +447,16 @@ const REACHABILITY_EXEMPT_TYPES = new Set([
447
447
  'pypi_malicious_package',
448
448
  'ai_config_injection', 'ai_config_injection_compound',
449
449
  'detached_credential_exfil', // DPRK/Lazarus: invoked via lifecycle, not require/import
450
- 'native_addon_install' // binding.gyp executes during npm install but isn't require()'d
450
+ 'native_addon_install', // binding.gyp executes during npm install but isn't require()'d
451
+ // Staged loader pattern (chai-* / poxios-chain campaign 2026-05): the malicious
452
+ // file is loaded indirectly (transport.js requires caller.js) and reachability
453
+ // resolution can fail, demoting CRITICAL to LOW. These types are unambiguously
454
+ // malicious — no legitimate code shadows process, calls Function.constructor("require"),
455
+ // or self-destructs after running new Function(...).
456
+ 'function_constructor_require', // AST-086 — Function.constructor("require", body)
457
+ 'process_variable_shadow', // AST-087 — const process = {env:{...}}
458
+ 'function_runtime_args', // AST-090 — new Function('require','__dirname',...)
459
+ 'self_destruct_eval' // AST-089 — dynamic exec + unlink __filename
451
460
  ]);
452
461
 
453
462
  // ============================================
@@ -500,8 +509,12 @@ const SCORING_COMPOUNDS = [
500
509
  requires: ['lifecycle_script', 'suspicious_dataflow'],
501
510
  severity: 'HIGH',
502
511
  message: 'Lifecycle hook + suspicious dataflow — install-time credential/data exfiltration pattern (scoring compound).',
503
- fileFrom: 'suspicious_dataflow'
512
+ fileFrom: 'suspicious_dataflow',
504
513
  // No sameFile: lifecycle is package-level, dataflow is file-level
514
+ // C7 : when every component lives only in dist/build/out, the cooccurrence
515
+ // is bundler aggregation (a postinstall mention + a pre-bundled HTTP client
516
+ // with credential fields), not real exfiltration. Skip the compound.
517
+ excludeIfBundled: true
505
518
  },
506
519
  {
507
520
  type: 'lifecycle_dangerous_exec',
@@ -510,6 +523,8 @@ const SCORING_COMPOUNDS = [
510
523
  message: 'Lifecycle hook + dangerous shell execution — install-time command injection (scoring compound).',
511
524
  fileFrom: 'dangerous_exec'
512
525
  // No sameFile: lifecycle is package-level
526
+ // dangerous_exec is in DIST_EXEMPT_TYPES so it is never coincidental in
527
+ // dist/ ; no excludeIfBundled gate added here.
513
528
  },
514
529
  {
515
530
  type: 'obfuscated_lifecycle_env',
@@ -518,7 +533,12 @@ const SCORING_COMPOUNDS = [
518
533
  message: 'Obfuscation + credential env access + lifecycle hook — obfuscated install-time credential theft (scoring compound).',
519
534
  fileFrom: 'env_access',
520
535
  // Only obfuscation_detected + env_access must be in the same file (lifecycle_script is package-level)
521
- sameFileTypes: ['obfuscation_detected', 'env_access']
536
+ sameFileTypes: ['obfuscation_detected', 'env_access'],
537
+ // C7 : tighter gate - a single MEDIUM env_access alongside generic
538
+ // obfuscation_detected in a bundler is not proof of credential theft.
539
+ // Require at least one component with originalSeverity HIGH+ to fire.
540
+ requireOriginalSeverityHigh: true,
541
+ excludeIfBundled: true
522
542
  },
523
543
  // v2.10.89: Security review compounds
524
544
  {
@@ -558,6 +578,20 @@ const SCORING_COMPOUNDS = [
558
578
  message: 'Stub package with external URL dep + known string IOC — chain-attack staging package (scoring compound).',
559
579
  fileFrom: 'ioc_string_match'
560
580
  },
581
+ // Security review 2026-05-09 — chai-* / poxios-chain / express-guardrail / justenv
582
+ // campaign. Pattern: fork pino + caller.js with `const process = {env: {DEV_API_KEY: <base64>}}`
583
+ // + axios.get(decoded URL) + new Function.constructor("require", body). The package
584
+ // body is otherwise legitimate pino code — only the injected file is malicious.
585
+ // Each individual signal is already CRITICAL/HIGH but reachability/per-file scoring
586
+ // can demote them. The compound recovers the signal when 2+ co-occur in the same file.
587
+ {
588
+ type: 'staged_remote_loader',
589
+ requires: ['function_constructor_require', 'process_variable_shadow'],
590
+ severity: 'CRITICAL',
591
+ message: 'Function.constructor("require", body) + shadowed process env in same file — staged remote loader (chai-* / poxios-chain pattern). Payload fetched at runtime from external paste service.',
592
+ fileFrom: 'function_constructor_require',
593
+ sameFile: true
594
+ },
561
595
  ];
562
596
 
563
597
  /**
@@ -591,6 +625,41 @@ function applyCompoundBoosts(threats) {
591
625
  );
592
626
  if (!hasSignificantComponent) continue;
593
627
 
628
+ // FPR plan Chantier 7 : tighter severity gate. requireOriginalSeverityHigh
629
+ // raises the bar from MEDIUM+ to HIGH+ on at least one component. Compounds
630
+ // whose constituents are mostly heuristic (env_access, obfuscation_detected)
631
+ // need HIGH+ to confirm a real signal versus framework noise.
632
+ if (compound.requireOriginalSeverityHigh) {
633
+ const hasHighOrigin = compound.requires.some(req =>
634
+ threats.some(t => t.type === req && (
635
+ (t.originalSeverity || t.severity) === 'HIGH' ||
636
+ (t.originalSeverity || t.severity) === 'CRITICAL'
637
+ ))
638
+ );
639
+ if (!hasHighOrigin) continue;
640
+ }
641
+
642
+ // FPR plan Chantier 7 : excludeIfBundled. When every required component
643
+ // appears only in bundle files, the co-occurrence is bundler aggregation
644
+ // (e.g. an HTTP client compiled into the same chunk as the credential-
645
+ // handling code), not malice. lifecycle_script is package-level
646
+ // (file = package.json) so it bypasses this gate naturally. Uses the
647
+ // canonical BUNDLE_PATH_RE which covers dist/build/out/.yarn/releases/
648
+ // assets/chunks/_app/fesm*/esm5/esm2015 etc. (vs the narrow DIST_FILE_RE).
649
+ if (compound.excludeIfBundled) {
650
+ const { BUNDLE_PATH_RE } = require('./shared/bundle-detect.js');
651
+ const allComponentsBundled = compound.requires.every(req => {
652
+ const fileBearingThreats = threats.filter(t => t.type === req && t.file && t.file !== 'package.json');
653
+ if (fileBearingThreats.length === 0) return true; // package-level, ignore for bundle test
654
+ return fileBearingThreats.every(t => BUNDLE_PATH_RE.test(t.file));
655
+ });
656
+ // Only skip when there is at least one file-bearing component AND all are bundled
657
+ const anyFileBearing = compound.requires.some(req =>
658
+ threats.some(t => t.type === req && t.file && t.file !== 'package.json')
659
+ );
660
+ if (anyFileBearing && allComponentsBundled) continue;
661
+ }
662
+
594
663
  // Same-file constraint: required types must appear in at least one common file.
595
664
  // sameFile: true = ALL required types must share a file.
596
665
  // sameFileTypes: [...] = only specified types must share a file.
@@ -651,7 +720,7 @@ const FRAMEWORK_PROTO_RE = new RegExp(
651
720
  '^(' + FRAMEWORK_PROTOTYPES.join('|') + ')\\.prototype\\.'
652
721
  );
653
722
 
654
- function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
723
+ function applyFPReductions(threats, reachableFiles, packageName, packageDeps, reachableFunctions) {
655
724
  // Initialize reductions audit trail on each threat
656
725
  // Store original severity before any FP reductions, so compound
657
726
  // severity gates can check pre-reduction severity (GAP 4b).
@@ -894,6 +963,25 @@ function applyFPReductions(threats, reachableFiles, packageName, packageDeps) {
894
963
  t.severity = 'LOW';
895
964
  t.unreachable = true;
896
965
  }
966
+ // FPR plan C2 : the file IS reachable but the surrounding function is
967
+ // not. Only fires when reachableFunctions is provided (env-flagged) and
968
+ // the threat carries a t.line (dataflow + anti-forensic emit lines ;
969
+ // most other scanners do not, so this is a no-op for them).
970
+ else if (reachableFunctions && typeof t.line === 'number' && t.line > 0) {
971
+ const fnInfo = reachableFunctions.get(normalizedFile);
972
+ if (fnInfo && !fnInfo.dynamic && Array.isArray(fnInfo.deadRanges)) {
973
+ for (const range of fnInfo.deadRanges) {
974
+ if (t.line >= range.startLine && t.line <= range.endLine) {
975
+ if (t.severity !== 'LOW') {
976
+ t.reductions.push({ rule: 'unreachable_function', from: t.severity, to: 'LOW' });
977
+ t.severity = 'LOW';
978
+ }
979
+ t.unreachableFunction = range.name || true;
980
+ break;
981
+ }
982
+ }
983
+ }
984
+ }
897
985
  }
898
986
 
899
987
  // C2: MCP server awareness — legitimate MCP servers write to MCP config files.
@@ -1338,6 +1426,26 @@ function _factorFromMetadata(meta) {
1338
1426
  else if (authorPkgs > 50) factor -= 0.1;
1339
1427
  signalsApplied++;
1340
1428
  }
1429
+ // FPR plan Chantier 4 : advanced registry signals. These complement the
1430
+ // basic AUC-tuned signals above. The first three are *boosts* used as a
1431
+ // safety net for Chantier 5's mature stable cap : an account takeover on a
1432
+ // mature package would otherwise be hidden by a passive multiplier.
1433
+ //
1434
+ // maintainer_change_recent -> +0.20 (Shai-Hulud / Axios pattern in 2026)
1435
+ // publish_cadence_anomaly -> +0.10 (publish frequency suddenly off)
1436
+ // stable_ownership_2y -> -0.15 (suppression douce, only structural)
1437
+ if (meta.maintainer_change_recent === true) {
1438
+ factor += 0.20;
1439
+ signalsApplied++;
1440
+ }
1441
+ if (meta.publish_cadence_anomaly === true) {
1442
+ factor += 0.10;
1443
+ signalsApplied++;
1444
+ }
1445
+ if (meta.stable_ownership_2y === true) {
1446
+ factor -= 0.15;
1447
+ signalsApplied++;
1448
+ }
1341
1449
  // If no signals applied (metadata fully absent), return neutral 1.0 rather
1342
1450
  // than the default-shaped factor — avoid spurious adjustments on rows where
1343
1451
  // the registry data is simply missing.
@@ -1347,6 +1455,27 @@ function _factorFromMetadata(meta) {
1347
1455
 
1348
1456
  function applyReputationFactor(result, metadata) {
1349
1457
  if (!result || !result.summary || !metadata) return null;
1458
+ // FPR plan : the reputation factor describes "how trustworthy this package
1459
+ // looks TODAY in the registry". Applying it to a historical / pinned-old /
1460
+ // vendored version with the same name would import today's good reputation
1461
+ // onto a code snapshot that may have been compromised at the time.
1462
+ // When both version fields are present we require them to match. When the
1463
+ // scan version is unknown (CLI scanning a directory without version field),
1464
+ // we fail open : skip the factor entirely rather than apply a multiplier
1465
+ // we cannot situate in time.
1466
+ if (
1467
+ typeof metadata.latest_version === 'string' &&
1468
+ typeof metadata.scan_version === 'string' &&
1469
+ metadata.latest_version !== metadata.scan_version
1470
+ ) {
1471
+ return null;
1472
+ }
1473
+ if (
1474
+ typeof metadata.latest_version === 'string' &&
1475
+ typeof metadata.scan_version !== 'string'
1476
+ ) {
1477
+ return null;
1478
+ }
1350
1479
  const factor = _factorFromMetadata(metadata);
1351
1480
  if (factor === 1.0) {
1352
1481
  result.summary.reputationFactor = 1.0;
@@ -1364,6 +1493,119 @@ function applyReputationFactor(result, metadata) {
1364
1493
  return { factor, oldScore, newScore };
1365
1494
  }
1366
1495
 
1496
+ // ============================================
1497
+ // FPR plan Chantier 5 : MATURE STABLE PACKAGE CAP
1498
+ // ============================================
1499
+ //
1500
+ // Caps the risk score at MEDIUM (25) when a package satisfies *all* of the
1501
+ // following structural conditions, none of which are recoverable by simple
1502
+ // rule heuristics :
1503
+ //
1504
+ // - >= 5 years old
1505
+ // - >= 50 published versions
1506
+ // - >= 10 000 weekly downloads
1507
+ // - stable_ownership_2y (registry signal from Chantier 4)
1508
+ // - no HIGH_CONFIDENCE_MALICE_TYPES present
1509
+ // - no IOC match
1510
+ // - no delta-added threats (skipped while Chantier 3 ships - the cap still
1511
+ // applies because the caller has not provided the field, and we treat
1512
+ // that as "no positive evidence of fresh malicious additions")
1513
+ //
1514
+ // This is the single structural suppression introduced by the FPR plan and
1515
+ // always sits BETWEEN applyContextualFPCaps (which it composes with) and
1516
+ // applySingleFireCriticalFloor (which can override it on hard signals). The
1517
+ // reputation factor downstream multiplies what's left.
1518
+ //
1519
+ // Disabled by default ; gated behind MUADDIB_MATURE_CAP=1 until measured
1520
+ // against the full evaluation corpus.
1521
+
1522
+ const MATURE_CAP_SCORE = 25;
1523
+ const MATURE_MIN_AGE_DAYS = 5 * 365;
1524
+ const MATURE_MIN_VERSION_COUNT = 50;
1525
+ const MATURE_MIN_WEEKLY_DOWNLOADS = 10000;
1526
+
1527
+ const MATURE_CAP_IOC_TYPES = new Set([
1528
+ 'ioc_match',
1529
+ 'ioc_string_match',
1530
+ 'known_malicious_hash',
1531
+ 'known_malicious_package',
1532
+ 'pypi_malicious_package',
1533
+ 'shai_hulud_marker',
1534
+ 'dependency_ioc_match'
1535
+ ]);
1536
+
1537
+ function applyMatureStableCap(result, registryMeta) {
1538
+ if (!result || !result.summary || !Array.isArray(result.threats)) return null;
1539
+ if (!registryMeta) return null;
1540
+
1541
+ const reasons = {};
1542
+
1543
+ // FPR plan : the cap MUST only fire when scanning the package's current
1544
+ // latest version. Historical / pinned-old / vendored versions of mature
1545
+ // packages bypass the cap so we never mask a static fixture of a real
1546
+ // attack (eslint-scope 3.7.2, chalk 5.6.1, coa 2.0.3, rc, solana-web3js,
1547
+ // ledgerhq-connect-kit etc.). Both fields must be present and equal.
1548
+ if (
1549
+ typeof registryMeta.latest_version !== 'string' ||
1550
+ typeof registryMeta.scan_version !== 'string' ||
1551
+ registryMeta.latest_version !== registryMeta.scan_version
1552
+ ) {
1553
+ return null;
1554
+ }
1555
+ reasons.scan_version = registryMeta.scan_version;
1556
+ reasons.latest_version = registryMeta.latest_version;
1557
+
1558
+ if (!_hasNumeric(registryMeta.age_days) || registryMeta.age_days < MATURE_MIN_AGE_DAYS) {
1559
+ return null;
1560
+ }
1561
+ reasons.age_days = registryMeta.age_days;
1562
+
1563
+ if (!_hasNumeric(registryMeta.version_count) || registryMeta.version_count < MATURE_MIN_VERSION_COUNT) {
1564
+ return null;
1565
+ }
1566
+ reasons.version_count = registryMeta.version_count;
1567
+
1568
+ if (!_hasNumeric(registryMeta.weekly_downloads) || registryMeta.weekly_downloads < MATURE_MIN_WEEKLY_DOWNLOADS) {
1569
+ return null;
1570
+ }
1571
+ reasons.weekly_downloads = registryMeta.weekly_downloads;
1572
+
1573
+ if (registryMeta.stable_ownership_2y !== true) {
1574
+ return null;
1575
+ }
1576
+ reasons.stable_ownership_2y = true;
1577
+
1578
+ // delta_added_threats is provided by Chantier 3 ; treat undefined as 0 to
1579
+ // ship Chantier 5 standalone. When C3 wires through a real number, the
1580
+ // condition `> 0` correctly disables the cap on suspicious deltas.
1581
+ const deltaAdded = registryMeta.delta_added_threats;
1582
+ if (typeof deltaAdded === 'number' && deltaAdded > 0) {
1583
+ return null;
1584
+ }
1585
+ reasons.delta_added_threats = deltaAdded == null ? 0 : deltaAdded;
1586
+
1587
+ for (const t of result.threats) {
1588
+ if (HIGH_CONFIDENCE_MALICE_TYPES.has(t.type)) return null;
1589
+ if (MATURE_CAP_IOC_TYPES.has(t.type)) return null;
1590
+ }
1591
+
1592
+ if (result.summary.riskScore <= MATURE_CAP_SCORE) {
1593
+ return null; // already at/below cap, nothing to do
1594
+ }
1595
+
1596
+ const oldScore = result.summary.riskScore;
1597
+ result.summary.riskScore = MATURE_CAP_SCORE;
1598
+ result.summary.matureStableCap = true;
1599
+ result.summary.matureStableCapReasons = reasons;
1600
+ const rs = MATURE_CAP_SCORE;
1601
+ result.summary.riskLevel =
1602
+ rs >= _riskThresholds.CRITICAL ? 'CRITICAL'
1603
+ : rs >= _riskThresholds.HIGH ? 'HIGH'
1604
+ : rs >= _riskThresholds.MEDIUM ? 'MEDIUM'
1605
+ : rs > 0 ? 'LOW' : 'SAFE';
1606
+ return { applied: true, oldScore, newScore: MATURE_CAP_SCORE, reasons };
1607
+ }
1608
+
1367
1609
  /**
1368
1610
  * Hybrid v3 Phase 1: apply CRITICAL floor when a deterministic single-fire
1369
1611
  * type is present at HIGH+ severity. Mutates result.summary.riskScore /
@@ -1396,10 +1638,151 @@ function applySingleFireCriticalFloor(result) {
1396
1638
  return triggers;
1397
1639
  }
1398
1640
 
1641
+ // ── Sandbox verdict scoring contract ──
1642
+ // Applied AFTER the sandbox completes a run triggered by evaluateSandboxTrigger.
1643
+ // The verdict adjusts the static riskScore based on dynamic confirmation.
1644
+ // Static stays the primary detection layer; sandbox is a borderline tribunal.
1645
+ const SANDBOX_VERDICT_CONFIRMED_FLOOR = 75;
1646
+ const SANDBOX_VERDICT_CHAIN_FLOOR = 60;
1647
+ const SANDBOX_VERDICT_CLEAN_DELTA = 8;
1648
+
1649
+ const _CRITICAL_PRELOAD_TYPES = new Set([
1650
+ 'sandbox_network_after_sensitive_read',
1651
+ 'sandbox_known_exfil_domain',
1652
+ 'sandbox_timer_delay_critical',
1653
+ 'canary_exfiltration',
1654
+ 'sandbox_honey_read',
1655
+ 'sandbox_persistence_write',
1656
+ 'sandbox_npm_self_invoke',
1657
+ 'sandbox_runtime_deobfuscation_executed'
1658
+ ]);
1659
+
1660
+ const _HIGH_COMPOUND_TYPES = new Set([
1661
+ 'sandbox_preload_sensitive_read',
1662
+ 'sandbox_exec_suspicious',
1663
+ 'sandbox_network_outlier',
1664
+ 'sandbox_execve_chain_depth',
1665
+ 'sandbox_credential_target_read'
1666
+ ]);
1667
+
1668
+ /**
1669
+ * Classify a sandbox run into a verdict and apply the score contract.
1670
+ *
1671
+ * Verdicts:
1672
+ * - MALICIOUS_CONFIRMED: >=1 critical preload signal OR honey read correlated
1673
+ * with non-registry outbound. Floor score at 75.
1674
+ * - MALICIOUS_CHAIN: >=2 high-severity compound signals in the same run.
1675
+ * Floor score at 60.
1676
+ * - CLEAN_HIGH_CONFIDENCE: install completed, no fingerprint detected, no
1677
+ * sandbox findings beyond DNS resolutions/registry traffic. Apply -8 delta.
1678
+ * - INCONCLUSIVE: gVisor unavailable, install crashed, or fingerprint
1679
+ * detected. Score unchanged, warning attached.
1680
+ *
1681
+ * Mutates result.summary.riskScore, riskLevel, and adds result.summary.sandboxVerdict.
1682
+ *
1683
+ * @param {object} result - Scan result with summary.{riskScore, riskLevel, ...}.
1684
+ * @param {object} sandboxResult - Output of runSandbox(): {score, severity, findings, raw_report, suspicious, inconclusive}.
1685
+ * @returns {{verdict:string, oldScore:number, newScore:number, signals:string[]}|null}
1686
+ */
1687
+ function applySandboxVerdict(result, sandboxResult) {
1688
+ if (!result || !result.summary || !sandboxResult) return null;
1689
+
1690
+ const findings = Array.isArray(sandboxResult.findings) ? sandboxResult.findings : [];
1691
+ const oldScore = result.summary.riskScore || 0;
1692
+
1693
+ // Inconclusive path: explicit flag, or score === -1 sentinel.
1694
+ if (sandboxResult.inconclusive === true || sandboxResult.score === -1) {
1695
+ result.summary.sandboxVerdict = {
1696
+ verdict: 'INCONCLUSIVE',
1697
+ oldScore,
1698
+ newScore: oldScore,
1699
+ signals: findings.filter(f => f && f.type).map(f => f.type)
1700
+ };
1701
+ return result.summary.sandboxVerdict;
1702
+ }
1703
+
1704
+ const findingTypes = findings.filter(f => f && f.type).map(f => f.type);
1705
+ const criticalSignals = findingTypes.filter(t => _CRITICAL_PRELOAD_TYPES.has(t));
1706
+ const highSignals = findingTypes.filter(t => _HIGH_COMPOUND_TYPES.has(t));
1707
+
1708
+ // MALICIOUS_CONFIRMED: any single critical preload/honey signal is enough.
1709
+ if (criticalSignals.length >= 1) {
1710
+ const newScore = Math.max(oldScore, SANDBOX_VERDICT_CONFIRMED_FLOOR);
1711
+ result.summary.riskScore = newScore;
1712
+ _refreshRiskLevel(result);
1713
+ result.summary.sandboxVerdict = {
1714
+ verdict: 'MALICIOUS_CONFIRMED',
1715
+ oldScore,
1716
+ newScore,
1717
+ signals: criticalSignals
1718
+ };
1719
+ return result.summary.sandboxVerdict;
1720
+ }
1721
+
1722
+ // MALICIOUS_CHAIN: 2+ high-severity compound signals from preload analyzer.
1723
+ if (highSignals.length >= 2) {
1724
+ const newScore = Math.max(oldScore, SANDBOX_VERDICT_CHAIN_FLOOR);
1725
+ result.summary.riskScore = newScore;
1726
+ _refreshRiskLevel(result);
1727
+ result.summary.sandboxVerdict = {
1728
+ verdict: 'MALICIOUS_CHAIN',
1729
+ oldScore,
1730
+ newScore,
1731
+ signals: highSignals
1732
+ };
1733
+ return result.summary.sandboxVerdict;
1734
+ }
1735
+
1736
+ // CLEAN_HIGH_CONFIDENCE: install completed, sandbox score 0, no findings
1737
+ // beyond INFO (DNS resolutions). Anti-fingerprint must NOT have been
1738
+ // detected (fingerprint detection is treated as inconclusive above).
1739
+ const actionableFindings = findings.filter(f => f && f.severity && f.severity !== 'INFO');
1740
+ const installCompleted = sandboxResult.score === 0 && !sandboxResult.inconclusive;
1741
+ if (installCompleted && actionableFindings.length === 0) {
1742
+ const newScore = Math.max(0, oldScore - SANDBOX_VERDICT_CLEAN_DELTA);
1743
+ result.summary.riskScore = newScore;
1744
+ _refreshRiskLevel(result);
1745
+ result.summary.sandboxVerdict = {
1746
+ verdict: 'CLEAN_HIGH_CONFIDENCE',
1747
+ oldScore,
1748
+ newScore,
1749
+ signals: []
1750
+ };
1751
+ return result.summary.sandboxVerdict;
1752
+ }
1753
+
1754
+ // Default: signals present but below thresholds — record verdict, no change.
1755
+ result.summary.sandboxVerdict = {
1756
+ verdict: 'INCONCLUSIVE',
1757
+ oldScore,
1758
+ newScore: oldScore,
1759
+ signals: findingTypes
1760
+ };
1761
+ return result.summary.sandboxVerdict;
1762
+ }
1763
+
1764
+ function _refreshRiskLevel(result) {
1765
+ const rs = result.summary.riskScore;
1766
+ result.summary.riskLevel =
1767
+ rs >= _riskThresholds.CRITICAL ? 'CRITICAL'
1768
+ : rs >= _riskThresholds.HIGH ? 'HIGH'
1769
+ : rs >= _riskThresholds.MEDIUM ? 'MEDIUM'
1770
+ : rs > 0 ? 'LOW' : 'SAFE';
1771
+ }
1772
+
1773
+ // FPR plan Chantier 3 - delta-aware decay. Re-exported here so the pipeline
1774
+ // can call it after applyFPReductions and before applyCompoundBoosts (matching
1775
+ // the plan : compounds operate on freshly-introduced patterns, never on stable
1776
+ // long-standing ones, so we suppress noise BEFORE compound boosts run).
1777
+ const { applyDeltaMultiplier } = require('./scoring/delta-multiplier.js');
1778
+
1399
1779
  module.exports = {
1400
1780
  SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, CONFIDENCE_FACTORS,
1401
1781
  SINGLE_FIRE_CRITICAL_TYPES, SINGLE_FIRE_CRITICAL_FLOOR, DECAY_ALPHA,
1402
1782
  REPUTATION_FACTOR_BOUNDS,
1783
+ MATURE_CAP_SCORE, MATURE_MIN_AGE_DAYS, MATURE_MIN_VERSION_COUNT, MATURE_MIN_WEEKLY_DOWNLOADS,
1784
+ SANDBOX_VERDICT_CONFIRMED_FLOOR, SANDBOX_VERDICT_CHAIN_FLOOR, SANDBOX_VERDICT_CLEAN_DELTA,
1785
+ applyMatureStableCap, applySandboxVerdict, applyDeltaMultiplier,
1403
1786
  isPackageLevelThreat, computeGroupScore, computeGroupScoreDecay,
1404
1787
  applyFPReductions, applyCompoundBoosts, calculateRiskScore,
1405
1788
  applyConfigOverrides, resetConfigOverrides, getSeverityWeights, getRiskThresholds,