Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -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; }
}
}