save progress
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user