Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Program.cs
2026-02-01 21:37:40 +02:00

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;
}