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

@@ -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);