- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
521 lines
16 KiB
C#
521 lines
16 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Service for generating PR annotations with reachability state flip summaries.
|
|
/// </summary>
|
|
public interface IPrAnnotationService
|
|
{
|
|
/// <summary>
|
|
/// Generates a state flip summary for a PR annotation.
|
|
/// </summary>
|
|
/// <param name="baseGraphId">Base graph ID (before).</param>
|
|
/// <param name="headGraphId">Head graph ID (after).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>State flip summary with PR annotation content.</returns>
|
|
Task<PrAnnotationResult> GenerateAnnotationAsync(
|
|
string baseGraphId,
|
|
string headGraphId,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Formats a state flip summary as a PR comment.
|
|
/// </summary>
|
|
/// <param name="summary">State flip summary.</param>
|
|
/// <returns>Formatted PR comment content.</returns>
|
|
string FormatAsComment(StateFlipSummary summary);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of generating a PR annotation.
|
|
/// </summary>
|
|
public sealed record PrAnnotationResult
|
|
{
|
|
/// <summary>
|
|
/// Whether the annotation was generated successfully.
|
|
/// </summary>
|
|
public required bool Success { get; init; }
|
|
|
|
/// <summary>
|
|
/// State flip summary.
|
|
/// </summary>
|
|
public StateFlipSummary? Summary { get; init; }
|
|
|
|
/// <summary>
|
|
/// Formatted comment content.
|
|
/// </summary>
|
|
public string? CommentBody { get; init; }
|
|
|
|
/// <summary>
|
|
/// Inline annotations for specific files/lines.
|
|
/// </summary>
|
|
public IReadOnlyList<InlineAnnotation>? InlineAnnotations { get; init; }
|
|
|
|
/// <summary>
|
|
/// Error message if generation failed.
|
|
/// </summary>
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// State flip summary for PR annotations.
|
|
/// </summary>
|
|
public sealed record StateFlipSummary
|
|
{
|
|
/// <summary>
|
|
/// Base scan ID.
|
|
/// </summary>
|
|
public required string BaseScanId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Head scan ID.
|
|
/// </summary>
|
|
public required string HeadScanId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether there are any state flips.
|
|
/// </summary>
|
|
public required bool HasFlips { get; init; }
|
|
|
|
/// <summary>
|
|
/// Count of new risks (became reachable).
|
|
/// </summary>
|
|
public required int NewRiskCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Count of mitigated risks (became unreachable).
|
|
/// </summary>
|
|
public required int MitigatedCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Net change in reachable vulnerabilities.
|
|
/// </summary>
|
|
public required int NetChange { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether this PR should be blocked based on policy.
|
|
/// </summary>
|
|
public required bool ShouldBlockPr { get; init; }
|
|
|
|
/// <summary>
|
|
/// Human-readable summary.
|
|
/// </summary>
|
|
public required string Summary { get; init; }
|
|
|
|
/// <summary>
|
|
/// Individual state flips.
|
|
/// </summary>
|
|
public required IReadOnlyList<StateFlip> Flips { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Individual state flip.
|
|
/// </summary>
|
|
public sealed record StateFlip
|
|
{
|
|
/// <summary>
|
|
/// Flip type.
|
|
/// </summary>
|
|
public required StateFlipType FlipType { get; init; }
|
|
|
|
/// <summary>
|
|
/// CVE ID.
|
|
/// </summary>
|
|
public required string CveId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Package PURL.
|
|
/// </summary>
|
|
public required string Purl { get; init; }
|
|
|
|
/// <summary>
|
|
/// Previous confidence tier.
|
|
/// </summary>
|
|
public string? PreviousTier { get; init; }
|
|
|
|
/// <summary>
|
|
/// New confidence tier.
|
|
/// </summary>
|
|
public required string NewTier { get; init; }
|
|
|
|
/// <summary>
|
|
/// Witness ID for the new state.
|
|
/// </summary>
|
|
public string? WitnessId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Entrypoint that triggers the vulnerability.
|
|
/// </summary>
|
|
public string? Entrypoint { get; init; }
|
|
|
|
/// <summary>
|
|
/// File path where the change occurred.
|
|
/// </summary>
|
|
public string? FilePath { get; init; }
|
|
|
|
/// <summary>
|
|
/// Line number where the change occurred.
|
|
/// </summary>
|
|
public int? LineNumber { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Type of state flip.
|
|
/// </summary>
|
|
public enum StateFlipType
|
|
{
|
|
/// <summary>
|
|
/// Vulnerability became reachable.
|
|
/// </summary>
|
|
BecameReachable,
|
|
|
|
/// <summary>
|
|
/// Vulnerability became unreachable.
|
|
/// </summary>
|
|
BecameUnreachable,
|
|
|
|
/// <summary>
|
|
/// Confidence tier increased.
|
|
/// </summary>
|
|
TierIncreased,
|
|
|
|
/// <summary>
|
|
/// Confidence tier decreased.
|
|
/// </summary>
|
|
TierDecreased
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inline annotation for a specific file/line.
|
|
/// </summary>
|
|
public sealed record InlineAnnotation
|
|
{
|
|
/// <summary>
|
|
/// File path relative to repository root.
|
|
/// </summary>
|
|
public required string FilePath { get; init; }
|
|
|
|
/// <summary>
|
|
/// Line number (1-based).
|
|
/// </summary>
|
|
public required int Line { get; init; }
|
|
|
|
/// <summary>
|
|
/// Annotation level.
|
|
/// </summary>
|
|
public required AnnotationLevel Level { get; init; }
|
|
|
|
/// <summary>
|
|
/// Annotation title.
|
|
/// </summary>
|
|
public required string Title { get; init; }
|
|
|
|
/// <summary>
|
|
/// Annotation message.
|
|
/// </summary>
|
|
public required string Message { get; init; }
|
|
|
|
/// <summary>
|
|
/// Raw details (for CI systems that support it).
|
|
/// </summary>
|
|
public string? RawDetails { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Annotation severity level.
|
|
/// </summary>
|
|
public enum AnnotationLevel
|
|
{
|
|
Notice,
|
|
Warning,
|
|
Failure
|
|
}
|
|
|
|
/// <summary>
|
|
/// Implementation of the PR annotation service.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<PrAnnotationResult> 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
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<StateFlip> ComputeStateFlips(
|
|
IReadOnlyDictionary<string, ReachabilityState> baseStates,
|
|
IReadOnlyDictionary<string, ReachabilityState> headStates)
|
|
{
|
|
var flips = new List<StateFlip>();
|
|
|
|
// 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<InlineAnnotation> GenerateInlineAnnotations(IReadOnlyList<StateFlip> flips)
|
|
{
|
|
var annotations = new List<InlineAnnotation>();
|
|
|
|
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<string>();
|
|
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] + "...";
|
|
}
|
|
}
|