using System.Collections.Immutable; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.SmartDiff.Detection; using StellaOps.Scanner.SmartDiff.Output; using StellaOps.Scanner.Storage.Postgres; using StellaOps.Scanner.WebService.Services; using StellaOps.Scanner.WebService.Security; namespace StellaOps.Scanner.WebService.Endpoints; /// /// Smart-Diff API endpoints for material risk changes and VEX candidates. /// Per Sprint 3500.3 - Smart-Diff Detection Rules. /// Task SDIFF-BIN-029 - API endpoint `GET /scans/{id}/sarif` /// internal static class SmartDiffEndpoints { public static void MapSmartDiffEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/smart-diff") { ArgumentNullException.ThrowIfNull(apiGroup); var group = apiGroup.MapGroup(prefix); // Material risk changes endpoints group.MapGet("/scans/{scanId}/changes", HandleGetScanChangesAsync) .WithName("scanner.smartdiff.scan-changes") .WithTags("SmartDiff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // SARIF output endpoint (Task SDIFF-BIN-029) group.MapGet("/scans/{scanId}/sarif", HandleGetScanSarifAsync) .WithName("scanner.smartdiff.sarif") .WithTags("SmartDiff", "SARIF") .Produces(StatusCodes.Status200OK, contentType: "application/sarif+json") .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); // VEX candidate endpoints group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync) .WithName("scanner.smartdiff.candidates") .WithTags("SmartDiff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); group.MapGet("/candidates/{candidateId}", HandleGetCandidateAsync) .WithName("scanner.smartdiff.candidate") .WithTags("SmartDiff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); group.MapPost("/candidates/{candidateId}/review", HandleReviewCandidateAsync) .WithName("scanner.smartdiff.review") .WithTags("SmartDiff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansWrite); } /// /// GET /smart-diff/scans/{scanId}/sarif - Get Smart-Diff results as SARIF 2.1.0. /// Task: SDIFF-BIN-029 /// private static async Task HandleGetScanSarifAsync( string scanId, IMaterialRiskChangeRepository changeRepo, IVexCandidateStore candidateStore, IScanMetadataRepository? metadataRepo = null, bool? pretty = null, CancellationToken ct = default) { // Gather all data for the scan var changes = await changeRepo.GetChangesForScanAsync(scanId, ct); // Get scan metadata if available string? baseDigest = null; string? targetDigest = null; DateTimeOffset scanTime = DateTimeOffset.UnixEpoch; if (metadataRepo is not null) { var metadata = await metadataRepo.GetScanMetadataAsync(scanId, ct); if (metadata is not null) { baseDigest = metadata.BaseDigest; targetDigest = metadata.TargetDigest; scanTime = metadata.ScanTime; } } // Convert to SARIF input format var sarifInput = new SmartDiffSarifInput( ScannerVersion: GetScannerVersion(), ScanTime: scanTime, BaseDigest: baseDigest, TargetDigest: targetDigest, MaterialChanges: changes .Where(c => c.HasMaterialChange) .Select(c => new MaterialRiskChange( VulnId: c.FindingKey.VulnId, ComponentPurl: c.FindingKey.ComponentPurl, Direction: ToSarifRiskDirection(c), Reason: ToSarifReason(c), FilePath: null )) .ToList(), HardeningRegressions: [], VexCandidates: [], ReachabilityChanges: []); // Generate SARIF var options = new SarifOutputOptions { IndentedJson = pretty == true, IncludeVexCandidates = true, IncludeHardeningRegressions = true, IncludeReachabilityChanges = true }; var generator = new SarifOutputGenerator(); var sarifJson = generator.GenerateJson(sarifInput, options); // Return as SARIF content type with proper filename var fileName = $"smartdiff-{scanId}.sarif"; return Results.Text( sarifJson, contentType: "application/sarif+json", statusCode: StatusCodes.Status200OK); } private static StellaOps.Scanner.SmartDiff.Output.RiskDirection ToSarifRiskDirection(MaterialRiskChangeResult change) { if (change.Changes.IsDefaultOrEmpty) { return StellaOps.Scanner.SmartDiff.Output.RiskDirection.Changed; } var hasIncreased = change.Changes.Any(c => c.Direction == StellaOps.Scanner.SmartDiff.Detection.RiskDirection.Increased); var hasDecreased = change.Changes.Any(c => c.Direction == StellaOps.Scanner.SmartDiff.Detection.RiskDirection.Decreased); return (hasIncreased, hasDecreased) switch { (true, false) => StellaOps.Scanner.SmartDiff.Output.RiskDirection.Increased, (false, true) => StellaOps.Scanner.SmartDiff.Output.RiskDirection.Decreased, _ => StellaOps.Scanner.SmartDiff.Output.RiskDirection.Changed }; } private static string ToSarifReason(MaterialRiskChangeResult change) { if (change.Changes.IsDefaultOrEmpty) { return "material_change"; } var reasons = change.Changes .Select(c => c.Reason) .Where(r => !string.IsNullOrWhiteSpace(r)) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .ToArray(); return reasons.Length switch { 0 => "material_change", 1 => reasons[0], _ => string.Join("; ", reasons) }; } private static string GetScannerVersion() { var assembly = typeof(SmartDiffEndpoints).Assembly; var version = assembly.GetName().Version; return version?.ToString() ?? "1.0.0"; } /// /// GET /smart-diff/scans/{scanId}/changes - Get material risk changes for a scan. /// private static async Task HandleGetScanChangesAsync( string scanId, IMaterialRiskChangeRepository repository, double? minPriority = null, CancellationToken ct = default) { var changes = await repository.GetChangesForScanAsync(scanId, ct); if (minPriority.HasValue) { changes = changes.Where(c => c.PriorityScore >= minPriority.Value).ToList(); } var response = new MaterialChangesResponse { ScanId = scanId, TotalChanges = changes.Count, Changes = changes.Select(ToChangeDto).ToImmutableArray() }; return Results.Ok(response); } /// /// GET /smart-diff/images/{digest}/candidates - Get VEX candidates for an image. /// private static async Task HandleGetCandidatesAsync( string digest, IVexCandidateStore store, double? minConfidence = null, bool? pendingOnly = null, CancellationToken ct = default) { var normalizedDigest = NormalizeDigest(digest); var candidates = await store.GetCandidatesAsync(normalizedDigest, ct); if (minConfidence.HasValue) { candidates = candidates.Where(c => c.Confidence >= minConfidence.Value).ToList(); } if (pendingOnly == true) { candidates = candidates.Where(c => c.RequiresReview).ToList(); } var response = new VexCandidatesResponse { ImageDigest = normalizedDigest, TotalCandidates = candidates.Count, Candidates = candidates.Select(ToCandidateDto).ToImmutableArray() }; return Results.Ok(response); } /// /// GET /smart-diff/candidates/{candidateId} - Get a specific VEX candidate. /// private static async Task HandleGetCandidateAsync( string candidateId, IVexCandidateStore store, CancellationToken ct = default) { var candidate = await store.GetCandidateAsync(candidateId, ct); if (candidate is null) { return Results.NotFound(new { error = "Candidate not found", candidateId }); } var response = new VexCandidateResponse { Candidate = ToCandidateDto(candidate) }; return Results.Ok(response); } /// /// POST /smart-diff/candidates/{candidateId}/review - Review a VEX candidate. /// private static async Task HandleReviewCandidateAsync( string candidateId, ReviewRequest request, IVexCandidateStore store, HttpContext httpContext, CancellationToken ct = default) { if (!Enum.TryParse(request.Action, true, out var action)) { return Results.BadRequest(new { error = "Invalid action", validActions = new[] { "accept", "reject", "defer" } }); } var reviewer = httpContext.User.Identity?.Name ?? "anonymous"; var review = new VexCandidateReview( Action: action, Reviewer: reviewer, ReviewedAt: DateTimeOffset.UtcNow, Comment: request.Comment); var success = await store.ReviewCandidateAsync(candidateId, review, ct); if (!success) { return Results.NotFound(new { error = "Candidate not found", candidateId }); } return Results.Ok(new ReviewResponse { CandidateId = candidateId, Action = action.ToString().ToLowerInvariant(), ReviewedBy = reviewer, ReviewedAt = review.ReviewedAt }); } #region Helper Methods private static string NormalizeDigest(string digest) { // Handle URL-encoded colons return digest.Replace("%3A", ":", StringComparison.OrdinalIgnoreCase); } private static MaterialChangeDto ToChangeDto(MaterialRiskChangeResult change) { return new MaterialChangeDto { VulnId = change.FindingKey.VulnId, Purl = change.FindingKey.ComponentPurl, HasMaterialChange = change.HasMaterialChange, PriorityScore = change.PriorityScore, PreviousStateHash = change.PreviousStateHash, CurrentStateHash = change.CurrentStateHash, Changes = change.Changes.Select(c => new DetectedChangeDto { Rule = c.Rule.ToString(), ChangeType = c.ChangeType.ToString(), Direction = c.Direction.ToString().ToLowerInvariant(), Reason = c.Reason, PreviousValue = c.PreviousValue, CurrentValue = c.CurrentValue, Weight = c.Weight, SubType = null }).ToImmutableArray() }; } private static VexCandidateDto ToCandidateDto(StellaOps.Scanner.SmartDiff.Detection.VexCandidate candidate) { return new VexCandidateDto { CandidateId = candidate.CandidateId, VulnId = candidate.FindingKey.VulnId, Purl = candidate.FindingKey.ComponentPurl, ImageDigest = candidate.ImageDigest, SuggestedStatus = candidate.SuggestedStatus.ToString().ToLowerInvariant(), Justification = MapJustificationToString(candidate.Justification), Rationale = candidate.Rationale, EvidenceLinks = candidate.EvidenceLinks.Select(e => new EvidenceLinkDto { Type = e.Type, Uri = e.Uri, Digest = e.Digest }).ToImmutableArray(), Confidence = candidate.Confidence, GeneratedAt = candidate.GeneratedAt, ExpiresAt = candidate.ExpiresAt, RequiresReview = candidate.RequiresReview }; } private static string MapJustificationToString(VexJustification justification) { return justification switch { VexJustification.ComponentNotPresent => "component_not_present", VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present", VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path", VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary", VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist", _ => "unknown" }; } #endregion } #region DTOs /// Response for GET /scans/{id}/changes public sealed class MaterialChangesResponse { public required string ScanId { get; init; } public int TotalChanges { get; init; } public required ImmutableArray Changes { get; init; } } public sealed class MaterialChangeDto { public required string VulnId { get; init; } public required string Purl { get; init; } public bool HasMaterialChange { get; init; } public double PriorityScore { get; init; } public required string PreviousStateHash { get; init; } public required string CurrentStateHash { get; init; } public required ImmutableArray Changes { get; init; } } public sealed class DetectedChangeDto { public required string Rule { get; init; } public required string ChangeType { get; init; } public required string Direction { get; init; } public required string Reason { get; init; } public required string PreviousValue { get; init; } public required string CurrentValue { get; init; } public double Weight { get; init; } public string? SubType { get; init; } } /// Response for GET /images/{digest}/candidates public sealed class VexCandidatesResponse { public required string ImageDigest { get; init; } public int TotalCandidates { get; init; } public required ImmutableArray Candidates { get; init; } } /// Response for GET /candidates/{id} public sealed class VexCandidateResponse { public required VexCandidateDto Candidate { get; init; } } public sealed class VexCandidateDto { public required string CandidateId { get; init; } public required string VulnId { get; init; } public required string Purl { get; init; } public required string ImageDigest { get; init; } public required string SuggestedStatus { get; init; } public required string Justification { get; init; } public required string Rationale { get; init; } public required ImmutableArray EvidenceLinks { get; init; } public double Confidence { get; init; } public DateTimeOffset GeneratedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } public bool RequiresReview { get; init; } } public sealed class EvidenceLinkDto { public required string Type { get; init; } public required string Uri { get; init; } public string? Digest { get; init; } } /// Request for POST /candidates/{id}/review public sealed class ReviewRequest { public required string Action { get; init; } public string? Comment { get; init; } } /// Response for POST /candidates/{id}/review public sealed class ReviewResponse { public required string CandidateId { get; init; } public required string Action { get; init; } public required string ReviewedBy { get; init; } public DateTimeOffset ReviewedAt { get; init; } } #endregion