stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -1,14 +1,3 @@
// -----------------------------------------------------------------------------
// 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 StellaOps.AuditPack.Models;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AuditPack.Services;
@@ -16,12 +5,12 @@ namespace StellaOps.AuditPack.Services;
/// <summary>
/// Writes self-contained audit bundles for deterministic offline replay.
/// </summary>
public sealed class AuditBundleWriter : IAuditBundleWriter
public sealed partial class AuditBundleWriter : IAuditBundleWriter
{
private readonly TimeProvider _timeProvider;
private readonly IAuditPackIdGenerator _idGenerator;
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
@@ -32,498 +21,4 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
_timeProvider = timeProvider ?? TimeProvider.System;
_idGenerator = idGenerator ?? new GuidAuditPackIdGenerator();
}
/// <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);
try
{
var entries = new List<BundleEntry>();
var files = new List<BundleFileEntry>();
var archiveEntries = new List<ArchiveEntry>();
// Write SBOM
string sbomDigest;
if (request.Sbom is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("sbom.json", request.Sbom));
}
else
{
return AuditBundleWriteResult.Failed("SBOM is required for audit bundle");
}
// Write feeds snapshot
string feedsDigest;
if (request.FeedsSnapshot is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("feeds/feeds-snapshot.ndjson", request.FeedsSnapshot));
}
else
{
return AuditBundleWriteResult.Failed("Feeds snapshot is required for audit bundle");
}
// Write policy bundle
string policyDigest;
if (request.PolicyBundle is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("policy/policy-bundle.tar.gz", request.PolicyBundle));
}
else
{
return AuditBundleWriteResult.Failed("Policy bundle is required for audit bundle");
}
// Write VEX (optional)
string? vexDigest = null;
if (request.VexStatements is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("vex/vex-statements.json", request.VexStatements));
}
// Write verdict
string verdictDigest;
if (request.Verdict is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("verdict.json", request.Verdict));
}
else
{
return AuditBundleWriteResult.Failed("Verdict is required for audit bundle");
}
// Write proof bundle (optional)
if (request.ProofBundle is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("proof/proof-bundle.json", request.ProofBundle));
}
// Write trust roots (optional)
string? trustRootsDigest = null;
if (request.TrustRoots is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("trust/trust-roots.json", request.TrustRoots));
}
// Write scoring rules (optional)
string? scoringDigest = null;
if (request.ScoringRules is not null)
{
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
});
archiveEntries.Add(new ArchiveEntry("scoring-rules.json", request.ScoringRules));
}
// Write time anchor (optional)
TimeAnchor? timeAnchor = null;
if (request.TimeAnchor is not null)
{
var timeAnchorBytes = CanonicalJson.Serialize(request.TimeAnchor, JsonOptions);
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
});
archiveEntries.Add(new ArchiveEntry("time-anchor.json", timeAnchorBytes));
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 ?? _idGenerator.NewBundleId(),
Name = request.Name ?? $"audit-{request.ScanId}",
CreatedAt = _timeProvider.GetUtcNow(),
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 = CanonicalJson.Serialize(manifest, JsonOptions);
archiveEntries.Add(new ArchiveEntry("manifest.json", manifestBytes));
// 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)
{
archiveEntries.Add(new ArchiveEntry("manifest.sig", signResult.Envelope));
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 ArchiveUtilities.WriteTarGzAsync(outputPath, archiveEntries, 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}");
}
}
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 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