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