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.System); builder.Services.AddSingleton(SystemGuidProvider.Instance); builder.Services.AddAuthentication(HeaderAuthenticationHandler.SchemeName) .AddScheme(HeaderAuthenticationHandler.SchemeName, _ => { }); builder.Services.AddAuthorization(); // Register SBOM query services using file-backed fixtures when present; fallback to in-memory seeds. builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); var env = sp.GetRequiredService(); var configured = config.GetValue("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(sp => { var config = sp.GetRequiredService(); var env = sp.GetRequiredService(); var configured = config.GetValue("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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new OrchestratorControlService( sp.GetRequiredService(), SbomMetrics.Meter)); builder.Services.AddSingleton(); builder.Services.AddOptions() .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() .Bind(builder.Configuration.GetSection($"SbomService:{RegistryHttpOptions.SectionName}")) .ValidateDataAnnotations() .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection($"SbomService:{ScannerHttpOptions.SectionName}")) .ValidateDataAnnotations() .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection($"SbomService:{RegistrySourceQueryOptions.SectionName}")) .ValidateDataAnnotations() .ValidateOnStart(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Lineage graph services (LIN-BE-013) builder.Services.AddSingleton(); // LIN-BE-015: Hover card cache for <150ms response times // Use distributed cache if configured, otherwise in-memory builder.Services.AddOptions() .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("UseDistributed")) { // Expects IDistributedCache to be registered (e.g., Valkey/Redis) builder.Services.AddSingleton(); } else { builder.Services.AddSingleton(); } builder.Services.AddSingleton(); // LIN-BE-028: Lineage compare service builder.Services.AddSingleton(); // LIN-010: Lineage export service for evidence packs builder.Services.AddSingleton(); // LIN-BE-023: Replay hash service builder.Services.AddSingleton(); // LIN-BE-033: Replay verification service builder.Services.AddSingleton(); // LIN-BE-034: Compare cache with TTL and VEX invalidation builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("SbomService:CompareCache")) .ValidateDataAnnotations() .ValidateOnStart(); builder.Services.AddSingleton(); // REG-SRC: Registry source management (SPRINT_20251229_012) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("RegistryDiscovery", (sp, client) => { var options = sp.GetRequiredService>().Value; if (options.Timeout > TimeSpan.Zero && options.Timeout != Timeout.InfiniteTimeSpan) { client.Timeout = options.Timeout; } }); builder.Services.AddHttpClient("Scanner", (sp, client) => { var options = sp.GetRequiredService>().Value; if (options.Timeout > TimeSpan.Zero && options.Timeout != Timeout.InfiniteTimeSpan) { client.Timeout = options.Timeout; } }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); var env = sp.GetRequiredService(); var configured = config.GetValue("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 ( [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 ( [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 ( [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("scope", scope ?? string.Empty), new KeyValuePair("env", string.Empty) }); SbomMetrics.PathsQueryTotal.Add(1, new[] { new KeyValuePair("cache_hit", result.CacheHit), new KeyValuePair("scope", scope ?? string.Empty) }); return Results.Ok(result.Result); }); app.MapGet("/components/lookup", async Task ( [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("scope", string.Empty), new KeyValuePair("env", string.Empty) }); SbomMetrics.PathsQueryTotal.Add(1, new[] { new KeyValuePair("cache_hit", result.CacheHit), new KeyValuePair("scope", string.Empty) }); return Results.Ok(result.Result); }); app.MapGet("/sbom/context", async Task ( [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 versions = Array.Empty(); if (timelineLimit > 0) { var timeline = await service.GetTimelineAsync( new SbomTimelineQuery(normalizedArtifact, timelineLimit, 0), cancellationToken); versions = timeline.Result.Versions; } IReadOnlyList dependencyPaths = Array.Empty(); 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 ( [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(); 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("scope", scope ?? string.Empty), new KeyValuePair("env", environment ?? string.Empty) }); SbomMetrics.PathsQueryTotal.Add(1, new[] { new KeyValuePair("cache_hit", result.CacheHit), new KeyValuePair("scope", scope ?? string.Empty) }); return Results.Ok(result.Result); }); app.MapGet("/sbom/versions", async Task ( [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("artifact", artifact) }); SbomMetrics.TimelineQueryTotal.Add(1, new[] { new KeyValuePair("artifact", artifact), new KeyValuePair("cache_hit", result.CacheHit) }); return Results.Ok(result.Result); }); var sbomUploadHandler = async Task ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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("tenant", projection.TenantId) }); SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, new[] { new KeyValuePair("tenant", projection.TenantId) }); SbomMetrics.ProjectionQueryTotal.Add(1, new[] { new KeyValuePair("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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [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 ( [FromServices] ISbomEventStore store, CancellationToken cancellationToken) => { var feed = await store.ListResolverAsync(cancellationToken); return Results.Ok(feed); }); app.MapPost("/internal/sbom/resolver-feed/backfill", async Task ( [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 ( [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 ( [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 ( [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 ( 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 ( [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 ( 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 ( [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 ( [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; }