Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs
master f5a9f874d0 feat(audit): wire AddAuditEmission into 9 services (AUDIT-002)
- Wire StellaOps.Audit.Emission DI in: Authority, Policy, Release-Orchestrator,
  EvidenceLocker, Notify, Scanner, Scheduler, Integrations, Platform
- Add AuditEmission__TimelineBaseUrl to compose defaults
- Endpoint filter annotation deferred to follow-up pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:20:39 +03:00

420 lines
23 KiB
C#

// 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.
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;
namespace StellaOps.Findings.Ledger.WebService.Services;
// ============================================================================
// Interfaces
// ============================================================================
public interface IVexDecisionRepository
{
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()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.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}";
}
}