more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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