Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,156 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.SbomService.Models;
using Xunit;
namespace StellaOps.SbomService.Tests;
public sealed class SbomLedgerEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public SbomLedgerEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}
[Fact]
public async Task Upload_accepts_cyclonedx_and_returns_analysis_job()
{
var client = _factory.CreateClient();
var request = CreateUploadRequest("acme/app:1.0", CycloneDxSample());
var response = await client.PostAsJsonAsync("/sbom/upload", request);
response.StatusCode.Should().Be(HttpStatusCode.Accepted);
var payload = await response.Content.ReadFromJsonAsync<SbomUploadResponse>();
payload.Should().NotBeNull();
payload!.ArtifactRef.Should().Be("acme/app:1.0");
payload.ValidationResult.Valid.Should().BeTrue();
payload.ValidationResult.ComponentCount.Should().Be(1);
payload.AnalysisJobId.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task Upload_accepts_spdx_and_records_history()
{
var client = _factory.CreateClient();
var artifact = "acme/worker:2.0";
var first = await client.PostAsJsonAsync("/sbom/upload", CreateUploadRequest(artifact, SpdxSample("4.17.21")));
first.StatusCode.Should().Be(HttpStatusCode.Accepted);
var firstPayload = await first.Content.ReadFromJsonAsync<SbomUploadResponse>();
firstPayload.Should().NotBeNull();
var second = await client.PostAsJsonAsync("/sbom/upload", CreateUploadRequest(artifact, SpdxSample("4.17.22")));
second.StatusCode.Should().Be(HttpStatusCode.Accepted);
var secondPayload = await second.Content.ReadFromJsonAsync<SbomUploadResponse>();
secondPayload.Should().NotBeNull();
var history = await client.GetAsync($"/sbom/ledger/history?artifact={Uri.EscapeDataString(artifact)}&limit=5");
history.StatusCode.Should().Be(HttpStatusCode.OK);
var historyPayload = await history.Content.ReadFromJsonAsync<SbomVersionHistoryResult>();
historyPayload.Should().NotBeNull();
historyPayload!.Versions.Should().HaveCount(2);
var diff = await client.GetAsync($"/sbom/ledger/diff?before={firstPayload!.SbomId}&after={secondPayload!.SbomId}");
diff.StatusCode.Should().Be(HttpStatusCode.OK);
var diffPayload = await diff.Content.ReadFromJsonAsync<SbomDiffResult>();
diffPayload.Should().NotBeNull();
diffPayload!.Summary.VersionChangedCount.Should().Be(1);
}
[Fact]
public async Task Lineage_includes_build_edges_for_shared_build_id()
{
var client = _factory.CreateClient();
var artifact = "acme/build:1.0";
var first = await client.PostAsJsonAsync("/sbom/upload", CreateUploadRequest(artifact, SpdxSample("1.0.0")));
first.StatusCode.Should().Be(HttpStatusCode.Accepted);
var second = await client.PostAsJsonAsync("/sbom/upload", CreateUploadRequest(artifact, SpdxSample("1.1.0")));
second.StatusCode.Should().Be(HttpStatusCode.Accepted);
var lineage = await client.GetAsync($"/sbom/ledger/lineage?artifact={Uri.EscapeDataString(artifact)}");
lineage.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await lineage.Content.ReadFromJsonAsync<SbomLineageResult>();
payload.Should().NotBeNull();
payload!.Edges.Should().Contain(e => e.Relationship == SbomLineageRelationships.Build);
}
private static SbomUploadRequest CreateUploadRequest(string artifactRef, string sbomJson)
{
using var document = JsonDocument.Parse(sbomJson);
return new SbomUploadRequest
{
ArtifactRef = artifactRef,
Sbom = document.RootElement.Clone(),
Source = new SbomUploadSource
{
Tool = "syft",
Version = "1.0.0",
CiContext = new SbomUploadCiContext
{
BuildId = "build-01",
Repository = "github.com/acme/app"
}
}
};
}
private static string CycloneDxSample()
{
return """
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"components": [
{
"type": "library",
"name": "lodash",
"version": "4.17.21",
"purl": "pkg:npm/lodash@4.17.21",
"licenses": [
{ "license": { "id": "MIT" } }
]
}
]
}
""";
}
private static string SpdxSample(string version)
{
return $$"""
{
"spdxVersion": "SPDX-2.3",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "sample",
"dataLicense": "CC0-1.0",
"packages": [
{
"SPDXID": "SPDXRef-Package-lodash",
"name": "lodash",
"versionInfo": "{{version}}",
"licenseDeclared": "MIT",
"externalRefs": [
{
"referenceType": "purl",
"referenceLocator": "pkg:npm/lodash@{{version}}",
"referenceCategory": "PACKAGE-MANAGER"
}
]
}
]
}
""";
}
}

View File

@@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.SbomService.Models;
public sealed record SbomUploadRequest
{
[JsonPropertyName("artifactRef")]
public string ArtifactRef { get; init; } = string.Empty;
[JsonPropertyName("sbom")]
public JsonElement? Sbom { get; init; }
[JsonPropertyName("sbomBase64")]
public string? SbomBase64 { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("source")]
public SbomUploadSource? Source { get; init; }
}
public sealed record SbomUploadSource
{
[JsonPropertyName("tool")]
public string? Tool { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("ciContext")]
public SbomUploadCiContext? CiContext { get; init; }
}
public sealed record SbomUploadCiContext
{
[JsonPropertyName("buildId")]
public string? BuildId { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
}
public sealed record SbomUploadResponse
{
[JsonPropertyName("sbomId")]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("artifactRef")]
public string ArtifactRef { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("formatVersion")]
public string FormatVersion { get; init; } = string.Empty;
[JsonPropertyName("validationResult")]
public SbomValidationSummary ValidationResult { get; init; } = new();
[JsonPropertyName("analysisJobId")]
public string AnalysisJobId { get; init; } = string.Empty;
}
public sealed record SbomValidationSummary
{
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("qualityScore")]
public double QualityScore { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
}
public sealed record SbomNormalizedComponent(
string Key,
string Name,
string? Version,
string? Purl,
string? License);
public sealed record SbomLedgerSubmission(
string ArtifactRef,
string Digest,
string Format,
string FormatVersion,
string Source,
SbomUploadSource? Provenance,
IReadOnlyList<SbomNormalizedComponent> Components,
Guid? ParentVersionId);
public sealed record SbomLedgerVersion
{
public required Guid VersionId { get; init; }
public required Guid ChainId { get; init; }
public required string ArtifactRef { get; init; }
public required int SequenceNumber { get; init; }
public required string Digest { get; init; }
public required string Format { get; init; }
public required string FormatVersion { get; init; }
public required string Source { get; init; }
public required DateTimeOffset CreatedAtUtc { get; init; }
public SbomUploadSource? Provenance { get; init; }
public Guid? ParentVersionId { get; init; }
public string? ParentDigest { get; init; }
public IReadOnlyList<SbomNormalizedComponent> Components { get; init; } = Array.Empty<SbomNormalizedComponent>();
}
public sealed record SbomVersionHistoryItem(
Guid VersionId,
int SequenceNumber,
string Digest,
string Format,
string FormatVersion,
string Source,
DateTimeOffset CreatedAtUtc,
Guid? ParentVersionId,
string? ParentDigest,
int ComponentCount);
public sealed record SbomVersionHistoryResult(
string ArtifactRef,
Guid ChainId,
IReadOnlyList<SbomVersionHistoryItem> Versions,
string? NextCursor);
public sealed record SbomTemporalQueryResult(
string ArtifactRef,
SbomVersionHistoryItem? Version);
public sealed record SbomDiffComponent(
string Key,
string Name,
string? Purl,
string? Version,
string? License);
public sealed record SbomVersionChange(
string Key,
string Name,
string? Purl,
string? FromVersion,
string? ToVersion);
public sealed record SbomLicenseChange(
string Key,
string Name,
string? Purl,
string? FromLicense,
string? ToLicense);
public sealed record SbomDiffSummary(
int AddedCount,
int RemovedCount,
int VersionChangedCount,
int LicenseChangedCount);
public sealed record SbomDiffResult
{
public required Guid BeforeVersionId { get; init; }
public required Guid AfterVersionId { get; init; }
public IReadOnlyList<SbomDiffComponent> Added { get; init; } = Array.Empty<SbomDiffComponent>();
public IReadOnlyList<SbomDiffComponent> Removed { get; init; } = Array.Empty<SbomDiffComponent>();
public IReadOnlyList<SbomVersionChange> VersionChanged { get; init; } = Array.Empty<SbomVersionChange>();
public IReadOnlyList<SbomLicenseChange> LicenseChanged { get; init; } = Array.Empty<SbomLicenseChange>();
public SbomDiffSummary Summary { get; init; } = new(0, 0, 0, 0);
}
public sealed record SbomLineageNode(
Guid VersionId,
int SequenceNumber,
string Digest,
string Source,
DateTimeOffset CreatedAtUtc);
public sealed record SbomLineageEdge(
Guid FromVersionId,
Guid ToVersionId,
string Relationship);
public static class SbomLineageRelationships
{
public const string Parent = "parent";
public const string Build = "build";
}
public sealed record SbomLineageResult(
string ArtifactRef,
Guid ChainId,
IReadOnlyList<SbomLineageNode> Nodes,
IReadOnlyList<SbomLineageEdge> Edges);
public sealed record SbomRetentionResult(
int VersionsPruned,
int ChainsTouched,
IReadOnlyList<string> Messages);
public sealed class SbomLedgerOptions
{
public int MaxVersionsPerArtifact { get; init; } = 50;
public int MaxAgeDays { get; init; }
public int MinVersionsToKeep { get; init; } = 1;
}
public sealed record SbomLedgerAuditEntry(
string ArtifactRef,
Guid VersionId,
string Action,
DateTimeOffset TimestampUtc,
string? Details);
public sealed record SbomAnalysisJob(
string JobId,
string ArtifactRef,
Guid VersionId,
DateTimeOffset CreatedAtUtc,
string Status);

View File

@@ -45,4 +45,16 @@ internal static class SbomMetrics
public static readonly Counter<long> ResolverFeedPublished =
Meter.CreateCounter<long>("sbom_resolver_feed_published",
description: "Resolver feed candidates published");
public static readonly Counter<long> LedgerUploadsTotal =
Meter.CreateCounter<long>("sbom_ledger_uploads_total",
description: "Total SBOM uploads ingested into the ledger");
public static readonly Counter<long> LedgerDiffsTotal =
Meter.CreateCounter<long>("sbom_ledger_diffs_total",
description: "Total SBOM ledger diff requests");
public static readonly Counter<long> LedgerRetentionPrunedTotal =
Meter.CreateCounter<long>("sbom_ledger_retention_pruned_total",
description: "Total SBOM ledger versions pruned by retention");
}

View File

@@ -62,6 +62,14 @@ builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
sp.GetRequiredService<IOrchestratorControlRepository>(),
SbomMetrics.Meter));
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
builder.Services.AddOptions<SbomLedgerOptions>()
.Bind(builder.Configuration.GetSection("SbomService:Ledger"));
builder.Services.AddSingleton<ISbomLedgerRepository, InMemorySbomLedgerRepository>();
builder.Services.AddSingleton<ISbomNormalizationService, SbomNormalizationService>();
builder.Services.AddSingleton<ISbomQualityScorer, SbomQualityScorer>();
builder.Services.AddSingleton<ISbomLedgerService, SbomLedgerService>();
builder.Services.AddSingleton<ISbomAnalysisTrigger, InMemorySbomAnalysisTrigger>();
builder.Services.AddSingleton<ISbomUploadService, SbomUploadService>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
@@ -454,6 +462,162 @@ app.MapGet("/sbom/versions", async Task<IResult> (
return Results.Ok(result.Result);
});
var sbomUploadHandler = async Task<IResult> (
[FromBody] SbomUploadRequest request,
[FromServices] ISbomUploadService uploadService,
CancellationToken cancellationToken) =>
{
var (response, validation) = await uploadService.UploadAsync(request, cancellationToken);
if (!validation.Valid)
{
return Results.BadRequest(new
{
error = "sbom_upload_validation_failed",
validation
});
}
SbomMetrics.LedgerUploadsTotal.Add(1);
return Results.Accepted($"/sbom/ledger/history?artifact={Uri.EscapeDataString(response.ArtifactRef)}", response);
};
app.MapPost("/sbom/upload", sbomUploadHandler);
app.MapPost("/api/v1/sbom/upload", sbomUploadHandler);
app.MapGet("/sbom/ledger/history", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = NormalizeLimit(limit, 50, 200);
var history = await ledgerService.GetHistoryAsync(artifact.Trim(), pageSize, offset, cancellationToken);
if (history is null)
{
return Results.NotFound(new { error = "ledger history not found" });
}
return Results.Ok(history);
});
app.MapGet("/sbom/ledger/point", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
[FromQuery] string? at,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (string.IsNullOrWhiteSpace(at) || !DateTimeOffset.TryParse(at, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var atUtc))
{
return Results.BadRequest(new { error = "at must be an ISO-8601 timestamp" });
}
var result = await ledgerService.GetAtTimeAsync(artifact.Trim(), atUtc, cancellationToken);
if (result is null)
{
return Results.NotFound(new { error = "ledger point not found" });
}
return Results.Ok(result);
});
app.MapGet("/sbom/ledger/range", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
[FromQuery] string? start,
[FromQuery] string? end,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (string.IsNullOrWhiteSpace(start) || !DateTimeOffset.TryParse(start, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var startUtc))
{
return Results.BadRequest(new { error = "start must be an ISO-8601 timestamp" });
}
if (string.IsNullOrWhiteSpace(end) || !DateTimeOffset.TryParse(end, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var endUtc))
{
return Results.BadRequest(new { error = "end must be an ISO-8601 timestamp" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = NormalizeLimit(limit, 50, 200);
var history = await ledgerService.GetRangeAsync(artifact.Trim(), startUtc, endUtc, pageSize, offset, cancellationToken);
if (history is null)
{
return Results.NotFound(new { error = "ledger range not found" });
}
return Results.Ok(history);
});
app.MapGet("/sbom/ledger/diff", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? before,
[FromQuery] string? after,
CancellationToken cancellationToken) =>
{
if (!Guid.TryParse(before, out var beforeId) || !Guid.TryParse(after, out var afterId))
{
return Results.BadRequest(new { error = "before and after must be GUIDs" });
}
var diff = await ledgerService.DiffAsync(beforeId, afterId, cancellationToken);
if (diff is null)
{
return Results.NotFound(new { error = "diff not found" });
}
SbomMetrics.LedgerDiffsTotal.Add(1);
return Results.Ok(diff);
});
app.MapGet("/sbom/ledger/lineage", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
var lineage = await ledgerService.GetLineageAsync(artifact.Trim(), cancellationToken);
if (lineage is null)
{
return Results.NotFound(new { error = "lineage not found" });
}
return Results.Ok(lineage);
});
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromRoute] string? snapshotId,
@@ -543,6 +707,34 @@ app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
return Results.Ok(events);
});
app.MapGet("/internal/sbom/ledger/audit", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
var audit = await ledgerService.GetAuditAsync(artifact.Trim(), cancellationToken);
return Results.Ok(audit.OrderBy(a => a.TimestampUtc).ToList());
});
app.MapGet("/internal/sbom/analysis/jobs", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
var jobs = await ledgerService.ListAnalysisJobsAsync(artifact.Trim(), cancellationToken);
return Results.Ok(jobs.OrderBy(j => j.CreatedAtUtc).ToList());
});
app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
[FromServices] IProjectionRepository repository,
[FromServices] ISbomEventPublisher publisher,
@@ -632,6 +824,19 @@ app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
return Results.Text(ndjson, "application/x-ndjson");
});
app.MapPost("/internal/sbom/retention/prune", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
CancellationToken cancellationToken) =>
{
var result = await ledgerService.ApplyRetentionAsync(cancellationToken);
if (result.VersionsPruned > 0)
{
SbomMetrics.LedgerRetentionPrunedTotal.Add(result.VersionsPruned);
}
return Results.Ok(result);
});
app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorRepository repository,

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
internal interface ISbomLedgerRepository
{
Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerVersion version, CancellationToken cancellationToken);
Task<SbomLedgerVersion?> GetVersionAsync(Guid versionId, 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);
Task AddAuditAsync(SbomLedgerAuditEntry entry, CancellationToken cancellationToken);
Task<int> RemoveVersionsAsync(string artifactRef, IReadOnlyList<Guid> versionIds, CancellationToken cancellationToken);
Task<IReadOnlyList<string>> ListArtifactsAsync(CancellationToken cancellationToken);
Task AddAnalysisJobAsync(SbomAnalysisJob job, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomAnalysisJob>> ListAnalysisJobsAsync(string artifactRef, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,172 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Repositories;
internal sealed class InMemorySbomLedgerRepository : ISbomLedgerRepository
{
private readonly ConcurrentDictionary<string, Guid> _chains = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<Guid, SbomLedgerVersion> _versions = new();
private readonly ConcurrentDictionary<string, List<Guid>> _versionsByArtifact = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, List<SbomLedgerAuditEntry>> _auditByArtifact = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, List<SbomAnalysisJob>> _analysisByArtifact = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
public Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerVersion version, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(version);
cancellationToken.ThrowIfCancellationRequested();
lock (_lock)
{
if (!_chains.ContainsKey(version.ArtifactRef))
{
_chains[version.ArtifactRef] = version.ChainId;
}
_versions[version.VersionId] = version;
var list = _versionsByArtifact.GetOrAdd(version.ArtifactRef, _ => new List<Guid>());
list.Add(version.VersionId);
}
return Task.FromResult(version);
}
public Task<SbomLedgerVersion?> GetVersionAsync(Guid versionId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_versions.TryGetValue(versionId, out var version);
return Task.FromResult(version);
}
public Task<IReadOnlyList<SbomLedgerVersion>> GetVersionsAsync(string artifactRef, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(artifactRef))
{
return Task.FromResult<IReadOnlyList<SbomLedgerVersion>>(Array.Empty<SbomLedgerVersion>());
}
if (!_versionsByArtifact.TryGetValue(artifactRef, out var versionIds))
{
return Task.FromResult<IReadOnlyList<SbomLedgerVersion>>(Array.Empty<SbomLedgerVersion>());
}
var result = versionIds
.Select(id => _versions.TryGetValue(id, out var version) ? version : null)
.Where(v => v is not null)
.Cast<SbomLedgerVersion>()
.ToList();
return Task.FromResult<IReadOnlyList<SbomLedgerVersion>>(result);
}
public Task<Guid?> GetChainIdAsync(string artifactRef, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_chains.TryGetValue(artifactRef, out var chainId) ? chainId : (Guid?)null);
}
public Task<IReadOnlyList<SbomLedgerAuditEntry>> GetAuditAsync(string artifactRef, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(artifactRef))
{
return Task.FromResult<IReadOnlyList<SbomLedgerAuditEntry>>(Array.Empty<SbomLedgerAuditEntry>());
}
if (_auditByArtifact.TryGetValue(artifactRef, out var entries))
{
return Task.FromResult<IReadOnlyList<SbomLedgerAuditEntry>>(entries.ToList());
}
return Task.FromResult<IReadOnlyList<SbomLedgerAuditEntry>>(Array.Empty<SbomLedgerAuditEntry>());
}
public Task AddAuditAsync(SbomLedgerAuditEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
cancellationToken.ThrowIfCancellationRequested();
var list = _auditByArtifact.GetOrAdd(entry.ArtifactRef, _ => new List<SbomLedgerAuditEntry>());
lock (_lock)
{
list.Add(entry);
}
return Task.CompletedTask;
}
public Task<int> RemoveVersionsAsync(string artifactRef, IReadOnlyList<Guid> versionIds, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(versionIds);
cancellationToken.ThrowIfCancellationRequested();
if (versionIds.Count == 0)
{
return Task.FromResult(0);
}
var removed = 0;
lock (_lock)
{
foreach (var versionId in versionIds)
{
if (_versions.TryRemove(versionId, out _))
{
removed++;
}
}
if (_versionsByArtifact.TryGetValue(artifactRef, out var list))
{
list.RemoveAll(id => versionIds.Contains(id));
}
}
return Task.FromResult(removed);
}
public Task<IReadOnlyList<string>> ListArtifactsAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var artifacts = _chains.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList();
return Task.FromResult<IReadOnlyList<string>>(artifacts);
}
public Task AddAnalysisJobAsync(SbomAnalysisJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
cancellationToken.ThrowIfCancellationRequested();
var list = _analysisByArtifact.GetOrAdd(job.ArtifactRef, _ => new List<SbomAnalysisJob>());
lock (_lock)
{
list.Add(job);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<SbomAnalysisJob>> ListAnalysisJobsAsync(string artifactRef, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(artifactRef))
{
return Task.FromResult<IReadOnlyList<SbomAnalysisJob>>(Array.Empty<SbomAnalysisJob>());
}
if (_analysisByArtifact.TryGetValue(artifactRef, out var jobs))
{
return Task.FromResult<IReadOnlyList<SbomAnalysisJob>>(jobs.ToList());
}
return Task.FromResult<IReadOnlyList<SbomAnalysisJob>>(Array.Empty<SbomAnalysisJob>());
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal interface ISbomLedgerService
{
Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken);
Task<SbomVersionHistoryResult?> GetHistoryAsync(string artifactRef, int limit, int offset, CancellationToken cancellationToken);
Task<SbomTemporalQueryResult?> GetAtTimeAsync(string artifactRef, DateTimeOffset atUtc, CancellationToken cancellationToken);
Task<SbomVersionHistoryResult?> GetRangeAsync(string artifactRef, DateTimeOffset startUtc, DateTimeOffset endUtc, int limit, int offset, CancellationToken cancellationToken);
Task<SbomDiffResult?> DiffAsync(Guid beforeVersionId, Guid afterVersionId, CancellationToken cancellationToken);
Task<SbomLineageResult?> GetLineageAsync(string artifactRef, CancellationToken cancellationToken);
Task<SbomRetentionResult> ApplyRetentionAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<SbomLedgerAuditEntry>> GetAuditAsync(string artifactRef, CancellationToken cancellationToken);
Task<IReadOnlyList<SbomAnalysisJob>> ListAnalysisJobsAsync(string artifactRef, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal interface ISbomUploadService
{
Task<(SbomUploadResponse Response, SbomValidationSummary Validation)> UploadAsync(
SbomUploadRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.SbomService.Models;
using StellaOps.SbomService.Repositories;
namespace StellaOps.SbomService.Services;
internal interface ISbomAnalysisTrigger
{
Task<SbomAnalysisJob> TriggerAsync(string artifactRef, Guid versionId, CancellationToken cancellationToken);
}
internal sealed class InMemorySbomAnalysisTrigger : ISbomAnalysisTrigger
{
private readonly ISbomLedgerRepository _repository;
private readonly IClock _clock;
public InMemorySbomAnalysisTrigger(ISbomLedgerRepository repository, IClock clock)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async Task<SbomAnalysisJob> TriggerAsync(string artifactRef, Guid versionId, CancellationToken cancellationToken)
{
var jobId = Guid.NewGuid().ToString("n");
var job = new SbomAnalysisJob(jobId, artifactRef, versionId, _clock.UtcNow, "queued");
await _repository.AddAnalysisJobAsync(job, cancellationToken).ConfigureAwait(false);
return job;
}
}

View File

@@ -0,0 +1,438 @@
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);
}

View File

@@ -0,0 +1,283 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal interface ISbomNormalizationService
{
string? DetectFormat(JsonElement root);
(string Format, string FormatVersion) ResolveFormat(JsonElement root, string? requestedFormat);
IReadOnlyList<SbomNormalizedComponent> Normalize(JsonElement root, string format);
}
internal sealed class SbomNormalizationService : ISbomNormalizationService
{
public string? DetectFormat(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
if (root.TryGetProperty("bomFormat", out var bomFormat)
&& bomFormat.ValueKind == JsonValueKind.String
&& string.Equals(bomFormat.GetString(), "CycloneDX", StringComparison.OrdinalIgnoreCase))
{
return "cyclonedx";
}
if (root.TryGetProperty("spdxVersion", out var spdxVersion)
&& spdxVersion.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(spdxVersion.GetString()))
{
return "spdx";
}
return null;
}
public (string Format, string FormatVersion) ResolveFormat(JsonElement root, string? requestedFormat)
{
var format = string.IsNullOrWhiteSpace(requestedFormat)
? DetectFormat(root)
: requestedFormat.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(format))
{
return (string.Empty, string.Empty);
}
var formatVersion = format switch
{
"cyclonedx" => GetCycloneDxVersion(root),
"spdx" => GetSpdxVersion(root),
_ => string.Empty
};
return (format, formatVersion);
}
public IReadOnlyList<SbomNormalizedComponent> Normalize(JsonElement root, string format)
{
if (string.Equals(format, "cyclonedx", StringComparison.OrdinalIgnoreCase))
{
return NormalizeCycloneDx(root);
}
if (string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase))
{
return NormalizeSpdx(root);
}
return Array.Empty<SbomNormalizedComponent>();
}
private static IReadOnlyList<SbomNormalizedComponent> NormalizeCycloneDx(JsonElement root)
{
if (!root.TryGetProperty("components", out var components) || components.ValueKind != JsonValueKind.Array)
{
return Array.Empty<SbomNormalizedComponent>();
}
var results = new List<SbomNormalizedComponent>();
foreach (var component in components.EnumerateArray())
{
if (component.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = GetString(component, "name");
var version = GetString(component, "version");
var purl = GetString(component, "purl");
var license = ExtractCycloneDxLicense(component);
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(purl))
{
continue;
}
var key = NormalizeKey(purl, name);
results.Add(new SbomNormalizedComponent(key, name, version, purl, license));
}
return results
.OrderBy(c => c.Key, StringComparer.Ordinal)
.ThenBy(c => c.Version ?? string.Empty, StringComparer.Ordinal)
.ToList();
}
private static IReadOnlyList<SbomNormalizedComponent> NormalizeSpdx(JsonElement root)
{
if (!root.TryGetProperty("packages", out var packages) || packages.ValueKind != JsonValueKind.Array)
{
return Array.Empty<SbomNormalizedComponent>();
}
var results = new List<SbomNormalizedComponent>();
foreach (var package in packages.EnumerateArray())
{
if (package.ValueKind != JsonValueKind.Object)
{
continue;
}
var name = GetString(package, "name");
var version = GetString(package, "versionInfo");
var purl = ExtractSpdxPurl(package);
var license = GetString(package, "licenseDeclared");
if (string.IsNullOrWhiteSpace(license))
{
license = GetString(package, "licenseConcluded");
}
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(purl))
{
continue;
}
var key = NormalizeKey(purl, name);
results.Add(new SbomNormalizedComponent(key, name, version, purl, license));
}
return results
.OrderBy(c => c.Key, StringComparer.Ordinal)
.ThenBy(c => c.Version ?? string.Empty, StringComparer.Ordinal)
.ToList();
}
private static string GetCycloneDxVersion(JsonElement root)
{
var spec = GetString(root, "specVersion");
if (!string.IsNullOrWhiteSpace(spec))
{
return spec.Trim();
}
return string.Empty;
}
private static string GetSpdxVersion(JsonElement root)
{
var version = GetString(root, "spdxVersion");
if (!string.IsNullOrWhiteSpace(version))
{
var trimmed = version.Trim();
if (trimmed.StartsWith("SPDX-", StringComparison.OrdinalIgnoreCase))
{
return trimmed[5..];
}
return trimmed;
}
return string.Empty;
}
private static string NormalizeKey(string? purl, string name)
{
if (!string.IsNullOrWhiteSpace(purl))
{
var trimmed = purl.Trim();
var qualifierIndex = trimmed.IndexOf('?');
if (qualifierIndex > 0)
{
trimmed = trimmed[..qualifierIndex];
}
var atIndex = trimmed.LastIndexOf('@');
if (atIndex > 4)
{
trimmed = trimmed[..atIndex];
}
return trimmed;
}
return name.Trim();
}
private static string? ExtractCycloneDxLicense(JsonElement component)
{
if (!component.TryGetProperty("licenses", out var licenses) || licenses.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var entry in licenses.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object)
{
continue;
}
if (entry.TryGetProperty("license", out var licenseObj) && licenseObj.ValueKind == JsonValueKind.Object)
{
var id = GetString(licenseObj, "id");
if (!string.IsNullOrWhiteSpace(id))
{
return id;
}
var name = GetString(licenseObj, "name");
if (!string.IsNullOrWhiteSpace(name))
{
return name;
}
}
}
return null;
}
private static string? ExtractSpdxPurl(JsonElement package)
{
if (!package.TryGetProperty("externalRefs", out var refs) || refs.ValueKind != JsonValueKind.Array)
{
return null;
}
foreach (var reference in refs.EnumerateArray())
{
if (reference.ValueKind != JsonValueKind.Object)
{
continue;
}
var referenceType = GetString(reference, "referenceType");
if (!string.Equals(referenceType, "purl", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var locator = GetString(reference, "referenceLocator");
if (!string.IsNullOrWhiteSpace(locator))
{
return locator;
}
}
return null;
}
private static string GetString(JsonElement element, string property)
{
if (element.ValueKind != JsonValueKind.Object)
{
return string.Empty;
}
if (!element.TryGetProperty(property, out var prop))
{
return string.Empty;
}
return prop.ValueKind == JsonValueKind.String ? prop.GetString() ?? string.Empty : string.Empty;
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal interface ISbomQualityScorer
{
(double Score, IReadOnlyList<string> Warnings) Score(IReadOnlyList<SbomNormalizedComponent> components);
}
internal sealed class SbomQualityScorer : ISbomQualityScorer
{
public (double Score, IReadOnlyList<string> Warnings) Score(IReadOnlyList<SbomNormalizedComponent> components)
{
if (components is null || components.Count == 0)
{
return (0.0, new[] { "No components detected in SBOM." });
}
var total = components.Count;
var withPurl = components.Count(c => !string.IsNullOrWhiteSpace(c.Purl));
var withVersion = components.Count(c => !string.IsNullOrWhiteSpace(c.Version));
var withLicense = components.Count(c => !string.IsNullOrWhiteSpace(c.License));
var purlRatio = (double)withPurl / total;
var versionRatio = (double)withVersion / total;
var licenseRatio = (double)withLicense / total;
var score = (purlRatio * 0.4) + (versionRatio * 0.3) + (licenseRatio * 0.3);
var warnings = new List<string>();
if (withPurl < total)
{
warnings.Add($"{total - withPurl} components missing PURL values.");
}
if (withVersion < total)
{
warnings.Add($"{total - withVersion} components missing version values.");
}
if (withLicense < total)
{
warnings.Add($"{total - withLicense} components missing license values.");
}
return (Math.Round(score, 2), warnings);
}
}

View File

@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal sealed class SbomUploadService : ISbomUploadService
{
private readonly ISbomNormalizationService _normalizationService;
private readonly ISbomQualityScorer _qualityScorer;
private readonly ISbomLedgerService _ledgerService;
private readonly ISbomAnalysisTrigger _analysisTrigger;
public SbomUploadService(
ISbomNormalizationService normalizationService,
ISbomQualityScorer qualityScorer,
ISbomLedgerService ledgerService,
ISbomAnalysisTrigger analysisTrigger)
{
_normalizationService = normalizationService ?? throw new ArgumentNullException(nameof(normalizationService));
_qualityScorer = qualityScorer ?? throw new ArgumentNullException(nameof(qualityScorer));
_ledgerService = ledgerService ?? throw new ArgumentNullException(nameof(ledgerService));
_analysisTrigger = analysisTrigger ?? throw new ArgumentNullException(nameof(analysisTrigger));
}
public async Task<(SbomUploadResponse Response, SbomValidationSummary Validation)> UploadAsync(
SbomUploadRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(request.ArtifactRef))
{
var validation = new SbomValidationSummary
{
Valid = false,
Errors = new[] { "artifactRef is required." }
};
return (new SbomUploadResponse { ValidationResult = validation }, validation);
}
var document = TryParseDocument(request, out var parseErrors);
if (document is null)
{
var validation = new SbomValidationSummary
{
Valid = false,
Errors = parseErrors.ToArray()
};
return (new SbomUploadResponse { ValidationResult = validation }, validation);
}
using (document)
{
var root = document.RootElement;
var (format, formatVersion) = _normalizationService.ResolveFormat(root, request.Format);
var errors = ValidateFormat(root, format, formatVersion);
if (errors.Count > 0)
{
var invalid = new SbomValidationSummary
{
Valid = false,
Errors = errors
};
return (new SbomUploadResponse { ValidationResult = invalid }, invalid);
}
var normalized = _normalizationService.Normalize(root, format);
var (score, warnings) = _qualityScorer.Score(normalized);
var digest = ComputeDigest(document);
var submission = new SbomLedgerSubmission(
ArtifactRef: request.ArtifactRef.Trim(),
Digest: digest,
Format: format,
FormatVersion: formatVersion,
Source: request.Source?.Tool ?? "upload",
Provenance: request.Source,
Components: normalized,
ParentVersionId: null);
var ledgerVersion = await _ledgerService.AddVersionAsync(submission, cancellationToken).ConfigureAwait(false);
var analysisJob = await _analysisTrigger.TriggerAsync(request.ArtifactRef.Trim(), ledgerVersion.VersionId, cancellationToken).ConfigureAwait(false);
var validation = new SbomValidationSummary
{
Valid = true,
QualityScore = score,
Warnings = warnings,
ComponentCount = normalized.Count
};
var response = new SbomUploadResponse
{
SbomId = ledgerVersion.VersionId.ToString(),
ArtifactRef = ledgerVersion.ArtifactRef,
Digest = ledgerVersion.Digest,
Format = ledgerVersion.Format,
FormatVersion = ledgerVersion.FormatVersion,
ValidationResult = validation,
AnalysisJobId = analysisJob.JobId
};
return (response, validation);
}
}
private static JsonDocument? TryParseDocument(SbomUploadRequest request, out List<string> errors)
{
errors = new List<string>();
if (request.Sbom is { } sbomElement && sbomElement.ValueKind == JsonValueKind.Object)
{
var raw = sbomElement.GetRawText();
return JsonDocument.Parse(raw);
}
if (!string.IsNullOrWhiteSpace(request.SbomBase64))
{
try
{
var bytes = Convert.FromBase64String(request.SbomBase64);
return JsonDocument.Parse(bytes);
}
catch (FormatException)
{
errors.Add("sbomBase64 is not valid base64.");
return null;
}
catch (JsonException ex)
{
errors.Add($"Invalid SBOM JSON: {ex.Message}");
return null;
}
}
errors.Add("sbom or sbomBase64 is required.");
return null;
}
private static string ComputeDigest(JsonDocument document)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(document.RootElement, new JsonSerializerOptions
{
WriteIndented = false
});
var hash = SHA256.HashData(bytes);
var builder = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
{
builder.Append(b.ToString("x2", CultureInfo.InvariantCulture));
}
return "sha256:" + builder;
}
private static IReadOnlyList<string> ValidateFormat(JsonElement root, string format, string formatVersion)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(format))
{
errors.Add("Unable to detect SBOM format.");
return errors;
}
if (string.Equals(format, "cyclonedx", StringComparison.OrdinalIgnoreCase))
{
if (!root.TryGetProperty("bomFormat", out var bomFormat) || bomFormat.ValueKind != JsonValueKind.String)
{
errors.Add("CycloneDX SBOM must include bomFormat.");
}
if (!string.IsNullOrWhiteSpace(formatVersion))
{
if (!IsSupportedCycloneDx(formatVersion))
{
errors.Add($"CycloneDX specVersion '{formatVersion}' is not supported (1.4-1.6).");
}
}
}
else if (string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase))
{
if (!root.TryGetProperty("spdxVersion", out var spdxVersion) || spdxVersion.ValueKind != JsonValueKind.String)
{
errors.Add("SPDX SBOM must include spdxVersion.");
}
if (!string.IsNullOrWhiteSpace(formatVersion) && !IsSupportedSpdx(formatVersion))
{
errors.Add($"SPDX version '{formatVersion}' is not supported (2.3, 3.0).");
}
}
else
{
errors.Add($"Unsupported SBOM format '{format}'.");
}
return errors;
}
private static bool IsSupportedCycloneDx(string version)
{
return version.StartsWith("1.4", StringComparison.OrdinalIgnoreCase)
|| version.StartsWith("1.5", StringComparison.OrdinalIgnoreCase)
|| version.StartsWith("1.6", StringComparison.OrdinalIgnoreCase);
}
private static bool IsSupportedSpdx(string version)
{
return version.StartsWith("2.3", StringComparison.OrdinalIgnoreCase)
|| version.StartsWith("3.0", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,4 +1,4 @@
# SbomService Tasks (prep sync)
# SbomService Tasks (prep sync)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
@@ -12,3 +12,5 @@
| SBOM-ORCH-34-001 | DONE | Watermark tracking endpoints (`/internal/orchestrator/watermarks`) implemented for backfill reconciliation. | 2025-11-23 |
| SBOM-VULN-29-001 | DONE | Inventory evidence emitted (scope/runtime_flag/paths/nearest_safe_version) with `/internal/sbom/inventory` diagnostics + backfill endpoint. | 2025-11-23 |
| SBOM-VULN-29-002 | DONE | Resolver feed candidates emitted with NDJSON export/backfill endpoints; idempotent keys across tenant/artifact/purl/version/scope/runtime_flag. | 2025-11-24 |
| SPRINT-4600-LEDGER | DONE | Implement SBOM lineage ledger (LEDGER-001..020) including version chain, diff, lineage, and retention. | 2025-12-22 |
| SPRINT-4600-BYOS | DONE | BYOS upload validation/normalization, quality scoring, analysis trigger stub, docs/tests. | 2025-12-22 |