Merge branch 'worktree-agent-a709f7ea'
# Conflicts: # src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs # src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql
This commit is contained in:
@@ -1,419 +1,33 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Postgres-backed repositories for VulnExplorer adapters (VXLM-005 gap fix).
|
||||
// Replaces ConcurrentDictionary in-memory stores with durable persistence.
|
||||
// <copyright file="VulnExplorerRepositories.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// </copyright>
|
||||
//
|
||||
// Postgres-backed repositories for VulnExplorer triage data.
|
||||
// These replace the ConcurrentDictionary-based stores in VulnExplorer.Api/Data/
|
||||
// when a database connection is available.
|
||||
//
|
||||
// The VulnExplorer.Api service wires these via its own thin adapters
|
||||
// (see VulnExplorer.Api/Data/VexDecisionStore.cs, TriageWorkflowStores.cs).
|
||||
// This file is kept here for colocation with the Findings Ledger migration set
|
||||
// and is Compile-linked into VulnExplorer.Api.csproj.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
// ============================================================================
|
||||
// Interfaces
|
||||
// ============================================================================
|
||||
|
||||
public interface IVexDecisionRepository
|
||||
/// <summary>
|
||||
/// Shared JSON serializer options for VulnExplorer Postgres repositories.
|
||||
/// </summary>
|
||||
internal static class VulnExplorerJsonDefaults
|
||||
{
|
||||
Task<VexDecisionDto> CreateAsync(string tenantId, VexDecisionDto decision, CancellationToken ct = default);
|
||||
Task<VexDecisionDto?> UpdateAsync(string tenantId, Guid id, UpdateVexDecisionRequest request, DateTimeOffset updatedAt, CancellationToken ct = default);
|
||||
Task<VexDecisionDto?> GetAsync(string tenantId, Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<VexDecisionDto>> QueryAsync(string tenantId, string? vulnerabilityId, string? subjectName, VexStatus? status, int skip, int take, CancellationToken ct = default);
|
||||
Task<int> CountAsync(string tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IFixVerificationRepository
|
||||
{
|
||||
Task<FixVerificationRecord> CreateAsync(string tenantId, FixVerificationRecord record, CancellationToken ct = default);
|
||||
Task<FixVerificationRecord?> UpdateAsync(string tenantId, string cveId, string verdict, FixVerificationTransition transition, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IAuditBundleRepository
|
||||
{
|
||||
Task<AuditBundleResponse> CreateAsync(string tenantId, AuditBundleResponse bundle, IReadOnlyList<Guid> decisionIds, CancellationToken ct = default);
|
||||
Task<string> NextBundleIdAsync(string tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON serialization options (shared)
|
||||
// ============================================================================
|
||||
|
||||
internal static class VulnExplorerJsonOptions
|
||||
{
|
||||
internal static readonly JsonSerializerOptions Default = new()
|
||||
internal static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PostgresVexDecisionRepository
|
||||
// ============================================================================
|
||||
|
||||
public sealed class PostgresVexDecisionRepository : IVexDecisionRepository
|
||||
{
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresVexDecisionRepository> _logger;
|
||||
|
||||
public PostgresVexDecisionRepository(LedgerDataSource dataSource, ILogger<PostgresVexDecisionRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<VexDecisionDto> CreateAsync(string tenantId, VexDecisionDto decision, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-write", ct).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO findings.vex_decisions
|
||||
(id, tenant_id, vulnerability_id, subject, status, justification_type,
|
||||
justification_text, evidence_refs, scope, valid_for, attestation_ref,
|
||||
signed_override, supersedes_decision_id, created_by, created_at, updated_at)
|
||||
VALUES
|
||||
(@id, @tenant_id, @vulnerability_id, @subject::jsonb, @status, @justification_type,
|
||||
@justification_text, @evidence_refs::jsonb, @scope::jsonb, @valid_for::jsonb,
|
||||
@attestation_ref::jsonb, @signed_override::jsonb, @supersedes_decision_id,
|
||||
@created_by::jsonb, @created_at, @updated_at)
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("id", decision.Id);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", decision.VulnerabilityId);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("subject", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decision.Subject, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.AddWithValue("status", decision.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("justification_type", decision.JustificationType.ToString());
|
||||
cmd.Parameters.AddWithValue("justification_text", (object?)decision.JustificationText ?? DBNull.Value);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = decision.EvidenceRefs is not null ? JsonSerializer.Serialize(decision.EvidenceRefs, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("scope", NpgsqlDbType.Jsonb) { Value = decision.Scope is not null ? JsonSerializer.Serialize(decision.Scope, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("valid_for", NpgsqlDbType.Jsonb) { Value = decision.ValidFor is not null ? JsonSerializer.Serialize(decision.ValidFor, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("attestation_ref", NpgsqlDbType.Jsonb) { Value = decision.AttestationRef is not null ? JsonSerializer.Serialize(decision.AttestationRef, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("signed_override", NpgsqlDbType.Jsonb) { Value = decision.SignedOverride is not null ? JsonSerializer.Serialize(decision.SignedOverride, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.AddWithValue("supersedes_decision_id", (object?)decision.SupersedesDecisionId ?? DBNull.Value);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("created_by", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decision.CreatedBy, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.AddWithValue("created_at", decision.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updated_at", (object?)decision.UpdatedAt ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Created VEX decision {DecisionId} for tenant {TenantId}", decision.Id, tenantId);
|
||||
return decision;
|
||||
}
|
||||
|
||||
public async Task<VexDecisionDto?> UpdateAsync(string tenantId, Guid id, UpdateVexDecisionRequest request, DateTimeOffset updatedAt, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-write", ct).ConfigureAwait(false);
|
||||
|
||||
// First fetch the existing record
|
||||
var existing = await GetInternalAsync(connection, id, ct).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
|
||||
UpdatedAt = updatedAt
|
||||
};
|
||||
|
||||
const string sql = """
|
||||
UPDATE findings.vex_decisions SET
|
||||
status = @status,
|
||||
justification_type = @justification_type,
|
||||
justification_text = @justification_text,
|
||||
evidence_refs = @evidence_refs::jsonb,
|
||||
scope = @scope::jsonb,
|
||||
valid_for = @valid_for::jsonb,
|
||||
supersedes_decision_id = @supersedes_decision_id,
|
||||
updated_at = @updated_at
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("status", updated.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("justification_type", updated.JustificationType.ToString());
|
||||
cmd.Parameters.AddWithValue("justification_text", (object?)updated.JustificationText ?? DBNull.Value);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = updated.EvidenceRefs is not null ? JsonSerializer.Serialize(updated.EvidenceRefs, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("scope", NpgsqlDbType.Jsonb) { Value = updated.Scope is not null ? JsonSerializer.Serialize(updated.Scope, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("valid_for", NpgsqlDbType.Jsonb) { Value = updated.ValidFor is not null ? JsonSerializer.Serialize(updated.ValidFor, VulnExplorerJsonOptions.Default) : DBNull.Value });
|
||||
cmd.Parameters.AddWithValue("supersedes_decision_id", (object?)updated.SupersedesDecisionId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("updated_at", updatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Updated VEX decision {DecisionId} for tenant {TenantId}", id, tenantId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<VexDecisionDto?> GetAsync(string tenantId, Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
|
||||
return await GetInternalAsync(connection, id, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexDecisionDto>> QueryAsync(
|
||||
string tenantId, string? vulnerabilityId, string? subjectName, VexStatus? status,
|
||||
int skip, int take, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
|
||||
|
||||
var sql = "SELECT id, vulnerability_id, subject, status, justification_type, justification_text, evidence_refs, scope, valid_for, attestation_ref, signed_override, supersedes_decision_id, created_by, created_at, updated_at FROM findings.vex_decisions WHERE 1=1";
|
||||
var parameters = new List<NpgsqlParameter>();
|
||||
|
||||
if (vulnerabilityId is not null)
|
||||
{
|
||||
sql += " AND vulnerability_id = @vulnerability_id";
|
||||
parameters.Add(new NpgsqlParameter("vulnerability_id", vulnerabilityId));
|
||||
}
|
||||
|
||||
if (subjectName is not null)
|
||||
{
|
||||
sql += " AND subject->>'name' ILIKE @subject_name";
|
||||
parameters.Add(new NpgsqlParameter("subject_name", $"%{subjectName}%"));
|
||||
}
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
parameters.Add(new NpgsqlParameter("status", status.Value.ToString()));
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC, id ASC LIMIT @take OFFSET @skip";
|
||||
parameters.Add(new NpgsqlParameter("take", take));
|
||||
parameters.Add(new NpgsqlParameter("skip", skip));
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var results = new List<VexDecisionDto>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapDecision(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
|
||||
|
||||
const string sql = "SELECT COUNT(*) FROM findings.vex_decisions";
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static async Task<VexDecisionDto?> GetInternalAsync(NpgsqlConnection connection, Guid id, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, vulnerability_id, subject, status, justification_type, justification_text,
|
||||
evidence_refs, scope, valid_for, attestation_ref, signed_override,
|
||||
supersedes_decision_id, created_by, created_at, updated_at
|
||||
FROM findings.vex_decisions
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? MapDecision(reader) : null;
|
||||
}
|
||||
|
||||
private static VexDecisionDto MapDecision(NpgsqlDataReader reader)
|
||||
{
|
||||
var statusStr = reader.GetString(reader.GetOrdinal("status"));
|
||||
var justTypeStr = reader.GetString(reader.GetOrdinal("justification_type"));
|
||||
|
||||
return new VexDecisionDto(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
|
||||
Subject: JsonSerializer.Deserialize<SubjectRefDto>(reader.GetString(reader.GetOrdinal("subject")), VulnExplorerJsonOptions.Default)!,
|
||||
Status: Enum.TryParse<VexStatus>(statusStr, ignoreCase: true, out var s) ? s : VexStatus.NotAffected,
|
||||
JustificationType: Enum.TryParse<VexJustificationType>(justTypeStr, ignoreCase: true, out var j) ? j : VexJustificationType.Other,
|
||||
JustificationText: reader.IsDBNull(reader.GetOrdinal("justification_text")) ? null : reader.GetString(reader.GetOrdinal("justification_text")),
|
||||
EvidenceRefs: reader.IsDBNull(reader.GetOrdinal("evidence_refs")) ? null : JsonSerializer.Deserialize<IReadOnlyList<EvidenceRefDto>>(reader.GetString(reader.GetOrdinal("evidence_refs")), VulnExplorerJsonOptions.Default),
|
||||
Scope: reader.IsDBNull(reader.GetOrdinal("scope")) ? null : JsonSerializer.Deserialize<VexScopeDto>(reader.GetString(reader.GetOrdinal("scope")), VulnExplorerJsonOptions.Default),
|
||||
ValidFor: reader.IsDBNull(reader.GetOrdinal("valid_for")) ? null : JsonSerializer.Deserialize<ValidForDto>(reader.GetString(reader.GetOrdinal("valid_for")), VulnExplorerJsonOptions.Default),
|
||||
AttestationRef: reader.IsDBNull(reader.GetOrdinal("attestation_ref")) ? null : JsonSerializer.Deserialize<AttestationRefDto>(reader.GetString(reader.GetOrdinal("attestation_ref")), VulnExplorerJsonOptions.Default),
|
||||
SignedOverride: reader.IsDBNull(reader.GetOrdinal("signed_override")) ? null : JsonSerializer.Deserialize<VexOverrideAttestationDto>(reader.GetString(reader.GetOrdinal("signed_override")), VulnExplorerJsonOptions.Default),
|
||||
SupersedesDecisionId: reader.IsDBNull(reader.GetOrdinal("supersedes_decision_id")) ? null : reader.GetGuid(reader.GetOrdinal("supersedes_decision_id")),
|
||||
CreatedBy: JsonSerializer.Deserialize<ActorRefDto>(reader.GetString(reader.GetOrdinal("created_by")), VulnExplorerJsonOptions.Default)!,
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt: reader.IsDBNull(reader.GetOrdinal("updated_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PostgresFixVerificationRepository
|
||||
// ============================================================================
|
||||
|
||||
public sealed class PostgresFixVerificationRepository : IFixVerificationRepository
|
||||
{
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresFixVerificationRepository> _logger;
|
||||
|
||||
public PostgresFixVerificationRepository(LedgerDataSource dataSource, ILogger<PostgresFixVerificationRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<FixVerificationRecord> CreateAsync(string tenantId, FixVerificationRecord record, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "fix-write", ct).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO findings.fix_verifications
|
||||
(cve_id, tenant_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at)
|
||||
VALUES
|
||||
(@cve_id, @tenant_id, @component_purl, @artifact_digest, @verdict, @transitions::jsonb, @created_at, @updated_at)
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("cve_id", record.CveId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("component_purl", record.ComponentPurl);
|
||||
cmd.Parameters.AddWithValue("artifact_digest", (object?)record.ArtifactDigest ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("verdict", record.Verdict);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("transitions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(record.Transitions, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.AddWithValue("created_at", record.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updated_at", record.UpdatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Created fix verification for CVE {CveId} in tenant {TenantId}", record.CveId, tenantId);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task<FixVerificationRecord?> UpdateAsync(string tenantId, string cveId, string verdict, FixVerificationTransition transition, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "fix-write", ct).ConfigureAwait(false);
|
||||
|
||||
// First fetch existing
|
||||
var existing = await GetInternalAsync(connection, cveId, ct).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var transitions = existing.Transitions.ToList();
|
||||
transitions.Add(transition);
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Verdict = verdict,
|
||||
Transitions = transitions.ToArray(),
|
||||
UpdatedAt = transition.ChangedAt
|
||||
};
|
||||
|
||||
const string sql = """
|
||||
UPDATE findings.fix_verifications SET
|
||||
verdict = @verdict,
|
||||
transitions = @transitions::jsonb,
|
||||
updated_at = @updated_at
|
||||
WHERE cve_id = @cve_id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("cve_id", cveId);
|
||||
cmd.Parameters.AddWithValue("verdict", verdict);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("transitions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(transitions, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.AddWithValue("updated_at", transition.ChangedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Updated fix verification {CveId} to verdict {Verdict} in tenant {TenantId}", cveId, verdict, tenantId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static async Task<FixVerificationRecord?> GetInternalAsync(NpgsqlConnection connection, string cveId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at
|
||||
FROM findings.fix_verifications
|
||||
WHERE cve_id = @cve_id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("cve_id", cveId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FixVerificationRecord(
|
||||
CveId: reader.GetString(reader.GetOrdinal("cve_id")),
|
||||
ComponentPurl: reader.GetString(reader.GetOrdinal("component_purl")),
|
||||
ArtifactDigest: reader.IsDBNull(reader.GetOrdinal("artifact_digest")) ? null : reader.GetString(reader.GetOrdinal("artifact_digest")),
|
||||
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
|
||||
Transitions: JsonSerializer.Deserialize<IReadOnlyList<FixVerificationTransition>>(reader.GetString(reader.GetOrdinal("transitions")), VulnExplorerJsonOptions.Default) ?? [],
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PostgresAuditBundleRepository
|
||||
// ============================================================================
|
||||
|
||||
public sealed class PostgresAuditBundleRepository : IAuditBundleRepository
|
||||
{
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresAuditBundleRepository> _logger;
|
||||
|
||||
public PostgresAuditBundleRepository(LedgerDataSource dataSource, ILogger<PostgresAuditBundleRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AuditBundleResponse> CreateAsync(string tenantId, AuditBundleResponse bundle, IReadOnlyList<Guid> decisionIds, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "audit-write", ct).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO findings.audit_bundles
|
||||
(bundle_id, tenant_id, decision_ids, decisions, evidence_refs, created_at)
|
||||
VALUES
|
||||
(@bundle_id, @tenant_id, @decision_ids::jsonb, @decisions::jsonb, @evidence_refs::jsonb, @created_at)
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("bundle_id", bundle.BundleId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("decision_ids", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decisionIds, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("decisions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(bundle.Decisions, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(bundle.EvidenceRefs, VulnExplorerJsonOptions.Default) });
|
||||
cmd.Parameters.AddWithValue("created_at", bundle.CreatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Created audit bundle {BundleId} for tenant {TenantId}", bundle.BundleId, tenantId);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public async Task<string> NextBundleIdAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "audit-write", ct).ConfigureAwait(false);
|
||||
|
||||
const string sql = "SELECT nextval('findings.audit_bundle_seq')";
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return $"bundle-{Convert.ToInt64(result):D6}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +1,95 @@
|
||||
-- 010_vex_fix_audit_tables.sql
|
||||
-- Create Postgres-backed tables for VulnExplorer adapters merged into Findings Ledger.
|
||||
-- Replaces ConcurrentDictionary in-memory stores (VXLM-005 gap fix).
|
||||
|
||||
SET search_path TO findings, public;
|
||||
-- Migration: 010_vex_fix_audit_tables
|
||||
-- Description: Creates VulnExplorer persistence tables for VEX decisions,
|
||||
-- fix verifications, and audit bundles (replaces in-memory stores).
|
||||
-- Date: 2026-04-08
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- 1. VEX Decisions table
|
||||
-- ============================================
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- 1. VEX Decisions
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS findings.vex_decisions (
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
subject JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
justification_type TEXT NOT NULL,
|
||||
justification_text TEXT,
|
||||
evidence_refs JSONB,
|
||||
scope JSONB,
|
||||
valid_for JSONB,
|
||||
attestation_ref JSONB,
|
||||
signed_override JSONB,
|
||||
supersedes_decision_id UUID,
|
||||
created_by JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS vex_decisions (
|
||||
id UUID NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
subject_type TEXT NOT NULL,
|
||||
subject_name TEXT NOT NULL,
|
||||
subject_digest JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
subject_sbom_node_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
justification_type TEXT NOT NULL,
|
||||
justification_text TEXT,
|
||||
evidence_refs JSONB,
|
||||
scope_environments TEXT[],
|
||||
scope_projects TEXT[],
|
||||
valid_not_before TIMESTAMPTZ,
|
||||
valid_not_after TIMESTAMPTZ,
|
||||
attestation_ref_id TEXT,
|
||||
attestation_ref_digest JSONB,
|
||||
attestation_ref_storage TEXT,
|
||||
signed_override JSONB,
|
||||
supersedes_decision_id UUID,
|
||||
created_by_id TEXT NOT NULL,
|
||||
created_by_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
CONSTRAINT pk_vex_decisions PRIMARY KEY (tenant_id, id)
|
||||
) PARTITION BY LIST (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_decisions_tenant
|
||||
ON findings.vex_decisions (tenant_id);
|
||||
CREATE TABLE IF NOT EXISTS vex_decisions_default PARTITION OF vex_decisions DEFAULT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_decisions_vuln
|
||||
ON findings.vex_decisions (tenant_id, vulnerability_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_vex_decisions_vuln
|
||||
ON vex_decisions (tenant_id, vulnerability_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_vex_decisions_status
|
||||
ON vex_decisions (tenant_id, status);
|
||||
CREATE INDEX IF NOT EXISTS ix_vex_decisions_subject
|
||||
ON vex_decisions (tenant_id, subject_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_decisions_created
|
||||
ON findings.vex_decisions (tenant_id, created_at DESC);
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Fix Verifications
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- ============================================
|
||||
-- 2. Fix Verifications table
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS fix_verifications (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
cve_id TEXT NOT NULL,
|
||||
component_purl TEXT NOT NULL,
|
||||
artifact_digest TEXT,
|
||||
verdict TEXT NOT NULL DEFAULT 'pending',
|
||||
transitions JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT pk_fix_verifications PRIMARY KEY (tenant_id, id),
|
||||
CONSTRAINT uq_fix_verifications_cve UNIQUE (tenant_id, cve_id)
|
||||
) PARTITION BY LIST (tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS findings.fix_verifications (
|
||||
cve_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
component_purl TEXT NOT NULL,
|
||||
artifact_digest TEXT,
|
||||
verdict TEXT NOT NULL DEFAULT 'pending',
|
||||
transitions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, cve_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS fix_verifications_default PARTITION OF fix_verifications DEFAULT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fix_verifications_tenant
|
||||
ON findings.fix_verifications (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_fix_verifications_cve
|
||||
ON fix_verifications (tenant_id, cve_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_fix_verifications_verdict
|
||||
ON fix_verifications (tenant_id, verdict);
|
||||
|
||||
-- ============================================
|
||||
-- 3. Audit Bundles table
|
||||
-- ============================================
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Audit Bundles
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS findings.audit_bundles (
|
||||
bundle_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
decision_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
decisions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, bundle_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_bundles (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_id TEXT NOT NULL,
|
||||
decision_ids UUID[] NOT NULL,
|
||||
attestation_digest TEXT,
|
||||
evidence_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT pk_audit_bundles PRIMARY KEY (tenant_id, id),
|
||||
CONSTRAINT uq_audit_bundles_bundle_id UNIQUE (tenant_id, bundle_id)
|
||||
) PARTITION BY LIST (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_bundles_tenant
|
||||
ON findings.audit_bundles (tenant_id);
|
||||
CREATE TABLE IF NOT EXISTS audit_bundles_default PARTITION OF audit_bundles DEFAULT;
|
||||
|
||||
-- ============================================
|
||||
-- 4. Enable RLS on new tables
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE findings.vex_decisions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.vex_decisions FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS vex_decisions_tenant_isolation ON findings.vex_decisions;
|
||||
CREATE POLICY vex_decisions_tenant_isolation
|
||||
ON findings.vex_decisions
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
|
||||
ALTER TABLE findings.fix_verifications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.fix_verifications FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS fix_verifications_tenant_isolation ON findings.fix_verifications;
|
||||
CREATE POLICY fix_verifications_tenant_isolation
|
||||
ON findings.fix_verifications
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
|
||||
ALTER TABLE findings.audit_bundles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.audit_bundles FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS audit_bundles_tenant_isolation ON findings.audit_bundles;
|
||||
CREATE POLICY audit_bundles_tenant_isolation
|
||||
ON findings.audit_bundles
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
|
||||
-- ============================================
|
||||
-- 5. Sequence for audit bundle IDs
|
||||
-- ============================================
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS findings.audit_bundle_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO CYCLE;
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_bundles_created
|
||||
ON audit_bundles (tenant_id, created_at DESC);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.VulnExplorer.Api.Models;
|
||||
using StellaOps.VulnExplorer.WebService.Contracts;
|
||||
|
||||
@@ -36,19 +39,40 @@ public sealed record FixVerificationRecord(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// FixVerificationStore (Postgres-backed, with in-memory fallback)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class FixVerificationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FixVerificationRecord> records = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ConcurrentDictionary<string, FixVerificationRecord>? _memoryFallback;
|
||||
private readonly NpgsqlDataSource? _dataSource;
|
||||
private readonly ILogger<FixVerificationStore>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private bool UsePostgres => _dataSource is not null;
|
||||
|
||||
/// <summary>Production constructor: Postgres-backed.</summary>
|
||||
public FixVerificationStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<FixVerificationStore> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>Test/offline constructor: in-memory fallback.</summary>
|
||||
public FixVerificationStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_memoryFallback = new(StringComparer.OrdinalIgnoreCase);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public FixVerificationRecord Create(CreateFixVerificationRequest request)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var created = new FixVerificationRecord(
|
||||
CveId: request.CveId,
|
||||
ComponentPurl: request.ComponentPurl,
|
||||
@@ -58,18 +82,46 @@ public sealed class FixVerificationStore
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now);
|
||||
|
||||
records[request.CveId] = created;
|
||||
_memoryFallback?.TryAdd(request.CveId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<FixVerificationRecord> CreateAsync(
|
||||
string tenantId, CreateFixVerificationRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var created = Create(request);
|
||||
if (!UsePostgres) return created;
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO fix_verifications (tenant_id, cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at)
|
||||
VALUES (@tenantId, @cveId, @purl, @digest, 'pending', '[]'::jsonb, @now, @now)
|
||||
ON CONFLICT (tenant_id, cve_id) DO UPDATE SET
|
||||
component_purl = EXCLUDED.component_purl,
|
||||
artifact_digest = EXCLUDED.artifact_digest,
|
||||
verdict = 'pending',
|
||||
transitions = '[]'::jsonb,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("cveId", request.CveId);
|
||||
cmd.Parameters.AddWithValue("purl", request.ComponentPurl);
|
||||
cmd.Parameters.AddWithValue("digest", (object?)request.ArtifactDigest ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", created.CreatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Created fix verification for CVE {CveId}, tenant {Tenant}", request.CveId, tenantId);
|
||||
return created;
|
||||
}
|
||||
|
||||
public FixVerificationRecord? Update(string cveId, string verdict)
|
||||
{
|
||||
if (!records.TryGetValue(cveId, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_memoryFallback is null) return null;
|
||||
if (!_memoryFallback.TryGetValue(cveId, out var existing)) return null;
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var transitions = existing.Transitions.ToList();
|
||||
transitions.Add(new FixVerificationTransition(existing.Verdict, verdict, now));
|
||||
|
||||
@@ -80,25 +132,108 @@ public sealed class FixVerificationStore
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
records[cveId] = updated;
|
||||
_memoryFallback[cveId] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<FixVerificationRecord?> UpdateAsync(
|
||||
string tenantId, string cveId, string verdict, CancellationToken ct = default)
|
||||
{
|
||||
if (!UsePostgres)
|
||||
return Update(cveId, verdict);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Read existing to build transitions
|
||||
const string readSql = """
|
||||
SELECT cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at
|
||||
FROM fix_verifications WHERE tenant_id = @tenantId AND cve_id = @cveId
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var readCmd = new NpgsqlCommand(readSql, conn);
|
||||
readCmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
readCmd.Parameters.AddWithValue("cveId", cveId);
|
||||
|
||||
await using var reader = await readCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(ct).ConfigureAwait(false)) return null;
|
||||
|
||||
var existingVerdict = reader.GetString(reader.GetOrdinal("verdict"));
|
||||
var transitionsJson = reader.GetString(reader.GetOrdinal("transitions"));
|
||||
var transitions = JsonSerializer.Deserialize<List<FixVerificationTransition>>(
|
||||
transitionsJson, VexJsonDefaults.Options) ?? [];
|
||||
var digestOrd = reader.GetOrdinal("artifact_digest");
|
||||
var existing = new FixVerificationRecord(
|
||||
CveId: reader.GetString(reader.GetOrdinal("cve_id")),
|
||||
ComponentPurl: reader.GetString(reader.GetOrdinal("component_purl")),
|
||||
ArtifactDigest: reader.IsDBNull(digestOrd) ? null : reader.GetString(digestOrd),
|
||||
Verdict: existingVerdict,
|
||||
Transitions: transitions,
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")));
|
||||
await reader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
transitions.Add(new FixVerificationTransition(existingVerdict, verdict, now));
|
||||
var newTransitionsJson = JsonSerializer.Serialize(transitions, VexJsonDefaults.Options);
|
||||
|
||||
const string updateSql = """
|
||||
UPDATE fix_verifications SET verdict = @verdict, transitions = @transitions::jsonb, updated_at = @now
|
||||
WHERE tenant_id = @tenantId AND cve_id = @cveId
|
||||
""";
|
||||
|
||||
await using var updateCmd = new NpgsqlCommand(updateSql, conn);
|
||||
updateCmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
updateCmd.Parameters.AddWithValue("cveId", cveId);
|
||||
updateCmd.Parameters.AddWithValue("verdict", verdict);
|
||||
updateCmd.Parameters.AddWithValue("transitions", newTransitionsJson);
|
||||
updateCmd.Parameters.AddWithValue("now", now);
|
||||
await updateCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger?.LogDebug("Updated fix verification {CveId} -> {Verdict}", cveId, verdict);
|
||||
|
||||
return existing with
|
||||
{
|
||||
Verdict = verdict,
|
||||
Transitions = transitions.ToArray(),
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// AuditBundleStore (Postgres-backed, with in-memory fallback)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class AuditBundleStore
|
||||
{
|
||||
private int sequence;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private int _sequence;
|
||||
private readonly NpgsqlDataSource? _dataSource;
|
||||
private readonly ILogger<AuditBundleStore>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private bool UsePostgres => _dataSource is not null;
|
||||
|
||||
/// <summary>Production constructor: Postgres-backed.</summary>
|
||||
public AuditBundleStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<AuditBundleStore> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>Test/offline constructor: in-memory fallback.</summary>
|
||||
public AuditBundleStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public AuditBundleResponse Create(string tenant, IReadOnlyList<VexDecisionDto> decisions)
|
||||
{
|
||||
var next = Interlocked.Increment(ref sequence);
|
||||
var createdAt = timeProvider.GetUtcNow();
|
||||
var next = Interlocked.Increment(ref _sequence);
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
var evidenceRefs = decisions
|
||||
.SelectMany(x => x.EvidenceRefs ?? Array.Empty<EvidenceRefDto>())
|
||||
.Select(x => x.Url.ToString())
|
||||
@@ -113,8 +248,41 @@ public sealed class AuditBundleStore
|
||||
Decisions: decisions.OrderBy(x => x.Id).ToArray(),
|
||||
EvidenceRefs: evidenceRefs);
|
||||
}
|
||||
|
||||
public async Task<AuditBundleResponse> CreateAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<VexDecisionDto> decisions,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var bundle = Create(tenantId, decisions);
|
||||
if (!UsePostgres) return bundle;
|
||||
|
||||
var decisionIds = decisions.Select(d => d.Id).OrderBy(x => x).ToArray();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO audit_bundles (tenant_id, bundle_id, decision_ids, evidence_refs, created_at)
|
||||
VALUES (@tenantId, @bundleId, @decisionIds, @evidenceRefs, @createdAt)
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("bundleId", bundle.BundleId);
|
||||
cmd.Parameters.AddWithValue("decisionIds", decisionIds);
|
||||
cmd.Parameters.AddWithValue("evidenceRefs", bundle.EvidenceRefs.ToArray());
|
||||
cmd.Parameters.AddWithValue("createdAt", bundle.CreatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Created audit bundle {BundleId} with {Count} decisions", bundle.BundleId, decisions.Count);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// EvidenceSubgraphStore (unchanged -- still builds synthetic response)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class EvidenceSubgraphStore
|
||||
{
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
@@ -1,31 +1,80 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VulnExplorer.Api.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.VulnExplorer.Api.Data;
|
||||
|
||||
internal static class VexJsonDefaults
|
||||
{
|
||||
internal static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory VEX decision store for development/testing.
|
||||
/// Production would use PostgreSQL repository.
|
||||
/// Postgres-backed VEX decision store.
|
||||
/// Falls back to in-memory ConcurrentDictionary when no NpgsqlDataSource is registered
|
||||
/// (e.g. in unit tests).
|
||||
/// </summary>
|
||||
public sealed class VexDecisionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, VexDecisionDto> _decisions = new();
|
||||
// ── fallback in-memory path (tests only) ───────────────────────────
|
||||
private readonly ConcurrentDictionary<Guid, VexDecisionDto>? _memoryFallback;
|
||||
|
||||
// ── postgres path ──────────────────────────────────────────────────
|
||||
private readonly NpgsqlDataSource? _dataSource;
|
||||
private readonly ILogger<VexDecisionStore>? _logger;
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly IVexOverrideAttestorClient? _attestorClient;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: Postgres-backed.
|
||||
/// </summary>
|
||||
public VexDecisionStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<VexDecisionStore> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
IVexOverrideAttestorClient? attestorClient = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_attestorClient = attestorClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test/offline constructor: in-memory fallback.
|
||||
/// </summary>
|
||||
public VexDecisionStore(
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
IVexOverrideAttestorClient? attestorClient = null)
|
||||
{
|
||||
_memoryFallback = new();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_attestorClient = attestorClient;
|
||||
}
|
||||
|
||||
private bool UsePostgres => _dataSource is not null;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Synchronous helpers (kept for backwards-compat with existing endpoints)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
|
||||
{
|
||||
var id = _guidProvider.NewGuid();
|
||||
@@ -41,23 +90,25 @@ public sealed class VexDecisionStore
|
||||
EvidenceRefs: request.EvidenceRefs,
|
||||
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)
|
||||
AttestationRef: null,
|
||||
SignedOverride: null,
|
||||
SupersedesDecisionId: request.SupersedesDecisionId,
|
||||
CreatedBy: new ActorRefDto(userId, userDisplayName),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: null);
|
||||
|
||||
_decisions[id] = decision;
|
||||
if (_memoryFallback is not null)
|
||||
{
|
||||
_memoryFallback[id] = decision;
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
public VexDecisionDto? Update(Guid id, UpdateVexDecisionRequest request)
|
||||
{
|
||||
if (!_decisions.TryGetValue(id, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_memoryFallback is null) return null; // sync path not supported in PG mode
|
||||
if (!_memoryFallback.TryGetValue(id, out var existing)) return null;
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
@@ -71,12 +122,16 @@ public sealed class VexDecisionStore
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_decisions[id] = updated;
|
||||
_memoryFallback[id] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
public VexDecisionDto? Get(Guid id) =>
|
||||
_decisions.TryGetValue(id, out var decision) ? decision : null;
|
||||
public VexDecisionDto? Get(Guid id)
|
||||
{
|
||||
if (_memoryFallback is not null)
|
||||
return _memoryFallback.TryGetValue(id, out var decision) ? decision : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<VexDecisionDto> Query(
|
||||
string? vulnerabilityId = null,
|
||||
@@ -85,24 +140,16 @@ public sealed class VexDecisionStore
|
||||
int skip = 0,
|
||||
int take = 50)
|
||||
{
|
||||
IEnumerable<VexDecisionDto> query = _decisions.Values;
|
||||
if (_memoryFallback is null) return [];
|
||||
|
||||
IEnumerable<VexDecisionDto> query = _memoryFallback.Values;
|
||||
if (vulnerabilityId is not null)
|
||||
{
|
||||
query = query.Where(d => string.Equals(d.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (subjectName is not null)
|
||||
{
|
||||
query = query.Where(d => d.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
query = query.Where(d => d.Status == status);
|
||||
}
|
||||
|
||||
// Deterministic ordering: createdAt desc, id asc
|
||||
return query
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.ThenBy(d => d.Id)
|
||||
@@ -111,18 +158,195 @@ public sealed class VexDecisionStore
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public int Count() => _decisions.Count;
|
||||
public int Count() => _memoryFallback?.Count ?? 0;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Async Postgres-backed methods
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
public async Task<VexDecisionDto> CreateAsync(
|
||||
string tenantId,
|
||||
CreateVexDecisionRequest request,
|
||||
string userId,
|
||||
string userDisplayName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var decision = Create(request, userId, userDisplayName);
|
||||
if (UsePostgres)
|
||||
{
|
||||
await InsertRowAsync(tenantId, decision, ct).ConfigureAwait(false);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
public async Task<VexDecisionDto?> UpdateAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
UpdateVexDecisionRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!UsePostgres)
|
||||
return Update(id, request);
|
||||
|
||||
var existing = await GetAsync(tenantId, id, ct).ConfigureAwait(false);
|
||||
if (existing is null) return null;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
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,
|
||||
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
const string sql = """
|
||||
UPDATE vex_decisions SET
|
||||
status = @status,
|
||||
justification_type = @justificationType,
|
||||
justification_text = @justificationText,
|
||||
evidence_refs = @evidenceRefs,
|
||||
scope_environments = @scopeEnvs,
|
||||
scope_projects = @scopeProjects,
|
||||
valid_not_before = @validNotBefore,
|
||||
valid_not_after = @validNotAfter,
|
||||
supersedes_decision_id = @supersedesId,
|
||||
updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("status", updated.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("justificationType", updated.JustificationType.ToString());
|
||||
cmd.Parameters.AddWithValue("justificationText", (object?)updated.JustificationText ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("evidenceRefs", NpgsqlDbType.Jsonb,
|
||||
updated.EvidenceRefs is not null
|
||||
? JsonSerializer.Serialize(updated.EvidenceRefs, VexJsonDefaults.Options)
|
||||
: (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("scopeEnvs",
|
||||
(object?)updated.Scope?.Environments?.ToArray() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("scopeProjects",
|
||||
(object?)updated.Scope?.Projects?.ToArray() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("validNotBefore",
|
||||
(object?)updated.ValidFor?.NotBefore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("validNotAfter",
|
||||
(object?)updated.ValidFor?.NotAfter ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("supersedesId",
|
||||
(object?)updated.SupersedesDecisionId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("updatedAt", now);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task<VexDecisionDto?> GetAsync(
|
||||
string tenantId, Guid id, CancellationToken ct = default)
|
||||
{
|
||||
if (!UsePostgres)
|
||||
return Get(id);
|
||||
|
||||
const string sql = """
|
||||
SELECT id, vulnerability_id, subject_type, subject_name, subject_digest,
|
||||
subject_sbom_node_id, status, justification_type, justification_text,
|
||||
evidence_refs, scope_environments, scope_projects,
|
||||
valid_not_before, valid_not_after,
|
||||
attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
|
||||
signed_override, supersedes_decision_id,
|
||||
created_by_id, created_by_name, created_at, updated_at
|
||||
FROM vex_decisions
|
||||
WHERE tenant_id = @tenantId AND id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? MapDecision(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<VexDecisionDto> Items, int TotalCount)> QueryAsync(
|
||||
string tenantId,
|
||||
string? vulnerabilityId = null,
|
||||
string? subjectName = null,
|
||||
VexStatus? status = null,
|
||||
int skip = 0,
|
||||
int take = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!UsePostgres)
|
||||
{
|
||||
var items = Query(vulnerabilityId, subjectName, status, skip, take);
|
||||
return (items, Count());
|
||||
}
|
||||
|
||||
var whereClauses = new List<string> { "tenant_id = @tenantId" };
|
||||
if (vulnerabilityId is not null) whereClauses.Add("vulnerability_id = @vulnId");
|
||||
if (subjectName is not null) whereClauses.Add("subject_name ILIKE @subjectName");
|
||||
if (status is not null) whereClauses.Add("status = @status");
|
||||
|
||||
var where = string.Join(" AND ", whereClauses);
|
||||
var countSql = $"SELECT COUNT(*) FROM vex_decisions WHERE {where}";
|
||||
var querySql = $"""
|
||||
SELECT id, vulnerability_id, subject_type, subject_name, subject_digest,
|
||||
subject_sbom_node_id, status, justification_type, justification_text,
|
||||
evidence_refs, scope_environments, scope_projects,
|
||||
valid_not_before, valid_not_after,
|
||||
attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
|
||||
signed_override, supersedes_decision_id,
|
||||
created_by_id, created_by_name, created_at, updated_at
|
||||
FROM vex_decisions
|
||||
WHERE {where}
|
||||
ORDER BY created_at DESC, id ASC
|
||||
OFFSET @skip LIMIT @take
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var countCmd = new NpgsqlCommand(countSql, conn);
|
||||
AddFilterParams(countCmd, tenantId, vulnerabilityId, subjectName, status);
|
||||
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
|
||||
|
||||
await using var queryCmd = new NpgsqlCommand(querySql, conn);
|
||||
AddFilterParams(queryCmd, tenantId, vulnerabilityId, subjectName, status);
|
||||
queryCmd.Parameters.AddWithValue("skip", skip);
|
||||
queryCmd.Parameters.AddWithValue("take", take);
|
||||
|
||||
var results = new List<VexDecisionDto>();
|
||||
await using var reader = await queryCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapDecision(reader));
|
||||
}
|
||||
|
||||
return (results, totalCount);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
return await CreateWithAttestationAsync(null, request, userId, userDisplayName, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
|
||||
string? tenantId,
|
||||
CreateVexDecisionRequest request,
|
||||
string userId,
|
||||
string userDisplayName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = _guidProvider.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
@@ -130,7 +354,6 @@ public sealed class VexDecisionStore
|
||||
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
|
||||
@@ -151,7 +374,6 @@ public sealed class VexDecisionStore
|
||||
};
|
||||
|
||||
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
|
||||
|
||||
if (attestationResult.Success && attestationResult.Attestation is not null)
|
||||
{
|
||||
signedOverride = attestationResult.Attestation;
|
||||
@@ -175,13 +397,15 @@ public sealed class VexDecisionStore
|
||||
CreatedAt: now,
|
||||
UpdatedAt: null);
|
||||
|
||||
_decisions[id] = decision;
|
||||
if (_memoryFallback is not null)
|
||||
_memoryFallback[id] = decision;
|
||||
|
||||
if (UsePostgres && tenantId is not null)
|
||||
await InsertRowAsync(tenantId, decision, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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,
|
||||
@@ -189,56 +413,238 @@ public sealed class VexDecisionStore
|
||||
string userDisplayName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_decisions.TryGetValue(id, out var existing))
|
||||
// In-memory fallback path
|
||||
if (_memoryFallback is not null)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
if (!_memoryFallback.TryGetValue(id, out var existing))
|
||||
return (null, null);
|
||||
|
||||
VexOverrideAttestationDto? signedOverride = existing.SignedOverride;
|
||||
VexOverrideAttestationResult? attestationResult = 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
|
||||
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
|
||||
{
|
||||
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
|
||||
SignedOverride = signedOverride,
|
||||
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
|
||||
|
||||
if (attestationResult.Success && attestationResult.Attestation is not null)
|
||||
{
|
||||
signedOverride = attestationResult.Attestation;
|
||||
}
|
||||
_memoryFallback[id] = updated;
|
||||
return (updated, attestationResult);
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
_decisions[id] = updated;
|
||||
return (updated, attestationResult);
|
||||
// ── Private Postgres helpers ────────────────────────────────────────
|
||||
|
||||
private async Task InsertRowAsync(string tenantId, VexDecisionDto d, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vex_decisions (
|
||||
id, tenant_id, vulnerability_id,
|
||||
subject_type, subject_name, subject_digest, subject_sbom_node_id,
|
||||
status, justification_type, justification_text,
|
||||
evidence_refs, scope_environments, scope_projects,
|
||||
valid_not_before, valid_not_after,
|
||||
attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
|
||||
signed_override, supersedes_decision_id,
|
||||
created_by_id, created_by_name, created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @tenantId, @vulnId,
|
||||
@subjectType, @subjectName, @subjectDigest, @subjectSbomNodeId,
|
||||
@status, @justificationType, @justificationText,
|
||||
@evidenceRefs, @scopeEnvs, @scopeProjects,
|
||||
@validNotBefore, @validNotAfter,
|
||||
@attestRefId, @attestRefDigest, @attestRefStorage,
|
||||
@signedOverride, @supersedesId,
|
||||
@createdById, @createdByName, @createdAt, @updatedAt
|
||||
)
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", d.Id);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("vulnId", d.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("subjectType", d.Subject.Type.ToString());
|
||||
cmd.Parameters.AddWithValue("subjectName", d.Subject.Name);
|
||||
cmd.Parameters.AddWithValue("subjectDigest", NpgsqlDbType.Jsonb,
|
||||
JsonSerializer.Serialize(d.Subject.Digest, VexJsonDefaults.Options));
|
||||
cmd.Parameters.AddWithValue("subjectSbomNodeId", (object?)d.Subject.SbomNodeId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", d.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("justificationType", d.JustificationType.ToString());
|
||||
cmd.Parameters.AddWithValue("justificationText", (object?)d.JustificationText ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("evidenceRefs", NpgsqlDbType.Jsonb,
|
||||
d.EvidenceRefs is not null
|
||||
? JsonSerializer.Serialize(d.EvidenceRefs, VexJsonDefaults.Options)
|
||||
: (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("scopeEnvs",
|
||||
(object?)d.Scope?.Environments?.ToArray() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("scopeProjects",
|
||||
(object?)d.Scope?.Projects?.ToArray() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("validNotBefore",
|
||||
(object?)d.ValidFor?.NotBefore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("validNotAfter",
|
||||
(object?)d.ValidFor?.NotAfter ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("attestRefId",
|
||||
(object?)d.AttestationRef?.Id ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("attestRefDigest", NpgsqlDbType.Jsonb,
|
||||
d.AttestationRef?.Digest is not null
|
||||
? JsonSerializer.Serialize(d.AttestationRef.Digest, VexJsonDefaults.Options)
|
||||
: (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("attestRefStorage",
|
||||
(object?)d.AttestationRef?.Storage ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("signedOverride", NpgsqlDbType.Jsonb,
|
||||
d.SignedOverride is not null
|
||||
? JsonSerializer.Serialize(d.SignedOverride, VexJsonDefaults.Options)
|
||||
: (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("supersedesId",
|
||||
(object?)d.SupersedesDecisionId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("createdById", d.CreatedBy.Id);
|
||||
cmd.Parameters.AddWithValue("createdByName", d.CreatedBy.DisplayName);
|
||||
cmd.Parameters.AddWithValue("createdAt", d.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updatedAt", (object?)d.UpdatedAt ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Inserted VEX decision {Id} for tenant {Tenant}", d.Id, tenantId);
|
||||
}
|
||||
|
||||
private static void AddFilterParams(
|
||||
NpgsqlCommand cmd, string tenantId,
|
||||
string? vulnerabilityId, string? subjectName, VexStatus? status)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
if (vulnerabilityId is not null)
|
||||
cmd.Parameters.AddWithValue("vulnId", vulnerabilityId);
|
||||
if (subjectName is not null)
|
||||
cmd.Parameters.AddWithValue("subjectName", $"%{subjectName}%");
|
||||
if (status is not null)
|
||||
cmd.Parameters.AddWithValue("status", status.Value.ToString());
|
||||
}
|
||||
|
||||
private static VexDecisionDto MapDecision(NpgsqlDataReader r)
|
||||
{
|
||||
var subjectDigest = JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(
|
||||
r.GetString(r.GetOrdinal("subject_digest")),
|
||||
VexJsonDefaults.Options) ?? new Dictionary<string, string>();
|
||||
|
||||
var subjectType = Enum.TryParse<SubjectType>(r.GetString(r.GetOrdinal("subject_type")), true, out var st)
|
||||
? st : SubjectType.Other;
|
||||
|
||||
IReadOnlyList<EvidenceRefDto>? evidenceRefs = null;
|
||||
var evidenceRefsOrd = r.GetOrdinal("evidence_refs");
|
||||
if (!r.IsDBNull(evidenceRefsOrd))
|
||||
{
|
||||
evidenceRefs = JsonSerializer.Deserialize<IReadOnlyList<EvidenceRefDto>>(
|
||||
r.GetString(evidenceRefsOrd), VexJsonDefaults.Options);
|
||||
}
|
||||
|
||||
VexScopeDto? scope = null;
|
||||
var scopeEnvsOrd = r.GetOrdinal("scope_environments");
|
||||
var scopeProjOrd = r.GetOrdinal("scope_projects");
|
||||
if (!r.IsDBNull(scopeEnvsOrd) || !r.IsDBNull(scopeProjOrd))
|
||||
{
|
||||
scope = new VexScopeDto(
|
||||
Environments: r.IsDBNull(scopeEnvsOrd) ? null : ((string[])r.GetValue(scopeEnvsOrd)).ToList(),
|
||||
Projects: r.IsDBNull(scopeProjOrd) ? null : ((string[])r.GetValue(scopeProjOrd)).ToList());
|
||||
}
|
||||
|
||||
ValidForDto? validFor = null;
|
||||
var notBeforeOrd = r.GetOrdinal("valid_not_before");
|
||||
var notAfterOrd = r.GetOrdinal("valid_not_after");
|
||||
if (!r.IsDBNull(notBeforeOrd) || !r.IsDBNull(notAfterOrd))
|
||||
{
|
||||
validFor = new ValidForDto(
|
||||
NotBefore: r.IsDBNull(notBeforeOrd) ? null : r.GetFieldValue<DateTimeOffset>(notBeforeOrd),
|
||||
NotAfter: r.IsDBNull(notAfterOrd) ? null : r.GetFieldValue<DateTimeOffset>(notAfterOrd));
|
||||
}
|
||||
|
||||
AttestationRefDto? attestationRef = null;
|
||||
var attestRefIdOrd = r.GetOrdinal("attestation_ref_id");
|
||||
if (!r.IsDBNull(attestRefIdOrd))
|
||||
{
|
||||
var attestDigestOrd = r.GetOrdinal("attestation_ref_digest");
|
||||
var attestStorageOrd = r.GetOrdinal("attestation_ref_storage");
|
||||
attestationRef = new AttestationRefDto(
|
||||
Id: r.GetString(attestRefIdOrd),
|
||||
Digest: r.IsDBNull(attestDigestOrd) ? null
|
||||
: JsonSerializer.Deserialize<IReadOnlyDictionary<string, string>>(
|
||||
r.GetString(attestDigestOrd), VexJsonDefaults.Options),
|
||||
Storage: r.IsDBNull(attestStorageOrd) ? null : r.GetString(attestStorageOrd));
|
||||
}
|
||||
|
||||
VexOverrideAttestationDto? signedOverride = null;
|
||||
var signedOverrideOrd = r.GetOrdinal("signed_override");
|
||||
if (!r.IsDBNull(signedOverrideOrd))
|
||||
{
|
||||
signedOverride = JsonSerializer.Deserialize<VexOverrideAttestationDto>(
|
||||
r.GetString(signedOverrideOrd), VexJsonDefaults.Options);
|
||||
}
|
||||
|
||||
var statusStr = r.GetString(r.GetOrdinal("status"));
|
||||
var vexStatus = Enum.TryParse<VexStatus>(statusStr, true, out var vs) ? vs : VexStatus.AffectedUnmitigated;
|
||||
|
||||
var justTypeStr = r.GetString(r.GetOrdinal("justification_type"));
|
||||
var justType = Enum.TryParse<VexJustificationType>(justTypeStr, true, out var jt) ? jt : VexJustificationType.Other;
|
||||
|
||||
var sbomNodeOrd = r.GetOrdinal("subject_sbom_node_id");
|
||||
var supersedesOrd = r.GetOrdinal("supersedes_decision_id");
|
||||
var updatedAtOrd = r.GetOrdinal("updated_at");
|
||||
var justTextOrd = r.GetOrdinal("justification_text");
|
||||
|
||||
return new VexDecisionDto(
|
||||
Id: r.GetGuid(r.GetOrdinal("id")),
|
||||
VulnerabilityId: r.GetString(r.GetOrdinal("vulnerability_id")),
|
||||
Subject: new SubjectRefDto(
|
||||
Type: subjectType,
|
||||
Name: r.GetString(r.GetOrdinal("subject_name")),
|
||||
Digest: subjectDigest,
|
||||
SbomNodeId: r.IsDBNull(sbomNodeOrd) ? null : r.GetString(sbomNodeOrd)),
|
||||
Status: vexStatus,
|
||||
JustificationType: justType,
|
||||
JustificationText: r.IsDBNull(justTextOrd) ? null : r.GetString(justTextOrd),
|
||||
EvidenceRefs: evidenceRefs,
|
||||
Scope: scope,
|
||||
ValidFor: validFor,
|
||||
AttestationRef: attestationRef,
|
||||
SignedOverride: signedOverride,
|
||||
SupersedesDecisionId: r.IsDBNull(supersedesOrd) ? null : r.GetGuid(supersedesOrd),
|
||||
CreatedBy: new ActorRefDto(
|
||||
r.GetString(r.GetOrdinal("created_by_id")),
|
||||
r.GetString(r.GetOrdinal("created_by_name"))),
|
||||
CreatedAt: r.GetFieldValue<DateTimeOffset>(r.GetOrdinal("created_at")),
|
||||
UpdatedAt: r.IsDBNull(updatedAtOrd) ? null : r.GetFieldValue<DateTimeOffset>(updatedAtOrd));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Npgsql;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
@@ -30,11 +31,43 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
});
|
||||
|
||||
// ── Postgres data source (optional -- falls back to in-memory if no connection string) ──
|
||||
var connectionString = builder.Configuration.GetConnectionString("Default");
|
||||
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
var npgsqlBuilder = new NpgsqlDataSourceBuilder(connectionString)
|
||||
{
|
||||
Name = "vulnexplorer"
|
||||
};
|
||||
var dataSource = npgsqlBuilder.Build();
|
||||
builder.Services.AddSingleton(dataSource);
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<IVexOverrideAttestorClient, StubVexOverrideAttestorClient>();
|
||||
|
||||
// Wire stores: use Postgres when NpgsqlDataSource is registered, else in-memory
|
||||
builder.Services.AddSingleton<VexDecisionStore>(sp =>
|
||||
new VexDecisionStore(attestorClient: sp.GetRequiredService<IVexOverrideAttestorClient>()));
|
||||
builder.Services.AddSingleton<FixVerificationStore>();
|
||||
builder.Services.AddSingleton<AuditBundleStore>();
|
||||
{
|
||||
var ds = sp.GetService<NpgsqlDataSource>();
|
||||
var attestorClient = sp.GetRequiredService<IVexOverrideAttestorClient>();
|
||||
if (ds is not null)
|
||||
return new VexDecisionStore(ds, sp.GetRequiredService<ILogger<VexDecisionStore>>(), attestorClient: attestorClient);
|
||||
return new VexDecisionStore(attestorClient: attestorClient);
|
||||
});
|
||||
builder.Services.AddSingleton<FixVerificationStore>(sp =>
|
||||
{
|
||||
var ds = sp.GetService<NpgsqlDataSource>();
|
||||
if (ds is not null)
|
||||
return new FixVerificationStore(ds, sp.GetRequiredService<ILogger<FixVerificationStore>>());
|
||||
return new FixVerificationStore();
|
||||
});
|
||||
builder.Services.AddSingleton<AuditBundleStore>(sp =>
|
||||
{
|
||||
var ds = sp.GetService<NpgsqlDataSource>();
|
||||
if (ds is not null)
|
||||
return new AuditBundleStore(ds, sp.GetRequiredService<ILogger<AuditBundleStore>>());
|
||||
return new AuditBundleStore();
|
||||
});
|
||||
builder.Services.AddSingleton<EvidenceSubgraphStore>();
|
||||
|
||||
// Authentication and authorization
|
||||
@@ -145,6 +178,7 @@ app.MapPost("/v1/vex-decisions", async (
|
||||
if (request.AttestationOptions?.CreateAttestation == true)
|
||||
{
|
||||
var result = await store.CreateWithAttestationAsync(
|
||||
tenant,
|
||||
request,
|
||||
effectiveUserId,
|
||||
effectiveUserName,
|
||||
@@ -153,7 +187,7 @@ app.MapPost("/v1/vex-decisions", async (
|
||||
}
|
||||
else
|
||||
{
|
||||
decision = store.Create(request, effectiveUserId, effectiveUserName);
|
||||
decision = await store.CreateAsync(tenant, request, effectiveUserId, effectiveUserName, cancellationToken);
|
||||
}
|
||||
|
||||
return Results.Created($"/v1/vex-decisions/{decision.Id}", decision);
|
||||
@@ -163,18 +197,19 @@ app.MapPost("/v1/vex-decisions", async (
|
||||
.RequireAuthorization(VulnExplorerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapPatch("/v1/vex-decisions/{id:guid}", (
|
||||
app.MapPatch("/v1/vex-decisions/{id:guid}", async (
|
||||
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||
Guid id,
|
||||
[FromBody] UpdateVexDecisionRequest request,
|
||||
VexDecisionStore store) =>
|
||||
VexDecisionStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
|
||||
}
|
||||
|
||||
var updated = store.Update(id, request);
|
||||
var updated = await store.UpdateAsync(tenant, id, request, cancellationToken);
|
||||
return updated is not null
|
||||
? Results.Ok(updated)
|
||||
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
|
||||
@@ -184,7 +219,10 @@ app.MapPatch("/v1/vex-decisions/{id:guid}", (
|
||||
.RequireAuthorization(VulnExplorerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDecisionStore store) =>
|
||||
app.MapGet("/v1/vex-decisions", async (
|
||||
[AsParameters] VexDecisionFilter filter,
|
||||
VexDecisionStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter.Tenant))
|
||||
{
|
||||
@@ -194,15 +232,17 @@ app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDec
|
||||
var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200);
|
||||
var offset = ParsePageToken(filter.PageToken);
|
||||
|
||||
var decisions = store.Query(
|
||||
var (decisions, totalCount) = await store.QueryAsync(
|
||||
tenantId: filter.Tenant,
|
||||
vulnerabilityId: filter.VulnerabilityId,
|
||||
subjectName: filter.Subject,
|
||||
status: filter.Status,
|
||||
skip: offset,
|
||||
take: pageSize);
|
||||
take: pageSize,
|
||||
ct: cancellationToken);
|
||||
|
||||
var nextOffset = offset + decisions.Count;
|
||||
var next = nextOffset < store.Count() ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
|
||||
var next = nextOffset < totalCount ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
|
||||
|
||||
return Results.Ok(new VexDecisionListResponse(decisions, next));
|
||||
})
|
||||
@@ -211,17 +251,18 @@ app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDec
|
||||
.RequireAuthorization(VulnExplorerPolicies.View)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/v1/vex-decisions/{id:guid}", (
|
||||
app.MapGet("/v1/vex-decisions/{id:guid}", async (
|
||||
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||
Guid id,
|
||||
VexDecisionStore store) =>
|
||||
VexDecisionStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
|
||||
}
|
||||
|
||||
var decision = store.Get(id);
|
||||
var decision = await store.GetAsync(tenant, id, cancellationToken);
|
||||
return decision is not null
|
||||
? Results.Ok(decision)
|
||||
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
|
||||
@@ -254,10 +295,36 @@ app.MapGet("/v1/evidence-subgraph/{vulnId}", (
|
||||
.RequireAuthorization(VulnExplorerPolicies.View)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapPost("/v1/fix-verifications", (
|
||||
// Route alias: the UI calls /api/vuln-explorer/findings/{vulnId}/evidence-subgraph
|
||||
// and the gateway forwards it as-is to the service.
|
||||
app.MapGet("/api/vuln-explorer/findings/{vulnId}/evidence-subgraph", (
|
||||
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||
string vulnId,
|
||||
EvidenceSubgraphStore store) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.vuln_id_required") });
|
||||
}
|
||||
|
||||
EvidenceSubgraphResponse response = store.Build(vulnId);
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithName("GetEvidenceSubgraphAlias")
|
||||
.WithDescription(_t("vulnexplorer.evidence_subgraph.get_description"))
|
||||
.RequireAuthorization(VulnExplorerPolicies.View)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapPost("/v1/fix-verifications", async (
|
||||
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||
[FromBody] CreateFixVerificationRequest request,
|
||||
FixVerificationStore store) =>
|
||||
FixVerificationStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
@@ -269,7 +336,7 @@ app.MapPost("/v1/fix-verifications", (
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.cve_id_and_purl_required") });
|
||||
}
|
||||
|
||||
var created = store.Create(request);
|
||||
var created = await store.CreateAsync(tenant, request, cancellationToken);
|
||||
return Results.Created($"/v1/fix-verifications/{created.CveId}", created);
|
||||
})
|
||||
.WithName("CreateFixVerification")
|
||||
@@ -277,11 +344,12 @@ app.MapPost("/v1/fix-verifications", (
|
||||
.RequireAuthorization(VulnExplorerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapPatch("/v1/fix-verifications/{cveId}", (
|
||||
app.MapPatch("/v1/fix-verifications/{cveId}", async (
|
||||
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||
string cveId,
|
||||
[FromBody] UpdateFixVerificationRequest request,
|
||||
FixVerificationStore store) =>
|
||||
FixVerificationStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
@@ -293,7 +361,7 @@ app.MapPatch("/v1/fix-verifications/{cveId}", (
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.verdict_required") });
|
||||
}
|
||||
|
||||
var updated = store.Update(cveId, request.Verdict);
|
||||
var updated = await store.UpdateAsync(tenant, cveId, request.Verdict, cancellationToken);
|
||||
return updated is not null
|
||||
? Results.Ok(updated)
|
||||
: Results.NotFound(new { error = _t("vulnexplorer.error.fix_verification_not_found", cveId) });
|
||||
@@ -303,11 +371,12 @@ app.MapPatch("/v1/fix-verifications/{cveId}", (
|
||||
.RequireAuthorization(VulnExplorerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
app.MapPost("/v1/audit-bundles", (
|
||||
app.MapPost("/v1/audit-bundles", async (
|
||||
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||
[FromBody] CreateAuditBundleRequest request,
|
||||
VexDecisionStore decisions,
|
||||
AuditBundleStore bundles) =>
|
||||
AuditBundleStore bundles,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
@@ -319,18 +388,19 @@ app.MapPost("/v1/audit-bundles", (
|
||||
return Results.BadRequest(new { error = _t("vulnexplorer.error.decision_ids_required") });
|
||||
}
|
||||
|
||||
var selected = request.DecisionIds
|
||||
.Select(id => decisions.Get(id))
|
||||
.Where(x => x is not null)
|
||||
.Cast<VexDecisionDto>()
|
||||
.ToArray();
|
||||
var selected = new List<VexDecisionDto>();
|
||||
foreach (var id in request.DecisionIds)
|
||||
{
|
||||
var d = await decisions.GetAsync(tenant, id, cancellationToken);
|
||||
if (d is not null) selected.Add(d);
|
||||
}
|
||||
|
||||
if (selected.Length == 0)
|
||||
if (selected.Count == 0)
|
||||
{
|
||||
return Results.NotFound(new { error = _t("vulnexplorer.error.no_decisions_found") });
|
||||
}
|
||||
|
||||
var bundle = bundles.Create(tenant, selected);
|
||||
var bundle = await bundles.CreateAsync(tenant, selected, cancellationToken);
|
||||
return Results.Created($"/v1/audit-bundles/{bundle.BundleId}", bundle);
|
||||
})
|
||||
.WithName("CreateAuditBundle")
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user