// ----------------------------------------------------------------------------- // AuditBundleWriter.cs // Sprint: SPRINT_4300_0001_0002 (One-Command Audit Replay CLI) // Tasks: REPLAY-002, REPLAY-003 - Create AuditBundleWriter with merkle root calculation // Description: Writes self-contained audit bundles for offline replay. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.AuditPack.Models; namespace StellaOps.AuditPack.Services; /// /// Writes self-contained audit bundles for deterministic offline replay. /// public sealed class AuditBundleWriter : IAuditBundleWriter { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Creates an audit bundle from the specified inputs. /// public async Task WriteAsync( AuditBundleWriteRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.OutputPath); var tempDir = Path.Combine(Path.GetTempPath(), $"audit-bundle-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); try { var entries = new List(); var files = new List(); // Write SBOM string sbomDigest; if (request.Sbom is not null) { var sbomPath = Path.Combine(tempDir, "sbom.json"); await File.WriteAllBytesAsync(sbomPath, request.Sbom, cancellationToken); sbomDigest = ComputeSha256(request.Sbom); entries.Add(new BundleEntry("sbom.json", sbomDigest, request.Sbom.Length)); files.Add(new BundleFileEntry { RelativePath = "sbom.json", Digest = sbomDigest, SizeBytes = request.Sbom.Length, ContentType = BundleContentType.Sbom }); } else { return AuditBundleWriteResult.Failed("SBOM is required for audit bundle"); } // Write feeds snapshot string feedsDigest; if (request.FeedsSnapshot is not null) { var feedsDir = Path.Combine(tempDir, "feeds"); Directory.CreateDirectory(feedsDir); var feedsPath = Path.Combine(feedsDir, "feeds-snapshot.ndjson"); await File.WriteAllBytesAsync(feedsPath, request.FeedsSnapshot, cancellationToken); feedsDigest = ComputeSha256(request.FeedsSnapshot); entries.Add(new BundleEntry("feeds/feeds-snapshot.ndjson", feedsDigest, request.FeedsSnapshot.Length)); files.Add(new BundleFileEntry { RelativePath = "feeds/feeds-snapshot.ndjson", Digest = feedsDigest, SizeBytes = request.FeedsSnapshot.Length, ContentType = BundleContentType.Feeds }); } else { return AuditBundleWriteResult.Failed("Feeds snapshot is required for audit bundle"); } // Write policy bundle string policyDigest; if (request.PolicyBundle is not null) { var policyDir = Path.Combine(tempDir, "policy"); Directory.CreateDirectory(policyDir); var policyPath = Path.Combine(policyDir, "policy-bundle.tar.gz"); await File.WriteAllBytesAsync(policyPath, request.PolicyBundle, cancellationToken); policyDigest = ComputeSha256(request.PolicyBundle); entries.Add(new BundleEntry("policy/policy-bundle.tar.gz", policyDigest, request.PolicyBundle.Length)); files.Add(new BundleFileEntry { RelativePath = "policy/policy-bundle.tar.gz", Digest = policyDigest, SizeBytes = request.PolicyBundle.Length, ContentType = BundleContentType.Policy }); } else { return AuditBundleWriteResult.Failed("Policy bundle is required for audit bundle"); } // Write VEX (optional) string? vexDigest = null; if (request.VexStatements is not null) { var vexDir = Path.Combine(tempDir, "vex"); Directory.CreateDirectory(vexDir); var vexPath = Path.Combine(vexDir, "vex-statements.json"); await File.WriteAllBytesAsync(vexPath, request.VexStatements, cancellationToken); vexDigest = ComputeSha256(request.VexStatements); entries.Add(new BundleEntry("vex/vex-statements.json", vexDigest, request.VexStatements.Length)); files.Add(new BundleFileEntry { RelativePath = "vex/vex-statements.json", Digest = vexDigest, SizeBytes = request.VexStatements.Length, ContentType = BundleContentType.Vex }); } // Write verdict string verdictDigest; if (request.Verdict is not null) { var verdictPath = Path.Combine(tempDir, "verdict.json"); await File.WriteAllBytesAsync(verdictPath, request.Verdict, cancellationToken); verdictDigest = ComputeSha256(request.Verdict); entries.Add(new BundleEntry("verdict.json", verdictDigest, request.Verdict.Length)); files.Add(new BundleFileEntry { RelativePath = "verdict.json", Digest = verdictDigest, SizeBytes = request.Verdict.Length, ContentType = BundleContentType.Verdict }); } else { return AuditBundleWriteResult.Failed("Verdict is required for audit bundle"); } // Write proof bundle (optional) if (request.ProofBundle is not null) { var proofDir = Path.Combine(tempDir, "proof"); Directory.CreateDirectory(proofDir); var proofPath = Path.Combine(proofDir, "proof-bundle.json"); await File.WriteAllBytesAsync(proofPath, request.ProofBundle, cancellationToken); var proofDigest = ComputeSha256(request.ProofBundle); entries.Add(new BundleEntry("proof/proof-bundle.json", proofDigest, request.ProofBundle.Length)); files.Add(new BundleFileEntry { RelativePath = "proof/proof-bundle.json", Digest = proofDigest, SizeBytes = request.ProofBundle.Length, ContentType = BundleContentType.ProofBundle }); } // Write trust roots (optional) string? trustRootsDigest = null; if (request.TrustRoots is not null) { var trustDir = Path.Combine(tempDir, "trust"); Directory.CreateDirectory(trustDir); var trustPath = Path.Combine(trustDir, "trust-roots.json"); await File.WriteAllBytesAsync(trustPath, request.TrustRoots, cancellationToken); trustRootsDigest = ComputeSha256(request.TrustRoots); entries.Add(new BundleEntry("trust/trust-roots.json", trustRootsDigest, request.TrustRoots.Length)); files.Add(new BundleFileEntry { RelativePath = "trust/trust-roots.json", Digest = trustRootsDigest, SizeBytes = request.TrustRoots.Length, ContentType = BundleContentType.TrustRoot }); } // Write scoring rules (optional) string? scoringDigest = null; if (request.ScoringRules is not null) { var scoringPath = Path.Combine(tempDir, "scoring-rules.json"); await File.WriteAllBytesAsync(scoringPath, request.ScoringRules, cancellationToken); scoringDigest = ComputeSha256(request.ScoringRules); entries.Add(new BundleEntry("scoring-rules.json", scoringDigest, request.ScoringRules.Length)); files.Add(new BundleFileEntry { RelativePath = "scoring-rules.json", Digest = scoringDigest, SizeBytes = request.ScoringRules.Length, ContentType = BundleContentType.Other }); } // Write time anchor (optional) TimeAnchor? timeAnchor = null; if (request.TimeAnchor is not null) { var timeAnchorPath = Path.Combine(tempDir, "time-anchor.json"); var timeAnchorBytes = JsonSerializer.SerializeToUtf8Bytes(request.TimeAnchor, JsonOptions); await File.WriteAllBytesAsync(timeAnchorPath, timeAnchorBytes, cancellationToken); var timeAnchorDigest = ComputeSha256(timeAnchorBytes); entries.Add(new BundleEntry("time-anchor.json", timeAnchorDigest, timeAnchorBytes.Length)); files.Add(new BundleFileEntry { RelativePath = "time-anchor.json", Digest = timeAnchorDigest, SizeBytes = timeAnchorBytes.Length, ContentType = BundleContentType.TimeAnchor }); timeAnchor = new TimeAnchor { Timestamp = request.TimeAnchor.Timestamp, Source = request.TimeAnchor.Source, TokenDigest = timeAnchorDigest }; } // Compute merkle root var merkleRoot = ComputeMerkleRoot(entries); // Build manifest var manifest = new AuditBundleManifest { BundleId = request.BundleId ?? Guid.NewGuid().ToString("N"), Name = request.Name ?? $"audit-{request.ScanId}", CreatedAt = DateTimeOffset.UtcNow, ScanId = request.ScanId, ImageRef = request.ImageRef, ImageDigest = request.ImageDigest, MerkleRoot = merkleRoot, Inputs = new InputDigests { SbomDigest = sbomDigest, FeedsDigest = feedsDigest, PolicyDigest = policyDigest, VexDigest = vexDigest, ScoringDigest = scoringDigest, TrustRootsDigest = trustRootsDigest }, VerdictDigest = verdictDigest, Decision = request.Decision, Files = [.. files], TotalSizeBytes = entries.Sum(e => e.SizeBytes), TimeAnchor = timeAnchor }; // Write manifest var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions); var manifestPath = Path.Combine(tempDir, "manifest.json"); await File.WriteAllBytesAsync(manifestPath, manifestBytes, cancellationToken); // Sign manifest if requested string? signingKeyId = null; string? signingAlgorithm = null; var signed = false; if (request.Sign) { var signer = new AuditBundleSigner(); var signResult = await signer.SignAsync( new AuditBundleSigningRequest { ManifestBytes = manifestBytes, KeyFilePath = request.SigningKeyPath, KeyPassword = request.SigningKeyPassword }, cancellationToken); if (signResult.Success && signResult.Envelope is not null) { var signaturePath = Path.Combine(tempDir, "manifest.sig"); await File.WriteAllBytesAsync(signaturePath, signResult.Envelope, cancellationToken); signingKeyId = signResult.KeyId; signingAlgorithm = signResult.Algorithm; signed = true; } } // Create tar.gz bundle var outputPath = request.OutputPath; if (!outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) { outputPath = $"{outputPath}.tar.gz"; } await CreateTarGzAsync(tempDir, outputPath, cancellationToken); var bundleDigest = await ComputeFileDigestAsync(outputPath, cancellationToken); return new AuditBundleWriteResult { Success = true, OutputPath = outputPath, BundleId = manifest.BundleId, MerkleRoot = merkleRoot, BundleDigest = bundleDigest, TotalSizeBytes = new FileInfo(outputPath).Length, FileCount = files.Count, CreatedAt = manifest.CreatedAt, Signed = signed, SigningKeyId = signingKeyId, SigningAlgorithm = signingAlgorithm }; } catch (Exception ex) { return AuditBundleWriteResult.Failed($"Failed to write audit bundle: {ex.Message}"); } finally { // Clean up temp directory try { if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); } } catch { // Ignore cleanup errors } } } private static string ComputeSha256(byte[] content) { var hash = SHA256.HashData(content); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static async Task ComputeFileDigestAsync(string filePath, CancellationToken ct) { await using var stream = File.OpenRead(filePath); var hash = await SHA256.HashDataAsync(stream, ct); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } /// /// Computes merkle root over all bundle entries for integrity verification. /// Uses a binary tree structure with SHA-256 hashing. /// private static string ComputeMerkleRoot(List entries) { if (entries.Count == 0) { return string.Empty; } // Create leaf nodes: hash of "path:digest" for each entry var leaves = entries .OrderBy(e => e.Path, StringComparer.Ordinal) .Select(e => SHA256.HashData(Encoding.UTF8.GetBytes($"{e.Path}:{e.Digest}"))) .ToArray(); // Build merkle tree by pairwise hashing until we reach the root while (leaves.Length > 1) { leaves = PairwiseHash(leaves).ToArray(); } return $"sha256:{Convert.ToHexString(leaves[0]).ToLowerInvariant()}"; } private static IEnumerable PairwiseHash(byte[][] nodes) { for (var i = 0; i < nodes.Length; i += 2) { if (i + 1 >= nodes.Length) { // Odd node: hash it alone (promotes to next level) yield return SHA256.HashData(nodes[i]); continue; } // Concatenate and hash pair var combined = new byte[nodes[i].Length + nodes[i + 1].Length]; Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length); Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length); yield return SHA256.HashData(combined); } } private static async Task CreateTarGzAsync(string sourceDir, string outputPath, CancellationToken ct) { var outputDir = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) { Directory.CreateDirectory(outputDir); } await using var fileStream = File.Create(outputPath); await using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal); await TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, includeBaseDirectory: false, ct); } private sealed record BundleEntry(string Path, string Digest, long SizeBytes); } /// /// Interface for audit bundle writing. /// public interface IAuditBundleWriter { Task WriteAsync( AuditBundleWriteRequest request, CancellationToken cancellationToken = default); } #region Request and Result Models /// /// Request for creating an audit bundle. /// public sealed record AuditBundleWriteRequest { /// /// Output path for the bundle (will add .tar.gz if not present). /// public required string OutputPath { get; init; } /// /// Unique bundle identifier (auto-generated if not provided). /// public string? BundleId { get; init; } /// /// Human-readable name for the bundle. /// public string? Name { get; init; } /// /// Scan ID this bundle was created from. /// public required string ScanId { get; init; } /// /// Image reference that was scanned. /// public required string ImageRef { get; init; } /// /// Image digest (sha256:...). /// public required string ImageDigest { get; init; } /// /// Decision from the verdict (pass, warn, block). /// public required string Decision { get; init; } /// /// SBOM document bytes (CycloneDX or SPDX JSON). /// public required byte[] Sbom { get; init; } /// /// Advisory feeds snapshot (NDJSON format). /// public required byte[] FeedsSnapshot { get; init; } /// /// Policy bundle (OPA tar.gz). /// public required byte[] PolicyBundle { get; init; } /// /// Verdict document bytes. /// public required byte[] Verdict { get; init; } /// /// VEX statements (OpenVEX JSON, optional). /// public byte[]? VexStatements { get; init; } /// /// Proof bundle bytes (optional). /// public byte[]? ProofBundle { get; init; } /// /// Trust roots document (optional). /// public byte[]? TrustRoots { get; init; } /// /// Scoring rules (optional). /// public byte[]? ScoringRules { get; init; } /// /// Time anchor for replay context (optional). /// public TimeAnchorInput? TimeAnchor { get; init; } /// /// Whether to sign the manifest. /// public bool Sign { get; init; } = true; /// /// Path to signing key file (PEM format). /// public string? SigningKeyPath { get; init; } /// /// Password for encrypted signing key. /// public string? SigningKeyPassword { get; init; } } /// /// Time anchor input for bundle creation. /// public sealed record TimeAnchorInput { public required DateTimeOffset Timestamp { get; init; } public required string Source { get; init; } } /// /// Result of creating an audit bundle. /// public sealed record AuditBundleWriteResult { public bool Success { get; init; } public string? OutputPath { get; init; } public string? BundleId { get; init; } public string? MerkleRoot { get; init; } public string? BundleDigest { get; init; } public long TotalSizeBytes { get; init; } public int FileCount { get; init; } public DateTimeOffset CreatedAt { get; init; } public string? Error { get; init; } /// /// Whether the manifest was signed. /// public bool Signed { get; init; } /// /// Key ID used for signing. /// public string? SigningKeyId { get; init; } /// /// Algorithm used for signing. /// public string? SigningAlgorithm { get; init; } public static AuditBundleWriteResult Failed(string error) => new() { Success = false, Error = error }; } #endregion