from __future__ import annotations import logging import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from typing import Literal from vuln_scan.core.dependency_graph import DependencyGraph from vuln_scan.core.models import Dependency from vuln_scan.core.registry import EcosystemRegistry from vuln_scan.github.repo_client import GitHubRepoClient logger = logging.getLogger(__name__) type WorkStatus = Literal["ok", "missing", "no_handler", "error"] @dataclass(frozen=True, slots=True) class ManifestScanResult: status: WorkStatus manifest_path: str dependencies: list[Dependency] error_message: str | None = None @dataclass(slots=True) class DefaultBranchScanStats: scanned_manifests: int = 0 skipped_missing: int = 0 skipped_no_handler: int = 0 failed_manifests: int = 0 @dataclass class DefaultBranchScanner: github: GitHubRepoClient max_workers: int = 4 def scan( self, registry: EcosystemRegistry, manifest_paths: set[str], ) -> DependencyGraph: start_ns = time.perf_counter_ns() default_branch = self.github.get_default_branch() logger.info( "Fetching manifests from default branch: repo=%s branch=%s", f"{self.github.owner}/{self.github.repo}", default_branch, ) graph = DependencyGraph() stats = DefaultBranchScanStats() if not manifest_paths: logger.info("No manifests to scan on default branch.") return graph effective_workers = max(1, min(self.max_workers, len(manifest_paths))) with ThreadPoolExecutor(max_workers=effective_workers) as ex: futures = [ ex.submit(self._scan_manifest, registry, default_branch, p) for p in manifest_paths ] for fut in as_completed(futures): self._apply_result(graph=graph, stats=stats, result=fut.result()) elapsed_ms = (time.perf_counter_ns() - start_ns) / 1_000_000 logger.info( "Done scanning default branch: %d manifest(s), %d package(s), %.2fms elapsed", stats.scanned_manifests, graph.dependency_count(), elapsed_ms, ) logger.debug( "Default branch scan stats: skipped_missing=%d skipped_no_handler=%d failed_manifests=%d", stats.skipped_missing, stats.skipped_no_handler, stats.failed_manifests, ) if stats.failed_manifests: logger.warning( "Default branch scan had %d failure(s). Results may be incomplete.", stats.failed_manifests, ) return graph def _scan_manifest( self, registry: EcosystemRegistry, default_branch: str, manifest_path: str, ) -> ManifestScanResult: try: handler = registry.for_manifest(manifest_path) if handler is None: return ManifestScanResult( status="no_handler", manifest_path=manifest_path, dependencies=[], ) content = self.github.get_file_content(manifest_path, ref=default_branch) if content is None: return ManifestScanResult( status="missing", manifest_path=manifest_path, dependencies=[], ) parsed = handler.parse_manifest_content(manifest_path, content) return ManifestScanResult( status="ok", manifest_path=manifest_path, dependencies=parsed.dependencies, ) except Exception as exc: logger.debug( "Failed to scan manifest on default branch: %s", manifest_path, exc_info=True, ) return ManifestScanResult( status="error", manifest_path=manifest_path, dependencies=[], error_message=str(exc), ) def _apply_result( self, *, graph: DependencyGraph, stats: DefaultBranchScanStats, result: ManifestScanResult, ) -> None: match result.status: case "ok": stats.scanned_manifests += 1 for dep in result.dependencies: graph.add_dependency(result.manifest_path, dep) case "missing": stats.skipped_missing += 1 case "no_handler": stats.skipped_no_handler += 1 case "error": stats.failed_manifests += 1