documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF

This commit is contained in:
master
2026-02-25 01:24:07 +02:00
parent b07d27772e
commit 4db038123b
9090 changed files with 4836 additions and 2909 deletions

View File

@@ -27,6 +27,27 @@ public partial class ScannerDbContext : DbContext
public virtual DbSet<CallGraphSnapshotEntity> CallGraphSnapshots { get; set; }
public virtual DbSet<ReachabilityResultEntity> ReachabilityResults { get; set; }
// ----- Reachability drift tables (scanner schema) -----
public virtual DbSet<CodeChangeEntity> CodeChanges { get; set; }
public virtual DbSet<ReachabilityDriftResultEntity> ReachabilityDriftResults { get; set; }
public virtual DbSet<DriftedSinkEntity> DriftedSinks { get; set; }
// ----- EPSS tables (scanner schema) -----
public virtual DbSet<EpssRawEntity> EpssRaw { get; set; }
public virtual DbSet<EpssSignalEntity> EpssSignals { get; set; }
public virtual DbSet<EpssSignalConfigEntity> EpssSignalConfigs { get; set; }
// ----- VEX candidates (scanner schema) -----
public virtual DbSet<VexCandidateEntity> VexCandidates { get; set; }
// ----- Function-level proof tables (scanner schema) -----
public virtual DbSet<FuncProofEntity> FuncProofs { get; set; }
public virtual DbSet<FuncNodeEntity> FuncNodes { get; set; }
public virtual DbSet<FuncTraceEntity> FuncTraces { get; set; }
// ----- Facet seals (scanner schema) -----
public virtual DbSet<FacetSealEntity> FacetSeals { get; set; }
// ----- Public/default schema tables -----
public virtual DbSet<ScanManifestEntity> ScanManifests { get; set; }
public virtual DbSet<ProofBundleEntity> ProofBundles { get; set; }
@@ -390,6 +411,336 @@ public partial class ScannerDbContext : DbContext
entity.Property(e => e.RekorTileId).HasColumnName("rekor_tile_id");
});
// ======================================================================
// Reachability drift tables (scanner schema, migration 010)
// ======================================================================
modelBuilder.Entity<CodeChangeEntity>(entity =>
{
entity.ToTable("code_changes", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.BaseScanId).HasColumnName("base_scan_id");
entity.Property(e => e.Language).HasColumnName("language");
entity.Property(e => e.NodeId).HasColumnName("node_id");
entity.Property(e => e.File).HasColumnName("file");
entity.Property(e => e.Symbol).HasColumnName("symbol");
entity.Property(e => e.ChangeKind).HasColumnName("change_kind");
entity.Property(e => e.Details).HasColumnName("details").HasColumnType("jsonb");
entity.Property(e => e.DetectedAt).HasColumnName("detected_at").HasDefaultValueSql("NOW()");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.BaseScanId, e.Language, e.Symbol, e.ChangeKind })
.IsUnique()
.HasDatabaseName("code_changes_unique");
entity.HasIndex(e => new { e.TenantId, e.ScanId, e.BaseScanId, e.Language })
.HasDatabaseName("idx_code_changes_tenant_scan");
entity.HasIndex(e => e.Symbol).HasDatabaseName("idx_code_changes_symbol");
entity.HasIndex(e => e.ChangeKind).HasDatabaseName("idx_code_changes_kind");
});
modelBuilder.Entity<ReachabilityDriftResultEntity>(entity =>
{
entity.ToTable("reachability_drift_results", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.BaseScanId).HasColumnName("base_scan_id");
entity.Property(e => e.HeadScanId).HasColumnName("head_scan_id");
entity.Property(e => e.Language).HasColumnName("language");
entity.Property(e => e.NewlyReachableCount).HasColumnName("newly_reachable_count").HasDefaultValue(0);
entity.Property(e => e.NewlyUnreachableCount).HasColumnName("newly_unreachable_count").HasDefaultValue(0);
entity.Property(e => e.DetectedAt).HasColumnName("detected_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.ResultDigest).HasColumnName("result_digest");
entity.HasIndex(e => new { e.TenantId, e.BaseScanId, e.HeadScanId, e.Language, e.ResultDigest })
.IsUnique()
.HasDatabaseName("reachability_drift_unique");
entity.HasIndex(e => new { e.TenantId, e.HeadScanId, e.Language })
.HasDatabaseName("idx_reachability_drift_head");
entity.HasMany(e => e.DriftedSinks)
.WithOne(d => d.DriftResult)
.HasForeignKey(d => d.DriftResultId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DriftedSinkEntity>(entity =>
{
entity.ToTable("drifted_sinks", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.DriftResultId).HasColumnName("drift_result_id");
entity.Property(e => e.SinkNodeId).HasColumnName("sink_node_id");
entity.Property(e => e.Symbol).HasColumnName("symbol");
entity.Property(e => e.SinkCategory).HasColumnName("sink_category");
entity.Property(e => e.Direction).HasColumnName("direction");
entity.Property(e => e.CauseKind).HasColumnName("cause_kind");
entity.Property(e => e.CauseDescription).HasColumnName("cause_description");
entity.Property(e => e.CauseSymbol).HasColumnName("cause_symbol");
entity.Property(e => e.CauseFile).HasColumnName("cause_file");
entity.Property(e => e.CauseLine).HasColumnName("cause_line");
entity.Property(e => e.CodeChangeId).HasColumnName("code_change_id");
entity.Property(e => e.CompressedPath).HasColumnName("compressed_path").HasColumnType("jsonb");
entity.Property(e => e.AssociatedVulns).HasColumnName("associated_vulns").HasColumnType("jsonb");
entity.HasIndex(e => new { e.DriftResultId, e.SinkNodeId })
.IsUnique()
.HasDatabaseName("drifted_sinks_unique");
entity.HasIndex(e => e.DriftResultId).HasDatabaseName("idx_drifted_sinks_drift");
entity.HasIndex(e => e.Direction).HasDatabaseName("idx_drifted_sinks_direction");
entity.HasIndex(e => e.SinkCategory).HasDatabaseName("idx_drifted_sinks_category");
});
// ======================================================================
// EPSS tables (scanner schema, migrations 011/012)
// ======================================================================
modelBuilder.Entity<EpssRawEntity>(entity =>
{
entity.ToTable("epss_raw", schema);
entity.HasKey(e => e.RawId);
entity.Property(e => e.RawId).HasColumnName("raw_id").UseIdentityAlwaysColumn();
entity.Property(e => e.SourceUri).HasColumnName("source_uri");
entity.Property(e => e.AsOfDate).HasColumnName("asof_date").HasColumnType("date");
entity.Property(e => e.IngestionTs).HasColumnName("ingestion_ts").HasDefaultValueSql("now()");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.PayloadSha256).HasColumnName("payload_sha256");
entity.Property(e => e.HeaderComment).HasColumnName("header_comment");
entity.Property(e => e.ModelVersion).HasColumnName("model_version");
entity.Property(e => e.PublishedDate).HasColumnName("published_date").HasColumnType("date");
entity.Property(e => e.RowCount).HasColumnName("row_count");
entity.Property(e => e.CompressedSize).HasColumnName("compressed_size");
entity.Property(e => e.DecompressedSize).HasColumnName("decompressed_size");
entity.Property(e => e.ImportRunId).HasColumnName("import_run_id");
entity.HasIndex(e => new { e.SourceUri, e.AsOfDate, e.PayloadSha256 })
.IsUnique()
.HasDatabaseName("epss_raw_unique");
entity.HasIndex(e => e.AsOfDate).IsDescending().HasDatabaseName("idx_epss_raw_asof");
entity.HasIndex(e => e.ModelVersion).HasDatabaseName("idx_epss_raw_model");
entity.HasIndex(e => e.ImportRunId).HasDatabaseName("idx_epss_raw_import_run");
});
modelBuilder.Entity<EpssSignalEntity>(entity =>
{
entity.ToTable("epss_signal", schema);
entity.HasKey(e => e.SignalId);
entity.Property(e => e.SignalId).HasColumnName("signal_id").UseIdentityAlwaysColumn();
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ModelDate).HasColumnName("model_date").HasColumnType("date");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.EventType).HasColumnName("event_type");
entity.Property(e => e.RiskBand).HasColumnName("risk_band");
entity.Property(e => e.EpssScore).HasColumnName("epss_score");
entity.Property(e => e.EpssDelta).HasColumnName("epss_delta");
entity.Property(e => e.Percentile).HasColumnName("percentile");
entity.Property(e => e.PercentileDelta).HasColumnName("percentile_delta");
entity.Property(e => e.IsModelChange).HasColumnName("is_model_change").HasDefaultValue(false);
entity.Property(e => e.ModelVersion).HasColumnName("model_version");
entity.Property(e => e.DedupeKey).HasColumnName("dedupe_key");
entity.Property(e => e.ExplainHash).HasColumnName("explain_hash");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.HasIndex(e => new { e.TenantId, e.DedupeKey })
.IsUnique()
.HasDatabaseName("epss_signal_dedupe");
entity.HasIndex(e => new { e.TenantId, e.ModelDate })
.IsDescending(false, true)
.HasDatabaseName("idx_epss_signal_tenant_date");
entity.HasIndex(e => new { e.TenantId, e.CveId, e.ModelDate })
.IsDescending(false, false, true)
.HasDatabaseName("idx_epss_signal_tenant_cve");
entity.HasIndex(e => new { e.TenantId, e.EventType, e.ModelDate })
.IsDescending(false, false, true)
.HasDatabaseName("idx_epss_signal_event_type");
});
modelBuilder.Entity<EpssSignalConfigEntity>(entity =>
{
entity.ToTable("epss_signal_config", schema);
entity.HasKey(e => e.ConfigId);
entity.Property(e => e.ConfigId).HasColumnName("config_id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.CriticalPercentile).HasColumnName("critical_percentile").HasDefaultValue(0.995);
entity.Property(e => e.HighPercentile).HasColumnName("high_percentile").HasDefaultValue(0.99);
entity.Property(e => e.MediumPercentile).HasColumnName("medium_percentile").HasDefaultValue(0.90);
entity.Property(e => e.BigJumpDelta).HasColumnName("big_jump_delta").HasDefaultValue(0.10);
entity.Property(e => e.SuppressOnModelChange).HasColumnName("suppress_on_model_change").HasDefaultValue(true);
entity.Property(e => e.EnabledEventTypes).HasColumnName("enabled_event_types");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
entity.HasIndex(e => e.TenantId)
.IsUnique()
.HasDatabaseName("epss_signal_config_tenant_unique");
});
// ======================================================================
// VEX candidates (scanner schema, migration 005)
// ======================================================================
modelBuilder.Entity<VexCandidateEntity>(entity =>
{
entity.ToTable("vex_candidates", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.CandidateId).HasColumnName("candidate_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.VulnId).HasColumnName("vuln_id");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
entity.Property(e => e.SuggestedStatus).HasColumnName("suggested_status");
entity.Property(e => e.Justification).HasColumnName("justification");
entity.Property(e => e.Rationale).HasColumnName("rationale");
entity.Property(e => e.EvidenceLinks).HasColumnName("evidence_links").HasColumnType("jsonb");
entity.Property(e => e.Confidence).HasColumnName("confidence").HasColumnType("numeric(4,3)");
entity.Property(e => e.GeneratedAt).HasColumnName("generated_at").HasDefaultValueSql("NOW()");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.Property(e => e.RequiresReview).HasColumnName("requires_review").HasDefaultValue(true);
entity.Property(e => e.ReviewAction).HasColumnName("review_action");
entity.Property(e => e.ReviewedBy).HasColumnName("reviewed_by");
entity.Property(e => e.ReviewedAt).HasColumnName("reviewed_at");
entity.Property(e => e.ReviewComment).HasColumnName("review_comment");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.CandidateId)
.IsUnique()
.HasDatabaseName("vex_candidates_candidate_id_key");
entity.HasIndex(e => new { e.TenantId, e.ImageDigest })
.HasDatabaseName("idx_vex_candidates_tenant_image");
entity.HasIndex(e => e.ExpiresAt).HasDatabaseName("idx_vex_candidates_expires");
});
// ======================================================================
// Function-level proof tables (scanner schema, migration 019)
// ======================================================================
modelBuilder.Entity<FuncProofEntity>(entity =>
{
entity.ToTable("func_proof", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.ScanId).HasColumnName("scan_id");
entity.Property(e => e.ProofId).HasColumnName("proof_id");
entity.Property(e => e.BuildId).HasColumnName("build_id");
entity.Property(e => e.BuildIdType).HasColumnName("build_id_type");
entity.Property(e => e.FileSha256).HasColumnName("file_sha256");
entity.Property(e => e.BinaryFormat).HasColumnName("binary_format");
entity.Property(e => e.Architecture).HasColumnName("architecture");
entity.Property(e => e.IsStripped).HasColumnName("is_stripped").HasDefaultValue(false);
entity.Property(e => e.FunctionCount).HasColumnName("function_count").HasDefaultValue(0);
entity.Property(e => e.TraceCount).HasColumnName("trace_count").HasDefaultValue(0);
entity.Property(e => e.ProofContent).HasColumnName("proof_content").HasColumnType("jsonb");
entity.Property(e => e.CompressedContent).HasColumnName("compressed_content");
entity.Property(e => e.DsseEnvelopeId).HasColumnName("dsse_envelope_id");
entity.Property(e => e.OciArtifactDigest).HasColumnName("oci_artifact_digest");
entity.Property(e => e.RekorEntryId).HasColumnName("rekor_entry_id");
entity.Property(e => e.GeneratorVersion).HasColumnName("generator_version");
entity.Property(e => e.GeneratedAtUtc).HasColumnName("generated_at_utc");
entity.Property(e => e.CreatedAtUtc).HasColumnName("created_at_utc").HasDefaultValueSql("NOW()");
entity.Property(e => e.UpdatedAtUtc).HasColumnName("updated_at_utc");
entity.HasIndex(e => e.ProofId).IsUnique().HasDatabaseName("idx_func_proof_proof_id");
entity.HasIndex(e => e.BuildId).HasDatabaseName("idx_func_proof_build_id");
entity.HasIndex(e => e.FileSha256).HasDatabaseName("idx_func_proof_file_sha256");
entity.HasIndex(e => e.ScanId).HasDatabaseName("idx_func_proof_scan_id");
entity.HasIndex(e => new { e.BuildId, e.Architecture }).HasDatabaseName("idx_func_proof_build_arch");
entity.HasMany(e => e.Nodes)
.WithOne(n => n.FuncProof)
.HasForeignKey(n => n.FuncProofId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.Traces)
.WithOne(t => t.FuncProof)
.HasForeignKey(t => t.FuncProofId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<FuncNodeEntity>(entity =>
{
entity.ToTable("func_node", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.FuncProofId).HasColumnName("func_proof_id");
entity.Property(e => e.Symbol).HasColumnName("symbol");
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
entity.Property(e => e.StartAddress).HasColumnName("start_address");
entity.Property(e => e.EndAddress).HasColumnName("end_address");
entity.Property(e => e.FunctionHash).HasColumnName("function_hash");
entity.Property(e => e.Confidence).HasColumnName("confidence").HasDefaultValue(1.0);
entity.Property(e => e.IsEntrypoint).HasColumnName("is_entrypoint").HasDefaultValue(false);
entity.Property(e => e.EntrypointType).HasColumnName("entrypoint_type");
entity.Property(e => e.IsSink).HasColumnName("is_sink").HasDefaultValue(false);
entity.Property(e => e.SinkVulnId).HasColumnName("sink_vuln_id");
entity.Property(e => e.SourceFile).HasColumnName("source_file");
entity.Property(e => e.SourceLine).HasColumnName("source_line");
entity.Property(e => e.CreatedAtUtc).HasColumnName("created_at_utc").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.SymbolDigest).HasDatabaseName("idx_func_node_symbol_digest");
entity.HasIndex(e => e.FuncProofId).HasDatabaseName("idx_func_node_proof_id");
entity.HasIndex(e => e.Symbol).HasDatabaseName("idx_func_node_symbol");
});
modelBuilder.Entity<FuncTraceEntity>(entity =>
{
entity.ToTable("func_trace", schema);
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.FuncProofId).HasColumnName("func_proof_id");
entity.Property(e => e.TraceId).HasColumnName("trace_id");
entity.Property(e => e.EdgeListHash).HasColumnName("edge_list_hash");
entity.Property(e => e.HopCount).HasColumnName("hop_count");
entity.Property(e => e.EntrySymbolDigest).HasColumnName("entry_symbol_digest");
entity.Property(e => e.SinkSymbolDigest).HasColumnName("sink_symbol_digest");
entity.Property(e => e.Path).HasColumnName("path");
entity.Property(e => e.Truncated).HasColumnName("truncated").HasDefaultValue(false);
entity.Property(e => e.CreatedAtUtc).HasColumnName("created_at_utc").HasDefaultValueSql("NOW()");
entity.HasIndex(e => e.FuncProofId).HasDatabaseName("idx_func_trace_proof_id");
entity.HasIndex(e => e.EntrySymbolDigest).HasDatabaseName("idx_func_trace_entry_digest");
entity.HasIndex(e => e.SinkSymbolDigest).HasDatabaseName("idx_func_trace_sink_digest");
entity.HasIndex(e => e.EdgeListHash).HasDatabaseName("idx_func_trace_edge_hash");
});
// ======================================================================
// Facet seals (scanner schema)
// ======================================================================
modelBuilder.Entity<FacetSealEntity>(entity =>
{
entity.ToTable("facet_seals", schema);
entity.HasKey(e => e.CombinedMerkleRoot);
entity.Property(e => e.CombinedMerkleRoot).HasColumnName("combined_merkle_root");
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
entity.Property(e => e.SchemaVersion).HasColumnName("schema_version");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.BuildAttestationRef).HasColumnName("build_attestation_ref");
entity.Property(e => e.Signature).HasColumnName("signature");
entity.Property(e => e.SigningKeyId).HasColumnName("signing_key_id");
entity.Property(e => e.SealContent).HasColumnName("seal_content").HasColumnType("jsonb");
entity.HasIndex(e => e.ImageDigest).HasDatabaseName("idx_facet_seals_image_digest");
entity.HasIndex(e => new { e.ImageDigest, e.CreatedAt })
.IsDescending(false, true)
.HasDatabaseName("idx_facet_seals_image_created");
});
OnModelCreatingPartial(modelBuilder);
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class CodeChangeEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string ScanId { get; set; } = null!;
public string BaseScanId { get; set; } = null!;
public string Language { get; set; } = null!;
public string? NodeId { get; set; }
public string File { get; set; } = null!;
public string Symbol { get; set; } = null!;
public string ChangeKind { get; set; } = null!;
public string? Details { get; set; }
public DateTimeOffset DetectedAt { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class DriftedSinkEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid DriftResultId { get; set; }
public string SinkNodeId { get; set; } = null!;
public string Symbol { get; set; } = null!;
public string SinkCategory { get; set; } = null!;
public string Direction { get; set; } = null!;
public string CauseKind { get; set; } = null!;
public string CauseDescription { get; set; } = null!;
public string? CauseSymbol { get; set; }
public string? CauseFile { get; set; }
public int? CauseLine { get; set; }
public Guid? CodeChangeId { get; set; }
public string CompressedPath { get; set; } = null!;
public string? AssociatedVulns { get; set; }
public ReachabilityDriftResultEntity DriftResult { get; set; } = null!;
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class EpssRawEntity
{
public long RawId { get; set; }
public string SourceUri { get; set; } = null!;
public DateOnly AsOfDate { get; set; }
public DateTimeOffset IngestionTs { get; set; }
public string Payload { get; set; } = null!;
public byte[] PayloadSha256 { get; set; } = null!;
public string? HeaderComment { get; set; }
public string? ModelVersion { get; set; }
public DateOnly? PublishedDate { get; set; }
public int RowCount { get; set; }
public long? CompressedSize { get; set; }
public long? DecompressedSize { get; set; }
public Guid? ImportRunId { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class EpssSignalConfigEntity
{
public Guid ConfigId { get; set; }
public Guid TenantId { get; set; }
public double CriticalPercentile { get; set; }
public double HighPercentile { get; set; }
public double MediumPercentile { get; set; }
public double BigJumpDelta { get; set; }
public bool SuppressOnModelChange { get; set; }
public string[] EnabledEventTypes { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class EpssSignalEntity
{
public long SignalId { get; set; }
public Guid TenantId { get; set; }
public DateOnly ModelDate { get; set; }
public string CveId { get; set; } = null!;
public string EventType { get; set; } = null!;
public string? RiskBand { get; set; }
public double? EpssScore { get; set; }
public double? EpssDelta { get; set; }
public double? Percentile { get; set; }
public double? PercentileDelta { get; set; }
public bool IsModelChange { get; set; }
public string? ModelVersion { get; set; }
public string DedupeKey { get; set; } = null!;
public byte[] ExplainHash { get; set; } = null!;
public string Payload { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class FacetSealEntity
{
public string CombinedMerkleRoot { get; set; } = null!;
public string ImageDigest { get; set; } = null!;
public int SchemaVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? BuildAttestationRef { get; set; }
public string? Signature { get; set; }
public string? SigningKeyId { get; set; }
public string SealContent { get; set; } = null!;
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class FuncNodeEntity
{
public Guid Id { get; set; }
public Guid FuncProofId { get; set; }
public string Symbol { get; set; } = null!;
public string SymbolDigest { get; set; } = null!;
public long StartAddress { get; set; }
public long EndAddress { get; set; }
public string FunctionHash { get; set; } = null!;
public double Confidence { get; set; }
public bool IsEntrypoint { get; set; }
public string? EntrypointType { get; set; }
public bool IsSink { get; set; }
public string? SinkVulnId { get; set; }
public string? SourceFile { get; set; }
public int? SourceLine { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public FuncProofEntity FuncProof { get; set; } = null!;
}

View File

@@ -0,0 +1,28 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class FuncProofEntity
{
public Guid Id { get; set; }
public Guid ScanId { get; set; }
public string ProofId { get; set; } = null!;
public string BuildId { get; set; } = null!;
public string BuildIdType { get; set; } = null!;
public string FileSha256 { get; set; } = null!;
public string BinaryFormat { get; set; } = null!;
public string Architecture { get; set; } = null!;
public bool IsStripped { get; set; }
public int FunctionCount { get; set; }
public int TraceCount { get; set; }
public string ProofContent { get; set; } = null!;
public byte[]? CompressedContent { get; set; }
public string? DsseEnvelopeId { get; set; }
public string? OciArtifactDigest { get; set; }
public string? RekorEntryId { get; set; }
public string GeneratorVersion { get; set; } = null!;
public DateTimeOffset GeneratedAtUtc { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? UpdatedAtUtc { get; set; }
public ICollection<FuncNodeEntity> Nodes { get; set; } = new List<FuncNodeEntity>();
public ICollection<FuncTraceEntity> Traces { get; set; } = new List<FuncTraceEntity>();
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class FuncTraceEntity
{
public Guid Id { get; set; }
public Guid FuncProofId { get; set; }
public string TraceId { get; set; } = null!;
public string EdgeListHash { get; set; } = null!;
public int HopCount { get; set; }
public string EntrySymbolDigest { get; set; } = null!;
public string SinkSymbolDigest { get; set; } = null!;
public string[] Path { get; set; } = [];
public bool Truncated { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public FuncProofEntity FuncProof { get; set; } = null!;
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class ReachabilityDriftResultEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string BaseScanId { get; set; } = null!;
public string HeadScanId { get; set; } = null!;
public string Language { get; set; } = null!;
public int NewlyReachableCount { get; set; }
public int NewlyUnreachableCount { get; set; }
public DateTimeOffset DetectedAt { get; set; }
public string ResultDigest { get; set; } = null!;
public ICollection<DriftedSinkEntity> DriftedSinks { get; set; } = new List<DriftedSinkEntity>();
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Scanner.Storage.EfCore.Models;
public class VexCandidateEntity
{
public Guid Id { get; set; }
public string CandidateId { get; set; } = null!;
public Guid TenantId { get; set; }
public string VulnId { get; set; } = null!;
public string Purl { get; set; } = null!;
public string ImageDigest { get; set; } = null!;
public string SuggestedStatus { get; set; } = null!;
public string Justification { get; set; } = null!;
public string Rationale { get; set; } = null!;
public string EvidenceLinks { get; set; } = null!;
public decimal Confidence { get; set; }
public DateTimeOffset GeneratedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
public bool RequiresReview { get; set; }
public string? ReviewAction { get; set; }
public string? ReviewedBy { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public string? ReviewComment { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -11,6 +11,7 @@ ALTER TABLE scanner.runtime_observations
ADD COLUMN IF NOT EXISTS observation_id TEXT,
ADD COLUMN IF NOT EXISTS node_hash TEXT,
ADD COLUMN IF NOT EXISTS function_name TEXT,
ADD COLUMN IF NOT EXISTS container_id TEXT,
ADD COLUMN IF NOT EXISTS pod_name TEXT,
ADD COLUMN IF NOT EXISTS namespace TEXT,
ADD COLUMN IF NOT EXISTS probe_type TEXT,

View File

@@ -212,8 +212,16 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
NULL::text AS "RawBomRef",
NULL::text AS "CanonicalBomRef",
NULL::text AS "DsseEnvelopeRef",
NULL::text AS "MergedVexRef",
NULL::text AS "CanonicalBomJson",
NULL::text AS "MergedVexJson",
NULL::text AS "AttestationsJson",
evidence_score AS "EvidenceScore",
rekor_tile_id AS "RekorTileId"
rekor_tile_id AS "RekorTileId",
NULL::text AS "PendingMergedVexJson"
FROM {TableName}
WHERE payload_digest = @p0
ORDER BY inserted_at DESC, build_id ASC
@@ -246,7 +254,16 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
evidence_score AS "EvidenceScore"
NULL::text AS "RawBomRef",
NULL::text AS "CanonicalBomRef",
NULL::text AS "DsseEnvelopeRef",
NULL::text AS "MergedVexRef",
NULL::text AS "CanonicalBomJson",
NULL::text AS "MergedVexJson",
NULL::text AS "AttestationsJson",
evidence_score AS "EvidenceScore",
NULL::text AS "RekorTileId",
NULL::text AS "PendingMergedVexJson"
FROM {TableName}
WHERE jsonb_path_exists(
canonical_bom,
@@ -289,7 +306,16 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
evidence_score AS "EvidenceScore"
NULL::text AS "RawBomRef",
NULL::text AS "CanonicalBomRef",
NULL::text AS "DsseEnvelopeRef",
NULL::text AS "MergedVexRef",
NULL::text AS "CanonicalBomJson",
NULL::text AS "MergedVexJson",
NULL::text AS "AttestationsJson",
evidence_score AS "EvidenceScore",
NULL::text AS "RekorTileId",
NULL::text AS "PendingMergedVexJson"
FROM {TableName}
WHERE jsonb_path_exists(
canonical_bom,
@@ -333,7 +359,15 @@ public sealed class PostgresArtifactBomRepository : IArtifactBomRepository
canonical_bom_sha256 AS "CanonicalBomSha256",
payload_digest AS "PayloadDigest",
inserted_at AS "InsertedAt",
NULL::text AS "RawBomRef",
NULL::text AS "CanonicalBomRef",
NULL::text AS "DsseEnvelopeRef",
NULL::text AS "MergedVexRef",
NULL::text AS "CanonicalBomJson",
NULL::text AS "MergedVexJson",
NULL::text AS "AttestationsJson",
evidence_score AS "EvidenceScore",
NULL::text AS "RekorTileId",
jsonb_path_query_array(merged_vex, @p0::jsonpath)::text AS "PendingMergedVexJson"
FROM {TableName}
WHERE jsonb_path_exists(merged_vex, @p0::jsonpath)

View File

@@ -84,7 +84,7 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT snapshot_json
SELECT snapshot_json AS "Value"
FROM {CallGraphSnapshotsTable}
WHERE tenant_id = @p0 AND scan_id = @p1 AND language = @p2
ORDER BY extracted_at DESC

View File

@@ -7,6 +7,7 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
@@ -79,26 +80,16 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
public async Task<EpssRaw?> GetByDateAsync(DateOnly asOfDate, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date = @p0
ORDER BY ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<RawRow>(
sql, asOfDate.ToDateTime(TimeOnly.MinValue))
var entity = await dbContext.EpssRaw
.Where(e => e.AsOfDate == asOfDate)
.OrderByDescending(e => e.IngestionTs)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is not null && row.raw_id != 0 ? MapToRaw(row) : null;
return entity is not null ? MapEntityToRaw(entity) : null;
}
public async Task<IReadOnlyList<EpssRaw>> GetByDateRangeAsync(
@@ -106,64 +97,40 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
DateOnly endDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date >= @p0 AND asof_date <= @p1
ORDER BY asof_date DESC
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RawRow>(
sql, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue))
var entities = await dbContext.EpssRaw
.Where(e => e.AsOfDate >= startDate && e.AsOfDate <= endDate)
.OrderByDescending(e => e.AsOfDate)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToRaw).ToList();
return entities.Select(MapEntityToRaw).ToList();
}
public async Task<EpssRaw?> GetLatestAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
ORDER BY asof_date DESC, ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<RawRow>(sql)
var entity = await dbContext.EpssRaw
.OrderByDescending(e => e.AsOfDate)
.ThenByDescending(e => e.IngestionTs)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is not null && row.raw_id != 0 ? MapToRaw(row) : null;
return entity is not null ? MapEntityToRaw(entity) : null;
}
public async Task<bool> ExistsAsync(DateOnly asOfDate, byte[] payloadSha256, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT CAST(CASE WHEN EXISTS (
SELECT 1 FROM {RawTable}
WHERE asof_date = $1 AND payload_sha256 = $2
) THEN 1 ELSE 0 END AS integer)
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, connection);
cmd.Parameters.AddWithValue(asOfDate.ToDateTime(TimeOnly.MinValue));
cmd.Parameters.AddWithValue(payloadSha256);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result) == 1;
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return await dbContext.EpssRaw
.AnyAsync(e => e.AsOfDate == asOfDate && e.PayloadSha256 == payloadSha256, cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<EpssRaw>> GetByModelVersionAsync(
@@ -171,26 +138,17 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE model_version = @p0
ORDER BY asof_date DESC
LIMIT @p1
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<RawRow>(
sql, modelVersion, limit)
var entities = await dbContext.EpssRaw
.Where(e => e.ModelVersion == modelVersion)
.OrderByDescending(e => e.AsOfDate)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToRaw).ToList();
return entities.Select(MapEntityToRaw).ToList();
}
public async Task<int> PruneAsync(int retentionDays = 365, CancellationToken cancellationToken = default)
@@ -204,40 +162,23 @@ public sealed class PostgresEpssRawRepository : IEpssRawRepository
return Convert.ToInt32(result);
}
private static EpssRaw MapToRaw(RawRow row)
private static EpssRaw MapEntityToRaw(EpssRawEntity entity)
{
return new EpssRaw
{
RawId = row.raw_id,
SourceUri = row.source_uri,
AsOfDate = DateOnly.FromDateTime(row.asof_date),
IngestionTs = row.ingestion_ts,
Payload = row.payload,
PayloadSha256 = row.payload_sha256,
HeaderComment = row.header_comment,
ModelVersion = row.model_version,
PublishedDate = row.published_date.HasValue ? DateOnly.FromDateTime(row.published_date.Value) : null,
RowCount = row.row_count,
CompressedSize = row.compressed_size,
DecompressedSize = row.decompressed_size,
ImportRunId = row.import_run_id
RawId = entity.RawId,
SourceUri = entity.SourceUri,
AsOfDate = entity.AsOfDate,
IngestionTs = entity.IngestionTs,
Payload = entity.Payload,
PayloadSha256 = entity.PayloadSha256,
HeaderComment = entity.HeaderComment,
ModelVersion = entity.ModelVersion,
PublishedDate = entity.PublishedDate,
RowCount = entity.RowCount,
CompressedSize = entity.CompressedSize,
DecompressedSize = entity.DecompressedSize,
ImportRunId = entity.ImportRunId
};
}
private sealed class RawRow
{
public long raw_id { get; set; }
public string source_uri { get; set; } = "";
public DateTime asof_date { get; set; }
public DateTimeOffset ingestion_ts { get; set; }
public string payload { get; set; } = "";
public byte[] payload_sha256 { get; set; } = [];
public string? header_comment { get; set; }
public string? model_version { get; set; }
public DateTime? published_date { get; set; }
public int row_count { get; set; }
public long? compressed_size { get; set; }
public long? decompressed_size { get; set; }
public Guid? import_run_id { get; set; }
}
}

View File

@@ -8,6 +8,7 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Repositories;
using System.Text.Json;
@@ -150,44 +151,27 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
var eventTypeList = eventTypes?.ToList();
var hasEventTypeFilter = eventTypeList?.Count > 0;
var paramList = new List<object>
{
tenantId,
startDate.ToDateTime(TimeOnly.MinValue),
endDate.ToDateTime(TimeOnly.MinValue)
};
var paramIndex = 3;
var eventTypeClause = "";
if (hasEventTypeFilter)
{
eventTypeClause = $"AND event_type = ANY(@p{paramIndex})";
paramList.Add(eventTypeList!.ToArray());
}
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @p0
AND model_date >= @p1
AND model_date <= @p2
{eventTypeClause}
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, paramList.ToArray())
IQueryable<EpssSignalEntity> query = dbContext.EpssSignals
.Where(e => e.TenantId == tenantId
&& e.ModelDate >= startDate
&& e.ModelDate <= endDate);
if (hasEventTypeFilter)
{
query = query.Where(e => eventTypeList!.Contains(e.EventType));
}
var entities = await query
.OrderByDescending(e => e.ModelDate)
.ThenByDescending(e => e.CreatedAt)
.Take(10000)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToSignal).ToList();
return entities.Select(MapEntityToSignal).ToList();
}
public async Task<IReadOnlyList<EpssSignal>> GetByCveAsync(
@@ -196,27 +180,18 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @p0
AND cve_id = @p1
ORDER BY model_date DESC, created_at DESC
LIMIT @p2
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, tenantId, cveId, limit)
var entities = await dbContext.EpssSignals
.Where(e => e.TenantId == tenantId && e.CveId == cveId)
.OrderByDescending(e => e.ModelDate)
.ThenByDescending(e => e.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToSignal).ToList();
return entities.Select(MapEntityToSignal).ToList();
}
public async Task<IReadOnlyList<EpssSignal>> GetHighPriorityAsync(
@@ -225,52 +200,35 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
DateOnly endDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @p0
AND model_date >= @p1
AND model_date <= @p2
AND risk_band IN ('CRITICAL', 'HIGH')
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
string[] highPriorityBands = ["CRITICAL", "HIGH"];
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, tenantId, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue))
var entities = await dbContext.EpssSignals
.Where(e => e.TenantId == tenantId
&& e.ModelDate >= startDate
&& e.ModelDate <= endDate
&& e.RiskBand != null && highPriorityBands.Contains(e.RiskBand))
.OrderByDescending(e => e.ModelDate)
.ThenByDescending(e => e.CreatedAt)
.Take(10000)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.Select(MapToSignal).ToList();
return entities.Select(MapEntityToSignal).ToList();
}
public async Task<EpssSignalConfig?> GetConfigAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
config_id, tenant_id,
critical_percentile, high_percentile, medium_percentile,
big_jump_delta, suppress_on_model_change, enabled_event_types,
created_at, updated_at
FROM {ConfigTable}
WHERE tenant_id = @p0
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<ConfigRow>(
sql, tenantId)
.FirstOrDefaultAsync(cancellationToken)
var entity = await dbContext.EpssSignalConfigs
.FirstOrDefaultAsync(e => e.TenantId == tenantId, cancellationToken)
.ConfigureAwait(false);
return row is not null && row.config_id != Guid.Empty ? MapToConfig(row) : null;
return entity is not null ? MapEntityToConfig(entity) : null;
}
public async Task<EpssSignalConfig> UpsertConfigAsync(EpssSignalConfig config, CancellationToken cancellationToken = default)
@@ -331,97 +289,53 @@ public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
private async Task<EpssSignal?> GetByDedupeKeyAsync(Guid tenantId, string dedupeKey, CancellationToken cancellationToken)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @p0 AND dedupe_key = @p1
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantId.ToString("D"), cancellationToken);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<SignalRow>(
sql, tenantId, dedupeKey)
.FirstOrDefaultAsync(cancellationToken)
var entity = await dbContext.EpssSignals
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.DedupeKey == dedupeKey, cancellationToken)
.ConfigureAwait(false);
return row is not null && row.signal_id != 0 ? MapToSignal(row) : null;
return entity is not null ? MapEntityToSignal(entity) : null;
}
private static EpssSignal MapToSignal(SignalRow row)
private static EpssSignal MapEntityToSignal(EpssSignalEntity entity)
{
return new EpssSignal
{
SignalId = row.signal_id,
TenantId = row.tenant_id,
ModelDate = row.model_date,
CveId = row.cve_id,
EventType = row.event_type,
RiskBand = row.risk_band,
EpssScore = row.epss_score,
EpssDelta = row.epss_delta,
Percentile = row.percentile,
PercentileDelta = row.percentile_delta,
IsModelChange = row.is_model_change,
ModelVersion = row.model_version,
DedupeKey = row.dedupe_key,
ExplainHash = row.explain_hash,
Payload = row.payload,
CreatedAt = row.created_at
SignalId = entity.SignalId,
TenantId = entity.TenantId,
ModelDate = entity.ModelDate,
CveId = entity.CveId,
EventType = entity.EventType,
RiskBand = entity.RiskBand,
EpssScore = entity.EpssScore,
EpssDelta = entity.EpssDelta,
Percentile = entity.Percentile,
PercentileDelta = entity.PercentileDelta,
IsModelChange = entity.IsModelChange,
ModelVersion = entity.ModelVersion,
DedupeKey = entity.DedupeKey,
ExplainHash = entity.ExplainHash,
Payload = entity.Payload,
CreatedAt = entity.CreatedAt
};
}
private static EpssSignalConfig MapToConfig(ConfigRow row)
private static EpssSignalConfig MapEntityToConfig(EpssSignalConfigEntity entity)
{
return new EpssSignalConfig
{
ConfigId = row.config_id,
TenantId = row.tenant_id,
CriticalPercentile = row.critical_percentile,
HighPercentile = row.high_percentile,
MediumPercentile = row.medium_percentile,
BigJumpDelta = row.big_jump_delta,
SuppressOnModelChange = row.suppress_on_model_change,
EnabledEventTypes = row.enabled_event_types ?? Array.Empty<string>(),
CreatedAt = row.created_at,
UpdatedAt = row.updated_at
ConfigId = entity.ConfigId,
TenantId = entity.TenantId,
CriticalPercentile = entity.CriticalPercentile,
HighPercentile = entity.HighPercentile,
MediumPercentile = entity.MediumPercentile,
BigJumpDelta = entity.BigJumpDelta,
SuppressOnModelChange = entity.SuppressOnModelChange,
EnabledEventTypes = entity.EnabledEventTypes ?? [],
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
private sealed class SignalRow
{
public long signal_id { get; set; }
public Guid tenant_id { get; set; }
public DateOnly model_date { get; set; }
public string cve_id { get; set; } = "";
public string event_type { get; set; } = "";
public string? risk_band { get; set; }
public double? epss_score { get; set; }
public double? epss_delta { get; set; }
public double? percentile { get; set; }
public double? percentile_delta { get; set; }
public bool is_model_change { get; set; }
public string? model_version { get; set; }
public string dedupe_key { get; set; } = "";
public byte[] explain_hash { get; set; } = [];
public string payload { get; set; } = "";
public DateTimeOffset created_at { get; set; }
}
private sealed class ConfigRow
{
public Guid config_id { get; set; }
public Guid tenant_id { get; set; }
public double critical_percentile { get; set; }
public double high_percentile { get; set; }
public double medium_percentile { get; set; }
public double big_jump_delta { get; set; }
public bool suppress_on_model_change { get; set; }
public string[]? enabled_event_types { get; set; }
public DateTimeOffset created_at { get; set; }
public DateTimeOffset updated_at { get; set; }
}
}

View File

@@ -4,11 +4,13 @@
// Sprint: SPRINT_20260105_002_003_FACET (QTA-013)
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Facet;
using StellaOps.Facet.Serialization;
using StellaOps.Scanner.Storage.EfCore.Models;
using System.Collections.Immutable;
using System.Text.Json;
@@ -29,10 +31,7 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
private readonly ILogger<PostgresFacetSealStore> _logger;
private readonly TimeProvider _timeProvider;
private const string SelectColumns = """
combined_merkle_root, image_digest, schema_version, created_at,
build_attestation_ref, signature, signing_key_id, seal_content
""";
private const int DefaultCommandTimeoutSeconds = 30;
private const string InsertSql = """
INSERT INTO scanner.facet_seals (
@@ -44,40 +43,6 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
)
""";
private const string SelectLatestSql = $"""
SELECT {SelectColumns}
FROM scanner.facet_seals
WHERE image_digest = @image_digest
ORDER BY created_at DESC
LIMIT 1
""";
private const string SelectByCombinedRootSql = $"""
SELECT {SelectColumns}
FROM scanner.facet_seals
WHERE combined_merkle_root = @combined_merkle_root
""";
private const string SelectHistorySql = $"""
SELECT {SelectColumns}
FROM scanner.facet_seals
WHERE image_digest = @image_digest
ORDER BY created_at DESC
LIMIT @limit
""";
private const string ExistsSql = """
SELECT EXISTS(
SELECT 1 FROM scanner.facet_seals
WHERE image_digest = @image_digest
)
""";
private const string DeleteByImageSql = """
DELETE FROM scanner.facet_seals
WHERE image_digest = @image_digest
""";
private const string PurgeSql = """
WITH ranked AS (
SELECT combined_merkle_root, image_digest, created_at,
@@ -116,16 +81,15 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectLatestSql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
var entity = await dbContext.FacetSeals
.Where(e => e.ImageDigest == imageDigest)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return MapSeal(reader);
return entity is not null ? MapEntityToSeal(entity) : null;
}
/// <inheritdoc/>
@@ -135,16 +99,13 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
ArgumentException.ThrowIfNullOrWhiteSpace(combinedMerkleRoot);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectByCombinedRootSql, conn);
cmd.Parameters.AddWithValue("combined_merkle_root", combinedMerkleRoot);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
{
return null;
}
var entity = await dbContext.FacetSeals
.FirstOrDefaultAsync(e => e.CombinedMerkleRoot == combinedMerkleRoot, ct)
.ConfigureAwait(false);
return MapSeal(reader);
return entity is not null ? MapEntityToSeal(entity) : null;
}
/// <inheritdoc/>
@@ -158,18 +119,16 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectHistorySql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
cmd.Parameters.AddWithValue("limit", limit);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
var seals = new List<FacetSeal>();
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
seals.Add(MapSeal(reader));
}
var entities = await dbContext.FacetSeals
.Where(e => e.ImageDigest == imageDigest)
.OrderByDescending(e => e.CreatedAt)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return [.. seals];
return [.. entities.Select(MapEntityToSeal)];
}
/// <inheritdoc/>
@@ -214,11 +173,11 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(ExistsSql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is true;
return await dbContext.FacetSeals
.AnyAsync(e => e.ImageDigest == imageDigest, ct)
.ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -228,10 +187,13 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(DeleteByImageSql, conn);
cmd.Parameters.AddWithValue("image_digest", imageDigest);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
var deleted = await dbContext.FacetSeals
.Where(e => e.ImageDigest == imageDigest)
.ExecuteDeleteAsync(ct)
.ConfigureAwait(false);
var deleted = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Deleted {Count} facet seal(s) for image {ImageDigest}",
deleted, imageDigest);
return deleted;
@@ -259,16 +221,14 @@ public sealed class PostgresFacetSealStore : IFacetSealStore
return purged;
}
private static FacetSeal MapSeal(NpgsqlDataReader reader)
private static FacetSeal MapEntityToSeal(FacetSealEntity entity)
{
// Read seal from JSONB column (index 7 is seal_content)
var sealJson = reader.GetString(7);
var seal = JsonSerializer.Deserialize<FacetSeal>(sealJson, FacetJsonOptions.Default);
var seal = JsonSerializer.Deserialize<FacetSeal>(entity.SealContent, FacetJsonOptions.Default);
if (seal is null)
{
throw new InvalidOperationException(
$"Failed to deserialize facet seal from database: {reader.GetString(0)}");
$"Failed to deserialize facet seal from database: {entity.CombinedMerkleRoot}");
}
return seal;

View File

@@ -6,9 +6,11 @@
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Determinism;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Entities;
using System.Text.Json;
@@ -69,6 +71,8 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private const int DefaultCommandTimeoutSeconds = 30;
public PostgresFuncProofRepository(
NpgsqlDataSource dataSource,
TimeProvider? timeProvider = null,
@@ -135,106 +139,64 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository
public async Task<FuncProofDocumentRow?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE id = @id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("id", id);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? MapRow(reader) : null;
var entity = await dbContext.FuncProofs
.FirstOrDefaultAsync(e => e.Id == id, ct)
.ConfigureAwait(false);
return entity is not null ? MapEntityToRow(entity) : null;
}
public async Task<FuncProofDocumentRow?> GetByProofIdAsync(string proofId, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE proof_id = @proof_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("proof_id", proofId);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? MapRow(reader) : null;
var entity = await dbContext.FuncProofs
.FirstOrDefaultAsync(e => e.ProofId == proofId, ct)
.ConfigureAwait(false);
return entity is not null ? MapEntityToRow(entity) : null;
}
public async Task<IReadOnlyList<FuncProofDocumentRow>> GetByBuildIdAsync(string buildId, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE build_id = @build_id
ORDER BY generated_at_utc DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("build_id", buildId);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
var results = new List<FuncProofDocumentRow>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapRow(reader));
}
return results;
var entities = await dbContext.FuncProofs
.Where(e => e.BuildId == buildId)
.OrderByDescending(e => e.GeneratedAtUtc)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapEntityToRow).ToList();
}
public async Task<IReadOnlyList<FuncProofDocumentRow>> GetByScanIdAsync(Guid scanId, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE scan_id = @scan_id
ORDER BY build_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("scan_id", scanId);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
var results = new List<FuncProofDocumentRow>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapRow(reader));
}
return results;
var entities = await dbContext.FuncProofs
.Where(e => e.ScanId == scanId)
.OrderBy(e => e.BuildId)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapEntityToRow).ToList();
}
public async Task<bool> ExistsAsync(string proofId, CancellationToken ct = default)
{
const string sql = "SELECT EXISTS(SELECT 1 FROM scanner.func_proof WHERE proof_id = @proof_id)";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("proof_id", proofId);
await using var dbContext = ScannerDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, ScannerStorageDefaults.DefaultSchemaName);
var result = await cmd.ExecuteScalarAsync(ct);
return result is true;
return await dbContext.FuncProofs
.AnyAsync(e => e.ProofId == proofId, ct)
.ConfigureAwait(false);
}
public async Task UpdateSignatureInfoAsync(
@@ -266,30 +228,30 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository
await cmd.ExecuteNonQueryAsync(ct);
}
private static FuncProofDocumentRow MapRow(NpgsqlDataReader reader)
private static FuncProofDocumentRow MapEntityToRow(FuncProofEntity entity)
{
return new FuncProofDocumentRow
{
Id = reader.GetGuid(0),
ScanId = reader.GetGuid(1),
ProofId = reader.GetString(2),
BuildId = reader.GetString(3),
BuildIdType = reader.GetString(4),
FileSha256 = reader.GetString(5),
BinaryFormat = reader.GetString(6),
Architecture = reader.GetString(7),
IsStripped = reader.GetBoolean(8),
FunctionCount = reader.GetInt32(9),
TraceCount = reader.GetInt32(10),
ProofContent = reader.GetString(11),
CompressedContent = reader.IsDBNull(12) ? null : (byte[])reader.GetValue(12),
DsseEnvelopeId = reader.IsDBNull(13) ? null : reader.GetString(13),
OciArtifactDigest = reader.IsDBNull(14) ? null : reader.GetString(14),
RekorEntryId = reader.IsDBNull(15) ? null : reader.GetString(15),
GeneratorVersion = reader.GetString(16),
GeneratedAtUtc = reader.GetDateTime(17),
CreatedAtUtc = reader.GetDateTime(18),
UpdatedAtUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19)
Id = entity.Id,
ScanId = entity.ScanId,
ProofId = entity.ProofId,
BuildId = entity.BuildId,
BuildIdType = entity.BuildIdType,
FileSha256 = entity.FileSha256,
BinaryFormat = entity.BinaryFormat,
Architecture = entity.Architecture,
IsStripped = entity.IsStripped,
FunctionCount = entity.FunctionCount,
TraceCount = entity.TraceCount,
ProofContent = entity.ProofContent,
CompressedContent = entity.CompressedContent,
DsseEnvelopeId = entity.DsseEnvelopeId,
OciArtifactDigest = entity.OciArtifactDigest,
RekorEntryId = entity.RekorEntryId,
GeneratorVersion = entity.GeneratorVersion,
GeneratedAtUtc = entity.GeneratedAtUtc,
CreatedAtUtc = entity.CreatedAtUtc,
UpdatedAtUtc = entity.UpdatedAtUtc
};
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.Storage.EfCore.Models;
using StellaOps.Scanner.Storage.Repositories;
using System.Collections.Immutable;
using System.Text.Json;
@@ -154,76 +155,47 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
ArgumentException.ThrowIfNullOrWhiteSpace(headScanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT id, base_scan_id, head_scan_id, language, detected_at, result_digest
FROM {DriftResultsTable}
WHERE tenant_id = @p0 AND head_scan_id = @p1 AND language = @p2
ORDER BY detected_at DESC
LIMIT 1
""";
var trimmedHead = headScanId.Trim();
var trimmedLang = language.Trim();
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var header = await dbContext.Database.SqlQueryRaw<DriftHeaderRow>(
sql, tenantScope.TenantId, headScanId.Trim(), language.Trim())
var entity = await dbContext.ReachabilityDriftResults
.Include(e => e.DriftedSinks)
.Where(e => e.TenantId == tenantScope.TenantId && e.HeadScanId == trimmedHead && e.Language == trimmedLang)
.OrderByDescending(e => e.DetectedAt)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
if (header is null)
{
return null;
}
return await LoadResultAsync(connection, header, tenantScope.TenantId, ct).ConfigureAwait(false);
return entity is not null ? MapEntityToResult(entity) : null;
}
public async Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT id, base_scan_id, head_scan_id, language, detected_at, result_digest
FROM {DriftResultsTable}
WHERE tenant_id = @p0 AND id = @p1
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var header = await dbContext.Database.SqlQueryRaw<DriftHeaderRow>(
sql, tenantScope.TenantId, driftId)
.FirstOrDefaultAsync(ct)
var entity = await dbContext.ReachabilityDriftResults
.Include(e => e.DriftedSinks)
.FirstOrDefaultAsync(e => e.TenantId == tenantScope.TenantId && e.Id == driftId, ct)
.ConfigureAwait(false);
if (header is null)
{
return null;
}
return await LoadResultAsync(connection, header, tenantScope.TenantId, ct).ConfigureAwait(false);
return entity is not null ? MapEntityToResult(entity) : null;
}
public async Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT CAST(1 AS integer) AS "Value"
FROM {DriftResultsTable}
WHERE tenant_id = @p0 AND id = @p1
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<int>(
sql, tenantScope.TenantId, driftId)
.FirstOrDefaultAsync(ct)
return await dbContext.ReachabilityDriftResults
.AnyAsync(e => e.TenantId == tenantScope.TenantId && e.Id == driftId, ct)
.ConfigureAwait(false);
return result != 0;
}
public async Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
@@ -244,37 +216,20 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
throw new ArgumentOutOfRangeException(nameof(limit));
}
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
id,
sink_node_id,
symbol,
sink_category,
direction,
cause_kind,
cause_description,
cause_symbol,
cause_file,
cause_line,
code_change_id,
compressed_path,
associated_vulns
FROM {DriftedSinksTable}
WHERE tenant_id = @p0 AND drift_result_id = @p1 AND direction = @p2
ORDER BY sink_node_id ASC
OFFSET @p3 LIMIT @p4
""";
var directionValue = ToDbValue(direction);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<DriftSinkRow>(
sql, tenantScope.TenantId, driftId, ToDbValue(direction), offset, limit)
var entities = await dbContext.DriftedSinks
.Where(e => e.TenantId == tenantScope.TenantId && e.DriftResultId == driftId && e.Direction == directionValue)
.OrderBy(e => e.SinkNodeId)
.Skip(offset)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToModel(direction)).ToList();
return entities.Select(e => MapSinkEntityToModel(e, direction)).ToList();
}
private static IEnumerable<SinkInsertParams> EnumerateSinkParams(
@@ -315,61 +270,71 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
string CauseKind, string CauseDescription, string? CauseSymbol, string? CauseFile,
int? CauseLine, Guid? CodeChangeId, string CompressedPath, string? AssociatedVulns);
private async Task<ReachabilityDriftResult> LoadResultAsync(
NpgsqlConnection connection,
DriftHeaderRow header,
Guid tenantId,
CancellationToken ct)
private static ReachabilityDriftResult MapEntityToResult(ReachabilityDriftResultEntity entity)
{
var sinksSql = $"""
SELECT
id,
sink_node_id,
symbol,
sink_category,
direction,
cause_kind,
cause_description,
cause_symbol,
cause_file,
cause_line,
code_change_id,
compressed_path,
associated_vulns
FROM {DriftedSinksTable}
WHERE tenant_id = @p0 AND drift_result_id = @p1
ORDER BY direction ASC, sink_node_id ASC
""";
var sinks = entity.DriftedSinks ?? [];
var reachableDirection = ToDbValue(DriftDirection.BecameReachable);
var unreachableDirection = ToDbValue(DriftDirection.BecameUnreachable);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<DriftSinkRow>(
sinksSql, tenantId, header.id)
.ToListAsync(ct)
.ConfigureAwait(false);
var reachable = rows
.Where(r => string.Equals(r.direction, ToDbValue(DriftDirection.BecameReachable), StringComparison.Ordinal))
.Select(r => r.ToModel(DriftDirection.BecameReachable))
var reachable = sinks
.Where(s => string.Equals(s.Direction, reachableDirection, StringComparison.Ordinal))
.Select(s => MapSinkEntityToModel(s, DriftDirection.BecameReachable))
.OrderBy(s => s.SinkNodeId, StringComparer.Ordinal)
.ToImmutableArray();
var unreachable = rows
.Where(r => string.Equals(r.direction, ToDbValue(DriftDirection.BecameUnreachable), StringComparison.Ordinal))
.Select(r => r.ToModel(DriftDirection.BecameUnreachable))
var unreachable = sinks
.Where(s => string.Equals(s.Direction, unreachableDirection, StringComparison.Ordinal))
.Select(s => MapSinkEntityToModel(s, DriftDirection.BecameUnreachable))
.OrderBy(s => s.SinkNodeId, StringComparer.Ordinal)
.ToImmutableArray();
return new ReachabilityDriftResult
{
Id = header.id,
BaseScanId = header.base_scan_id,
HeadScanId = header.head_scan_id,
Language = header.language,
DetectedAt = header.detected_at,
Id = entity.Id,
BaseScanId = entity.BaseScanId,
HeadScanId = entity.HeadScanId,
Language = entity.Language,
DetectedAt = entity.DetectedAt,
NewlyReachable = reachable,
NewlyUnreachable = unreachable,
ResultDigest = header.result_digest
ResultDigest = entity.ResultDigest
};
}
private static DriftedSink MapSinkEntityToModel(DriftedSinkEntity entity, DriftDirection direction)
{
var path = JsonSerializer.Deserialize<CompressedPath>(entity.CompressedPath, JsonOptions)
?? new CompressedPath
{
Entrypoint = new PathNode { NodeId = string.Empty, Symbol = string.Empty },
Sink = new PathNode { NodeId = string.Empty, Symbol = string.Empty },
IntermediateCount = 0,
KeyNodes = ImmutableArray<PathNode>.Empty
};
var vulns = string.IsNullOrWhiteSpace(entity.AssociatedVulns)
? ImmutableArray<AssociatedVuln>.Empty
: (JsonSerializer.Deserialize<AssociatedVuln[]>(entity.AssociatedVulns!, JsonOptions) ?? [])
.ToImmutableArray();
return new DriftedSink
{
Id = entity.Id,
SinkNodeId = entity.SinkNodeId,
Symbol = entity.Symbol,
SinkCategory = ParseSinkCategory(entity.SinkCategory),
Direction = direction,
Cause = new DriftCause
{
Kind = ParseCauseKind(entity.CauseKind),
Description = entity.CauseDescription,
ChangedSymbol = entity.CauseSymbol,
ChangedFile = entity.CauseFile,
ChangedLine = entity.CauseLine,
CodeChangeId = entity.CodeChangeId
},
Path = path,
AssociatedVulns = vulns
};
}
@@ -438,67 +403,4 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
};
}
private sealed class DriftHeaderRow
{
public Guid id { get; init; }
public string base_scan_id { get; init; } = string.Empty;
public string head_scan_id { get; init; } = string.Empty;
public string language { get; init; } = string.Empty;
public DateTimeOffset detected_at { get; init; }
public string result_digest { get; init; } = string.Empty;
}
private sealed class DriftSinkRow
{
public Guid id { get; init; }
public string sink_node_id { get; init; } = string.Empty;
public string symbol { get; init; } = string.Empty;
public string sink_category { get; init; } = string.Empty;
public string direction { get; init; } = string.Empty;
public string cause_kind { get; init; } = string.Empty;
public string cause_description { get; init; } = string.Empty;
public string? cause_symbol { get; init; }
public string? cause_file { get; init; }
public int? cause_line { get; init; }
public Guid? code_change_id { get; init; }
public string compressed_path { get; init; } = "{}";
public string? associated_vulns { get; init; }
public DriftedSink ToModel(DriftDirection direction)
{
var path = JsonSerializer.Deserialize<CompressedPath>(compressed_path, JsonOptions)
?? new CompressedPath
{
Entrypoint = new PathNode { NodeId = string.Empty, Symbol = string.Empty },
Sink = new PathNode { NodeId = string.Empty, Symbol = string.Empty },
IntermediateCount = 0,
KeyNodes = ImmutableArray<PathNode>.Empty
};
var vulns = string.IsNullOrWhiteSpace(associated_vulns)
? ImmutableArray<AssociatedVuln>.Empty
: (JsonSerializer.Deserialize<AssociatedVuln[]>(associated_vulns!, JsonOptions) ?? [])
.ToImmutableArray();
return new DriftedSink
{
Id = id,
SinkNodeId = sink_node_id,
Symbol = symbol,
SinkCategory = ParseSinkCategory(sink_category),
Direction = direction,
Cause = new DriftCause
{
Kind = ParseCauseKind(cause_kind),
Description = cause_description,
ChangedSymbol = cause_symbol,
ChangedFile = cause_file,
ChangedLine = cause_line,
CodeChangeId = code_change_id
},
Path = path,
AssociatedVulns = vulns
};
}
}
}

View File

@@ -67,15 +67,20 @@ public sealed class PostgresScanManifestRepository : IScanManifestRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var result = await dbContext.Database.SqlQueryRaw<ManifestInsertResult>(
// INSERT...RETURNING is non-composable SQL; EF Core's SqlQueryRaw cannot wrap
// it in a subquery (which .FirstAsync would require). Materialize the full
// result set first, then pick the single returned row in-memory.
var results = await dbContext.Database.SqlQueryRaw<ManifestInsertResult>(
sql,
manifest.ScanId, manifest.ManifestHash, manifest.SbomHash, manifest.RulesHash,
manifest.FeedHash, manifest.PolicyHash, manifest.ScanStartedAt,
(object?)manifest.ScanCompletedAt ?? DBNull.Value,
manifest.ManifestContent, manifest.ScannerVersion)
.FirstAsync(cancellationToken)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var result = results.First();
manifest.ManifestId = result.manifest_id;
manifest.CreatedAt = result.created_at;
return manifest;

View File

@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.Storage.EfCore.Models;
using System.Collections.Immutable;
using System.Text.Json;
@@ -66,55 +67,34 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT AS suggested_status, justification::TEXT AS justification, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT AS review_action, reviewed_by, reviewed_at, review_comment
FROM {VexCandidatesTable}
WHERE tenant_id = @p0
AND image_digest = @p1
ORDER BY confidence DESC
""";
var trimmedDigest = imageDigest.Trim();
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var rows = await dbContext.Database.SqlQueryRaw<VexCandidateRow>(
sql, tenantScope.TenantId, imageDigest.Trim())
var entities = await dbContext.VexCandidates
.Where(e => e.TenantId == tenantScope.TenantId && e.ImageDigest == trimmedDigest)
.OrderByDescending(e => e.Confidence)
.ToListAsync(ct)
.ConfigureAwait(false);
return rows.Select(r => r.ToCandidate()).ToList();
return entities.Select(MapEntityToCandidate).ToList();
}
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var sql = $"""
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT AS suggested_status, justification::TEXT AS justification, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT AS review_action, reviewed_by, reviewed_at, review_comment
FROM {VexCandidatesTable}
WHERE tenant_id = @p0
AND candidate_id = @p1
""";
var trimmedId = candidateId.Trim();
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var row = await dbContext.Database.SqlQueryRaw<VexCandidateRow>(
sql, tenantScope.TenantId, candidateId.Trim())
.FirstOrDefaultAsync(ct)
var entity = await dbContext.VexCandidates
.FirstOrDefaultAsync(e => e.TenantId == tenantScope.TenantId && e.CandidateId == trimmedId, ct)
.ConfigureAwait(false);
return row?.ToCandidate();
return entity is not null ? MapEntityToCandidate(entity) : null;
}
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default, string? tenantId = null)
@@ -232,70 +212,47 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
};
}
/// <summary>
/// Row mapping class for EF Core SqlQueryRaw.
/// </summary>
private sealed class VexCandidateRow
private static VexCandidate MapEntityToCandidate(VexCandidateEntity entity)
{
public string candidate_id { get; set; } = "";
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public string image_digest { get; set; } = "";
public string suggested_status { get; set; } = "not_affected";
public string justification { get; set; } = "vulnerable_code_not_present";
public string rationale { get; set; } = "";
public string evidence_links { get; set; } = "[]";
public decimal confidence { get; set; }
public DateTimeOffset generated_at { get; set; }
public DateTimeOffset expires_at { get; set; }
public bool requires_review { get; set; }
public string? review_action { get; set; }
public string? reviewed_by { get; set; }
public DateTimeOffset? reviewed_at { get; set; }
public string? review_comment { get; set; }
var links = JsonSerializer.Deserialize<List<EvidenceLink>>(entity.EvidenceLinks, JsonOptions)
?? [];
public VexCandidate ToCandidate()
return new VexCandidate(
CandidateId: entity.CandidateId,
FindingKey: new FindingKey(entity.VulnId, entity.Purl),
SuggestedStatus: ParseVexStatus(entity.SuggestedStatus),
Justification: ParseJustification(entity.Justification),
Rationale: entity.Rationale,
EvidenceLinks: [.. links],
Confidence: (double)entity.Confidence,
ImageDigest: entity.ImageDigest,
GeneratedAt: entity.GeneratedAt,
ExpiresAt: entity.ExpiresAt,
RequiresReview: entity.RequiresReview);
}
private static VexStatusType ParseVexStatus(string value)
{
return value.ToLowerInvariant() switch
{
var links = JsonSerializer.Deserialize<List<EvidenceLink>>(evidence_links, JsonOptions)
?? [];
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
return new VexCandidate(
CandidateId: candidate_id,
FindingKey: new FindingKey(vuln_id, purl),
SuggestedStatus: ParseVexStatus(suggested_status),
Justification: ParseJustification(justification),
Rationale: rationale,
EvidenceLinks: [.. links],
Confidence: (double)confidence,
ImageDigest: image_digest,
GeneratedAt: generated_at,
ExpiresAt: expires_at,
RequiresReview: requires_review);
}
private static VexStatusType ParseVexStatus(string value)
private static VexJustification ParseJustification(string value)
{
return value.ToLowerInvariant() switch
{
return value.ToLowerInvariant() switch
{
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
private static VexJustification ParseJustification(string value)
{
return value.ToLowerInvariant() switch
{
"component_not_present" => VexJustification.ComponentNotPresent,
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
_ => VexJustification.VulnerableCodeNotPresent
};
}
"component_not_present" => VexJustification.ComponentNotPresent,
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
_ => VexJustification.VulnerableCodeNotPresent
};
}
}