Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Services/SbomExportService.cs
2026-02-01 21:37:40 +02:00

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