Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Services/PrAnnotationService.cs
StellaOps Bot 5fc469ad98 feat: Add VEX Status Chip component and integration tests for reachability drift detection
- 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.
2025-12-20 01:26:42 +02:00

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] + "...";
}
}