351 lines
13 KiB
C#
351 lines
13 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Endpoint extensions for SBOM operations.
|
|
/// </summary>
|
|
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<SbomLearnResponse>(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<SbomAffectedResponse>(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<SbomListResponse>(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<SbomDetailResponse>(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<SbomRematchResponse>(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<SbomStatsResponse>(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<string> Purls { get; init; }
|
|
public string? Source { get; init; }
|
|
public string? TenantId { get; init; }
|
|
public IReadOnlyDictionary<string, bool>? ReachabilityMap { get; init; }
|
|
public IReadOnlyDictionary<string, bool>? 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<SbomMatchInfo> 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<SbomSummary> 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
|