from __future__ import annotations from dataclasses import dataclass from datetime import timedelta from vuln_scan.core.models import ( FindingStatus, PolicyDecision, SecurityVulnerability, Severity, ) from vuln_scan.core.time import Clock # --------------------------------------------------------- # Grace periods # --------------------------------------------------------- @dataclass(frozen=True) class GracePeriods: """ Defines grace periods for vulnerabilities. """ critical_days: int = 7 high_days: int = 14 moderate_days: int = 30 low_days: int = 60 non_blocking_days: int = 90 # used when no patch exists def for_alert(self, alert: SecurityVulnerability) -> int: """ Return grace period in days for an alert. """ # If no fix available → longer grace if not alert.has_patch: return self.non_blocking_days mapping = { Severity.CRITICAL: self.critical_days, Severity.HIGH: self.high_days, Severity.MODERATE: self.moderate_days, Severity.LOW: self.low_days, } return mapping.get(alert.severity, self.non_blocking_days) # --------------------------------------------------------- # Blocking Policy # --------------------------------------------------------- class BlockingPolicy: """ Determines whether a vulnerability should block the PR. """ def __init__(self, grace: GracePeriods, clock: Clock): self._grace = grace self._clock = clock def evaluate( self, *, alert: SecurityVulnerability, is_vulnerable: bool, has_patch: bool, is_removed: bool = False, is_introduced: bool = False, ) -> PolicyDecision: """ Evaluate whether a vulnerability is blocking. """ now = self._clock.now() # ---------------------------------------- # Case 1: Removed # ---------------------------------------- if is_removed: return PolicyDecision( blocking_date=None, remaining_days=0, is_blocking=False, status=FindingStatus.REMOVED, message="Dependency not found (likely removed)", reason="Dependency removed", ) # ---------------------------------------- # Case 2: No patch available # ---------------------------------------- if not has_patch: return PolicyDecision( blocking_date=None, remaining_days=0, is_blocking=False, status=FindingStatus.NO_PATCH, message="No patched version available", reason="No patch available", ) # ---------------------------------------- # Case 3: Already fixed # ---------------------------------------- if not is_vulnerable: return PolicyDecision( blocking_date=None, remaining_days=0, is_blocking=False, status=FindingStatus.FIXED, message="Dependency already satisfies safe version", reason="Already fixed", ) # ---------------------------------------- # Case 4: Newly introduced + vulnerable + has patch → block immediately # ---------------------------------------- if is_introduced: return PolicyDecision( blocking_date=now, remaining_days=0, is_blocking=True, status=FindingStatus.VULNERABLE, message="Newly introduced dependency is vulnerable", reason="New dependency", ) # ---------------------------------------- # Case 5: Existing dependency → apply grace logic # ---------------------------------------- grace_days = self._grace.for_alert(alert) blocking_date = alert.created_at + timedelta(days=grace_days) is_blocking = now >= blocking_date remaining_days = max(0, (blocking_date - now).days) reason = self._build_reason( remaining_days=remaining_days, is_blocking=is_blocking, ) return PolicyDecision( blocking_date=blocking_date, remaining_days=remaining_days, is_blocking=is_blocking, status=FindingStatus.VULNERABLE, message="Dependency is within vulnerable range", reason=reason, ) # ----------------------------------------------------- def _build_reason( self, *, remaining_days: int, is_blocking: bool, ) -> str: if is_blocking: return "Grace period exceeded" return f"{remaining_days} days remaining"