// ----------------------------------------------------------------------------- // PrAnnotationService.cs // Sprint: SPRINT_3700_0005_0001_witness_ui_cli // Tasks: PR-001, PR-002 // Description: Service for generating PR annotations with reachability state flips. // ----------------------------------------------------------------------------- using StellaOps.Scanner.Reachability; namespace StellaOps.Scanner.WebService.Services; /// /// Service for generating PR annotations with reachability state flip summaries. /// public interface IPrAnnotationService { /// /// Generates a state flip summary for a PR annotation. /// /// Base graph ID (before). /// Head graph ID (after). /// Cancellation token. /// State flip summary with PR annotation content. Task GenerateAnnotationAsync( string baseGraphId, string headGraphId, CancellationToken cancellationToken = default); /// /// Formats a state flip summary as a PR comment. /// /// State flip summary. /// Formatted PR comment content. string FormatAsComment(StateFlipSummary summary); } /// /// Result of generating a PR annotation. /// public sealed record PrAnnotationResult { /// /// Whether the annotation was generated successfully. /// public required bool Success { get; init; } /// /// State flip summary. /// public StateFlipSummary? Summary { get; init; } /// /// Formatted comment content. /// public string? CommentBody { get; init; } /// /// Inline annotations for specific files/lines. /// public IReadOnlyList? InlineAnnotations { get; init; } /// /// Error message if generation failed. /// public string? Error { get; init; } } /// /// State flip summary for PR annotations. /// public sealed record StateFlipSummary { /// /// Base scan ID. /// public required string BaseScanId { get; init; } /// /// Head scan ID. /// public required string HeadScanId { get; init; } /// /// Whether there are any state flips. /// public required bool HasFlips { get; init; } /// /// Count of new risks (became reachable). /// public required int NewRiskCount { get; init; } /// /// Count of mitigated risks (became unreachable). /// public required int MitigatedCount { get; init; } /// /// Net change in reachable vulnerabilities. /// public required int NetChange { get; init; } /// /// Whether this PR should be blocked based on policy. /// public required bool ShouldBlockPr { get; init; } /// /// Human-readable summary. /// public required string Summary { get; init; } /// /// Individual state flips. /// public required IReadOnlyList Flips { get; init; } } /// /// Individual state flip. /// public sealed record StateFlip { /// /// Flip type. /// public required StateFlipType FlipType { get; init; } /// /// CVE ID. /// public required string CveId { get; init; } /// /// Package PURL. /// public required string Purl { get; init; } /// /// Previous confidence tier. /// public string? PreviousTier { get; init; } /// /// New confidence tier. /// public required string NewTier { get; init; } /// /// Witness ID for the new state. /// public string? WitnessId { get; init; } /// /// Entrypoint that triggers the vulnerability. /// public string? Entrypoint { get; init; } /// /// File path where the change occurred. /// public string? FilePath { get; init; } /// /// Line number where the change occurred. /// public int? LineNumber { get; init; } } /// /// Type of state flip. /// public enum StateFlipType { /// /// Vulnerability became reachable. /// BecameReachable, /// /// Vulnerability became unreachable. /// BecameUnreachable, /// /// Confidence tier increased. /// TierIncreased, /// /// Confidence tier decreased. /// TierDecreased } /// /// Inline annotation for a specific file/line. /// public sealed record InlineAnnotation { /// /// File path relative to repository root. /// public required string FilePath { get; init; } /// /// Line number (1-based). /// public required int Line { get; init; } /// /// Annotation level. /// public required AnnotationLevel Level { get; init; } /// /// Annotation title. /// public required string Title { get; init; } /// /// Annotation message. /// public required string Message { get; init; } /// /// Raw details (for CI systems that support it). /// public string? RawDetails { get; init; } } /// /// Annotation severity level. /// public enum AnnotationLevel { Notice, Warning, Failure } /// /// Implementation of the PR annotation service. /// public sealed class PrAnnotationService : IPrAnnotationService { private readonly IReachabilityQueryService _reachabilityService; private readonly TimeProvider _timeProvider; public PrAnnotationService( IReachabilityQueryService reachabilityService, TimeProvider? timeProvider = null) { _reachabilityService = reachabilityService ?? throw new ArgumentNullException(nameof(reachabilityService)); _timeProvider = timeProvider ?? TimeProvider.System; } /// public async Task GenerateAnnotationAsync( string baseGraphId, string headGraphId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(baseGraphId); ArgumentException.ThrowIfNullOrWhiteSpace(headGraphId); try { // Get reachability states for both graphs var baseStates = await _reachabilityService.GetReachabilityStatesAsync(baseGraphId, cancellationToken); var headStates = await _reachabilityService.GetReachabilityStatesAsync(headGraphId, cancellationToken); // Compute flips var flips = ComputeStateFlips(baseStates, headStates); var newRiskCount = flips.Count(f => f.FlipType == StateFlipType.BecameReachable); var mitigatedCount = flips.Count(f => f.FlipType == StateFlipType.BecameUnreachable); var netChange = newRiskCount - mitigatedCount; // Determine if PR should be blocked (any new reachable critical/high vulns) var shouldBlock = flips.Any(f => f.FlipType == StateFlipType.BecameReachable && (f.NewTier == "confirmed" || f.NewTier == "likely")); var summary = new StateFlipSummary { BaseScanId = baseGraphId, HeadScanId = headGraphId, HasFlips = flips.Count > 0, NewRiskCount = newRiskCount, MitigatedCount = mitigatedCount, NetChange = netChange, ShouldBlockPr = shouldBlock, Summary = GenerateSummaryText(newRiskCount, mitigatedCount, netChange), Flips = flips }; var commentBody = FormatAsComment(summary); var inlineAnnotations = GenerateInlineAnnotations(flips); return new PrAnnotationResult { Success = true, Summary = summary, CommentBody = commentBody, InlineAnnotations = inlineAnnotations }; } catch (Exception ex) { return new PrAnnotationResult { Success = false, Error = ex.Message }; } } /// public string FormatAsComment(StateFlipSummary summary) { var sb = new System.Text.StringBuilder(); // Header sb.AppendLine("## 🔍 Reachability Analysis"); sb.AppendLine(); // Status badge if (summary.ShouldBlockPr) { sb.AppendLine("⛔ **Status: BLOCKING** - New reachable vulnerabilities detected"); } else if (summary.NewRiskCount > 0) { sb.AppendLine("⚠️ **Status: WARNING** - Reachability changes detected"); } else if (summary.MitigatedCount > 0) { sb.AppendLine("✅ **Status: IMPROVED** - Vulnerabilities became unreachable"); } else { sb.AppendLine("✅ **Status: NO CHANGE** - No reachability changes"); } sb.AppendLine(); // Summary stats sb.AppendLine("### Summary"); sb.AppendLine($"| Metric | Count |"); sb.AppendLine($"|--------|-------|"); sb.AppendLine($"| New Risks | {summary.NewRiskCount} |"); sb.AppendLine($"| Mitigated | {summary.MitigatedCount} |"); sb.AppendLine($"| Net Change | {(summary.NetChange >= 0 ? "+" : "")}{summary.NetChange} |"); sb.AppendLine(); // Flips table if (summary.Flips.Count > 0) { sb.AppendLine("### State Flips"); sb.AppendLine(); sb.AppendLine("| CVE | Package | Change | Confidence | Witness |"); sb.AppendLine("|-----|---------|--------|------------|---------|"); foreach (var flip in summary.Flips.Take(20)) // Limit to 20 entries { var changeIcon = flip.FlipType switch { StateFlipType.BecameReachable => "🔴 Became Reachable", StateFlipType.BecameUnreachable => "🟢 Became Unreachable", StateFlipType.TierIncreased => "🟡 Tier ↑", StateFlipType.TierDecreased => "🟢 Tier ↓", _ => "?" }; var witnessLink = !string.IsNullOrEmpty(flip.WitnessId) ? $"[View](?witness={flip.WitnessId})" : "-"; sb.AppendLine($"| {flip.CveId} | `{TruncatePurl(flip.Purl)}` | {changeIcon} | {flip.NewTier} | {witnessLink} |"); } if (summary.Flips.Count > 20) { sb.AppendLine(); sb.AppendLine($"*... and {summary.Flips.Count - 20} more flips*"); } } sb.AppendLine(); sb.AppendLine("---"); sb.AppendLine($"*Generated by StellaOps at {_timeProvider.GetUtcNow():O}*"); return sb.ToString(); } private static List ComputeStateFlips( IReadOnlyDictionary baseStates, IReadOnlyDictionary headStates) { var flips = new List(); // Find vulns that changed state foreach (var (vulnKey, headState) in headStates) { if (!baseStates.TryGetValue(vulnKey, out var baseState)) { // New vuln, not a flip continue; } if (baseState.IsReachable != headState.IsReachable) { flips.Add(new StateFlip { FlipType = headState.IsReachable ? StateFlipType.BecameReachable : StateFlipType.BecameUnreachable, CveId = headState.CveId, Purl = headState.Purl, PreviousTier = baseState.ConfidenceTier, NewTier = headState.ConfidenceTier, WitnessId = headState.WitnessId, Entrypoint = headState.Entrypoint, FilePath = headState.FilePath, LineNumber = headState.LineNumber }); } else if (baseState.ConfidenceTier != headState.ConfidenceTier) { var tierOrder = new[] { "unreachable", "unknown", "present", "likely", "confirmed" }; var baseOrder = Array.IndexOf(tierOrder, baseState.ConfidenceTier); var headOrder = Array.IndexOf(tierOrder, headState.ConfidenceTier); flips.Add(new StateFlip { FlipType = headOrder > baseOrder ? StateFlipType.TierIncreased : StateFlipType.TierDecreased, CveId = headState.CveId, Purl = headState.Purl, PreviousTier = baseState.ConfidenceTier, NewTier = headState.ConfidenceTier, WitnessId = headState.WitnessId, Entrypoint = headState.Entrypoint, FilePath = headState.FilePath, LineNumber = headState.LineNumber }); } } return flips .OrderByDescending(f => f.FlipType == StateFlipType.BecameReachable) .ThenBy(f => f.CveId, StringComparer.Ordinal) .ToList(); } private static List GenerateInlineAnnotations(IReadOnlyList flips) { var annotations = new List(); foreach (var flip in flips.Where(f => !string.IsNullOrEmpty(f.FilePath) && f.LineNumber > 0)) { var level = flip.FlipType switch { StateFlipType.BecameReachable => flip.NewTier is "confirmed" or "likely" ? AnnotationLevel.Failure : AnnotationLevel.Warning, StateFlipType.TierIncreased => AnnotationLevel.Warning, _ => AnnotationLevel.Notice }; var title = flip.FlipType switch { StateFlipType.BecameReachable => $"🔴 {flip.CveId} is now reachable", StateFlipType.BecameUnreachable => $"🟢 {flip.CveId} is no longer reachable", StateFlipType.TierIncreased => $"🟡 {flip.CveId} reachability increased", StateFlipType.TierDecreased => $"🟢 {flip.CveId} reachability decreased", _ => flip.CveId }; var message = $"Package: {flip.Purl}\n" + $"Confidence: {flip.PreviousTier ?? "N/A"} → {flip.NewTier}\n" + (flip.Entrypoint != null ? $"Entrypoint: {flip.Entrypoint}\n" : "") + (flip.WitnessId != null ? $"Witness: {flip.WitnessId}" : ""); annotations.Add(new InlineAnnotation { FilePath = flip.FilePath!, Line = flip.LineNumber!.Value, Level = level, Title = title, Message = message }); } return annotations; } private static string GenerateSummaryText(int newRiskCount, int mitigatedCount, int netChange) { if (newRiskCount == 0 && mitigatedCount == 0) { return "No reachability changes detected."; } var parts = new List(); if (newRiskCount > 0) { parts.Add($"{newRiskCount} vulnerabilit{(newRiskCount == 1 ? "y" : "ies")} became reachable"); } if (mitigatedCount > 0) { parts.Add($"{mitigatedCount} vulnerabilit{(mitigatedCount == 1 ? "y" : "ies")} became unreachable"); } return string.Join("; ", parts) + $" (net: {(netChange >= 0 ? "+" : "")}{netChange})."; } private static string TruncatePurl(string purl) { if (purl.Length <= 50) return purl; return purl[..47] + "..."; } }