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:
StellaOps Bot
2025-12-20 01:26:42 +02:00
parent edc91ea96f
commit 5fc469ad98
159 changed files with 41116 additions and 2305 deletions

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -85,6 +85,8 @@ internal static class ScanEndpoints
scans.MapReachabilityEndpoints();
scans.MapReachabilityDriftScanEndpoints();
scans.MapExportEndpoints();
scans.MapEvidenceEndpoints();
scans.MapApprovalEndpoints();
}
private static async Task<IResult> HandleSubmitAsync(