save progress
This commit is contained in:
@@ -5,9 +5,6 @@
|
||||
// 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;
|
||||
@@ -20,12 +17,21 @@ namespace StellaOps.AuditPack.Services;
|
||||
/// </summary>
|
||||
public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuditPackIdGenerator _idGenerator;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public AuditBundleWriter(TimeProvider? timeProvider = null, IAuditPackIdGenerator? idGenerator = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_idGenerator = idGenerator ?? new GuidAuditPackIdGenerator();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an audit bundle from the specified inputs.
|
||||
/// </summary>
|
||||
@@ -36,20 +42,16 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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>();
|
||||
var archiveEntries = new List<ArchiveEntry>();
|
||||
|
||||
// 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
|
||||
@@ -59,6 +61,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
SizeBytes = request.Sbom.Length,
|
||||
ContentType = BundleContentType.Sbom
|
||||
});
|
||||
archiveEntries.Add(new ArchiveEntry("sbom.json", request.Sbom));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -69,10 +72,6 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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
|
||||
@@ -82,6 +81,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
SizeBytes = request.FeedsSnapshot.Length,
|
||||
ContentType = BundleContentType.Feeds
|
||||
});
|
||||
archiveEntries.Add(new ArchiveEntry("feeds/feeds-snapshot.ndjson", request.FeedsSnapshot));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -92,10 +92,6 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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
|
||||
@@ -105,6 +101,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
SizeBytes = request.PolicyBundle.Length,
|
||||
ContentType = BundleContentType.Policy
|
||||
});
|
||||
archiveEntries.Add(new ArchiveEntry("policy/policy-bundle.tar.gz", request.PolicyBundle));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -115,10 +112,6 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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
|
||||
@@ -128,14 +121,13 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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)
|
||||
{
|
||||
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
|
||||
@@ -145,6 +137,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
SizeBytes = request.Verdict.Length,
|
||||
ContentType = BundleContentType.Verdict
|
||||
});
|
||||
archiveEntries.Add(new ArchiveEntry("verdict.json", request.Verdict));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -154,10 +147,6 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
// 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
|
||||
@@ -167,16 +156,13 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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)
|
||||
{
|
||||
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
|
||||
@@ -186,14 +172,13 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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)
|
||||
{
|
||||
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
|
||||
@@ -203,15 +188,14 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
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 timeAnchorPath = Path.Combine(tempDir, "time-anchor.json");
|
||||
var timeAnchorBytes = JsonSerializer.SerializeToUtf8Bytes(request.TimeAnchor, JsonOptions);
|
||||
await File.WriteAllBytesAsync(timeAnchorPath, timeAnchorBytes, cancellationToken);
|
||||
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
|
||||
@@ -221,6 +205,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
SizeBytes = timeAnchorBytes.Length,
|
||||
ContentType = BundleContentType.TimeAnchor
|
||||
});
|
||||
archiveEntries.Add(new ArchiveEntry("time-anchor.json", timeAnchorBytes));
|
||||
timeAnchor = new TimeAnchor
|
||||
{
|
||||
Timestamp = request.TimeAnchor.Timestamp,
|
||||
@@ -235,9 +220,9 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
// Build manifest
|
||||
var manifest = new AuditBundleManifest
|
||||
{
|
||||
BundleId = request.BundleId ?? Guid.NewGuid().ToString("N"),
|
||||
BundleId = request.BundleId ?? _idGenerator.NewBundleId(),
|
||||
Name = request.Name ?? $"audit-{request.ScanId}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ScanId = request.ScanId,
|
||||
ImageRef = request.ImageRef,
|
||||
ImageDigest = request.ImageDigest,
|
||||
@@ -259,9 +244,8 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
var manifestPath = Path.Combine(tempDir, "manifest.json");
|
||||
await File.WriteAllBytesAsync(manifestPath, manifestBytes, cancellationToken);
|
||||
var manifestBytes = CanonicalJson.Serialize(manifest, JsonOptions);
|
||||
archiveEntries.Add(new ArchiveEntry("manifest.json", manifestBytes));
|
||||
|
||||
// Sign manifest if requested
|
||||
string? signingKeyId = null;
|
||||
@@ -282,8 +266,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
|
||||
if (signResult.Success && signResult.Envelope is not null)
|
||||
{
|
||||
var signaturePath = Path.Combine(tempDir, "manifest.sig");
|
||||
await File.WriteAllBytesAsync(signaturePath, signResult.Envelope, cancellationToken);
|
||||
archiveEntries.Add(new ArchiveEntry("manifest.sig", signResult.Envelope));
|
||||
signingKeyId = signResult.KeyId;
|
||||
signingAlgorithm = signResult.Algorithm;
|
||||
signed = true;
|
||||
@@ -297,7 +280,7 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
outputPath = $"{outputPath}.tar.gz";
|
||||
}
|
||||
|
||||
await CreateTarGzAsync(tempDir, outputPath, cancellationToken);
|
||||
await ArchiveUtilities.WriteTarGzAsync(outputPath, archiveEntries, cancellationToken);
|
||||
|
||||
var bundleDigest = await ComputeFileDigestAsync(outputPath, cancellationToken);
|
||||
|
||||
@@ -320,21 +303,6 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
{
|
||||
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)
|
||||
@@ -395,19 +363,6 @@ public sealed class AuditBundleWriter : IAuditBundleWriter
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user