- Added IsolatedReplayContext class to provide an isolated environment for replaying audit bundles without external calls. - Introduced methods for initializing the context, verifying input digests, and extracting inputs for policy evaluation. - Created supporting interfaces and options for context configuration. feat: Create ReplayExecutor for executing policy re-evaluation and verdict comparison - Developed ReplayExecutor class to handle the execution of replay processes, including input verification and verdict comparison. - Implemented detailed drift detection and error handling during replay execution. - Added interfaces for policy evaluation and replay execution options. feat: Add ScanSnapshotFetcher for fetching scan data and snapshots - Introduced ScanSnapshotFetcher class to retrieve necessary scan data and snapshots for audit bundle creation. - Implemented methods to fetch scan metadata, advisory feeds, policy snapshots, and VEX statements. - Created supporting interfaces for scan data, feed snapshots, and policy snapshots.
574 lines
21 KiB
C#
574 lines
21 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Writes self-contained audit bundles for deterministic offline replay.
|
|
/// </summary>
|
|
public sealed class AuditBundleWriter : IAuditBundleWriter
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates an audit bundle from the specified inputs.
|
|
/// </summary>
|
|
public async Task<AuditBundleWriteResult> 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<BundleEntry>();
|
|
var files = new List<BundleFileEntry>();
|
|
|
|
// 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<string> 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()}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes merkle root over all bundle entries for integrity verification.
|
|
/// Uses a binary tree structure with SHA-256 hashing.
|
|
/// </summary>
|
|
private static string ComputeMerkleRoot(List<BundleEntry> 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<byte[]> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for audit bundle writing.
|
|
/// </summary>
|
|
public interface IAuditBundleWriter
|
|
{
|
|
Task<AuditBundleWriteResult> WriteAsync(
|
|
AuditBundleWriteRequest request,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
#region Request and Result Models
|
|
|
|
/// <summary>
|
|
/// Request for creating an audit bundle.
|
|
/// </summary>
|
|
public sealed record AuditBundleWriteRequest
|
|
{
|
|
/// <summary>
|
|
/// Output path for the bundle (will add .tar.gz if not present).
|
|
/// </summary>
|
|
public required string OutputPath { get; init; }
|
|
|
|
/// <summary>
|
|
/// Unique bundle identifier (auto-generated if not provided).
|
|
/// </summary>
|
|
public string? BundleId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Human-readable name for the bundle.
|
|
/// </summary>
|
|
public string? Name { get; init; }
|
|
|
|
/// <summary>
|
|
/// Scan ID this bundle was created from.
|
|
/// </summary>
|
|
public required string ScanId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Image reference that was scanned.
|
|
/// </summary>
|
|
public required string ImageRef { get; init; }
|
|
|
|
/// <summary>
|
|
/// Image digest (sha256:...).
|
|
/// </summary>
|
|
public required string ImageDigest { get; init; }
|
|
|
|
/// <summary>
|
|
/// Decision from the verdict (pass, warn, block).
|
|
/// </summary>
|
|
public required string Decision { get; init; }
|
|
|
|
/// <summary>
|
|
/// SBOM document bytes (CycloneDX or SPDX JSON).
|
|
/// </summary>
|
|
public required byte[] Sbom { get; init; }
|
|
|
|
/// <summary>
|
|
/// Advisory feeds snapshot (NDJSON format).
|
|
/// </summary>
|
|
public required byte[] FeedsSnapshot { get; init; }
|
|
|
|
/// <summary>
|
|
/// Policy bundle (OPA tar.gz).
|
|
/// </summary>
|
|
public required byte[] PolicyBundle { get; init; }
|
|
|
|
/// <summary>
|
|
/// Verdict document bytes.
|
|
/// </summary>
|
|
public required byte[] Verdict { get; init; }
|
|
|
|
/// <summary>
|
|
/// VEX statements (OpenVEX JSON, optional).
|
|
/// </summary>
|
|
public byte[]? VexStatements { get; init; }
|
|
|
|
/// <summary>
|
|
/// Proof bundle bytes (optional).
|
|
/// </summary>
|
|
public byte[]? ProofBundle { get; init; }
|
|
|
|
/// <summary>
|
|
/// Trust roots document (optional).
|
|
/// </summary>
|
|
public byte[]? TrustRoots { get; init; }
|
|
|
|
/// <summary>
|
|
/// Scoring rules (optional).
|
|
/// </summary>
|
|
public byte[]? ScoringRules { get; init; }
|
|
|
|
/// <summary>
|
|
/// Time anchor for replay context (optional).
|
|
/// </summary>
|
|
public TimeAnchorInput? TimeAnchor { get; init; }
|
|
|
|
/// <summary>
|
|
/// Whether to sign the manifest.
|
|
/// </summary>
|
|
public bool Sign { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Path to signing key file (PEM format).
|
|
/// </summary>
|
|
public string? SigningKeyPath { get; init; }
|
|
|
|
/// <summary>
|
|
/// Password for encrypted signing key.
|
|
/// </summary>
|
|
public string? SigningKeyPassword { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Time anchor input for bundle creation.
|
|
/// </summary>
|
|
public sealed record TimeAnchorInput
|
|
{
|
|
public required DateTimeOffset Timestamp { get; init; }
|
|
public required string Source { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of creating an audit bundle.
|
|
/// </summary>
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// Whether the manifest was signed.
|
|
/// </summary>
|
|
public bool Signed { get; init; }
|
|
|
|
/// <summary>
|
|
/// Key ID used for signing.
|
|
/// </summary>
|
|
public string? SigningKeyId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Algorithm used for signing.
|
|
/// </summary>
|
|
public string? SigningAlgorithm { get; init; }
|
|
|
|
public static AuditBundleWriteResult Failed(string error) => new()
|
|
{
|
|
Success = false,
|
|
Error = error
|
|
};
|
|
}
|
|
|
|
#endregion
|