266 lines
9.2 KiB
C#
266 lines
9.2 KiB
C#
// <copyright file="SbomExportService.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
|
// </copyright>
|
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Canonical.Json;
|
|
using StellaOps.Scanner.Core.Contracts;
|
|
using StellaOps.Scanner.Emit.Composition;
|
|
using StellaOps.Scanner.Emit.Spdx;
|
|
using StellaOps.Scanner.WebService.Domain;
|
|
using StellaOps.Scanner.WebService.Endpoints;
|
|
using System.Collections.Immutable;
|
|
|
|
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.JsonSha256,
|
|
0)); // ComponentCount not available on SpdxArtifact
|
|
}
|
|
|
|
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 LayerComponentFragment for SpdxComposer
|
|
var fragments = layerFragments.Select(f => new LayerComponentFragment
|
|
{
|
|
LayerDigest = f.LayerDigest,
|
|
Components = f.ComponentPurls
|
|
.Select(purl => new ComponentRecord
|
|
{
|
|
Identity = ComponentIdentity.Create(
|
|
key: purl,
|
|
name: ExtractNameFromPurl(purl),
|
|
version: ExtractVersionFromPurl(purl),
|
|
purl: purl),
|
|
LayerDigest = f.LayerDigest
|
|
})
|
|
.ToImmutableArray()
|
|
}).ToImmutableArray();
|
|
|
|
var image = new ImageArtifactDescriptor
|
|
{
|
|
ImageDigest = snapshot.Target.Digest ?? string.Empty,
|
|
ImageReference = snapshot.Target.Reference
|
|
};
|
|
|
|
return SbomCompositionRequest.Create(
|
|
image,
|
|
fragments,
|
|
_timeProvider.GetUtcNow(),
|
|
generatorName: "StellaOps-Scanner",
|
|
generatorVersion: "1.0");
|
|
}
|
|
|
|
private static string ExtractNameFromPurl(string purl)
|
|
{
|
|
// Basic PURL parsing: pkg:type/namespace/name@version
|
|
// Returns the name portion
|
|
try
|
|
{
|
|
var withoutScheme = purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)
|
|
? purl[4..]
|
|
: purl;
|
|
var atIndex = withoutScheme.IndexOf('@');
|
|
var pathPart = atIndex >= 0 ? withoutScheme[..atIndex] : withoutScheme;
|
|
var slashIndex = pathPart.LastIndexOf('/');
|
|
return slashIndex >= 0 ? pathPart[(slashIndex + 1)..] : pathPart;
|
|
}
|
|
catch
|
|
{
|
|
return purl;
|
|
}
|
|
}
|
|
|
|
private static string? ExtractVersionFromPurl(string purl)
|
|
{
|
|
// Basic PURL parsing: pkg:type/namespace/name@version
|
|
// Returns the version portion
|
|
try
|
|
{
|
|
var atIndex = purl.IndexOf('@');
|
|
if (atIndex < 0) return null;
|
|
var versionPart = purl[(atIndex + 1)..];
|
|
var queryIndex = versionPart.IndexOf('?');
|
|
return queryIndex >= 0 ? versionPart[..queryIndex] : versionPart;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|