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:
master
2025-12-29 19:12:38 +02:00
parent 41552d26ec
commit a4badc275e
286 changed files with 50918 additions and 992 deletions

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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"))
);
}
}

View File

@@ -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"))
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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);

View File

@@ -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
);
}
}

View File

@@ -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>