656 lines
23 KiB
C#
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
|