Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,82 @@
// -----------------------------------------------------------------------------
// ReplayVerificationModels.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033)
// Task: Create replay verification API models
// Description: Request/response models for the replay verification endpoints.
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Models;
/// <summary>
/// Request model for replay hash verification.
/// </summary>
public sealed record ReplayVerifyRequest
{
/// <summary>
/// The replay hash to verify.
/// </summary>
public required string ReplayHash { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Optional: SBOM digest to use for verification.
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Optional: Feeds snapshot digest to use.
/// </summary>
public string? FeedsSnapshotDigest { get; init; }
/// <summary>
/// Optional: Policy version to use.
/// </summary>
public string? PolicyVersion { get; init; }
/// <summary>
/// Optional: VEX verdicts digest to use.
/// </summary>
public string? VexVerdictsDigest { get; init; }
/// <summary>
/// Optional: Timestamp to use for verification.
/// </summary>
public DateTimeOffset? Timestamp { get; init; }
/// <summary>
/// Whether to freeze time to the original evaluation timestamp.
/// Default: true.
/// </summary>
public bool? FreezeTime { get; init; }
/// <summary>
/// Whether to re-evaluate policy with frozen feeds.
/// Default: false.
/// </summary>
public bool? ReEvaluatePolicy { get; init; }
}
/// <summary>
/// Request model for comparing drift between two replay hashes.
/// </summary>
public sealed record CompareDriftRequest
{
/// <summary>
/// First replay hash.
/// </summary>
public required string HashA { get; init; }
/// <summary>
/// Second replay hash.
/// </summary>
public required string HashB { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
}

View File

@@ -21,6 +21,16 @@ public sealed record SbomUploadRequest
[JsonPropertyName("source")]
public SbomUploadSource? Source { get; init; }
// LIN-BE-003: Lineage ancestry fields
[JsonPropertyName("parentArtifactDigest")]
public string? ParentArtifactDigest { get; init; }
[JsonPropertyName("baseImageRef")]
public string? BaseImageRef { get; init; }
[JsonPropertyName("baseImageDigest")]
public string? BaseImageDigest { get; init; }
}
public sealed record SbomUploadSource
@@ -101,7 +111,12 @@ public sealed record SbomLedgerSubmission(
string Source,
SbomUploadSource? Provenance,
IReadOnlyList<SbomNormalizedComponent> Components,
Guid? ParentVersionId);
Guid? ParentVersionId,
// LIN-BE-003: Lineage ancestry fields
string? ParentArtifactDigest = null,
string? BaseImageRef = null,
string? BaseImageDigest = null,
string? BuildId = null);
public sealed record SbomLedgerVersion
{
@@ -117,7 +132,29 @@ public sealed record SbomLedgerVersion
public SbomUploadSource? Provenance { get; init; }
public Guid? ParentVersionId { get; init; }
public string? ParentDigest { get; init; }
// LIN-BE-003: Lineage ancestry fields
public string? ParentArtifactDigest { get; init; }
public string? BaseImageRef { get; init; }
public string? BaseImageDigest { get; init; }
public string? BuildId { get; init; }
public IReadOnlyList<SbomNormalizedComponent> Components { get; init; } = Array.Empty<SbomNormalizedComponent>();
// LIN-BE-023: Replay hash for reproducibility verification
public string? ReplayHash { get; init; }
public ReplayHashInputSnapshot? ReplayHashInputs { get; init; }
}
/// <summary>
/// Snapshot of inputs used to compute a replay hash.
/// Stored alongside the version for audit and reproducibility.
/// Sprint: LIN-BE-023
/// </summary>
public sealed record ReplayHashInputSnapshot
{
public required string SbomDigest { get; init; }
public required string FeedsSnapshotDigest { get; init; }
public required string PolicyVersion { get; init; }
public required string VexVerdictsDigest { get; init; }
public required DateTimeOffset ComputedAt { get; init; }
}
public sealed record SbomVersionHistoryItem(
@@ -196,6 +233,7 @@ public static class SbomLineageRelationships
{
public const string Parent = "parent";
public const string Build = "build";
public const string Base = "base";
}
public sealed record SbomLineageResult(
@@ -204,6 +242,140 @@ public sealed record SbomLineageResult(
IReadOnlyList<SbomLineageNode> Nodes,
IReadOnlyList<SbomLineageEdge> Edges);
// -----------------------------------------------------------------------------
// Extended Lineage Models for SBOM Lineage Graph
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-004/005)
// -----------------------------------------------------------------------------
/// <summary>
/// Persistent lineage edge stored in database.
/// Uses artifact digests as stable identifiers across systems.
/// </summary>
public sealed record SbomLineageEdgeEntity
{
public required Guid Id { get; init; }
public required string ParentDigest { get; init; }
public required string ChildDigest { get; init; }
public required LineageRelationship Relationship { get; init; }
public required Guid TenantId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Lineage relationship type.
/// </summary>
public enum LineageRelationship
{
/// <summary>
/// Direct version succession (v1.0 → v1.1).
/// </summary>
Parent,
/// <summary>
/// Same CI build produced multiple artifacts (multi-arch).
/// </summary>
Build,
/// <summary>
/// Derived from base image (FROM instruction).
/// </summary>
Base
}
/// <summary>
/// Extended lineage node with badge information for UI.
/// </summary>
public sealed record SbomLineageNodeExtended
{
public required Guid Id { get; init; }
public required string Digest { get; init; }
public required string ArtifactRef { get; init; }
public required int SequenceNumber { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string Source { get; init; }
public SbomLineageBadges? Badges { get; init; }
public string? ReplayHash { get; init; }
}
/// <summary>
/// Badge information for lineage node.
/// </summary>
public sealed record SbomLineageBadges
{
public int NewVulns { get; init; }
public int ResolvedVulns { get; init; }
public string SignatureStatus { get; init; } = "unknown";
public int ComponentCount { get; init; }
}
/// <summary>
/// Extended lineage edge with digest-based endpoints.
/// </summary>
public sealed record SbomLineageEdgeExtended
{
public required string From { get; init; }
public required string To { get; init; }
public required string Relationship { get; init; }
}
/// <summary>
/// Complete lineage graph response.
/// </summary>
public sealed record SbomLineageGraphResponse
{
public required string Artifact { get; init; }
public required IReadOnlyList<SbomLineageNodeExtended> Nodes { get; init; }
public required IReadOnlyList<SbomLineageEdgeExtended> Edges { get; init; }
}
/// <summary>
/// Lineage diff response with component and VEX deltas.
/// </summary>
public sealed record SbomLineageDiffResponse
{
public required SbomDiffResult SbomDiff { get; init; }
public required IReadOnlyList<VexDeltaSummary> VexDiff { get; init; }
public IReadOnlyList<ReachabilityDeltaSummary>? ReachabilityDiff { get; init; }
public required string ReplayHash { get; init; }
}
/// <summary>
/// VEX status change between versions.
/// </summary>
public sealed record VexDeltaSummary
{
public required string Cve { get; init; }
public required string FromStatus { get; init; }
public required string ToStatus { get; init; }
public string? Reason { get; init; }
public string? EvidenceLink { get; init; }
}
/// <summary>
/// Reachability change between versions.
/// </summary>
public sealed record ReachabilityDeltaSummary
{
public required string Cve { get; init; }
public required string FromStatus { get; init; }
public required string ToStatus { get; init; }
public int PathsAdded { get; init; }
public int PathsRemoved { get; init; }
public IReadOnlyList<string>? GatesAdded { get; init; }
public IReadOnlyList<string>? GatesRemoved { get; init; }
}
/// <summary>
/// Query options for lineage graph.
/// </summary>
public sealed record SbomLineageQueryOptions
{
public int MaxDepth { get; init; } = 10;
public bool IncludeVerdicts { get; init; } = true;
public bool IncludeBadges { get; init; } = true;
public bool IncludeReplayHash { get; init; }
}
public sealed record SbomRetentionResult(
int VersionsPruned,
int ChainsTouched,

View File

@@ -71,6 +71,36 @@ builder.Services.AddSingleton<ISbomLedgerService, SbomLedgerService>();
builder.Services.AddSingleton<ISbomAnalysisTrigger, InMemorySbomAnalysisTrigger>();
builder.Services.AddSingleton<ISbomUploadService, SbomUploadService>();
// Lineage graph services (LIN-BE-013)
builder.Services.AddSingleton<ISbomLineageEdgeRepository, InMemorySbomLineageEdgeRepository>();
// LIN-BE-015: Hover card cache for <150ms response times
// Use distributed cache if configured, otherwise in-memory
var hoverCacheConfig = builder.Configuration.GetSection("SbomService:HoverCache");
if (hoverCacheConfig.GetValue<bool>("UseDistributed"))
{
// Expects IDistributedCache to be registered (e.g., Valkey/Redis)
builder.Services.AddSingleton<ILineageHoverCache, DistributedLineageHoverCache>();
}
else
{
builder.Services.AddSingleton<ILineageHoverCache, InMemoryLineageHoverCache>();
}
builder.Services.AddSingleton<ISbomLineageGraphService, SbomLineageGraphService>();
// LIN-BE-028: Lineage compare service
builder.Services.AddSingleton<ILineageCompareService, LineageCompareService>();
// LIN-BE-023: Replay hash service
builder.Services.AddSingleton<IReplayHashService, ReplayHashService>();
// LIN-BE-033: Replay verification service
builder.Services.AddSingleton<IReplayVerificationService, ReplayVerificationService>();
// LIN-BE-034: Compare cache with TTL and VEX invalidation
builder.Services.Configure<CompareCacheOptions>(builder.Configuration.GetSection("SbomService:CompareCache"));
builder.Services.AddSingleton<ILineageCompareCache, InMemoryLineageCompareCache>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
@@ -618,6 +648,307 @@ app.MapGet("/sbom/ledger/lineage", async Task<IResult> (
return Results.Ok(lineage);
});
// -----------------------------------------------------------------------------
// Lineage Graph API Endpoints (LIN-BE-013/014)
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i
// -----------------------------------------------------------------------------
app.MapGet("/api/v1/lineage/{artifactDigest}", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromRoute] string artifactDigest,
[FromQuery] string? tenant,
[FromQuery] int? maxDepth,
[FromQuery] bool? includeBadges,
[FromQuery] bool? includeReplayHash,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest is required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var options = new SbomLineageQueryOptions
{
MaxDepth = maxDepth ?? 10,
IncludeBadges = includeBadges ?? true,
IncludeReplayHash = includeReplayHash ?? false
};
using var activity = SbomTracing.Source.StartActivity("lineage.graph", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("artifact_digest", artifactDigest);
var graph = await lineageService.GetLineageGraphAsync(
artifactDigest.Trim(),
tenant.Trim(),
options,
cancellationToken);
if (graph is null)
{
return Results.NotFound(new { error = "lineage graph not found" });
}
return Results.Ok(graph);
});
app.MapGet("/api/v1/lineage/diff", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromQuery] string? from,
[FromQuery] string? to,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
return Results.BadRequest(new { error = "from and to digests are required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.diff", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("from_digest", from);
activity?.SetTag("to_digest", to);
var diff = await lineageService.GetLineageDiffAsync(
from.Trim(),
to.Trim(),
tenant.Trim(),
cancellationToken);
if (diff is null)
{
return Results.NotFound(new { error = "lineage diff not found" });
}
SbomMetrics.LedgerDiffsTotal.Add(1);
return Results.Ok(diff);
});
app.MapGet("/api/v1/lineage/hover", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromQuery] string? from,
[FromQuery] string? to,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
return Results.BadRequest(new { error = "from and to digests are required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.hover", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
var hoverCard = await lineageService.GetHoverCardAsync(
from.Trim(),
to.Trim(),
tenant.Trim(),
cancellationToken);
if (hoverCard is null)
{
return Results.NotFound(new { error = "hover card data not found" });
}
return Results.Ok(hoverCard);
});
app.MapGet("/api/v1/lineage/{artifactDigest}/children", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromRoute] string artifactDigest,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest is required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.children", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("artifact_digest", artifactDigest);
var children = await lineageService.GetChildrenAsync(
artifactDigest.Trim(),
tenant.Trim(),
cancellationToken);
return Results.Ok(new { parentDigest = artifactDigest.Trim(), children });
});
app.MapGet("/api/v1/lineage/{artifactDigest}/parents", async Task<IResult> (
[FromServices] ISbomLineageGraphService lineageService,
[FromRoute] string artifactDigest,
[FromQuery] string? tenant,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest is required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.parents", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("artifact_digest", artifactDigest);
var parents = await lineageService.GetParentsAsync(
artifactDigest.Trim(),
tenant.Trim(),
cancellationToken);
return Results.Ok(new { childDigest = artifactDigest.Trim(), parents });
});
// -----------------------------------------------------------------------------
// Lineage Compare API (LIN-BE-028)
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
// -----------------------------------------------------------------------------
app.MapGet("/api/v1/lineage/compare", async Task<IResult> (
[FromServices] ILineageCompareService compareService,
[FromQuery(Name = "a")] string? fromDigest,
[FromQuery(Name = "b")] string? toDigest,
[FromQuery] string? tenant,
[FromQuery] bool? includeSbomDiff,
[FromQuery] bool? includeVexDeltas,
[FromQuery] bool? includeReachabilityDeltas,
[FromQuery] bool? includeAttestations,
[FromQuery] bool? includeReplayHashes,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(fromDigest) || string.IsNullOrWhiteSpace(toDigest))
{
return Results.BadRequest(new { error = "a (from digest) and b (to digest) query parameters are required" });
}
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant is required" });
}
var options = new LineageCompareOptions
{
IncludeSbomDiff = includeSbomDiff ?? true,
IncludeVexDeltas = includeVexDeltas ?? true,
IncludeReachabilityDeltas = includeReachabilityDeltas ?? true,
IncludeAttestations = includeAttestations ?? true,
IncludeReplayHashes = includeReplayHashes ?? true
};
using var activity = SbomTracing.Source.StartActivity("lineage.compare", ActivityKind.Server);
activity?.SetTag("tenant", tenant);
activity?.SetTag("from_digest", fromDigest);
activity?.SetTag("to_digest", toDigest);
var result = await compareService.CompareAsync(
fromDigest.Trim(),
toDigest.Trim(),
tenant.Trim(),
options,
cancellationToken);
if (result is null)
{
return Results.NotFound(new { error = "comparison data not found for the specified artifacts" });
}
return Results.Ok(result);
});
// -----------------------------------------------------------------------------
// Replay Verification API (LIN-BE-033)
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
// -----------------------------------------------------------------------------
app.MapPost("/api/v1/lineage/verify", async Task<IResult> (
[FromServices] IReplayVerificationService verificationService,
[FromBody] ReplayVerifyRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.ReplayHash))
{
return Results.BadRequest(new { error = "replayHash is required" });
}
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenantId is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.verify", ActivityKind.Server);
activity?.SetTag("tenant", request.TenantId);
activity?.SetTag("replay_hash", request.ReplayHash.Length > 16 ? request.ReplayHash[..16] + "..." : request.ReplayHash);
var verifyRequest = new ReplayVerificationRequest
{
ReplayHash = request.ReplayHash,
TenantId = request.TenantId,
SbomDigest = request.SbomDigest,
FeedsSnapshotDigest = request.FeedsSnapshotDigest,
PolicyVersion = request.PolicyVersion,
VexVerdictsDigest = request.VexVerdictsDigest,
Timestamp = request.Timestamp,
FreezeTime = request.FreezeTime ?? true,
ReEvaluatePolicy = request.ReEvaluatePolicy ?? false
};
var result = await verificationService.VerifyAsync(verifyRequest, cancellationToken);
return Results.Ok(result);
});
app.MapPost("/api/v1/lineage/compare-drift", async Task<IResult> (
[FromServices] IReplayVerificationService verificationService,
[FromBody] CompareDriftRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.HashA) || string.IsNullOrWhiteSpace(request.HashB))
{
return Results.BadRequest(new { error = "hashA and hashB are required" });
}
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenantId is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.compare-drift", ActivityKind.Server);
activity?.SetTag("tenant", request.TenantId);
var result = await verificationService.CompareDriftAsync(
request.HashA,
request.HashB,
request.TenantId,
cancellationToken);
return Results.Ok(result);
});
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromRoute] string? snapshotId,
@@ -932,4 +1263,5 @@ app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
app.Run();
// Program class public for WebApplicationFactory<Program>
public partial class Program;

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.SbomService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62535;http://localhost:62537"
}
}
}

View File

@@ -10,6 +10,8 @@ internal interface ISbomLedgerRepository
{
Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerVersion version, CancellationToken cancellationToken);
Task<SbomLedgerVersion?> GetVersionAsync(Guid versionId, CancellationToken cancellationToken);
// LIN-BE-003: Lookup version by digest for ancestry resolution
Task<SbomLedgerVersion?> GetVersionByDigestAsync(string digest, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomLedgerVersion>> GetVersionsAsync(string artifactRef, CancellationToken cancellationToken);
Task<Guid?> GetChainIdAsync(string artifactRef, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomLedgerAuditEntry>> GetAuditAsync(string artifactRef, CancellationToken cancellationToken);

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// ISbomLineageEdgeRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-005)
// Task: Repository interface for SBOM lineage edge persistence
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// Repository for persisting and querying SBOM lineage edges.
/// Edges represent relationships between artifact versions using digests.
/// </summary>
public interface ISbomLineageEdgeRepository
{
/// <summary>
/// Adds a lineage edge between two artifacts.
/// </summary>
/// <param name="edge">The edge to add.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created edge with assigned ID.</returns>
ValueTask<SbomLineageEdgeEntity> AddAsync(
SbomLineageEdgeEntity edge,
CancellationToken ct = default);
/// <summary>
/// Adds multiple lineage edges in a single operation.
/// </summary>
/// <param name="edges">Edges to add.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of edges added.</returns>
ValueTask<int> AddRangeAsync(
IEnumerable<SbomLineageEdgeEntity> edges,
CancellationToken ct = default);
/// <summary>
/// Gets all edges where the specified digest is the parent.
/// </summary>
/// <param name="parentDigest">Parent artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of edges to child artifacts.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetChildrenAsync(
string parentDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets all edges where the specified digest is the child.
/// </summary>
/// <param name="childDigest">Child artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of edges from parent artifacts.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetParentsAsync(
string childDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets the full lineage graph for an artifact up to a specified depth.
/// </summary>
/// <param name="artifactDigest">Starting artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>All edges in the lineage graph.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetGraphAsync(
string artifactDigest,
Guid tenantId,
int maxDepth = 10,
CancellationToken ct = default);
/// <summary>
/// Checks if an edge exists between two artifacts.
/// </summary>
/// <param name="parentDigest">Parent artifact digest.</param>
/// <param name="childDigest">Child artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if edge exists.</returns>
ValueTask<bool> ExistsAsync(
string parentDigest,
string childDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Deletes edges for an artifact (when pruning old versions).
/// </summary>
/// <param name="artifactDigest">Artifact digest to remove edges for.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of edges deleted.</returns>
ValueTask<int> DeleteByArtifactAsync(
string artifactDigest,
Guid tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets edges by relationship type.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="relationship">Relationship type filter.</param>
/// <param name="limit">Maximum edges to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Edges matching the relationship type.</returns>
ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetByRelationshipAsync(
Guid tenantId,
LineageRelationship relationship,
int limit = 100,
CancellationToken ct = default);
}

View File

@@ -44,6 +44,24 @@ internal sealed class InMemorySbomLedgerRepository : ISbomLedgerRepository
return Task.FromResult(version);
}
// LIN-BE-003: Lookup version by digest for ancestry resolution
public Task<SbomLedgerVersion?> GetVersionByDigestAsync(string digest, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(digest))
{
return Task.FromResult<SbomLedgerVersion?>(null);
}
// Find the most recent version with matching digest
var version = _versions.Values
.Where(v => string.Equals(v.Digest, digest, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(v => v.CreatedAtUtc)
.FirstOrDefault();
return Task.FromResult(version);
}
public Task<IReadOnlyList<SbomLedgerVersion>> GetVersionsAsync(string artifactRef, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

View File

@@ -0,0 +1,244 @@
// -----------------------------------------------------------------------------
// InMemorySbomLineageEdgeRepository.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-005)
// Task: In-memory implementation of SBOM lineage edge repository
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
/// <summary>
/// In-memory implementation of lineage edge repository.
/// Suitable for testing and development.
/// </summary>
public sealed class InMemorySbomLineageEdgeRepository : ISbomLineageEdgeRepository
{
private readonly ConcurrentDictionary<Guid, SbomLineageEdgeEntity> _edges = new();
private readonly object _lock = new();
/// <inheritdoc />
public ValueTask<SbomLineageEdgeEntity> AddAsync(
SbomLineageEdgeEntity edge,
CancellationToken ct = default)
{
lock (_lock)
{
// Check for duplicate edge
var existing = _edges.Values.FirstOrDefault(e =>
e.ParentDigest == edge.ParentDigest &&
e.ChildDigest == edge.ChildDigest &&
e.TenantId == edge.TenantId);
if (existing is not null)
{
return ValueTask.FromResult(existing);
}
var newEdge = edge with
{
Id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id,
CreatedAt = edge.CreatedAt == default ? DateTimeOffset.UtcNow : edge.CreatedAt
};
_edges[newEdge.Id] = newEdge;
return ValueTask.FromResult(newEdge);
}
}
/// <inheritdoc />
public ValueTask<int> AddRangeAsync(
IEnumerable<SbomLineageEdgeEntity> edges,
CancellationToken ct = default)
{
var count = 0;
lock (_lock)
{
foreach (var edge in edges)
{
var existing = _edges.Values.FirstOrDefault(e =>
e.ParentDigest == edge.ParentDigest &&
e.ChildDigest == edge.ChildDigest &&
e.TenantId == edge.TenantId);
if (existing is null)
{
var newEdge = edge with
{
Id = edge.Id == Guid.Empty ? Guid.NewGuid() : edge.Id,
CreatedAt = edge.CreatedAt == default ? DateTimeOffset.UtcNow : edge.CreatedAt
};
_edges[newEdge.Id] = newEdge;
count++;
}
}
}
return ValueTask.FromResult(count);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetChildrenAsync(
string parentDigest,
Guid tenantId,
CancellationToken ct = default)
{
var children = _edges.Values
.Where(e => e.ParentDigest == parentDigest && e.TenantId == tenantId)
.OrderBy(e => e.ChildDigest, StringComparer.Ordinal)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(children);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetParentsAsync(
string childDigest,
Guid tenantId,
CancellationToken ct = default)
{
var parents = _edges.Values
.Where(e => e.ChildDigest == childDigest && e.TenantId == tenantId)
.OrderBy(e => e.ParentDigest, StringComparer.Ordinal)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(parents);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetGraphAsync(
string artifactDigest,
Guid tenantId,
int maxDepth = 10,
CancellationToken ct = default)
{
var visited = new HashSet<string>();
var result = new List<SbomLineageEdgeEntity>();
var queue = new Queue<(string Digest, int Depth)>();
queue.Enqueue((artifactDigest, 0));
visited.Add(artifactDigest);
while (queue.Count > 0)
{
var (currentDigest, depth) = queue.Dequeue();
if (depth >= maxDepth)
{
continue;
}
// Get edges where current is parent (descendants)
var childEdges = _edges.Values
.Where(e => e.ParentDigest == currentDigest && e.TenantId == tenantId)
.ToList();
foreach (var edge in childEdges)
{
result.Add(edge);
if (!visited.Contains(edge.ChildDigest))
{
visited.Add(edge.ChildDigest);
queue.Enqueue((edge.ChildDigest, depth + 1));
}
}
// Get edges where current is child (ancestors)
var parentEdges = _edges.Values
.Where(e => e.ChildDigest == currentDigest && e.TenantId == tenantId)
.ToList();
foreach (var edge in parentEdges)
{
if (!result.Any(r => r.Id == edge.Id))
{
result.Add(edge);
}
if (!visited.Contains(edge.ParentDigest))
{
visited.Add(edge.ParentDigest);
queue.Enqueue((edge.ParentDigest, depth + 1));
}
}
}
// Order deterministically
var orderedResult = result
.OrderBy(e => e.ParentDigest, StringComparer.Ordinal)
.ThenBy(e => e.ChildDigest, StringComparer.Ordinal)
.ThenBy(e => e.Relationship)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(orderedResult);
}
/// <inheritdoc />
public ValueTask<bool> ExistsAsync(
string parentDigest,
string childDigest,
Guid tenantId,
CancellationToken ct = default)
{
var exists = _edges.Values.Any(e =>
e.ParentDigest == parentDigest &&
e.ChildDigest == childDigest &&
e.TenantId == tenantId);
return ValueTask.FromResult(exists);
}
/// <inheritdoc />
public ValueTask<int> DeleteByArtifactAsync(
string artifactDigest,
Guid tenantId,
CancellationToken ct = default)
{
var count = 0;
lock (_lock)
{
var toRemove = _edges.Values
.Where(e => e.TenantId == tenantId &&
(e.ParentDigest == artifactDigest || e.ChildDigest == artifactDigest))
.Select(e => e.Id)
.ToList();
foreach (var id in toRemove)
{
if (_edges.TryRemove(id, out _))
{
count++;
}
}
}
return ValueTask.FromResult(count);
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<SbomLineageEdgeEntity>> GetByRelationshipAsync(
Guid tenantId,
LineageRelationship relationship,
int limit = 100,
CancellationToken ct = default)
{
var edges = _edges.Values
.Where(e => e.TenantId == tenantId && e.Relationship == relationship)
.OrderByDescending(e => e.CreatedAt)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<SbomLineageEdgeEntity>>(edges);
}
/// <summary>
/// Clears all edges (for testing).
/// </summary>
public void Clear()
{
_edges.Clear();
}
/// <summary>
/// Gets the count of edges (for testing).
/// </summary>
public int Count => _edges.Count;
}

View File

@@ -0,0 +1,112 @@
// -----------------------------------------------------------------------------
// ILineageCompareCache.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-034)
// Task: Add caching for compare results
// Description: Interface for caching lineage compare results with TTL.
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Services;
/// <summary>
/// Cache for lineage compare results.
/// Supports TTL-based expiration and VEX-triggered invalidation.
/// </summary>
public interface ILineageCompareCache
{
/// <summary>
/// Gets a cached compare result.
/// </summary>
/// <param name="fromDigest">The source artifact digest.</param>
/// <param name="toDigest">The target artifact digest.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The cached result or null if not cached or expired.</returns>
Task<LineageCompareResponse?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Stores a compare result in cache.
/// </summary>
/// <param name="fromDigest">The source artifact digest.</param>
/// <param name="toDigest">The target artifact digest.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="result">The compare result to cache.</param>
/// <param name="ttl">Optional TTL override.</param>
/// <param name="ct">Cancellation token.</param>
Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareResponse result,
TimeSpan? ttl = null,
CancellationToken ct = default);
/// <summary>
/// Invalidates cached results for a specific artifact.
/// Called when VEX data changes for the artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest to invalidate.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of cache entries invalidated.</returns>
Task<int> InvalidateForArtifactAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Invalidates all cached results for a tenant.
/// </summary>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of cache entries invalidated.</returns>
Task<int> InvalidateForTenantAsync(
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
CompareCacheStats GetStats();
}
/// <summary>
/// Cache statistics for monitoring.
/// </summary>
public sealed record CompareCacheStats
{
/// <summary>
/// Total number of cache entries.
/// </summary>
public int TotalEntries { get; init; }
/// <summary>
/// Number of cache hits.
/// </summary>
public long CacheHits { get; init; }
/// <summary>
/// Number of cache misses.
/// </summary>
public long CacheMisses { get; init; }
/// <summary>
/// Number of invalidations performed.
/// </summary>
public long Invalidations { get; init; }
/// <summary>
/// Hit rate percentage.
/// </summary>
public double HitRate => CacheHits + CacheMisses > 0
? (double)CacheHits / (CacheHits + CacheMisses) * 100
: 0;
/// <summary>
/// Estimated memory usage in bytes.
/// </summary>
public long EstimatedMemoryBytes { get; init; }
}

View File

@@ -0,0 +1,532 @@
// -----------------------------------------------------------------------------
// ILineageCompareService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-028)
// Task: Create GET /api/v1/lineage/compare endpoint
// Description: Service for full artifact comparison with SBOM, VEX, reachability deltas.
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for comprehensive artifact comparison.
/// Returns full comparison with SBOM diff, VEX deltas, reachability deltas,
/// attestation links, and replay hashes.
/// </summary>
internal interface ILineageCompareService
{
/// <summary>
/// Compares two artifacts and returns full comparison data.
/// </summary>
/// <param name="fromDigest">Source artifact digest.</param>
/// <param name="toDigest">Target artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="options">Comparison options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Full comparison response.</returns>
Task<LineageCompareResponse?> CompareAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// Options for lineage comparison.
/// </summary>
internal sealed record LineageCompareOptions
{
/// <summary>
/// Whether to include SBOM component diff.
/// </summary>
public bool IncludeSbomDiff { get; init; } = true;
/// <summary>
/// Whether to include VEX status deltas.
/// </summary>
public bool IncludeVexDeltas { get; init; } = true;
/// <summary>
/// Whether to include reachability deltas.
/// </summary>
public bool IncludeReachabilityDeltas { get; init; } = true;
/// <summary>
/// Whether to include attestation links.
/// </summary>
public bool IncludeAttestations { get; init; } = true;
/// <summary>
/// Whether to include replay hashes.
/// </summary>
public bool IncludeReplayHashes { get; init; } = true;
/// <summary>
/// Maximum number of component changes to include.
/// </summary>
public int MaxComponentChanges { get; init; } = 100;
/// <summary>
/// Maximum number of VEX deltas to include.
/// </summary>
public int MaxVexDeltas { get; init; } = 100;
/// <summary>
/// Maximum number of reachability deltas to include.
/// </summary>
public int MaxReachabilityDeltas { get; init; } = 50;
}
/// <summary>
/// Full comparison response between two artifacts.
/// </summary>
public sealed record LineageCompareResponse
{
/// <summary>
/// Source artifact digest.
/// </summary>
public required string FromDigest { get; init; }
/// <summary>
/// Target artifact digest.
/// </summary>
public required string ToDigest { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the comparison was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Source artifact metadata.
/// </summary>
public LineageCompareArtifactInfo? FromArtifact { get; init; }
/// <summary>
/// Target artifact metadata.
/// </summary>
public LineageCompareArtifactInfo? ToArtifact { get; init; }
/// <summary>
/// Summary statistics for the comparison.
/// </summary>
public required LineageCompareSummary Summary { get; init; }
/// <summary>
/// SBOM component diff.
/// </summary>
public LineageSbomDiff? SbomDiff { get; init; }
/// <summary>
/// VEX status deltas.
/// </summary>
public LineageVexDeltaSummary? VexDeltas { get; init; }
/// <summary>
/// Reachability deltas.
/// </summary>
public LineageReachabilityDeltaSummary? ReachabilityDeltas { get; init; }
/// <summary>
/// Linked attestations.
/// </summary>
public IReadOnlyList<LineageAttestationLink>? Attestations { get; init; }
/// <summary>
/// Replay hash information.
/// </summary>
public LineageReplayHashInfo? ReplayHashes { get; init; }
}
/// <summary>
/// Artifact information in comparison.
/// </summary>
public sealed record LineageCompareArtifactInfo
{
/// <summary>
/// Artifact digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// SBOM digest if available.
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Artifact name/repository.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Artifact version/tag.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// When the SBOM was created.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>
/// Component count in SBOM.
/// </summary>
public int ComponentCount { get; init; }
/// <summary>
/// Vulnerability count.
/// </summary>
public int VulnerabilityCount { get; init; }
}
/// <summary>
/// Summary of comparison results.
/// </summary>
public sealed record LineageCompareSummary
{
/// <summary>
/// Components added in target.
/// </summary>
public int ComponentsAdded { get; init; }
/// <summary>
/// Components removed from source.
/// </summary>
public int ComponentsRemoved { get; init; }
/// <summary>
/// Components with version changes.
/// </summary>
public int ComponentsModified { get; init; }
/// <summary>
/// New vulnerabilities in target.
/// </summary>
public int VulnerabilitiesAdded { get; init; }
/// <summary>
/// Vulnerabilities resolved in target.
/// </summary>
public int VulnerabilitiesResolved { get; init; }
/// <summary>
/// VEX status changes.
/// </summary>
public int VexStatusChanges { get; init; }
/// <summary>
/// Reachability changes.
/// </summary>
public int ReachabilityChanges { get; init; }
/// <summary>
/// Attestations available.
/// </summary>
public int AttestationCount { get; init; }
/// <summary>
/// Overall risk trend: "improved", "degraded", or "unchanged".
/// </summary>
public string RiskTrend { get; init; } = "unchanged";
}
/// <summary>
/// SBOM component diff.
/// </summary>
public sealed record LineageSbomDiff
{
/// <summary>
/// Components added in target.
/// </summary>
public IReadOnlyList<LineageComponentChange> Added { get; init; } = Array.Empty<LineageComponentChange>();
/// <summary>
/// Components removed from source.
/// </summary>
public IReadOnlyList<LineageComponentChange> Removed { get; init; } = Array.Empty<LineageComponentChange>();
/// <summary>
/// Components with version changes.
/// </summary>
public IReadOnlyList<LineageComponentModification> Modified { get; init; } = Array.Empty<LineageComponentModification>();
/// <summary>
/// Whether the diff is truncated.
/// </summary>
public bool Truncated { get; init; }
/// <summary>
/// Total count of changes (may be higher than arrays if truncated).
/// </summary>
public int TotalChanges { get; init; }
}
/// <summary>
/// Component change entry.
/// </summary>
public sealed record LineageComponentChange
{
/// <summary>
/// Package URL (PURL).
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Component name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Component version.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Component type (library, application, etc.).
/// </summary>
public string? Type { get; init; }
/// <summary>
/// License information.
/// </summary>
public string? License { get; init; }
/// <summary>
/// Known vulnerabilities in this component.
/// </summary>
public IReadOnlyList<string>? Vulnerabilities { get; init; }
}
/// <summary>
/// Component modification entry.
/// </summary>
public sealed record LineageComponentModification
{
/// <summary>
/// Package URL (PURL) - common base without version.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Component name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Version in source artifact.
/// </summary>
public required string FromVersion { get; init; }
/// <summary>
/// Version in target artifact.
/// </summary>
public required string ToVersion { get; init; }
/// <summary>
/// Whether this is a major, minor, or patch upgrade.
/// </summary>
public string? UpgradeType { get; init; }
/// <summary>
/// Vulnerabilities fixed by this upgrade.
/// </summary>
public IReadOnlyList<string>? FixedVulnerabilities { get; init; }
/// <summary>
/// New vulnerabilities introduced by this upgrade.
/// </summary>
public IReadOnlyList<string>? IntroducedVulnerabilities { get; init; }
}
/// <summary>
/// VEX delta summary for comparison.
/// </summary>
public sealed record LineageVexDeltaSummary
{
/// <summary>
/// Total VEX status changes.
/// </summary>
public int TotalChanges { get; init; }
/// <summary>
/// Status upgrades (e.g., under_investigation -> not_affected).
/// </summary>
public int StatusUpgrades { get; init; }
/// <summary>
/// Status downgrades (e.g., not_affected -> affected).
/// </summary>
public int StatusDowngrades { get; init; }
/// <summary>
/// Individual VEX status changes.
/// </summary>
public IReadOnlyList<LineageVexChange> Changes { get; init; } = Array.Empty<LineageVexChange>();
/// <summary>
/// Whether the list is truncated.
/// </summary>
public bool Truncated { get; init; }
}
/// <summary>
/// Individual VEX status change.
/// </summary>
public sealed record LineageVexChange
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// Status in source artifact.
/// </summary>
public required string FromStatus { get; init; }
/// <summary>
/// Status in target artifact.
/// </summary>
public required string ToStatus { get; init; }
/// <summary>
/// Justification for the change.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Attestation digest if available.
/// </summary>
public string? AttestationDigest { get; init; }
}
/// <summary>
/// Reachability delta summary for comparison.
/// </summary>
public sealed record LineageReachabilityDeltaSummary
{
/// <summary>
/// Total reachability changes.
/// </summary>
public int TotalChanges { get; init; }
/// <summary>
/// Vulnerabilities that became reachable.
/// </summary>
public int NewlyReachable { get; init; }
/// <summary>
/// Vulnerabilities that became unreachable.
/// </summary>
public int NewlyUnreachable { get; init; }
/// <summary>
/// Individual reachability changes.
/// </summary>
public IReadOnlyList<LineageReachabilityChange> Changes { get; init; } = Array.Empty<LineageReachabilityChange>();
/// <summary>
/// Whether the list is truncated.
/// </summary>
public bool Truncated { get; init; }
}
/// <summary>
/// Individual reachability change.
/// </summary>
public sealed record LineageReachabilityChange
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string Cve { get; init; }
/// <summary>
/// Change type.
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Reachability status in source.
/// </summary>
public required string FromStatus { get; init; }
/// <summary>
/// Reachability status in target.
/// </summary>
public required string ToStatus { get; init; }
/// <summary>
/// Path count change.
/// </summary>
public int PathCountDelta { get; init; }
/// <summary>
/// Brief explanation.
/// </summary>
public string? Explanation { get; init; }
}
/// <summary>
/// Attestation link for comparison.
/// </summary>
public sealed record LineageAttestationLink
{
/// <summary>
/// Attestation digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Predicate type.
/// </summary>
public required string PredicateType { get; init; }
/// <summary>
/// When the attestation was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Transparency log entry index.
/// </summary>
public long? TransparencyLogIndex { get; init; }
/// <summary>
/// Brief description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Replay hash information for comparison.
/// </summary>
public sealed record LineageReplayHashInfo
{
/// <summary>
/// Replay hash for source artifact.
/// </summary>
public string? FromReplayHash { get; init; }
/// <summary>
/// Replay hash for target artifact.
/// </summary>
public string? ToReplayHash { get; init; }
/// <summary>
/// Whether the comparison is reproducible.
/// </summary>
public bool IsReproducible { get; init; }
/// <summary>
/// Verification status.
/// </summary>
public string? VerificationStatus { get; init; }
}

View File

@@ -0,0 +1,102 @@
// -----------------------------------------------------------------------------
// IReplayHashService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023)
// Task: Compute replay hash per lineage node
// Description: Replay hash = SHA256(sbom_digest + feeds_snapshot_digest +
// policy_version + vex_verdicts_digest + timestamp)
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for computing deterministic replay hashes for lineage nodes.
/// Replay hashes enable verification that a security evaluation can be reproduced
/// given the same inputs (SBOM, feeds, policy, VEX verdicts).
/// </summary>
internal interface IReplayHashService
{
/// <summary>
/// Computes the replay hash for a lineage node.
/// </summary>
/// <param name="inputs">The inputs used to compute the hash.</param>
/// <returns>Hex-encoded SHA256 hash.</returns>
string ComputeHash(ReplayHashInputs inputs);
/// <summary>
/// Computes the replay hash for a lineage node asynchronously,
/// gathering the required inputs from services.
/// </summary>
/// <param name="sbomDigest">The SBOM digest.</param>
/// <param name="tenantId">The tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Computed replay hash and the inputs used.</returns>
Task<ReplayHashResult> ComputeHashAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Verifies that a replay hash matches the expected value.
/// </summary>
/// <param name="expectedHash">The expected replay hash.</param>
/// <param name="inputs">The inputs to verify against.</param>
/// <returns>True if the hash matches.</returns>
bool VerifyHash(string expectedHash, ReplayHashInputs inputs);
}
/// <summary>
/// Inputs for computing a replay hash.
/// All inputs are content-addressed digests or version identifiers.
/// </summary>
public sealed record ReplayHashInputs
{
/// <summary>
/// Content digest of the SBOM (sha256:xxxx).
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Digest of the vulnerability feeds snapshot used for analysis.
/// Computed from feed versions and their content hashes.
/// </summary>
public required string FeedsSnapshotDigest { get; init; }
/// <summary>
/// Version identifier of the policy bundle used.
/// Typically a semantic version or content hash.
/// </summary>
public required string PolicyVersion { get; init; }
/// <summary>
/// Digest of the aggregated VEX verdicts that apply to this SBOM.
/// Computed from consensus projections ordered deterministically.
/// </summary>
public required string VexVerdictsDigest { get; init; }
/// <summary>
/// UTC timestamp when the analysis was performed.
/// Truncated to minute precision for reproducibility.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Result of replay hash computation.
/// </summary>
public sealed record ReplayHashResult
{
/// <summary>
/// The computed replay hash (hex-encoded SHA256).
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// The inputs used to compute the hash.
/// </summary>
public required ReplayHashInputs Inputs { get; init; }
/// <summary>
/// Timestamp when the hash was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
}

View File

@@ -0,0 +1,255 @@
// -----------------------------------------------------------------------------
// IReplayVerificationService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033)
// Task: Replay verification endpoint
// Description: Interface for verifying replay hashes and detecting drift.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for verifying replay hashes and detecting drift in security evaluations.
/// </summary>
public interface IReplayVerificationService
{
/// <summary>
/// Verifies a replay hash by re-computing it with provided or current inputs.
/// </summary>
/// <param name="request">Verification request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with match status and drift details.</returns>
Task<ReplayVerificationResult> VerifyAsync(
ReplayVerificationRequest request,
CancellationToken ct = default);
/// <summary>
/// Compares two replay hashes and identifies drift between them.
/// </summary>
/// <param name="hashA">First replay hash.</param>
/// <param name="hashB">Second replay hash.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Drift analysis between the two evaluations.</returns>
Task<ReplayDriftAnalysis> CompareDriftAsync(
string hashA,
string hashB,
string tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Request for replay verification.
/// </summary>
public sealed record ReplayVerificationRequest
{
/// <summary>
/// The replay hash to verify.
/// </summary>
public required string ReplayHash { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Optional: SBOM digest to use for verification.
/// If not provided, will try to lookup from stored hash metadata.
/// </summary>
public string? SbomDigest { get; init; }
/// <summary>
/// Optional: Feeds snapshot digest to use.
/// If not provided, uses current feeds.
/// </summary>
public string? FeedsSnapshotDigest { get; init; }
/// <summary>
/// Optional: Policy version to use.
/// If not provided, uses current policy.
/// </summary>
public string? PolicyVersion { get; init; }
/// <summary>
/// Optional: VEX verdicts digest to use.
/// If not provided, uses current VEX state.
/// </summary>
public string? VexVerdictsDigest { get; init; }
/// <summary>
/// Optional: Timestamp to use for verification.
/// If not provided, uses current time.
/// </summary>
public DateTimeOffset? Timestamp { get; init; }
/// <summary>
/// Whether to freeze time to the original evaluation timestamp.
/// </summary>
public bool FreezeTime { get; init; } = true;
/// <summary>
/// Whether to re-evaluate policy with frozen feeds.
/// </summary>
public bool ReEvaluatePolicy { get; init; } = false;
}
/// <summary>
/// Result of replay verification.
/// </summary>
public sealed record ReplayVerificationResult
{
/// <summary>
/// Whether the replay hash matches.
/// </summary>
public required bool IsMatch { get; init; }
/// <summary>
/// The expected replay hash.
/// </summary>
public required string ExpectedHash { get; init; }
/// <summary>
/// The computed replay hash.
/// </summary>
public required string ComputedHash { get; init; }
/// <summary>
/// Overall verification status.
/// </summary>
public required ReplayVerificationStatus Status { get; init; }
/// <summary>
/// The inputs used for the expected hash (from storage).
/// </summary>
public ReplayHashInputs? ExpectedInputs { get; init; }
/// <summary>
/// The inputs used to compute the verification hash.
/// </summary>
public ReplayHashInputs? ComputedInputs { get; init; }
/// <summary>
/// Field-level differences between expected and computed.
/// </summary>
public ImmutableArray<ReplayFieldDrift> Drifts { get; init; } = ImmutableArray<ReplayFieldDrift>.Empty;
/// <summary>
/// When the verification was performed.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Optional message with additional context.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Verification status enumeration.
/// </summary>
public enum ReplayVerificationStatus
{
/// <summary>
/// Hash matches exactly - evaluation is reproducible.
/// </summary>
Match,
/// <summary>
/// Hash doesn't match - drift detected.
/// </summary>
Drift,
/// <summary>
/// Unable to lookup original inputs.
/// </summary>
InputsNotFound,
/// <summary>
/// Verification failed due to error.
/// </summary>
Error
}
/// <summary>
/// Field-level drift in replay verification.
/// </summary>
public sealed record ReplayFieldDrift
{
/// <summary>
/// Name of the field that drifted.
/// </summary>
public required string FieldName { get; init; }
/// <summary>
/// Expected value (from original evaluation).
/// </summary>
public required string ExpectedValue { get; init; }
/// <summary>
/// Actual/computed value.
/// </summary>
public required string ActualValue { get; init; }
/// <summary>
/// Severity of the drift: "info", "warning", "critical".
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// Human-readable description of the drift impact.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Analysis of drift between two replay evaluations.
/// </summary>
public sealed record ReplayDriftAnalysis
{
/// <summary>
/// First replay hash.
/// </summary>
public required string HashA { get; init; }
/// <summary>
/// Second replay hash.
/// </summary>
public required string HashB { get; init; }
/// <summary>
/// Whether the hashes are identical.
/// </summary>
public required bool IsIdentical { get; init; }
/// <summary>
/// Inputs for first hash.
/// </summary>
public ReplayHashInputs? InputsA { get; init; }
/// <summary>
/// Inputs for second hash.
/// </summary>
public ReplayHashInputs? InputsB { get; init; }
/// <summary>
/// Field-level drifts between A and B.
/// </summary>
public ImmutableArray<ReplayFieldDrift> Drifts { get; init; } = ImmutableArray<ReplayFieldDrift>.Empty;
/// <summary>
/// Summary of drift severity.
/// </summary>
public required string DriftSummary { get; init; }
/// <summary>
/// When the analysis was performed.
/// </summary>
public DateTimeOffset AnalyzedAt { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// ISbomLineageGraphService.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-013)
// Task: Create lineage graph service interface
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for querying and computing SBOM lineage graphs.
/// Provides graph traversal, diff computation, and hover card data.
/// </summary>
internal interface ISbomLineageGraphService
{
/// <summary>
/// Gets the full lineage graph for an artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest to start from.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="options">Query options (depth, include badges, etc.).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Complete lineage graph response.</returns>
Task<SbomLineageGraphResponse?> GetLineageGraphAsync(
string artifactDigest,
string tenantId,
SbomLineageQueryOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Gets the lineage diff between two artifacts.
/// </summary>
/// <param name="fromDigest">Source artifact digest.</param>
/// <param name="toDigest">Target artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Diff response with component and VEX deltas.</returns>
Task<SbomLineageDiffResponse?> GetLineageDiffAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets hover card data for a specific edge.
/// </summary>
/// <param name="fromDigest">Source artifact digest.</param>
/// <param name="toDigest">Target artifact digest.</param>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Hover card data with summary info.</returns>
Task<SbomLineageHoverCard?> GetHoverCardAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets children of an artifact in the lineage graph.
/// </summary>
Task<IReadOnlyList<SbomLineageNodeExtended>> GetChildrenAsync(
string parentDigest,
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Gets parents of an artifact in the lineage graph.
/// </summary>
Task<IReadOnlyList<SbomLineageNodeExtended>> GetParentsAsync(
string childDigest,
string tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Hover card data for lineage edge.
/// </summary>
internal sealed record SbomLineageHoverCard
{
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public required string Relationship { get; init; }
public SbomDiffSummary? ComponentDiff { get; init; }
public VexDeltaHoverSummary? VexDiff { get; init; }
public string? ReplayHash { get; init; }
public DateTimeOffset? FromCreatedAt { get; init; }
public DateTimeOffset? ToCreatedAt { get; init; }
}
/// <summary>
/// VEX delta summary for hover card.
/// </summary>
internal sealed record VexDeltaHoverSummary
{
public int NewVulns { get; init; }
public int ResolvedVulns { get; init; }
public int StatusChanges { get; init; }
public IReadOnlyList<string>? CriticalCves { get; init; }
}

View File

@@ -0,0 +1,324 @@
// -----------------------------------------------------------------------------
// InMemoryLineageCompareCache.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-034)
// Task: Add caching for compare results
// Description: In-memory implementation of lineage compare cache with TTL.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.SbomService.Services;
/// <summary>
/// In-memory implementation of <see cref="ILineageCompareCache"/>.
/// Uses ConcurrentDictionary with TTL-based expiration.
/// </summary>
internal sealed class InMemoryLineageCompareCache : ILineageCompareCache, IDisposable
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.CompareCache");
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private readonly ILogger<InMemoryLineageCompareCache> _logger;
private readonly CompareCacheOptions _options;
private readonly IClock _clock;
private readonly Timer _cleanupTimer;
private long _cacheHits;
private long _cacheMisses;
private long _invalidations;
public InMemoryLineageCompareCache(
ILogger<InMemoryLineageCompareCache> logger,
IOptions<CompareCacheOptions>? options,
IClock clock)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new CompareCacheOptions();
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
// Periodic cleanup of expired entries
_cleanupTimer = new Timer(
_ => CleanupExpiredEntries(),
null,
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(1));
_logger.LogInformation(
"Compare cache initialized with TTL {TtlMinutes} minutes, max entries {MaxEntries}",
_options.DefaultTtlMinutes,
_options.MaxEntries);
}
/// <inheritdoc />
public Task<LineageCompareResponse?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
var key = BuildCacheKey(fromDigest, toDigest, tenantId);
using var activity = ActivitySource.StartActivity("CompareCache.Get");
activity?.SetTag("cache_key", key);
if (_cache.TryGetValue(key, out var entry))
{
if (entry.ExpiresAt > _clock.UtcNow)
{
Interlocked.Increment(ref _cacheHits);
activity?.SetTag("cache_hit", true);
_logger.LogDebug("Cache hit for compare {FromDigest} -> {ToDigest}",
TruncateDigest(fromDigest), TruncateDigest(toDigest));
return Task.FromResult<LineageCompareResponse?>(entry.Value);
}
// Expired - remove
_cache.TryRemove(key, out _);
}
Interlocked.Increment(ref _cacheMisses);
activity?.SetTag("cache_hit", false);
return Task.FromResult<LineageCompareResponse?>(null);
}
/// <inheritdoc />
public Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareResponse result,
TimeSpan? ttl = null,
CancellationToken ct = default)
{
var key = BuildCacheKey(fromDigest, toDigest, tenantId);
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
using var activity = ActivitySource.StartActivity("CompareCache.Set");
activity?.SetTag("cache_key", key);
activity?.SetTag("ttl_seconds", effectiveTtl.TotalSeconds);
// Check max entries limit
if (_cache.Count >= _options.MaxEntries)
{
EvictOldestEntries(_options.MaxEntries / 10); // Evict 10%
}
var entry = new CacheEntry
{
Value = result,
FromDigest = fromDigest,
ToDigest = toDigest,
TenantId = tenantId,
CreatedAt = _clock.UtcNow,
ExpiresAt = _clock.UtcNow.Add(effectiveTtl)
};
_cache[key] = entry;
_logger.LogDebug(
"Cached compare result for {FromDigest} -> {ToDigest}, expires at {ExpiresAt}",
TruncateDigest(fromDigest), TruncateDigest(toDigest), entry.ExpiresAt);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> InvalidateForArtifactAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CompareCache.InvalidateArtifact");
activity?.SetTag("artifact_digest", TruncateDigest(artifactDigest));
activity?.SetTag("tenant_id", tenantId);
var invalidated = 0;
var keysToRemove = new List<string>();
foreach (var kvp in _cache)
{
var entry = kvp.Value;
if (entry.TenantId == tenantId &&
(entry.FromDigest == artifactDigest || entry.ToDigest == artifactDigest))
{
keysToRemove.Add(kvp.Key);
}
}
foreach (var key in keysToRemove)
{
if (_cache.TryRemove(key, out _))
{
invalidated++;
}
}
if (invalidated > 0)
{
Interlocked.Add(ref _invalidations, invalidated);
_logger.LogInformation(
"Invalidated {Count} cache entries for artifact {ArtifactDigest}",
invalidated, TruncateDigest(artifactDigest));
}
activity?.SetTag("invalidated_count", invalidated);
return Task.FromResult(invalidated);
}
/// <inheritdoc />
public Task<int> InvalidateForTenantAsync(
string tenantId,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CompareCache.InvalidateTenant");
activity?.SetTag("tenant_id", tenantId);
var invalidated = 0;
var keysToRemove = new List<string>();
foreach (var kvp in _cache)
{
if (kvp.Value.TenantId == tenantId)
{
keysToRemove.Add(kvp.Key);
}
}
foreach (var key in keysToRemove)
{
if (_cache.TryRemove(key, out _))
{
invalidated++;
}
}
if (invalidated > 0)
{
Interlocked.Add(ref _invalidations, invalidated);
_logger.LogInformation(
"Invalidated {Count} cache entries for tenant {TenantId}",
invalidated, tenantId);
}
activity?.SetTag("invalidated_count", invalidated);
return Task.FromResult(invalidated);
}
/// <inheritdoc />
public CompareCacheStats GetStats()
{
return new CompareCacheStats
{
TotalEntries = _cache.Count,
CacheHits = Interlocked.Read(ref _cacheHits),
CacheMisses = Interlocked.Read(ref _cacheMisses),
Invalidations = Interlocked.Read(ref _invalidations),
EstimatedMemoryBytes = _cache.Count * 4096 // Rough estimate
};
}
private void CleanupExpiredEntries()
{
var now = _clock.UtcNow;
var expired = 0;
foreach (var kvp in _cache)
{
if (kvp.Value.ExpiresAt <= now)
{
if (_cache.TryRemove(kvp.Key, out _))
{
expired++;
}
}
}
if (expired > 0)
{
_logger.LogDebug("Cleaned up {Count} expired cache entries", expired);
}
}
private void EvictOldestEntries(int count)
{
var oldest = _cache
.OrderBy(kvp => kvp.Value.CreatedAt)
.Take(count)
.Select(kvp => kvp.Key)
.ToList();
var evicted = 0;
foreach (var key in oldest)
{
if (_cache.TryRemove(key, out _))
{
evicted++;
}
}
_logger.LogInformation("Evicted {Count} oldest cache entries", evicted);
}
private static string BuildCacheKey(string fromDigest, string toDigest, string tenantId)
{
// Normalize: always use smaller digest first for bidirectional lookup
var (first, second) = string.CompareOrdinal(fromDigest, toDigest) <= 0
? (fromDigest, toDigest)
: (toDigest, fromDigest);
return $"{tenantId}:{first}:{second}";
}
private static string TruncateDigest(string digest)
{
if (string.IsNullOrEmpty(digest)) return digest;
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
public void Dispose()
{
_cleanupTimer.Dispose();
}
private sealed class CacheEntry
{
public required LineageCompareResponse Value { get; init; }
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
}
/// <summary>
/// Configuration options for the compare cache.
/// </summary>
public sealed class CompareCacheOptions
{
/// <summary>
/// Default TTL in minutes. Default: 10.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 10;
/// <summary>
/// Maximum number of entries. Default: 10000.
/// </summary>
public int MaxEntries { get; set; } = 10000;
/// <summary>
/// Whether to enable cache. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,528 @@
// -----------------------------------------------------------------------------
// LineageCompareService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-028)
// Task: Create GET /api/v1/lineage/compare endpoint
// Description: Implementation of full artifact comparison service.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
using StellaOps.Excititor.Persistence.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="ILineageCompareService"/>.
/// Aggregates data from multiple sources to provide comprehensive artifact comparison.
/// </summary>
internal sealed class LineageCompareService : ILineageCompareService
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.LineageCompare");
private readonly ISbomLineageGraphService _lineageService;
private readonly ISbomLedgerService _ledgerService;
private readonly IVexDeltaRepository? _vexDeltaRepository;
private readonly ILineageCompareCache? _cache;
private readonly ILogger<LineageCompareService> _logger;
public LineageCompareService(
ISbomLineageGraphService lineageService,
ISbomLedgerService ledgerService,
ILogger<LineageCompareService> logger,
IVexDeltaRepository? vexDeltaRepository = null,
ILineageCompareCache? cache = null)
{
_lineageService = lineageService ?? throw new ArgumentNullException(nameof(lineageService));
_ledgerService = ledgerService ?? throw new ArgumentNullException(nameof(ledgerService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_vexDeltaRepository = vexDeltaRepository;
_cache = cache;
}
/// <inheritdoc />
public async Task<LineageCompareResponse?> CompareAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareOptions? options = null,
CancellationToken ct = default)
{
options ??= new LineageCompareOptions();
using var activity = ActivitySource.StartActivity("Compare");
activity?.SetTag("from_digest", fromDigest);
activity?.SetTag("to_digest", toDigest);
activity?.SetTag("tenant_id", tenantId);
// Try cache first (LIN-BE-034)
if (_cache is not null)
{
var cached = await _cache.GetAsync(fromDigest, toDigest, tenantId, ct);
if (cached is not null)
{
_logger.LogDebug("Returning cached compare result for {FromDigest} -> {ToDigest}",
TruncateDigest(fromDigest), TruncateDigest(toDigest));
activity?.SetTag("cache_hit", true);
return cached;
}
activity?.SetTag("cache_hit", false);
}
_logger.LogInformation(
"Computing comparison from {FromDigest} to {ToDigest} for tenant {TenantId}",
TruncateDigest(fromDigest),
TruncateDigest(toDigest),
tenantId);
// Get lineage diff (reuses existing service for SBOM component diff)
var linageDiff = await _lineageService.GetLineageDiffAsync(fromDigest, toDigest, tenantId, ct);
if (linageDiff is null)
{
_logger.LogWarning(
"Lineage diff not found for {FromDigest} to {ToDigest}",
TruncateDigest(fromDigest),
TruncateDigest(toDigest));
return null;
}
// Get artifact metadata from ledger
var fromArtifact = await GetArtifactInfoAsync(fromDigest, tenantId, ct);
var toArtifact = await GetArtifactInfoAsync(toDigest, tenantId, ct);
// Build SBOM diff from lineage diff
LineageSbomDiff? sbomDiff = null;
if (options.IncludeSbomDiff)
{
sbomDiff = BuildSbomDiff(linageDiff, options.MaxComponentChanges);
}
// Get VEX deltas
LineageVexDeltaSummary? vexDeltas = null;
if (options.IncludeVexDeltas && _vexDeltaRepository is not null)
{
vexDeltas = await GetVexDeltasAsync(fromDigest, toDigest, tenantId, options.MaxVexDeltas, ct);
}
else if (options.IncludeVexDeltas && linageDiff.VexDiff is not null && linageDiff.VexDiff.Count > 0)
{
// Fall back to lineage diff VEX data
vexDeltas = BuildVexDeltasFromDiff(linageDiff.VexDiff, options.MaxVexDeltas);
}
// Get reachability deltas (placeholder for now - would need Graph API integration)
LineageReachabilityDeltaSummary? reachabilityDeltas = null;
if (options.IncludeReachabilityDeltas)
{
reachabilityDeltas = await GetReachabilityDeltasAsync(fromDigest, toDigest, tenantId, options.MaxReachabilityDeltas, ct);
}
// Get attestations
IReadOnlyList<LineageAttestationLink>? attestations = null;
if (options.IncludeAttestations)
{
attestations = await GetAttestationsAsync(fromDigest, toDigest, tenantId, ct);
}
// Get replay hashes
LineageReplayHashInfo? replayHashes = null;
if (options.IncludeReplayHashes)
{
replayHashes = await GetReplayHashesAsync(fromDigest, toDigest, tenantId, ct);
}
// Compute summary
var summary = ComputeSummary(sbomDiff, vexDeltas, reachabilityDeltas, attestations);
var result = new LineageCompareResponse
{
FromDigest = fromDigest,
ToDigest = toDigest,
TenantId = tenantId,
ComputedAt = DateTimeOffset.UtcNow,
FromArtifact = fromArtifact,
ToArtifact = toArtifact,
Summary = summary,
SbomDiff = sbomDiff,
VexDeltas = vexDeltas,
ReachabilityDeltas = reachabilityDeltas,
Attestations = attestations,
ReplayHashes = replayHashes
};
// Cache the result (LIN-BE-034)
if (_cache is not null)
{
await _cache.SetAsync(fromDigest, toDigest, tenantId, result, ct: ct);
}
return result;
}
private async Task<LineageCompareArtifactInfo?> GetArtifactInfoAsync(
string artifactDigest,
string tenantId,
CancellationToken ct)
{
var lineage = await _ledgerService.GetLineageAsync(artifactDigest, ct);
if (lineage is null || lineage.Nodes.Count == 0)
{
return new LineageCompareArtifactInfo
{
Digest = artifactDigest,
Name = ExtractArtifactName(artifactDigest)
};
}
// Get the latest node (highest sequence number)
var latestNode = lineage.Nodes.OrderByDescending(n => n.SequenceNumber).First();
return new LineageCompareArtifactInfo
{
Digest = artifactDigest,
SbomDigest = latestNode.Digest,
Name = lineage.ArtifactRef,
Version = latestNode.SequenceNumber.ToString(),
CreatedAt = latestNode.CreatedAtUtc,
ComponentCount = 0, // Not available from SbomLineageNode
VulnerabilityCount = 0 // Not available from SbomLineageNode
};
}
private static LineageSbomDiff BuildSbomDiff(SbomLineageDiffResponse diff, int maxChanges)
{
var added = new List<LineageComponentChange>();
var removed = new List<LineageComponentChange>();
var modified = new List<LineageComponentModification>();
// Convert SBOM diff to our format
foreach (var comp in diff.SbomDiff.Added.Take(maxChanges / 3))
{
added.Add(new LineageComponentChange
{
Purl = comp.Purl ?? comp.Name,
Name = comp.Name,
Version = comp.Version,
Type = null,
License = comp.License
});
}
foreach (var comp in diff.SbomDiff.Removed.Take(maxChanges / 3))
{
removed.Add(new LineageComponentChange
{
Purl = comp.Purl ?? comp.Name,
Name = comp.Name,
Version = comp.Version,
Type = null,
License = comp.License
});
}
foreach (var comp in diff.SbomDiff.VersionChanged.Take(maxChanges / 3))
{
modified.Add(new LineageComponentModification
{
Purl = comp.Purl ?? comp.Name,
Name = comp.Name,
FromVersion = comp.FromVersion ?? "unknown",
ToVersion = comp.ToVersion ?? "unknown",
UpgradeType = DetermineUpgradeType(comp.FromVersion, comp.ToVersion)
});
}
var totalChanges = diff.SbomDiff.Added.Count
+ diff.SbomDiff.Removed.Count
+ diff.SbomDiff.VersionChanged.Count;
return new LineageSbomDiff
{
Added = added,
Removed = removed,
Modified = modified,
Truncated = totalChanges > maxChanges,
TotalChanges = totalChanges
};
}
private async Task<LineageVexDeltaSummary?> GetVexDeltasAsync(
string fromDigest,
string toDigest,
string tenantId,
int maxDeltas,
CancellationToken ct)
{
if (_vexDeltaRepository is null)
{
return null;
}
var deltas = await _vexDeltaRepository.GetDeltasAsync(fromDigest, toDigest, tenantId, ct);
if (deltas.Count == 0)
{
return null;
}
var changes = deltas
.Take(maxDeltas)
.Select(d => new LineageVexChange
{
Cve = d.Cve,
FromStatus = d.FromStatus,
ToStatus = d.ToStatus,
Justification = d.Rationale?.Reason,
AttestationDigest = d.AttestationDigest
})
.ToList();
return new LineageVexDeltaSummary
{
TotalChanges = deltas.Count,
StatusUpgrades = deltas.Count(d => IsStatusUpgrade(d.FromStatus, d.ToStatus)),
StatusDowngrades = deltas.Count(d => IsStatusDowngrade(d.FromStatus, d.ToStatus)),
Changes = changes,
Truncated = deltas.Count > maxDeltas
};
}
private static LineageVexDeltaSummary? BuildVexDeltasFromDiff(IReadOnlyList<VexDeltaSummary> vexDeltas, int maxDeltas)
{
if (vexDeltas.Count == 0)
{
return null;
}
var changes = vexDeltas
.Take(maxDeltas)
.Select(d => new LineageVexChange
{
Cve = d.Cve,
FromStatus = d.FromStatus,
ToStatus = d.ToStatus,
Justification = d.Reason
})
.ToList();
return new LineageVexDeltaSummary
{
TotalChanges = vexDeltas.Count,
StatusUpgrades = vexDeltas.Count(d => IsStatusUpgrade(d.FromStatus, d.ToStatus)),
StatusDowngrades = vexDeltas.Count(d => IsStatusDowngrade(d.FromStatus, d.ToStatus)),
Changes = changes,
Truncated = vexDeltas.Count > maxDeltas
};
}
private async Task<LineageReachabilityDeltaSummary?> GetReachabilityDeltasAsync(
string fromDigest,
string toDigest,
string tenantId,
int maxDeltas,
CancellationToken ct)
{
// TODO: Integrate with Graph API's IReachabilityDeltaService when available
// For now, return empty/null as placeholder
await Task.CompletedTask;
_logger.LogDebug(
"Reachability delta computation not yet integrated for {FromDigest} to {ToDigest}",
TruncateDigest(fromDigest),
TruncateDigest(toDigest));
return null;
}
private async Task<IReadOnlyList<LineageAttestationLink>?> GetAttestationsAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct)
{
// Collect attestation digests from VEX deltas
var attestations = new List<LineageAttestationLink>();
if (_vexDeltaRepository is not null)
{
var deltas = await _vexDeltaRepository.GetDeltasAsync(fromDigest, toDigest, tenantId, ct);
foreach (var delta in deltas.Where(d => !string.IsNullOrEmpty(d.AttestationDigest)))
{
attestations.Add(new LineageAttestationLink
{
Digest = delta.AttestationDigest!,
PredicateType = "stella.ops/vex-delta@v1",
CreatedAt = delta.CreatedAt,
Description = $"VEX delta attestation for {delta.Cve}"
});
}
}
return attestations.Count > 0 ? attestations : null;
}
private async Task<LineageReplayHashInfo?> GetReplayHashesAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct)
{
var fromLineage = await _ledgerService.GetLineageAsync(fromDigest, ct);
var toLineage = await _ledgerService.GetLineageAsync(toDigest, ct);
// SbomLineageNode doesn't have ReplayHash - would need to query lineage graph service
// For now, use the replay hash from the diff response if available
string? fromHash = null;
string? toHash = null;
// Check if we have nodes (indicates artifact exists in lineage)
var hasFrom = fromLineage?.Nodes.Count > 0;
var hasTo = toLineage?.Nodes.Count > 0;
if (!hasFrom && !hasTo)
{
return null;
}
// Replay hashes would come from the lineage graph service extended nodes
// Return status based on whether both artifacts exist in lineage
return new LineageReplayHashInfo
{
FromReplayHash = fromHash,
ToReplayHash = toHash,
IsReproducible = hasFrom && hasTo,
VerificationStatus = hasFrom && hasTo ? "pending" : "partial"
};
}
private static LineageCompareSummary ComputeSummary(
LineageSbomDiff? sbomDiff,
LineageVexDeltaSummary? vexDeltas,
LineageReachabilityDeltaSummary? reachabilityDeltas,
IReadOnlyList<LineageAttestationLink>? attestations)
{
var componentsAdded = sbomDiff?.Added.Count ?? 0;
var componentsRemoved = sbomDiff?.Removed.Count ?? 0;
var componentsModified = sbomDiff?.Modified.Count ?? 0;
var vexChanges = vexDeltas?.TotalChanges ?? 0;
var reachChanges = reachabilityDeltas?.TotalChanges ?? 0;
var attestCount = attestations?.Count ?? 0;
// Determine risk trend
var riskTrend = "unchanged";
var vexUpgrades = vexDeltas?.StatusUpgrades ?? 0;
var vexDowngrades = vexDeltas?.StatusDowngrades ?? 0;
var reachImproved = reachabilityDeltas?.NewlyUnreachable ?? 0;
var reachDegraded = reachabilityDeltas?.NewlyReachable ?? 0;
var improvementScore = vexUpgrades + reachImproved;
var degradationScore = vexDowngrades + reachDegraded;
if (improvementScore > degradationScore)
{
riskTrend = "improved";
}
else if (degradationScore > improvementScore)
{
riskTrend = "degraded";
}
return new LineageCompareSummary
{
ComponentsAdded = componentsAdded,
ComponentsRemoved = componentsRemoved,
ComponentsModified = componentsModified,
VulnerabilitiesAdded = vexDowngrades,
VulnerabilitiesResolved = vexUpgrades,
VexStatusChanges = vexChanges,
ReachabilityChanges = reachChanges,
AttestationCount = attestCount,
RiskTrend = riskTrend
};
}
private static bool IsStatusUpgrade(string fromStatus, string toStatus)
{
// Status upgrades: moving toward "not_affected" or "fixed"
var goodStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"not_affected", "fixed"
};
var badStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"affected", "under_investigation", "unknown"
};
return badStatuses.Contains(fromStatus) && goodStatuses.Contains(toStatus);
}
private static bool IsStatusDowngrade(string fromStatus, string toStatus)
{
var goodStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"not_affected", "fixed"
};
var badStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"affected", "under_investigation", "unknown"
};
return goodStatuses.Contains(fromStatus) && badStatuses.Contains(toStatus);
}
private static string? DetermineUpgradeType(string? fromVersion, string? toVersion)
{
if (string.IsNullOrEmpty(fromVersion) || string.IsNullOrEmpty(toVersion))
{
return null;
}
// Simple semver comparison
var fromParts = fromVersion.Split('.');
var toParts = toVersion.Split('.');
if (fromParts.Length > 0 && toParts.Length > 0)
{
if (int.TryParse(fromParts[0], out var fromMajor) &&
int.TryParse(toParts[0], out var toMajor) &&
toMajor > fromMajor)
{
return "major";
}
if (fromParts.Length > 1 && toParts.Length > 1)
{
if (int.TryParse(fromParts[1], out var fromMinor) &&
int.TryParse(toParts[1], out var toMinor) &&
toMinor > fromMinor)
{
return "minor";
}
}
}
return "patch";
}
private static string ExtractArtifactName(string digest)
{
var colonIndex = digest.IndexOf(':');
return colonIndex >= 0 && digest.Length > colonIndex + 8
? digest[(colonIndex + 1)..(colonIndex + 9)]
: digest;
}
private static string TruncateDigest(string digest)
{
if (string.IsNullOrEmpty(digest))
{
return digest;
}
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
}

View File

@@ -0,0 +1,309 @@
// -----------------------------------------------------------------------------
// LineageHoverCache.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-015)
// Task: Add Valkey caching for hover card data with 5-minute TTL
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Cache service for lineage hover card data.
/// Implements a 5-minute TTL cache to achieve &lt;150ms response times.
/// </summary>
internal interface ILineageHoverCache
{
/// <summary>
/// Gets a cached hover card if available.
/// </summary>
Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default);
/// <summary>
/// Sets a hover card in cache.
/// </summary>
Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default);
/// <summary>
/// Invalidates cached hover cards for an artifact.
/// </summary>
Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Cache options for lineage hover cards.
/// </summary>
public sealed record LineageHoverCacheOptions
{
/// <summary>
/// Whether caching is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Time-to-live for hover card cache entries.
/// Default: 5 minutes.
/// </summary>
public TimeSpan Ttl { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Key prefix for hover cache entries.
/// </summary>
public string KeyPrefix { get; init; } = "lineage:hover";
}
/// <summary>
/// Implementation of <see cref="ILineageHoverCache"/> using <see cref="IDistributedCache"/>.
/// Supports Valkey/Redis or any IDistributedCache implementation.
/// </summary>
internal sealed class DistributedLineageHoverCache : ILineageHoverCache
{
private readonly IDistributedCache _cache;
private readonly LineageHoverCacheOptions _options;
private readonly ILogger<DistributedLineageHoverCache> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.LineageCache");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public DistributedLineageHoverCache(
IDistributedCache cache,
IOptions<LineageHoverCacheOptions> options,
ILogger<DistributedLineageHoverCache> logger)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? new LineageHoverCacheOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<SbomLineageHoverCard?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return null;
}
using var activity = _activitySource.StartActivity("hover_cache.get");
activity?.SetTag("from_digest", TruncateDigest(fromDigest));
activity?.SetTag("to_digest", TruncateDigest(toDigest));
activity?.SetTag("tenant", tenantId);
try
{
var key = BuildKey(fromDigest, toDigest, tenantId);
var cached = await _cache.GetStringAsync(key, ct).ConfigureAwait(false);
if (cached is null)
{
activity?.SetTag("cache_hit", false);
_logger.LogDebug("Cache miss for hover card {From} -> {To}", TruncateDigest(fromDigest), TruncateDigest(toDigest));
return null;
}
activity?.SetTag("cache_hit", true);
_logger.LogDebug("Cache hit for hover card {From} -> {To}", TruncateDigest(fromDigest), TruncateDigest(toDigest));
return JsonSerializer.Deserialize<SbomLineageHoverCard>(cached, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get hover card from cache");
return null;
}
}
/// <inheritdoc/>
public async Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
SbomLineageHoverCard hoverCard,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return;
}
using var activity = _activitySource.StartActivity("hover_cache.set");
activity?.SetTag("from_digest", TruncateDigest(fromDigest));
activity?.SetTag("to_digest", TruncateDigest(toDigest));
activity?.SetTag("tenant", tenantId);
try
{
var key = BuildKey(fromDigest, toDigest, tenantId);
var json = JsonSerializer.Serialize(hoverCard, JsonOptions);
var cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.Ttl
};
await _cache.SetStringAsync(key, json, cacheOptions, ct).ConfigureAwait(false);
_logger.LogDebug("Cached hover card {From} -> {To} with TTL {Ttl}", TruncateDigest(fromDigest), TruncateDigest(toDigest), _options.Ttl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set hover card in cache");
}
}
/// <inheritdoc/>
public async Task InvalidateAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return;
}
using var activity = _activitySource.StartActivity("hover_cache.invalidate");
activity?.SetTag("artifact_digest", TruncateDigest(artifactDigest));
activity?.SetTag("tenant", tenantId);
// Note: Full pattern-based invalidation requires Valkey SCAN.
// For now, we rely on TTL expiration. Pattern invalidation can be added
// when using direct Valkey client (StackExchange.Redis).
_logger.LogDebug("Hover card invalidation requested for {Artifact} (relying on TTL)", TruncateDigest(artifactDigest));
}
private string BuildKey(string fromDigest, string toDigest, string tenantId)
{
// Format: {prefix}:{tenant}:{from_short}:{to_short}
var fromShort = GetDigestShort(fromDigest);
var toShort = GetDigestShort(toDigest);
return $"{_options.KeyPrefix}:{tenantId}:{fromShort}:{toShort}";
}
private static string GetDigestShort(string digest)
{
// Extract first 16 chars after algorithm prefix for shorter key
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
{
return digest[(colonIndex + 1)..(colonIndex + 17)];
}
return digest.Length > 16 ? digest[..16] : digest;
}
private static string TruncateDigest(string digest)
{
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
}
/// <summary>
/// In-memory implementation of <see cref="ILineageHoverCache"/> for testing.
/// </summary>
internal sealed class InMemoryLineageHoverCache : ILineageHoverCache
{
private readonly Dictionary<string, (SbomLineageHoverCard Card, DateTimeOffset ExpiresAt)> _cache = new();
private readonly LineageHoverCacheOptions _options;
private readonly object _lock = new();
public InMemoryLineageHoverCache(LineageHoverCacheOptions? options = null)
{
_options = options ?? new LineageHoverCacheOptions();
}
public Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult<SbomLineageHoverCard?>(null);
}
var key = BuildKey(fromDigest, toDigest, tenantId);
lock (_lock)
{
if (_cache.TryGetValue(key, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<SbomLineageHoverCard?>(entry.Card);
}
_cache.Remove(key);
}
}
return Task.FromResult<SbomLineageHoverCard?>(null);
}
public Task SetAsync(string fromDigest, string toDigest, string tenantId, SbomLineageHoverCard hoverCard, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.CompletedTask;
}
var key = BuildKey(fromDigest, toDigest, tenantId);
var expiresAt = DateTimeOffset.UtcNow.Add(_options.Ttl);
lock (_lock)
{
_cache[key] = (hoverCard, expiresAt);
}
return Task.CompletedTask;
}
public Task InvalidateAsync(string artifactDigest, string tenantId, CancellationToken ct = default)
{
var prefix = $"{_options.KeyPrefix}:{tenantId}:";
var digestShort = GetDigestShort(artifactDigest);
lock (_lock)
{
var keysToRemove = _cache.Keys
.Where(k => k.StartsWith(prefix, StringComparison.Ordinal) && k.Contains(digestShort))
.ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
}
return Task.CompletedTask;
}
private string BuildKey(string fromDigest, string toDigest, string tenantId)
{
var fromShort = GetDigestShort(fromDigest);
var toShort = GetDigestShort(toDigest);
return $"{_options.KeyPrefix}:{tenantId}:{fromShort}:{toShort}";
}
private static string GetDigestShort(string digest)
{
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
{
return digest[(colonIndex + 1)..(colonIndex + 17)];
}
return digest.Length > 16 ? digest[..16] : digest;
}
}

View File

@@ -0,0 +1,272 @@
// -----------------------------------------------------------------------------
// ReplayHashService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023)
// Task: Compute replay hash per lineage node
// Description: Implements deterministic replay hash computation per spec:
// Hash = SHA256(sbom_digest + feeds_snapshot_digest +
// policy_version + vex_verdicts_digest + timestamp)
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="IReplayHashService"/>.
/// Computes deterministic replay hashes for lineage nodes.
/// </summary>
internal sealed class ReplayHashService : IReplayHashService
{
private readonly IFeedsSnapshotService? _feedsService;
private readonly IPolicyVersionService? _policyService;
private readonly IVexVerdictsDigestService? _vexService;
private readonly ILogger<ReplayHashService> _logger;
private readonly TimeProvider _timeProvider;
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.ReplayHash");
// Delimiter used between hash components for determinism
private const char Delimiter = '|';
// Timestamp format for reproducibility (minute precision)
private const string TimestampFormat = "yyyy-MM-ddTHH:mm";
public ReplayHashService(
ILogger<ReplayHashService> logger,
IFeedsSnapshotService? feedsService = null,
IPolicyVersionService? policyService = null,
IVexVerdictsDigestService? vexService = null,
TimeProvider? timeProvider = null)
{
_logger = logger;
_feedsService = feedsService;
_policyService = policyService;
_vexService = vexService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public string ComputeHash(ReplayHashInputs inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
using var activity = _activitySource.StartActivity("ComputeReplayHash");
activity?.SetTag("sbom_digest", inputs.SbomDigest);
// Build canonical string representation
// Order: sbom_digest | feeds_snapshot_digest | policy_version | vex_verdicts_digest | timestamp
var canonicalString = BuildCanonicalString(inputs);
// Compute SHA256 hash
var bytes = Encoding.UTF8.GetBytes(canonicalString);
var hash = SHA256.HashData(bytes);
var hexHash = Convert.ToHexStringLower(hash);
activity?.SetTag("replay_hash", hexHash);
_logger.LogDebug(
"Computed replay hash {Hash} for SBOM {SbomDigest}",
hexHash,
inputs.SbomDigest);
return hexHash;
}
/// <inheritdoc/>
public async Task<ReplayHashResult> ComputeHashAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sbomDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
using var activity = _activitySource.StartActivity("ComputeReplayHashAsync");
activity?.SetTag("sbom_digest", sbomDigest);
activity?.SetTag("tenant_id", tenantId);
var now = _timeProvider.GetUtcNow();
// Gather inputs from services
var feedsDigest = await GetFeedsSnapshotDigestAsync(tenantId, ct);
var policyVersion = await GetPolicyVersionAsync(tenantId, ct);
var vexDigest = await GetVexVerdictsDigestAsync(sbomDigest, tenantId, ct);
var inputs = new ReplayHashInputs
{
SbomDigest = sbomDigest,
FeedsSnapshotDigest = feedsDigest,
PolicyVersion = policyVersion,
VexVerdictsDigest = vexDigest,
Timestamp = TruncateToMinute(now)
};
var hash = ComputeHash(inputs);
return new ReplayHashResult
{
Hash = hash,
Inputs = inputs,
ComputedAt = now
};
}
/// <inheritdoc/>
public bool VerifyHash(string expectedHash, ReplayHashInputs inputs)
{
ArgumentException.ThrowIfNullOrWhiteSpace(expectedHash);
ArgumentNullException.ThrowIfNull(inputs);
var computedHash = ComputeHash(inputs);
var matches = string.Equals(expectedHash, computedHash, StringComparison.OrdinalIgnoreCase);
if (!matches)
{
_logger.LogWarning(
"Replay hash verification failed. Expected={Expected}, Computed={Computed}",
expectedHash,
computedHash);
}
return matches;
}
/// <summary>
/// Builds the canonical string representation for hashing.
/// Components are sorted and concatenated with delimiters for determinism.
/// </summary>
private static string BuildCanonicalString(ReplayHashInputs inputs)
{
var sb = new StringBuilder(512);
// Component 1: SBOM digest (normalized to lowercase)
sb.Append(NormalizeDigest(inputs.SbomDigest));
sb.Append(Delimiter);
// Component 2: Feeds snapshot digest
sb.Append(NormalizeDigest(inputs.FeedsSnapshotDigest));
sb.Append(Delimiter);
// Component 3: Policy version (trimmed, lowercase for consistency)
sb.Append(inputs.PolicyVersion.Trim().ToLowerInvariant());
sb.Append(Delimiter);
// Component 4: VEX verdicts digest
sb.Append(NormalizeDigest(inputs.VexVerdictsDigest));
sb.Append(Delimiter);
// Component 5: Timestamp (minute precision, UTC)
sb.Append(inputs.Timestamp.ToUniversalTime().ToString(TimestampFormat));
return sb.ToString();
}
/// <summary>
/// Normalizes a digest string for consistent hashing.
/// </summary>
private static string NormalizeDigest(string digest)
{
// Handle digest formats: sha256:xxxx, xxxx (bare)
var normalized = digest.Trim().ToLowerInvariant();
// If it doesn't have algorithm prefix, assume sha256
if (!normalized.Contains(':'))
{
normalized = $"sha256:{normalized}";
}
return normalized;
}
/// <summary>
/// Truncates a timestamp to minute precision for reproducibility.
/// </summary>
private static DateTimeOffset TruncateToMinute(DateTimeOffset timestamp)
{
return new DateTimeOffset(
timestamp.Year,
timestamp.Month,
timestamp.Day,
timestamp.Hour,
timestamp.Minute,
0,
TimeSpan.Zero);
}
private async Task<string> GetFeedsSnapshotDigestAsync(string tenantId, CancellationToken ct)
{
if (_feedsService is not null)
{
return await _feedsService.GetCurrentSnapshotDigestAsync(tenantId, ct);
}
// Fallback: return a placeholder indicating feeds service not available
_logger.LogDebug("FeedsSnapshotService not available, using placeholder digest");
return "sha256:feeds-snapshot-unavailable";
}
private async Task<string> GetPolicyVersionAsync(string tenantId, CancellationToken ct)
{
if (_policyService is not null)
{
return await _policyService.GetCurrentVersionAsync(tenantId, ct);
}
// Fallback: return default policy version
_logger.LogDebug("PolicyVersionService not available, using default version");
return "v1.0.0-default";
}
private async Task<string> GetVexVerdictsDigestAsync(
string sbomDigest,
string tenantId,
CancellationToken ct)
{
if (_vexService is not null)
{
return await _vexService.ComputeVerdictsDigestAsync(sbomDigest, tenantId, ct);
}
// Fallback: return a placeholder
_logger.LogDebug("VexVerdictsDigestService not available, using placeholder digest");
return "sha256:vex-verdicts-unavailable";
}
}
/// <summary>
/// Service for getting the current feeds snapshot digest.
/// </summary>
internal interface IFeedsSnapshotService
{
/// <summary>
/// Gets the content digest of the current vulnerability feeds snapshot.
/// </summary>
Task<string> GetCurrentSnapshotDigestAsync(string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Service for getting the current policy version.
/// </summary>
internal interface IPolicyVersionService
{
/// <summary>
/// Gets the current policy bundle version for a tenant.
/// </summary>
Task<string> GetCurrentVersionAsync(string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Service for computing VEX verdicts digest.
/// </summary>
internal interface IVexVerdictsDigestService
{
/// <summary>
/// Computes a content digest of all VEX verdicts applicable to an SBOM.
/// </summary>
Task<string> ComputeVerdictsDigestAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,348 @@
// -----------------------------------------------------------------------------
// ReplayVerificationService.cs
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033)
// Task: Replay verification endpoint
// Description: Implementation of replay hash verification with drift detection.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="IReplayVerificationService"/>.
/// Verifies replay hashes and detects drift in security evaluations.
/// </summary>
internal sealed class ReplayVerificationService : IReplayVerificationService
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.ReplayVerification");
private readonly IReplayHashService _hashService;
private readonly ILogger<ReplayVerificationService> _logger;
private readonly IClock _clock;
// In-memory cache of replay hash inputs for demonstration
// In production, would be stored in database
private readonly ConcurrentDictionary<string, ReplayHashInputs> _inputsCache = new();
public ReplayVerificationService(
IReplayHashService hashService,
ILogger<ReplayVerificationService> logger,
IClock clock)
{
_hashService = hashService ?? throw new ArgumentNullException(nameof(hashService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
/// <inheritdoc />
public async Task<ReplayVerificationResult> VerifyAsync(
ReplayVerificationRequest request,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("VerifyReplayHash");
activity?.SetTag("replay_hash", TruncateHash(request.ReplayHash));
activity?.SetTag("tenant_id", request.TenantId);
_logger.LogInformation(
"Verifying replay hash {ReplayHash} for tenant {TenantId}",
TruncateHash(request.ReplayHash), request.TenantId);
try
{
// Try to lookup original inputs
ReplayHashInputs? expectedInputs = null;
if (_inputsCache.TryGetValue(request.ReplayHash, out var cached))
{
expectedInputs = cached;
}
// Build verification inputs
var verificationInputs = await BuildVerificationInputsAsync(
request,
expectedInputs,
ct);
if (verificationInputs is null)
{
return new ReplayVerificationResult
{
IsMatch = false,
ExpectedHash = request.ReplayHash,
ComputedHash = string.Empty,
Status = ReplayVerificationStatus.InputsNotFound,
Error = "Unable to determine verification inputs. Provide explicit inputs or ensure hash is stored."
};
}
// Compute verification hash
var computedHash = _hashService.ComputeHash(verificationInputs);
// Compare
var isMatch = string.Equals(computedHash, request.ReplayHash, StringComparison.OrdinalIgnoreCase);
// Compute drifts if not matching
var drifts = ImmutableArray<ReplayFieldDrift>.Empty;
if (!isMatch && expectedInputs is not null)
{
drifts = ComputeDrifts(expectedInputs, verificationInputs);
}
var status = isMatch ? ReplayVerificationStatus.Match : ReplayVerificationStatus.Drift;
_logger.LogInformation(
"Replay verification {Status}: expected {Expected}, computed {Computed}",
status, TruncateHash(request.ReplayHash), TruncateHash(computedHash));
return new ReplayVerificationResult
{
IsMatch = isMatch,
ExpectedHash = request.ReplayHash,
ComputedHash = computedHash,
Status = status,
ExpectedInputs = expectedInputs,
ComputedInputs = verificationInputs,
Drifts = drifts,
VerifiedAt = _clock.UtcNow,
Message = isMatch ? "Replay hash verified successfully" : $"Drift detected in {drifts.Length} field(s)"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to verify replay hash {ReplayHash}", request.ReplayHash);
return new ReplayVerificationResult
{
IsMatch = false,
ExpectedHash = request.ReplayHash,
ComputedHash = string.Empty,
Status = ReplayVerificationStatus.Error,
Error = ex.Message
};
}
}
/// <inheritdoc />
public async Task<ReplayDriftAnalysis> CompareDriftAsync(
string hashA,
string hashB,
string tenantId,
CancellationToken ct = default)
{
using var activity = ActivitySource.StartActivity("CompareDrift");
activity?.SetTag("hash_a", TruncateHash(hashA));
activity?.SetTag("hash_b", TruncateHash(hashB));
_logger.LogInformation(
"Comparing drift between {HashA} and {HashB}",
TruncateHash(hashA), TruncateHash(hashB));
// Lookup inputs for both hashes
_inputsCache.TryGetValue(hashA, out var inputsA);
_inputsCache.TryGetValue(hashB, out var inputsB);
var isIdentical = string.Equals(hashA, hashB, StringComparison.OrdinalIgnoreCase);
var drifts = ImmutableArray<ReplayFieldDrift>.Empty;
var driftSummary = "identical";
if (!isIdentical && inputsA is not null && inputsB is not null)
{
drifts = ComputeDrifts(inputsA, inputsB);
driftSummary = SummarizeDrifts(drifts);
}
else if (!isIdentical)
{
driftSummary = "unable to compare - inputs not found";
}
await Task.CompletedTask;
return new ReplayDriftAnalysis
{
HashA = hashA,
HashB = hashB,
IsIdentical = isIdentical,
InputsA = inputsA,
InputsB = inputsB,
Drifts = drifts,
DriftSummary = driftSummary,
AnalyzedAt = _clock.UtcNow
};
}
/// <summary>
/// Stores inputs for a replay hash (for later verification).
/// </summary>
public void StoreInputs(string replayHash, ReplayHashInputs inputs)
{
_inputsCache[replayHash] = inputs;
}
private async Task<ReplayHashInputs?> BuildVerificationInputsAsync(
ReplayVerificationRequest request,
ReplayHashInputs? expectedInputs,
CancellationToken ct)
{
// If we have explicit inputs in request, use them
if (!string.IsNullOrEmpty(request.SbomDigest))
{
// Get current or specified values for other fields
var feedsDigest = request.FeedsSnapshotDigest
?? expectedInputs?.FeedsSnapshotDigest
?? await GetCurrentFeedsDigestAsync(request.TenantId, ct);
var policyVersion = request.PolicyVersion
?? expectedInputs?.PolicyVersion
?? await GetCurrentPolicyVersionAsync(request.TenantId, ct);
var vexDigest = request.VexVerdictsDigest
?? expectedInputs?.VexVerdictsDigest
?? await GetCurrentVexDigestAsync(request.SbomDigest, request.TenantId, ct);
var timestamp = request.Timestamp
?? (request.FreezeTime ? expectedInputs?.Timestamp : null)
?? _clock.UtcNow;
return new ReplayHashInputs
{
SbomDigest = request.SbomDigest,
FeedsSnapshotDigest = feedsDigest,
PolicyVersion = policyVersion,
VexVerdictsDigest = vexDigest,
Timestamp = timestamp
};
}
// If we have expected inputs and should use them
if (expectedInputs is not null)
{
if (request.FreezeTime)
{
return expectedInputs;
}
// Re-compute with current time but same other inputs
return expectedInputs with
{
Timestamp = _clock.UtcNow
};
}
// Cannot determine inputs
return null;
}
private static ImmutableArray<ReplayFieldDrift> ComputeDrifts(
ReplayHashInputs expected,
ReplayHashInputs actual)
{
var drifts = new List<ReplayFieldDrift>();
if (!string.Equals(expected.SbomDigest, actual.SbomDigest, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "SbomDigest",
ExpectedValue = expected.SbomDigest,
ActualValue = actual.SbomDigest,
Severity = "critical",
Description = "SBOM content has changed - represents a different artifact or version"
});
}
if (!string.Equals(expected.FeedsSnapshotDigest, actual.FeedsSnapshotDigest, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "FeedsSnapshotDigest",
ExpectedValue = expected.FeedsSnapshotDigest,
ActualValue = actual.FeedsSnapshotDigest,
Severity = "warning",
Description = "Vulnerability feeds have been updated since original evaluation"
});
}
if (!string.Equals(expected.PolicyVersion, actual.PolicyVersion, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "PolicyVersion",
ExpectedValue = expected.PolicyVersion,
ActualValue = actual.PolicyVersion,
Severity = "warning",
Description = "Policy rules have been modified since original evaluation"
});
}
if (!string.Equals(expected.VexVerdictsDigest, actual.VexVerdictsDigest, StringComparison.OrdinalIgnoreCase))
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "VexVerdictsDigest",
ExpectedValue = expected.VexVerdictsDigest,
ActualValue = actual.VexVerdictsDigest,
Severity = "warning",
Description = "VEX verdicts have changed (new statements or consensus updates)"
});
}
if (expected.Timestamp != actual.Timestamp)
{
drifts.Add(new ReplayFieldDrift
{
FieldName = "Timestamp",
ExpectedValue = expected.Timestamp.ToString("O"),
ActualValue = actual.Timestamp.ToString("O"),
Severity = "info",
Description = "Evaluation timestamp differs (expected when not freezing time)"
});
}
return drifts.ToImmutableArray();
}
private static string SummarizeDrifts(ImmutableArray<ReplayFieldDrift> drifts)
{
if (drifts.IsEmpty)
{
return "no drift detected";
}
var criticalCount = drifts.Count(d => d.Severity == "critical");
var warningCount = drifts.Count(d => d.Severity == "warning");
var infoCount = drifts.Count(d => d.Severity == "info");
var parts = new List<string>();
if (criticalCount > 0) parts.Add($"{criticalCount} critical");
if (warningCount > 0) parts.Add($"{warningCount} warning");
if (infoCount > 0) parts.Add($"{infoCount} info");
return string.Join(", ", parts);
}
private Task<string> GetCurrentFeedsDigestAsync(string tenantId, CancellationToken ct)
{
// In real implementation, would query feeds service
return Task.FromResult($"sha256:feeds-snapshot-{_clock.UtcNow:yyyyMMddHH}");
}
private Task<string> GetCurrentPolicyVersionAsync(string tenantId, CancellationToken ct)
{
// In real implementation, would query policy service
return Task.FromResult("v1.0.0");
}
private Task<string> GetCurrentVexDigestAsync(string sbomDigest, string tenantId, CancellationToken ct)
{
// In real implementation, would query VexLens
return Task.FromResult($"sha256:vex-{sbomDigest[..16]}-current");
}
private static string TruncateHash(string hash)
{
if (string.IsNullOrEmpty(hash)) return hash;
return hash.Length > 16 ? $"{hash[..16]}..." : hash;
}
}

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
@@ -13,14 +14,23 @@ namespace StellaOps.SbomService.Services;
internal sealed class SbomLedgerService : ISbomLedgerService
{
private readonly ISbomLedgerRepository _repository;
private readonly ISbomLineageEdgeRepository? _lineageEdgeRepository;
private readonly IClock _clock;
private readonly SbomLedgerOptions _options;
private readonly ILogger<SbomLedgerService>? _logger;
public SbomLedgerService(ISbomLedgerRepository repository, IClock clock, IOptions<SbomLedgerOptions> options)
public SbomLedgerService(
ISbomLedgerRepository repository,
IClock clock,
IOptions<SbomLedgerOptions> options,
ISbomLineageEdgeRepository? lineageEdgeRepository = null,
ILogger<SbomLedgerService>? logger = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_options = options?.Value ?? new SbomLedgerOptions();
_lineageEdgeRepository = lineageEdgeRepository;
_logger = logger;
}
public async Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken)
@@ -36,7 +46,10 @@ internal sealed class SbomLedgerService : ISbomLedgerService
var versionId = Guid.NewGuid();
var createdAt = _clock.UtcNow;
// LIN-BE-003: Resolve parent from ParentVersionId or ParentArtifactDigest
SbomLedgerVersion? parent = null;
Guid? resolvedParentVersionId = submission.ParentVersionId;
if (submission.ParentVersionId.HasValue)
{
parent = await _repository.GetVersionAsync(submission.ParentVersionId.Value, cancellationToken).ConfigureAwait(false);
@@ -45,6 +58,15 @@ internal sealed class SbomLedgerService : ISbomLedgerService
throw new InvalidOperationException($"Parent version '{submission.ParentVersionId}' was not found.");
}
}
else if (!string.IsNullOrWhiteSpace(submission.ParentArtifactDigest))
{
// LIN-BE-003: Lookup parent by digest
parent = await _repository.GetVersionByDigestAsync(submission.ParentArtifactDigest, cancellationToken).ConfigureAwait(false);
if (parent is not null)
{
resolvedParentVersionId = parent.VersionId;
}
}
var version = new SbomLedgerVersion
{
@@ -58,8 +80,13 @@ internal sealed class SbomLedgerService : ISbomLedgerService
Source = submission.Source,
CreatedAtUtc = createdAt,
Provenance = submission.Provenance,
ParentVersionId = parent?.VersionId,
ParentVersionId = resolvedParentVersionId,
ParentDigest = parent?.Digest,
// LIN-BE-003: Lineage ancestry fields
ParentArtifactDigest = submission.ParentArtifactDigest,
BaseImageRef = submission.BaseImageRef,
BaseImageDigest = submission.BaseImageDigest,
BuildId = submission.BuildId ?? submission.Provenance?.CiContext?.BuildId,
Components = submission.Components
};
@@ -68,9 +95,93 @@ internal sealed class SbomLedgerService : ISbomLedgerService
new SbomLedgerAuditEntry(artifact, versionId, "created", createdAt, $"format={submission.Format}"),
cancellationToken).ConfigureAwait(false);
// LIN-BE-006: Persist lineage edges on version creation
if (_lineageEdgeRepository is not null)
{
var tenantId = submission.Provenance?.CiContext?.Repository ?? "default";
await PersistLineageEdgesAsync(version, tenantId, cancellationToken).ConfigureAwait(false);
}
return version;
}
/// <summary>
/// LIN-BE-006: Persist lineage edges for version relationships.
/// Creates edges for: parent (from ParentArtifactDigest), build (same BuildId), base (from BaseImageDigest).
/// </summary>
private async Task PersistLineageEdgesAsync(SbomLedgerVersion version, string tenantId, CancellationToken ct)
{
if (_lineageEdgeRepository is null)
{
return;
}
var edges = new List<SbomLineageEdgeEntity>();
var tenantGuid = ParseTenantIdToGuid(tenantId);
// Parent edge: from parent digest to this artifact
if (!string.IsNullOrWhiteSpace(version.ParentArtifactDigest))
{
edges.Add(new SbomLineageEdgeEntity
{
Id = Guid.NewGuid(),
ParentDigest = version.ParentArtifactDigest,
ChildDigest = version.Digest,
Relationship = LineageRelationship.Parent,
TenantId = tenantGuid,
CreatedAt = version.CreatedAtUtc
});
}
// Base image edge: from base image to this artifact
if (!string.IsNullOrWhiteSpace(version.BaseImageDigest))
{
edges.Add(new SbomLineageEdgeEntity
{
Id = Guid.NewGuid(),
ParentDigest = version.BaseImageDigest,
ChildDigest = version.Digest,
Relationship = LineageRelationship.Base,
TenantId = tenantGuid,
CreatedAt = version.CreatedAtUtc
});
}
// Build edges: link all versions with the same BuildId
if (!string.IsNullOrWhiteSpace(version.BuildId))
{
var siblingVersions = await _repository.GetVersionsAsync(version.ArtifactRef, ct).ConfigureAwait(false);
var buildSiblings = siblingVersions
.Where(v => v.VersionId != version.VersionId &&
!string.IsNullOrWhiteSpace(v.BuildId) &&
string.Equals(v.BuildId, version.BuildId, StringComparison.Ordinal))
.ToList();
foreach (var sibling in buildSiblings)
{
// Link in chronological order (earlier version is parent)
if (sibling.CreatedAtUtc < version.CreatedAtUtc)
{
edges.Add(new SbomLineageEdgeEntity
{
Id = Guid.NewGuid(),
ParentDigest = sibling.Digest,
ChildDigest = version.Digest,
Relationship = LineageRelationship.Build,
TenantId = tenantGuid,
CreatedAt = version.CreatedAtUtc
});
}
}
}
if (edges.Count > 0)
{
var added = await _lineageEdgeRepository.AddRangeAsync(edges, ct).ConfigureAwait(false);
_logger?.LogDebug("Persisted {EdgeCount} lineage edges for version {VersionId}", added, version.VersionId);
}
}
public async Task<SbomVersionHistoryResult?> GetHistoryAsync(string artifactRef, int limit, int offset, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactRef))
@@ -435,4 +546,20 @@ internal sealed class SbomLedgerService : ISbomLedgerService
private static SbomDiffComponent ToDiffComponent(SbomNormalizedComponent component)
=> new(component.Key, component.Name, component.Purl, component.Version, component.License);
/// <summary>
/// Parses tenant ID string to Guid. For non-GUID strings, creates deterministic GUID from hash.
/// </summary>
private static Guid ParseTenantIdToGuid(string tenantId)
{
if (Guid.TryParse(tenantId, out var guid))
{
return guid;
}
// For string-based tenant IDs, generate deterministic GUID from hash
var bytes = System.Text.Encoding.UTF8.GetBytes(tenantId);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return new Guid(hash.Take(16).ToArray());
}
}

View File

@@ -0,0 +1,539 @@
// -----------------------------------------------------------------------------
// SbomLineageGraphService.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-013)
// Updated: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023) - Replay hash
// Task: Implement SBOM lineage graph service
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="ISbomLineageGraphService"/>.
/// Provides lineage graph traversal and diff computation.
/// </summary>
internal sealed class SbomLineageGraphService : ISbomLineageGraphService
{
private readonly ISbomLineageEdgeRepository _edgeRepository;
private readonly ISbomLedgerRepository _ledgerRepository;
// LIN-BE-015: Hover card cache for <150ms response times
private readonly ILineageHoverCache? _hoverCache;
// LIN-BE-023: Replay hash service for deterministic verification
private readonly IReplayHashService? _replayHashService;
private readonly ILogger<SbomLineageGraphService> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.SbomService.LineageGraph");
public SbomLineageGraphService(
ISbomLineageEdgeRepository edgeRepository,
ISbomLedgerRepository ledgerRepository,
ILogger<SbomLineageGraphService> logger,
ILineageHoverCache? hoverCache = null,
IReplayHashService? replayHashService = null)
{
_edgeRepository = edgeRepository;
_ledgerRepository = ledgerRepository;
_logger = logger;
_hoverCache = hoverCache;
_replayHashService = replayHashService;
}
/// <inheritdoc/>
public async Task<SbomLineageGraphResponse?> GetLineageGraphAsync(
string artifactDigest,
string tenantId,
SbomLineageQueryOptions? options = null,
CancellationToken ct = default)
{
options ??= new SbomLineageQueryOptions();
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
_logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId);
return null;
}
// Get all edges in the lineage graph
var edges = await _edgeRepository.GetGraphAsync(
artifactDigest,
tenantGuid,
options.MaxDepth,
ct).ConfigureAwait(false);
if (edges.Count == 0)
{
// Return single-node graph if no edges found
var singleNode = await BuildNodeFromDigestAsync(artifactDigest, tenantId, options, ct);
if (singleNode is null)
{
return null;
}
return new SbomLineageGraphResponse
{
Artifact = artifactDigest,
Nodes = new[] { singleNode },
Edges = Array.Empty<SbomLineageEdgeExtended>()
};
}
// Collect all unique digests
var allDigests = edges
.SelectMany(e => new[] { e.ParentDigest, e.ChildDigest })
.Distinct(StringComparer.Ordinal)
.ToList();
// Build nodes for each digest
var nodes = new List<SbomLineageNodeExtended>();
foreach (var digest in allDigests)
{
var node = await BuildNodeFromDigestAsync(digest, tenantId, options, ct);
if (node is not null)
{
nodes.Add(node);
}
}
// Convert edges to extended format
var extendedEdges = edges
.Select(e => new SbomLineageEdgeExtended
{
From = e.ParentDigest,
To = e.ChildDigest,
Relationship = e.Relationship.ToString().ToLowerInvariant()
})
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ToList();
return new SbomLineageGraphResponse
{
Artifact = artifactDigest,
Nodes = nodes.OrderBy(n => n.Digest, StringComparer.Ordinal).ToList(),
Edges = extendedEdges
};
}
/// <inheritdoc/>
public async Task<SbomLineageDiffResponse?> GetLineageDiffAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
// Get versions by digest
var fromVersion = await _ledgerRepository.GetVersionByDigestAsync(fromDigest, ct).ConfigureAwait(false);
var toVersion = await _ledgerRepository.GetVersionByDigestAsync(toDigest, ct).ConfigureAwait(false);
if (fromVersion is null || toVersion is null)
{
_logger.LogWarning(
"Could not find versions for diff: from={FromDigest} to={ToDigest}",
fromDigest,
toDigest);
return null;
}
// Compute component diff
var sbomDiff = ComputeComponentDiff(fromVersion, toVersion);
// Get VEX deltas (placeholder - integrate with VEX delta repository)
var vexDiff = new List<VexDeltaSummary>();
// Compute replay hash
var replayHash = ComputeReplayHash(fromDigest, toDigest, sbomDiff);
return new SbomLineageDiffResponse
{
SbomDiff = sbomDiff,
VexDiff = vexDiff,
ReplayHash = replayHash
};
}
/// <inheritdoc/>
public async Task<SbomLineageHoverCard?> GetHoverCardAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("lineage.hover_card");
activity?.SetTag("from_digest", fromDigest);
activity?.SetTag("to_digest", toDigest);
activity?.SetTag("tenant", tenantId);
// LIN-BE-015: Check cache first
if (_hoverCache is not null)
{
var cached = await _hoverCache.GetAsync(fromDigest, toDigest, tenantId, ct).ConfigureAwait(false);
if (cached is not null)
{
activity?.SetTag("cache_hit", true);
return cached;
}
activity?.SetTag("cache_hit", false);
}
var sw = Stopwatch.StartNew();
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
return null;
}
// Check edge exists
var edgeExists = await _edgeRepository.ExistsAsync(
fromDigest,
toDigest,
tenantGuid,
ct).ConfigureAwait(false);
if (!edgeExists)
{
return null;
}
// Get diff summary
var diff = await GetLineageDiffAsync(fromDigest, toDigest, tenantId, ct);
if (diff is null)
{
return null;
}
// Get version info for timestamps
var fromVersion = await _ledgerRepository.GetVersionByDigestAsync(fromDigest, ct);
var toVersion = await _ledgerRepository.GetVersionByDigestAsync(toDigest, ct);
// Compute VEX delta summary
var vexSummary = ComputeVexHoverSummary(diff.VexDiff);
// Determine relationship type from edge
var children = await _edgeRepository.GetChildrenAsync(fromDigest, tenantGuid, ct);
var edge = children.FirstOrDefault(e => e.ChildDigest == toDigest);
var relationship = edge?.Relationship.ToString().ToLowerInvariant() ?? "parent";
var hoverCard = new SbomLineageHoverCard
{
FromDigest = fromDigest,
ToDigest = toDigest,
Relationship = relationship,
ComponentDiff = diff.SbomDiff.Summary,
VexDiff = vexSummary,
ReplayHash = diff.ReplayHash,
FromCreatedAt = fromVersion?.CreatedAtUtc,
ToCreatedAt = toVersion?.CreatedAtUtc
};
sw.Stop();
activity?.SetTag("compute_ms", sw.ElapsedMilliseconds);
// LIN-BE-015: Cache the result
if (_hoverCache is not null)
{
await _hoverCache.SetAsync(fromDigest, toDigest, tenantId, hoverCard, ct).ConfigureAwait(false);
}
_logger.LogDebug("Computed hover card in {ElapsedMs}ms", sw.ElapsedMilliseconds);
return hoverCard;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomLineageNodeExtended>> GetChildrenAsync(
string parentDigest,
string tenantId,
CancellationToken ct = default)
{
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
return Array.Empty<SbomLineageNodeExtended>();
}
var edges = await _edgeRepository.GetChildrenAsync(parentDigest, tenantGuid, ct);
var options = new SbomLineageQueryOptions { IncludeBadges = true };
var nodes = new List<SbomLineageNodeExtended>();
foreach (var edge in edges)
{
var node = await BuildNodeFromDigestAsync(edge.ChildDigest, tenantId, options, ct);
if (node is not null)
{
nodes.Add(node);
}
}
return nodes.OrderBy(n => n.Digest, StringComparer.Ordinal).ToList();
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SbomLineageNodeExtended>> GetParentsAsync(
string childDigest,
string tenantId,
CancellationToken ct = default)
{
var tenantGuid = ParseTenantId(tenantId);
if (tenantGuid == Guid.Empty)
{
return Array.Empty<SbomLineageNodeExtended>();
}
var edges = await _edgeRepository.GetParentsAsync(childDigest, tenantGuid, ct);
var options = new SbomLineageQueryOptions { IncludeBadges = true };
var nodes = new List<SbomLineageNodeExtended>();
foreach (var edge in edges)
{
var node = await BuildNodeFromDigestAsync(edge.ParentDigest, tenantId, options, ct);
if (node is not null)
{
nodes.Add(node);
}
}
return nodes.OrderBy(n => n.Digest, StringComparer.Ordinal).ToList();
}
private async Task<SbomLineageNodeExtended?> BuildNodeFromDigestAsync(
string digest,
string tenantId,
SbomLineageQueryOptions options,
CancellationToken ct)
{
var version = await _ledgerRepository.GetVersionByDigestAsync(digest, ct).ConfigureAwait(false);
if (version is null)
{
// Create minimal node for unknown digests
return new SbomLineageNodeExtended
{
Id = Guid.Empty,
Digest = digest,
ArtifactRef = "unknown",
SequenceNumber = 0,
CreatedAt = DateTimeOffset.MinValue,
Source = "unknown"
};
}
var badges = options.IncludeBadges
? new SbomLineageBadges
{
ComponentCount = version.Components.Count,
SignatureStatus = "unsigned" // TODO: integrate with signature service
}
: null;
// LIN-BE-023: Use stored replay hash or compute via service
string? replayHash = null;
if (options.IncludeReplayHash)
{
// Prefer stored hash for performance
replayHash = version.ReplayHash;
// Fallback to service computation if not stored
if (string.IsNullOrEmpty(replayHash) && _replayHashService is not null)
{
try
{
var result = await _replayHashService.ComputeHashAsync(digest, tenantId, ct);
replayHash = result.Hash;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to compute replay hash for {Digest}", digest);
// Fallback to simple hash
replayHash = ComputeNodeReplayHash(version);
}
}
else if (string.IsNullOrEmpty(replayHash))
{
// Fallback to simple hash if service not available
replayHash = ComputeNodeReplayHash(version);
}
}
return new SbomLineageNodeExtended
{
Id = version.VersionId,
Digest = version.Digest,
ArtifactRef = version.ArtifactRef,
SequenceNumber = version.SequenceNumber,
CreatedAt = version.CreatedAtUtc,
Source = version.Source,
Badges = badges,
ReplayHash = replayHash
};
}
private static SbomDiffResult ComputeComponentDiff(
SbomLedgerVersion fromVersion,
SbomLedgerVersion toVersion)
{
var fromComponents = fromVersion.Components.ToDictionary(c => c.Key, StringComparer.Ordinal);
var toComponents = toVersion.Components.ToDictionary(c => c.Key, StringComparer.Ordinal);
var added = new List<SbomDiffComponent>();
var removed = new List<SbomDiffComponent>();
var versionChanged = new List<SbomVersionChange>();
var licenseChanged = new List<SbomLicenseChange>();
// Find added components
foreach (var (key, component) in toComponents)
{
if (!fromComponents.ContainsKey(key))
{
added.Add(new SbomDiffComponent(
component.Key,
component.Name,
component.Purl,
component.Version,
component.License));
}
}
// Find removed components and changes
foreach (var (key, fromComponent) in fromComponents)
{
if (!toComponents.TryGetValue(key, out var toComponent))
{
removed.Add(new SbomDiffComponent(
fromComponent.Key,
fromComponent.Name,
fromComponent.Purl,
fromComponent.Version,
fromComponent.License));
}
else
{
// Check for version change
if (!string.Equals(fromComponent.Version, toComponent.Version, StringComparison.Ordinal))
{
versionChanged.Add(new SbomVersionChange(
fromComponent.Key,
fromComponent.Name,
fromComponent.Purl,
fromComponent.Version,
toComponent.Version));
}
// Check for license change
if (!string.Equals(fromComponent.License, toComponent.License, StringComparison.Ordinal))
{
licenseChanged.Add(new SbomLicenseChange(
fromComponent.Key,
fromComponent.Name,
fromComponent.Purl,
fromComponent.License,
toComponent.License));
}
}
}
return new SbomDiffResult
{
BeforeVersionId = fromVersion.VersionId,
AfterVersionId = toVersion.VersionId,
Added = added.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
Removed = removed.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
VersionChanged = versionChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
LicenseChanged = licenseChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(),
Summary = new SbomDiffSummary(
added.Count,
removed.Count,
versionChanged.Count,
licenseChanged.Count)
};
}
private static string ComputeReplayHash(
string fromDigest,
string toDigest,
SbomDiffResult diff)
{
var sb = new StringBuilder();
sb.Append(fromDigest);
sb.Append('|');
sb.Append(toDigest);
sb.Append('|');
sb.Append(diff.Summary.AddedCount);
sb.Append('|');
sb.Append(diff.Summary.RemovedCount);
sb.Append('|');
sb.Append(diff.Summary.VersionChangedCount);
sb.Append('|');
sb.Append(diff.Summary.LicenseChangedCount);
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static string ComputeNodeReplayHash(SbomLedgerVersion version)
{
var sb = new StringBuilder();
sb.Append(version.Digest);
sb.Append('|');
sb.Append(version.Components.Count);
sb.Append('|');
sb.Append(version.Format);
sb.Append('|');
sb.Append(version.FormatVersion);
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static VexDeltaHoverSummary ComputeVexHoverSummary(IReadOnlyList<VexDeltaSummary> vexDiffs)
{
var newVulns = 0;
var resolvedVulns = 0;
var statusChanges = 0;
var criticalCves = new List<string>();
foreach (var diff in vexDiffs)
{
statusChanges++;
if (diff.FromStatus == "not_affected" && diff.ToStatus == "affected")
{
newVulns++;
criticalCves.Add(diff.Cve);
}
else if (diff.FromStatus == "affected" &&
(diff.ToStatus == "not_affected" || diff.ToStatus == "fixed"))
{
resolvedVulns++;
}
}
return new VexDeltaHoverSummary
{
NewVulns = newVulns,
ResolvedVulns = resolvedVulns,
StatusChanges = statusChanges,
CriticalCves = criticalCves.Count > 0 ? criticalCves.Take(5).ToList() : null
};
}
private static Guid ParseTenantId(string tenantId)
{
if (Guid.TryParse(tenantId, out var guid))
{
return guid;
}
// For string-based tenant IDs, generate deterministic GUID
var bytes = Encoding.UTF8.GetBytes(tenantId);
var hash = SHA256.HashData(bytes);
return new Guid(hash.Take(16).ToArray());
}
}

View File

@@ -85,7 +85,12 @@ internal sealed class SbomUploadService : ISbomUploadService
Source: request.Source?.Tool ?? "upload",
Provenance: request.Source,
Components: normalized,
ParentVersionId: null);
ParentVersionId: null,
// LIN-BE-003: Lineage ancestry fields
ParentArtifactDigest: request.ParentArtifactDigest,
BaseImageRef: request.BaseImageRef,
BaseImageDigest: request.BaseImageDigest,
BuildId: request.Source?.CiContext?.BuildId);
var ledgerVersion = await _ledgerService.AddVersionAsync(submission, cancellationToken).ConfigureAwait(false);
var analysisJob = await _analysisTrigger.TriggerAsync(request.ArtifactRef.Trim(), ledgerVersion.VersionId, cancellationToken).ConfigureAwait(false);

View File

@@ -12,6 +12,8 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<!-- LIN-BE-028: Lineage compare service needs VEX delta repository -->
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<ItemGroup>