Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
// VerdictBundleExporter - Export replay bundle for offline verification
|
||||
// Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation
|
||||
// Task 9: Verdict Replay Bundle Exporter
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Verdict.Schema;
|
||||
|
||||
namespace StellaOps.Verdict.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting verdict replay bundles for offline verification.
|
||||
/// </summary>
|
||||
public interface IVerdictBundleExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Export a verdict with all inputs to a replay bundle.
|
||||
/// </summary>
|
||||
/// <param name="verdict">The verdict to export.</param>
|
||||
/// <param name="context">Additional context for the bundle.</param>
|
||||
/// <param name="outputPath">Output path for the bundle (.tar.zst or directory).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the export operation.</returns>
|
||||
Task<VerdictBundleExportResult> ExportAsync(
|
||||
StellaVerdict verdict,
|
||||
VerdictBundleContext context,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Export a verdict bundle to a stream.
|
||||
/// </summary>
|
||||
Task<VerdictBundleExportResult> ExportToStreamAsync(
|
||||
StellaVerdict verdict,
|
||||
VerdictBundleContext context,
|
||||
Stream outputStream,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional context for bundle export.
|
||||
/// </summary>
|
||||
public sealed class VerdictBundleContext
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM slice relevant to the verdict.
|
||||
/// </summary>
|
||||
public string? SbomSliceJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory feed snapshots.
|
||||
/// </summary>
|
||||
public IList<VerdictFeedSnapshot> FeedSnapshots { get; set; } = new List<VerdictFeedSnapshot>();
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle used for evaluation.
|
||||
/// </summary>
|
||||
public string? PolicyBundleJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle version.
|
||||
/// </summary>
|
||||
public string? PolicyBundleVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability analysis data.
|
||||
/// </summary>
|
||||
public string? ReachabilityJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime configuration.
|
||||
/// </summary>
|
||||
public string? RuntimeConfigJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Include full signatures in bundle.
|
||||
/// </summary>
|
||||
public bool IncludeSignatures { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Compress the bundle.
|
||||
/// </summary>
|
||||
public bool Compress { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory feed snapshot for replay.
|
||||
/// </summary>
|
||||
public sealed class VerdictFeedSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Feed source name (e.g., "nvd", "debian-vex").
|
||||
/// </summary>
|
||||
public required string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Feed date.
|
||||
/// </summary>
|
||||
public required DateOnly Date { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Feed content (JSON).
|
||||
/// </summary>
|
||||
public required string ContentJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for verification.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle export.
|
||||
/// </summary>
|
||||
public sealed record VerdictBundleExportResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? OutputPath { get; init; }
|
||||
public string? ManifestHash { get; init; }
|
||||
public long SizeBytes { get; init; }
|
||||
public int FileCount { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of verdict bundle exporter.
|
||||
/// </summary>
|
||||
public sealed class VerdictBundleExporter : IVerdictBundleExporter
|
||||
{
|
||||
private readonly ILogger<VerdictBundleExporter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public VerdictBundleExporter(
|
||||
ILogger<VerdictBundleExporter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<VerdictBundleExportResult> ExportAsync(
|
||||
StellaVerdict verdict,
|
||||
VerdictBundleContext context,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create output directory if exporting to directory
|
||||
if (!outputPath.EndsWith(".tar.zst", StringComparison.OrdinalIgnoreCase) &&
|
||||
!outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) &&
|
||||
!outputPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await ExportToDirectoryAsync(verdict, context, outputPath, cancellationToken);
|
||||
}
|
||||
|
||||
// Export to compressed archive
|
||||
await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
|
||||
var result = await ExportToStreamAsync(verdict, context, fileStream, cancellationToken);
|
||||
|
||||
return result with { OutputPath = outputPath };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export verdict bundle to {Path}", outputPath);
|
||||
return new VerdictBundleExportResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<VerdictBundleExportResult> ExportToStreamAsync(
|
||||
StellaVerdict verdict,
|
||||
VerdictBundleContext context,
|
||||
Stream outputStream,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "1.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow().ToString("O"),
|
||||
VerdictId = verdict.VerdictId,
|
||||
VulnerabilityId = verdict.Subject.VulnerabilityId,
|
||||
Purl = verdict.Subject.Purl,
|
||||
Files = new List<BundleFileEntry>()
|
||||
};
|
||||
|
||||
int fileCount = 0;
|
||||
long totalSize = 0;
|
||||
|
||||
using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
// Add verdict.json
|
||||
var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions);
|
||||
await AddEntryAsync(archive, "verdict.json", verdictJson, manifest);
|
||||
fileCount++;
|
||||
totalSize += verdictJson.Length;
|
||||
|
||||
// Add SBOM slice if available
|
||||
if (!string.IsNullOrEmpty(context.SbomSliceJson))
|
||||
{
|
||||
await AddEntryAsync(archive, "sbom-slice.json", context.SbomSliceJson, manifest);
|
||||
fileCount++;
|
||||
totalSize += context.SbomSliceJson.Length;
|
||||
}
|
||||
|
||||
// Add feed snapshots
|
||||
foreach (var feed in context.FeedSnapshots)
|
||||
{
|
||||
var feedPath = $"feeds/{feed.Source}-{feed.Date:yyyy-MM-dd}.json";
|
||||
await AddEntryAsync(archive, feedPath, feed.ContentJson, manifest);
|
||||
fileCount++;
|
||||
totalSize += feed.ContentJson.Length;
|
||||
}
|
||||
|
||||
// Add policy bundle
|
||||
if (!string.IsNullOrEmpty(context.PolicyBundleJson))
|
||||
{
|
||||
var policyPath = $"policy/bundle-{context.PolicyBundleVersion ?? "latest"}.json";
|
||||
await AddEntryAsync(archive, policyPath, context.PolicyBundleJson, manifest);
|
||||
fileCount++;
|
||||
totalSize += context.PolicyBundleJson.Length;
|
||||
}
|
||||
|
||||
// Add reachability data
|
||||
if (!string.IsNullOrEmpty(context.ReachabilityJson))
|
||||
{
|
||||
await AddEntryAsync(archive, "callgraph/reachability.json", context.ReachabilityJson, manifest);
|
||||
fileCount++;
|
||||
totalSize += context.ReachabilityJson.Length;
|
||||
}
|
||||
|
||||
// Add runtime config
|
||||
if (!string.IsNullOrEmpty(context.RuntimeConfigJson))
|
||||
{
|
||||
await AddEntryAsync(archive, "config/runtime.json", context.RuntimeConfigJson, manifest);
|
||||
fileCount++;
|
||||
totalSize += context.RuntimeConfigJson.Length;
|
||||
}
|
||||
|
||||
// Add manifest as last file
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal);
|
||||
await using (var manifestStream = manifestEntry.Open())
|
||||
{
|
||||
await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson), cancellationToken);
|
||||
}
|
||||
fileCount++;
|
||||
totalSize += manifestJson.Length;
|
||||
|
||||
var manifestHash = ComputeHash(manifestJson);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported verdict bundle {VerdictId} with {FileCount} files ({Size} bytes)",
|
||||
verdict.VerdictId, fileCount, totalSize);
|
||||
|
||||
return new VerdictBundleExportResult
|
||||
{
|
||||
Success = true,
|
||||
ManifestHash = manifestHash,
|
||||
SizeBytes = totalSize,
|
||||
FileCount = fileCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export verdict bundle to stream");
|
||||
return new VerdictBundleExportResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<VerdictBundleExportResult> ExportToDirectoryAsync(
|
||||
StellaVerdict verdict,
|
||||
VerdictBundleContext context,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
Version = "1.0",
|
||||
CreatedAt = _timeProvider.GetUtcNow().ToString("O"),
|
||||
VerdictId = verdict.VerdictId,
|
||||
VulnerabilityId = verdict.Subject.VulnerabilityId,
|
||||
Purl = verdict.Subject.Purl,
|
||||
Files = new List<BundleFileEntry>()
|
||||
};
|
||||
|
||||
int fileCount = 0;
|
||||
long totalSize = 0;
|
||||
|
||||
// Write verdict.json
|
||||
var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions);
|
||||
await WriteFileAsync(outputPath, "verdict.json", verdictJson, manifest, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += verdictJson.Length;
|
||||
|
||||
// Write SBOM slice
|
||||
if (!string.IsNullOrEmpty(context.SbomSliceJson))
|
||||
{
|
||||
await WriteFileAsync(outputPath, "sbom-slice.json", context.SbomSliceJson, manifest, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += context.SbomSliceJson.Length;
|
||||
}
|
||||
|
||||
// Write feed snapshots
|
||||
if (context.FeedSnapshots.Count > 0)
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(outputPath, "feeds"));
|
||||
foreach (var feed in context.FeedSnapshots)
|
||||
{
|
||||
var feedPath = $"feeds/{feed.Source}-{feed.Date:yyyy-MM-dd}.json";
|
||||
await WriteFileAsync(outputPath, feedPath, feed.ContentJson, manifest, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += feed.ContentJson.Length;
|
||||
}
|
||||
}
|
||||
|
||||
// Write policy bundle
|
||||
if (!string.IsNullOrEmpty(context.PolicyBundleJson))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(outputPath, "policy"));
|
||||
var policyPath = $"policy/bundle-{context.PolicyBundleVersion ?? "latest"}.json";
|
||||
await WriteFileAsync(outputPath, policyPath, context.PolicyBundleJson, manifest, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += context.PolicyBundleJson.Length;
|
||||
}
|
||||
|
||||
// Write reachability data
|
||||
if (!string.IsNullOrEmpty(context.ReachabilityJson))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(outputPath, "callgraph"));
|
||||
await WriteFileAsync(outputPath, "callgraph/reachability.json", context.ReachabilityJson, manifest, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += context.ReachabilityJson.Length;
|
||||
}
|
||||
|
||||
// Write runtime config
|
||||
if (!string.IsNullOrEmpty(context.RuntimeConfigJson))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(outputPath, "config"));
|
||||
await WriteFileAsync(outputPath, "config/runtime.json", context.RuntimeConfigJson, manifest, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += context.RuntimeConfigJson.Length;
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
await File.WriteAllTextAsync(Path.Combine(outputPath, "manifest.json"), manifestJson, cancellationToken);
|
||||
fileCount++;
|
||||
totalSize += manifestJson.Length;
|
||||
|
||||
var manifestHash = ComputeHash(manifestJson);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported verdict bundle {VerdictId} to {Path} with {FileCount} files",
|
||||
verdict.VerdictId, outputPath, fileCount);
|
||||
|
||||
return new VerdictBundleExportResult
|
||||
{
|
||||
Success = true,
|
||||
OutputPath = outputPath,
|
||||
ManifestHash = manifestHash,
|
||||
SizeBytes = totalSize,
|
||||
FileCount = fileCount
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task AddEntryAsync(
|
||||
ZipArchive archive,
|
||||
string path,
|
||||
string content,
|
||||
BundleManifest manifest)
|
||||
{
|
||||
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
|
||||
await using var stream = entry.Open();
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
manifest.Files.Add(new BundleFileEntry
|
||||
{
|
||||
Path = path,
|
||||
Hash = ComputeHash(content),
|
||||
Size = content.Length
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task WriteFileAsync(
|
||||
string basePath,
|
||||
string relativePath,
|
||||
string content,
|
||||
BundleManifest manifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = Path.Combine(basePath, relativePath);
|
||||
await File.WriteAllTextAsync(fullPath, content, cancellationToken);
|
||||
|
||||
manifest.Files.Add(new BundleFileEntry
|
||||
{
|
||||
Path = relativePath,
|
||||
Hash = ComputeHash(content),
|
||||
Size = content.Length
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed class BundleManifest
|
||||
{
|
||||
public required string Version { get; set; }
|
||||
public required string CreatedAt { get; set; }
|
||||
public required string VerdictId { get; set; }
|
||||
public required string VulnerabilityId { get; set; }
|
||||
public required string Purl { get; set; }
|
||||
public required List<BundleFileEntry> Files { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleFileEntry
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Hash { get; set; }
|
||||
public required long Size { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user