save checkpoint
This commit is contained in:
@@ -221,6 +221,107 @@ public sealed record SbomUploadRecordDto
|
||||
public DateTimeOffset CreatedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Latest-by-payload hot lookup response row.
|
||||
/// </summary>
|
||||
public sealed record SbomHotLookupLatestResponseDto
|
||||
{
|
||||
[JsonPropertyName("buildId")]
|
||||
public string BuildId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("canonicalBomSha256")]
|
||||
public string CanonicalBomSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadDigest")]
|
||||
public string PayloadDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("insertedAtUtc")]
|
||||
public DateTimeOffset InsertedAtUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceScore")]
|
||||
public int EvidenceScore { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorTileId")]
|
||||
public string? RekorTileId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared hot lookup row for component search.
|
||||
/// </summary>
|
||||
public sealed record SbomHotLookupComponentItemDto
|
||||
{
|
||||
[JsonPropertyName("buildId")]
|
||||
public string BuildId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("canonicalBomSha256")]
|
||||
public string CanonicalBomSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadDigest")]
|
||||
public string PayloadDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("insertedAtUtc")]
|
||||
public DateTimeOffset InsertedAtUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceScore")]
|
||||
public int EvidenceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component search response with bounded pagination.
|
||||
/// </summary>
|
||||
public sealed record SbomHotLookupComponentSearchResponseDto
|
||||
{
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomHotLookupComponentItemDto> Items { get; init; }
|
||||
= Array.Empty<SbomHotLookupComponentItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pending triage row from merged VEX projection.
|
||||
/// </summary>
|
||||
public sealed record SbomHotLookupPendingItemDto
|
||||
{
|
||||
[JsonPropertyName("buildId")]
|
||||
public string BuildId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("canonicalBomSha256")]
|
||||
public string CanonicalBomSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadDigest")]
|
||||
public string PayloadDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("insertedAtUtc")]
|
||||
public DateTimeOffset InsertedAtUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceScore")]
|
||||
public int EvidenceScore { get; init; }
|
||||
|
||||
[JsonPropertyName("pending")]
|
||||
public JsonElement Pending { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pending triage search response with bounded pagination.
|
||||
/// </summary>
|
||||
public sealed record SbomHotLookupPendingSearchResponseDto
|
||||
{
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomHotLookupPendingItemDto> Items { get; init; }
|
||||
= Array.Empty<SbomHotLookupPendingItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format types.
|
||||
/// </summary>
|
||||
|
||||
@@ -133,6 +133,8 @@ internal static class SbomEndpoints
|
||||
sbomDocument,
|
||||
format,
|
||||
contentDigest,
|
||||
snapshot.Target.Digest,
|
||||
parsed.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
sbomDocument.Dispose();
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class SbomHotLookupEndpoints
|
||||
{
|
||||
public static void MapSbomHotLookupEndpoints(this RouteGroupBuilder sbomGroup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbomGroup);
|
||||
|
||||
var hotLookup = sbomGroup.MapGroup("/hot-lookup");
|
||||
|
||||
hotLookup.MapGet("/payload/{payloadDigest}/latest", HandleGetLatestByPayloadDigestAsync)
|
||||
.WithName("scanner.sbom.hotlookup.latest-by-payload")
|
||||
.WithTags("SBOM")
|
||||
.Produces<SbomHotLookupLatestResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
hotLookup.MapGet("/components", HandleSearchComponentsAsync)
|
||||
.WithName("scanner.sbom.hotlookup.components")
|
||||
.WithTags("SBOM")
|
||||
.Produces<SbomHotLookupComponentSearchResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
hotLookup.MapGet("/pending-triage", HandleSearchPendingTriageAsync)
|
||||
.WithName("scanner.sbom.hotlookup.pending-triage")
|
||||
.WithTags("SBOM")
|
||||
.Produces<SbomHotLookupPendingSearchResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetLatestByPayloadDigestAsync(
|
||||
string payloadDigest,
|
||||
ISbomHotLookupService hotLookupService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hotLookupService);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payloadDigest))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid payload digest",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "payloadDigest is required.");
|
||||
}
|
||||
|
||||
var latest = await hotLookupService
|
||||
.GetLatestByPayloadDigestAsync(payloadDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (latest is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"No SBOM projection found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "No artifact_boms projection row exists for the provided payload digest.");
|
||||
}
|
||||
|
||||
return Results.Ok(latest);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSearchComponentsAsync(
|
||||
string? purl,
|
||||
string? name,
|
||||
string? minVersion,
|
||||
int limit,
|
||||
int offset,
|
||||
ISbomHotLookupService hotLookupService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hotLookupService);
|
||||
|
||||
var hasPurl = !string.IsNullOrWhiteSpace(purl);
|
||||
var hasName = !string.IsNullOrWhiteSpace(name);
|
||||
|
||||
if (!hasPurl && !hasName)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid component query",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Provide either 'purl' or 'name' query parameter.");
|
||||
}
|
||||
|
||||
if (hasPurl && hasName)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Ambiguous component query",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Use either 'purl' or 'name', not both.");
|
||||
}
|
||||
|
||||
if (!SbomHotLookupService.IsLimitValid(limit))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid limit",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "limit must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (!SbomHotLookupService.IsOffsetValid(offset))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offset",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "offset must be greater than or equal to 0.");
|
||||
}
|
||||
|
||||
var result = await hotLookupService
|
||||
.SearchComponentsAsync(purl, name, minVersion, limit, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSearchPendingTriageAsync(
|
||||
int limit,
|
||||
int offset,
|
||||
ISbomHotLookupService hotLookupService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hotLookupService);
|
||||
|
||||
if (!SbomHotLookupService.IsLimitValid(limit))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid limit",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "limit must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (!SbomHotLookupService.IsOffsetValid(offset))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid offset",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "offset must be greater than or equal to 0.");
|
||||
}
|
||||
|
||||
var result = await hotLookupService
|
||||
.SearchPendingTriageAsync(limit, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ internal static class SbomUploadEndpoints
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
sbomGroup.MapSbomHotLookupEndpoints();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleUploadAsync(
|
||||
|
||||
@@ -158,6 +158,7 @@ builder.Services.AddSingleton<IAttestationChainVerifier, AttestationChainVerifie
|
||||
builder.Services.AddSingleton<IHumanApprovalAttestationService, HumanApprovalAttestationService>();
|
||||
builder.Services.AddScoped<ICallGraphIngestionService, CallGraphIngestionService>();
|
||||
builder.Services.AddScoped<ISbomIngestionService, SbomIngestionService>();
|
||||
builder.Services.AddScoped<ISbomHotLookupService, SbomHotLookupService>();
|
||||
builder.Services.AddScoped<ILayerSbomService, LayerSbomService>();
|
||||
builder.Services.AddSingleton<ISbomUploadStore, InMemorySbomUploadStore>();
|
||||
builder.Services.AddScoped<ISbomByosUploadService, SbomByosUploadService>();
|
||||
|
||||
@@ -27,6 +27,8 @@ public interface ISbomIngestionService
|
||||
JsonDocument sbomDocument,
|
||||
string format,
|
||||
string? contentDigest,
|
||||
string? payloadDigest,
|
||||
string? buildId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -121,7 +121,14 @@ internal sealed class SbomByosUploadService : ISbomByosUploadService
|
||||
var scanId = ScanIdGenerator.Create(target, force: false, clientRequestId: null, metadata);
|
||||
|
||||
var ingestion = await _ingestionService
|
||||
.IngestAsync(scanId, document, format, digest, cancellationToken)
|
||||
.IngestAsync(
|
||||
scanId,
|
||||
document,
|
||||
format,
|
||||
digest,
|
||||
target.Digest,
|
||||
request.Source?.CiContext?.BuildId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var submission = new ScanSubmission(target, false, null, metadata);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface ISbomHotLookupService
|
||||
{
|
||||
Task<SbomHotLookupLatestResponseDto?> GetLatestByPayloadDigestAsync(
|
||||
string payloadDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SbomHotLookupComponentSearchResponseDto> SearchComponentsAsync(
|
||||
string? purl,
|
||||
string? name,
|
||||
string? minVersion,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SbomHotLookupPendingSearchResponseDto> SearchPendingTriageAsync(
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class SbomHotLookupService : ISbomHotLookupService
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int DefaultPendingLimit = 100;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
private readonly IArtifactBomRepository _repository;
|
||||
|
||||
public SbomHotLookupService(IArtifactBomRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task<SbomHotLookupLatestResponseDto?> GetLatestByPayloadDigestAsync(
|
||||
string payloadDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(payloadDigest);
|
||||
|
||||
var row = await _repository
|
||||
.TryGetLatestByPayloadDigestAsync(payloadDigest.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is null ? null : MapLatest(row);
|
||||
}
|
||||
|
||||
public async Task<SbomHotLookupComponentSearchResponseDto> SearchComponentsAsync(
|
||||
string? purl,
|
||||
string? name,
|
||||
string? minVersion,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit, DefaultLimit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
IReadOnlyList<ArtifactBomRow> rows;
|
||||
if (!string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
rows = await _repository
|
||||
.FindByComponentPurlAsync(
|
||||
purl.Trim(),
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
rows = await _repository
|
||||
.FindByComponentNameAsync(
|
||||
name!.Trim().ToLowerInvariant(),
|
||||
minVersion,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SbomHotLookupComponentSearchResponseDto
|
||||
{
|
||||
Limit = normalizedLimit,
|
||||
Offset = normalizedOffset,
|
||||
Items = rows.Select(MapComponentItem).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SbomHotLookupPendingSearchResponseDto> SearchPendingTriageAsync(
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit, DefaultPendingLimit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
var rows = await _repository
|
||||
.FindPendingTriageAsync(normalizedLimit, normalizedOffset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SbomHotLookupPendingSearchResponseDto
|
||||
{
|
||||
Limit = normalizedLimit,
|
||||
Offset = normalizedOffset,
|
||||
Items = rows.Select(MapPendingItem).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsLimitValid(int limit)
|
||||
=> limit == 0 || (limit >= 1 && limit <= MaxLimit);
|
||||
|
||||
public static bool IsOffsetValid(int offset)
|
||||
=> offset >= 0;
|
||||
|
||||
private static int NormalizeLimit(int requestedLimit, int fallback)
|
||||
{
|
||||
if (requestedLimit <= 0)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Min(requestedLimit, MaxLimit);
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int requestedOffset)
|
||||
=> requestedOffset < 0 ? 0 : requestedOffset;
|
||||
|
||||
private static SbomHotLookupLatestResponseDto MapLatest(ArtifactBomRow row)
|
||||
{
|
||||
return new SbomHotLookupLatestResponseDto
|
||||
{
|
||||
BuildId = row.BuildId,
|
||||
CanonicalBomSha256 = row.CanonicalBomSha256,
|
||||
PayloadDigest = row.PayloadDigest,
|
||||
InsertedAtUtc = row.InsertedAt.ToUniversalTime(),
|
||||
EvidenceScore = row.EvidenceScore,
|
||||
RekorTileId = row.RekorTileId
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomHotLookupComponentItemDto MapComponentItem(ArtifactBomRow row)
|
||||
{
|
||||
return new SbomHotLookupComponentItemDto
|
||||
{
|
||||
BuildId = row.BuildId,
|
||||
CanonicalBomSha256 = row.CanonicalBomSha256,
|
||||
PayloadDigest = row.PayloadDigest,
|
||||
InsertedAtUtc = row.InsertedAt.ToUniversalTime(),
|
||||
EvidenceScore = row.EvidenceScore
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomHotLookupPendingItemDto MapPendingItem(ArtifactBomRow row)
|
||||
{
|
||||
return new SbomHotLookupPendingItemDto
|
||||
{
|
||||
BuildId = row.BuildId,
|
||||
CanonicalBomSha256 = row.CanonicalBomSha256,
|
||||
PayloadDigest = row.PayloadDigest,
|
||||
InsertedAtUtc = row.InsertedAt.ToUniversalTime(),
|
||||
EvidenceScore = row.EvidenceScore,
|
||||
Pending = ParsePendingJson(row.PendingMergedVexJson)
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement ParsePendingJson(string? pendingJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pendingJson))
|
||||
{
|
||||
using var empty = JsonDocument.Parse("[]");
|
||||
return empty.RootElement.Clone();
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(pendingJson);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
@@ -16,11 +19,19 @@ internal sealed class SbomIngestionService : ISbomIngestionService
|
||||
};
|
||||
|
||||
private readonly ArtifactStorageService _artifactStorage;
|
||||
private readonly IArtifactBomRepository _artifactBomRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SbomIngestionService> _logger;
|
||||
|
||||
public SbomIngestionService(ArtifactStorageService artifactStorage, ILogger<SbomIngestionService> logger)
|
||||
public SbomIngestionService(
|
||||
ArtifactStorageService artifactStorage,
|
||||
IArtifactBomRepository artifactBomRepository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SbomIngestionService> logger)
|
||||
{
|
||||
_artifactStorage = artifactStorage ?? throw new ArgumentNullException(nameof(artifactStorage));
|
||||
_artifactBomRepository = artifactBomRepository ?? throw new ArgumentNullException(nameof(artifactBomRepository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -96,6 +107,8 @@ internal sealed class SbomIngestionService : ISbomIngestionService
|
||||
JsonDocument sbomDocument,
|
||||
string format,
|
||||
string? contentDigest,
|
||||
string? payloadDigest,
|
||||
string? buildId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbomDocument);
|
||||
@@ -126,20 +139,49 @@ internal sealed class SbomIngestionService : ISbomIngestionService
|
||||
stored.BytesSha256);
|
||||
}
|
||||
|
||||
var componentCount = CountComponents(sbomDocument, format);
|
||||
var canonical = BuildCanonicalProjection(sbomDocument.RootElement, format);
|
||||
var canonicalBomJson = SerializeCanonicalProjection(canonical);
|
||||
var canonicalBomSha256 = ComputeSha256Digest(canonicalBomJson);
|
||||
var mergedVexJson = ExtractMergedVexProjection(sbomDocument.RootElement, format);
|
||||
var attestationsJson = ExtractAttestationsProjection(sbomDocument.RootElement);
|
||||
var rekorTileId = ExtractRekorTileId(sbomDocument.RootElement);
|
||||
var projectionInsertedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var projectionRow = new ArtifactBomRow
|
||||
{
|
||||
BuildId = NormalizeBuildId(buildId, scanId),
|
||||
CanonicalBomSha256 = canonicalBomSha256,
|
||||
PayloadDigest = NormalizePayloadDigest(payloadDigest, scanId),
|
||||
InsertedAt = projectionInsertedAt,
|
||||
RawBomRef = stored.Id,
|
||||
CanonicalBomRef = stored.Id,
|
||||
DsseEnvelopeRef = null,
|
||||
MergedVexRef = string.IsNullOrWhiteSpace(mergedVexJson) ? null : stored.Id,
|
||||
CanonicalBomJson = canonicalBomJson,
|
||||
MergedVexJson = mergedVexJson,
|
||||
AttestationsJson = attestationsJson,
|
||||
EvidenceScore = ComputeEvidenceScore(
|
||||
canonical.Components.Count,
|
||||
canonical.ComponentsWithPurl,
|
||||
!string.IsNullOrWhiteSpace(mergedVexJson)),
|
||||
RekorTileId = rekorTileId
|
||||
};
|
||||
|
||||
await _artifactBomRepository.UpsertMonthlyAsync(projectionRow, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ingested sbom scan={ScanId} format={Format} components={Components} digest={Digest} id={SbomId}",
|
||||
"Ingested sbom scan={ScanId} format={Format} components={Components} digest={Digest} id={SbomId} payloadDigest={PayloadDigest}",
|
||||
scanId.Value,
|
||||
format,
|
||||
componentCount,
|
||||
canonical.Components.Count,
|
||||
stored.BytesSha256,
|
||||
stored.Id);
|
||||
stored.Id,
|
||||
projectionRow.PayloadDigest);
|
||||
|
||||
return new SbomIngestionResult(
|
||||
SbomId: stored.Id,
|
||||
Format: format,
|
||||
ComponentCount: componentCount,
|
||||
ComponentCount: canonical.Components.Count,
|
||||
Digest: stored.BytesSha256);
|
||||
}
|
||||
|
||||
@@ -158,36 +200,460 @@ internal sealed class SbomIngestionService : ISbomIngestionService
|
||||
return (ArtifactDocumentFormat.CycloneDxJson, "application/json");
|
||||
}
|
||||
|
||||
private static int CountComponents(JsonDocument document, string format)
|
||||
private static CanonicalProjection BuildCanonicalProjection(JsonElement root, string format)
|
||||
{
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
var components = string.Equals(format, SbomFormats.Spdx, StringComparison.OrdinalIgnoreCase)
|
||||
? ExtractSpdxComponents(root)
|
||||
: ExtractCycloneDxComponents(root);
|
||||
|
||||
var root = document.RootElement;
|
||||
var ordered = components
|
||||
.OrderBy(component => component.Purl ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.Name, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.Version ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (string.Equals(format, SbomFormats.CycloneDx, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (root.TryGetProperty("components", out var components) && components.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return components.GetArrayLength();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (string.Equals(format, SbomFormats.Spdx, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (root.TryGetProperty("packages", out var packages) && packages.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return packages.GetArrayLength();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return new CanonicalProjection(
|
||||
Format: format.Trim().ToLowerInvariant(),
|
||||
Components: ordered,
|
||||
ComponentsWithPurl: ordered.Count(component => !string.IsNullOrWhiteSpace(component.Purl)));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CanonicalComponent> ExtractCycloneDxComponents(JsonElement root)
|
||||
{
|
||||
if (!TryGetPropertyCaseInsensitive(root, "components", out var components)
|
||||
|| components.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<CanonicalComponent>();
|
||||
}
|
||||
|
||||
var result = new List<CanonicalComponent>();
|
||||
foreach (var component in components.EnumerateArray())
|
||||
{
|
||||
if (component.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var purl = NormalizeOptionalString(GetString(component, "purl"), toLower: true);
|
||||
var name = NormalizeOptionalString(GetString(component, "name"), toLower: true);
|
||||
var version = NormalizeOptionalString(GetString(component, "version"), toLower: false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(purl) && string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new CanonicalComponent(
|
||||
Name: name ?? string.Empty,
|
||||
Version: version,
|
||||
Purl: purl));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CanonicalComponent> ExtractSpdxComponents(JsonElement root)
|
||||
{
|
||||
if (!TryGetPropertyCaseInsensitive(root, "packages", out var packages)
|
||||
|| packages.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<CanonicalComponent>();
|
||||
}
|
||||
|
||||
var result = new List<CanonicalComponent>();
|
||||
foreach (var package in packages.EnumerateArray())
|
||||
{
|
||||
if (package.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var purl = NormalizeOptionalString(ExtractSpdxPurl(package), toLower: true);
|
||||
var name = NormalizeOptionalString(GetString(package, "name"), toLower: true);
|
||||
var version = NormalizeOptionalString(GetString(package, "versionInfo"), toLower: false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(purl) && string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new CanonicalComponent(
|
||||
Name: name ?? string.Empty,
|
||||
Version: version,
|
||||
Purl: purl));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? ExtractSpdxPurl(JsonElement package)
|
||||
{
|
||||
if (!TryGetPropertyCaseInsensitive(package, "externalRefs", out var references)
|
||||
|| references.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var reference in references.EnumerateArray())
|
||||
{
|
||||
if (reference.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var referenceType = GetString(reference, "referenceType");
|
||||
if (!string.Equals(referenceType, "purl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return GetString(reference, "referenceLocator");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SerializeCanonicalProjection(CanonicalProjection canonical)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream);
|
||||
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("format", canonical.Format);
|
||||
writer.WriteStartArray("components");
|
||||
foreach (var component in canonical.Components)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", component.Name);
|
||||
if (!string.IsNullOrWhiteSpace(component.Version))
|
||||
{
|
||||
writer.WriteString("version", component.Version);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(component.Purl))
|
||||
{
|
||||
writer.WriteString("purl", component.Purl);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ExtractMergedVexProjection(JsonElement root, string format)
|
||||
{
|
||||
if (TryGetPropertyCaseInsensitive(root, "merged_vex", out var mergedVex))
|
||||
{
|
||||
return CanonicalizeJson(mergedVex);
|
||||
}
|
||||
|
||||
if (TryGetPropertyCaseInsensitive(root, "mergedVex", out mergedVex))
|
||||
{
|
||||
return CanonicalizeJson(mergedVex);
|
||||
}
|
||||
|
||||
if (!string.Equals(format, SbomFormats.CycloneDx, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!TryGetPropertyCaseInsensitive(root, "vulnerabilities", out var vulnerabilities)
|
||||
|| vulnerabilities.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entries = new List<MergedVexEntry>();
|
||||
foreach (var vulnerability in vulnerabilities.EnumerateArray())
|
||||
{
|
||||
if (vulnerability.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vulnerabilityId = NormalizeOptionalString(
|
||||
GetString(vulnerability, "id"),
|
||||
toLower: false);
|
||||
|
||||
string state = "unknown";
|
||||
if (TryGetPropertyCaseInsensitive(vulnerability, "analysis", out var analysis)
|
||||
&& analysis.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
state = NormalizeOptionalString(GetString(analysis, "state"), toLower: true) ?? "unknown";
|
||||
}
|
||||
|
||||
var affected = new List<string>();
|
||||
if (TryGetPropertyCaseInsensitive(vulnerability, "affects", out var affects)
|
||||
&& affects.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var affectedRef in affects.EnumerateArray())
|
||||
{
|
||||
if (affectedRef.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var refValue = NormalizeOptionalString(GetString(affectedRef, "ref"), toLower: false);
|
||||
if (!string.IsNullOrWhiteSpace(refValue))
|
||||
{
|
||||
affected.Add(refValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new MergedVexEntry(
|
||||
Id: vulnerabilityId ?? string.Empty,
|
||||
State: string.IsNullOrWhiteSpace(state) ? "unknown" : state,
|
||||
Affected: affected
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray()));
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
entries.Sort(static (left, right) =>
|
||||
{
|
||||
var byId = StringComparer.Ordinal.Compare(left.Id, right.Id);
|
||||
if (byId != 0)
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
|
||||
return StringComparer.Ordinal.Compare(left.State, right.State);
|
||||
});
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream);
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", entry.Id);
|
||||
writer.WriteString("state", entry.State);
|
||||
writer.WriteStartArray("affected");
|
||||
foreach (var affected in entry.Affected)
|
||||
{
|
||||
writer.WriteStringValue(affected);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.Flush();
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static string? ExtractAttestationsProjection(JsonElement root)
|
||||
{
|
||||
if (TryGetPropertyCaseInsensitive(root, "attestations", out var attestations))
|
||||
{
|
||||
return CanonicalizeJson(attestations);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractRekorTileId(JsonElement root)
|
||||
{
|
||||
if (TryGetPropertyCaseInsensitive(root, "rekor_tile_id", out var rekorTileId)
|
||||
&& rekorTileId.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return NormalizeOptionalString(rekorTileId.GetString(), toLower: false);
|
||||
}
|
||||
|
||||
if (TryGetPropertyCaseInsensitive(root, "rekorTileId", out rekorTileId)
|
||||
&& rekorTileId.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return NormalizeOptionalString(rekorTileId.GetString(), toLower: false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ComputeEvidenceScore(int componentCount, int componentsWithPurl, bool hasMergedVex)
|
||||
{
|
||||
if (componentCount <= 0)
|
||||
{
|
||||
return hasMergedVex ? 20 : 0;
|
||||
}
|
||||
|
||||
var purlCoverage = (double)componentsWithPurl / componentCount;
|
||||
var score = (purlCoverage * 80d) + (hasMergedVex ? 20d : 0d);
|
||||
return Math.Clamp((int)Math.Round(score, MidpointRounding.AwayFromZero), 0, 100);
|
||||
}
|
||||
|
||||
private static string NormalizeBuildId(string? buildId, ScanId scanId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(buildId))
|
||||
{
|
||||
return buildId.Trim();
|
||||
}
|
||||
|
||||
return scanId.Value;
|
||||
}
|
||||
|
||||
private static string NormalizePayloadDigest(string? payloadDigest, ScanId scanId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadDigest))
|
||||
{
|
||||
return $"scan:{scanId.Value}";
|
||||
}
|
||||
|
||||
var normalized = payloadDigest.Trim().ToLowerInvariant();
|
||||
if (normalized.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.All(IsHexChar))
|
||||
{
|
||||
return $"sha256:{normalized}";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsHexChar(char c)
|
||||
{
|
||||
return (c >= '0' && c <= '9')
|
||||
|| (c >= 'a' && c <= 'f')
|
||||
|| (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetPropertyCaseInsensitive(element, propertyName, out var value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value, bool toLower)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
return toLower ? normalized.ToLowerInvariant() : normalized;
|
||||
}
|
||||
|
||||
private static string? CanonicalizeJson(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream);
|
||||
WriteCanonicalElement(writer, element);
|
||||
writer.Flush();
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalElement(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
return;
|
||||
}
|
||||
case JsonValueKind.Array:
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonicalElement(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
return;
|
||||
}
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
return;
|
||||
case JsonValueKind.Number:
|
||||
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
|
||||
return;
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
return;
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
return;
|
||||
case JsonValueKind.Null:
|
||||
case JsonValueKind.Undefined:
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
default:
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CanonicalProjection(
|
||||
string Format,
|
||||
IReadOnlyList<CanonicalComponent> Components,
|
||||
int ComponentsWithPurl);
|
||||
|
||||
private sealed record CanonicalComponent(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Purl);
|
||||
|
||||
private sealed record MergedVexEntry(
|
||||
string Id,
|
||||
string State,
|
||||
IReadOnlyList<string> Affected);
|
||||
}
|
||||
|
||||
@@ -12,3 +12,5 @@ Source of truth: `docs/implplan/SPRINT_20260112_003_BE_csproj_audit_pending_appl
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added `POST /api/v1/scans/vex-reachability/filter` endpoint and deterministic matrix annotations for findings (2026-02-08). |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Implement triage cluster batch action and cluster statistics endpoints for sprint 063 (2026-02-08). |
|
||||
| HOT-003 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: wired SBOM ingestion projection writes into Scanner WebService pipeline. |
|
||||
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added SBOM hot-lookup read endpoints with bounded pagination. |
|
||||
|
||||
Reference in New Issue
Block a user