from __future__ import annotations from abc import ABC, abstractmethod from typing import NamedTuple from vuln_scan.core.models import Finding _SEVERITY_ICONS = { "critical": "🔴 CRITICAL", "high": "🟠 HIGH", "moderate": "🟡 MODERATE", "low": "🟢 LOW", } class FindingRow(NamedTuple): file: str package: str version: str vuln_range: str patch: str severity: str is_blocking: bool grace_period: str blocking_date: str reason: str class Reporter(ABC): """Base class for all reporter implementations.""" @abstractmethod def render(self, findings: list[Finding], dependabot_url: str) -> str: ... @staticmethod def extract_row(f: Finding) -> FindingRow: """Extract a single finding into a display-ready row dict.""" dep = f.dependency alert = f.alert return FindingRow( file=(dep.manifest_path if dep else alert.vulnerable_manifest_path) or "-", package=(dep.name if dep else alert.package_name) or "-", version=(dep.version or dep.requirement or "-") if dep else "-", vuln_range=alert.vulnerable_range or "-", patch=alert.first_patched_version or "No Patch", severity=alert.severity.value, is_blocking=f.decision.is_blocking, grace_period=str(max(f.decision.remaining_days, 0)), blocking_date=( f.decision.blocking_date.strftime("%Y-%m-%d") if f.decision.blocking_date else "-" ), reason=f.decision.reason, ) @staticmethod def summary_counts(findings: list[Finding]) -> tuple[int, int]: """Returns (blocking_count, non_blocking_count).""" blocking = sum(1 for f in findings if f.decision.is_blocking) return blocking, len(findings) - blocking @staticmethod def severity_badge(severity: str) -> str: return _SEVERITY_ICONS.get(severity.lower(), severity.upper()) @staticmethod def blocking_badge(is_blocking: bool) -> str: return "🔴 YES" if is_blocking else "🟢 NO"