Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Services/SbomLedgerService.cs

439 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
internal sealed class SbomLedgerService : ISbomLedgerService
{
private readonly ISbomLedgerRepository _repository;
private readonly IClock _clock;
private readonly SbomLedgerOptions _options;
public SbomLedgerService(ISbomLedgerRepository repository, IClock clock, IOptions<SbomLedgerOptions> options)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_options = options?.Value ?? new SbomLedgerOptions();
}
public async Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(submission);
cancellationToken.ThrowIfCancellationRequested();
var artifact = submission.ArtifactRef.Trim();
var chainId = await _repository.GetChainIdAsync(artifact, cancellationToken).ConfigureAwait(false) ?? Guid.NewGuid();
var existing = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
var sequence = existing.Count + 1;
var versionId = Guid.NewGuid();
var createdAt = _clock.UtcNow;
SbomLedgerVersion? parent = null;
if (submission.ParentVersionId.HasValue)
{
parent = await _repository.GetVersionAsync(submission.ParentVersionId.Value, cancellationToken).ConfigureAwait(false);
if (parent is null)
{
throw new InvalidOperationException($"Parent version '{submission.ParentVersionId}' was not found.");
}
}
var version = new SbomLedgerVersion
{
VersionId = versionId,
ChainId = chainId,
ArtifactRef = artifact,
SequenceNumber = sequence,
Digest = submission.Digest,
Format = submission.Format,
FormatVersion = submission.FormatVersion,
Source = submission.Source,
CreatedAtUtc = createdAt,
Provenance = submission.Provenance,
ParentVersionId = parent?.VersionId,
ParentDigest = parent?.Digest,
Components = submission.Components
};
await _repository.AddVersionAsync(version, cancellationToken).ConfigureAwait(false);
await _repository.AddAuditAsync(
new SbomLedgerAuditEntry(artifact, versionId, "created", createdAt, $"format={submission.Format}"),
cancellationToken).ConfigureAwait(false);
return version;
}
public async Task<SbomVersionHistoryResult?> GetHistoryAsync(string artifactRef, int limit, int offset, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactRef))
{
return null;
}
var artifact = artifactRef.Trim();
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
if (versions.Count == 0)
{
return null;
}
var ordered = versions
.OrderByDescending(v => v.SequenceNumber)
.ThenByDescending(v => v.CreatedAtUtc)
.ToList();
var page = ordered
.Skip(offset)
.Take(limit)
.Select(v => new SbomVersionHistoryItem(
v.VersionId,
v.SequenceNumber,
v.Digest,
v.Format,
v.FormatVersion,
v.Source,
v.CreatedAtUtc,
v.ParentVersionId,
v.ParentDigest,
v.Components.Count))
.ToList();
var nextCursor = offset + limit < ordered.Count
? (offset + limit).ToString(CultureInfo.InvariantCulture)
: null;
var chainId = versions.First().ChainId;
return new SbomVersionHistoryResult(artifact, chainId, page, nextCursor);
}
public async Task<SbomTemporalQueryResult?> GetAtTimeAsync(string artifactRef, DateTimeOffset atUtc, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactRef))
{
return null;
}
var artifact = artifactRef.Trim();
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
if (versions.Count == 0)
{
return null;
}
var match = versions
.Where(v => v.CreatedAtUtc <= atUtc)
.OrderByDescending(v => v.CreatedAtUtc)
.ThenByDescending(v => v.SequenceNumber)
.FirstOrDefault();
if (match is null)
{
return new SbomTemporalQueryResult(artifact, null);
}
return new SbomTemporalQueryResult(
artifact,
new SbomVersionHistoryItem(
match.VersionId,
match.SequenceNumber,
match.Digest,
match.Format,
match.FormatVersion,
match.Source,
match.CreatedAtUtc,
match.ParentVersionId,
match.ParentDigest,
match.Components.Count));
}
public async Task<SbomVersionHistoryResult?> GetRangeAsync(string artifactRef, DateTimeOffset startUtc, DateTimeOffset endUtc, int limit, int offset, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactRef))
{
return null;
}
var artifact = artifactRef.Trim();
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
if (versions.Count == 0)
{
return null;
}
var filtered = versions
.Where(v => v.CreatedAtUtc >= startUtc && v.CreatedAtUtc <= endUtc)
.OrderByDescending(v => v.CreatedAtUtc)
.ThenByDescending(v => v.SequenceNumber)
.ToList();
var page = filtered
.Skip(offset)
.Take(limit)
.Select(v => new SbomVersionHistoryItem(
v.VersionId,
v.SequenceNumber,
v.Digest,
v.Format,
v.FormatVersion,
v.Source,
v.CreatedAtUtc,
v.ParentVersionId,
v.ParentDigest,
v.Components.Count))
.ToList();
var nextCursor = offset + limit < filtered.Count
? (offset + limit).ToString(CultureInfo.InvariantCulture)
: null;
var chainId = versions.First().ChainId;
return new SbomVersionHistoryResult(artifact, chainId, page, nextCursor);
}
public async Task<SbomDiffResult?> DiffAsync(Guid beforeVersionId, Guid afterVersionId, CancellationToken cancellationToken)
{
var before = await _repository.GetVersionAsync(beforeVersionId, cancellationToken).ConfigureAwait(false);
var after = await _repository.GetVersionAsync(afterVersionId, cancellationToken).ConfigureAwait(false);
if (before is null || after is null)
{
return null;
}
var beforeMap = BuildComponentMap(before.Components);
var afterMap = BuildComponentMap(after.Components);
var added = new List<SbomDiffComponent>();
var removed = new List<SbomDiffComponent>();
var versionChanged = new List<SbomVersionChange>();
var licenseChanged = new List<SbomLicenseChange>();
foreach (var (key, component) in afterMap)
{
if (!beforeMap.TryGetValue(key, out var beforeComponent))
{
added.Add(ToDiffComponent(component));
continue;
}
if (!string.Equals(component.Version, beforeComponent.Version, StringComparison.OrdinalIgnoreCase))
{
versionChanged.Add(new SbomVersionChange(
key,
component.Name,
component.Purl,
beforeComponent.Version,
component.Version));
}
if (!string.Equals(component.License, beforeComponent.License, StringComparison.OrdinalIgnoreCase))
{
licenseChanged.Add(new SbomLicenseChange(
key,
component.Name,
component.Purl,
beforeComponent.License,
component.License));
}
}
foreach (var (key, component) in beforeMap)
{
if (!afterMap.ContainsKey(key))
{
removed.Add(ToDiffComponent(component));
}
}
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();
return new SbomDiffResult
{
BeforeVersionId = beforeVersionId,
AfterVersionId = afterVersionId,
Added = added,
Removed = removed,
VersionChanged = versionChanged,
LicenseChanged = licenseChanged,
Summary = new SbomDiffSummary(added.Count, removed.Count, versionChanged.Count, licenseChanged.Count)
};
}
public async Task<SbomLineageResult?> GetLineageAsync(string artifactRef, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactRef))
{
return null;
}
var artifact = artifactRef.Trim();
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
if (versions.Count == 0)
{
return null;
}
var nodes = versions
.OrderBy(v => v.SequenceNumber)
.ThenBy(v => v.CreatedAtUtc)
.Select(v => new SbomLineageNode(v.VersionId, v.SequenceNumber, v.Digest, v.Source, v.CreatedAtUtc))
.ToList();
var edges = new List<SbomLineageEdge>();
edges.AddRange(versions
.Where(v => v.ParentVersionId.HasValue)
.Select(v => new SbomLineageEdge(v.ParentVersionId!.Value, v.VersionId, SbomLineageRelationships.Parent)));
var buildEdges = versions
.Where(v => !string.IsNullOrWhiteSpace(v.Provenance?.CiContext?.BuildId))
.GroupBy(v => v.Provenance!.CiContext!.BuildId!.Trim(), StringComparer.OrdinalIgnoreCase)
.SelectMany(group =>
{
var ordered = group
.OrderBy(v => v.SequenceNumber)
.ThenBy(v => v.CreatedAtUtc)
.ToList();
var groupEdges = new List<SbomLineageEdge>();
for (var i = 1; i < ordered.Count; i++)
{
groupEdges.Add(new SbomLineageEdge(
ordered[i - 1].VersionId,
ordered[i].VersionId,
SbomLineageRelationships.Build));
}
return groupEdges;
})
.ToList();
edges.AddRange(buildEdges);
var orderedEdges = edges
.GroupBy(e => new { e.FromVersionId, e.ToVersionId, e.Relationship })
.Select(g => g.First())
.OrderBy(e => e.FromVersionId)
.ThenBy(e => e.ToVersionId)
.ThenBy(e => e.Relationship, StringComparer.Ordinal)
.ToList();
return new SbomLineageResult(artifact, versions[0].ChainId, nodes, orderedEdges);
}
public async Task<SbomRetentionResult> ApplyRetentionAsync(CancellationToken cancellationToken)
{
var artifacts = await _repository.ListArtifactsAsync(cancellationToken).ConfigureAwait(false);
var messages = new List<string>();
var totalPruned = 0;
var chainsTouched = 0;
foreach (var artifact in artifacts)
{
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
if (versions.Count == 0)
{
continue;
}
var prunable = ApplyRetentionPolicy(versions);
if (prunable.Count == 0)
{
continue;
}
var pruned = await _repository.RemoveVersionsAsync(artifact, prunable.Select(v => v.VersionId).ToList(), cancellationToken).ConfigureAwait(false);
totalPruned += pruned;
chainsTouched++;
foreach (var version in prunable)
{
await _repository.AddAuditAsync(
new SbomLedgerAuditEntry(artifact, version.VersionId, "retention_prune", _clock.UtcNow, $"sequence={version.SequenceNumber}"),
cancellationToken).ConfigureAwait(false);
}
messages.Add($"Pruned {pruned} versions for {artifact}.");
}
return new SbomRetentionResult(totalPruned, chainsTouched, messages);
}
public Task<IReadOnlyList<SbomLedgerAuditEntry>> GetAuditAsync(string artifactRef, CancellationToken cancellationToken)
{
return _repository.GetAuditAsync(artifactRef.Trim(), cancellationToken);
}
public Task<IReadOnlyList<SbomAnalysisJob>> ListAnalysisJobsAsync(string artifactRef, CancellationToken cancellationToken)
{
return _repository.ListAnalysisJobsAsync(artifactRef.Trim(), cancellationToken);
}
private List<SbomLedgerVersion> ApplyRetentionPolicy(IReadOnlyList<SbomLedgerVersion> versions)
{
var ordered = versions.OrderByDescending(v => v.CreatedAtUtc).ThenByDescending(v => v.SequenceNumber).ToList();
var keep = new HashSet<Guid>();
for (var i = 0; i < Math.Min(_options.MinVersionsToKeep, ordered.Count); i++)
{
keep.Add(ordered[i].VersionId);
}
var prunable = new List<SbomLedgerVersion>();
if (_options.MaxVersionsPerArtifact > 0 && ordered.Count > _options.MaxVersionsPerArtifact)
{
var toPrune = ordered
.Skip(_options.MaxVersionsPerArtifact)
.Where(v => !keep.Contains(v.VersionId))
.ToList();
prunable.AddRange(toPrune);
}
if (_options.MaxAgeDays > 0)
{
var threshold = _clock.UtcNow.AddDays(-_options.MaxAgeDays);
foreach (var version in ordered.Where(v => v.CreatedAtUtc < threshold))
{
if (!keep.Contains(version.VersionId))
{
prunable.Add(version);
}
}
}
return prunable
.GroupBy(v => v.VersionId)
.Select(g => g.First())
.ToList();
}
private static Dictionary<string, SbomNormalizedComponent> BuildComponentMap(IReadOnlyList<SbomNormalizedComponent> components)
{
var map = new Dictionary<string, SbomNormalizedComponent>(StringComparer.OrdinalIgnoreCase);
foreach (var component in components.OrderBy(c => c.Key, StringComparer.Ordinal))
{
if (!map.ContainsKey(component.Key))
{
map[component.Key] = component;
}
}
return map;
}
private static SbomDiffComponent ToDiffComponent(SbomNormalizedComponent component)
=> new(component.Key, component.Name, component.Purl, component.Version, component.License);
}