// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // 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; /// /// 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 /// public sealed class SbomExportService : ISbomExportService { private readonly IScanCoordinator _coordinator; private readonly ISpdxComposer _spdxComposer; private readonly ILayerSbomService _layerSbomService; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public SbomExportService( IScanCoordinator coordinator, ISpdxComposer spdxComposer, ILayerSbomService layerSbomService, TimeProvider timeProvider, ILogger 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)); } /// public async Task 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 ExportSpdx3Async( ScanSnapshot snapshot, IReadOnlyList 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 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(), 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 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(), 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 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); } }