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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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';
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user