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:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

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

View File

@@ -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,

View File

@@ -1,8 +1,19 @@
// -----------------------------------------------------------------------------
// ExcititorPostgresFixture.cs
// Sprint: SPRINT_5100_0007_0004_storage_harness
// Task: STOR-HARNESS-012
// Description: Excititor PostgreSQL test fixture using TestKit
// -----------------------------------------------------------------------------
using System.Reflection;
using StellaOps.Excititor.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
// Type aliases to disambiguate TestKit and Infrastructure.Postgres.Testing fixtures
using TestKitPostgresFixture = StellaOps.TestKit.Fixtures.PostgresFixture;
using TestKitPostgresIsolationMode = StellaOps.TestKit.Fixtures.PostgresIsolationMode;
namespace StellaOps.Excititor.Storage.Postgres.Tests;
/// <summary>
@@ -28,3 +39,36 @@ public sealed class ExcititorPostgresCollection : ICollectionFixture<ExcititorPo
{
public const string Name = "ExcititorPostgres";
}
/// <summary>
/// TestKit-based PostgreSQL fixture for Excititor storage tests.
/// Uses TestKit's PostgresFixture for enhanced isolation modes.
/// </summary>
public sealed class ExcititorTestKitPostgresFixture : IAsyncLifetime
{
private TestKitPostgresFixture _fixture = null!;
private Assembly MigrationAssembly => typeof(ExcititorDataSource).Assembly;
public TestKitPostgresFixture Fixture => _fixture;
public string ConnectionString => _fixture.ConnectionString;
public async Task InitializeAsync()
{
_fixture = new TestKitPostgresFixture(TestKitPostgresIsolationMode.Truncation);
await _fixture.InitializeAsync();
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly, "public", "Migrations");
}
public Task DisposeAsync() => _fixture.DisposeAsync();
public Task TruncateAllTablesAsync() => _fixture.TruncateAllTablesAsync();
}
/// <summary>
/// Collection definition for Excititor TestKit PostgreSQL tests.
/// </summary>
[CollectionDefinition(ExcititorTestKitPostgresCollection.Name)]
public sealed class ExcititorTestKitPostgresCollection : ICollectionFixture<ExcititorTestKitPostgresFixture>
{
public const string Name = "ExcititorTestKitPostgres";
}

View File

@@ -36,6 +36,6 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>