update evidence bundle to include new evidence types and implement ProofSpine integration
Some checks failed
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-15 09:15:30 +02:00
parent 8c8f0c632d
commit 505fe7a885
49 changed files with 4756 additions and 551 deletions

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.Storage.ObjectStore;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.Storage.Repositories;
@@ -73,6 +74,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<EntryTraceRepository>();
services.AddScoped<RubyPackageInventoryRepository>();
services.AddScoped<BunPackageInventoryRepository>();
services.AddScoped<IProofSpineRepository, PostgresProofSpineRepository>();
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();

View File

@@ -0,0 +1,63 @@
-- proof spine storage schema (startup migration)
-- schema: created externally via search_path; tables unqualified for scanner schema compatibility
CREATE TABLE IF NOT EXISTS proof_spines (
spine_id TEXT PRIMARY KEY,
artifact_id TEXT NOT NULL,
vuln_id TEXT NOT NULL,
policy_profile_id TEXT NOT NULL,
verdict TEXT NOT NULL,
verdict_reason TEXT,
root_hash TEXT NOT NULL,
scan_run_id TEXT NOT NULL,
segment_count INT NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
superseded_by_spine_id TEXT REFERENCES proof_spines(spine_id),
CONSTRAINT proof_spines_unique_decision UNIQUE (artifact_id, vuln_id, policy_profile_id, root_hash)
);
CREATE INDEX IF NOT EXISTS ix_proof_spines_lookup
ON proof_spines(artifact_id, vuln_id, policy_profile_id);
CREATE INDEX IF NOT EXISTS ix_proof_spines_scan_run
ON proof_spines(scan_run_id);
CREATE INDEX IF NOT EXISTS ix_proof_spines_created_at
ON proof_spines(created_at_utc DESC);
CREATE TABLE IF NOT EXISTS proof_segments (
segment_id TEXT PRIMARY KEY,
spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
idx INT NOT NULL,
segment_type TEXT NOT NULL,
input_hash TEXT NOT NULL,
result_hash TEXT NOT NULL,
prev_segment_hash TEXT,
envelope_json TEXT NOT NULL,
tool_id TEXT NOT NULL,
tool_version TEXT NOT NULL,
status TEXT NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT proof_segments_unique_index UNIQUE (spine_id, idx)
);
CREATE INDEX IF NOT EXISTS ix_proof_segments_spine_idx
ON proof_segments(spine_id, idx);
CREATE INDEX IF NOT EXISTS ix_proof_segments_type
ON proof_segments(segment_type);
CREATE TABLE IF NOT EXISTS proof_spine_history (
id TEXT PRIMARY KEY,
old_spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
new_spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
reason TEXT NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_old
ON proof_spine_history(old_spine_id);
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_new
ON proof_spine_history(new_spine_id);
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_created_at
ON proof_spine_history(created_at_utc DESC);

View File

@@ -3,4 +3,5 @@ namespace StellaOps.Scanner.Storage.Postgres.Migrations;
internal static class MigrationIds
{
public const string CreateTables = "001_create_tables.sql";
public const string ProofSpineTables = "002_proof_spine_tables.sql";
}

View File

@@ -0,0 +1,397 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.Storage.Postgres;
using ProofSpineModel = StellaOps.Scanner.ProofSpine.ProofSpine;
namespace StellaOps.Scanner.Storage.Repositories;
public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSource>, IProofSpineRepository
{
private const string Tenant = "";
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string SpinesTable => $"{SchemaName}.proof_spines";
private string SegmentsTable => $"{SchemaName}.proof_segments";
private string HistoryTable => $"{SchemaName}.proof_spine_history";
private static readonly JsonSerializerOptions LenientJson = new()
{
PropertyNameCaseInsensitive = true
};
private readonly TimeProvider _timeProvider;
public PostgresProofSpineRepository(
ScannerDataSource dataSource,
ILogger<PostgresProofSpineRepository> logger,
TimeProvider? timeProvider = null)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<ProofSpineModel?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(spineId);
var sql = $"""
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
verdict, verdict_reason, root_hash, scan_run_id,
created_at_utc, superseded_by_spine_id
FROM {SpinesTable}
WHERE spine_id = @spine_id
""";
return QuerySingleOrDefaultAsync(
Tenant,
sql,
cmd => AddParameter(cmd, "spine_id", spineId.Trim()),
MapSpine,
cancellationToken);
}
public Task<ProofSpineModel?> GetByDecisionAsync(
string artifactId,
string vulnId,
string policyProfileId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
ArgumentException.ThrowIfNullOrWhiteSpace(vulnId);
ArgumentException.ThrowIfNullOrWhiteSpace(policyProfileId);
var sql = $"""
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
verdict, verdict_reason, root_hash, scan_run_id,
created_at_utc, superseded_by_spine_id
FROM {SpinesTable}
WHERE artifact_id = @artifact_id
AND vuln_id = @vuln_id
AND policy_profile_id = @policy_profile_id
ORDER BY created_at_utc DESC, spine_id DESC
LIMIT 1
""";
return QuerySingleOrDefaultAsync(
Tenant,
sql,
cmd =>
{
AddParameter(cmd, "artifact_id", artifactId.Trim());
AddParameter(cmd, "vuln_id", vulnId.Trim());
AddParameter(cmd, "policy_profile_id", policyProfileId.Trim());
},
MapSpine,
cancellationToken);
}
public Task<IReadOnlyList<ProofSpineModel>> GetByScanRunAsync(
string scanRunId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanRunId);
var sql = $"""
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
verdict, verdict_reason, root_hash, scan_run_id,
created_at_utc, superseded_by_spine_id
FROM {SpinesTable}
WHERE scan_run_id = @scan_run_id
ORDER BY created_at_utc DESC, spine_id DESC
""";
return QueryAsync(
Tenant,
sql,
cmd => AddParameter(cmd, "scan_run_id", scanRunId.Trim()),
MapSpine,
cancellationToken);
}
public async Task<ProofSpineModel> SaveAsync(ProofSpineModel spine, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(spine);
cancellationToken.ThrowIfCancellationRequested();
if (spine.Segments is null || spine.Segments.Count == 0)
{
throw new InvalidOperationException("ProofSpine requires at least one segment.");
}
var createdAt = spine.CreatedAt == default ? _timeProvider.GetUtcNow() : spine.CreatedAt;
await using var connection = await DataSource.OpenConnectionAsync(Tenant, "writer", cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
var insertSpine = $"""
INSERT INTO {SpinesTable} (
spine_id, artifact_id, vuln_id, policy_profile_id,
verdict, verdict_reason, root_hash, scan_run_id,
segment_count, created_at_utc, superseded_by_spine_id
)
VALUES (
@spine_id, @artifact_id, @vuln_id, @policy_profile_id,
@verdict, @verdict_reason, @root_hash, @scan_run_id,
@segment_count, @created_at_utc, @superseded_by_spine_id
)
ON CONFLICT (spine_id) DO NOTHING
""";
await using (var command = CreateCommand(insertSpine, connection))
{
command.Transaction = transaction;
AddParameter(command, "spine_id", spine.SpineId);
AddParameter(command, "artifact_id", spine.ArtifactId);
AddParameter(command, "vuln_id", spine.VulnerabilityId);
AddParameter(command, "policy_profile_id", spine.PolicyProfileId);
AddParameter(command, "verdict", spine.Verdict);
AddParameter(command, "verdict_reason", spine.VerdictReason);
AddParameter(command, "root_hash", spine.RootHash);
AddParameter(command, "scan_run_id", spine.ScanRunId);
AddParameter(command, "segment_count", spine.Segments.Count);
AddParameter(command, "created_at_utc", createdAt);
AddParameter(command, "superseded_by_spine_id", spine.SupersededBySpineId);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
var insertSegment = $"""
INSERT INTO {SegmentsTable} (
segment_id, spine_id, idx, segment_type, input_hash, result_hash, prev_segment_hash,
envelope_json, tool_id, tool_version, status, created_at_utc
)
VALUES (
@segment_id, @spine_id, @idx, @segment_type, @input_hash, @result_hash, @prev_segment_hash,
@envelope_json, @tool_id, @tool_version, @status, @created_at_utc
)
ON CONFLICT (segment_id) DO NOTHING
""";
foreach (var segment in spine.Segments.OrderBy(s => s.Index))
{
cancellationToken.ThrowIfCancellationRequested();
await using var command = CreateCommand(insertSegment, connection);
command.Transaction = transaction;
AddParameter(command, "segment_id", segment.SegmentId);
AddParameter(command, "spine_id", spine.SpineId);
AddParameter(command, "idx", segment.Index);
AddParameter(command, "segment_type", segment.SegmentType.ToString());
AddParameter(command, "input_hash", segment.InputHash);
AddParameter(command, "result_hash", segment.ResultHash);
AddParameter(command, "prev_segment_hash", segment.PrevSegmentHash);
AddParameter(command, "envelope_json", SerializeEnvelope(segment.Envelope));
AddParameter(command, "tool_id", segment.ToolId);
AddParameter(command, "tool_version", segment.ToolVersion);
AddParameter(command, "status", segment.Status.ToString());
AddParameter(command, "created_at_utc", segment.CreatedAt == default ? createdAt : segment.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
return spine with { CreatedAt = createdAt };
}
public async Task SupersedeAsync(
string oldSpineId,
string newSpineId,
string reason,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(oldSpineId);
ArgumentException.ThrowIfNullOrWhiteSpace(newSpineId);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
await using var connection = await DataSource.OpenConnectionAsync(Tenant, "writer", cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
try
{
var update = $"""
UPDATE {SpinesTable}
SET superseded_by_spine_id = @new_spine_id
WHERE spine_id = @old_spine_id
""";
await using (var command = CreateCommand(update, connection))
{
command.Transaction = transaction;
AddParameter(command, "old_spine_id", oldSpineId.Trim());
AddParameter(command, "new_spine_id", newSpineId.Trim());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
var insertHistory = $"""
INSERT INTO {HistoryTable} (id, old_spine_id, new_spine_id, reason, created_at_utc)
VALUES (@id, @old_spine_id, @new_spine_id, @reason, @created_at_utc)
ON CONFLICT (id) DO NOTHING
""";
await using (var command = CreateCommand(insertHistory, connection))
{
command.Transaction = transaction;
AddParameter(command, "id", Guid.NewGuid().ToString("N"));
AddParameter(command, "old_spine_id", oldSpineId.Trim());
AddParameter(command, "new_spine_id", newSpineId.Trim());
AddParameter(command, "reason", reason);
AddParameter(command, "created_at_utc", _timeProvider.GetUtcNow());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
public Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(string spineId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(spineId);
var sql = $"""
SELECT segment_id, segment_type, idx, input_hash, result_hash, prev_segment_hash,
envelope_json, tool_id, tool_version, status, created_at_utc
FROM {SegmentsTable}
WHERE spine_id = @spine_id
ORDER BY idx
""";
return QueryAsync(
Tenant,
sql,
cmd => AddParameter(cmd, "spine_id", spineId.Trim()),
MapSegment,
cancellationToken);
}
public Task<IReadOnlyList<ProofSpineSummary>> GetSummariesByScanRunAsync(
string scanRunId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanRunId);
var sql = $"""
SELECT spine_id, artifact_id, vuln_id, verdict, segment_count, created_at_utc
FROM {SpinesTable}
WHERE scan_run_id = @scan_run_id
ORDER BY created_at_utc DESC, spine_id DESC
""";
return QueryAsync(
Tenant,
sql,
cmd => AddParameter(cmd, "scan_run_id", scanRunId.Trim()),
MapSummary,
cancellationToken);
}
private static ProofSpineModel MapSpine(NpgsqlDataReader reader)
{
return new ProofSpineModel(
SpineId: reader.GetString(reader.GetOrdinal("spine_id")),
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
VulnerabilityId: reader.GetString(reader.GetOrdinal("vuln_id")),
PolicyProfileId: reader.GetString(reader.GetOrdinal("policy_profile_id")),
Segments: Array.Empty<ProofSegment>(),
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
VerdictReason: reader.IsDBNull(reader.GetOrdinal("verdict_reason"))
? string.Empty
: reader.GetString(reader.GetOrdinal("verdict_reason")),
RootHash: reader.GetString(reader.GetOrdinal("root_hash")),
ScanRunId: reader.GetString(reader.GetOrdinal("scan_run_id")),
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")),
SupersededBySpineId: GetNullableString(reader, reader.GetOrdinal("superseded_by_spine_id")));
}
private static ProofSegment MapSegment(NpgsqlDataReader reader)
{
var segmentTypeString = reader.GetString(reader.GetOrdinal("segment_type"));
if (!Enum.TryParse<ProofSegmentType>(segmentTypeString, ignoreCase: true, out var segmentType))
{
throw new InvalidOperationException($"Unsupported proof segment type '{segmentTypeString}'.");
}
var statusString = reader.GetString(reader.GetOrdinal("status"));
if (!Enum.TryParse<ProofSegmentStatus>(statusString, ignoreCase: true, out var status))
{
status = ProofSegmentStatus.Pending;
}
var envelopeJson = reader.GetString(reader.GetOrdinal("envelope_json"));
return new ProofSegment(
SegmentId: reader.GetString(reader.GetOrdinal("segment_id")),
SegmentType: segmentType,
Index: reader.GetInt32(reader.GetOrdinal("idx")),
InputHash: reader.GetString(reader.GetOrdinal("input_hash")),
ResultHash: reader.GetString(reader.GetOrdinal("result_hash")),
PrevSegmentHash: GetNullableString(reader, reader.GetOrdinal("prev_segment_hash")),
Envelope: DeserializeEnvelope(envelopeJson),
ToolId: reader.GetString(reader.GetOrdinal("tool_id")),
ToolVersion: reader.GetString(reader.GetOrdinal("tool_version")),
Status: status,
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")));
}
private static string SerializeEnvelope(DsseEnvelope envelope)
{
var doc = new DsseEnvelopeDocument(
envelope.PayloadType,
envelope.Payload,
envelope.Signatures.Select(s => new DsseSignatureDocument(s.KeyId, s.Sig)).ToArray());
return CanonicalJson.Serialize(doc);
}
private static DsseEnvelope DeserializeEnvelope(string json)
{
var doc = JsonSerializer.Deserialize<DsseEnvelopeDocument>(json, LenientJson)
?? throw new InvalidOperationException("DSSE envelope deserialized to null.");
var signatures = doc.Signatures is null
? Array.Empty<DsseSignature>()
: doc.Signatures.Select(s => new DsseSignature(s.KeyId, s.Sig)).ToArray();
return new DsseEnvelope(doc.PayloadType, doc.Payload, signatures);
}
private sealed record DsseEnvelopeDocument(
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignatureDocument> Signatures);
private sealed record DsseSignatureDocument(
[property: JsonPropertyName("keyid")] string KeyId,
[property: JsonPropertyName("sig")] string Sig);
private static ProofSpineSummary MapSummary(NpgsqlDataReader reader)
=> new(
SpineId: reader.GetString(reader.GetOrdinal("spine_id")),
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
VulnerabilityId: reader.GetString(reader.GetOrdinal("vuln_id")),
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
SegmentCount: reader.GetInt32(reader.GetOrdinal("segment_count")),
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")));
}

View File

@@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>