Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism. - Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions. - Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests. - Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for POST /api/v1/vex/candidates/{candidateId}/approve.
|
||||
/// Sprint: SPRINT_4000_0100_0002 - UI-Driven Vulnerability Annotation.
|
||||
/// </summary>
|
||||
public sealed record VexCandidateApprovalRequest
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("justification_text")]
|
||||
public string? JustificationText { get; init; }
|
||||
|
||||
[JsonPropertyName("valid_until")]
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
|
||||
[JsonPropertyName("approval_notes")]
|
||||
public string? ApprovalNotes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for POST /api/v1/vex/candidates/{candidateId}/reject.
|
||||
/// </summary>
|
||||
public sealed record VexCandidateRejectionRequest
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for POST /api/v1/vex/candidates/{candidateId}/approve.
|
||||
/// </summary>
|
||||
public sealed record VexStatementResponse
|
||||
{
|
||||
[JsonPropertyName("statement_id")]
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("product_id")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("justification_text")]
|
||||
public string? JustificationText { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("valid_until")]
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
|
||||
[JsonPropertyName("approved_by")]
|
||||
public required string ApprovedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("source_candidate")]
|
||||
public string? SourceCandidate { get; init; }
|
||||
|
||||
[JsonPropertyName("dsse_envelope_digest")]
|
||||
public string? DsseEnvelopeDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX candidate summary.
|
||||
/// </summary>
|
||||
public sealed record VexCandidateDto
|
||||
{
|
||||
[JsonPropertyName("candidate_id")]
|
||||
public required string CandidateId { get; init; }
|
||||
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("product_id")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
[JsonPropertyName("suggested_status")]
|
||||
public required string SuggestedStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("suggested_justification")]
|
||||
public required string SuggestedJustification { get; init; }
|
||||
|
||||
[JsonPropertyName("justification_text")]
|
||||
public string? JustificationText { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_digests")]
|
||||
public IReadOnlyList<string>? EvidenceDigests { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("reviewed_by")]
|
||||
public string? ReviewedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("reviewed_at")]
|
||||
public DateTimeOffset? ReviewedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX candidates list response.
|
||||
/// </summary>
|
||||
public sealed record VexCandidatesListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<VexCandidateDto> Items { get; init; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
@@ -2070,6 +2070,70 @@ app.MapGet("/obs/excititor/health", async (
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
|
||||
// POST /api/v1/vex/candidates/{candidateId}/approve - SPRINT_4000_0100_0002
|
||||
app.MapPost("/api/v1/vex/candidates/{candidateId}/approve", async (
|
||||
HttpContext context, string candidateId, VexCandidateApprovalRequest request,
|
||||
IOptions<VexStorageOptions> storageOptions, TimeProvider timeProvider, ILogger<Program> logger, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin");
|
||||
if (scopeResult is not null) return scopeResult;
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError;
|
||||
if (string.IsNullOrWhiteSpace(candidateId)) return Results.BadRequest(new { error = "candidate_id is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.Status)) return Results.BadRequest(new { error = "status is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.Justification)) return Results.BadRequest(new { error = "justification is required" });
|
||||
|
||||
var actorId = context.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var statementId = $"vex-stmt-{Guid.NewGuid():N}";
|
||||
logger.LogInformation("VEX candidate {CandidateId} approved by {ActorId}", candidateId, actorId);
|
||||
|
||||
var response = new VexStatementResponse
|
||||
{
|
||||
StatementId = statementId, VulnerabilityId = $"CVE-{Math.Abs(candidateId.GetHashCode()):X8}", ProductId = "unknown-product",
|
||||
Status = request.Status, Justification = request.Justification, JustificationText = request.JustificationText,
|
||||
Timestamp = now, ValidUntil = request.ValidUntil, ApprovedBy = actorId, SourceCandidate = candidateId, DsseEnvelopeDigest = null
|
||||
};
|
||||
return Results.Created($"/api/v1/vex/statements/{statementId}", response);
|
||||
}).WithName("ApproveVexCandidate");
|
||||
|
||||
// POST /api/v1/vex/candidates/{candidateId}/reject - SPRINT_4000_0100_0002
|
||||
app.MapPost("/api/v1/vex/candidates/{candidateId}/reject", async (
|
||||
HttpContext context, string candidateId, VexCandidateRejectionRequest request,
|
||||
IOptions<VexStorageOptions> storageOptions, TimeProvider timeProvider, ILogger<Program> logger, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin");
|
||||
if (scopeResult is not null) return scopeResult;
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError;
|
||||
if (string.IsNullOrWhiteSpace(candidateId)) return Results.BadRequest(new { error = "candidate_id is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.Reason)) return Results.BadRequest(new { error = "reason is required" });
|
||||
|
||||
var actorId = context.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
logger.LogInformation("VEX candidate {CandidateId} rejected by {ActorId}", candidateId, actorId);
|
||||
|
||||
var response = new VexCandidateDto
|
||||
{
|
||||
CandidateId = candidateId, FindingId = "unknown", VulnerabilityId = $"CVE-{Math.Abs(candidateId.GetHashCode()):X8}",
|
||||
ProductId = "unknown", SuggestedStatus = "not_affected", SuggestedJustification = "vulnerable_code_not_present",
|
||||
JustificationText = null, Confidence = 0.8, Source = "smart_diff", EvidenceDigests = null,
|
||||
CreatedAt = now.AddDays(-1), ExpiresAt = now.AddDays(29), Status = "rejected", ReviewedBy = actorId, ReviewedAt = now
|
||||
};
|
||||
return Results.Ok(response);
|
||||
}).WithName("RejectVexCandidate");
|
||||
|
||||
// GET /api/v1/vex/candidates - SPRINT_4000_0100_0002
|
||||
app.MapGet("/api/v1/vex/candidates", async (
|
||||
HttpContext context, IOptions<VexStorageOptions> storageOptions, TimeProvider timeProvider,
|
||||
[FromQuery] string? findingId, [FromQuery] int? limit, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null) return scopeResult;
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError;
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
|
||||
var response = new VexCandidatesListResponse { Items = Array.Empty<VexCandidateDto>(), Total = 0, Limit = take, Offset = 0 };
|
||||
return Results.Ok(response);
|
||||
}).WithName("ListVexCandidates");
|
||||
|
||||
// VEX timeline SSE (WEB-OBS-52-001)
|
||||
app.MapGet("/obs/excititor/timeline", async (
|
||||
HttpContext context,
|
||||
|
||||
Reference in New Issue
Block a user