save checkpoint

This commit is contained in:
master
2026-02-11 01:32:14 +02:00
parent 5593212b41
commit cf5b72974f
2316 changed files with 68799 additions and 3808 deletions

View File

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

View File

@@ -133,6 +133,8 @@ internal static class SbomEndpoints
sbomDocument,
format,
contentDigest,
snapshot.Target.Digest,
parsed.Value,
cancellationToken).ConfigureAwait(false);
sbomDocument.Dispose();

View File

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

View File

@@ -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(

View File

@@ -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>();

View File

@@ -27,6 +27,8 @@ public interface ISbomIngestionService
JsonDocument sbomDocument,
string format,
string? contentDigest,
string? payloadDigest,
string? buildId,
CancellationToken cancellationToken = default);
/// <summary>

View File

@@ -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);

View File

@@ -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();
}
}

View File

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

View File

@@ -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. |