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:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

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

View File

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

View File

@@ -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:&lt;artifact-digest&gt;
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
/// If provided, CycloneDxWriter will generate serialNumber as urn:sha256:&lt;artifact-digest&gt;
/// 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; }
}

View File

@@ -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:&lt;artifact-digest&gt;
/// 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);

View File

@@ -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:&lt;artifact-digest&gt; 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
}

View File

@@ -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:&lt;artifact-digest&gt;
/// </summary>
/// <remarks>
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-004)
/// If provided, CycloneDxWriter will generate serialNumber as urn:sha256:&lt;artifact-digest&gt;
/// 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; }
}

View File

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