more audit work
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Emit.Spdx;
|
||||
using StellaOps.Scanner.WebService.Constants;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Infrastructure;
|
||||
@@ -23,6 +24,17 @@ internal static class ExportEndpoints
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scansGroup);
|
||||
|
||||
// GET /scans/{scanId}/exports/sbom - SPDX 3.0.1 SBOM export with format and profile selection
|
||||
// Sprint: SPRINT_20260107_004_002 Task SG-010
|
||||
scansGroup.MapGet("/{scanId}/exports/sbom", HandleExportSbomAsync)
|
||||
.WithName("scanner.scans.exports.sbom")
|
||||
.WithTags("Exports", "SBOM")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/spdx+json")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/ld+json")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.cyclonedx+json")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
// GET /scans/{scanId}/exports/sarif
|
||||
scansGroup.MapGet("/{scanId}/exports/sarif", HandleExportSarifAsync)
|
||||
.WithName("scanner.scans.exports.sarif")
|
||||
@@ -185,4 +197,142 @@ internal static class ExportEndpoints
|
||||
var json = JsonSerializer.Serialize(vexDocument, SerializerOptions);
|
||||
return Results.Content(json, "application/json", System.Text.Encoding.UTF8, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles SBOM export with format and profile selection.
|
||||
/// Sprint: SPRINT_20260107_004_002 Tasks SG-010, SG-012
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="format">SBOM format: spdx3, spdx2, cyclonedx (default: spdx2 for backward compatibility).</param>
|
||||
/// <param name="profile">SPDX 3.0.1 profile: software, lite (default: software). Only applies to spdx3 format.</param>
|
||||
/// <param name="coordinator">The scan coordinator service.</param>
|
||||
/// <param name="sbomExportService">The SBOM export service.</param>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
private static async Task<IResult> HandleExportSbomAsync(
|
||||
string scanId,
|
||||
string? format,
|
||||
string? profile,
|
||||
IScanCoordinator coordinator,
|
||||
ISbomExportService sbomExportService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(sbomExportService);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
|
||||
// SG-012: Format selection logic with fallback to SPDX 2.3 for backward compatibility
|
||||
var selectedFormat = SelectSbomFormat(format);
|
||||
var selectedProfile = SelectSpdx3Profile(profile);
|
||||
|
||||
var exportResult = await sbomExportService.ExportAsync(
|
||||
parsed,
|
||||
selectedFormat,
|
||||
selectedProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exportResult is null || exportResult.Bytes is null || exportResult.Bytes.Length == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"No SBOM data available",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "No SBOM data available for export.");
|
||||
}
|
||||
|
||||
// Set appropriate content-type header based on format
|
||||
var contentType = selectedFormat switch
|
||||
{
|
||||
SbomExportFormat.Spdx3 => "application/ld+json; profile=\"https://spdx.org/rdf/3.0.1/terms/Software/ProfileIdentifierType/" + selectedProfile.ToString().ToLowerInvariant() + "\"",
|
||||
SbomExportFormat.Spdx2 => "application/spdx+json; version=2.3",
|
||||
SbomExportFormat.CycloneDx => "application/vnd.cyclonedx+json; version=1.7",
|
||||
_ => "application/json"
|
||||
};
|
||||
|
||||
context.Response.Headers["X-StellaOps-Format"] = selectedFormat.ToString().ToLowerInvariant();
|
||||
if (selectedFormat == SbomExportFormat.Spdx3)
|
||||
{
|
||||
context.Response.Headers["X-StellaOps-Profile"] = selectedProfile.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return Results.Bytes(exportResult.Bytes, contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects SBOM format with fallback to SPDX 2.3 for backward compatibility.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-012
|
||||
/// </summary>
|
||||
private static SbomExportFormat SelectSbomFormat(string? format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
// Default to SPDX 2.3 for backward compatibility
|
||||
return SbomExportFormat.Spdx2;
|
||||
}
|
||||
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"spdx3" or "spdx-3" or "spdx3.0" or "spdx-3.0.1" => SbomExportFormat.Spdx3,
|
||||
"spdx2" or "spdx-2" or "spdx2.3" or "spdx-2.3" or "spdx" => SbomExportFormat.Spdx2,
|
||||
"cyclonedx" or "cdx" or "cdx17" or "cyclonedx-1.7" => SbomExportFormat.CycloneDx,
|
||||
_ => SbomExportFormat.Spdx2 // Fallback for unknown formats
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects SPDX 3.0.1 profile with default to Software.
|
||||
/// </summary>
|
||||
private static Spdx3ProfileType SelectSpdx3Profile(string? profile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile))
|
||||
{
|
||||
return Spdx3ProfileType.Software;
|
||||
}
|
||||
|
||||
return profile.ToLowerInvariant() switch
|
||||
{
|
||||
"lite" => Spdx3ProfileType.Lite,
|
||||
"build" => Spdx3ProfileType.Build,
|
||||
"security" => Spdx3ProfileType.Security,
|
||||
"software" or _ => Spdx3ProfileType.Software
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export format enumeration.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-012
|
||||
/// </summary>
|
||||
public enum SbomExportFormat
|
||||
{
|
||||
/// <summary>SPDX 2.3 JSON format (default for backward compatibility).</summary>
|
||||
Spdx2,
|
||||
|
||||
/// <summary>SPDX 3.0.1 JSON-LD format with profile support.</summary>
|
||||
Spdx3,
|
||||
|
||||
/// <summary>CycloneDX 1.7 JSON format.</summary>
|
||||
CycloneDx
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using StellaOps.Scanner.Emit.Spdx;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
@@ -34,3 +36,40 @@ public interface IOpenVexExportService
|
||||
/// </summary>
|
||||
Task<object?> ExportAsync(ScanId scanId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting SBOMs in multiple formats.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-010
|
||||
/// </summary>
|
||||
public interface ISbomExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports an SBOM for the given scan in the requested format and profile.
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="format">The SBOM export format (SPDX 2.3, SPDX 3.0.1, or CycloneDX).</param>
|
||||
/// <param name="profile">The SPDX 3.0.1 profile (only applies to SPDX 3.0.1 format).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The export result containing the SBOM bytes and metadata.</returns>
|
||||
Task<SbomExportResult?> ExportAsync(
|
||||
ScanId scanId,
|
||||
SbomExportFormat format,
|
||||
Spdx3ProfileType profile,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an SBOM export operation.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-010
|
||||
/// </summary>
|
||||
/// <param name="Bytes">The serialized SBOM bytes.</param>
|
||||
/// <param name="Format">The format used for export.</param>
|
||||
/// <param name="Profile">The SPDX 3.0.1 profile (if applicable).</param>
|
||||
/// <param name="Digest">The SHA256 digest of the SBOM content.</param>
|
||||
/// <param name="ComponentCount">Number of components in the SBOM.</param>
|
||||
public sealed record SbomExportResult(
|
||||
byte[] Bytes,
|
||||
SbomExportFormat Format,
|
||||
Spdx3ProfileType? Profile,
|
||||
string Digest,
|
||||
int ComponentCount);
|
||||
|
||||
@@ -66,6 +66,30 @@ public interface ILayerSbomService
|
||||
string imageDigest,
|
||||
LayerSbomCompositionResult result,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the composed SBOM for the entire scan.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-010
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="format">SBOM format: "cdx" or "spdx".</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>SBOM bytes, or null if not available.</returns>
|
||||
Task<byte[]?> GetComposedSbomAsync(
|
||||
ScanId scanId,
|
||||
string format,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the layer fragments for SBOM composition.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-010
|
||||
/// </summary>
|
||||
/// <param name="scanId">The scan identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of layer fragments, or null if not available.</returns>
|
||||
Task<IReadOnlyList<SbomLayerFragment>?> GetLayerFragmentsAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,3 +117,30 @@ public sealed record LayerSummary
|
||||
/// </summary>
|
||||
public required int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer fragment for SBOM composition.
|
||||
/// Sprint: SPRINT_20260107_004_002 Task SG-010
|
||||
/// </summary>
|
||||
public sealed record SbomLayerFragment
|
||||
{
|
||||
/// <summary>
|
||||
/// The layer digest.
|
||||
/// </summary>
|
||||
public required string LayerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer order (0-indexed).
|
||||
/// </summary>
|
||||
public required int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURLs in this layer.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> ComponentPurls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer command (e.g., from Dockerfile).
|
||||
/// </summary>
|
||||
public string? Command { get; init; }
|
||||
}
|
||||
|
||||
@@ -183,6 +183,72 @@ public sealed class LayerSbomService : ILayerSbomService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Sprint: SPRINT_20260107_004_002 Task SG-010</remarks>
|
||||
public Task<byte[]?> GetComposedSbomAsync(
|
||||
ScanId scanId,
|
||||
string format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = scanId.Value;
|
||||
|
||||
if (!LayerSbomCache.TryGetValue(key, out var store))
|
||||
{
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
if (store.Artifacts.IsDefaultOrEmpty)
|
||||
{
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
// Compose all layer SBOMs into a single document
|
||||
// For now, we concatenate the first layer's SBOM as a placeholder
|
||||
// In production, this would use the SBOM composer to merge layer fragments
|
||||
var firstArtifact = store.Artifacts.FirstOrDefault();
|
||||
if (firstArtifact is null)
|
||||
{
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
var bytes = string.Equals(format, "spdx", StringComparison.OrdinalIgnoreCase)
|
||||
? firstArtifact.SpdxJsonBytes
|
||||
: firstArtifact.CycloneDxJsonBytes;
|
||||
|
||||
return Task.FromResult<byte[]?>(bytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Sprint: SPRINT_20260107_004_002 Task SG-010</remarks>
|
||||
public Task<IReadOnlyList<SbomLayerFragment>?> GetLayerFragmentsAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = scanId.Value;
|
||||
|
||||
if (!LayerSbomCache.TryGetValue(key, out var store))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SbomLayerFragment>?>(null);
|
||||
}
|
||||
|
||||
if (store.LayerRefs.IsDefaultOrEmpty)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SbomLayerFragment>?>(null);
|
||||
}
|
||||
|
||||
var fragments = store.LayerRefs
|
||||
.OrderBy(r => r.Order)
|
||||
.Select(r => new SbomLayerFragment
|
||||
{
|
||||
LayerDigest = r.LayerDigest,
|
||||
Order = r.Order,
|
||||
ComponentPurls = r.ComponentPurls ?? Array.Empty<string>()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SbomLayerFragment>?>(fragments);
|
||||
}
|
||||
|
||||
private sealed record LayerSbomStore
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// <copyright file="SbomExportService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.Emit.Spdx;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting SBOMs in multiple formats (SPDX 2.3, SPDX 3.0.1, CycloneDX).
|
||||
/// Sprint: SPRINT_20260107_004_002 Tasks SG-010, SG-012
|
||||
/// </summary>
|
||||
public sealed class SbomExportService : ISbomExportService
|
||||
{
|
||||
private readonly IScanCoordinator _coordinator;
|
||||
private readonly ISpdxComposer _spdxComposer;
|
||||
private readonly ILayerSbomService _layerSbomService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SbomExportService> _logger;
|
||||
|
||||
public SbomExportService(
|
||||
IScanCoordinator coordinator,
|
||||
ISpdxComposer spdxComposer,
|
||||
ILayerSbomService layerSbomService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SbomExportService> logger)
|
||||
{
|
||||
_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
|
||||
_spdxComposer = spdxComposer ?? throw new ArgumentNullException(nameof(spdxComposer));
|
||||
_layerSbomService = layerSbomService ?? throw new ArgumentNullException(nameof(layerSbomService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SbomExportResult?> ExportAsync(
|
||||
ScanId scanId,
|
||||
SbomExportFormat format,
|
||||
Spdx3ProfileType profile,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Exporting SBOM for scan {ScanId} with format {Format} and profile {Profile}",
|
||||
scanId,
|
||||
format,
|
||||
profile);
|
||||
|
||||
var snapshot = await _coordinator.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
_logger.LogWarning("Scan {ScanId} not found for SBOM export", scanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get layer fragments for SBOM composition
|
||||
var layerFragments = await _layerSbomService.GetLayerFragmentsAsync(scanId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (layerFragments is null || layerFragments.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No layer fragments found for scan {ScanId}", scanId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return format switch
|
||||
{
|
||||
SbomExportFormat.Spdx3 => await ExportSpdx3Async(snapshot, layerFragments, profile, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
SbomExportFormat.Spdx2 => await ExportSpdx2Async(snapshot, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
SbomExportFormat.CycloneDx => await ExportCycloneDxAsync(snapshot, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
_ => await ExportSpdx2Async(snapshot, cancellationToken).ConfigureAwait(false)
|
||||
};
|
||||
}
|
||||
|
||||
private Task<SbomExportResult> ExportSpdx3Async(
|
||||
ScanSnapshot snapshot,
|
||||
IReadOnlyList<SbomLayerFragment> layerFragments,
|
||||
Spdx3ProfileType profile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Generating SPDX 3.0.1 SBOM with profile {Profile}", profile);
|
||||
|
||||
// Build composition request from layer fragments
|
||||
var request = BuildCompositionRequest(snapshot, layerFragments);
|
||||
var options = new SpdxCompositionOptions
|
||||
{
|
||||
ProfileType = profile,
|
||||
IncludeFiles = profile != Spdx3ProfileType.Lite,
|
||||
IncludeTagValue = false
|
||||
};
|
||||
|
||||
var artifact = _spdxComposer.Compose(request, options, cancellationToken);
|
||||
|
||||
return Task.FromResult(new SbomExportResult(
|
||||
artifact.JsonBytes,
|
||||
SbomExportFormat.Spdx3,
|
||||
profile,
|
||||
artifact.JsonDigest,
|
||||
artifact.ComponentCount));
|
||||
}
|
||||
|
||||
private async Task<SbomExportResult> ExportSpdx2Async(
|
||||
ScanSnapshot snapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Generating SPDX 2.3 SBOM");
|
||||
|
||||
// For SPDX 2.3, we use the layer SBOM service's existing functionality
|
||||
var sbomBytes = await _layerSbomService.GetComposedSbomAsync(
|
||||
snapshot.ScanId,
|
||||
"spdx",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (sbomBytes is null || sbomBytes.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("No SPDX 2.3 SBOM available for scan {ScanId}", snapshot.ScanId);
|
||||
return new SbomExportResult(
|
||||
Array.Empty<byte>(),
|
||||
SbomExportFormat.Spdx2,
|
||||
null,
|
||||
string.Empty,
|
||||
0);
|
||||
}
|
||||
|
||||
var digest = CanonJson.Sha256Hex(sbomBytes);
|
||||
var componentCount = EstimateComponentCount(sbomBytes);
|
||||
|
||||
return new SbomExportResult(
|
||||
sbomBytes,
|
||||
SbomExportFormat.Spdx2,
|
||||
null,
|
||||
digest,
|
||||
componentCount);
|
||||
}
|
||||
|
||||
private async Task<SbomExportResult> ExportCycloneDxAsync(
|
||||
ScanSnapshot snapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Generating CycloneDX 1.7 SBOM");
|
||||
|
||||
var sbomBytes = await _layerSbomService.GetComposedSbomAsync(
|
||||
snapshot.ScanId,
|
||||
"cdx",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (sbomBytes is null || sbomBytes.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("No CycloneDX SBOM available for scan {ScanId}", snapshot.ScanId);
|
||||
return new SbomExportResult(
|
||||
Array.Empty<byte>(),
|
||||
SbomExportFormat.CycloneDx,
|
||||
null,
|
||||
string.Empty,
|
||||
0);
|
||||
}
|
||||
|
||||
var digest = CanonJson.Sha256Hex(sbomBytes);
|
||||
var componentCount = EstimateComponentCount(sbomBytes);
|
||||
|
||||
return new SbomExportResult(
|
||||
sbomBytes,
|
||||
SbomExportFormat.CycloneDx,
|
||||
null,
|
||||
digest,
|
||||
componentCount);
|
||||
}
|
||||
|
||||
private SbomCompositionRequest BuildCompositionRequest(
|
||||
ScanSnapshot snapshot,
|
||||
IReadOnlyList<SbomLayerFragment> layerFragments)
|
||||
{
|
||||
// Convert SbomLayerFragment to the format expected by SpdxComposer
|
||||
var fragments = layerFragments.Select(f => new Scanner.Core.Contracts.LayerSbomFragment
|
||||
{
|
||||
LayerDigest = f.LayerDigest,
|
||||
Order = f.Order,
|
||||
ComponentPurls = f.ComponentPurls.ToList()
|
||||
}).ToList();
|
||||
|
||||
return new SbomCompositionRequest
|
||||
{
|
||||
Image = new Scanner.Core.Contracts.ImageReference
|
||||
{
|
||||
ImageDigest = snapshot.Target.Digest ?? string.Empty,
|
||||
ImageRef = snapshot.Target.Reference ?? string.Empty
|
||||
},
|
||||
LayerFragments = fragments,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
GeneratorVersion = "StellaOps-Scanner/1.0"
|
||||
};
|
||||
}
|
||||
|
||||
private static int EstimateComponentCount(byte[] sbomBytes)
|
||||
{
|
||||
// Quick heuristic: count "purl" occurrences as proxy for component count
|
||||
var content = System.Text.Encoding.UTF8.GetString(sbomBytes);
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
while ((index = content.IndexOf("\"purl\"", index, StringComparison.Ordinal)) != -1)
|
||||
{
|
||||
count++;
|
||||
index += 6;
|
||||
}
|
||||
return Math.Max(count, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user