feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view representing the current state of a triage case,
|
||||
/// combining the latest risk, reachability, and VEX data.
|
||||
/// </summary>
|
||||
[Keyless]
|
||||
public sealed class TriageCaseCurrent
|
||||
{
|
||||
/// <summary>
|
||||
/// The case/finding ID.
|
||||
/// </summary>
|
||||
[Column("case_id")]
|
||||
public Guid CaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The asset ID.
|
||||
/// </summary>
|
||||
[Column("asset_id")]
|
||||
public Guid AssetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environment ID.
|
||||
/// </summary>
|
||||
[Column("environment_id")]
|
||||
public Guid? EnvironmentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable asset label.
|
||||
/// </summary>
|
||||
[Column("asset_label")]
|
||||
public string AssetLabel { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package URL of the affected component.
|
||||
/// </summary>
|
||||
[Column("purl")]
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier (if vulnerability finding).
|
||||
/// </summary>
|
||||
[Column("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule identifier (if policy rule finding).
|
||||
/// </summary>
|
||||
[Column("rule_id")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was first seen.
|
||||
/// </summary>
|
||||
[Column("first_seen_at")]
|
||||
public DateTimeOffset FirstSeenAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was last seen.
|
||||
/// </summary>
|
||||
[Column("last_seen_at")]
|
||||
public DateTimeOffset LastSeenAt { get; init; }
|
||||
|
||||
// Latest risk result fields
|
||||
|
||||
/// <summary>
|
||||
/// Policy ID from latest risk evaluation.
|
||||
/// </summary>
|
||||
[Column("policy_id")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version from latest risk evaluation.
|
||||
/// </summary>
|
||||
[Column("policy_version")]
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inputs hash from latest risk evaluation.
|
||||
/// </summary>
|
||||
[Column("inputs_hash")]
|
||||
public string? InputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score (0-100).
|
||||
/// </summary>
|
||||
[Column("score")]
|
||||
public int? Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final verdict.
|
||||
/// </summary>
|
||||
[Column("verdict")]
|
||||
public TriageVerdict? Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current triage lane.
|
||||
/// </summary>
|
||||
[Column("lane")]
|
||||
public TriageLane? Lane { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Short narrative explaining the current state.
|
||||
/// </summary>
|
||||
[Column("why")]
|
||||
public string? Why { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the risk was last computed.
|
||||
/// </summary>
|
||||
[Column("risk_computed_at")]
|
||||
public DateTimeOffset? RiskComputedAt { get; init; }
|
||||
|
||||
// Latest reachability fields
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination.
|
||||
/// </summary>
|
||||
[Column("reachable")]
|
||||
public TriageReachability Reachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability confidence (0-100).
|
||||
/// </summary>
|
||||
[Column("reach_confidence")]
|
||||
public short? ReachConfidence { get; init; }
|
||||
|
||||
// Latest VEX fields
|
||||
|
||||
/// <summary>
|
||||
/// VEX status.
|
||||
/// </summary>
|
||||
[Column("vex_status")]
|
||||
public TriageVexStatus? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX issuer.
|
||||
/// </summary>
|
||||
[Column("vex_issuer")]
|
||||
public string? VexIssuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX signature reference.
|
||||
/// </summary>
|
||||
[Column("vex_signature_ref")]
|
||||
public string? VexSignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX source domain.
|
||||
/// </summary>
|
||||
[Column("vex_source_domain")]
|
||||
public string? VexSourceDomain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX source reference.
|
||||
/// </summary>
|
||||
[Column("vex_source_ref")]
|
||||
public string? VexSourceRef { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Signed triage decision (mute, ack, exception). Decisions are reversible via revocation.
|
||||
/// </summary>
|
||||
[Table("triage_decision")]
|
||||
public sealed class TriageDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this decision applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of decision.
|
||||
/// </summary>
|
||||
[Column("kind")]
|
||||
public TriageDecisionKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason code for the decision (from a controlled vocabulary).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("reason_code")]
|
||||
public required string ReasonCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional freeform note from the decision maker.
|
||||
/// </summary>
|
||||
[Column("note")]
|
||||
public string? Note { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that allowed this decision.
|
||||
/// </summary>
|
||||
[Column("policy_ref")]
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for the decision (null = indefinite).
|
||||
/// </summary>
|
||||
[Column("ttl")]
|
||||
public DateTimeOffset? Ttl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority subject (sub) of the actor who made the decision.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("actor_subject")]
|
||||
public required string ActorSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the actor.
|
||||
/// </summary>
|
||||
[Column("actor_display")]
|
||||
public string? ActorDisplay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to DSSE signature.
|
||||
/// </summary>
|
||||
[Column("signature_ref")]
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the DSSE envelope.
|
||||
/// </summary>
|
||||
[Column("dsse_hash")]
|
||||
public string? DsseHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was revoked (null = active).
|
||||
/// </summary>
|
||||
[Column("revoked_at")]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_reason")]
|
||||
public string? RevokeReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature reference for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_signature_ref")]
|
||||
public string? RevokeSignatureRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE hash for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_dsse_hash")]
|
||||
public string? RevokeDsseHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this decision is currently active.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsActive => RevokedAt is null;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Effective VEX status for a finding after merging multiple VEX sources.
|
||||
/// Preserves provenance pointers for auditability.
|
||||
/// </summary>
|
||||
[Table("triage_effective_vex")]
|
||||
public sealed class TriageEffectiveVex
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this VEX status applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The effective VEX status after merging.
|
||||
/// </summary>
|
||||
[Column("status")]
|
||||
public TriageVexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source domain that provided this VEX (e.g., "excititor").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("source_domain")]
|
||||
public required string SourceDomain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable reference string to the source document.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("source_ref")]
|
||||
public required string SourceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Array of pruned VEX sources with reasons (for merge transparency).
|
||||
/// </summary>
|
||||
[Column("pruned_sources", TypeName = "jsonb")]
|
||||
public string? PrunedSourcesJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the DSSE envelope if signed.
|
||||
/// </summary>
|
||||
[Column("dsse_envelope_hash")]
|
||||
public string? DsseEnvelopeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor/ledger entry for signature verification.
|
||||
/// </summary>
|
||||
[Column("signature_ref")]
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of the VEX document.
|
||||
/// </summary>
|
||||
[Column("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this VEX status became valid.
|
||||
/// </summary>
|
||||
[Column("valid_from")]
|
||||
public DateTimeOffset ValidFrom { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When this VEX status expires (null = indefinite).
|
||||
/// </summary>
|
||||
[Column("valid_to")]
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this record was collected.
|
||||
/// </summary>
|
||||
[Column("collected_at")]
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Triage lane indicating the current workflow state of a finding.
|
||||
/// </summary>
|
||||
public enum TriageLane
|
||||
{
|
||||
/// <summary>Finding is actively being evaluated.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Finding is blocking shipment.</summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>Finding requires a security exception to proceed.</summary>
|
||||
NeedsException,
|
||||
|
||||
/// <summary>Finding is muted due to reachability analysis (not reachable).</summary>
|
||||
MutedReach,
|
||||
|
||||
/// <summary>Finding is muted due to VEX status (not affected).</summary>
|
||||
MutedVex,
|
||||
|
||||
/// <summary>Finding is mitigated by compensating controls.</summary>
|
||||
Compensated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final verdict for a triage case.
|
||||
/// </summary>
|
||||
public enum TriageVerdict
|
||||
{
|
||||
/// <summary>Can ship - no blocking issues.</summary>
|
||||
Ship,
|
||||
|
||||
/// <summary>Cannot ship - blocking issues present.</summary>
|
||||
Block,
|
||||
|
||||
/// <summary>Exception granted - can ship with documented exception.</summary>
|
||||
Exception
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination result.
|
||||
/// </summary>
|
||||
public enum TriageReachability
|
||||
{
|
||||
/// <summary>Vulnerable code is reachable.</summary>
|
||||
Yes,
|
||||
|
||||
/// <summary>Vulnerable code is not reachable.</summary>
|
||||
No,
|
||||
|
||||
/// <summary>Reachability cannot be determined.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status per OpenVEX specification.
|
||||
/// </summary>
|
||||
public enum TriageVexStatus
|
||||
{
|
||||
/// <summary>Product is affected by the vulnerability.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Product is not affected by the vulnerability.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Investigation is ongoing.</summary>
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>Status is unknown.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of triage decision.
|
||||
/// </summary>
|
||||
public enum TriageDecisionKind
|
||||
{
|
||||
/// <summary>Mute based on reachability analysis.</summary>
|
||||
MuteReach,
|
||||
|
||||
/// <summary>Mute based on VEX status.</summary>
|
||||
MuteVex,
|
||||
|
||||
/// <summary>Acknowledge the finding without action.</summary>
|
||||
Ack,
|
||||
|
||||
/// <summary>Grant a security exception.</summary>
|
||||
Exception
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger that caused a triage snapshot to be created.
|
||||
/// </summary>
|
||||
public enum TriageSnapshotTrigger
|
||||
{
|
||||
/// <summary>Vulnerability feed was updated.</summary>
|
||||
FeedUpdate,
|
||||
|
||||
/// <summary>VEX document was updated.</summary>
|
||||
VexUpdate,
|
||||
|
||||
/// <summary>SBOM was updated.</summary>
|
||||
SbomUpdate,
|
||||
|
||||
/// <summary>Runtime trace was received.</summary>
|
||||
RuntimeTrace,
|
||||
|
||||
/// <summary>Policy was updated.</summary>
|
||||
PolicyUpdate,
|
||||
|
||||
/// <summary>A triage decision was made.</summary>
|
||||
Decision,
|
||||
|
||||
/// <summary>Manual rescan was triggered.</summary>
|
||||
Rescan
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence artifact attached to a finding.
|
||||
/// </summary>
|
||||
public enum TriageEvidenceType
|
||||
{
|
||||
/// <summary>Slice of the SBOM relevant to the finding.</summary>
|
||||
SbomSlice,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
VexDoc,
|
||||
|
||||
/// <summary>Build provenance attestation.</summary>
|
||||
Provenance,
|
||||
|
||||
/// <summary>Callstack or callgraph slice.</summary>
|
||||
CallstackSlice,
|
||||
|
||||
/// <summary>Reachability proof document.</summary>
|
||||
ReachabilityProof,
|
||||
|
||||
/// <summary>Replay manifest for deterministic reproduction.</summary>
|
||||
ReplayManifest,
|
||||
|
||||
/// <summary>Policy document that was applied.</summary>
|
||||
Policy,
|
||||
|
||||
/// <summary>Scan log output.</summary>
|
||||
ScanLog,
|
||||
|
||||
/// <summary>Other evidence type.</summary>
|
||||
Other
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence artifact attached to a finding. Hash-addressed and optionally signed.
|
||||
/// </summary>
|
||||
[Table("triage_evidence_artifact")]
|
||||
public sealed class TriageEvidenceArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this evidence applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of evidence.
|
||||
/// </summary>
|
||||
[Column("type")]
|
||||
public TriageEvidenceType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable title for the evidence.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of the evidence (if applicable).
|
||||
/// </summary>
|
||||
[Column("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the evidence is cryptographically signed.
|
||||
/// </summary>
|
||||
[Column("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entity that signed the evidence.
|
||||
/// </summary>
|
||||
[Column("signed_by")]
|
||||
public string? SignedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable hash of the artifact.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("content_hash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the signature.
|
||||
/// </summary>
|
||||
[Column("signature_ref")]
|
||||
public string? SignatureRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME type of the artifact.
|
||||
/// </summary>
|
||||
[Column("media_type")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI to the artifact (object store, file path, or inline reference).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the artifact in bytes.
|
||||
/// </summary>
|
||||
[Column("size_bytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata (JSON).
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public string? MetadataJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this artifact was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a triage finding (case). This is the core entity that ties
|
||||
/// together all triage-related data for a specific vulnerability/rule
|
||||
/// on a specific asset.
|
||||
/// </summary>
|
||||
[Table("triage_finding")]
|
||||
public sealed class TriageFinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the finding (also serves as the case ID).
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The asset this finding applies to.
|
||||
/// </summary>
|
||||
[Column("asset_id")]
|
||||
public Guid AssetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environment identifier (e.g., prod, staging).
|
||||
/// </summary>
|
||||
[Column("environment_id")]
|
||||
public Guid? EnvironmentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable asset label (e.g., "prod/api-gateway:1.2.3").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("asset_label")]
|
||||
public required string AssetLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL identifying the affected component.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier if this is a vulnerability finding.
|
||||
/// </summary>
|
||||
[Column("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule identifier if this is a policy rule finding.
|
||||
/// </summary>
|
||||
[Column("rule_id")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was first observed.
|
||||
/// </summary>
|
||||
[Column("first_seen_at")]
|
||||
public DateTimeOffset FirstSeenAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was last observed.
|
||||
/// </summary>
|
||||
[Column("last_seen_at")]
|
||||
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<TriageEffectiveVex> EffectiveVexRecords { get; init; } = new List<TriageEffectiveVex>();
|
||||
public ICollection<TriageReachabilityResult> ReachabilityResults { get; init; } = new List<TriageReachabilityResult>();
|
||||
public ICollection<TriageRiskResult> RiskResults { get; init; } = new List<TriageRiskResult>();
|
||||
public ICollection<TriageDecision> Decisions { get; init; } = new List<TriageDecision>();
|
||||
public ICollection<TriageEvidenceArtifact> EvidenceArtifacts { get; init; } = new List<TriageEvidenceArtifact>();
|
||||
public ICollection<TriageSnapshot> Snapshots { get; init; } = new List<TriageSnapshot>();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis result for a finding.
|
||||
/// </summary>
|
||||
[Table("triage_reachability_result")]
|
||||
public sealed class TriageReachabilityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this reachability result applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination (Yes, No, Unknown).
|
||||
/// </summary>
|
||||
[Column("reachable")]
|
||||
public TriageReachability Reachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level (0-100).
|
||||
/// </summary>
|
||||
[Column("confidence")]
|
||||
[Range(0, 100)]
|
||||
public short Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to static analysis proof (callgraph slice, CFG slice).
|
||||
/// </summary>
|
||||
[Column("static_proof_ref")]
|
||||
public string? StaticProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to runtime proof (runtime trace hits).
|
||||
/// </summary>
|
||||
[Column("runtime_proof_ref")]
|
||||
public string? RuntimeProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the inputs used to compute reachability (for caching/diffing).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("inputs_hash")]
|
||||
public required string InputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this result was computed.
|
||||
/// </summary>
|
||||
[Column("computed_at")]
|
||||
public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Risk/lattice result from the scanner's policy evaluation.
|
||||
/// </summary>
|
||||
[Table("triage_risk_result")]
|
||||
public sealed class TriageRiskResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this risk result applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy that was applied.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("policy_id")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the policy that was applied.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("policy_version")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the inputs used for this evaluation.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("inputs_hash")]
|
||||
public required string InputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed risk score (0-100).
|
||||
/// </summary>
|
||||
[Column("score")]
|
||||
[Range(0, 100)]
|
||||
public int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final verdict (Ship, Block, Exception).
|
||||
/// </summary>
|
||||
[Column("verdict")]
|
||||
public TriageVerdict Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current lane based on policy evaluation.
|
||||
/// </summary>
|
||||
[Column("lane")]
|
||||
public TriageLane Lane { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Short narrative explaining the decision.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("why")]
|
||||
public required string Why { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Structured lattice explanation for UI diffing (JSON).
|
||||
/// </summary>
|
||||
[Column("explanation", TypeName = "jsonb")]
|
||||
public string? ExplanationJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this result was computed.
|
||||
/// </summary>
|
||||
[Column("computed_at")]
|
||||
public DateTimeOffset ComputedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot record for Smart-Diff, capturing input/output changes.
|
||||
/// </summary>
|
||||
[Table("triage_snapshot")]
|
||||
public sealed class TriageSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The finding this snapshot applies to.
|
||||
/// </summary>
|
||||
[Column("finding_id")]
|
||||
public Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// What triggered this snapshot.
|
||||
/// </summary>
|
||||
[Column("trigger")]
|
||||
public TriageSnapshotTrigger Trigger { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous inputs hash (null for first snapshot).
|
||||
/// </summary>
|
||||
[Column("from_inputs_hash")]
|
||||
public string? FromInputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New inputs hash.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("to_inputs_hash")]
|
||||
public required string ToInputsHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of what changed.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Precomputed diff in JSON format (optional).
|
||||
/// </summary>
|
||||
[Column("diff_json", TypeName = "jsonb")]
|
||||
public string? DiffJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this snapshot was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Navigation property
|
||||
[ForeignKey(nameof(FindingId))]
|
||||
public TriageFinding? Finding { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
-- Stella Ops Triage Schema Migration
|
||||
-- Generated from docs/db/triage_schema.sql
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Enums
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_lane') THEN
|
||||
CREATE TYPE triage_lane AS ENUM (
|
||||
'ACTIVE',
|
||||
'BLOCKED',
|
||||
'NEEDS_EXCEPTION',
|
||||
'MUTED_REACH',
|
||||
'MUTED_VEX',
|
||||
'COMPENSATED'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_verdict') THEN
|
||||
CREATE TYPE triage_verdict AS ENUM ('SHIP', 'BLOCK', 'EXCEPTION');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_reachability') THEN
|
||||
CREATE TYPE triage_reachability AS ENUM ('YES', 'NO', 'UNKNOWN');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_vex_status') THEN
|
||||
CREATE TYPE triage_vex_status AS ENUM ('affected', 'not_affected', 'under_investigation', 'unknown');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_decision_kind') THEN
|
||||
CREATE TYPE triage_decision_kind AS ENUM ('MUTE_REACH', 'MUTE_VEX', 'ACK', 'EXCEPTION');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_snapshot_trigger') THEN
|
||||
CREATE TYPE triage_snapshot_trigger AS ENUM (
|
||||
'FEED_UPDATE',
|
||||
'VEX_UPDATE',
|
||||
'SBOM_UPDATE',
|
||||
'RUNTIME_TRACE',
|
||||
'POLICY_UPDATE',
|
||||
'DECISION',
|
||||
'RESCAN'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_evidence_type') THEN
|
||||
CREATE TYPE triage_evidence_type AS ENUM (
|
||||
'SBOM_SLICE',
|
||||
'VEX_DOC',
|
||||
'PROVENANCE',
|
||||
'CALLSTACK_SLICE',
|
||||
'REACHABILITY_PROOF',
|
||||
'REPLAY_MANIFEST',
|
||||
'POLICY',
|
||||
'SCAN_LOG',
|
||||
'OTHER'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Core: finding (caseId == findingId)
|
||||
CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_id uuid NOT NULL,
|
||||
environment_id uuid NULL,
|
||||
asset_label text NOT NULL,
|
||||
purl text NOT NULL,
|
||||
cve_id text NULL,
|
||||
rule_id text NULL,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (asset_id, environment_id, purl, cve_id, rule_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_last_seen ON triage_finding (last_seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asset_label);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id);
|
||||
|
||||
-- Effective VEX (post-merge)
|
||||
CREATE TABLE IF NOT EXISTS triage_effective_vex (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
status triage_vex_status NOT NULL,
|
||||
source_domain text NOT NULL,
|
||||
source_ref text NOT NULL,
|
||||
pruned_sources jsonb NULL,
|
||||
dsse_envelope_hash text NULL,
|
||||
signature_ref text NULL,
|
||||
issuer text NULL,
|
||||
valid_from timestamptz NOT NULL DEFAULT now(),
|
||||
valid_to timestamptz NULL,
|
||||
collected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_effective_vex_finding ON triage_effective_vex (finding_id, collected_at DESC);
|
||||
|
||||
-- Reachability results
|
||||
CREATE TABLE IF NOT EXISTS triage_reachability_result (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
reachable triage_reachability NOT NULL,
|
||||
confidence smallint NOT NULL CHECK (confidence >= 0 AND confidence <= 100),
|
||||
static_proof_ref text NULL,
|
||||
runtime_proof_ref text NULL,
|
||||
inputs_hash text NOT NULL,
|
||||
computed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_reachability_finding ON triage_reachability_result (finding_id, computed_at DESC);
|
||||
|
||||
-- Risk/lattice result
|
||||
CREATE TABLE IF NOT EXISTS triage_risk_result (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
policy_id text NOT NULL,
|
||||
policy_version text NOT NULL,
|
||||
inputs_hash text NOT NULL,
|
||||
score int NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||
verdict triage_verdict NOT NULL,
|
||||
lane triage_lane NOT NULL,
|
||||
why text NOT NULL,
|
||||
explanation jsonb NULL,
|
||||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, policy_id, policy_version, inputs_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_risk_finding ON triage_risk_result (finding_id, computed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_risk_lane ON triage_risk_result (lane, computed_at DESC);
|
||||
|
||||
-- Signed Decisions
|
||||
CREATE TABLE IF NOT EXISTS triage_decision (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
kind triage_decision_kind NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
note text NULL,
|
||||
policy_ref text NULL,
|
||||
ttl timestamptz NULL,
|
||||
actor_subject text NOT NULL,
|
||||
actor_display text NULL,
|
||||
signature_ref text NULL,
|
||||
dsse_hash text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz NULL,
|
||||
revoke_reason text NULL,
|
||||
revoke_signature_ref text NULL,
|
||||
revoke_dsse_hash text NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_finding ON triage_decision (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_kind ON triage_decision (kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_active ON triage_decision (finding_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- Evidence artifacts
|
||||
CREATE TABLE IF NOT EXISTS triage_evidence_artifact (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
type triage_evidence_type NOT NULL,
|
||||
title text NOT NULL,
|
||||
issuer text NULL,
|
||||
signed boolean NOT NULL DEFAULT false,
|
||||
signed_by text NULL,
|
||||
content_hash text NOT NULL,
|
||||
signature_ref text NULL,
|
||||
media_type text NULL,
|
||||
uri text NOT NULL,
|
||||
size_bytes bigint NULL,
|
||||
metadata jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, type, content_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_evidence_finding ON triage_evidence_artifact (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_evidence_type ON triage_evidence_artifact (type, created_at DESC);
|
||||
|
||||
-- Snapshots for Smart-Diff
|
||||
CREATE TABLE IF NOT EXISTS triage_snapshot (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
trigger triage_snapshot_trigger NOT NULL,
|
||||
from_inputs_hash text NULL,
|
||||
to_inputs_hash text NOT NULL,
|
||||
summary text NOT NULL,
|
||||
diff_json jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, to_inputs_hash, created_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC);
|
||||
|
||||
-- Current-case view
|
||||
CREATE OR REPLACE VIEW v_triage_case_current AS
|
||||
WITH latest_risk AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, policy_id, policy_version, inputs_hash, score, verdict, lane, why, computed_at
|
||||
FROM triage_risk_result
|
||||
ORDER BY finding_id, computed_at DESC
|
||||
),
|
||||
latest_reach AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, reachable, confidence, static_proof_ref, runtime_proof_ref, computed_at
|
||||
FROM triage_reachability_result
|
||||
ORDER BY finding_id, computed_at DESC
|
||||
),
|
||||
latest_vex AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, status, issuer, signature_ref, source_domain, source_ref, collected_at
|
||||
FROM triage_effective_vex
|
||||
ORDER BY finding_id, collected_at DESC
|
||||
)
|
||||
SELECT
|
||||
f.id AS case_id,
|
||||
f.asset_id,
|
||||
f.environment_id,
|
||||
f.asset_label,
|
||||
f.purl,
|
||||
f.cve_id,
|
||||
f.rule_id,
|
||||
f.first_seen_at,
|
||||
f.last_seen_at,
|
||||
r.policy_id,
|
||||
r.policy_version,
|
||||
r.inputs_hash,
|
||||
r.score,
|
||||
r.verdict,
|
||||
r.lane,
|
||||
r.why,
|
||||
r.computed_at AS risk_computed_at,
|
||||
coalesce(re.reachable, 'UNKNOWN'::triage_reachability) AS reachable,
|
||||
re.confidence AS reach_confidence,
|
||||
v.status AS vex_status,
|
||||
v.issuer AS vex_issuer,
|
||||
v.signature_ref AS vex_signature_ref,
|
||||
v.source_domain AS vex_source_domain,
|
||||
v.source_ref AS vex_source_ref
|
||||
FROM triage_finding f
|
||||
LEFT JOIN latest_risk r ON r.finding_id = f.id
|
||||
LEFT JOIN latest_reach re ON re.finding_id = f.id
|
||||
LEFT JOIN latest_vex v ON v.finding_id = f.id;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Scanner.Triage</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0-*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
namespace StellaOps.Scanner.Triage;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for the Triage schema.
|
||||
/// </summary>
|
||||
public sealed class TriageDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TriageDbContext"/> class.
|
||||
/// </summary>
|
||||
public TriageDbContext(DbContextOptions<TriageDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triage findings (cases).
|
||||
/// </summary>
|
||||
public DbSet<TriageFinding> Findings => Set<TriageFinding>();
|
||||
|
||||
/// <summary>
|
||||
/// Effective VEX records.
|
||||
/// </summary>
|
||||
public DbSet<TriageEffectiveVex> EffectiveVex => Set<TriageEffectiveVex>();
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis results.
|
||||
/// </summary>
|
||||
public DbSet<TriageReachabilityResult> ReachabilityResults => Set<TriageReachabilityResult>();
|
||||
|
||||
/// <summary>
|
||||
/// Risk/lattice evaluation results.
|
||||
/// </summary>
|
||||
public DbSet<TriageRiskResult> RiskResults => Set<TriageRiskResult>();
|
||||
|
||||
/// <summary>
|
||||
/// Triage decisions.
|
||||
/// </summary>
|
||||
public DbSet<TriageDecision> Decisions => Set<TriageDecision>();
|
||||
|
||||
/// <summary>
|
||||
/// Evidence artifacts.
|
||||
/// </summary>
|
||||
public DbSet<TriageEvidenceArtifact> EvidenceArtifacts => Set<TriageEvidenceArtifact>();
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots for Smart-Diff.
|
||||
/// </summary>
|
||||
public DbSet<TriageSnapshot> Snapshots => Set<TriageSnapshot>();
|
||||
|
||||
/// <summary>
|
||||
/// Current case view (read-only).
|
||||
/// </summary>
|
||||
public DbSet<TriageCaseCurrent> CurrentCases => Set<TriageCaseCurrent>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure PostgreSQL enums
|
||||
modelBuilder.HasPostgresEnum<TriageLane>("triage_lane");
|
||||
modelBuilder.HasPostgresEnum<TriageVerdict>("triage_verdict");
|
||||
modelBuilder.HasPostgresEnum<TriageReachability>("triage_reachability");
|
||||
modelBuilder.HasPostgresEnum<TriageVexStatus>("triage_vex_status");
|
||||
modelBuilder.HasPostgresEnum<TriageDecisionKind>("triage_decision_kind");
|
||||
modelBuilder.HasPostgresEnum<TriageSnapshotTrigger>("triage_snapshot_trigger");
|
||||
modelBuilder.HasPostgresEnum<TriageEvidenceType>("triage_evidence_type");
|
||||
|
||||
// Configure TriageFinding
|
||||
modelBuilder.Entity<TriageFinding>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_finding");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => e.LastSeenAt)
|
||||
.IsDescending()
|
||||
.HasDatabaseName("ix_triage_finding_last_seen");
|
||||
|
||||
entity.HasIndex(e => e.AssetLabel)
|
||||
.HasDatabaseName("ix_triage_finding_asset_label");
|
||||
|
||||
entity.HasIndex(e => e.Purl)
|
||||
.HasDatabaseName("ix_triage_finding_purl");
|
||||
|
||||
entity.HasIndex(e => e.CveId)
|
||||
.HasDatabaseName("ix_triage_finding_cve");
|
||||
|
||||
entity.HasIndex(e => new { e.AssetId, e.EnvironmentId, e.Purl, e.CveId, e.RuleId })
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
// Configure TriageEffectiveVex
|
||||
modelBuilder.Entity<TriageEffectiveVex>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_effective_vex");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CollectedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_effective_vex_finding");
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.EffectiveVexRecords)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageReachabilityResult
|
||||
modelBuilder.Entity<TriageReachabilityResult>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_reachability_result");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.ComputedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_reachability_finding");
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.ReachabilityResults)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageRiskResult
|
||||
modelBuilder.Entity<TriageRiskResult>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_risk_result");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.ComputedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_risk_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Lane, e.ComputedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_risk_lane");
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.PolicyId, e.PolicyVersion, e.InputsHash })
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.RiskResults)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageDecision
|
||||
modelBuilder.Entity<TriageDecision>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_decision");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_decision_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Kind, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_decision_kind");
|
||||
|
||||
entity.HasIndex(e => e.FindingId)
|
||||
.HasFilter("revoked_at IS NULL")
|
||||
.HasDatabaseName("ix_triage_decision_active");
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.Decisions)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageEvidenceArtifact
|
||||
modelBuilder.Entity<TriageEvidenceArtifact>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_evidence_artifact");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_evidence_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Type, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_evidence_type");
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.Type, e.ContentHash })
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.EvidenceArtifacts)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure TriageSnapshot
|
||||
modelBuilder.Entity<TriageSnapshot>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_snapshot");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_snapshot_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.Trigger, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_snapshot_trigger");
|
||||
|
||||
entity.HasIndex(e => new { e.FindingId, e.ToInputsHash, e.CreatedAt })
|
||||
.IsUnique();
|
||||
|
||||
entity.HasOne(e => e.Finding)
|
||||
.WithMany(f => f.Snapshots)
|
||||
.HasForeignKey(e => e.FindingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure the read-only view
|
||||
modelBuilder.Entity<TriageCaseCurrent>(entity =>
|
||||
{
|
||||
entity.ToView("v_triage_case_current");
|
||||
entity.HasNoKey();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user