Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
231
src/SbomService/StellaOps.SbomService/Models/SbomLedgerModels.cs
Normal file
231
src/SbomService/StellaOps.SbomService/Models/SbomLedgerModels.cs
Normal 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);
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user