1357 lines
48 KiB
C#
1357 lines
48 KiB
C#
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.SbomService.Auth;
|
|
using StellaOps.SbomService.Models;
|
|
using StellaOps.SbomService.Observability;
|
|
using StellaOps.SbomService.Repositories;
|
|
using StellaOps.SbomService.Services;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration
|
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
|
.AddEnvironmentVariables("SBOM_");
|
|
|
|
builder.Services.AddOptions();
|
|
builder.Services.AddLogging();
|
|
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
|
builder.Services.AddSingleton<IGuidProvider>(SystemGuidProvider.Instance);
|
|
|
|
builder.Services.AddAuthentication(HeaderAuthenticationHandler.SchemeName)
|
|
.AddScheme<AuthenticationSchemeOptions, HeaderAuthenticationHandler>(HeaderAuthenticationHandler.SchemeName, _ => { });
|
|
builder.Services.AddAuthorization();
|
|
|
|
// Register SBOM query services using file-backed fixtures when present; fallback to in-memory seeds.
|
|
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
|
{
|
|
var config = sp.GetRequiredService<IConfiguration>();
|
|
var env = sp.GetRequiredService<IHostEnvironment>();
|
|
var configured = config.GetValue<string>("SbomService:ComponentLookupPath");
|
|
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
|
{
|
|
return new FileComponentLookupRepository(configured!);
|
|
}
|
|
|
|
var candidate = FindFixture(env, "component_lookup.json");
|
|
return candidate is not null
|
|
? new FileComponentLookupRepository(candidate)
|
|
: new InMemoryComponentLookupRepository();
|
|
});
|
|
|
|
builder.Services.AddSingleton<ICatalogRepository>(sp =>
|
|
{
|
|
var config = sp.GetRequiredService<IConfiguration>();
|
|
var env = sp.GetRequiredService<IHostEnvironment>();
|
|
var configured = config.GetValue<string>("SbomService:CatalogPath");
|
|
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
|
{
|
|
return new FileCatalogRepository(configured!);
|
|
}
|
|
|
|
var candidate = FindFixture(env, "catalog.json");
|
|
return candidate is not null
|
|
? new FileCatalogRepository(candidate)
|
|
: new InMemoryCatalogRepository();
|
|
});
|
|
builder.Services.AddSingleton<IClock, StellaOps.SbomService.Services.SystemClock>();
|
|
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
|
|
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
|
|
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
|
|
builder.Services.AddSingleton<IEntrypointRepository, InMemoryEntrypointRepository>();
|
|
builder.Services.AddSingleton<IOrchestratorRepository, InMemoryOrchestratorRepository>();
|
|
builder.Services.AddSingleton<IOrchestratorControlRepository, InMemoryOrchestratorControlRepository>();
|
|
builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
|
|
new OrchestratorControlService(
|
|
sp.GetRequiredService<IOrchestratorControlRepository>(),
|
|
SbomMetrics.Meter));
|
|
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
|
|
builder.Services.AddOptions<SbomLedgerOptions>()
|
|
.Bind(builder.Configuration.GetSection("SbomService:Ledger"))
|
|
.ValidateDataAnnotations()
|
|
.Validate(options => options.MaxVersionsPerArtifact <= 0 || options.MinVersionsToKeep <= options.MaxVersionsPerArtifact,
|
|
"MinVersionsToKeep must be less than or equal to MaxVersionsPerArtifact.")
|
|
.ValidateOnStart();
|
|
builder.Services.AddOptions<RegistryHttpOptions>()
|
|
.Bind(builder.Configuration.GetSection($"SbomService:{RegistryHttpOptions.SectionName}"))
|
|
.ValidateDataAnnotations()
|
|
.ValidateOnStart();
|
|
builder.Services.AddOptions<ScannerHttpOptions>()
|
|
.Bind(builder.Configuration.GetSection($"SbomService:{ScannerHttpOptions.SectionName}"))
|
|
.ValidateDataAnnotations()
|
|
.ValidateOnStart();
|
|
builder.Services.AddOptions<RegistrySourceQueryOptions>()
|
|
.Bind(builder.Configuration.GetSection($"SbomService:{RegistrySourceQueryOptions.SectionName}"))
|
|
.ValidateDataAnnotations()
|
|
.ValidateOnStart();
|
|
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>();
|
|
|
|
// Lineage graph services (LIN-BE-013)
|
|
builder.Services.AddSingleton<ISbomLineageEdgeRepository, InMemorySbomLineageEdgeRepository>();
|
|
|
|
// LIN-BE-015: Hover card cache for <150ms response times
|
|
// Use distributed cache if configured, otherwise in-memory
|
|
builder.Services.AddOptions<LineageHoverCacheOptions>()
|
|
.Bind(builder.Configuration.GetSection("SbomService:HoverCache"))
|
|
.ValidateDataAnnotations()
|
|
.Validate(options => options.Ttl > TimeSpan.Zero, "Hover cache TTL must be positive.")
|
|
.ValidateOnStart();
|
|
var hoverCacheConfig = builder.Configuration.GetSection("SbomService:HoverCache");
|
|
if (hoverCacheConfig.GetValue<bool>("UseDistributed"))
|
|
{
|
|
// Expects IDistributedCache to be registered (e.g., Valkey/Redis)
|
|
builder.Services.AddSingleton<ILineageHoverCache, DistributedLineageHoverCache>();
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddSingleton<ILineageHoverCache, InMemoryLineageHoverCache>();
|
|
}
|
|
builder.Services.AddSingleton<ISbomLineageGraphService, SbomLineageGraphService>();
|
|
|
|
// LIN-BE-028: Lineage compare service
|
|
builder.Services.AddSingleton<ILineageCompareService, LineageCompareService>();
|
|
|
|
// LIN-010: Lineage export service for evidence packs
|
|
builder.Services.AddSingleton<ILineageExportService, LineageExportService>();
|
|
|
|
// LIN-BE-023: Replay hash service
|
|
builder.Services.AddSingleton<IReplayHashService, ReplayHashService>();
|
|
|
|
// LIN-BE-033: Replay verification service
|
|
builder.Services.AddSingleton<IReplayVerificationService, ReplayVerificationService>();
|
|
|
|
// LIN-BE-034: Compare cache with TTL and VEX invalidation
|
|
builder.Services.AddOptions<CompareCacheOptions>()
|
|
.Bind(builder.Configuration.GetSection("SbomService:CompareCache"))
|
|
.ValidateDataAnnotations()
|
|
.ValidateOnStart();
|
|
builder.Services.AddSingleton<ILineageCompareCache, InMemoryLineageCompareCache>();
|
|
|
|
// REG-SRC: Registry source management (SPRINT_20251229_012)
|
|
builder.Services.AddSingleton<IRegistrySourceRepository, InMemoryRegistrySourceRepository>();
|
|
builder.Services.AddSingleton<IRegistrySourceRunRepository, InMemoryRegistrySourceRunRepository>();
|
|
builder.Services.AddSingleton<IRegistrySourceService, RegistrySourceService>();
|
|
builder.Services.AddSingleton<IRegistryWebhookService, RegistryWebhookService>();
|
|
builder.Services.AddHttpClient("RegistryDiscovery", (sp, client) =>
|
|
{
|
|
var options = sp.GetRequiredService<IOptions<RegistryHttpOptions>>().Value;
|
|
if (options.Timeout > TimeSpan.Zero && options.Timeout != Timeout.InfiniteTimeSpan)
|
|
{
|
|
client.Timeout = options.Timeout;
|
|
}
|
|
});
|
|
builder.Services.AddHttpClient("Scanner", (sp, client) =>
|
|
{
|
|
var options = sp.GetRequiredService<IOptions<ScannerHttpOptions>>().Value;
|
|
if (options.Timeout > TimeSpan.Zero && options.Timeout != Timeout.InfiniteTimeSpan)
|
|
{
|
|
client.Timeout = options.Timeout;
|
|
}
|
|
});
|
|
builder.Services.AddSingleton<IRegistryDiscoveryService, RegistryDiscoveryService>();
|
|
builder.Services.AddSingleton<IScanJobEmitterService, ScanJobEmitterService>();
|
|
|
|
builder.Services.AddSingleton<IProjectionRepository>(sp =>
|
|
{
|
|
var config = sp.GetRequiredService<IConfiguration>();
|
|
var env = sp.GetRequiredService<IHostEnvironment>();
|
|
|
|
var configured = config.GetValue<string>("SbomService:ProjectionsPath");
|
|
if (!string.IsNullOrWhiteSpace(configured))
|
|
{
|
|
return new FileProjectionRepository(configured!);
|
|
}
|
|
|
|
var candidateRoots = new[]
|
|
{
|
|
env.ContentRootPath,
|
|
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..")),
|
|
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..")),
|
|
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", ".."))
|
|
};
|
|
|
|
foreach (var root in candidateRoots)
|
|
{
|
|
var candidate = Path.Combine(root, "docs", "modules", "sbom-service", "fixtures", "lnm-v1", "projections.json");
|
|
if (File.Exists(candidate))
|
|
{
|
|
return new FileProjectionRepository(candidate);
|
|
}
|
|
}
|
|
|
|
return new FileProjectionRepository(string.Empty);
|
|
});
|
|
|
|
static string? FindFixture(IHostEnvironment env, string fileName)
|
|
{
|
|
var candidateRoots = new[]
|
|
{
|
|
env.ContentRootPath,
|
|
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..")),
|
|
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..")),
|
|
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", ".."))
|
|
};
|
|
|
|
foreach (var root in candidateRoots)
|
|
{
|
|
var candidate = Path.Combine(root, "docs", "modules", "sbom-service", "fixtures", "lnm-v1", fileName);
|
|
if (File.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
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())
|
|
{
|
|
app.UseDeveloperExceptionPage();
|
|
}
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
|
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
|
|
|
|
app.MapGet("/entrypoints", async Task<IResult> (
|
|
[FromServices] IEntrypointRepository repo,
|
|
[FromQuery] string? tenant,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
var tenantId = tenant.Trim();
|
|
using var activity = SbomTracing.Source.StartActivity("entrypoints.list", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenantId);
|
|
|
|
var items = await repo.ListAsync(tenantId, cancellationToken);
|
|
return Results.Ok(new EntrypointListResponse(tenantId, items));
|
|
});
|
|
|
|
app.MapPost("/entrypoints", async Task<IResult> (
|
|
[FromServices] IEntrypointRepository repo,
|
|
[FromBody] EntrypointUpsertRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Artifact) || string.IsNullOrWhiteSpace(request.Service) || string.IsNullOrWhiteSpace(request.Path))
|
|
{
|
|
return Results.BadRequest(new { error = "artifact, service, and path are required" });
|
|
}
|
|
|
|
var entrypoint = new Entrypoint(
|
|
request.Artifact.Trim(),
|
|
request.Service.Trim(),
|
|
request.Path.Trim(),
|
|
string.IsNullOrWhiteSpace(request.Scope) ? "runtime" : request.Scope.Trim(),
|
|
request.RuntimeFlag);
|
|
|
|
var tenantId = request.Tenant.Trim();
|
|
using var activity = SbomTracing.Source.StartActivity("entrypoints.upsert", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenantId);
|
|
activity?.SetTag("artifact", entrypoint.Artifact);
|
|
activity?.SetTag("service", entrypoint.Service);
|
|
|
|
await repo.UpsertAsync(tenantId, entrypoint, cancellationToken);
|
|
|
|
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" });
|
|
}
|
|
|
|
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
|
var pageSize = limit ?? 50;
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("console.sboms", ActivityKind.Server);
|
|
activity?.SetTag("artifact", artifact);
|
|
var start = Stopwatch.GetTimestamp();
|
|
var result = await service.GetConsoleCatalogAsync(
|
|
new SbomCatalogQuery(artifact?.Trim(), license?.Trim(), scope?.Trim(), assetTag?.Trim(), pageSize, offset),
|
|
cancellationToken);
|
|
|
|
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
|
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" });
|
|
}
|
|
|
|
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
|
var pageSize = limit ?? 50;
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("components.lookup", ActivityKind.Server);
|
|
activity?.SetTag("purl", purl);
|
|
activity?.SetTag("artifact", artifact);
|
|
var start = Stopwatch.GetTimestamp();
|
|
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[]
|
|
{
|
|
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,
|
|
[FromQuery] string? artifact,
|
|
[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" });
|
|
}
|
|
|
|
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
|
var pageSize = limit ?? 50;
|
|
|
|
var service = services.GetRequiredService<ISbomQueryService>();
|
|
var start = Stopwatch.GetTimestamp();
|
|
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[]
|
|
{
|
|
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,
|
|
[FromQuery] string? cursor,
|
|
[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[]
|
|
{
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Lineage Graph API Endpoints (LIN-BE-013/014)
|
|
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i
|
|
// -----------------------------------------------------------------------------
|
|
|
|
app.MapGet("/api/v1/lineage/{artifactDigest}", async Task<IResult> (
|
|
[FromServices] ISbomLineageGraphService lineageService,
|
|
[FromRoute] string artifactDigest,
|
|
[FromQuery] string? tenant,
|
|
[FromQuery] int? maxDepth,
|
|
[FromQuery] bool? includeBadges,
|
|
[FromQuery] bool? includeReplayHash,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(artifactDigest))
|
|
{
|
|
return Results.BadRequest(new { error = "artifactDigest is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
var options = new SbomLineageQueryOptions
|
|
{
|
|
MaxDepth = maxDepth ?? 10,
|
|
IncludeBadges = includeBadges ?? true,
|
|
IncludeReplayHash = includeReplayHash ?? false
|
|
};
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.graph", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenant);
|
|
activity?.SetTag("artifact_digest", artifactDigest);
|
|
|
|
var graph = await lineageService.GetLineageGraphAsync(
|
|
artifactDigest.Trim(),
|
|
tenant.Trim(),
|
|
options,
|
|
cancellationToken);
|
|
|
|
if (graph is null)
|
|
{
|
|
return Results.NotFound(new { error = "lineage graph not found" });
|
|
}
|
|
|
|
return Results.Ok(graph);
|
|
});
|
|
|
|
app.MapGet("/api/v1/lineage/diff", async Task<IResult> (
|
|
[FromServices] ISbomLineageGraphService lineageService,
|
|
[FromQuery] string? from,
|
|
[FromQuery] string? to,
|
|
[FromQuery] string? tenant,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
|
|
{
|
|
return Results.BadRequest(new { error = "from and to digests are required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.diff", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenant);
|
|
activity?.SetTag("from_digest", from);
|
|
activity?.SetTag("to_digest", to);
|
|
|
|
var diff = await lineageService.GetLineageDiffAsync(
|
|
from.Trim(),
|
|
to.Trim(),
|
|
tenant.Trim(),
|
|
cancellationToken);
|
|
|
|
if (diff is null)
|
|
{
|
|
return Results.NotFound(new { error = "lineage diff not found" });
|
|
}
|
|
|
|
SbomMetrics.LedgerDiffsTotal.Add(1);
|
|
return Results.Ok(diff);
|
|
});
|
|
|
|
app.MapGet("/api/v1/lineage/hover", async Task<IResult> (
|
|
[FromServices] ISbomLineageGraphService lineageService,
|
|
[FromQuery] string? from,
|
|
[FromQuery] string? to,
|
|
[FromQuery] string? tenant,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
|
|
{
|
|
return Results.BadRequest(new { error = "from and to digests are required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.hover", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenant);
|
|
|
|
var hoverCard = await lineageService.GetHoverCardAsync(
|
|
from.Trim(),
|
|
to.Trim(),
|
|
tenant.Trim(),
|
|
cancellationToken);
|
|
|
|
if (hoverCard is null)
|
|
{
|
|
return Results.NotFound(new { error = "hover card data not found" });
|
|
}
|
|
|
|
return Results.Ok(hoverCard);
|
|
});
|
|
|
|
app.MapGet("/api/v1/lineage/{artifactDigest}/children", async Task<IResult> (
|
|
[FromServices] ISbomLineageGraphService lineageService,
|
|
[FromRoute] string artifactDigest,
|
|
[FromQuery] string? tenant,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(artifactDigest))
|
|
{
|
|
return Results.BadRequest(new { error = "artifactDigest is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.children", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenant);
|
|
activity?.SetTag("artifact_digest", artifactDigest);
|
|
|
|
var children = await lineageService.GetChildrenAsync(
|
|
artifactDigest.Trim(),
|
|
tenant.Trim(),
|
|
cancellationToken);
|
|
|
|
return Results.Ok(new { parentDigest = artifactDigest.Trim(), children });
|
|
});
|
|
|
|
app.MapGet("/api/v1/lineage/{artifactDigest}/parents", async Task<IResult> (
|
|
[FromServices] ISbomLineageGraphService lineageService,
|
|
[FromRoute] string artifactDigest,
|
|
[FromQuery] string? tenant,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(artifactDigest))
|
|
{
|
|
return Results.BadRequest(new { error = "artifactDigest is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.parents", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenant);
|
|
activity?.SetTag("artifact_digest", artifactDigest);
|
|
|
|
var parents = await lineageService.GetParentsAsync(
|
|
artifactDigest.Trim(),
|
|
tenant.Trim(),
|
|
cancellationToken);
|
|
|
|
return Results.Ok(new { childDigest = artifactDigest.Trim(), parents });
|
|
});
|
|
|
|
app.MapPost("/api/v1/lineage/export", async Task<IResult> (
|
|
[FromServices] ILineageExportService exportService,
|
|
[FromBody] LineageExportRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.FromDigest) || string.IsNullOrWhiteSpace(request.ToDigest))
|
|
{
|
|
return Results.BadRequest(new { error = "fromDigest and toDigest are required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.TenantId))
|
|
{
|
|
return Results.BadRequest(new { error = "tenantId is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.export", ActivityKind.Server);
|
|
activity?.SetTag("tenant", request.TenantId);
|
|
activity?.SetTag("from_digest", request.FromDigest);
|
|
activity?.SetTag("to_digest", request.ToDigest);
|
|
|
|
var result = await exportService.ExportAsync(request, cancellationToken);
|
|
|
|
if (result is null)
|
|
{
|
|
return Results.StatusCode(500);
|
|
}
|
|
|
|
if (result.SizeBytes > 50 * 1024 * 1024)
|
|
{
|
|
return Results.StatusCode(413); // Payload Too Large
|
|
}
|
|
|
|
return Results.Ok(result);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Lineage Compare API (LIN-BE-028)
|
|
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
|
// -----------------------------------------------------------------------------
|
|
|
|
app.MapGet("/api/v1/lineage/compare", async Task<IResult> (
|
|
[FromServices] ILineageCompareService compareService,
|
|
[FromQuery(Name = "a")] string? fromDigest,
|
|
[FromQuery(Name = "b")] string? toDigest,
|
|
[FromQuery] string? tenant,
|
|
[FromQuery] bool? includeSbomDiff,
|
|
[FromQuery] bool? includeVexDeltas,
|
|
[FromQuery] bool? includeReachabilityDeltas,
|
|
[FromQuery] bool? includeAttestations,
|
|
[FromQuery] bool? includeReplayHashes,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fromDigest) || string.IsNullOrWhiteSpace(toDigest))
|
|
{
|
|
return Results.BadRequest(new { error = "a (from digest) and b (to digest) query parameters are required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
var options = new LineageCompareOptions
|
|
{
|
|
IncludeSbomDiff = includeSbomDiff ?? true,
|
|
IncludeVexDeltas = includeVexDeltas ?? true,
|
|
IncludeReachabilityDeltas = includeReachabilityDeltas ?? true,
|
|
IncludeAttestations = includeAttestations ?? true,
|
|
IncludeReplayHashes = includeReplayHashes ?? true
|
|
};
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.compare", ActivityKind.Server);
|
|
activity?.SetTag("tenant", tenant);
|
|
activity?.SetTag("from_digest", fromDigest);
|
|
activity?.SetTag("to_digest", toDigest);
|
|
|
|
var result = await compareService.CompareAsync(
|
|
fromDigest.Trim(),
|
|
toDigest.Trim(),
|
|
tenant.Trim(),
|
|
options,
|
|
cancellationToken);
|
|
|
|
if (result is null)
|
|
{
|
|
return Results.NotFound(new { error = "comparison data not found for the specified artifacts" });
|
|
}
|
|
|
|
return Results.Ok(result);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Replay Verification API (LIN-BE-033)
|
|
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
|
// -----------------------------------------------------------------------------
|
|
|
|
app.MapPost("/api/v1/lineage/verify", async Task<IResult> (
|
|
[FromServices] IReplayVerificationService verificationService,
|
|
[FromBody] ReplayVerifyRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.ReplayHash))
|
|
{
|
|
return Results.BadRequest(new { error = "replayHash is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.TenantId))
|
|
{
|
|
return Results.BadRequest(new { error = "tenantId is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.verify", ActivityKind.Server);
|
|
activity?.SetTag("tenant", request.TenantId);
|
|
activity?.SetTag("replay_hash", request.ReplayHash.Length > 16 ? request.ReplayHash[..16] + "..." : request.ReplayHash);
|
|
|
|
var verifyRequest = new ReplayVerificationRequest
|
|
{
|
|
ReplayHash = request.ReplayHash,
|
|
TenantId = request.TenantId,
|
|
SbomDigest = request.SbomDigest,
|
|
FeedsSnapshotDigest = request.FeedsSnapshotDigest,
|
|
PolicyVersion = request.PolicyVersion,
|
|
VexVerdictsDigest = request.VexVerdictsDigest,
|
|
Timestamp = request.Timestamp,
|
|
FreezeTime = request.FreezeTime ?? true,
|
|
ReEvaluatePolicy = request.ReEvaluatePolicy ?? false
|
|
};
|
|
|
|
var result = await verificationService.VerifyAsync(verifyRequest, cancellationToken);
|
|
|
|
return Results.Ok(result);
|
|
});
|
|
|
|
app.MapPost("/api/v1/lineage/compare-drift", async Task<IResult> (
|
|
[FromServices] IReplayVerificationService verificationService,
|
|
[FromBody] CompareDriftRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.HashA) || string.IsNullOrWhiteSpace(request.HashB))
|
|
{
|
|
return Results.BadRequest(new { error = "hashA and hashB are required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.TenantId))
|
|
{
|
|
return Results.BadRequest(new { error = "tenantId is required" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("lineage.compare-drift", ActivityKind.Server);
|
|
activity?.SetTag("tenant", request.TenantId);
|
|
|
|
var result = await verificationService.CompareDriftAsync(
|
|
request.HashA,
|
|
request.HashB,
|
|
request.TenantId,
|
|
cancellationToken);
|
|
|
|
return Results.Ok(result);
|
|
});
|
|
|
|
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
|
|
[FromServices] ISbomQueryService service,
|
|
[FromRoute] string? snapshotId,
|
|
[FromQuery(Name = "tenant")] string? tenantId,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(snapshotId))
|
|
{
|
|
return Results.BadRequest(new { error = "snapshotId is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant is required" });
|
|
}
|
|
|
|
var start = Stopwatch.GetTimestamp();
|
|
var projection = await service.GetProjectionAsync(snapshotId.Trim(), tenantId.Trim(), cancellationToken);
|
|
if (projection is null)
|
|
{
|
|
return Results.NotFound(new { error = "projection not found" });
|
|
}
|
|
|
|
using var activity = SbomTracing.Source.StartActivity("sbom.projection", ActivityKind.Server);
|
|
activity?.SetTag("tenant", projection.TenantId);
|
|
activity?.SetTag("snapshotId", projection.SnapshotId);
|
|
activity?.SetTag("schema", projection.SchemaVersion);
|
|
|
|
var payload = new
|
|
{
|
|
snapshotId = projection.SnapshotId,
|
|
tenantId = projection.TenantId,
|
|
schemaVersion = projection.SchemaVersion,
|
|
hash = projection.ProjectionHash,
|
|
projection = projection.Projection
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
|
|
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new[]
|
|
{
|
|
new KeyValuePair<string, object?>("tenant", projection.TenantId)
|
|
});
|
|
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
|
|
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);
|
|
|
|
return Results.Ok(payload);
|
|
});
|
|
|
|
app.MapGet("/internal/sbom/events", async Task<IResult> (
|
|
[FromServices] ISbomEventStore store,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
using var activity = SbomTracing.Source.StartActivity("events.list", ActivityKind.Server);
|
|
var events = await store.ListAsync(cancellationToken);
|
|
SbomMetrics.EventBacklogSize.Record(events.Count);
|
|
|
|
if (events.Count > 100)
|
|
{
|
|
app.Logger.LogWarning("sbom event backlog high: {Count}", events.Count);
|
|
}
|
|
return Results.Ok(events);
|
|
});
|
|
|
|
app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
|
|
[FromServices] ISbomEventStore store,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
using var activity = SbomTracing.Source.StartActivity("asset-events.list", ActivityKind.Server);
|
|
var events = await store.ListAssetsAsync(cancellationToken);
|
|
SbomMetrics.EventBacklogSize.Record(events.Count);
|
|
|
|
if (events.Count > 100)
|
|
{
|
|
app.Logger.LogWarning("sbom asset event backlog high: {Count}", events.Count);
|
|
}
|
|
|
|
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,
|
|
[FromServices] IClock clock,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var projections = await repository.ListAsync(cancellationToken);
|
|
var published = 0;
|
|
foreach (var projection in projections)
|
|
{
|
|
var evt = new SbomVersionCreatedEvent(
|
|
projection.SnapshotId,
|
|
projection.TenantId,
|
|
projection.ProjectionHash,
|
|
projection.SchemaVersion,
|
|
clock.UtcNow);
|
|
if (await publisher.PublishVersionCreatedAsync(evt, cancellationToken))
|
|
{
|
|
published++;
|
|
}
|
|
}
|
|
|
|
SbomMetrics.EventBacklogSize.Record(published);
|
|
if (published > 0)
|
|
{
|
|
app.Logger.LogInformation("sbom events backfilled={Count}", published);
|
|
}
|
|
return Results.Ok(new { published });
|
|
});
|
|
|
|
app.MapGet("/internal/sbom/inventory", async Task<IResult> (
|
|
[FromServices] ISbomEventStore store,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
using var activity = SbomTracing.Source.StartActivity("inventory.list", ActivityKind.Server);
|
|
var items = await store.ListInventoryAsync(cancellationToken);
|
|
return Results.Ok(items);
|
|
});
|
|
|
|
app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
|
|
[FromServices] ISbomQueryService service,
|
|
[FromServices] ISbomEventStore store,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
// clear existing inventory and replay by listing projections
|
|
await store.ClearInventoryAsync(cancellationToken);
|
|
var projections = new[] { ("snap-001", "tenant-a") };
|
|
var published = 0;
|
|
foreach (var (snapshot, tenant) in projections)
|
|
{
|
|
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
|
|
published++;
|
|
}
|
|
return Results.Ok(new { published });
|
|
});
|
|
|
|
app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
|
|
[FromServices] ISbomEventStore store,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var feed = await store.ListResolverAsync(cancellationToken);
|
|
return Results.Ok(feed);
|
|
});
|
|
|
|
app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
|
|
[FromServices] ISbomEventStore store,
|
|
[FromServices] ISbomQueryService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
await store.ClearResolverAsync(cancellationToken);
|
|
var projections = new[] { ("snap-001", "tenant-a") };
|
|
foreach (var (snapshot, tenant) in projections)
|
|
{
|
|
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
|
|
}
|
|
var feed = await store.ListResolverAsync(cancellationToken);
|
|
return Results.Ok(new { published = feed.Count });
|
|
});
|
|
|
|
app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
|
|
[FromServices] ISbomEventStore store,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var feed = await store.ListResolverAsync(cancellationToken);
|
|
var lines = feed.Select(candidate => JsonSerializer.Serialize(candidate));
|
|
var ndjson = string.Join('\n', lines);
|
|
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,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant required" });
|
|
}
|
|
|
|
var sources = await repository.ListAsync(tenant.Trim(), cancellationToken);
|
|
return Results.Ok(new { tenant = tenant.Trim(), items = sources });
|
|
});
|
|
|
|
app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
|
|
RegisterOrchestratorSourceRequest request,
|
|
[FromServices] IOrchestratorRepository repository,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.TenantId))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant required" });
|
|
}
|
|
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
|
{
|
|
return Results.BadRequest(new { error = "artifactDigest required" });
|
|
}
|
|
if (string.IsNullOrWhiteSpace(request.SourceType))
|
|
{
|
|
return Results.BadRequest(new { error = "sourceType required" });
|
|
}
|
|
|
|
var source = await repository.RegisterAsync(request, cancellationToken);
|
|
return Results.Ok(source);
|
|
});
|
|
|
|
app.MapGet("/internal/orchestrator/control", async Task<IResult> (
|
|
[FromQuery] string? tenant,
|
|
[FromServices] IOrchestratorControlService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant required" });
|
|
}
|
|
|
|
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
|
|
return Results.Ok(state);
|
|
});
|
|
|
|
app.MapPost("/internal/orchestrator/control", async Task<IResult> (
|
|
OrchestratorControlRequest request,
|
|
[FromServices] IOrchestratorControlService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.TenantId))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant required" });
|
|
}
|
|
|
|
var updated = await service.UpdateAsync(request, cancellationToken);
|
|
return Results.Ok(updated);
|
|
});
|
|
|
|
app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
|
|
[FromQuery] string? tenant,
|
|
[FromServices] IWatermarkService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant required" });
|
|
}
|
|
|
|
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
|
|
return Results.Ok(state);
|
|
});
|
|
|
|
app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
|
|
[FromQuery] string? tenant,
|
|
[FromQuery] string? watermark,
|
|
[FromServices] IWatermarkService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenant))
|
|
{
|
|
return Results.BadRequest(new { error = "tenant required" });
|
|
}
|
|
|
|
var updated = await service.SetAsync(tenant.Trim(), watermark ?? string.Empty, cancellationToken);
|
|
return Results.Ok(updated);
|
|
});
|
|
|
|
app.Run();
|
|
|
|
// Program class in namespace to avoid conflicts with other assemblies
|
|
namespace StellaOps.SbomService
|
|
{
|
|
public partial class Program;
|
|
}
|