using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.SmartDiff.Detection; using StellaOps.Scanner.SmartDiff.Output; using StellaOps.Scanner.WebService.Security; using StellaOps.Scanner.WebService.Services; using StellaOps.Scanner.WebService.Tenancy; using System.Collections.Immutable; 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("/{scanId}/vex-candidates", HandleGetScanCandidatesAsync) .WithName("scanner.smartdiff.scan-candidates") .WithTags("SmartDiff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); group.MapPost("/{scanId}/vex-candidates/review", HandleReviewScanCandidateAsync) .WithName("scanner.smartdiff.scan-review") .WithTags("SmartDiff") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansWrite); 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, HttpContext? context = null, CancellationToken ct = default) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } // Gather all data for the scan var changes = await changeRepo.GetChangesForScanAsync(scanId, ct, tenantId: tenantId); // 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 = NormalizeDigest(metadata.BaseDigest); targetDigest = NormalizeDigest(metadata.TargetDigest); scanTime = metadata.ScanTime; } } IReadOnlyList vexCandidates = []; if (!string.IsNullOrWhiteSpace(targetDigest)) { var candidates = await candidateStore.GetCandidatesAsync(targetDigest, ct, tenantId: tenantId).ConfigureAwait(false); vexCandidates = candidates.Select(ToSarifVexCandidate).ToList(); } // 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: 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); } /// /// GET /smart-diff/{scanId}/vex-candidates - Get VEX candidates using scan metadata. /// private static async Task HandleGetScanCandidatesAsync( string scanId, IScanMetadataRepository metadataRepository, IVexCandidateStore store, double? minConfidence = null, bool? pendingOnly = null, HttpContext? context = null, CancellationToken ct = default) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false); var targetDigest = NormalizeDigest(metadata?.TargetDigest); if (string.IsNullOrWhiteSpace(targetDigest)) { return Results.NotFound(new { error = "Scan metadata not found", scanId }); } return await HandleGetCandidatesAsync(targetDigest, store, minConfidence, pendingOnly, context, ct).ConfigureAwait(false); } 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, HttpContext? context = null, CancellationToken ct = default) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } var changes = await repository.GetChangesForScanAsync(scanId, ct, tenantId: tenantId); 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, HttpContext? context = null, CancellationToken ct = default) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } var normalizedDigest = NormalizeDigest(digest); if (string.IsNullOrWhiteSpace(normalizedDigest)) { return Results.BadRequest(new { error = "Invalid image digest" }); } var candidates = await store.GetCandidatesAsync(normalizedDigest, ct, tenantId: tenantId); 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, HttpContext? context = null, CancellationToken ct = default) { if (!TryResolveTenant(context, out var tenantId, out var failure)) { return failure!; } var candidate = await store.GetCandidateAsync(candidateId, ct, tenantId: tenantId); 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, TimeProvider timeProvider, 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" } }); } if (!TryResolveTenant(httpContext, out var tenantId, out var failure)) { return failure!; } var reviewer = httpContext.User.Identity?.Name ?? "anonymous"; var review = new VexCandidateReview( Action: action, Reviewer: reviewer, ReviewedAt: timeProvider.GetUtcNow(), Comment: request.Comment); var success = await store.ReviewCandidateAsync(candidateId, review, ct, tenantId: tenantId); 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 }); } /// /// POST /smart-diff/{scanId}/vex-candidates/review - Review a scan-scoped VEX candidate. /// private static async Task HandleReviewScanCandidateAsync( string scanId, ScanReviewRequest request, IScanMetadataRepository metadataRepository, IVexCandidateStore store, TimeProvider timeProvider, HttpContext httpContext, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(request.CandidateId)) { return Results.BadRequest(new { error = "CandidateId is required" }); } if (!TryResolveTenant(httpContext, out var tenantId, out var failure)) { return failure!; } var metadata = await metadataRepository.GetScanMetadataAsync(scanId, ct).ConfigureAwait(false); var targetDigest = NormalizeDigest(metadata?.TargetDigest); if (string.IsNullOrWhiteSpace(targetDigest)) { return Results.NotFound(new { error = "Scan metadata not found", scanId }); } var candidate = await store.GetCandidateAsync(request.CandidateId, ct, tenantId: tenantId).ConfigureAwait(false); if (candidate is null || !string.Equals(candidate.ImageDigest, targetDigest, StringComparison.OrdinalIgnoreCase)) { return Results.NotFound(new { error = "Candidate not found for scan", scanId, candidateId = request.CandidateId }); } return await HandleReviewCandidateAsync( request.CandidateId, new ReviewRequest { Action = request.Action, Comment = request.Comment }, store, timeProvider, httpContext, ct).ConfigureAwait(false); } #region Helper Methods private static string? NormalizeDigest(string? digest) { if (string.IsNullOrWhiteSpace(digest)) { return null; } // Handle URL-encoded colons. var normalized = digest.Trim().Replace("%3A", ":", StringComparison.OrdinalIgnoreCase); if (!normalized.Contains(':', StringComparison.Ordinal)) { normalized = $"sha256:{normalized}"; } return normalized; } private static StellaOps.Scanner.SmartDiff.Output.VexCandidate ToSarifVexCandidate( StellaOps.Scanner.SmartDiff.Detection.VexCandidate candidate) { return new StellaOps.Scanner.SmartDiff.Output.VexCandidate( VulnId: candidate.FindingKey.VulnId, ComponentPurl: candidate.FindingKey.ComponentPurl, Justification: MapJustificationToString(candidate.Justification), ImpactStatement: candidate.Rationale); } 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" }; } private static bool TryResolveTenant(HttpContext? context, out string tenantId, out IResult? failure) { tenantId = string.Empty; failure = null; if (context is null) { failure = Results.BadRequest(new { type = "validation-error", title = "Invalid tenant context", detail = "tenant_missing" }); return false; } if (ScannerRequestContextResolver.TryResolveTenant( context, out tenantId, out var tenantError, allowDefaultTenant: true)) { return true; } failure = Results.BadRequest(new { type = "validation-error", title = "Invalid tenant context", detail = tenantError ?? "tenant_conflict" }); return false; } #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; } } /// Request for POST /{scanId}/vex-candidates/review public sealed class ScanReviewRequest { public required string CandidateId { get; init; } 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