Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/SmartDiffEndpoints.cs

656 lines
23 KiB
C#

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;
/// <summary>
/// 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`
/// </summary>
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<MaterialChangesResponse>(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<VexCandidatesResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
group.MapPost("/{scanId}/vex-candidates/review", HandleReviewScanCandidateAsync)
.WithName("scanner.smartdiff.scan-review")
.WithTags("SmartDiff")
.Produces<ReviewResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansWrite);
group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync)
.WithName("scanner.smartdiff.candidates")
.WithTags("SmartDiff")
.Produces<VexCandidatesResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
group.MapGet("/candidates/{candidateId}", HandleGetCandidateAsync)
.WithName("scanner.smartdiff.candidate")
.WithTags("SmartDiff")
.Produces<VexCandidateResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
group.MapPost("/candidates/{candidateId}/review", HandleReviewCandidateAsync)
.WithName("scanner.smartdiff.review")
.WithTags("SmartDiff")
.Produces<ReviewResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansWrite);
}
/// <summary>
/// GET /smart-diff/scans/{scanId}/sarif - Get Smart-Diff results as SARIF 2.1.0.
/// Task: SDIFF-BIN-029
/// </summary>
private static async Task<IResult> 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<StellaOps.Scanner.SmartDiff.Output.VexCandidate> 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);
}
/// <summary>
/// GET /smart-diff/{scanId}/vex-candidates - Get VEX candidates using scan metadata.
/// </summary>
private static async Task<IResult> 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";
}
/// <summary>
/// GET /smart-diff/scans/{scanId}/changes - Get material risk changes for a scan.
/// </summary>
private static async Task<IResult> 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);
}
/// <summary>
/// GET /smart-diff/images/{digest}/candidates - Get VEX candidates for an image.
/// </summary>
private static async Task<IResult> 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);
}
/// <summary>
/// GET /smart-diff/candidates/{candidateId} - Get a specific VEX candidate.
/// </summary>
private static async Task<IResult> 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);
}
/// <summary>
/// POST /smart-diff/candidates/{candidateId}/review - Review a VEX candidate.
/// </summary>
private static async Task<IResult> HandleReviewCandidateAsync(
string candidateId,
ReviewRequest request,
IVexCandidateStore store,
TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken ct = default)
{
if (!Enum.TryParse<VexReviewAction>(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
});
}
/// <summary>
/// POST /smart-diff/{scanId}/vex-candidates/review - Review a scan-scoped VEX candidate.
/// </summary>
private static async Task<IResult> 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
/// <summary>Response for GET /scans/{id}/changes</summary>
public sealed class MaterialChangesResponse
{
public required string ScanId { get; init; }
public int TotalChanges { get; init; }
public required ImmutableArray<MaterialChangeDto> 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<DetectedChangeDto> 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; }
}
/// <summary>Response for GET /images/{digest}/candidates</summary>
public sealed class VexCandidatesResponse
{
public required string ImageDigest { get; init; }
public int TotalCandidates { get; init; }
public required ImmutableArray<VexCandidateDto> Candidates { get; init; }
}
/// <summary>Response for GET /candidates/{id}</summary>
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<EvidenceLinkDto> 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; }
}
/// <summary>Request for POST /candidates/{id}/review</summary>
public sealed class ReviewRequest
{
public required string Action { get; init; }
public string? Comment { get; init; }
}
/// <summary>Request for POST /{scanId}/vex-candidates/review</summary>
public sealed class ScanReviewRequest
{
public required string CandidateId { get; init; }
public required string Action { get; init; }
public string? Comment { get; init; }
}
/// <summary>Response for POST /candidates/{id}/review</summary>
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