// ----------------------------------------------------------------------------- // PrReachabilityGate.cs // Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-014) // Description: Evaluates incremental reachability results for PR gate decisions. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.Scanner.Reachability.Cache; /// /// Configuration options for PR reachability gate evaluation. /// public sealed class PrReachabilityGateOptions { /// /// Section name for configuration binding. /// public const string SectionName = "Scanner:Reachability:PrGate"; /// /// Whether the PR gate is enabled. Default: true. /// public bool Enabled { get; set; } = true; /// /// Whether to block PRs that introduce new reachable vulnerabilities. Default: true. /// public bool BlockOnNewReachable { get; set; } = true; /// /// Maximum number of new reachable paths allowed before blocking. Default: 0. /// public int MaxNewReachablePaths { get; set; } = 0; /// /// Whether to require a minimum confidence level for blocking decisions. Default: true. /// public bool RequireMinimumConfidence { get; set; } = true; /// /// Minimum confidence level (0.0-1.0) for a path to count as blocking. Default: 0.7. /// public double MinimumConfidenceThreshold { get; set; } = 0.7; /// /// Whether to add annotations to the PR for state flips. Default: true. /// public bool AddAnnotations { get; set; } = true; /// /// Maximum number of annotations to add per PR. Default: 10. /// public int MaxAnnotations { get; set; } = 10; /// /// Whether to include mitigated paths in the summary. Default: true. /// public bool IncludeMitigatedInSummary { get; set; } = true; } /// /// Result of PR gate evaluation. /// public sealed record PrGateResult { /// /// Whether the PR passed the gate. /// public required bool Passed { get; init; } /// /// Human-readable reason for the decision. /// public required string Reason { get; init; } /// /// Detailed decision breakdown. /// public required PrGateDecision Decision { get; init; } /// /// Annotations to add to the PR. /// public IReadOnlyList Annotations { get; init; } = []; /// /// Summary markdown for PR comment. /// public string? SummaryMarkdown { get; init; } } /// /// Detailed breakdown of PR gate decision. /// public sealed record PrGateDecision { /// /// Number of new reachable vulnerability paths introduced. /// public int NewReachableCount { get; init; } /// /// Number of vulnerability paths mitigated (became unreachable). /// public int MitigatedCount { get; init; } /// /// Net change in reachable vulnerability paths. /// public int NetChange { get; init; } /// /// Whether incremental analysis was used. /// public bool WasIncremental { get; init; } /// /// Cache savings ratio (1.0 = 100% cached, 0.0 = full recompute). /// public double SavingsRatio { get; init; } /// /// Analysis duration. /// public TimeSpan Duration { get; init; } /// /// State flips that caused blocking. /// public IReadOnlyList BlockingFlips { get; init; } = []; } /// /// Annotation to add to a PR. /// public sealed record PrGateAnnotation { /// /// Annotation level (error, warning, notice). /// public required PrAnnotationLevel Level { get; init; } /// /// Annotation message. /// public required string Message { get; init; } /// /// File path (if applicable). /// public string? FilePath { get; init; } /// /// Start line (if applicable). /// public int? StartLine { get; init; } /// /// End line (if applicable). /// public int? EndLine { get; init; } /// /// Annotation title. /// public string? Title { get; init; } } /// /// PR annotation severity level. /// public enum PrAnnotationLevel { /// /// Notice level (informational). /// Notice, /// /// Warning level (non-blocking). /// Warning, /// /// Error level (blocking). /// Error } /// /// Evaluates incremental reachability results for PR gate decisions. /// public interface IPrReachabilityGate { /// /// Evaluates an incremental reachability result for PR gating. /// /// The incremental reachability result. /// The PR gate evaluation result. PrGateResult Evaluate(IncrementalReachabilityResult result); /// /// Evaluates state flips directly for PR gating. /// /// The state flip result. /// Whether incremental analysis was used. /// Cache savings ratio. /// Analysis duration. /// The PR gate evaluation result. PrGateResult EvaluateFlips( StateFlipResult stateFlips, bool wasIncremental, double savingsRatio, TimeSpan duration); } /// /// Default implementation of . /// public sealed class PrReachabilityGate : IPrReachabilityGate { private readonly IOptionsMonitor _options; private readonly ILogger _logger; /// /// Creates a new PR reachability gate. /// public PrReachabilityGate( IOptionsMonitor options, ILogger logger) { _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public PrGateResult Evaluate(IncrementalReachabilityResult result) { ArgumentNullException.ThrowIfNull(result); if (result.StateFlips is null) { return CreatePassResult( "No state flip detection performed", wasIncremental: result.WasIncremental, savingsRatio: result.SavingsRatio, duration: result.Duration); } return EvaluateFlips( result.StateFlips, result.WasIncremental, result.SavingsRatio, result.Duration); } /// public PrGateResult EvaluateFlips( StateFlipResult stateFlips, bool wasIncremental, double savingsRatio, TimeSpan duration) { ArgumentNullException.ThrowIfNull(stateFlips); var options = _options.CurrentValue; // If gate is disabled, always pass if (!options.Enabled) { _logger.LogDebug("PR gate is disabled, passing"); return CreatePassResult( "PR gate is disabled", wasIncremental, savingsRatio, duration); } // No flips = pass if (!stateFlips.HasFlips) { _logger.LogDebug("No reachability changes detected"); return CreatePassResult( "No reachability changes", wasIncremental, savingsRatio, duration); } // Filter blocking flips by confidence if required var blockingFlips = options.RequireMinimumConfidence ? stateFlips.NewlyReachable .Where(f => f.Confidence >= options.MinimumConfidenceThreshold) .ToList() : stateFlips.NewlyReachable.ToList(); var blockingCount = blockingFlips.Count; // Check if should block var shouldBlock = options.BlockOnNewReachable && blockingCount > options.MaxNewReachablePaths; var decision = new PrGateDecision { NewReachableCount = stateFlips.NewRiskCount, MitigatedCount = stateFlips.MitigatedCount, NetChange = stateFlips.NetChange, WasIncremental = wasIncremental, SavingsRatio = savingsRatio, Duration = duration, BlockingFlips = blockingFlips }; if (shouldBlock) { _logger.LogWarning( "PR gate BLOCKED: {Count} new reachable vulnerability paths introduced", blockingCount); PrReachabilityGateMetrics.BlockedPrs.Add(1); return new PrGateResult { Passed = false, Reason = $"{blockingCount} vulnerabilities became reachable", Decision = decision, Annotations = BuildAnnotations(blockingFlips, options), SummaryMarkdown = BuildSummaryMarkdown(decision, options, passed: false) }; } _logger.LogInformation( "PR gate PASSED: {NewCount} new, {MitigatedCount} mitigated (net: {Net})", stateFlips.NewRiskCount, stateFlips.MitigatedCount, stateFlips.NetChange); PrReachabilityGateMetrics.PassedPrs.Add(1); var reason = stateFlips.MitigatedCount > 0 ? $"{stateFlips.MitigatedCount} vulnerabilities mitigated" : "Reachability changes within threshold"; return new PrGateResult { Passed = true, Reason = reason, Decision = decision, Annotations = BuildAnnotations(blockingFlips, options), SummaryMarkdown = BuildSummaryMarkdown(decision, options, passed: true) }; } private PrGateResult CreatePassResult( string reason, bool wasIncremental, double savingsRatio, TimeSpan duration) { return new PrGateResult { Passed = true, Reason = reason, Decision = new PrGateDecision { NewReachableCount = 0, MitigatedCount = 0, NetChange = 0, WasIncremental = wasIncremental, SavingsRatio = savingsRatio, Duration = duration, BlockingFlips = [] }, Annotations = [], SummaryMarkdown = null }; } private static IReadOnlyList BuildAnnotations( IReadOnlyList blockingFlips, PrReachabilityGateOptions options) { if (!options.AddAnnotations || blockingFlips.Count == 0) return []; var annotations = new List(); var flipsToAnnotate = blockingFlips.Take(options.MaxAnnotations); foreach (var flip in flipsToAnnotate) { annotations.Add(new PrGateAnnotation { Level = PrAnnotationLevel.Error, Title = "New Reachable Vulnerability Path", Message = $"Vulnerability path became reachable: {flip.EntryMethodKey} -> {flip.SinkMethodKey}", FilePath = flip.SourceFile, StartLine = flip.StartLine, EndLine = flip.EndLine }); } return annotations; } private static string BuildSummaryMarkdown( PrGateDecision decision, PrReachabilityGateOptions options, bool passed) { var sb = new StringBuilder(); sb.AppendLine(passed ? "## ✅ Reachability Gate Passed" : "## ❌ Reachability Gate Blocked"); sb.AppendLine(); sb.AppendLine("| Metric | Value |"); sb.AppendLine("|--------|-------|"); sb.AppendLine($"| New reachable paths | {decision.NewReachableCount} |"); if (options.IncludeMitigatedInSummary) { sb.AppendLine($"| Mitigated paths | {decision.MitigatedCount} |"); sb.AppendLine($"| Net change | {decision.NetChange:+#;-#;0} |"); } sb.AppendLine($"| Analysis type | {(decision.WasIncremental ? "Incremental" : "Full")} |"); sb.AppendLine($"| Cache savings | {decision.SavingsRatio:P0} |"); sb.AppendLine($"| Duration | {decision.Duration.TotalMilliseconds:F0}ms |"); if (!passed && decision.BlockingFlips.Count > 0) { sb.AppendLine(); sb.AppendLine("### Blocking Paths"); sb.AppendLine(); foreach (var flip in decision.BlockingFlips.Take(10)) { sb.AppendLine($"- `{flip.EntryMethodKey}` -> `{flip.SinkMethodKey}` (confidence: {flip.Confidence:P0})"); } if (decision.BlockingFlips.Count > 10) { sb.AppendLine($"- ... and {decision.BlockingFlips.Count - 10} more"); } } return sb.ToString(); } } /// /// Metrics for PR reachability gate. /// internal static class PrReachabilityGateMetrics { private static readonly string MeterName = "StellaOps.Scanner.Reachability.PrGate"; /// /// Counter for passed PRs. /// public static readonly System.Diagnostics.Metrics.Counter PassedPrs = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.reachability_prgate.passed", description: "Number of PRs that passed the reachability gate"); /// /// Counter for blocked PRs. /// public static readonly System.Diagnostics.Metrics.Counter BlockedPrs = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.reachability_prgate.blocked", description: "Number of PRs blocked by the reachability gate"); }