from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime from enum import StrEnum from pathlib import Path from typing import Any class Ecosystem(StrEnum): COMPOSER = "COMPOSER" ERLANG = "ERLANG" GO = "GO" MAVEN = "MAVEN" NPM = "NPM" NUGET = "NUGET" PIP = "PIP" PUB = "PUB" RUBYGEMS = "RUBYGEMS" RUST = "RUST" SWIFT = "SWIFT" class Severity(StrEnum): CRITICAL = "CRITICAL" HIGH = "HIGH" MODERATE = "MODERATE" LOW = "LOW" UNKNOWN = "UNKNOWN" @property def rank(self) -> int: """Numeric rank for comparison. Higher = more severe.""" return _SEVERITY_RANK.get(self, 0) @classmethod def from_string(cls, value: str | None) -> Severity: """ Normalize external severity values (GitHub, etc.) """ if not value: return cls.UNKNOWN normalized = value.lower() mapping = { "critical": cls.CRITICAL, "high": cls.HIGH, "medium": cls.MODERATE, "moderate": cls.MODERATE, "low": cls.LOW, } return mapping.get(normalized, cls.UNKNOWN) _SEVERITY_RANK: dict[Severity, int] = { Severity.CRITICAL: 4, Severity.HIGH: 3, Severity.MODERATE: 2, Severity.LOW: 1, Severity.UNKNOWN: 0, } @dataclass(frozen=True) class Dependency: """Represents a dependency discovered in a manifest.""" ecosystem: Ecosystem name: str version: str | None requirement: str | None manifest_path: str is_lockfile: bool @property def normalized_name(self) -> str: return self.name.lower() @dataclass class SecurityVulnerability: """ Represents a vulnerability alert from Dependabot and advisory database. """ id: str ghsa_id: str package_name: str ecosystem: Ecosystem vulnerable_manifest_path: str vulnerable_range: str | None first_patched_version: str | None severity: Severity created_at: datetime source: str @property def has_patch(self) -> bool: return bool(self.first_patched_version) @staticmethod def _parse_datetime(value: str | None) -> datetime: if not value: return datetime.now(UTC) return datetime.fromisoformat(value.replace("Z", "+00:00")) @classmethod def from_dependabot_node(cls, node: dict[str, Any]) -> SecurityVulnerability: vuln = node.get("securityVulnerability", {}) or {} advisory = vuln.get("advisory", {}) or {} package = vuln.get("package", {}) or {} patched = vuln.get("firstPatchedVersion") or {} patched_version = patched.get("identifier") if isinstance(patched, dict) else None return cls( id=str(node.get("number")), ghsa_id=str(advisory.get("ghsaId")), package_name=package.get("name", ""), ecosystem=Ecosystem(package.get("ecosystem", "").upper()), vulnerable_manifest_path=node.get("vulnerableManifestPath", ""), vulnerable_range=vuln.get("vulnerableVersionRange"), first_patched_version=patched_version, severity=Severity.from_string(vuln.get("severity", "")), created_at=cls._parse_datetime(node.get("createdAt")), source="dependabot", ) @classmethod def from_advisory_node( cls, node: dict[str, Any], ) -> SecurityVulnerability: advisory = node.get("advisory", {}) or {} patched = node.get("firstPatchedVersion") or {} patched_version = patched.get("identifier") if isinstance(patched, dict) else None package = node.get("package") or {} return cls( id=advisory.get("ghsaId", ""), ghsa_id=str(advisory.get("ghsaId")), package_name=package.get("name", ""), ecosystem=Ecosystem(package.get("ecosystem", "").upper()), vulnerable_manifest_path="", vulnerable_range=str(node.get("vulnerableVersionRange")), first_patched_version=patched_version, severity=Severity.from_string(node.get("severity", "")), created_at=cls._parse_datetime(advisory.get("publishedAt")), source="advisory", ) @dataclass class EcosystemParseResult: """ Result returned by an ecosystem handler after parsing a manifest. Contains: - discovered dependencies - optional metadata useful for debugging or reporting """ dependencies: list[Dependency] = field(default_factory=list) # Optional informational fields manifest_type: str | None = None parser_name: str | None = None class FindingStatus(StrEnum): VULNERABLE = "VULNERABLE" FIXED = "FIXED" NO_PATCH = "NO_PATCH" REMOVED = "REMOVED" @dataclass class Finding: """ Represents a detected vulnerability in a dependency. """ dependency: Dependency | None alert: SecurityVulnerability decision: PolicyDecision @property def package_name(self) -> str: if self.dependency: return self.dependency.normalized_name return self.alert.package_name or "unknown" @dataclass class PolicyDecision: blocking_date: datetime | None remaining_days: int is_blocking: bool status: FindingStatus message: str reason: str @dataclass class ScanResult: """ Result returned by VulnerabilityScanner. """ findings: list[Finding] # ---------------------------------------- # Blocking # ---------------------------------------- @property def any_blocking(self) -> bool: return any(f.decision.is_blocking for f in self.findings) @property def blocking_count(self) -> int: return sum(1 for f in self.findings if f.decision.is_blocking) # ---------------------------------------- # Vulnerability states (status-based) # ---------------------------------------- @property def vulnerable_count(self) -> int: return sum(1 for f in self.findings if f.decision.status == FindingStatus.VULNERABLE) @property def fixed_count(self) -> int: return sum(1 for f in self.findings if f.decision.status == FindingStatus.FIXED) @property def no_patch_count(self) -> int: return sum(1 for f in self.findings if f.decision.status == FindingStatus.NO_PATCH) @property def removed_count(self) -> int: return sum(1 for f in self.findings if f.decision.status == FindingStatus.REMOVED) @dataclass class ScanConfig: """ Configuration for a vulnerability scan. """ owner: str repo: str base_path: Path project_dir: str | None = None excluded_path_fragments: tuple[str, ...] = () skip_phrase: str = "skip vulnerability scan" pr_comment_text: str = "" dependabot_url: str = "" ManifestPackage = tuple[Ecosystem, str, str] # (ecosystem, package_name, manifest_path) ManifestPackageVersion = tuple[ Ecosystem, str, str, str | None ] # (ecosystem, package_name, manifest_path, version) EcosystemPackage = tuple[Ecosystem, str] Findings = list[Finding] Dependencies = list[Dependency] IntroducedVersioned = set[ tuple[Ecosystem, str, str | None] ] # (ecosystem, normalized_name, version)