// -----------------------------------------------------------------------------
// 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