UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization
Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
using StellaOps.SbomService.Lineage.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection extensions for lineage services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add SBOM lineage services to the container.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddLineageServices(this IServiceCollection services)
|
||||
{
|
||||
// Data source
|
||||
services.AddSingleton<LineageDataSource>();
|
||||
|
||||
// Repositories
|
||||
services.AddScoped<ISbomLineageEdgeRepository, SbomLineageEdgeRepository>();
|
||||
services.AddScoped<IVexDeltaRepository, VexDeltaRepository>();
|
||||
services.AddScoped<ISbomVerdictLinkRepository, SbomVerdictLinkRepository>();
|
||||
|
||||
// Services
|
||||
services.AddScoped<ILineageGraphService, LineageGraphService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
namespace StellaOps.SbomService.Lineage.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a node in the SBOM lineage graph.
|
||||
/// </summary>
|
||||
public sealed record LineageNode(
|
||||
string ArtifactDigest,
|
||||
Guid? SbomVersionId,
|
||||
long SequenceNumber,
|
||||
DateTimeOffset CreatedAt,
|
||||
LineageNodeMetadata? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata associated with a lineage node.
|
||||
/// </summary>
|
||||
public sealed record LineageNodeMetadata(
|
||||
string? ImageReference,
|
||||
string? Repository,
|
||||
string? Tag,
|
||||
string? CommitSha,
|
||||
Dictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an edge in the SBOM lineage graph.
|
||||
/// </summary>
|
||||
public sealed record LineageEdge(
|
||||
Guid Id,
|
||||
string ParentDigest,
|
||||
string ChildDigest,
|
||||
LineageRelationship Relationship,
|
||||
Guid TenantId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Type of relationship between two SBOM versions.
|
||||
/// </summary>
|
||||
public enum LineageRelationship
|
||||
{
|
||||
/// <summary>
|
||||
/// General parent-child relationship (ancestor).
|
||||
/// </summary>
|
||||
Parent,
|
||||
|
||||
/// <summary>
|
||||
/// Built from relationship (e.g., multi-stage builds).
|
||||
/// </summary>
|
||||
Build,
|
||||
|
||||
/// <summary>
|
||||
/// Container base image relationship.
|
||||
/// </summary>
|
||||
Base
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete lineage graph with nodes and edges.
|
||||
/// </summary>
|
||||
public sealed record LineageGraph(
|
||||
IReadOnlyList<LineageNode> Nodes,
|
||||
IReadOnlyList<LineageEdge> Edges);
|
||||
|
||||
/// <summary>
|
||||
/// VEX status delta between two SBOM versions.
|
||||
/// </summary>
|
||||
public sealed record VexDelta(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string FromArtifactDigest,
|
||||
string ToArtifactDigest,
|
||||
string Cve,
|
||||
VexStatus FromStatus,
|
||||
VexStatus ToStatus,
|
||||
VexDeltaRationale Rationale,
|
||||
string ReplayHash,
|
||||
string? AttestationDigest,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// VEX status values.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
Unknown,
|
||||
UnderInvestigation,
|
||||
Affected,
|
||||
NotAffected,
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rationale explaining a VEX status transition.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaRationale(
|
||||
string Reason,
|
||||
IReadOnlyList<string> EvidencePointers,
|
||||
Dictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Link between SBOM version and VEX consensus verdict.
|
||||
/// </summary>
|
||||
public sealed record SbomVerdictLink(
|
||||
Guid SbomVersionId,
|
||||
string Cve,
|
||||
Guid ConsensusProjectionId,
|
||||
VexStatus VerdictStatus,
|
||||
decimal ConfidenceScore,
|
||||
Guid TenantId,
|
||||
DateTimeOffset LinkedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Options for lineage graph queries.
|
||||
/// </summary>
|
||||
public sealed record LineageQueryOptions(
|
||||
int MaxDepth = 10,
|
||||
bool IncludeVerdicts = true,
|
||||
bool IncludeBadges = true,
|
||||
bool IncludeReachability = false);
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Data source for SBOM lineage database operations.
|
||||
/// </summary>
|
||||
public sealed class LineageDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for lineage tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "sbom";
|
||||
|
||||
public LineageDataSource(
|
||||
IOptions<PostgresOptions> options,
|
||||
ILogger<LineageDataSource> logger)
|
||||
: base(options.Value, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "SbomLineage";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
-- ============================================================================
|
||||
-- SbomService.Lineage - Initial Schema (Pre-v1.0 Baseline)
|
||||
-- Date: 2025-12-29
|
||||
-- Sprint: SPRINT_20251229_005_001_BE_sbom_lineage_api
|
||||
-- Description: Consolidated baseline schema for SBOM lineage tracking
|
||||
-- ============================================================================
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 1. SBOM Lineage Edges Table
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.sbom_lineage_edges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_digest TEXT NOT NULL,
|
||||
child_digest TEXT NOT NULL,
|
||||
relationship TEXT NOT NULL CHECK (relationship IN ('parent', 'build', 'base')),
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_lineage_edge UNIQUE (parent_digest, child_digest, tenant_id)
|
||||
);
|
||||
|
||||
-- Indexes for efficient lineage traversal
|
||||
CREATE INDEX IF NOT EXISTS idx_lineage_edges_parent ON sbom.sbom_lineage_edges(parent_digest, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lineage_edges_child ON sbom.sbom_lineage_edges(child_digest, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lineage_edges_created ON sbom.sbom_lineage_edges(tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lineage_edges_relationship ON sbom.sbom_lineage_edges(relationship, tenant_id);
|
||||
|
||||
-- RLS Policy for tenant isolation
|
||||
ALTER TABLE sbom.sbom_lineage_edges ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY IF NOT EXISTS lineage_edges_tenant_isolation ON sbom.sbom_lineage_edges
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE sbom.sbom_lineage_edges IS 'SBOM lineage relationships for tracking artifact evolution';
|
||||
COMMENT ON COLUMN sbom.sbom_lineage_edges.relationship IS 'Type of relationship: parent (ancestor), build (built from), base (container base image)';
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 2. VEX Deltas Table
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vex.vex_deltas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
from_artifact_digest TEXT NOT NULL,
|
||||
to_artifact_digest TEXT NOT NULL,
|
||||
cve TEXT NOT NULL,
|
||||
from_status TEXT NOT NULL CHECK (from_status IN ('affected', 'not_affected', 'fixed', 'under_investigation', 'unknown')),
|
||||
to_status TEXT NOT NULL CHECK (to_status IN ('affected', 'not_affected', 'fixed', 'under_investigation', 'unknown')),
|
||||
rationale JSONB NOT NULL DEFAULT '{}',
|
||||
replay_hash TEXT NOT NULL,
|
||||
attestation_digest TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_vex_delta UNIQUE (tenant_id, from_artifact_digest, to_artifact_digest, cve)
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_deltas_to ON vex.vex_deltas(to_artifact_digest, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_deltas_from ON vex.vex_deltas(from_artifact_digest, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_deltas_cve ON vex.vex_deltas(cve, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_deltas_created ON vex.vex_deltas(tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_deltas_status_change ON vex.vex_deltas(tenant_id, from_status, to_status)
|
||||
WHERE from_status != to_status;
|
||||
|
||||
-- RLS Policy
|
||||
ALTER TABLE vex.vex_deltas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY IF NOT EXISTS vex_deltas_tenant_isolation ON vex.vex_deltas
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE vex.vex_deltas IS 'VEX status transitions between SBOM versions for audit and lineage';
|
||||
COMMENT ON COLUMN vex.vex_deltas.replay_hash IS 'Deterministic hash for verdict reproducibility';
|
||||
COMMENT ON COLUMN vex.vex_deltas.rationale IS 'JSON explaining the status transition with evidence pointers';
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. SBOM Verdict Links Table
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.sbom_verdict_links (
|
||||
sbom_version_id UUID NOT NULL,
|
||||
cve TEXT NOT NULL,
|
||||
consensus_projection_id UUID NOT NULL,
|
||||
verdict_status TEXT NOT NULL CHECK (verdict_status IN ('affected', 'not_affected', 'fixed', 'under_investigation', 'unknown')),
|
||||
confidence_score DECIMAL(5,4) NOT NULL CHECK (confidence_score >= 0 AND confidence_score <= 1),
|
||||
tenant_id UUID NOT NULL,
|
||||
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
PRIMARY KEY (sbom_version_id, cve, tenant_id)
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_links_cve ON sbom.sbom_verdict_links(cve, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_links_projection ON sbom.sbom_verdict_links(consensus_projection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_links_sbom_version ON sbom.sbom_verdict_links(sbom_version_id, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_links_status ON sbom.sbom_verdict_links(verdict_status, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_links_confidence ON sbom.sbom_verdict_links(tenant_id, confidence_score DESC);
|
||||
|
||||
-- RLS Policy
|
||||
ALTER TABLE sbom.sbom_verdict_links ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY IF NOT EXISTS verdict_links_tenant_isolation ON sbom.sbom_verdict_links
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE sbom.sbom_verdict_links IS 'Links SBOM versions to VEX consensus verdicts for efficient querying';
|
||||
COMMENT ON COLUMN sbom.sbom_verdict_links.confidence_score IS 'Confidence score from VexLens consensus engine (0.0 to 1.0)';
|
||||
COMMENT ON COLUMN sbom.sbom_verdict_links.consensus_projection_id IS 'Reference to VexLens consensus projection record';
|
||||
@@ -0,0 +1,59 @@
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for SBOM lineage edges.
|
||||
/// </summary>
|
||||
public interface ISbomLineageEdgeRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the complete lineage graph for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest to query.</param>
|
||||
/// <param name="tenantId">Tenant ID for isolation.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Lineage graph with nodes and edges.</returns>
|
||||
ValueTask<LineageGraph> GetGraphAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
int maxDepth,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get parent edges for an artifact.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<LineageEdge>> GetParentsAsync(
|
||||
string childDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get child edges for an artifact.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<LineageEdge>> GetChildrenAsync(
|
||||
string parentDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add a new lineage edge.
|
||||
/// </summary>
|
||||
ValueTask<LineageEdge> AddEdgeAsync(
|
||||
string parentDigest,
|
||||
string childDigest,
|
||||
LineageRelationship relationship,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a lineage path exists between two artifacts.
|
||||
/// </summary>
|
||||
ValueTask<bool> PathExistsAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for SBOM-to-VEX verdict links.
|
||||
/// </summary>
|
||||
public interface ISbomVerdictLinkRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a new verdict link.
|
||||
/// </summary>
|
||||
ValueTask<SbomVerdictLink> AddAsync(SbomVerdictLink link, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all verdict links for an SBOM version.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<SbomVerdictLink>> GetBySbomVersionAsync(
|
||||
Guid sbomVersionId,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get verdict link for a specific CVE in an SBOM version.
|
||||
/// </summary>
|
||||
ValueTask<SbomVerdictLink?> GetByCveAsync(
|
||||
Guid sbomVersionId,
|
||||
string cve,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all SBOM versions affected by a CVE.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<SbomVerdictLink>> GetByCveAcrossVersionsAsync(
|
||||
string cve,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch add verdict links for an SBOM version.
|
||||
/// </summary>
|
||||
ValueTask BatchAddAsync(
|
||||
IReadOnlyList<SbomVerdictLink> links,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get high-confidence affected verdicts for an SBOM version.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<SbomVerdictLink>> GetHighConfidenceAffectedAsync(
|
||||
Guid sbomVersionId,
|
||||
Guid tenantId,
|
||||
decimal minConfidence = 0.8m,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for VEX status deltas.
|
||||
/// </summary>
|
||||
public interface IVexDeltaRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a new VEX delta record.
|
||||
/// </summary>
|
||||
ValueTask<VexDelta> AddAsync(VexDelta delta, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all deltas between two artifact versions.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexDelta>> GetDeltasAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get deltas for a specific CVE across versions.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexDelta>> GetDeltasByCveAsync(
|
||||
string cve,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all deltas targeting a specific artifact version.
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexDelta>> GetDeltasToArtifactAsync(
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get deltas showing status changes (not identity transitions).
|
||||
/// </summary>
|
||||
ValueTask<IReadOnlyList<VexDelta>> GetStatusChangesAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of SBOM lineage edge repository.
|
||||
/// </summary>
|
||||
public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource>, ISbomLineageEdgeRepository
|
||||
{
|
||||
private const string Schema = "sbom";
|
||||
private const string Table = "sbom_lineage_edges";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
public SbomLineageEdgeRepository(
|
||||
LineageDataSource dataSource,
|
||||
ILogger<SbomLineageEdgeRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<LineageGraph> GetGraphAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
int maxDepth,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// BFS traversal with depth limit
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<(string Digest, int Depth)>();
|
||||
queue.Enqueue((artifactDigest, 0));
|
||||
|
||||
var nodes = new List<LineageNode>();
|
||||
var edges = new List<LineageEdge>();
|
||||
var edgeIds = new HashSet<Guid>();
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, depth) = queue.Dequeue();
|
||||
if (depth > maxDepth || !visited.Add(current))
|
||||
continue;
|
||||
|
||||
// Get node metadata (if exists in SBOM versions table)
|
||||
var node = await GetNodeAsync(current, tenantId, ct);
|
||||
if (node != null)
|
||||
nodes.Add(node);
|
||||
|
||||
// Get children edges
|
||||
var children = await GetChildrenAsync(current, tenantId, ct);
|
||||
foreach (var edge in children)
|
||||
{
|
||||
if (edgeIds.Add(edge.Id))
|
||||
{
|
||||
edges.Add(edge);
|
||||
queue.Enqueue((edge.ChildDigest, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Get parent edges
|
||||
var parents = await GetParentsAsync(current, tenantId, ct);
|
||||
foreach (var edge in parents)
|
||||
{
|
||||
if (edgeIds.Add(edge.Id))
|
||||
{
|
||||
edges.Add(edge);
|
||||
queue.Enqueue((edge.ParentDigest, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic ordering per architecture spec
|
||||
return new LineageGraph(
|
||||
Nodes: nodes
|
||||
.OrderByDescending(n => n.SequenceNumber)
|
||||
.ThenByDescending(n => n.CreatedAt)
|
||||
.ToList(),
|
||||
Edges: edges
|
||||
.OrderBy(e => e.ParentDigest, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.ChildDigest, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Relationship)
|
||||
.ToList()
|
||||
);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<LineageEdge>> GetParentsAsync(
|
||||
string childDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
FROM {FullTable}
|
||||
WHERE child_digest = @childDigest AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "childDigest", childDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<LineageEdge>> GetChildrenAsync(
|
||||
string parentDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
FROM {FullTable}
|
||||
WHERE parent_digest = @parentDigest AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "parentDigest", parentDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<LineageEdge> AddEdgeAsync(
|
||||
string parentDigest,
|
||||
string childDigest,
|
||||
LineageRelationship relationship,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (parent_digest, child_digest, relationship, tenant_id)
|
||||
VALUES (@parentDigest, @childDigest, @relationship, @tenantId)
|
||||
ON CONFLICT (parent_digest, child_digest, tenant_id) DO NOTHING
|
||||
RETURNING id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "parentDigest", parentDigest);
|
||||
AddParameter(cmd, "childDigest", childDigest);
|
||||
AddParameter(cmd, "relationship", relationship.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
// Edge already exists, fetch it
|
||||
const string fetchSql = $"""
|
||||
SELECT id, parent_digest, child_digest, relationship, tenant_id, created_at
|
||||
FROM {FullTable}
|
||||
WHERE parent_digest = @parentDigest
|
||||
AND child_digest = @childDigest
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
result = await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
fetchSql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "parentDigest", parentDigest);
|
||||
AddParameter(cmd, "childDigest", childDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapEdge,
|
||||
ct);
|
||||
}
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to create or retrieve lineage edge");
|
||||
}
|
||||
|
||||
public async ValueTask<bool> PathExistsAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Simple BFS to check if path exists
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<(string Digest, int Depth)>();
|
||||
queue.Enqueue((fromDigest, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, depth) = queue.Dequeue();
|
||||
|
||||
if (current.Equals(toDigest, StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
if (depth >= maxDepth || !visited.Add(current))
|
||||
continue;
|
||||
|
||||
var children = await GetChildrenAsync(current, tenantId, ct);
|
||||
foreach (var edge in children)
|
||||
queue.Enqueue((edge.ChildDigest, depth + 1));
|
||||
|
||||
var parents = await GetParentsAsync(current, tenantId, ct);
|
||||
foreach (var edge in parents)
|
||||
queue.Enqueue((edge.ParentDigest, depth + 1));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async ValueTask<LineageNode?> GetNodeAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Query sbom.sbom_versions table for node metadata
|
||||
// This assumes the table exists - adjust based on actual schema
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest, sequence_number, created_at
|
||||
FROM sbom.sbom_versions
|
||||
WHERE artifact_digest = @digest AND tenant_id = @tenantId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "digest", artifactDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
reader => new LineageNode(
|
||||
ArtifactDigest: reader.GetString(reader.GetOrdinal("artifact_digest")),
|
||||
SbomVersionId: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
Metadata: null // TODO: Extract from labels/metadata columns
|
||||
),
|
||||
ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If sbom_versions doesn't exist or has different schema, return minimal node
|
||||
return new LineageNode(
|
||||
ArtifactDigest: artifactDigest,
|
||||
SbomVersionId: null,
|
||||
SequenceNumber: 0,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
Metadata: null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static LineageEdge MapEdge(System.Data.Common.DbDataReader reader)
|
||||
{
|
||||
var relationshipStr = reader.GetString(reader.GetOrdinal("relationship"));
|
||||
var relationship = relationshipStr.ToLowerInvariant() switch
|
||||
{
|
||||
"parent" => LineageRelationship.Parent,
|
||||
"build" => LineageRelationship.Build,
|
||||
"base" => LineageRelationship.Base,
|
||||
_ => throw new InvalidOperationException($"Unknown relationship: {relationshipStr}")
|
||||
};
|
||||
|
||||
return new LineageEdge(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
ParentDigest: reader.GetString(reader.GetOrdinal("parent_digest")),
|
||||
ChildDigest: reader.GetString(reader.GetOrdinal("child_digest")),
|
||||
Relationship: relationship,
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of SBOM verdict link repository.
|
||||
/// </summary>
|
||||
public sealed class SbomVerdictLinkRepository : RepositoryBase<LineageDataSource>, ISbomVerdictLinkRepository
|
||||
{
|
||||
private const string Schema = "sbom";
|
||||
private const string Table = "sbom_verdict_links";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
public SbomVerdictLinkRepository(
|
||||
LineageDataSource dataSource,
|
||||
ILogger<SbomVerdictLinkRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<SbomVerdictLink> AddAsync(SbomVerdictLink link, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (
|
||||
sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id
|
||||
)
|
||||
VALUES (
|
||||
@sbomVersionId, @cve, @projectionId,
|
||||
@status, @confidence, @tenantId
|
||||
)
|
||||
ON CONFLICT (sbom_version_id, cve, tenant_id)
|
||||
DO UPDATE SET
|
||||
consensus_projection_id = EXCLUDED.consensus_projection_id,
|
||||
verdict_status = EXCLUDED.verdict_status,
|
||||
confidence_score = EXCLUDED.confidence_score,
|
||||
linked_at = NOW()
|
||||
RETURNING sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
link.TenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", link.SbomVersionId);
|
||||
AddParameter(cmd, "cve", link.Cve);
|
||||
AddParameter(cmd, "projectionId", link.ConsensusProjectionId);
|
||||
AddParameter(cmd, "status", link.VerdictStatus.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "confidence", link.ConfidenceScore);
|
||||
AddParameter(cmd, "tenantId", link.TenantId);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to add verdict link");
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<SbomVerdictLink>> GetBySbomVersionAsync(
|
||||
Guid sbomVersionId,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE sbom_version_id = @sbomVersionId AND tenant_id = @tenantId
|
||||
ORDER BY cve ASC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", sbomVersionId);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<SbomVerdictLink?> GetByCveAsync(
|
||||
Guid sbomVersionId,
|
||||
string cve,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE sbom_version_id = @sbomVersionId
|
||||
AND cve = @cve
|
||||
AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", sbomVersionId);
|
||||
AddParameter(cmd, "cve", cve);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<SbomVerdictLink>> GetByCveAcrossVersionsAsync(
|
||||
string cve,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE cve = @cve AND tenant_id = @tenantId
|
||||
ORDER BY linked_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "cve", cve);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask BatchAddAsync(
|
||||
IReadOnlyList<SbomVerdictLink> links,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (links.Count == 0)
|
||||
return;
|
||||
|
||||
// Simple batch insert - could be optimized with COPY later
|
||||
foreach (var link in links)
|
||||
{
|
||||
await AddAsync(link, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<SbomVerdictLink>> GetHighConfidenceAffectedAsync(
|
||||
Guid sbomVersionId,
|
||||
Guid tenantId,
|
||||
decimal minConfidence = 0.8m,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT sbom_version_id, cve, consensus_projection_id,
|
||||
verdict_status, confidence_score, tenant_id, linked_at
|
||||
FROM {FullTable}
|
||||
WHERE sbom_version_id = @sbomVersionId
|
||||
AND tenant_id = @tenantId
|
||||
AND verdict_status = 'affected'
|
||||
AND confidence_score >= @minConfidence
|
||||
ORDER BY confidence_score DESC, cve ASC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sbomVersionId", sbomVersionId);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "minConfidence", minConfidence);
|
||||
},
|
||||
MapLink,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static SbomVerdictLink MapLink(System.Data.Common.DbDataReader reader)
|
||||
{
|
||||
var statusStr = reader.GetString(reader.GetOrdinal("verdict_status"));
|
||||
var status = statusStr.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
_ => throw new InvalidOperationException($"Unknown status: {statusStr}")
|
||||
};
|
||||
|
||||
return new SbomVerdictLink(
|
||||
SbomVersionId: reader.GetGuid(reader.GetOrdinal("sbom_version_id")),
|
||||
Cve: reader.GetString(reader.GetOrdinal("cve")),
|
||||
ConsensusProjectionId: reader.GetGuid(reader.GetOrdinal("consensus_projection_id")),
|
||||
VerdictStatus: status,
|
||||
ConfidenceScore: reader.GetDecimal(reader.GetOrdinal("confidence_score")),
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
LinkedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("linked_at"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of VEX delta repository.
|
||||
/// </summary>
|
||||
public sealed class VexDeltaRepository : RepositoryBase<LineageDataSource>, IVexDeltaRepository
|
||||
{
|
||||
private const string Schema = "vex";
|
||||
private const string Table = "vex_deltas";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
public VexDeltaRepository(
|
||||
LineageDataSource dataSource,
|
||||
ILogger<VexDeltaRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async ValueTask<VexDelta> AddAsync(VexDelta delta, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (
|
||||
tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest
|
||||
)
|
||||
VALUES (
|
||||
@tenantId, @fromDigest, @toDigest, @cve,
|
||||
@fromStatus, @toStatus, @rationale::jsonb, @replayHash, @attestationDigest
|
||||
)
|
||||
ON CONFLICT (tenant_id, from_artifact_digest, to_artifact_digest, cve)
|
||||
DO UPDATE SET
|
||||
to_status = EXCLUDED.to_status,
|
||||
rationale = EXCLUDED.rationale,
|
||||
replay_hash = EXCLUDED.replay_hash,
|
||||
attestation_digest = EXCLUDED.attestation_digest
|
||||
RETURNING id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
""";
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
delta.TenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenantId", delta.TenantId);
|
||||
AddParameter(cmd, "fromDigest", delta.FromArtifactDigest);
|
||||
AddParameter(cmd, "toDigest", delta.ToArtifactDigest);
|
||||
AddParameter(cmd, "cve", delta.Cve);
|
||||
AddParameter(cmd, "fromStatus", delta.FromStatus.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "toStatus", delta.ToStatus.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "rationale", SerializeRationale(delta.Rationale));
|
||||
AddParameter(cmd, "replayHash", delta.ReplayHash);
|
||||
AddParameter(cmd, "attestationDigest", (object?)delta.AttestationDigest ?? DBNull.Value);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to add VEX delta");
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetDeltasAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE from_artifact_digest = @fromDigest
|
||||
AND to_artifact_digest = @toDigest
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY cve ASC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "fromDigest", fromDigest);
|
||||
AddParameter(cmd, "toDigest", toDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetDeltasByCveAsync(
|
||||
string cve,
|
||||
Guid tenantId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE cve = @cve AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "cve", cve);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetDeltasToArtifactAsync(
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE to_artifact_digest = @toDigest AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC, cve ASC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "toDigest", toDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<VexDelta>> GetStatusChangesAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, from_artifact_digest, to_artifact_digest, cve,
|
||||
from_status, to_status, rationale, replay_hash, attestation_digest, created_at
|
||||
FROM {FullTable}
|
||||
WHERE (from_artifact_digest = @digest OR to_artifact_digest = @digest)
|
||||
AND from_status != to_status
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "digest", artifactDigest);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapDelta,
|
||||
ct);
|
||||
}
|
||||
|
||||
private static VexDelta MapDelta(System.Data.Common.DbDataReader reader)
|
||||
{
|
||||
var fromStatusStr = reader.GetString(reader.GetOrdinal("from_status"));
|
||||
var toStatusStr = reader.GetString(reader.GetOrdinal("to_status"));
|
||||
|
||||
return new VexDelta(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
FromArtifactDigest: reader.GetString(reader.GetOrdinal("from_artifact_digest")),
|
||||
ToArtifactDigest: reader.GetString(reader.GetOrdinal("to_artifact_digest")),
|
||||
Cve: reader.GetString(reader.GetOrdinal("cve")),
|
||||
FromStatus: ParseStatus(fromStatusStr),
|
||||
ToStatus: ParseStatus(toStatusStr),
|
||||
Rationale: DeserializeRationale(reader.GetString(reader.GetOrdinal("rationale"))),
|
||||
ReplayHash: reader.GetString(reader.GetOrdinal("replay_hash")),
|
||||
AttestationDigest: reader.IsDBNull(reader.GetOrdinal("attestation_digest"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("attestation_digest")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
);
|
||||
}
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status.ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => VexStatus.Unknown,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
_ => throw new InvalidOperationException($"Unknown VEX status: {status}")
|
||||
};
|
||||
|
||||
private static string SerializeRationale(VexDeltaRationale rationale)
|
||||
{
|
||||
var jsonObj = new
|
||||
{
|
||||
reason = rationale.Reason,
|
||||
evidence_pointers = rationale.EvidencePointers,
|
||||
metadata = rationale.Metadata
|
||||
};
|
||||
return JsonSerializer.Serialize(jsonObj);
|
||||
}
|
||||
|
||||
private static VexDeltaRationale DeserializeRationale(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
return new VexDeltaRationale(
|
||||
Reason: root.TryGetProperty("reason", out var reasonProp) ? reasonProp.GetString() ?? "" : "",
|
||||
EvidencePointers: root.TryGetProperty("evidence_pointers", out var evidenceProp)
|
||||
? evidenceProp.EnumerateArray().Select(e => e.GetString() ?? "").ToList()
|
||||
: [],
|
||||
Metadata: root.TryGetProperty("metadata", out var metaProp)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(metaProp.GetRawText())
|
||||
: null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying and analyzing SBOM lineage graphs.
|
||||
/// </summary>
|
||||
public interface ILineageGraphService
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the complete lineage graph for an artifact.
|
||||
/// </summary>
|
||||
ValueTask<LineageGraphResponse> GetLineageAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
LineageQueryOptions options,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compute differences between two artifact versions (SBOM + VEX + reachability).
|
||||
/// </summary>
|
||||
ValueTask<LineageDiffResponse> GetDiffAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a signed evidence pack for export.
|
||||
/// </summary>
|
||||
ValueTask<ExportResult> ExportEvidencePackAsync(
|
||||
ExportRequest request,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing lineage graph with enriched metadata.
|
||||
/// </summary>
|
||||
public sealed record LineageGraphResponse(
|
||||
LineageGraph Graph,
|
||||
Dictionary<string, NodeEnrichment> Enrichment);
|
||||
|
||||
/// <summary>
|
||||
/// Enriched metadata for a lineage node.
|
||||
/// </summary>
|
||||
public sealed record NodeEnrichment(
|
||||
int VulnerabilityCount,
|
||||
int HighSeverityCount,
|
||||
int AffectedCount,
|
||||
IReadOnlyList<string> TopCves);
|
||||
|
||||
/// <summary>
|
||||
/// Response containing differences between two versions.
|
||||
/// </summary>
|
||||
public sealed record LineageDiffResponse(
|
||||
string FromDigest,
|
||||
string ToDigest,
|
||||
SbomDiff SbomDifferences,
|
||||
VexDiff VexDifferences,
|
||||
ReachabilityDiff? ReachabilityDifferences);
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component differences.
|
||||
/// </summary>
|
||||
public sealed record SbomDiff(
|
||||
IReadOnlyList<ComponentChange> Added,
|
||||
IReadOnlyList<ComponentChange> Removed,
|
||||
IReadOnlyList<ComponentChange> Modified);
|
||||
|
||||
/// <summary>
|
||||
/// Component change in SBOM.
|
||||
/// </summary>
|
||||
public sealed record ComponentChange(
|
||||
string Name,
|
||||
string? FromVersion,
|
||||
string? ToVersion,
|
||||
string Ecosystem);
|
||||
|
||||
/// <summary>
|
||||
/// VEX status differences.
|
||||
/// </summary>
|
||||
public sealed record VexDiff(
|
||||
IReadOnlyList<VexDelta> StatusChanges,
|
||||
int NewVulnerabilities,
|
||||
int ResolvedVulnerabilities,
|
||||
int AffectedToNotAffected,
|
||||
int NotAffectedToAffected);
|
||||
|
||||
/// <summary>
|
||||
/// Reachability differences (optional).
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDiff(
|
||||
int NewReachable,
|
||||
int NewUnreachable,
|
||||
IReadOnlyList<string> NewlyReachableCves);
|
||||
|
||||
/// <summary>
|
||||
/// Export request for evidence packs.
|
||||
/// </summary>
|
||||
public sealed record ExportRequest(
|
||||
string ArtifactDigest,
|
||||
bool IncludeLineage,
|
||||
bool IncludeVerdicts,
|
||||
bool IncludeReachability,
|
||||
bool SignWithSigstore,
|
||||
int MaxDepth = 5);
|
||||
|
||||
/// <summary>
|
||||
/// Result of evidence pack export.
|
||||
/// </summary>
|
||||
public sealed record ExportResult(
|
||||
string DownloadUrl,
|
||||
DateTimeOffset ExpiresAt,
|
||||
long SizeBytes,
|
||||
string? SignatureDigest);
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of lineage graph service with caching and enrichment.
|
||||
/// </summary>
|
||||
public sealed class LineageGraphService : ILineageGraphService
|
||||
{
|
||||
private readonly ISbomLineageEdgeRepository _edgeRepository;
|
||||
private readonly IVexDeltaRepository _deltaRepository;
|
||||
private readonly ISbomVerdictLinkRepository _verdictRepository;
|
||||
private readonly IDistributedCache? _cache;
|
||||
private readonly ILogger<LineageGraphService> _logger;
|
||||
|
||||
private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(10);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public LineageGraphService(
|
||||
ISbomLineageEdgeRepository edgeRepository,
|
||||
IVexDeltaRepository deltaRepository,
|
||||
ISbomVerdictLinkRepository verdictRepository,
|
||||
ILogger<LineageGraphService> logger,
|
||||
IDistributedCache? cache = null)
|
||||
{
|
||||
_edgeRepository = edgeRepository;
|
||||
_deltaRepository = deltaRepository;
|
||||
_verdictRepository = verdictRepository;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async ValueTask<LineageGraphResponse> GetLineageAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
LineageQueryOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Try cache first
|
||||
var cacheKey = $"lineage:{tenantId}:{artifactDigest}:{options.MaxDepth}";
|
||||
if (_cache != null)
|
||||
{
|
||||
var cached = await _cache.GetStringAsync(cacheKey, ct);
|
||||
if (cached != null)
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<LineageGraphResponse>(cached, SerializerOptions);
|
||||
if (response != null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for lineage {Digest}", artifactDigest);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build graph
|
||||
var graph = await _edgeRepository.GetGraphAsync(artifactDigest, tenantId, options.MaxDepth, ct);
|
||||
|
||||
// Enrich with verdict data if requested
|
||||
var enrichment = new Dictionary<string, NodeEnrichment>();
|
||||
if (options.IncludeVerdicts)
|
||||
{
|
||||
foreach (var node in graph.Nodes.Where(n => n.SbomVersionId.HasValue))
|
||||
{
|
||||
var verdicts = await _verdictRepository.GetBySbomVersionAsync(
|
||||
node.SbomVersionId!.Value,
|
||||
tenantId,
|
||||
ct);
|
||||
|
||||
var affected = verdicts.Where(v => v.VerdictStatus == VexStatus.Affected).ToList();
|
||||
var high = affected.Where(v => v.ConfidenceScore >= 0.8m).ToList();
|
||||
|
||||
enrichment[node.ArtifactDigest] = new NodeEnrichment(
|
||||
VulnerabilityCount: verdicts.Count,
|
||||
HighSeverityCount: high.Count,
|
||||
AffectedCount: affected.Count,
|
||||
TopCves: affected
|
||||
.OrderByDescending(v => v.ConfidenceScore)
|
||||
.Take(5)
|
||||
.Select(v => v.Cve)
|
||||
.ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new LineageGraphResponse(graph, enrichment);
|
||||
|
||||
// Cache the result
|
||||
if (_cache != null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, SerializerOptions);
|
||||
await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = CacheExpiry
|
||||
}, ct);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask<LineageDiffResponse> GetDiffAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Try cache first
|
||||
var cacheKey = $"lineage:compare:{tenantId}:{fromDigest}:{toDigest}";
|
||||
if (_cache != null)
|
||||
{
|
||||
var cached = await _cache.GetStringAsync(cacheKey, ct);
|
||||
if (cached != null)
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<LineageDiffResponse>(cached, SerializerOptions);
|
||||
if (response != null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for diff {From} -> {To}", fromDigest, toDigest);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get VEX deltas
|
||||
var deltas = await _deltaRepository.GetDeltasAsync(fromDigest, toDigest, tenantId, ct);
|
||||
|
||||
var statusChanges = deltas.Where(d => d.FromStatus != d.ToStatus).ToList();
|
||||
var newVulns = deltas.Count(d => d.FromStatus == VexStatus.Unknown && d.ToStatus == VexStatus.Affected);
|
||||
var resolved = deltas.Count(d => d.FromStatus == VexStatus.Affected && d.ToStatus == VexStatus.Fixed);
|
||||
var affectedToNot = deltas.Count(d => d.FromStatus == VexStatus.Affected && d.ToStatus == VexStatus.NotAffected);
|
||||
var notToAffected = deltas.Count(d => d.FromStatus == VexStatus.NotAffected && d.ToStatus == VexStatus.Affected);
|
||||
|
||||
var vexDiff = new VexDiff(
|
||||
StatusChanges: statusChanges,
|
||||
NewVulnerabilities: newVulns,
|
||||
ResolvedVulnerabilities: resolved,
|
||||
AffectedToNotAffected: affectedToNot,
|
||||
NotAffectedToAffected: notToAffected
|
||||
);
|
||||
|
||||
// TODO: Implement SBOM diff by comparing component lists
|
||||
var sbomDiff = new SbomDiff([], [], []);
|
||||
|
||||
// TODO: Implement reachability diff if requested
|
||||
ReachabilityDiff? reachDiff = null;
|
||||
|
||||
var result = new LineageDiffResponse(
|
||||
FromDigest: fromDigest,
|
||||
ToDigest: toDigest,
|
||||
SbomDifferences: sbomDiff,
|
||||
VexDifferences: vexDiff,
|
||||
ReachabilityDifferences: reachDiff
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
if (_cache != null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, SerializerOptions);
|
||||
await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = CacheExpiry
|
||||
}, ct);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async ValueTask<ExportResult> ExportEvidencePackAsync(
|
||||
ExportRequest request,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Exporting evidence pack for {Digest}", request.ArtifactDigest);
|
||||
|
||||
// TODO: Implement evidence pack generation
|
||||
// 1. Get lineage graph if requested
|
||||
// 2. Get verdicts if requested
|
||||
// 3. Get reachability data if requested
|
||||
// 4. Bundle into archive (tar.gz or zip)
|
||||
// 5. Sign with Sigstore if requested
|
||||
// 6. Upload to storage and return download URL
|
||||
|
||||
// Placeholder implementation
|
||||
var downloadUrl = $"https://evidence.stellaops.example/exports/{Guid.NewGuid()}.tar.gz";
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
|
||||
|
||||
return new ExportResult(
|
||||
DownloadUrl: downloadUrl,
|
||||
ExpiresAt: expiresAt,
|
||||
SizeBytes: 0,
|
||||
SignatureDigest: request.SignWithSigstore ? "sha256:placeholder" : null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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.SbomService.Lineage</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user