feat: add Attestation Chain and Triage Evidence API clients and models

- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

@@ -0,0 +1,195 @@
// -----------------------------------------------------------------------------
// EpssUpdatedEvent.cs
// Sprint: SPRINT_3410_0001_0001_epss_ingestion_storage
// Task: EPSS-3410-011
// Description: Event published when EPSS data is successfully updated.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Storage.Epss.Events;
/// <summary>
/// Event published when EPSS data is successfully ingested.
/// Event type: "epss.updated@1"
/// </summary>
public sealed record EpssUpdatedEvent
{
/// <summary>
/// Event type identifier for routing.
/// </summary>
public const string EventType = "epss.updated@1";
/// <summary>
/// Event version for schema evolution.
/// </summary>
public const int Version = 1;
/// <summary>
/// Unique identifier for this event instance.
/// </summary>
[JsonPropertyName("event_id")]
public required Guid EventId { get; init; }
/// <summary>
/// UTC timestamp when the event occurred.
/// </summary>
[JsonPropertyName("occurred_at_utc")]
public required DateTimeOffset OccurredAtUtc { get; init; }
/// <summary>
/// The import run ID that produced this update.
/// </summary>
[JsonPropertyName("import_run_id")]
public required Guid ImportRunId { get; init; }
/// <summary>
/// The EPSS model date (YYYY-MM-DD) that was imported.
/// </summary>
[JsonPropertyName("model_date")]
public required DateOnly ModelDate { get; init; }
/// <summary>
/// The EPSS model version tag (e.g., "v2025.12.17").
/// </summary>
[JsonPropertyName("model_version_tag")]
public string? ModelVersionTag { get; init; }
/// <summary>
/// The published date from the EPSS data.
/// </summary>
[JsonPropertyName("published_date")]
public DateOnly? PublishedDate { get; init; }
/// <summary>
/// Total number of CVEs in the snapshot.
/// </summary>
[JsonPropertyName("row_count")]
public required int RowCount { get; init; }
/// <summary>
/// Number of distinct CVE IDs in the snapshot.
/// </summary>
[JsonPropertyName("distinct_cve_count")]
public required int DistinctCveCount { get; init; }
/// <summary>
/// SHA256 hash of the decompressed CSV content.
/// </summary>
[JsonPropertyName("content_hash")]
public string? ContentHash { get; init; }
/// <summary>
/// Source URI (online URL or bundle path).
/// </summary>
[JsonPropertyName("source_uri")]
public required string SourceUri { get; init; }
/// <summary>
/// Duration of the ingestion in milliseconds.
/// </summary>
[JsonPropertyName("duration_ms")]
public required long DurationMs { get; init; }
/// <summary>
/// Summary of material changes detected.
/// </summary>
[JsonPropertyName("change_summary")]
public EpssChangeSummary? ChangeSummary { get; init; }
/// <summary>
/// Creates an idempotency key for this event based on model date and import run.
/// </summary>
public string GetIdempotencyKey()
=> $"epss.updated:{ModelDate:yyyy-MM-dd}:{ImportRunId:N}";
}
/// <summary>
/// Summary of material changes in an EPSS update.
/// </summary>
public sealed record EpssChangeSummary
{
/// <summary>
/// Number of CVEs newly scored (first appearance).
/// </summary>
[JsonPropertyName("new_scored")]
public int NewScored { get; init; }
/// <summary>
/// Number of CVEs that crossed the high threshold upward.
/// </summary>
[JsonPropertyName("crossed_high")]
public int CrossedHigh { get; init; }
/// <summary>
/// Number of CVEs that crossed the high threshold downward.
/// </summary>
[JsonPropertyName("crossed_low")]
public int CrossedLow { get; init; }
/// <summary>
/// Number of CVEs with a big jump up in score.
/// </summary>
[JsonPropertyName("big_jump_up")]
public int BigJumpUp { get; init; }
/// <summary>
/// Number of CVEs with a big jump down in score.
/// </summary>
[JsonPropertyName("big_jump_down")]
public int BigJumpDown { get; init; }
/// <summary>
/// Number of CVEs that entered the top percentile.
/// </summary>
[JsonPropertyName("top_percentile")]
public int TopPercentile { get; init; }
/// <summary>
/// Number of CVEs that left the top percentile.
/// </summary>
[JsonPropertyName("left_top_percentile")]
public int LeftTopPercentile { get; init; }
/// <summary>
/// Total number of CVEs with any material change.
/// </summary>
[JsonPropertyName("total_changed")]
public int TotalChanged { get; init; }
}
/// <summary>
/// Builder for creating <see cref="EpssUpdatedEvent"/> instances.
/// </summary>
public static class EpssUpdatedEventBuilder
{
public static EpssUpdatedEvent Create(
Guid importRunId,
DateOnly modelDate,
string sourceUri,
int rowCount,
int distinctCveCount,
long durationMs,
TimeProvider timeProvider,
string? modelVersionTag = null,
DateOnly? publishedDate = null,
string? contentHash = null,
EpssChangeSummary? changeSummary = null)
{
return new EpssUpdatedEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = timeProvider.GetUtcNow(),
ImportRunId = importRunId,
ModelDate = modelDate,
ModelVersionTag = modelVersionTag,
PublishedDate = publishedDate,
RowCount = rowCount,
DistinctCveCount = distinctCveCount,
ContentHash = contentHash,
SourceUri = sourceUri,
DurationMs = durationMs,
ChangeSummary = changeSummary
};
}
}

View File

@@ -82,8 +82,17 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReachabilityResultRepository, PostgresReachabilityResultRepository>();
services.AddScoped<ICodeChangeRepository, PostgresCodeChangeRepository>();
services.AddScoped<IReachabilityDriftResultRepository, PostgresReachabilityDriftResultRepository>();
// EPSS ingestion services
services.AddSingleton<EpssCsvStreamParser>();
services.AddScoped<IEpssRepository, PostgresEpssRepository>();
services.AddSingleton<EpssOnlineSource>();
services.AddSingleton<EpssBundleSource>();
services.AddSingleton<EpssChangeDetector>();
// Witness storage (Sprint: SPRINT_3700_0001_0001)
services.AddScoped<IWitnessRepository, PostgresWitnessRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();

View File

@@ -0,0 +1,60 @@
-- Migration: 013_witness_storage.sql
-- Sprint: SPRINT_3700_0001_0001_witness_foundation
-- Task: WIT-011
-- Description: Creates tables for DSSE-signed path witnesses and witness storage.
-- Witness storage for reachability path proofs
CREATE TABLE IF NOT EXISTS scanner.witnesses (
witness_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
witness_hash TEXT NOT NULL, -- BLAKE3 hash of witness payload
schema_version TEXT NOT NULL DEFAULT 'stellaops.witness.v1',
witness_type TEXT NOT NULL, -- 'reachability_path', 'gate_proof', etc.
-- Reference to the graph/analysis that produced this witness
graph_hash TEXT NOT NULL, -- BLAKE3 hash of source rich graph
scan_id UUID,
run_id UUID,
-- Witness content
payload_json JSONB NOT NULL, -- PathWitness JSON
dsse_envelope JSONB, -- DSSE signed envelope (nullable until signed)
-- Provenance
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
signed_at TIMESTAMPTZ,
signer_key_id TEXT,
-- Indexing
entrypoint_fqn TEXT, -- For quick lookup by entrypoint
sink_cve TEXT, -- For quick lookup by CVE
CONSTRAINT uk_witness_hash UNIQUE (witness_hash)
);
-- Index for efficient lookups
CREATE INDEX IF NOT EXISTS ix_witnesses_graph_hash ON scanner.witnesses (graph_hash);
CREATE INDEX IF NOT EXISTS ix_witnesses_scan_id ON scanner.witnesses (scan_id) WHERE scan_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_witnesses_sink_cve ON scanner.witnesses (sink_cve) WHERE sink_cve IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_witnesses_entrypoint ON scanner.witnesses (entrypoint_fqn) WHERE entrypoint_fqn IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_witnesses_created_at ON scanner.witnesses (created_at DESC);
-- GIN index for JSONB queries on payload
CREATE INDEX IF NOT EXISTS ix_witnesses_payload_gin ON scanner.witnesses USING gin (payload_json jsonb_path_ops);
-- Witness verification log (for audit trail)
CREATE TABLE IF NOT EXISTS scanner.witness_verifications (
verification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
witness_id UUID NOT NULL REFERENCES scanner.witnesses(witness_id),
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_by TEXT, -- 'system', 'api', 'cli'
verification_status TEXT NOT NULL, -- 'valid', 'invalid', 'expired'
verification_error TEXT,
verifier_key_id TEXT
);
CREATE INDEX IF NOT EXISTS ix_witness_verifications_witness_id ON scanner.witness_verifications (witness_id);
COMMENT ON TABLE scanner.witnesses IS 'DSSE-signed path witnesses for reachability proofs (stellaops.witness.v1)';
COMMENT ON TABLE scanner.witness_verifications IS 'Audit log of witness verification attempts';
COMMENT ON COLUMN scanner.witnesses.witness_hash IS 'BLAKE3 hash of witness payload for deduplication and integrity';
COMMENT ON COLUMN scanner.witnesses.dsse_envelope IS 'Dead Simple Signing Envelope (DSSE) containing the signed witness';

View File

@@ -12,4 +12,7 @@ internal static class MigrationIds
public const string EpssIntegration = "008_epss_integration.sql";
public const string CallGraphTables = "009_call_graph_tables.sql";
public const string ReachabilityDriftTables = "010_reachability_drift_tables.sql";
public const string EpssRawLayer = "011_epss_raw_layer.sql";
public const string EpssSignalLayer = "012_epss_signal_layer.sql";
public const string WitnessStorage = "013_witness_storage.sql";
}

View File

@@ -0,0 +1,89 @@
// -----------------------------------------------------------------------------
// IWitnessRepository.cs
// Sprint: SPRINT_3700_0001_0001_witness_foundation
// Task: WIT-012
// Description: Repository interface for path witness storage and retrieval.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for DSSE-signed path witnesses.
/// </summary>
public interface IWitnessRepository
{
/// <summary>
/// Stores a witness and returns the assigned ID.
/// </summary>
Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a witness by its ID.
/// </summary>
Task<WitnessRecord?> GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a witness by its hash.
/// </summary>
Task<WitnessRecord?> GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all witnesses for a given graph hash.
/// </summary>
Task<IReadOnlyList<WitnessRecord>> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves witnesses for a given scan.
/// </summary>
Task<IReadOnlyList<WitnessRecord>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves witnesses for a given CVE.
/// </summary>
Task<IReadOnlyList<WitnessRecord>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default);
/// <summary>
/// Updates a witness with a DSSE envelope after signing.
/// </summary>
Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default);
/// <summary>
/// Records a verification attempt for a witness.
/// </summary>
Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default);
}
/// <summary>
/// Record representing a stored witness.
/// </summary>
public sealed record WitnessRecord
{
public Guid WitnessId { get; init; }
public required string WitnessHash { get; init; }
public string SchemaVersion { get; init; } = "stellaops.witness.v1";
public required string WitnessType { get; init; }
public required string GraphHash { get; init; }
public Guid? ScanId { get; init; }
public Guid? RunId { get; init; }
public required string PayloadJson { get; init; }
public string? DsseEnvelope { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? SignedAt { get; init; }
public string? SignerKeyId { get; init; }
public string? EntrypointFqn { get; init; }
public string? SinkCve { get; init; }
}
/// <summary>
/// Record representing a witness verification attempt.
/// </summary>
public sealed record WitnessVerificationRecord
{
public Guid VerificationId { get; init; }
public required Guid WitnessId { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
public string? VerifiedBy { get; init; }
public required string VerificationStatus { get; init; }
public string? VerificationError { get; init; }
public string? VerifierKeyId { get; init; }
}

View File

@@ -0,0 +1,275 @@
// -----------------------------------------------------------------------------
// PostgresWitnessRepository.cs
// Sprint: SPRINT_3700_0001_0001_witness_foundation
// Task: WIT-012
// Description: Postgres implementation of IWitnessRepository for witness storage.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Scanner.Storage.Postgres;
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Postgres implementation of <see cref="IWitnessRepository"/>.
/// </summary>
public sealed class PostgresWitnessRepository : IWitnessRepository
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresWitnessRepository> _logger;
public PostgresWitnessRepository(ScannerDataSource dataSource, ILogger<PostgresWitnessRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<Guid> StoreAsync(WitnessRecord witness, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(witness);
const string sql = """
INSERT INTO scanner.witnesses (
witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
) VALUES (
@witness_hash, @schema_version, @witness_type, @graph_hash,
@scan_id, @run_id, @payload_json::jsonb, @dsse_envelope::jsonb, @created_at,
@signed_at, @signer_key_id, @entrypoint_fqn, @sink_cve
)
ON CONFLICT (witness_hash) DO UPDATE SET
dsse_envelope = COALESCE(EXCLUDED.dsse_envelope, scanner.witnesses.dsse_envelope),
signed_at = COALESCE(EXCLUDED.signed_at, scanner.witnesses.signed_at),
signer_key_id = COALESCE(EXCLUDED.signer_key_id, scanner.witnesses.signer_key_id)
RETURNING witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_hash", witness.WitnessHash);
cmd.Parameters.AddWithValue("schema_version", witness.SchemaVersion);
cmd.Parameters.AddWithValue("witness_type", witness.WitnessType);
cmd.Parameters.AddWithValue("graph_hash", witness.GraphHash);
cmd.Parameters.AddWithValue("scan_id", witness.ScanId.HasValue ? witness.ScanId.Value : DBNull.Value);
cmd.Parameters.AddWithValue("run_id", witness.RunId.HasValue ? witness.RunId.Value : DBNull.Value);
cmd.Parameters.AddWithValue("payload_json", witness.PayloadJson);
cmd.Parameters.AddWithValue("dsse_envelope", string.IsNullOrEmpty(witness.DsseEnvelope) ? DBNull.Value : witness.DsseEnvelope);
cmd.Parameters.AddWithValue("created_at", witness.CreatedAt == default ? DateTimeOffset.UtcNow : witness.CreatedAt);
cmd.Parameters.AddWithValue("signed_at", witness.SignedAt.HasValue ? witness.SignedAt.Value : DBNull.Value);
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(witness.SignerKeyId) ? DBNull.Value : witness.SignerKeyId);
cmd.Parameters.AddWithValue("entrypoint_fqn", string.IsNullOrEmpty(witness.EntrypointFqn) ? DBNull.Value : witness.EntrypointFqn);
cmd.Parameters.AddWithValue("sink_cve", string.IsNullOrEmpty(witness.SinkCve) ? DBNull.Value : witness.SinkCve);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
var witnessId = (Guid)result!;
_logger.LogDebug("Stored witness {WitnessId} with hash {WitnessHash}", witnessId, witness.WitnessHash);
return witnessId;
}
public async Task<WitnessRecord?> GetByIdAsync(Guid witnessId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE witness_id = @witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapToRecord(reader);
}
return null;
}
public async Task<WitnessRecord?> GetByHashAsync(string witnessHash, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(witnessHash);
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE witness_hash = @witness_hash
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_hash", witnessHash);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapToRecord(reader);
}
return null;
}
public async Task<IReadOnlyList<WitnessRecord>> GetByGraphHashAsync(string graphHash, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE graph_hash = @graph_hash
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("graph_hash", graphHash);
var results = new List<WitnessRecord>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
public async Task<IReadOnlyList<WitnessRecord>> GetByScanIdAsync(Guid scanId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE scan_id = @scan_id
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("scan_id", scanId);
var results = new List<WitnessRecord>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
public async Task<IReadOnlyList<WitnessRecord>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
const string sql = """
SELECT witness_id, witness_hash, schema_version, witness_type, graph_hash,
scan_id, run_id, payload_json, dsse_envelope, created_at,
signed_at, signer_key_id, entrypoint_fqn, sink_cve
FROM scanner.witnesses
WHERE sink_cve = @sink_cve
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("sink_cve", cveId);
var results = new List<WitnessRecord>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
public async Task UpdateDsseEnvelopeAsync(Guid witnessId, string dsseEnvelopeJson, string signerKeyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dsseEnvelopeJson);
const string sql = """
UPDATE scanner.witnesses
SET dsse_envelope = @dsse_envelope::jsonb,
signed_at = @signed_at,
signer_key_id = @signer_key_id
WHERE witness_id = @witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson);
cmd.Parameters.AddWithValue("signed_at", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("signer_key_id", string.IsNullOrEmpty(signerKeyId) ? DBNull.Value : signerKeyId);
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (affected > 0)
{
_logger.LogDebug("Updated DSSE envelope for witness {WitnessId}", witnessId);
}
}
public async Task RecordVerificationAsync(WitnessVerificationRecord verification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(verification);
const string sql = """
INSERT INTO scanner.witness_verifications (
witness_id, verified_at, verified_by, verification_status,
verification_error, verifier_key_id
) VALUES (
@witness_id, @verified_at, @verified_by, @verification_status,
@verification_error, @verifier_key_id
)
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", verification.WitnessId);
cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt);
cmd.Parameters.AddWithValue("verified_by", string.IsNullOrEmpty(verification.VerifiedBy) ? DBNull.Value : verification.VerifiedBy);
cmd.Parameters.AddWithValue("verification_status", verification.VerificationStatus);
cmd.Parameters.AddWithValue("verification_error", string.IsNullOrEmpty(verification.VerificationError) ? DBNull.Value : verification.VerificationError);
cmd.Parameters.AddWithValue("verifier_key_id", string.IsNullOrEmpty(verification.VerifierKeyId) ? DBNull.Value : verification.VerifierKeyId);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Recorded verification for witness {WitnessId}: {Status}", verification.WitnessId, verification.VerificationStatus);
}
private static WitnessRecord MapToRecord(NpgsqlDataReader reader)
{
return new WitnessRecord
{
WitnessId = reader.GetGuid(0),
WitnessHash = reader.GetString(1),
SchemaVersion = reader.GetString(2),
WitnessType = reader.GetString(3),
GraphHash = reader.GetString(4),
ScanId = reader.IsDBNull(5) ? null : reader.GetGuid(5),
RunId = reader.IsDBNull(6) ? null : reader.GetGuid(6),
PayloadJson = reader.GetString(7),
DsseEnvelope = reader.IsDBNull(8) ? null : reader.GetString(8),
CreatedAt = reader.GetDateTime(9),
SignedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
SignerKeyId = reader.IsDBNull(11) ? null : reader.GetString(11),
EntrypointFqn = reader.IsDBNull(12) ? null : reader.GetString(12),
SinkCve = reader.IsDBNull(13) ? null : reader.GetString(13)
};
}
}