using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using StellaOps.SbomService.Lineage.Domain; using StellaOps.SbomService.Lineage.Repositories; using System.Text.Json; namespace StellaOps.SbomService.Lineage.Services; /// /// Implementation of lineage graph service with caching and enrichment. /// public sealed class LineageGraphService : ILineageGraphService { private readonly ISbomLineageEdgeRepository _edgeRepository; private readonly IVexDeltaRepository _deltaRepository; private readonly ISbomVerdictLinkRepository _verdictRepository; private readonly IDistributedCache? _cache; private readonly ILogger _logger; private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(10); private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); public LineageGraphService( ISbomLineageEdgeRepository edgeRepository, IVexDeltaRepository deltaRepository, ISbomVerdictLinkRepository verdictRepository, ILogger logger, IDistributedCache? cache = null) { _edgeRepository = edgeRepository; _deltaRepository = deltaRepository; _verdictRepository = verdictRepository; _cache = cache; _logger = logger; } public async ValueTask GetLineageAsync( string artifactDigest, Guid tenantId, LineageQueryOptions options, CancellationToken ct = default) { // Try cache first var cacheKey = $"lineage:{tenantId}:{artifactDigest}:{options.MaxDepth}"; if (_cache != null) { var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached != null) { var response = JsonSerializer.Deserialize(cached, SerializerOptions); if (response != null) { _logger.LogDebug("Cache hit for lineage {Digest}", artifactDigest); return response; } } } // Build graph var graph = await _edgeRepository.GetGraphAsync(artifactDigest, tenantId, options.MaxDepth, ct); // Enrich with verdict data if requested var enrichment = new Dictionary(); if (options.IncludeVerdicts) { foreach (var node in graph.Nodes.Where(n => n.SbomVersionId.HasValue)) { var verdicts = await _verdictRepository.GetBySbomVersionAsync( node.SbomVersionId!.Value, tenantId, ct); var affected = verdicts.Where(v => v.VerdictStatus == VexStatus.Affected).ToList(); var high = affected.Where(v => v.ConfidenceScore >= 0.8m).ToList(); enrichment[node.ArtifactDigest] = new NodeEnrichment( VulnerabilityCount: verdicts.Count, HighSeverityCount: high.Count, AffectedCount: affected.Count, TopCves: affected .OrderByDescending(v => v.ConfidenceScore) .Take(5) .Select(v => v.Cve) .ToList() ); } } var result = new LineageGraphResponse(graph, enrichment); // Cache the result if (_cache != null) { var json = JsonSerializer.Serialize(result, SerializerOptions); await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheExpiry }, ct); } return result; } public async ValueTask GetDiffAsync( string fromDigest, string toDigest, Guid tenantId, CancellationToken ct = default) { // Try cache first var cacheKey = $"lineage:compare:{tenantId}:{fromDigest}:{toDigest}"; if (_cache != null) { var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached != null) { var response = JsonSerializer.Deserialize(cached, SerializerOptions); if (response != null) { _logger.LogDebug("Cache hit for diff {From} -> {To}", fromDigest, toDigest); return response; } } } // Get VEX deltas var deltas = await _deltaRepository.GetDeltasAsync(fromDigest, toDigest, tenantId, ct); var statusChanges = deltas.Where(d => d.FromStatus != d.ToStatus).ToList(); var newVulns = deltas.Count(d => d.FromStatus == VexStatus.Unknown && d.ToStatus == VexStatus.Affected); var resolved = deltas.Count(d => d.FromStatus == VexStatus.Affected && d.ToStatus == VexStatus.Fixed); var affectedToNot = deltas.Count(d => d.FromStatus == VexStatus.Affected && d.ToStatus == VexStatus.NotAffected); var notToAffected = deltas.Count(d => d.FromStatus == VexStatus.NotAffected && d.ToStatus == VexStatus.Affected); var vexDiff = new VexDiff( StatusChanges: statusChanges, NewVulnerabilities: newVulns, ResolvedVulnerabilities: resolved, AffectedToNotAffected: affectedToNot, NotAffectedToAffected: notToAffected ); // TODO: Implement SBOM diff by comparing component lists var sbomDiff = new SbomDiff([], [], []); // TODO: Implement reachability diff if requested ReachabilityDiff? reachDiff = null; var result = new LineageDiffResponse( FromDigest: fromDigest, ToDigest: toDigest, SbomDifferences: sbomDiff, VexDifferences: vexDiff, ReachabilityDifferences: reachDiff ); // Cache the result if (_cache != null) { var json = JsonSerializer.Serialize(result, SerializerOptions); await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = CacheExpiry }, ct); } return result; } public async ValueTask ExportEvidencePackAsync( ExportRequest request, Guid tenantId, CancellationToken ct = default) { _logger.LogInformation("Exporting evidence pack for {Digest}", request.ArtifactDigest); // TODO: Implement evidence pack generation // 1. Get lineage graph if requested // 2. Get verdicts if requested // 3. Get reachability data if requested // 4. Bundle into archive (tar.gz or zip) // 5. Sign with Sigstore if requested // 6. Upload to storage and return download URL // Placeholder implementation var downloadUrl = $"https://evidence.stellaops.example/exports/{Guid.NewGuid()}.tar.gz"; var expiresAt = DateTimeOffset.UtcNow.AddHours(24); return new ExportResult( DownloadUrl: downloadUrl, ExpiresAt: expiresAt, SizeBytes: 0, SignatureDigest: request.SignWithSigstore ? "sha256:placeholder" : null ); } }