doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISbomCanonicalizer.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_deterministic_sbom_generation
|
||||
// Task: TASK-015-003 - Create Canonicalizer Utility
|
||||
// Description: Interface for SBOM canonicalization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Canonicalization;
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes SBOM documents for deterministic DSSE signing.
|
||||
/// Wraps existing RFC 8785 implementation with SBOM-specific ordering.
|
||||
/// </summary>
|
||||
public interface ISbomCanonicalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonicalizes an SBOM document to deterministic bytes.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">SBOM document type.</typeparam>
|
||||
/// <param name="document">The SBOM document.</param>
|
||||
/// <returns>Canonical JSON bytes.</returns>
|
||||
byte[] Canonicalize<T>(T document) where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 hash of canonical SBOM.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">SBOM document type.</typeparam>
|
||||
/// <param name="document">The SBOM document.</param>
|
||||
/// <returns>Hex-encoded SHA-256 hash.</returns>
|
||||
string ComputeHash<T>(T document) where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a document produces the expected hash.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">SBOM document type.</typeparam>
|
||||
/// <param name="document">The SBOM document.</param>
|
||||
/// <param name="expectedHash">Expected SHA-256 hash.</param>
|
||||
/// <returns>True if hash matches.</returns>
|
||||
bool VerifyHash<T>(T document, string expectedHash) where T : class;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format types.
|
||||
/// </summary>
|
||||
public enum SbomFormat
|
||||
{
|
||||
/// <summary>CycloneDX 1.5/1.6 JSON.</summary>
|
||||
CycloneDx,
|
||||
|
||||
/// <summary>SPDX 2.3 JSON.</summary>
|
||||
Spdx2,
|
||||
|
||||
/// <summary>SPDX 3.0 JSON-LD.</summary>
|
||||
Spdx3
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomCanonicalizer.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_deterministic_sbom_generation
|
||||
// Task: TASK-015-003 - Create Canonicalizer Utility
|
||||
// Description: SBOM canonicalization using RFC 8785
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Canonicalization;
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes SBOM documents for deterministic DSSE signing.
|
||||
/// Uses RFC 8785 (JCS) canonicalization with SBOM-specific ordering.
|
||||
/// </summary>
|
||||
public sealed class SbomCanonicalizer : ISbomCanonicalizer
|
||||
{
|
||||
private readonly JsonSerializerOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SBOM canonicalizer.
|
||||
/// </summary>
|
||||
public SbomCanonicalizer()
|
||||
{
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] Canonicalize<T>(T document) where T : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
// Serialize to JSON
|
||||
var json = JsonSerializer.Serialize(document, _options);
|
||||
|
||||
// Parse and re-serialize with canonical ordering
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var canonicalJson = CanonicalizeElement(doc.RootElement);
|
||||
|
||||
return Encoding.UTF8.GetBytes(canonicalJson);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeHash<T>(T document) where T : class
|
||||
{
|
||||
var bytes = Canonicalize(document);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool VerifyHash<T>(T document, string expectedHash) where T : class
|
||||
{
|
||||
var actualHash = ComputeHash(document);
|
||||
return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => CanonicalizeObject(element),
|
||||
JsonValueKind.Array => CanonicalizeArray(element),
|
||||
JsonValueKind.String => JsonSerializer.Serialize(element.GetString()),
|
||||
JsonValueKind.Number => CanonicalizeNumber(element),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => throw new InvalidOperationException($"Unexpected JSON element kind: {element.ValueKind}")
|
||||
};
|
||||
}
|
||||
|
||||
private static string CanonicalizeObject(JsonElement element)
|
||||
{
|
||||
// RFC 8785: Sort properties by Unicode code point order
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.Select(p => $"{JsonSerializer.Serialize(p.Name)}:{CanonicalizeElement(p.Value)}");
|
||||
|
||||
return "{" + string.Join(",", properties) + "}";
|
||||
}
|
||||
|
||||
private static string CanonicalizeArray(JsonElement element)
|
||||
{
|
||||
var items = element.EnumerateArray()
|
||||
.Select(CanonicalizeElement);
|
||||
|
||||
return "[" + string.Join(",", items) + "]";
|
||||
}
|
||||
|
||||
private static string CanonicalizeNumber(JsonElement element)
|
||||
{
|
||||
// RFC 8785: Numbers must use the shortest decimal representation
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
{
|
||||
return longValue.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (element.TryGetDouble(out var doubleValue))
|
||||
{
|
||||
// Use "G17" for maximum precision, then trim trailing zeros
|
||||
var str = doubleValue.ToString("G17", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
// Remove trailing zeros after decimal point
|
||||
if (str.Contains('.'))
|
||||
{
|
||||
str = str.TrimEnd('0').TrimEnd('.');
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
return element.GetRawText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomDocument.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_deterministic_sbom_generation
|
||||
// Task: TASK-015-005 - SBOM Document Model
|
||||
// Description: Format-agnostic SBOM document model for CycloneDX/SPDX emission
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Format-agnostic SBOM document that can be serialized to CycloneDX or SPDX.
|
||||
/// This model abstracts common SBOM concepts across formats.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Immutable by design - all collections use <see cref="ImmutableArray{T}"/>.
|
||||
/// </remarks>
|
||||
public sealed record SbomDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Document name/identifier.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Document version.
|
||||
/// </summary>
|
||||
public string Version { get; init; } = "1";
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the artifact this SBOM describes (e.g., container image digest).
|
||||
/// Used to derive deterministic serialNumber: urn:sha256:<artifact-digest>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
|
||||
/// If provided, CycloneDxWriter will generate serialNumber as urn:sha256:<artifact-digest>
|
||||
/// instead of using a deterministic UUID. This enables reproducible SBOMs where the
|
||||
/// serialNumber directly references the artifact being described.
|
||||
/// Format: lowercase hex string, 64 characters (no prefix).
|
||||
/// </remarks>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Document metadata.
|
||||
/// </summary>
|
||||
public SbomMetadata? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Software components in this SBOM.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomComponent> Components { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Relationships between components.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomRelationship> Relationships { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// External references.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomExternalReference> ExternalReferences { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities associated with components.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomVulnerability> Vulnerabilities { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM document metadata.
|
||||
/// </summary>
|
||||
public sealed record SbomMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Tools used to generate this SBOM.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tools { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Authors of this SBOM.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Authors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Component this SBOM describes (for CycloneDX metadata.component).
|
||||
/// </summary>
|
||||
public SbomComponent? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Supplier information.
|
||||
/// </summary>
|
||||
public string? Supplier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manufacturer information.
|
||||
/// </summary>
|
||||
public string? Manufacturer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Software component in an SBOM.
|
||||
/// </summary>
|
||||
public sealed record SbomComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Component type (library, application, framework, etc.).
|
||||
/// </summary>
|
||||
public SbomComponentType Type { get; init; } = SbomComponentType.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Unique reference within this SBOM (bom-ref for CycloneDX, part of SPDXID for SPDX).
|
||||
/// </summary>
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (purl) - primary identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See https://github.com/package-url/purl-spec
|
||||
/// </remarks>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPE identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See https://nvd.nist.gov/products/cpe
|
||||
/// </remarks>
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component group/namespace.
|
||||
/// </summary>
|
||||
public string? Group { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Publisher/author.
|
||||
/// </summary>
|
||||
public string? Publisher { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download location URL.
|
||||
/// </summary>
|
||||
public string? DownloadLocation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic hashes of the component.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomHash> Hashes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Licenses applicable to this component.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomLicense> Licenses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// External references for this component.
|
||||
/// </summary>
|
||||
public ImmutableArray<SbomExternalReference> ExternalReferences { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Component properties (key-value metadata).
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Properties { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component type classification.
|
||||
/// </summary>
|
||||
public enum SbomComponentType
|
||||
{
|
||||
/// <summary>Software library.</summary>
|
||||
Library,
|
||||
|
||||
/// <summary>Standalone application.</summary>
|
||||
Application,
|
||||
|
||||
/// <summary>Software framework.</summary>
|
||||
Framework,
|
||||
|
||||
/// <summary>Container image.</summary>
|
||||
Container,
|
||||
|
||||
/// <summary>Operating system.</summary>
|
||||
OperatingSystem,
|
||||
|
||||
/// <summary>Device/hardware.</summary>
|
||||
Device,
|
||||
|
||||
/// <summary>Firmware.</summary>
|
||||
Firmware,
|
||||
|
||||
/// <summary>Source file.</summary>
|
||||
File,
|
||||
|
||||
/// <summary>Data/dataset.</summary>
|
||||
Data,
|
||||
|
||||
/// <summary>Machine learning model.</summary>
|
||||
MachineLearningModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic hash of a component.
|
||||
/// </summary>
|
||||
public sealed record SbomHash
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash algorithm (SHA-256, SHA-512, etc.).
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash value (hex-encoded).
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License information.
|
||||
/// </summary>
|
||||
public sealed record SbomLicense
|
||||
{
|
||||
/// <summary>
|
||||
/// SPDX license identifier.
|
||||
/// </summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License name (when not an SPDX ID).
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License text URL.
|
||||
/// </summary>
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full license text.
|
||||
/// </summary>
|
||||
public string? Text { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relationship between components.
|
||||
/// </summary>
|
||||
public sealed record SbomRelationship
|
||||
{
|
||||
/// <summary>
|
||||
/// Source component reference (bom-ref).
|
||||
/// </summary>
|
||||
public required string SourceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target component reference (bom-ref).
|
||||
/// </summary>
|
||||
public required string TargetRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relationship type.
|
||||
/// </summary>
|
||||
public SbomRelationshipType Type { get; init; } = SbomRelationshipType.DependsOn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relationship type between components.
|
||||
/// </summary>
|
||||
public enum SbomRelationshipType
|
||||
{
|
||||
/// <summary>Source depends on target.</summary>
|
||||
DependsOn,
|
||||
|
||||
/// <summary>Source is a dependency of target.</summary>
|
||||
DependencyOf,
|
||||
|
||||
/// <summary>Source contains target.</summary>
|
||||
Contains,
|
||||
|
||||
/// <summary>Source is contained by target.</summary>
|
||||
ContainedBy,
|
||||
|
||||
/// <summary>Source is a build tool for target.</summary>
|
||||
BuildToolOf,
|
||||
|
||||
/// <summary>Source is a dev dependency of target.</summary>
|
||||
DevDependencyOf,
|
||||
|
||||
/// <summary>Source is an optional dependency of target.</summary>
|
||||
OptionalDependencyOf,
|
||||
|
||||
/// <summary>Source provides target.</summary>
|
||||
Provides,
|
||||
|
||||
/// <summary>Other relationship.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// External reference.
|
||||
/// </summary>
|
||||
public sealed record SbomExternalReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference type.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference URL.
|
||||
/// </summary>
|
||||
public required string Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability information.
|
||||
/// </summary>
|
||||
public sealed record SbomVulnerability
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability ID (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability source.
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected component references.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedRefs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Severity rating.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score.
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -158,6 +158,10 @@ public sealed class CycloneDxPredicateParser : IPredicateParser
|
||||
errors.Add(new ValidationError("$.version", "Missing required field: version (BOM serial version)", "CDX_MISSING_VERSION"));
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
|
||||
// Validate serialNumber format for deterministic SBOM compliance
|
||||
ValidateSerialNumberFormat(payload, warnings);
|
||||
|
||||
// Components array (may be missing for empty BOMs)
|
||||
if (!payload.TryGetProperty("components", out var components))
|
||||
{
|
||||
@@ -175,6 +179,69 @@ public sealed class CycloneDxPredicateParser : IPredicateParser
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates serialNumber format for deterministic SBOM compliance.
|
||||
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deterministic SBOMs should use the format: urn:sha256:<artifact-digest>
|
||||
/// where artifact-digest is the SHA-256 hash of the artifact being described.
|
||||
/// Non-deterministic formats (urn:uuid:) are allowed for backwards compatibility
|
||||
/// but generate a warning to encourage migration to deterministic format.
|
||||
/// </remarks>
|
||||
private void ValidateSerialNumberFormat(JsonElement payload, List<ValidationWarning> warnings)
|
||||
{
|
||||
if (!payload.TryGetProperty("serialNumber", out var serialNumber))
|
||||
{
|
||||
// serialNumber is optional in CycloneDX, no warning needed
|
||||
return;
|
||||
}
|
||||
|
||||
var serialNumberValue = serialNumber.GetString();
|
||||
if (string.IsNullOrEmpty(serialNumberValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for deterministic format: urn:sha256:<64-hex-chars>
|
||||
if (serialNumberValue.StartsWith("urn:sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Validate hash format
|
||||
var hashPart = serialNumberValue.Substring("urn:sha256:".Length);
|
||||
if (hashPart.Length == 64 && hashPart.All(c => char.IsAsciiHexDigit(c)))
|
||||
{
|
||||
_logger.LogDebug("serialNumber uses deterministic format: {SerialNumber}", serialNumberValue);
|
||||
return; // Valid deterministic format
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add(new ValidationWarning(
|
||||
"$.serialNumber",
|
||||
$"serialNumber has urn:sha256: prefix but invalid hash format (expected 64 hex chars, got '{hashPart}')",
|
||||
"CDX_SERIAL_INVALID_SHA256"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for UUID format (non-deterministic but common)
|
||||
if (serialNumberValue.StartsWith("urn:uuid:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("serialNumber uses non-deterministic UUID format: {SerialNumber}", serialNumberValue);
|
||||
warnings.Add(new ValidationWarning(
|
||||
"$.serialNumber",
|
||||
$"serialNumber uses non-deterministic UUID format. For reproducible SBOMs, use 'urn:sha256:<artifact-digest>' format instead.",
|
||||
"CDX_SERIAL_NON_DETERMINISTIC"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Other formats - warn about non-standard format
|
||||
_logger.LogDebug("serialNumber uses non-standard format: {SerialNumber}", serialNumberValue);
|
||||
warnings.Add(new ValidationWarning(
|
||||
"$.serialNumber",
|
||||
$"serialNumber uses non-standard format '{serialNumberValue}'. Expected 'urn:sha256:<artifact-digest>' for deterministic SBOMs.",
|
||||
"CDX_SERIAL_NON_STANDARD"));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload)
|
||||
{
|
||||
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CycloneDxWriter.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_deterministic_sbom_generation
|
||||
// Task: TASK-015-001 - Implement CycloneDX 1.6 JSON Writer
|
||||
// Description: Deterministic CycloneDX writer for DSSE signing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.StandardPredicates.Canonicalization;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Writers;
|
||||
|
||||
/// <summary>
|
||||
/// Writes CycloneDX 1.6 JSON documents with deterministic output.
|
||||
/// </summary>
|
||||
public sealed class CycloneDxWriter : ISbomWriter
|
||||
{
|
||||
private readonly ISbomCanonicalizer _canonicalizer;
|
||||
private readonly JsonSerializerOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// CycloneDX spec version.
|
||||
/// </summary>
|
||||
public const string SpecVersion = "1.6";
|
||||
|
||||
/// <summary>
|
||||
/// Namespace for UUIDv5 generation.
|
||||
/// </summary>
|
||||
private static readonly Guid CycloneDxNamespace = new("6ba7b810-9dad-11d1-80b4-00c04fd430c8");
|
||||
|
||||
/// <inheritdoc />
|
||||
public SbomFormat Format => SbomFormat.CycloneDx;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CycloneDX writer.
|
||||
/// </summary>
|
||||
public CycloneDxWriter(ISbomCanonicalizer? canonicalizer = null)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? new SbomCanonicalizer();
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] Write(SbomDocument document)
|
||||
{
|
||||
var cdx = ConvertToCycloneDx(document);
|
||||
return _canonicalizer.Canonicalize(cdx);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> WriteAsync(SbomDocument document, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(Write(document));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeContentHash(SbomDocument document)
|
||||
{
|
||||
var bytes = Write(document);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private CycloneDxBom ConvertToCycloneDx(SbomDocument document)
|
||||
{
|
||||
// Sort components by bom-ref
|
||||
var sortedComponents = document.Components
|
||||
.OrderBy(c => c.BomRef, StringComparer.Ordinal)
|
||||
.Select(c => new CycloneDxComponent
|
||||
{
|
||||
BomRef = c.BomRef,
|
||||
Type = c.Type,
|
||||
Name = c.Name,
|
||||
Version = c.Version,
|
||||
Purl = c.Purl,
|
||||
Hashes = c.Hashes
|
||||
.OrderBy(h => h.Algorithm, StringComparer.Ordinal)
|
||||
.Select(h => new CycloneDxHash { Alg = h.Algorithm, Content = h.Value })
|
||||
.ToList(),
|
||||
Licenses = c.Licenses.Count > 0
|
||||
? c.Licenses.OrderBy(l => l, StringComparer.Ordinal)
|
||||
.Select(l => new CycloneDxLicense { Id = l })
|
||||
.ToList()
|
||||
: null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort dependencies by ref
|
||||
var sortedDependencies = document.Dependencies
|
||||
.OrderBy(d => d.Ref, StringComparer.Ordinal)
|
||||
.Select(d => new CycloneDxDependency
|
||||
{
|
||||
Ref = d.Ref,
|
||||
DependsOn = d.DependsOn.OrderBy(x => x, StringComparer.Ordinal).ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
|
||||
// Generate deterministic serial number using artifact digest when available
|
||||
var serialNumber = GenerateSerialNumber(document, sortedComponents);
|
||||
|
||||
return new CycloneDxBom
|
||||
{
|
||||
BomFormat = "CycloneDX",
|
||||
SpecVersion = SpecVersion,
|
||||
SerialNumber = serialNumber,
|
||||
Version = 1,
|
||||
Metadata = new CycloneDxMetadata
|
||||
{
|
||||
Timestamp = document.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture),
|
||||
Tools = document.Tool != null
|
||||
? [new CycloneDxTool { Name = document.Tool.Name, Version = document.Tool.Version, Vendor = document.Tool.Vendor }]
|
||||
: null
|
||||
},
|
||||
Components = sortedComponents,
|
||||
Dependencies = sortedDependencies.Count > 0 ? sortedDependencies : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic serialNumber for the SBOM.
|
||||
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If ArtifactDigest is provided, generates urn:sha256:<artifact-digest> format.
|
||||
/// Otherwise, falls back to UUIDv5 derived from sorted component list for backwards compatibility.
|
||||
/// The urn:sha256: format is preferred as it directly ties the SBOM identity to the artifact
|
||||
/// it describes, enabling reproducible builds and deterministic verification.
|
||||
/// </remarks>
|
||||
private string GenerateSerialNumber(SbomDocument document, IReadOnlyList<CycloneDxComponent> sortedComponents)
|
||||
{
|
||||
// Preferred: Use artifact digest when available
|
||||
if (!string.IsNullOrEmpty(document.ArtifactDigest))
|
||||
{
|
||||
// Validate and normalize the digest (lowercase, 64 hex chars)
|
||||
var digest = document.ArtifactDigest.ToLowerInvariant();
|
||||
if (digest.Length == 64 && digest.All(c => char.IsAsciiHexDigit(c)))
|
||||
{
|
||||
return $"urn:sha256:{digest}";
|
||||
}
|
||||
|
||||
// If digest has sha256: prefix, extract the hash
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var hashPart = digest.Substring(7);
|
||||
if (hashPart.Length == 64 && hashPart.All(c => char.IsAsciiHexDigit(c)))
|
||||
{
|
||||
return $"urn:sha256:{hashPart}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Generate UUIDv5 from sorted components (legacy behavior)
|
||||
var contentForSerial = JsonSerializer.Serialize(sortedComponents, _options);
|
||||
var uuid = GenerateUuidV5(contentForSerial);
|
||||
return $"urn:uuid:{uuid}";
|
||||
}
|
||||
|
||||
private static string GenerateUuidV5(string input)
|
||||
{
|
||||
var nameBytes = Encoding.UTF8.GetBytes(input);
|
||||
var namespaceBytes = CycloneDxNamespace.ToByteArray();
|
||||
|
||||
// Swap byte order for RFC 4122 compatibility
|
||||
SwapByteOrder(namespaceBytes);
|
||||
|
||||
var combined = new byte[namespaceBytes.Length + nameBytes.Length];
|
||||
Buffer.BlockCopy(namespaceBytes, 0, combined, 0, namespaceBytes.Length);
|
||||
Buffer.BlockCopy(nameBytes, 0, combined, namespaceBytes.Length, nameBytes.Length);
|
||||
|
||||
var hash = SHA256.HashData(combined);
|
||||
|
||||
// Set version (5) and variant bits
|
||||
hash[6] = (byte)((hash[6] & 0x0F) | 0x50);
|
||||
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
|
||||
|
||||
var guid = new Guid(hash.Take(16).ToArray());
|
||||
return guid.ToString("D");
|
||||
}
|
||||
|
||||
private static void SwapByteOrder(byte[] guid)
|
||||
{
|
||||
// Swap first 4 bytes
|
||||
(guid[0], guid[3]) = (guid[3], guid[0]);
|
||||
(guid[1], guid[2]) = (guid[2], guid[1]);
|
||||
// Swap bytes 4-5
|
||||
(guid[4], guid[5]) = (guid[5], guid[4]);
|
||||
// Swap bytes 6-7
|
||||
(guid[6], guid[7]) = (guid[7], guid[6]);
|
||||
}
|
||||
|
||||
#region CycloneDX Models
|
||||
|
||||
private sealed record CycloneDxBom
|
||||
{
|
||||
[JsonPropertyName("bomFormat")]
|
||||
public required string BomFormat { get; init; }
|
||||
|
||||
[JsonPropertyName("specVersion")]
|
||||
public required string SpecVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("serialNumber")]
|
||||
public required string SerialNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public CycloneDxMetadata? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<CycloneDxComponent>? Components { get; init; }
|
||||
|
||||
[JsonPropertyName("dependencies")]
|
||||
public IReadOnlyList<CycloneDxDependency>? Dependencies { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CycloneDxMetadata
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("tools")]
|
||||
public IReadOnlyList<CycloneDxTool>? Tools { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CycloneDxTool
|
||||
{
|
||||
[JsonPropertyName("vendor")]
|
||||
public string? Vendor { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CycloneDxComponent
|
||||
{
|
||||
[JsonPropertyName("bom-ref")]
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<CycloneDxHash>? Hashes { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<CycloneDxLicense>? Licenses { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CycloneDxHash
|
||||
{
|
||||
[JsonPropertyName("alg")]
|
||||
public required string Alg { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CycloneDxLicense
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CycloneDxDependency
|
||||
{
|
||||
[JsonPropertyName("ref")]
|
||||
public required string Ref { get; init; }
|
||||
|
||||
[JsonPropertyName("dependsOn")]
|
||||
public IReadOnlyList<string>? DependsOn { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISbomWriter.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_deterministic_sbom_generation
|
||||
// Task: TASK-015-001, TASK-015-002 - SBOM Writers
|
||||
// Description: Interface for deterministic SBOM writing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Writers;
|
||||
|
||||
/// <summary>
|
||||
/// Writes SBOM documents in deterministic, canonical format.
|
||||
/// </summary>
|
||||
public interface ISbomWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM format this writer produces.
|
||||
/// </summary>
|
||||
Canonicalization.SbomFormat Format { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes an SBOM to canonical bytes.
|
||||
/// </summary>
|
||||
/// <param name="document">The SBOM document model.</param>
|
||||
/// <returns>Canonical JSON bytes.</returns>
|
||||
byte[] Write(SbomDocument document);
|
||||
|
||||
/// <summary>
|
||||
/// Writes an SBOM to canonical bytes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="document">The SBOM document model.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Canonical JSON bytes.</returns>
|
||||
Task<byte[]> WriteAsync(SbomDocument document, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the content hash of the canonical SBOM.
|
||||
/// </summary>
|
||||
/// <param name="document">The SBOM document.</param>
|
||||
/// <returns>SHA-256 hash in hex format.</returns>
|
||||
string ComputeContentHash(SbomDocument document);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified SBOM document model for Attestor operations.
|
||||
/// </summary>
|
||||
public sealed record SbomDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Document name/identifier.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Document version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the artifact this SBOM describes (e.g., container image digest).
|
||||
/// Used to derive deterministic serialNumber: urn:sha256:<artifact-digest>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
|
||||
/// If provided, CycloneDxWriter will generate serialNumber as urn:sha256:<artifact-digest>
|
||||
/// instead of using a deterministic UUID. This enables reproducible SBOMs where the
|
||||
/// serialNumber directly references the artifact being described.
|
||||
/// Format: lowercase hex string, 64 characters (no prefix).
|
||||
/// </remarks>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Components in the SBOM.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SbomComponent> Components { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Dependencies between components.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SbomDependency> Dependencies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tool information.
|
||||
/// </summary>
|
||||
public SbomTool? Tool { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// External references.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SbomExternalReference> ExternalReferences { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component in the SBOM.
|
||||
/// </summary>
|
||||
public sealed record SbomComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique reference ID.
|
||||
/// </summary>
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (purl).
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component type.
|
||||
/// </summary>
|
||||
public string Type { get; init; } = "library";
|
||||
|
||||
/// <summary>
|
||||
/// Hashes for the component.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SbomHash> Hashes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// License identifiers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Licenses { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A hash in the SBOM.
|
||||
/// </summary>
|
||||
public sealed record SbomHash
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash algorithm (e.g., SHA-256, SHA-512).
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash value in hex format.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dependency relationship.
|
||||
/// </summary>
|
||||
public sealed record SbomDependency
|
||||
{
|
||||
/// <summary>
|
||||
/// The component that has the dependency.
|
||||
/// </summary>
|
||||
public required string Ref { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Components this component depends on.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DependsOn { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tool information.
|
||||
/// </summary>
|
||||
public sealed record SbomTool
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool vendor.
|
||||
/// </summary>
|
||||
public string? Vendor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An external reference.
|
||||
/// </summary>
|
||||
public sealed record SbomExternalReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference type.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference URL.
|
||||
/// </summary>
|
||||
public required string Url { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SpdxWriter.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_deterministic_sbom_generation
|
||||
// Task: TASK-015-002 - Implement SPDX 3.0 JSON Writer
|
||||
// Description: Deterministic SPDX 3.0 JSON-LD writer for DSSE signing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.StandardPredicates.Canonicalization;
|
||||
using StellaOps.Attestor.StandardPredicates.Models;
|
||||
|
||||
namespace StellaOps.Attestor.StandardPredicates.Writers;
|
||||
|
||||
/// <summary>
|
||||
/// Writes SPDX 3.0 JSON-LD documents with deterministic output.
|
||||
/// </summary>
|
||||
public sealed class SpdxWriter : ISbomWriter
|
||||
{
|
||||
private readonly ISbomCanonicalizer _canonicalizer;
|
||||
private readonly JsonSerializerOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// SPDX spec version.
|
||||
/// </summary>
|
||||
public const string SpecVersion = "3.0";
|
||||
|
||||
/// <summary>
|
||||
/// SPDX JSON-LD context.
|
||||
/// </summary>
|
||||
public const string Context = "https://spdx.org/rdf/3.0.0/spdx-context.jsonld";
|
||||
|
||||
/// <inheritdoc />
|
||||
public SbomFormat Format => SbomFormat.Spdx;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SPDX writer.
|
||||
/// </summary>
|
||||
public SpdxWriter(ISbomCanonicalizer? canonicalizer = null)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? new SbomCanonicalizer();
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SbomWriteResult Write(SbomDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
// Build SPDX structure
|
||||
var spdxDocument = BuildSpdxDocument(document);
|
||||
|
||||
// Serialize to JSON
|
||||
var json = JsonSerializer.Serialize(spdxDocument, _options);
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
// Canonicalize
|
||||
var canonicalBytes = _canonicalizer.Canonicalize(jsonBytes);
|
||||
|
||||
// Compute golden hash
|
||||
var goldenHash = _canonicalizer.ComputeGoldenHash(canonicalBytes);
|
||||
|
||||
return new SbomWriteResult
|
||||
{
|
||||
Format = SbomFormat.Spdx,
|
||||
CanonicalBytes = canonicalBytes,
|
||||
GoldenHash = goldenHash,
|
||||
DocumentId = spdxDocument.SpdxId
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SbomWriteResult> WriteAsync(SbomDocument document, CancellationToken ct = default)
|
||||
{
|
||||
return await Task.Run(() => Write(document), ct);
|
||||
}
|
||||
|
||||
private SpdxJsonLd BuildSpdxDocument(SbomDocument document)
|
||||
{
|
||||
var spdxId = GenerateSpdxId("SPDXRef-DOCUMENT", document.Name);
|
||||
var creationTime = document.Timestamp.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
|
||||
// Build elements list (sorted by SPDXID)
|
||||
var elements = new List<SpdxElement>();
|
||||
|
||||
// Add document element
|
||||
elements.Add(new SpdxSbomElement
|
||||
{
|
||||
SpdxId = spdxId,
|
||||
Type = "SpdxDocument",
|
||||
Name = document.Name,
|
||||
CreationInfo = new SpdxCreationInfo
|
||||
{
|
||||
Created = creationTime,
|
||||
CreatedBy = document.Metadata?.Authors?.Select(a => $"Person: {a}").ToList() ?? [],
|
||||
CreatedUsing = document.Metadata?.Tools?.Select(t => $"Tool: {t}").ToList() ?? []
|
||||
}
|
||||
});
|
||||
|
||||
// Add package elements for components
|
||||
foreach (var component in document.Components.OrderBy(c => c.BomRef, StringComparer.Ordinal))
|
||||
{
|
||||
var packageId = GenerateSpdxId("SPDXRef-Package", component.BomRef);
|
||||
|
||||
elements.Add(new SpdxPackageElement
|
||||
{
|
||||
SpdxId = packageId,
|
||||
Type = "Package",
|
||||
Name = component.Name,
|
||||
Version = component.Version,
|
||||
PackageUrl = component.Purl,
|
||||
Cpe = component.Cpe,
|
||||
DownloadLocation = component.DownloadLocation ?? "NOASSERTION",
|
||||
FilesAnalyzed = false,
|
||||
Checksums = component.Hashes
|
||||
.OrderBy(h => h.Algorithm, StringComparer.Ordinal)
|
||||
.Select(h => new SpdxChecksum
|
||||
{
|
||||
Algorithm = MapHashAlgorithm(h.Algorithm),
|
||||
ChecksumValue = h.Value
|
||||
})
|
||||
.ToList(),
|
||||
LicenseConcluded = component.Licenses?.FirstOrDefault()?.Id ?? "NOASSERTION",
|
||||
LicenseDeclared = component.Licenses?.FirstOrDefault()?.Id ?? "NOASSERTION",
|
||||
CopyrightText = "NOASSERTION"
|
||||
});
|
||||
}
|
||||
|
||||
// Sort elements by SPDXID
|
||||
elements = elements.OrderBy(e => e.SpdxId, StringComparer.Ordinal).ToList();
|
||||
|
||||
// Build relationships (sorted)
|
||||
var relationships = new List<SpdxRelationship>();
|
||||
|
||||
foreach (var rel in document.Relationships.OrderBy(r => r.SourceRef).ThenBy(r => r.TargetRef).ThenBy(r => r.Type))
|
||||
{
|
||||
relationships.Add(new SpdxRelationship
|
||||
{
|
||||
SpdxElementId = GenerateSpdxId("SPDXRef-Package", rel.SourceRef),
|
||||
RelationshipType = MapRelationshipType(rel.Type),
|
||||
RelatedSpdxElement = GenerateSpdxId("SPDXRef-Package", rel.TargetRef)
|
||||
});
|
||||
}
|
||||
|
||||
return new SpdxJsonLd
|
||||
{
|
||||
Context = Context,
|
||||
Graph = elements,
|
||||
SpdxId = spdxId,
|
||||
SpdxVersion = $"SPDX-{SpecVersion}",
|
||||
Relationships = relationships
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSpdxId(string prefix, string value)
|
||||
{
|
||||
// Sanitize for SPDX ID format (letters, numbers, ., -)
|
||||
var sanitized = new StringBuilder();
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c == '.' || c == '-')
|
||||
{
|
||||
sanitized.Append(c);
|
||||
}
|
||||
else
|
||||
{
|
||||
sanitized.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
return $"{prefix}-{sanitized}";
|
||||
}
|
||||
|
||||
private static string MapHashAlgorithm(string algorithm)
|
||||
{
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
"SHA-256" or "SHA256" => "SHA256",
|
||||
"SHA-512" or "SHA512" => "SHA512",
|
||||
"SHA-1" or "SHA1" => "SHA1",
|
||||
"MD5" => "MD5",
|
||||
_ => algorithm.ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapRelationshipType(SbomRelationshipType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
SbomRelationshipType.DependsOn => "DEPENDS_ON",
|
||||
SbomRelationshipType.DependencyOf => "DEPENDENCY_OF",
|
||||
SbomRelationshipType.Contains => "CONTAINS",
|
||||
SbomRelationshipType.ContainedBy => "CONTAINED_BY",
|
||||
SbomRelationshipType.BuildToolOf => "BUILD_TOOL_OF",
|
||||
SbomRelationshipType.DevDependencyOf => "DEV_DEPENDENCY_OF",
|
||||
SbomRelationshipType.OptionalDependencyOf => "OPTIONAL_DEPENDENCY_OF",
|
||||
_ => "OTHER"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// SPDX JSON-LD models
|
||||
|
||||
/// <summary>
|
||||
/// SPDX 3.0 JSON-LD root document.
|
||||
/// </summary>
|
||||
public sealed record SpdxJsonLd
|
||||
{
|
||||
/// <summary>JSON-LD context.</summary>
|
||||
[JsonPropertyName("@context")]
|
||||
public required string Context { get; init; }
|
||||
|
||||
/// <summary>SPDX document ID.</summary>
|
||||
[JsonPropertyName("spdxId")]
|
||||
public required string SpdxId { get; init; }
|
||||
|
||||
/// <summary>SPDX version.</summary>
|
||||
[JsonPropertyName("spdxVersion")]
|
||||
public required string SpdxVersion { get; init; }
|
||||
|
||||
/// <summary>Graph of elements.</summary>
|
||||
[JsonPropertyName("@graph")]
|
||||
public required IReadOnlyList<SpdxElement> Graph { get; init; }
|
||||
|
||||
/// <summary>Relationships.</summary>
|
||||
[JsonPropertyName("relationships")]
|
||||
public IReadOnlyList<SpdxRelationship>? Relationships { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base SPDX element.
|
||||
/// </summary>
|
||||
public abstract record SpdxElement
|
||||
{
|
||||
/// <summary>SPDX ID.</summary>
|
||||
[JsonPropertyName("spdxId")]
|
||||
public required string SpdxId { get; init; }
|
||||
|
||||
/// <summary>Element type.</summary>
|
||||
[JsonPropertyName("@type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Element name.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX SBOM document element.
|
||||
/// </summary>
|
||||
public sealed record SpdxSbomElement : SpdxElement
|
||||
{
|
||||
/// <summary>Creation info.</summary>
|
||||
[JsonPropertyName("creationInfo")]
|
||||
public SpdxCreationInfo? CreationInfo { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX package element.
|
||||
/// </summary>
|
||||
public sealed record SpdxPackageElement : SpdxElement
|
||||
{
|
||||
/// <summary>Package version.</summary>
|
||||
[JsonPropertyName("versionInfo")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>Package URL.</summary>
|
||||
[JsonPropertyName("externalIdentifier")]
|
||||
public string? PackageUrl { get; init; }
|
||||
|
||||
/// <summary>CPE.</summary>
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
/// <summary>Download location.</summary>
|
||||
[JsonPropertyName("downloadLocation")]
|
||||
public string? DownloadLocation { get; init; }
|
||||
|
||||
/// <summary>Files analyzed.</summary>
|
||||
[JsonPropertyName("filesAnalyzed")]
|
||||
public bool FilesAnalyzed { get; init; }
|
||||
|
||||
/// <summary>Checksums.</summary>
|
||||
[JsonPropertyName("checksums")]
|
||||
public IReadOnlyList<SpdxChecksum>? Checksums { get; init; }
|
||||
|
||||
/// <summary>Concluded license.</summary>
|
||||
[JsonPropertyName("licenseConcluded")]
|
||||
public string? LicenseConcluded { get; init; }
|
||||
|
||||
/// <summary>Declared license.</summary>
|
||||
[JsonPropertyName("licenseDeclared")]
|
||||
public string? LicenseDeclared { get; init; }
|
||||
|
||||
/// <summary>Copyright text.</summary>
|
||||
[JsonPropertyName("copyrightText")]
|
||||
public string? CopyrightText { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX creation info.
|
||||
/// </summary>
|
||||
public sealed record SpdxCreationInfo
|
||||
{
|
||||
/// <summary>Created timestamp.</summary>
|
||||
[JsonPropertyName("created")]
|
||||
public required string Created { get; init; }
|
||||
|
||||
/// <summary>Created by.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public IReadOnlyList<string>? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Created using tools.</summary>
|
||||
[JsonPropertyName("createdUsing")]
|
||||
public IReadOnlyList<string>? CreatedUsing { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX checksum.
|
||||
/// </summary>
|
||||
public sealed record SpdxChecksum
|
||||
{
|
||||
/// <summary>Algorithm.</summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>Checksum value.</summary>
|
||||
[JsonPropertyName("checksumValue")]
|
||||
public required string ChecksumValue { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPDX relationship.
|
||||
/// </summary>
|
||||
public sealed record SpdxRelationship
|
||||
{
|
||||
/// <summary>Source element ID.</summary>
|
||||
[JsonPropertyName("spdxElementId")]
|
||||
public required string SpdxElementId { get; init; }
|
||||
|
||||
/// <summary>Relationship type.</summary>
|
||||
[JsonPropertyName("relationshipType")]
|
||||
public required string RelationshipType { get; init; }
|
||||
|
||||
/// <summary>Related element ID.</summary>
|
||||
[JsonPropertyName("relatedSpdxElement")]
|
||||
public required string RelatedSpdxElement { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user