save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

@@ -14,6 +14,7 @@ using ProtoSerializer = CycloneDX.Protobuf.Serializer;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Evidence;
using StellaOps.Scanner.Emit.Pedigree;
namespace StellaOps.Scanner.Emit.Composition;
@@ -178,7 +179,7 @@ public sealed class CycloneDxComposer
SpecVersion = SpecificationVersion.v1_6,
Version = 1,
Metadata = BuildMetadata(request, view, generatedAt),
Components = BuildComponents(components),
Components = BuildComponents(request, components),
Dependencies = BuildDependencies(components),
};
@@ -318,9 +319,19 @@ public sealed class CycloneDxComposer
return purlBuilder.ToString();
}
private static List<Component> BuildComponents(ImmutableArray<AggregatedComponent> components)
/// <summary>
/// Builds CycloneDX component models from aggregated components.
/// Sprint: SPRINT_20260107_005_002 Task PD-009 - Added pedigree support.
/// </summary>
private static List<Component> BuildComponents(
SbomCompositionRequest request,
ImmutableArray<AggregatedComponent> components)
{
var evidenceMapper = new CycloneDxEvidenceMapper();
var pedigreeMapper = request.IncludePedigree && request.PedigreeDataByPurl is not null
? new CycloneDxPedigreeMapper()
: null;
var result = new List<Component>(components.Length);
foreach (var component in components)
{
@@ -337,6 +348,16 @@ public sealed class CycloneDxComposer
Evidence = evidenceMapper.Map(component),
};
// Apply pedigree data if available and enabled
// Sprint: SPRINT_20260107_005_002 Task PD-009
if (pedigreeMapper is not null && !string.IsNullOrEmpty(component.Identity.Purl))
{
if (request.PedigreeDataByPurl!.TryGetValue(component.Identity.Purl, out var pedigreeData))
{
model.Pedigree = pedigreeMapper.Map(pedigreeData);
}
}
result.Add(model);
}

View File

@@ -4,6 +4,7 @@ using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Pedigree;
namespace StellaOps.Scanner.Emit.Composition;
@@ -45,6 +46,21 @@ public sealed record SbomCompositionRequest
public ImmutableArray<SbomPolicyFinding> PolicyFindings { get; init; }
= ImmutableArray<SbomPolicyFinding>.Empty;
/// <summary>
/// Gets the pre-fetched pedigree data keyed by component PURL.
/// This enables synchronous composition while allowing async pedigree lookups
/// to happen before calling <see cref="CycloneDxComposer.Compose"/>.
/// Sprint: SPRINT_20260107_005_002 Task PD-009
/// </summary>
public IReadOnlyDictionary<string, PedigreeData>? PedigreeDataByPurl { get; init; }
= null;
/// <summary>
/// Gets whether pedigree data should be included in the SBOM.
/// Defaults to true if pedigree data is provided.
/// </summary>
public bool IncludePedigree { get; init; } = true;
public static SbomCompositionRequest Create(
ImageArtifactDescriptor image,
IEnumerable<LayerComponentFragment> fragments,
@@ -52,7 +68,9 @@ public sealed record SbomCompositionRequest
string? generatorName = null,
string? generatorVersion = null,
IReadOnlyDictionary<string, string>? properties = null,
IEnumerable<SbomPolicyFinding>? policyFindings = null)
IEnumerable<SbomPolicyFinding>? policyFindings = null,
IReadOnlyDictionary<string, PedigreeData>? pedigreeData = null,
bool includePedigree = true)
{
ArgumentNullException.ThrowIfNull(image);
ArgumentNullException.ThrowIfNull(fragments);
@@ -75,6 +93,8 @@ public sealed record SbomCompositionRequest
GeneratorVersion = Normalize(generatorVersion),
AdditionalProperties = properties,
PolicyFindings = NormalizePolicyFindings(policyFindings),
PedigreeDataByPurl = pedigreeData,
IncludePedigree = includePedigree,
};
}

View File

@@ -0,0 +1,604 @@
// <copyright file="SbomValidationPipeline.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Diagnostics.Metrics;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Validation;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Pipeline configuration for SBOM validation after generation.
/// </summary>
public sealed class SbomValidationPipelineOptions
{
/// <summary>
/// Whether validation is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to fail composition when validation fails. Default: true.
/// </summary>
public bool FailOnError { get; set; } = true;
/// <summary>
/// Whether to validate CycloneDX SBOMs. Default: true.
/// </summary>
public bool ValidateCycloneDx { get; set; } = true;
/// <summary>
/// Whether to validate SPDX SBOMs. Default: true.
/// </summary>
public bool ValidateSpdx { get; set; } = true;
/// <summary>
/// Timeout for validation operations. Default: 60 seconds.
/// </summary>
public TimeSpan ValidationTimeout { get; set; } = TimeSpan.FromSeconds(60);
}
/// <summary>
/// Result of SBOM validation pipeline execution.
/// </summary>
public sealed record SbomValidationPipelineResult
{
/// <summary>
/// Gets whether all validations passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Gets the CycloneDX inventory validation result, if performed.
/// </summary>
public SbomValidationResult? CycloneDxInventoryResult { get; init; }
/// <summary>
/// Gets the CycloneDX usage validation result, if performed.
/// </summary>
public SbomValidationResult? CycloneDxUsageResult { get; init; }
/// <summary>
/// Gets the SPDX inventory validation result, if performed.
/// </summary>
public SbomValidationResult? SpdxInventoryResult { get; init; }
/// <summary>
/// Gets the per-layer validation results, if performed.
/// </summary>
public ImmutableArray<LayerValidationResult> LayerResults { get; init; } = [];
/// <summary>
/// Gets the total number of errors across all validations.
/// </summary>
public int TotalErrorCount =>
(CycloneDxInventoryResult?.ErrorCount ?? 0) +
(CycloneDxUsageResult?.ErrorCount ?? 0) +
(SpdxInventoryResult?.ErrorCount ?? 0) +
LayerResults.Sum(r => r.CycloneDxResult?.ErrorCount ?? 0) +
LayerResults.Sum(r => r.SpdxResult?.ErrorCount ?? 0);
/// <summary>
/// Gets the total number of warnings across all validations.
/// </summary>
public int TotalWarningCount =>
(CycloneDxInventoryResult?.WarningCount ?? 0) +
(CycloneDxUsageResult?.WarningCount ?? 0) +
(SpdxInventoryResult?.WarningCount ?? 0) +
LayerResults.Sum(r => r.CycloneDxResult?.WarningCount ?? 0) +
LayerResults.Sum(r => r.SpdxResult?.WarningCount ?? 0);
/// <summary>
/// Gets whether validation was skipped entirely.
/// </summary>
public bool WasSkipped { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static SbomValidationPipelineResult Success(
SbomValidationResult? cycloneDxInventory = null,
SbomValidationResult? cycloneDxUsage = null,
SbomValidationResult? spdxInventory = null,
ImmutableArray<LayerValidationResult>? layerResults = null) =>
new()
{
IsValid = true,
CycloneDxInventoryResult = cycloneDxInventory,
CycloneDxUsageResult = cycloneDxUsage,
SpdxInventoryResult = spdxInventory,
LayerResults = layerResults ?? []
};
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static SbomValidationPipelineResult Failure(
SbomValidationResult? cycloneDxInventory = null,
SbomValidationResult? cycloneDxUsage = null,
SbomValidationResult? spdxInventory = null,
ImmutableArray<LayerValidationResult>? layerResults = null) =>
new()
{
IsValid = false,
CycloneDxInventoryResult = cycloneDxInventory,
CycloneDxUsageResult = cycloneDxUsage,
SpdxInventoryResult = spdxInventory,
LayerResults = layerResults ?? []
};
/// <summary>
/// Creates a skipped validation result.
/// </summary>
public static SbomValidationPipelineResult Skipped() =>
new() { IsValid = true, WasSkipped = true };
}
/// <summary>
/// Validation result for a single layer.
/// </summary>
public sealed record LayerValidationResult
{
/// <summary>
/// Gets the layer identifier (digest or index).
/// </summary>
public required string LayerId { get; init; }
/// <summary>
/// Gets the CycloneDX validation result for this layer.
/// </summary>
public SbomValidationResult? CycloneDxResult { get; init; }
/// <summary>
/// Gets the SPDX validation result for this layer.
/// </summary>
public SbomValidationResult? SpdxResult { get; init; }
/// <summary>
/// Gets whether this layer's validation passed.
/// </summary>
public bool IsValid =>
(CycloneDxResult?.IsValid ?? true) &&
(SpdxResult?.IsValid ?? true);
}
/// <summary>
/// Pipeline for validating generated SBOMs.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260107_005_003 Task VG-005
/// This pipeline runs validation after SBOM generation and can optionally
/// fail the composition if validation errors are detected.
/// </remarks>
public sealed class SbomValidationPipeline
{
private readonly ISbomValidator _validator;
private readonly IOptions<SbomValidationPipelineOptions> _options;
private readonly ILogger<SbomValidationPipeline> _logger;
private readonly TimeProvider _timeProvider;
// Metrics
private readonly Counter<long> _validationRuns;
private readonly Counter<long> _validationPassed;
private readonly Counter<long> _validationFailed;
private readonly Counter<long> _validationSkipped;
private readonly Histogram<double> _validationDuration;
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationPipeline"/> class.
/// </summary>
public SbomValidationPipeline(
ISbomValidator validator,
IOptions<SbomValidationPipelineOptions> options,
ILogger<SbomValidationPipeline> logger,
TimeProvider timeProvider,
IMeterFactory? meterFactory = null)
{
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
// Initialize metrics
var meter = meterFactory?.Create("StellaOps.Scanner.Validation") ??
new Meter("StellaOps.Scanner.Validation");
_validationRuns = meter.CreateCounter<long>(
"sbom.validation.runs",
"runs",
"Total number of validation pipeline runs");
_validationPassed = meter.CreateCounter<long>(
"sbom.validation.passed",
"runs",
"Number of validation runs that passed");
_validationFailed = meter.CreateCounter<long>(
"sbom.validation.failed",
"runs",
"Number of validation runs that failed");
_validationSkipped = meter.CreateCounter<long>(
"sbom.validation.skipped",
"runs",
"Number of validation runs that were skipped");
_validationDuration = meter.CreateHistogram<double>(
"sbom.validation.duration",
"ms",
"Duration of validation pipeline execution");
}
/// <summary>
/// Validates a composition result.
/// </summary>
/// <param name="result">The composition result to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The validation pipeline result.</returns>
/// <exception cref="SbomValidationException">
/// Thrown when validation fails and <see cref="SbomValidationPipelineOptions.FailOnError"/> is true.
/// </exception>
public async Task<SbomValidationPipelineResult> ValidateAsync(
SbomCompositionResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var opts = _options.Value;
var startTime = _timeProvider.GetTimestamp();
_validationRuns.Add(1);
try
{
if (!opts.Enabled)
{
_logger.LogDebug("SBOM validation is disabled, skipping");
_validationSkipped.Add(1);
return SbomValidationPipelineResult.Skipped();
}
_logger.LogInformation("Starting SBOM validation pipeline");
var validationOptions = new SbomValidationOptions
{
Timeout = opts.ValidationTimeout
};
// Validate main SBOMs in parallel
var tasks = new List<Task<(string Name, SbomValidationResult? Result)>>();
if (opts.ValidateCycloneDx)
{
tasks.Add(ValidateCycloneDxAsync(
"CycloneDX-Inventory",
result.Inventory.JsonBytes,
validationOptions,
cancellationToken));
if (result.Usage is not null)
{
tasks.Add(ValidateCycloneDxAsync(
"CycloneDX-Usage",
result.Usage.JsonBytes,
validationOptions,
cancellationToken));
}
}
if (opts.ValidateSpdx && result.SpdxInventory is not null)
{
tasks.Add(ValidateSpdxAsync(
"SPDX-Inventory",
result.SpdxInventory.JsonBytes,
validationOptions,
cancellationToken));
}
var mainResults = await Task.WhenAll(tasks).ConfigureAwait(false);
// Extract results by name
SbomValidationResult? cdxInventory = null;
SbomValidationResult? cdxUsage = null;
SbomValidationResult? spdxInventory = null;
foreach (var (name, validationResult) in mainResults)
{
switch (name)
{
case "CycloneDX-Inventory":
cdxInventory = validationResult;
break;
case "CycloneDX-Usage":
cdxUsage = validationResult;
break;
case "SPDX-Inventory":
spdxInventory = validationResult;
break;
}
}
// Validate layer SBOMs if present
var layerResults = await ValidateLayersAsync(
result.LayerSbomArtifacts,
validationOptions,
opts,
cancellationToken).ConfigureAwait(false);
// Determine overall validity
var allValid =
(cdxInventory?.IsValid ?? true) &&
(cdxUsage?.IsValid ?? true) &&
(spdxInventory?.IsValid ?? true) &&
layerResults.All(r => r.IsValid);
var pipelineResult = allValid
? SbomValidationPipelineResult.Success(cdxInventory, cdxUsage, spdxInventory, layerResults)
: SbomValidationPipelineResult.Failure(cdxInventory, cdxUsage, spdxInventory, layerResults);
// Log summary
LogValidationSummary(pipelineResult);
// Update metrics
if (allValid)
{
_validationPassed.Add(1);
}
else
{
_validationFailed.Add(1);
}
// Throw if configured to fail on error
if (!allValid && opts.FailOnError)
{
throw new SbomValidationException(
$"SBOM validation failed with {pipelineResult.TotalErrorCount} error(s)",
pipelineResult);
}
return pipelineResult;
}
finally
{
var elapsed = _timeProvider.GetElapsedTime(startTime);
_validationDuration.Record(elapsed.TotalMilliseconds);
}
}
private async Task<(string Name, SbomValidationResult? Result)> ValidateCycloneDxAsync(
string name,
byte[] content,
SbomValidationOptions options,
CancellationToken cancellationToken)
{
try
{
_logger.LogDebug("Validating {Name} ({Size} bytes)", name, content.Length);
var result = await _validator.ValidateAsync(
content,
SbomFormat.CycloneDxJson,
options,
cancellationToken).ConfigureAwait(false);
LogValidationResult(name, result);
return (name, result);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to validate {Name}: {Message}", name, ex.Message);
return (name, SbomValidationResult.ValidatorUnavailable(
SbomFormat.CycloneDxJson,
"CycloneDX",
ex.Message));
}
}
private async Task<(string Name, SbomValidationResult? Result)> ValidateSpdxAsync(
string name,
byte[] content,
SbomValidationOptions options,
CancellationToken cancellationToken)
{
try
{
_logger.LogDebug("Validating {Name} ({Size} bytes)", name, content.Length);
var result = await _validator.ValidateAsync(
content,
SbomFormat.Spdx3JsonLd,
options,
cancellationToken).ConfigureAwait(false);
LogValidationResult(name, result);
return (name, result);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to validate {Name}: {Message}", name, ex.Message);
return (name, SbomValidationResult.ValidatorUnavailable(
SbomFormat.Spdx3JsonLd,
"SPDX",
ex.Message));
}
}
private async Task<ImmutableArray<LayerValidationResult>> ValidateLayersAsync(
ImmutableArray<LayerSbomArtifact> layerArtifacts,
SbomValidationOptions options,
SbomValidationPipelineOptions pipelineOptions,
CancellationToken cancellationToken)
{
if (layerArtifacts.IsDefaultOrEmpty)
{
return [];
}
_logger.LogDebug("Validating {Count} layer SBOMs", layerArtifacts.Length);
var results = new List<LayerValidationResult>();
foreach (var layer in layerArtifacts)
{
SbomValidationResult? cdxResult = null;
SbomValidationResult? spdxResult = null;
if (pipelineOptions.ValidateCycloneDx && layer.CycloneDxJsonBytes is not null)
{
var (_, result) = await ValidateCycloneDxAsync(
$"Layer-{layer.LayerDigest}-CDX",
layer.CycloneDxJsonBytes,
options,
cancellationToken).ConfigureAwait(false);
cdxResult = result;
}
if (pipelineOptions.ValidateSpdx && layer.SpdxJsonBytes is not null)
{
var (_, result) = await ValidateSpdxAsync(
$"Layer-{layer.LayerDigest}-SPDX",
layer.SpdxJsonBytes,
options,
cancellationToken).ConfigureAwait(false);
spdxResult = result;
}
results.Add(new LayerValidationResult
{
LayerId = layer.LayerDigest,
CycloneDxResult = cdxResult,
SpdxResult = spdxResult
});
}
return [.. results];
}
private void LogValidationResult(string name, SbomValidationResult result)
{
if (result.IsValid)
{
if (result.WarningCount > 0)
{
_logger.LogInformation(
"{Name} validation passed with {WarningCount} warning(s)",
name,
result.WarningCount);
}
else
{
_logger.LogInformation("{Name} validation passed", name);
}
}
else
{
_logger.LogWarning(
"{Name} validation failed with {ErrorCount} error(s), {WarningCount} warning(s)",
name,
result.ErrorCount,
result.WarningCount);
foreach (var diagnostic in result.Diagnostics.Where(d => d.Severity == SbomValidationSeverity.Error))
{
_logger.LogWarning(" [{Code}] {Message}", diagnostic.Code, diagnostic.Message);
}
}
}
private void LogValidationSummary(SbomValidationPipelineResult result)
{
var sb = new StringBuilder();
sb.AppendLine("SBOM validation summary:");
if (result.CycloneDxInventoryResult is not null)
{
sb.AppendLine($" - CycloneDX Inventory: {(result.CycloneDxInventoryResult.IsValid ? "PASSED" : "FAILED")}");
}
if (result.CycloneDxUsageResult is not null)
{
sb.AppendLine($" - CycloneDX Usage: {(result.CycloneDxUsageResult.IsValid ? "PASSED" : "FAILED")}");
}
if (result.SpdxInventoryResult is not null)
{
sb.AppendLine($" - SPDX Inventory: {(result.SpdxInventoryResult.IsValid ? "PASSED" : "FAILED")}");
}
if (!result.LayerResults.IsDefaultOrEmpty)
{
var passedLayers = result.LayerResults.Count(r => r.IsValid);
sb.AppendLine($" - Layers: {passedLayers}/{result.LayerResults.Length} passed");
}
sb.AppendLine($" Total errors: {result.TotalErrorCount}");
sb.AppendLine($" Total warnings: {result.TotalWarningCount}");
_logger.LogInformation(sb.ToString());
}
}
/// <summary>
/// Exception thrown when SBOM validation fails.
/// </summary>
public sealed class SbomValidationException : Exception
{
/// <summary>
/// Gets the validation pipeline result.
/// </summary>
public SbomValidationPipelineResult? Result { get; }
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationException"/> class.
/// </summary>
public SbomValidationException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationException"/> class.
/// </summary>
public SbomValidationException(string message, SbomValidationPipelineResult result)
: base(message)
{
Result = result;
}
/// <summary>
/// Initializes a new instance of the <see cref="SbomValidationException"/> class.
/// </summary>
public SbomValidationException(string message, Exception innerException)
: base(message, innerException)
{
}
}
/// <summary>
/// Extension methods for registering the validation pipeline.
/// </summary>
public static class SbomValidationPipelineExtensions
{
/// <summary>
/// Adds the SBOM validation pipeline to the service collection.
/// </summary>
public static IServiceCollection AddSbomValidationPipeline(
this IServiceCollection services,
Action<SbomValidationPipelineOptions>? configure = null)
{
services.AddOptions<SbomValidationPipelineOptions>()
.Configure(configure ?? (_ => { }))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<SbomValidationPipeline>();
return services;
}
}

View File

@@ -3,11 +3,12 @@
// </copyright>
using CycloneDX.Models;
using CdxPedigree = CycloneDX.Models.Pedigree;
namespace StellaOps.Scanner.Emit.Pedigree;
/// <summary>
/// Maps <see cref="PedigreeData"/> to CycloneDX <see cref="Pedigree"/> model.
/// Maps <see cref="PedigreeData"/> to CycloneDX <see cref="CdxPedigree"/> model.
/// Sprint: SPRINT_20260107_005_002 Task PD-003
/// </summary>
public sealed class CycloneDxPedigreeMapper
@@ -17,14 +18,14 @@ public sealed class CycloneDxPedigreeMapper
/// </summary>
/// <param name="data">The pedigree data to map.</param>
/// <returns>CycloneDX pedigree model, or null if no data.</returns>
public Pedigree? Map(PedigreeData? data)
public CdxPedigree? Map(PedigreeData? data)
{
if (data is null || !data.HasData)
{
return null;
}
return new Pedigree
return new CdxPedigree
{
Ancestors = MapAncestors(data.Ancestors),
Variants = MapVariants(data.Variants),
@@ -158,7 +159,7 @@ public sealed class CycloneDxPedigreeMapper
{
Name = actor.Name,
Email = actor.Email,
Timestamp = actor.Timestamp
Timestamp = actor.Timestamp?.UtcDateTime
};
}

View File

@@ -10,6 +10,7 @@
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Validation\StellaOps.Scanner.Validation.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>