more audit work
This commit is contained in:
@@ -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