439 lines
16 KiB
C#
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);
|
|
}
|