new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View File

@@ -0,0 +1,314 @@
// <copyright file="IVexOverrideAttestorClient.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
// </copyright>
using System.Text.Json.Serialization;
using StellaOps.VulnExplorer.Api.Models;
namespace StellaOps.VulnExplorer.Api.Data;
/// <summary>
/// Client for creating signed VEX override attestations via Attestor.
/// </summary>
public interface IVexOverrideAttestorClient
{
/// <summary>
/// Creates a signed DSSE attestation for a VEX override decision.
/// </summary>
Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies an existing VEX override attestation.
/// </summary>
Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to create a VEX override attestation.
/// </summary>
public sealed record VexOverrideAttestationRequest
{
/// <summary>
/// Vulnerability ID being overridden.
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Subject the override applies to.
/// </summary>
[JsonPropertyName("subject")]
public required SubjectRefDto Subject { get; init; }
/// <summary>
/// VEX status being set.
/// </summary>
[JsonPropertyName("status")]
public required VexStatus Status { get; init; }
/// <summary>
/// Justification type.
/// </summary>
[JsonPropertyName("justificationType")]
public required VexJustificationType JustificationType { get; init; }
/// <summary>
/// Justification text.
/// </summary>
[JsonPropertyName("justificationText")]
public string? JustificationText { get; init; }
/// <summary>
/// Evidence references supporting the decision.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<EvidenceRefDto>? EvidenceRefs { get; init; }
/// <summary>
/// Scope of the override.
/// </summary>
[JsonPropertyName("scope")]
public VexScopeDto? Scope { get; init; }
/// <summary>
/// Validity period.
/// </summary>
[JsonPropertyName("validFor")]
public ValidForDto? ValidFor { get; init; }
/// <summary>
/// Actor creating the override.
/// </summary>
[JsonPropertyName("createdBy")]
public required ActorRefDto CreatedBy { get; init; }
/// <summary>
/// Whether to anchor to Rekor.
/// </summary>
[JsonPropertyName("anchorToRekor")]
public bool AnchorToRekor { get; init; }
/// <summary>
/// Signing key ID (null = default).
/// </summary>
[JsonPropertyName("signingKeyId")]
public string? SigningKeyId { get; init; }
/// <summary>
/// Storage destination for the attestation.
/// </summary>
[JsonPropertyName("storageDestination")]
public string? StorageDestination { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("additionalMetadata")]
public IReadOnlyDictionary<string, string>? AdditionalMetadata { get; init; }
}
/// <summary>
/// Result of creating a VEX override attestation.
/// </summary>
public sealed record VexOverrideAttestationResult
{
/// <summary>
/// Whether the attestation was successfully created.
/// </summary>
[JsonPropertyName("success")]
public required bool Success { get; init; }
/// <summary>
/// Created attestation details (if successful).
/// </summary>
[JsonPropertyName("attestation")]
public VexOverrideAttestationDto? Attestation { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Error code (if failed).
/// </summary>
[JsonPropertyName("errorCode")]
public string? ErrorCode { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static VexOverrideAttestationResult Ok(VexOverrideAttestationDto attestation) => new()
{
Success = true,
Attestation = attestation
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static VexOverrideAttestationResult Fail(string error, string? errorCode = null) => new()
{
Success = false,
Error = error,
ErrorCode = errorCode
};
}
/// <summary>
/// HTTP client implementation for VEX override attestations.
/// </summary>
public sealed class HttpVexOverrideAttestorClient : IVexOverrideAttestorClient
{
private readonly HttpClient _httpClient;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HttpVexOverrideAttestorClient> _logger;
public HttpVexOverrideAttestorClient(
HttpClient httpClient,
TimeProvider timeProvider,
ILogger<HttpVexOverrideAttestorClient> logger)
{
_httpClient = httpClient;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/attestations/vex-override",
request,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning(
"Failed to create VEX override attestation: {StatusCode} - {Error}",
response.StatusCode, errorBody);
return VexOverrideAttestationResult.Fail(
$"Attestor returned {response.StatusCode}: {errorBody}",
response.StatusCode.ToString());
}
var result = await response.Content.ReadFromJsonAsync<VexOverrideAttestationDto>(
cancellationToken: cancellationToken);
if (result is null)
{
return VexOverrideAttestationResult.Fail("Empty response from Attestor");
}
return VexOverrideAttestationResult.Ok(result);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error creating VEX override attestation");
return VexOverrideAttestationResult.Fail($"HTTP error: {ex.Message}", "HTTP_ERROR");
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "Timeout creating VEX override attestation");
return VexOverrideAttestationResult.Fail("Request timed out", "TIMEOUT");
}
}
public async Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync(
$"/api/v1/attestations/{envelopeDigest}/verify",
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: $"Attestor returned {response.StatusCode}");
}
var result = await response.Content.ReadFromJsonAsync<AttestationVerificationStatusDto>(
cancellationToken: cancellationToken);
return result ?? new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "Empty response from Attestor");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Error verifying attestation {Digest}", envelopeDigest);
return new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: ex.Message);
}
}
}
/// <summary>
/// Stub implementation for offline/testing scenarios.
/// </summary>
public sealed class StubVexOverrideAttestorClient : IVexOverrideAttestorClient
{
private readonly TimeProvider _timeProvider;
public StubVexOverrideAttestorClient(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<VexOverrideAttestationResult> CreateAttestationAsync(
VexOverrideAttestationRequest request,
CancellationToken cancellationToken = default)
{
// In offline mode, return an unsigned placeholder
var now = _timeProvider.GetUtcNow();
var attestation = new VexOverrideAttestationDto(
EnvelopeDigest: $"sha256:offline-stub-{Guid.NewGuid():N}",
PredicateType: "https://stellaops.dev/predicates/vex-override@v1",
RekorLogIndex: null,
RekorEntryId: null,
StorageRef: "offline-queue",
AttestationCreatedAt: now,
Verified: false,
VerificationStatus: null);
return Task.FromResult(VexOverrideAttestationResult.Ok(attestation));
}
public Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
string envelopeDigest,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new AttestationVerificationStatusDto(
SignatureValid: false,
RekorVerified: null,
VerifiedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "Offline mode - verification unavailable"));
}
}

View File

@@ -13,11 +13,16 @@ public sealed class VexDecisionStore
private readonly ConcurrentDictionary<Guid, VexDecisionDto> _decisions = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly IVexOverrideAttestorClient? _attestorClient;
public VexDecisionStore(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
public VexDecisionStore(
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
IVexOverrideAttestorClient? attestorClient = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_attestorClient = attestorClient;
}
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
@@ -36,6 +41,7 @@ public sealed class VexDecisionStore
Scope: request.Scope,
ValidFor: request.ValidFor,
AttestationRef: null, // Will be set when attestation is generated
SignedOverride: null, // Will be set when attestation is generated (VEX-OVR-002)
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
@@ -105,4 +111,133 @@ public sealed class VexDecisionStore
}
public int Count() => _decisions.Count;
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
/// <summary>
/// Creates a VEX decision with a signed attestation.
/// </summary>
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
CreateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
var id = _guidProvider.NewGuid();
var now = _timeProvider.GetUtcNow();
VexOverrideAttestationDto? signedOverride = null;
VexOverrideAttestationResult? attestationResult = null;
// Create attestation if requested and client is available
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
{
VulnerabilityId = request.VulnerabilityId,
Subject = request.Subject,
Status = request.Status,
JustificationType = request.JustificationType,
JustificationText = request.JustificationText,
EvidenceRefs = request.EvidenceRefs,
Scope = request.Scope,
ValidFor = request.ValidFor,
CreatedBy = new ActorRefDto(userId, userDisplayName),
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
SigningKeyId = request.AttestationOptions.SigningKeyId,
StorageDestination = request.AttestationOptions.StorageDestination,
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
if (attestationResult.Success && attestationResult.Attestation is not null)
{
signedOverride = attestationResult.Attestation;
}
}
var decision = new VexDecisionDto(
Id: id,
VulnerabilityId: request.VulnerabilityId,
Subject: request.Subject,
Status: request.Status,
JustificationType: request.JustificationType,
JustificationText: request.JustificationText,
EvidenceRefs: request.EvidenceRefs,
Scope: request.Scope,
ValidFor: request.ValidFor,
AttestationRef: null,
SignedOverride: signedOverride,
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
UpdatedAt: null);
_decisions[id] = decision;
return (decision, attestationResult);
}
/// <summary>
/// Updates a VEX decision and optionally creates a new attestation.
/// </summary>
public async Task<(VexDecisionDto? Decision, VexOverrideAttestationResult? AttestationResult)> UpdateWithAttestationAsync(
Guid id,
UpdateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
{
if (!_decisions.TryGetValue(id, out var existing))
{
return (null, null);
}
VexOverrideAttestationDto? signedOverride = existing.SignedOverride;
VexOverrideAttestationResult? attestationResult = null;
// Create new attestation if requested
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
{
VulnerabilityId = existing.VulnerabilityId,
Subject = existing.Subject,
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
CreatedBy = new ActorRefDto(userId, userDisplayName),
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
SigningKeyId = request.AttestationOptions.SigningKeyId,
StorageDestination = request.AttestationOptions.StorageDestination,
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
if (attestationResult.Success && attestationResult.Attestation is not null)
{
signedOverride = attestationResult.Attestation;
}
}
var updated = existing with
{
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
SignedOverride = signedOverride,
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
UpdatedAt = _timeProvider.GetUtcNow()
};
_decisions[id] = updated;
return (updated, attestationResult);
}
}

View File

@@ -15,11 +15,57 @@ public sealed record VexDecisionDto(
VexScopeDto? Scope,
ValidForDto? ValidFor,
AttestationRefDto? AttestationRef,
VexOverrideAttestationDto? SignedOverride,
Guid? SupersedesDecisionId,
ActorRefDto CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
/// <summary>
/// Signed VEX override attestation details.
/// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-001)
/// </summary>
public sealed record VexOverrideAttestationDto(
/// <summary>DSSE envelope digest (sha256:hex).</summary>
string EnvelopeDigest,
/// <summary>Predicate type for the attestation.</summary>
string PredicateType,
/// <summary>Rekor transparency log index (null if not anchored).</summary>
long? RekorLogIndex,
/// <summary>Rekor entry ID (null if not anchored).</summary>
string? RekorEntryId,
/// <summary>Attestation storage location/reference.</summary>
string? StorageRef,
/// <summary>Timestamp when attestation was created.</summary>
DateTimeOffset AttestationCreatedAt,
/// <summary>Whether the attestation has been verified.</summary>
bool Verified,
/// <summary>Verification status details.</summary>
AttestationVerificationStatusDto? VerificationStatus);
/// <summary>
/// Attestation verification status details.
/// </summary>
public sealed record AttestationVerificationStatusDto(
/// <summary>Whether signature was valid.</summary>
bool SignatureValid,
/// <summary>Whether Rekor inclusion was verified.</summary>
bool? RekorVerified,
/// <summary>Timestamp when verification was performed.</summary>
DateTimeOffset? VerifiedAt,
/// <summary>Error message if verification failed.</summary>
string? ErrorMessage);
/// <summary>
/// Reference to an artifact or SBOM component that a VEX decision applies to.
/// </summary>
@@ -128,7 +174,29 @@ public sealed record CreateVexDecisionRequest(
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
Guid? SupersedesDecisionId);
Guid? SupersedesDecisionId,
/// <summary>Attestation options for signed override.</summary>
AttestationRequestOptions? AttestationOptions);
/// <summary>
/// Options for creating a signed attestation with the VEX decision.
/// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-001)
/// </summary>
public sealed record AttestationRequestOptions(
/// <summary>Whether to create a signed attestation (required in strict mode).</summary>
bool CreateAttestation,
/// <summary>Whether to anchor the attestation to Rekor transparency log.</summary>
bool AnchorToRekor = false,
/// <summary>Key ID to use for signing (null = default).</summary>
string? SigningKeyId = null,
/// <summary>Storage destination for the attestation.</summary>
string? StorageDestination = null,
/// <summary>Additional metadata to include in the attestation.</summary>
IReadOnlyDictionary<string, string>? AdditionalMetadata = null);
/// <summary>
/// Request to update an existing VEX decision.
@@ -140,7 +208,9 @@ public sealed record UpdateVexDecisionRequest(
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
VexScopeDto? Scope,
ValidForDto? ValidFor,
Guid? SupersedesDecisionId);
Guid? SupersedesDecisionId,
/// <summary>Attestation options for signed override update.</summary>
AttestationRequestOptions? AttestationOptions);
/// <summary>
/// Response for listing VEX decisions.