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.
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ApprovalEndpoints.cs
|
||||
// Sprint: SPRINT_3801_0001_0005_approvals_api
|
||||
// Description: HTTP endpoints for human approval workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for human approval workflow.
|
||||
/// </summary>
|
||||
internal static class ApprovalEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps approval endpoints to the scans route group.
|
||||
/// </summary>
|
||||
public static void MapApprovalEndpoints(this RouteGroupBuilder scansGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// POST /scans/{scanId}/approvals
|
||||
scansGroup.MapPost("/{scanId}/approvals", HandleCreateApprovalAsync)
|
||||
.WithName("scanner.scans.approvals.create")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Creates a human approval attestation for a finding.")
|
||||
.Produces<ApprovalResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.RequireAuthorization(ScannerPolicies.ScansApprove);
|
||||
|
||||
// GET /scans/{scanId}/approvals
|
||||
scansGroup.MapGet("/{scanId}/approvals", HandleListApprovalsAsync)
|
||||
.WithName("scanner.scans.approvals.list")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Lists all active approvals for a scan.")
|
||||
.Produces<ApprovalListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/approvals/{findingId}
|
||||
scansGroup.MapGet("/{scanId}/approvals/{findingId}", HandleGetApprovalAsync)
|
||||
.WithName("scanner.scans.approvals.get")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Gets an approval for a specific finding.")
|
||||
.Produces<ApprovalResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// DELETE /scans/{scanId}/approvals/{findingId}
|
||||
scansGroup.MapDelete("/{scanId}/approvals/{findingId}", HandleRevokeApprovalAsync)
|
||||
.WithName("scanner.scans.approvals.revoke")
|
||||
.WithTags("Approvals")
|
||||
.WithDescription("Revokes an existing approval.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansApprove);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateApprovalAsync(
|
||||
string scanId,
|
||||
CreateApprovalRequest request,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
IAttestationChainVerifier chainVerifier,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(chainVerifier);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Request body is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.FindingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"FindingId is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Justification))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Justification is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// Extract approver from claims
|
||||
var approverInfo = ExtractApproverInfo(context.User);
|
||||
if (string.IsNullOrWhiteSpace(approverInfo.UserId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Unable to identify approver",
|
||||
StatusCodes.Status401Unauthorized,
|
||||
detail: "User identity could not be determined from the request.");
|
||||
}
|
||||
|
||||
// Parse the decision
|
||||
if (!Enum.TryParse<ApprovalDecision>(request.Decision, ignoreCase: true, out var decision))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid decision value",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: $"Decision must be one of: AcceptRisk, Defer, Reject, Suppress, Escalate. Got: {request.Decision}");
|
||||
}
|
||||
|
||||
// Create the approval
|
||||
var input = new HumanApprovalAttestationInput
|
||||
{
|
||||
ScanId = parsed,
|
||||
FindingId = request.FindingId,
|
||||
Decision = decision,
|
||||
ApproverUserId = approverInfo.UserId,
|
||||
ApproverDisplayName = approverInfo.DisplayName,
|
||||
ApproverRole = approverInfo.Role,
|
||||
Justification = request.Justification,
|
||||
PolicyDecisionRef = request.PolicyDecisionRef,
|
||||
Restrictions = request.Restrictions,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
var result = await approvalService.CreateAttestationAsync(input, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Internal,
|
||||
"Failed to create approval",
|
||||
StatusCodes.Status500InternalServerError,
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
// Get chain status
|
||||
ChainStatus? chainStatus = null;
|
||||
try
|
||||
{
|
||||
var chainInput = new ChainVerificationInput
|
||||
{
|
||||
ScanId = parsed,
|
||||
FindingId = request.FindingId,
|
||||
RootDigest = result.AttestationId!
|
||||
};
|
||||
var chainResult = await chainVerifier.VerifyChainAsync(chainInput, cancellationToken);
|
||||
chainStatus = chainResult.Chain?.Status;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Chain verification is optional, don't fail the request
|
||||
}
|
||||
|
||||
var response = MapToResponse(result, chainStatus);
|
||||
return Results.Created($"/{scanId}/approvals/{request.FindingId}", response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListApprovalsAsync(
|
||||
string scanId,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var approvals = await approvalService.GetApprovalsByScanAsync(parsed, cancellationToken);
|
||||
|
||||
var response = new ApprovalListResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
Approvals = approvals.Select(a => MapToResponse(a, null)).ToList(),
|
||||
TotalCount = approvals.Count
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetApprovalAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"FindingId is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var result = await approvalService.GetAttestationAsync(parsed, findingId, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Approval not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No approval found for finding '{findingId}' in scan '{scanId}'.");
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(result, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRevokeApprovalAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
RevokeApprovalRequest? request,
|
||||
IHumanApprovalAttestationService approvalService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(approvalService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"FindingId is required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var revoker = ExtractApproverInfo(context.User);
|
||||
if (string.IsNullOrWhiteSpace(revoker.UserId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Authentication,
|
||||
"Unable to identify revoker",
|
||||
StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var reason = request?.Reason ?? "Revoked via API";
|
||||
|
||||
var revoked = await approvalService.RevokeApprovalAsync(
|
||||
parsed,
|
||||
findingId,
|
||||
revoker.UserId,
|
||||
reason,
|
||||
cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Approval not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: $"No approval found for finding '{findingId}' in scan '{scanId}'.");
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static (string UserId, string? DisplayName, string? Role) ExtractApproverInfo(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
return (string.Empty, null, null);
|
||||
}
|
||||
|
||||
// Try various claim types for user ID
|
||||
var userId = user.FindFirstValue(ClaimTypes.Email)
|
||||
?? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirstValue("sub")
|
||||
?? user.FindFirstValue("preferred_username")
|
||||
?? string.Empty;
|
||||
|
||||
var displayName = user.FindFirstValue(ClaimTypes.Name)
|
||||
?? user.FindFirstValue("name");
|
||||
|
||||
var role = user.FindFirstValue(ClaimTypes.Role)
|
||||
?? user.FindFirstValue("role");
|
||||
|
||||
return (userId, displayName, role);
|
||||
}
|
||||
|
||||
private static ApprovalResponse MapToResponse(
|
||||
HumanApprovalAttestationResult result,
|
||||
ChainStatus? chainStatus)
|
||||
{
|
||||
var statement = result.Statement!;
|
||||
var predicate = statement.Predicate;
|
||||
|
||||
return new ApprovalResponse
|
||||
{
|
||||
ApprovalId = predicate.ApprovalId,
|
||||
FindingId = predicate.FindingId,
|
||||
Decision = predicate.Decision.ToString(),
|
||||
AttestationId = result.AttestationId!,
|
||||
Approver = predicate.Approver.UserId,
|
||||
ApproverDisplayName = predicate.Approver.DisplayName,
|
||||
ApprovedAt = predicate.ApprovedAt,
|
||||
ExpiresAt = predicate.ExpiresAt ?? predicate.ApprovedAt.AddDays(30),
|
||||
Justification = predicate.Justification,
|
||||
ChainStatus = chainStatus?.ToString(),
|
||||
IsRevoked = result.IsRevoked,
|
||||
PolicyDecisionRef = predicate.PolicyDecisionRef,
|
||||
Restrictions = predicate.Restrictions
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an approval.
|
||||
/// </summary>
|
||||
public sealed record CreateApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The finding ID (e.g., CVE identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision: AcceptRisk, Defer, Reject, Suppress, Escalate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required string Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy decision attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_decision_ref")]
|
||||
public string? PolicyDecisionRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional restrictions on the approval scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("restrictions")]
|
||||
public ApprovalRestrictions? Restrictions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an approval.
|
||||
/// </summary>
|
||||
public sealed record RevokeApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for an approval.
|
||||
/// </summary>
|
||||
public sealed record ApprovalResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The approval ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approval_id")]
|
||||
public required string ApprovalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The finding ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approval decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required string Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestation_id")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's user ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approver")]
|
||||
public required string Approver { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The approver's display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approver_display_name")]
|
||||
public string? ApproverDisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval was made.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approved_at")]
|
||||
public required DateTimeOffset ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the approval expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The justification for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attestation chain status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("chain_status")]
|
||||
public string? ChainStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the approval has been revoked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_revoked")]
|
||||
public bool IsRevoked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy decision attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_decision_ref")]
|
||||
public string? PolicyDecisionRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions on the approval scope.
|
||||
/// </summary>
|
||||
[JsonPropertyName("restrictions")]
|
||||
public ApprovalRestrictions? Restrictions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing approvals.
|
||||
/// </summary>
|
||||
public sealed record ApprovalListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scan_id")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The list of approvals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvals")]
|
||||
public required IList<ApprovalResponse> Approvals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count of approvals.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total_count")]
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceEndpoints.cs
|
||||
// Sprint: SPRINT_3800_0003_0001_evidence_api_endpoint
|
||||
// Description: HTTP endpoints for unified finding evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for retrieving unified finding evidence.
|
||||
/// </summary>
|
||||
internal static class EvidenceEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps evidence endpoints to the scans route group.
|
||||
/// </summary>
|
||||
public static void MapEvidenceEndpoints(this RouteGroupBuilder scansGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// GET /scans/{scanId}/evidence/{findingId}
|
||||
scansGroup.MapGet("/{scanId}/evidence/{findingId}", HandleGetEvidenceAsync)
|
||||
.WithName("scanner.scans.evidence.get")
|
||||
.WithTags("Evidence")
|
||||
.WithDescription("Retrieves unified evidence for a specific finding within a scan.")
|
||||
.Produces<FindingEvidenceResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/evidence (list all findings with evidence)
|
||||
scansGroup.MapGet("/{scanId}/evidence", HandleListEvidenceAsync)
|
||||
.WithName("scanner.scans.evidence.list")
|
||||
.WithTags("Evidence")
|
||||
.WithDescription("Lists all findings with evidence for a scan.")
|
||||
.Produces<EvidenceListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetEvidenceAsync(
|
||||
string scanId,
|
||||
string findingId,
|
||||
IEvidenceCompositionService evidenceService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid finding identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Finding identifier is required.");
|
||||
}
|
||||
|
||||
var evidence = await evidenceService.GetEvidenceAsync(
|
||||
parsed,
|
||||
findingId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (evidence is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Finding not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "The requested finding could not be located in this scan.");
|
||||
}
|
||||
|
||||
// Add warning header if evidence is stale or near expiry
|
||||
if (evidence.IsStale)
|
||||
{
|
||||
context.Response.Headers["X-Evidence-Warning"] = "stale";
|
||||
}
|
||||
else if (evidence.ExpiresAt.HasValue)
|
||||
{
|
||||
var timeUntilExpiry = evidence.ExpiresAt.Value - DateTimeOffset.UtcNow;
|
||||
if (timeUntilExpiry <= TimeSpan.FromDays(1))
|
||||
{
|
||||
context.Response.Headers["X-Evidence-Warning"] = "near-expiry";
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(evidence, SerializerOptions, contentType: "application/json", statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListEvidenceAsync(
|
||||
string scanId,
|
||||
IEvidenceCompositionService evidenceService,
|
||||
IReachabilityQueryService reachabilityService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidenceService);
|
||||
ArgumentNullException.ThrowIfNull(reachabilityService);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
// Get all findings for the scan
|
||||
var findings = await reachabilityService.GetFindingsAsync(
|
||||
parsed,
|
||||
cveFilter: null,
|
||||
statusFilter: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (findings.Count == 0)
|
||||
{
|
||||
return Results.Json(
|
||||
new EvidenceListResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
TotalCount = 0,
|
||||
Items = Array.Empty<EvidenceSummary>()
|
||||
},
|
||||
SerializerOptions,
|
||||
contentType: "application/json",
|
||||
statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// Build summary list (without fetching full evidence for performance)
|
||||
var items = findings.Select(f => new EvidenceSummary
|
||||
{
|
||||
FindingId = $"{f.CveId}@{f.Purl}",
|
||||
Cve = f.CveId,
|
||||
Purl = f.Purl,
|
||||
ReachabilityStatus = f.Status,
|
||||
Confidence = f.Confidence,
|
||||
HasPath = f.Status.Equals("reachable", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.Status.Equals("direct", StringComparison.OrdinalIgnoreCase)
|
||||
}).ToList();
|
||||
|
||||
return Results.Json(
|
||||
new EvidenceListResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
TotalCount = items.Count,
|
||||
Items = items
|
||||
},
|
||||
SerializerOptions,
|
||||
contentType: "application/json",
|
||||
statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of evidence summaries.
|
||||
/// </summary>
|
||||
public sealed record EvidenceListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The scan identifier.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("scan_id")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of findings with evidence.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("total_count")]
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of each finding's evidence.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("items")]
|
||||
public IReadOnlyList<EvidenceSummary> Items { get; init; } = Array.Empty<EvidenceSummary>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a finding's evidence (for list view).
|
||||
/// </summary>
|
||||
public sealed record EvidenceSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier (CVE@PURL format).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("finding_id")]
|
||||
public string FindingId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("cve")]
|
||||
public string Cve { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package URL.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("purl")]
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("reachability_status")]
|
||||
public string ReachabilityStatus { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a reachable path exists.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("has_path")]
|
||||
public bool HasPath { get; init; }
|
||||
}
|
||||
@@ -85,6 +85,8 @@ internal static class ScanEndpoints
|
||||
scans.MapReachabilityEndpoints();
|
||||
scans.MapReachabilityDriftScanEndpoints();
|
||||
scans.MapExportEndpoints();
|
||||
scans.MapEvidenceEndpoints();
|
||||
scans.MapApprovalEndpoints();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSubmitAsync(
|
||||
|
||||
Reference in New Issue
Block a user