This commit is contained in:
master
2025-12-09 10:50:15 +02:00
parent cc69d332e3
commit f30805ad7f
25 changed files with 846 additions and 317 deletions

View File

@@ -9,9 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.0" />
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>

View File

@@ -129,4 +129,74 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase));
secondPage.NextCursor.Should().BeNull();
}
[Fact]
public async Task Context_requires_artifact_id()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Context_returns_versions_and_paths_with_hash()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=2&maxDependencyPaths=1");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomContextResponse>();
payload.Should().NotBeNull();
payload!.Schema.Should().Be("stellaops.sbom.context/1.0");
payload.ArtifactId.Should().Be("ghcr.io/stellaops/sample-api");
payload.Versions.Should().NotBeEmpty();
payload.DependencyPaths.Should().NotBeEmpty();
payload.Hash.Should().StartWith("sha256:", StringComparison.Ordinal);
}
[Fact]
public async Task Context_includes_environment_flags_and_blast_radius_when_requested()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=5&maxDependencyPaths=5&includeEnvironmentFlags=true&includeBlastRadius=true");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomContextResponse>();
payload.Should().NotBeNull();
payload!.EnvironmentFlags.Should().ContainKey("prod");
payload.EnvironmentFlags["prod"].Should().Be("2");
payload.BlastRadius.Should().NotBeNull();
payload.BlastRadius!.ImpactedAssets.Should().BeGreaterThan(0);
payload.BlastRadius.Metadata.Should().ContainKey("blast_radius_tags");
}
[Fact]
public async Task Context_honors_zero_timeline_limit_and_dependency_results()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=0&maxDependencyPaths=2&includeEnvironmentFlags=false&includeBlastRadius=false");
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SbomContextResponse>();
payload.Should().NotBeNull();
payload!.Versions.Should().BeEmpty();
payload.DependencyPaths.Should().NotBeEmpty();
payload.EnvironmentFlags.Should().BeEmpty();
payload.BlastRadius.Should().BeNull();
}
[Fact]
public async Task Context_returns_not_found_when_no_data()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/sbom/context?artifactId=does-not-exist&purl=pkg:npm/missing@1.0.0");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace StellaOps.SbomService.Models;
public sealed record SbomContextResponse(
[property: JsonPropertyName("schema")] string Schema,
[property: JsonPropertyName("generated")] DateTimeOffset Generated,
[property: JsonPropertyName("artifactId")] string ArtifactId,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("versions")] IReadOnlyList<SbomContextVersion> Versions,
[property: JsonPropertyName("dependencyPaths")] IReadOnlyList<SbomContextDependencyPath> DependencyPaths,
[property: JsonPropertyName("environmentFlags")] IReadOnlyDictionary<string, string> EnvironmentFlags,
[property: JsonPropertyName("blastRadius")] SbomContextBlastRadius? BlastRadius,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata,
[property: JsonPropertyName("hash")] string Hash);
public sealed record SbomContextVersion(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
public sealed record SbomContextDependencyPath(
[property: JsonPropertyName("nodes")] IReadOnlyList<SbomContextDependencyNode> Nodes,
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);
public sealed record SbomContextDependencyNode(
[property: JsonPropertyName("identifier")] string Identifier,
[property: JsonPropertyName("version")] string? Version);
public sealed record SbomContextBlastRadius(
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> Metadata);

View File

@@ -14,7 +14,10 @@ public sealed record SbomPath(
IReadOnlyList<SbomPathNode> Nodes,
bool RuntimeFlag,
string? BlastRadius,
string? NearestSafeVersion);
string? NearestSafeVersion,
string? Scope,
string? Environment,
string? Artifact);
public sealed record SbomPathResult(
string Purl,

View File

@@ -16,7 +16,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("SBOM_");
builder.Services.AddOptions();
builder.Services.AddLogging();
@@ -152,6 +152,21 @@ static string? FindFixture(IHostEnvironment env, string fileName)
return null;
}
static int NormalizeLimit(int? requested, int defaultValue, int ceiling)
{
if (!requested.HasValue)
{
return defaultValue;
}
if (requested.Value <= 0)
{
return 0;
}
return Math.Min(requested.Value, ceiling);
}
var app = builder.Build();
if (app.Environment.IsDevelopment())
@@ -225,27 +240,27 @@ app.MapPost("/entrypoints", async Task<IResult> (
var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items));
});
app.MapGet("/console/sboms", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
[FromQuery] string? license,
[FromQuery] string? scope,
[FromQuery(Name = "assetTag")] string? assetTag,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
[FromQuery] string? scope,
[FromQuery(Name = "assetTag")] string? assetTag,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
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 = limit ?? 50;
@@ -257,43 +272,43 @@ app.MapGet("/console/sboms", async Task<IResult> (
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", scope ?? string.Empty },
{ "env", string.Empty }
});
SbomMetrics.PathsQueryTotal.Add(1, new TagList
{
{ "cache_hit", result.CacheHit },
{ "scope", scope ?? string.Empty }
});
return Results.Ok(result.Result);
});
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{
new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
new KeyValuePair<string, object?>("env", string.Empty)
});
SbomMetrics.PathsQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
});
return Results.Ok(result.Result);
});
app.MapGet("/components/lookup", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? purl,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
}
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
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 = limit ?? 50;
@@ -304,22 +319,84 @@ app.MapGet("/components/lookup", async Task<IResult> (
var result = await service.GetComponentLookupAsync(
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", string.Empty },
{ "env", string.Empty }
});
SbomMetrics.PathsQueryTotal.Add(1, new TagList
{
{ "cache_hit", result.CacheHit },
{ "scope", string.Empty }
});
return Results.Ok(result.Result);
});
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{
new KeyValuePair<string, object?>("scope", string.Empty),
new KeyValuePair<string, object?>("env", string.Empty)
});
SbomMetrics.PathsQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
new KeyValuePair<string, object?>("scope", string.Empty)
});
return Results.Ok(result.Result);
});
app.MapGet("/sbom/context", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromServices] IClock clock,
[FromQuery(Name = "artifactId")] string? artifactId,
[FromQuery] string? purl,
[FromQuery] int? maxTimelineEntries,
[FromQuery] int? maxDependencyPaths,
[FromQuery] bool? includeEnvironmentFlags,
[FromQuery] bool? includeBlastRadius,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactId))
{
return Results.BadRequest(new { error = "artifactId is required" });
}
var normalizedArtifact = artifactId.Trim();
var normalizedPurl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
var timelineLimit = NormalizeLimit(maxTimelineEntries, 50, 500);
var dependencyLimit = NormalizeLimit(maxDependencyPaths, 25, 200);
var includeEnvFlags = includeEnvironmentFlags ?? true;
var includeBlast = includeBlastRadius ?? true;
IReadOnlyList<SbomVersion> versions = Array.Empty<SbomVersion>();
if (timelineLimit > 0)
{
var timeline = await service.GetTimelineAsync(
new SbomTimelineQuery(normalizedArtifact, timelineLimit, 0),
cancellationToken);
versions = timeline.Result.Versions;
}
IReadOnlyList<SbomPath> dependencyPaths = Array.Empty<SbomPath>();
if (dependencyLimit > 0 && !string.IsNullOrWhiteSpace(normalizedPurl))
{
var artifactFilter = normalizedArtifact.Contains('@', StringComparison.Ordinal)
? normalizedArtifact
: null;
var pathResult = await service.GetPathsAsync(
new SbomPathQuery(normalizedPurl!, artifactFilter, Scope: null, Environment: null, Limit: dependencyLimit, Offset: 0),
cancellationToken);
dependencyPaths = pathResult.Result.Paths;
}
if (versions.Count == 0 && dependencyPaths.Count == 0)
{
return Results.NotFound(new { error = "No SBOM context available for specified artifact/purl." });
}
var response = SbomContextAssembler.Build(
normalizedArtifact,
normalizedPurl,
clock.UtcNow,
versions,
dependencyPaths,
includeEnvFlags,
includeBlast);
return Results.Ok(response);
});
app.MapGet("/sbom/paths", async Task<IResult> (
[FromServices] IServiceProvider services,
[FromQuery] string? purl,
@@ -327,22 +404,22 @@ app.MapGet("/sbom/paths", async Task<IResult> (
[FromQuery] string? scope,
[FromQuery(Name = "env")] string? environment,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{
return Results.BadRequest(new { error = "cursor must be an integer offset" });
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
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);
@@ -353,22 +430,22 @@ app.MapGet("/sbom/paths", async Task<IResult> (
var result = await service.GetPathsAsync(
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
{
{ "scope", scope ?? string.Empty },
{ "env", environment ?? string.Empty }
});
SbomMetrics.PathsQueryTotal.Add(1, new TagList
{
{ "cache_hit", result.CacheHit },
{ "scope", scope ?? string.Empty }
});
return Results.Ok(result.Result);
});
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{
new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
new KeyValuePair<string, object?>("env", environment ?? string.Empty)
});
SbomMetrics.PathsQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
});
return Results.Ok(result.Result);
});
app.MapGet("/sbom/versions", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
@@ -376,33 +453,40 @@ app.MapGet("/sbom/versions", async Task<IResult> (
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
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 = limit ?? 50;
var start = Stopwatch.GetTimestamp();
var result = await service.GetTimelineAsync(
new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } });
SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } });
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
}
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 = limit ?? 50;
var start = Stopwatch.GetTimestamp();
var result = await service.GetTimelineAsync(
new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new[]
{
new KeyValuePair<string, object?>("artifact", artifact)
});
SbomMetrics.TimelineQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("artifact", artifact),
new KeyValuePair<string, object?>("cache_hit", result.CacheHit)
});
return Results.Ok(result.Result);
});
@@ -445,10 +529,19 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
var json = JsonSerializer.Serialize(payload);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } });
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
new TagList { { "tenant", projection.TenantId } });
SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } });
new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);

View File

@@ -6,9 +6,9 @@ using StellaOps.SbomService.Models;
using StellaOps.SbomService.Observability;
using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Services;
internal sealed class InMemorySbomQueryService : ISbomQueryService
{
private readonly IReadOnlyList<PathRecord> _paths;
@@ -36,77 +36,77 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
_paths = SeedPaths();
_timelines = SeedTimelines();
}
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
{
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
{
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
}
var filtered = _paths
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p.Artifact)
.ThenBy(p => p.Environment)
.ThenBy(p => p.Scope)
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
.ToList();
var page = filtered
.Skip(query.Offset)
.Take(query.Limit)
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion))
.ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null;
var result = new SbomPathResult(
Purl: query.Purl,
Artifact: query.Artifact,
Scope: query.Scope,
Environment: query.Environment,
Paths: page,
NextCursor: nextCursor);
_cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
}
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
{
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
{
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
}
var filtered = _timelines
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.ThenByDescending(t => t.Version)
.ToList();
var page = filtered
.Skip(query.Offset)
.Take(query.Limit)
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
.ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null;
var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
_cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
}
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
{
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
{
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
}
var filtered = _paths
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
.Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p.Artifact)
.ThenBy(p => p.Environment)
.ThenBy(p => p.Scope)
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
.ToList();
var page = filtered
.Skip(query.Offset)
.Take(query.Limit)
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion, r.Scope, r.Environment, r.Artifact))
.ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null;
var result = new SbomPathResult(
Purl: query.Purl,
Artifact: query.Artifact,
Scope: query.Scope,
Environment: query.Environment,
Paths: page,
NextCursor: nextCursor);
_cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
}
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
{
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
{
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
}
var filtered = _timelines
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.ThenByDescending(t => t.Version)
.ToList();
var page = filtered
.Skip(query.Offset)
.Take(query.Limit)
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
.ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null;
var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
_cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
}
public async Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
{
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
@@ -138,7 +138,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
_cache[cacheKey] = result;
return new QueryResult<SbomCatalogResult>(result, false);
}
public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
{
var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}";
@@ -146,7 +146,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
{
return new QueryResult<ComponentLookupResult>(cachedResult, true);
}
var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken);
string? nextCursor = query.Offset + query.Limit < total
@@ -156,7 +156,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
var neighbors = items
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
.ToList();
var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase)
? "storage"
: "seeded";
@@ -211,7 +211,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
return projection;
}
private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset)
{
asset = default!;
@@ -276,7 +276,10 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
RuntimeFlag: path.RuntimeFlag,
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty);
SbomMetrics.ResolverFeedPublished.Add(1, new TagList { { "tenant", tenantId } });
SbomMetrics.ResolverFeedPublished.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", tenantId)
});
}
}
@@ -301,95 +304,95 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
{
return new List<PathRecord>
{
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21",
Scope: "runtime",
Environment: "prod",
RuntimeFlag: true,
BlastRadius: "medium",
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[]
{
new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("express", "npm"),
new SbomPathNode("lodash", "npm")
}),
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21",
Scope: "build",
Environment: "prod",
RuntimeFlag: false,
BlastRadius: "low",
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[]
{
new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("rollup", "npm"),
new SbomPathNode("lodash", "npm")
}),
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
Scope: "runtime",
Environment: "staging",
RuntimeFlag: true,
BlastRadius: "high",
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
Nodes: new[]
{
new SbomPathNode("sample-worker", "artifact"),
new SbomPathNode("StellaOps.Core", "nuget"),
new SbomPathNode("Newtonsoft.Json", "nuget")
})
};
}
private static IReadOnlyList<TimelineRecord> SeedTimelines()
{
return new List<TimelineRecord>
{
new(
Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.15.1",
Digest: "sha256:111",
SourceBundleHash: "sha256:bundle111",
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new(
Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.16.1",
Digest: "sha256:112",
SourceBundleHash: "sha256:bundle112",
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new(
Artifact: "ghcr.io/stellaops/sample-worker",
Version: "2025.11.12.0",
Digest: "sha256:222",
SourceBundleHash: "sha256:bundle222",
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
Provenance: "upload:spdx:worker"),
};
}
private sealed record PathRecord(
string Artifact,
string Purl,
string? Scope,
string? Environment,
bool RuntimeFlag,
string? BlastRadius,
string? NearestSafeVersion,
IReadOnlyList<SbomPathNode> Nodes);
private sealed record TimelineRecord(
string Artifact,
string Version,
string Digest,
string SourceBundleHash,
DateTimeOffset CreatedAt,
string? Provenance);
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21",
Scope: "runtime",
Environment: "prod",
RuntimeFlag: true,
BlastRadius: "medium",
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[]
{
new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("express", "npm"),
new SbomPathNode("lodash", "npm")
}),
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21",
Scope: "build",
Environment: "prod",
RuntimeFlag: false,
BlastRadius: "low",
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[]
{
new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("rollup", "npm"),
new SbomPathNode("lodash", "npm")
}),
new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
Scope: "runtime",
Environment: "staging",
RuntimeFlag: true,
BlastRadius: "high",
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
Nodes: new[]
{
new SbomPathNode("sample-worker", "artifact"),
new SbomPathNode("StellaOps.Core", "nuget"),
new SbomPathNode("Newtonsoft.Json", "nuget")
})
};
}
private static IReadOnlyList<TimelineRecord> SeedTimelines()
{
return new List<TimelineRecord>
{
new(
Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.15.1",
Digest: "sha256:111",
SourceBundleHash: "sha256:bundle111",
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new(
Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.16.1",
Digest: "sha256:112",
SourceBundleHash: "sha256:bundle112",
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new(
Artifact: "ghcr.io/stellaops/sample-worker",
Version: "2025.11.12.0",
Digest: "sha256:222",
SourceBundleHash: "sha256:bundle222",
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
Provenance: "upload:spdx:worker"),
};
}
private sealed record PathRecord(
string Artifact,
string Purl,
string? Scope,
string? Environment,
bool RuntimeFlag,
string? BlastRadius,
string? NearestSafeVersion,
IReadOnlyList<SbomPathNode> Nodes);
private sealed record TimelineRecord(
string Artifact,
string Version,
string Digest,
string SourceBundleHash,
DateTimeOffset CreatedAt,
string? Provenance);
}

View File

@@ -70,7 +70,10 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
await _repository.SetAsync(updated, cancellationToken);
_cache[updated.TenantId] = updated;
_controlUpdates.Add(1, new TagList { { "tenant", updated.TenantId } });
_controlUpdates.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", updated.TenantId)
});
return updated;
}
@@ -78,7 +81,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
{
foreach (var kvp in _cache)
{
yield return new Measurement<int>(kvp.Value.ThrottlePercent, new TagList { { "tenant", kvp.Key } });
yield return new Measurement<int>(
kvp.Value.ThrottlePercent,
new[]
{
new KeyValuePair<string, object?>("tenant", kvp.Key)
});
}
}
@@ -86,7 +94,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
{
foreach (var kvp in _cache)
{
yield return new Measurement<int>(kvp.Value.Paused ? 1 : 0, new TagList { { "tenant", kvp.Key } });
yield return new Measurement<int>(
kvp.Value.Paused ? 1 : 0,
new[]
{
new KeyValuePair<string, object?>("tenant", kvp.Key)
});
}
}
}

View File

@@ -0,0 +1,259 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
internal static class SbomContextAssembler
{
private const string Schema = "stellaops.sbom.context/1.0";
private static readonly JsonSerializerOptions HashSerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private static readonly IReadOnlyDictionary<string, string> EmptyDictionary =
ImmutableDictionary<string, string>.Empty;
private static readonly IReadOnlyList<SbomContextVersion> EmptyVersions =
ImmutableArray<SbomContextVersion>.Empty;
private static readonly IReadOnlyList<SbomContextDependencyPath> EmptyPaths =
ImmutableArray<SbomContextDependencyPath>.Empty;
public static SbomContextResponse Build(
string artifactId,
string? purl,
DateTimeOffset generated,
IReadOnlyList<SbomVersion> timeline,
IReadOnlyList<SbomPath> paths,
bool includeEnvironmentFlags,
bool includeBlastRadius)
{
var versions = timeline.Count == 0 ? EmptyVersions : BuildVersions(timeline);
var dependencyPaths = paths.Count == 0 ? EmptyPaths : BuildDependencyPaths(paths);
var environmentFlags = includeEnvironmentFlags
? BuildEnvironmentFlags(dependencyPaths)
: EmptyDictionary;
var blastRadius = includeBlastRadius
? BuildBlastRadius(dependencyPaths)
: null;
var metadata = BuildMetadata(artifactId, generated, versions.Count, dependencyPaths.Count, environmentFlags.Count, blastRadius is not null);
var response = new SbomContextResponse(
Schema,
generated,
artifactId,
purl,
versions,
dependencyPaths,
environmentFlags,
blastRadius,
metadata,
Hash: string.Empty);
var hash = ComputeHash(response);
return response with { Hash = hash };
}
private static IReadOnlyList<SbomContextVersion> BuildVersions(IReadOnlyList<SbomVersion> versions)
{
return versions
.OrderByDescending(v => v.CreatedAt)
.ThenBy(v => v.Version, StringComparer.Ordinal)
.Select(v =>
{
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadata["digest"] = v.Digest;
metadata["source_bundle_hash"] = v.SourceBundleHash;
if (!string.IsNullOrWhiteSpace(v.Provenance))
{
metadata["provenance"] = v.Provenance!;
}
return new SbomContextVersion(
v.Version,
v.CreatedAt,
v.CreatedAt,
"observed",
string.IsNullOrWhiteSpace(v.Provenance) ? "sbom" : v.Provenance!.Trim(),
false,
metadata.ToImmutable());
})
.ToImmutableArray();
}
private static IReadOnlyList<SbomContextDependencyPath> BuildDependencyPaths(IReadOnlyList<SbomPath> paths)
{
return paths
.Select(path =>
{
var nodes = path.Nodes
.Select(node => new SbomContextDependencyNode(
Identifier: string.IsNullOrWhiteSpace(node.Name) ? "unknown" : node.Name,
Version: null))
.ToImmutableArray();
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(path.Scope))
{
metadata["scope"] = path.Scope!;
}
if (!string.IsNullOrWhiteSpace(path.Environment))
{
metadata["environment"] = path.Environment!;
}
if (!string.IsNullOrWhiteSpace(path.Artifact))
{
metadata["artifact"] = path.Artifact!;
}
if (!string.IsNullOrWhiteSpace(path.NearestSafeVersion))
{
metadata["nearest_safe_version"] = path.NearestSafeVersion!;
}
if (!string.IsNullOrWhiteSpace(path.BlastRadius))
{
metadata["blast_radius"] = path.BlastRadius!;
}
metadata["path_length"] = nodes.Length.ToString(CultureInfo.InvariantCulture);
return new SbomContextDependencyPath(
nodes,
path.RuntimeFlag,
"sbom.paths",
metadata.ToImmutable());
})
.ToImmutableArray();
}
private static IReadOnlyDictionary<string, string> BuildEnvironmentFlags(IReadOnlyList<SbomContextDependencyPath> paths)
{
if (paths.Count == 0)
{
return EmptyDictionary;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var environmentCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var path in paths)
{
if (path.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment))
{
var key = environment.Trim();
environmentCounts[key] = environmentCounts.TryGetValue(key, out var count) ? count + 1 : 1;
}
}
if (environmentCounts.Count == 0)
{
return EmptyDictionary;
}
foreach (var pair in environmentCounts.OrderBy(p => p.Key, StringComparer.Ordinal))
{
builder[pair.Key] = pair.Value.ToString(CultureInfo.InvariantCulture);
}
return builder.ToImmutable();
}
private static SbomContextBlastRadius? BuildBlastRadius(IReadOnlyList<SbomContextDependencyPath> paths)
{
if (paths.Count == 0)
{
return null;
}
var impactedAssets = paths
.SelectMany(p => p.Metadata.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope)
? new[] { scope.Trim() }
: Array.Empty<string>())
.Distinct(StringComparer.Ordinal)
.Count();
var impactedNamespaces = paths
.SelectMany(p => p.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment)
? new[] { environment.Trim() }
: Array.Empty<string>())
.Distinct(StringComparer.Ordinal)
.Count();
var impactedWorkloads = paths.Count(p => p.IsRuntime);
double? impactedPercentage = paths.Count == 0
? null
: Math.Round((double)impactedWorkloads / paths.Count, 3);
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["path_sample_count"] = paths.Count.ToString(CultureInfo.InvariantCulture);
var blastTags = paths
.Select(p => p.Metadata.TryGetValue("blast_radius", out var tag) ? tag : null)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (blastTags.Length > 0)
{
metadataBuilder["blast_radius_tags"] = string.Join(",", blastTags);
}
return new SbomContextBlastRadius(
impactedAssets,
impactedWorkloads,
impactedNamespaces,
impactedPercentage,
metadataBuilder.ToImmutable());
}
private static IReadOnlyDictionary<string, string> BuildMetadata(
string artifactId,
DateTimeOffset generated,
int versionCount,
int dependencyCount,
int environmentFlagCount,
bool hasBlastRadius)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["generated_at"] = generated.ToString("O", CultureInfo.InvariantCulture);
builder["artifact"] = artifactId;
builder["version_count"] = versionCount.ToString(CultureInfo.InvariantCulture);
builder["dependency_count"] = dependencyCount.ToString(CultureInfo.InvariantCulture);
builder["environment_flag_count"] = environmentFlagCount.ToString(CultureInfo.InvariantCulture);
builder["blast_radius_present"] = hasBlastRadius.ToString();
builder["source"] = "sbom-service";
return builder.ToImmutable();
}
private static string ComputeHash(SbomContextResponse response)
{
var snapshot = new
{
response.Schema,
response.Generated,
response.ArtifactId,
response.Purl,
response.Versions,
response.DependencyPaths,
response.EnvironmentFlags,
response.BlastRadius,
response.Metadata
};
var json = JsonSerializer.Serialize(snapshot, HashSerializerOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}