// ----------------------------------------------------------------------------- // SbomEndpointExtensions.cs // Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring // Tasks: SBOM-8200-022 through SBOM-8200-024 // Description: API endpoints for SBOM registration and learning // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Mvc; using StellaOps.Concelier.SbomIntegration; using StellaOps.Concelier.SbomIntegration.Models; using HttpResults = Microsoft.AspNetCore.Http.Results; namespace StellaOps.Concelier.WebService.Extensions; /// /// Endpoint extensions for SBOM operations. /// internal static class SbomEndpointExtensions { public static void MapSbomEndpoints(this WebApplication app) { var group = app.MapGroup("/api/v1") .WithTags("SBOM Learning"); // POST /api/v1/learn/sbom - Register and learn from an SBOM group.MapPost("/learn/sbom", async ( [FromBody] LearnSbomRequest request, ISbomRegistryService registryService, CancellationToken ct) => { var input = new SbomRegistrationInput { Digest = request.SbomDigest, Format = ParseSbomFormat(request.Format), SpecVersion = request.SpecVersion ?? "1.6", PrimaryName = request.PrimaryName, PrimaryVersion = request.PrimaryVersion, Purls = request.Purls, Source = request.Source ?? "api", TenantId = request.TenantId, ReachabilityMap = request.ReachabilityMap, DeploymentMap = request.DeploymentMap }; var result = await registryService.LearnSbomAsync(input, ct).ConfigureAwait(false); return HttpResults.Ok(new SbomLearnResponse { SbomDigest = result.Registration.Digest, SbomId = result.Registration.Id, ComponentsProcessed = result.Registration.ComponentCount, AdvisoriesMatched = result.Matches.Count, ScoresUpdated = result.ScoresUpdated, ProcessingTimeMs = result.ProcessingTimeMs }); }) .WithName("LearnSbom") .WithSummary("Register SBOM and update interest scores for affected advisories") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); // GET /api/v1/sboms/{digest}/affected - Get advisories affecting an SBOM group.MapGet("/sboms/{digest}/affected", async ( string digest, ISbomRegistryService registryService, CancellationToken ct) => { var registration = await registryService.GetByDigestAsync(digest, ct).ConfigureAwait(false); if (registration is null) { return HttpResults.NotFound(new { error = "SBOM not found", digest }); } var matches = await registryService.GetMatchesAsync(digest, ct).ConfigureAwait(false); return HttpResults.Ok(new SbomAffectedResponse { SbomDigest = digest, SbomId = registration.Id, PrimaryName = registration.PrimaryName, PrimaryVersion = registration.PrimaryVersion, ComponentCount = registration.ComponentCount, AffectedCount = matches.Count, Matches = matches.Select(m => new SbomMatchInfo { CanonicalId = m.CanonicalId, Purl = m.Purl, IsReachable = m.IsReachable, IsDeployed = m.IsDeployed, Confidence = m.Confidence, Method = m.Method.ToString(), MatchedAt = m.MatchedAt }).ToList() }); }) .WithName("GetSbomAffected") .WithSummary("Get advisories affecting an SBOM") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // GET /api/v1/sboms - List registered SBOMs group.MapGet("/sboms", async ( [FromQuery] int? offset, [FromQuery] int? limit, [FromQuery] string? tenantId, ISbomRegistryService registryService, CancellationToken ct) => { var registrations = await registryService.ListAsync( offset ?? 0, limit ?? 50, tenantId, ct).ConfigureAwait(false); var count = await registryService.CountAsync(tenantId, ct).ConfigureAwait(false); return HttpResults.Ok(new SbomListResponse { Items = registrations.Select(r => new SbomSummary { Id = r.Id, Digest = r.Digest, Format = r.Format.ToString(), PrimaryName = r.PrimaryName, PrimaryVersion = r.PrimaryVersion, ComponentCount = r.ComponentCount, AffectedCount = r.AffectedCount, RegisteredAt = r.RegisteredAt, LastMatchedAt = r.LastMatchedAt }).ToList(), TotalCount = count, Offset = offset ?? 0, Limit = limit ?? 50 }); }) .WithName("ListSboms") .WithSummary("List registered SBOMs with pagination") .Produces(StatusCodes.Status200OK); // GET /api/v1/sboms/{digest} - Get SBOM registration details group.MapGet("/sboms/{digest}", async ( string digest, ISbomRegistryService registryService, CancellationToken ct) => { var registration = await registryService.GetByDigestAsync(digest, ct).ConfigureAwait(false); if (registration is null) { return HttpResults.NotFound(new { error = "SBOM not found", digest }); } return HttpResults.Ok(new SbomDetailResponse { Id = registration.Id, Digest = registration.Digest, Format = registration.Format.ToString(), SpecVersion = registration.SpecVersion, PrimaryName = registration.PrimaryName, PrimaryVersion = registration.PrimaryVersion, ComponentCount = registration.ComponentCount, AffectedCount = registration.AffectedCount, Source = registration.Source, TenantId = registration.TenantId, RegisteredAt = registration.RegisteredAt, LastMatchedAt = registration.LastMatchedAt }); }) .WithName("GetSbom") .WithSummary("Get SBOM registration details") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // DELETE /api/v1/sboms/{digest} - Unregister an SBOM group.MapDelete("/sboms/{digest}", async ( string digest, ISbomRegistryService registryService, CancellationToken ct) => { await registryService.UnregisterAsync(digest, ct).ConfigureAwait(false); return HttpResults.NoContent(); }) .WithName("UnregisterSbom") .WithSummary("Unregister an SBOM") .Produces(StatusCodes.Status204NoContent); // POST /api/v1/sboms/{digest}/rematch - Rematch SBOM against current advisories group.MapPost("/sboms/{digest}/rematch", async ( string digest, ISbomRegistryService registryService, CancellationToken ct) => { try { var result = await registryService.RematchSbomAsync(digest, ct).ConfigureAwait(false); return HttpResults.Ok(new SbomRematchResponse { SbomDigest = digest, PreviousAffectedCount = result.Registration.AffectedCount, NewAffectedCount = result.Matches.Count, ProcessingTimeMs = result.ProcessingTimeMs }); } catch (InvalidOperationException ex) when (ex.Message.Contains("not found")) { return HttpResults.NotFound(new { error = ex.Message }); } }) .WithName("RematchSbom") .WithSummary("Re-match SBOM against current advisory database") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // GET /api/v1/sboms/stats - Get SBOM registry statistics group.MapGet("/sboms/stats", async ( [FromQuery] string? tenantId, ISbomRegistryService registryService, CancellationToken ct) => { var stats = await registryService.GetStatsAsync(tenantId, ct).ConfigureAwait(false); return HttpResults.Ok(new SbomStatsResponse { TotalSboms = stats.TotalSboms, TotalPurls = stats.TotalPurls, TotalMatches = stats.TotalMatches, AffectedSboms = stats.AffectedSboms, AverageMatchesPerSbom = stats.AverageMatchesPerSbom }); }) .WithName("GetSbomStats") .WithSummary("Get SBOM registry statistics") .Produces(StatusCodes.Status200OK); } private static SbomFormat ParseSbomFormat(string? format) { return format?.ToLowerInvariant() switch { "cyclonedx" => SbomFormat.CycloneDX, "spdx" => SbomFormat.SPDX, _ => SbomFormat.CycloneDX }; } } #region Request/Response DTOs public sealed record LearnSbomRequest { public required string SbomDigest { get; init; } public string? Format { get; init; } public string? SpecVersion { get; init; } public string? PrimaryName { get; init; } public string? PrimaryVersion { get; init; } public required IReadOnlyList Purls { get; init; } public string? Source { get; init; } public string? TenantId { get; init; } public IReadOnlyDictionary? ReachabilityMap { get; init; } public IReadOnlyDictionary? DeploymentMap { get; init; } } public sealed record SbomLearnResponse { public required string SbomDigest { get; init; } public Guid SbomId { get; init; } public int ComponentsProcessed { get; init; } public int AdvisoriesMatched { get; init; } public int ScoresUpdated { get; init; } public double ProcessingTimeMs { get; init; } } public sealed record SbomAffectedResponse { public required string SbomDigest { get; init; } public Guid SbomId { get; init; } public string? PrimaryName { get; init; } public string? PrimaryVersion { get; init; } public int ComponentCount { get; init; } public int AffectedCount { get; init; } public required IReadOnlyList Matches { get; init; } } public sealed record SbomMatchInfo { public Guid CanonicalId { get; init; } public required string Purl { get; init; } public bool IsReachable { get; init; } public bool IsDeployed { get; init; } public double Confidence { get; init; } public required string Method { get; init; } public DateTimeOffset MatchedAt { get; init; } } public sealed record SbomListResponse { public required IReadOnlyList Items { get; init; } public long TotalCount { get; init; } public int Offset { get; init; } public int Limit { get; init; } } public sealed record SbomSummary { public Guid Id { get; init; } public required string Digest { get; init; } public required string Format { get; init; } public string? PrimaryName { get; init; } public string? PrimaryVersion { get; init; } public int ComponentCount { get; init; } public int AffectedCount { get; init; } public DateTimeOffset RegisteredAt { get; init; } public DateTimeOffset? LastMatchedAt { get; init; } } public sealed record SbomDetailResponse { public Guid Id { get; init; } public required string Digest { get; init; } public required string Format { get; init; } public required string SpecVersion { get; init; } public string? PrimaryName { get; init; } public string? PrimaryVersion { get; init; } public int ComponentCount { get; init; } public int AffectedCount { get; init; } public required string Source { get; init; } public string? TenantId { get; init; } public DateTimeOffset RegisteredAt { get; init; } public DateTimeOffset? LastMatchedAt { get; init; } } public sealed record SbomRematchResponse { public required string SbomDigest { get; init; } public int PreviousAffectedCount { get; init; } public int NewAffectedCount { get; init; } public double ProcessingTimeMs { get; init; } } public sealed record SbomStatsResponse { public long TotalSboms { get; init; } public long TotalPurls { get; init; } public long TotalMatches { get; init; } public long AffectedSboms { get; init; } public double AverageMatchesPerSbom { get; init; } } #endregion