Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.SbomService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62535;http://localhost:62537"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user