// ----------------------------------------------------------------------------- // CommandHandlers.Audit.cs // Sprint: SPRINT_4300_0001_0002_one_command_audit_replay // Description: Command handlers for audit pack export, replay, and verification. // ----------------------------------------------------------------------------- using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.AuditPack.Models; using StellaOps.AuditPack.Services; using StellaOps.Cli.Configuration; using StellaOps.Cli.Services; using StellaOps.Cli.Telemetry; using Spectre.Console; namespace StellaOps.Cli.Commands; internal static partial class CommandHandlers { private static readonly JsonSerializerOptions AuditJsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; internal static async Task HandleAuditExportAsync( IServiceProvider services, string scanId, string? output, string? name, bool sign, string? signingKey, bool includeFeeds, bool includePolicy, bool minimal, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("audit-export"); var options = scope.ServiceProvider.GetRequiredService(); using var activity = CliActivitySource.Instance.StartActivity("cli.audit.export", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("audit export"); if (string.IsNullOrWhiteSpace(scanId)) { AnsiConsole.MarkupLine("[red]Error:[/] --scan-id is required."); Environment.ExitCode = 2; return 2; } var outputPath = output ?? $"audit-{scanId}.tar.gz"; try { AnsiConsole.MarkupLine($"Exporting audit pack for scan [bold]{Markup.Escape(scanId)}[/]..."); var builder = scope.ServiceProvider.GetService(); if (builder is null) { AnsiConsole.MarkupLine("[red]Error:[/] Audit pack builder not available."); Environment.ExitCode = 2; return 2; } // Build the audit pack var packOptions = new AuditPackOptions { Name = name, IncludeFeeds = includeFeeds, IncludePolicies = includePolicy, MinimizeSize = minimal }; var scanResult = new ScanResult(scanId); var pack = await builder.BuildAsync(scanResult, packOptions, cancellationToken).ConfigureAwait(false); // Export to archive var exportOptions = new ExportOptions { Sign = sign, SigningKey = signingKey, Compress = true }; await builder.ExportAsync(pack, outputPath, exportOptions, cancellationToken).ConfigureAwait(false); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine($"[green]Success![/] Audit pack exported to: [bold]{Markup.Escape(outputPath)}[/]"); AnsiConsole.MarkupLine($"Pack ID: {Markup.Escape(pack.PackId)}"); AnsiConsole.MarkupLine($"Pack digest: {Markup.Escape(pack.PackDigest ?? "unsigned")}"); if (verbose) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("Contents:"); AnsiConsole.MarkupLine($" Files: {pack.Contents.FileCount}"); AnsiConsole.MarkupLine($" Size: {FormatBytes(pack.Contents.TotalSizeBytes)}"); AnsiConsole.MarkupLine($" Attestations: {pack.Attestations.Length}"); AnsiConsole.MarkupLine($" SBOMs: {pack.Sboms.Length}"); AnsiConsole.MarkupLine($" VEX documents: {pack.VexDocuments.Length}"); } Environment.ExitCode = 0; return 0; } catch (Exception ex) { logger.LogError(ex, "Audit export failed for scan {ScanId}", scanId); AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); Environment.ExitCode = 2; return 2; } } internal static async Task HandleAuditReplayAsync( IServiceProvider services, string bundlePath, string? outputDir, string format, bool strict, bool offline, string? trustStore, string? timeAnchor, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("audit-replay"); var options = scope.ServiceProvider.GetRequiredService(); using var activity = CliActivitySource.Instance.StartActivity("cli.audit.replay", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("audit replay"); if (string.IsNullOrWhiteSpace(bundlePath)) { WriteAuditError("Bundle path is required.", format); Environment.ExitCode = 2; return 2; } if (!File.Exists(bundlePath)) { WriteAuditError($"Bundle not found: {bundlePath}", format); Environment.ExitCode = 2; return 2; } // Enforce offline mode if requested if (offline) { OfflineModeGuard.IsOffline = true; logger.LogDebug("Running in offline mode as requested."); } try { var importer = scope.ServiceProvider.GetService(); var replayer = scope.ServiceProvider.GetService(); if (importer is null || replayer is null) { WriteAuditError("Audit pack services not available.", format); Environment.ExitCode = 2; return 2; } // Parse time anchor if provided DateTimeOffset? timeAnchorParsed = null; if (!string.IsNullOrWhiteSpace(timeAnchor)) { if (DateTimeOffset.TryParse(timeAnchor, out var parsed)) { timeAnchorParsed = parsed; } else { WriteAuditError($"Invalid time anchor format: {timeAnchor}", format); Environment.ExitCode = 2; return 2; } } // Import the audit pack if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { AnsiConsole.MarkupLine($"Loading audit pack: [bold]{Markup.Escape(bundlePath)}[/]..."); } var importOptions = new ImportOptions { TrustStorePath = trustStore, OutputDirectory = outputDir }; var pack = await importer.ImportAsync(bundlePath, importOptions, cancellationToken).ConfigureAwait(false); // Execute replay if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { AnsiConsole.MarkupLine("Executing replay..."); } var replayOptions = new ReplayOptions { Strict = strict, Offline = offline, TimeAnchor = timeAnchorParsed, OutputDirectory = outputDir }; var result = await replayer.ReplayAsync(pack, replayOptions, cancellationToken).ConfigureAwait(false); // Output results WriteAuditReplayResult(result, format, verbose); // Exit code based on result var exitCode = result.Status switch { AuditReplayStatus.Match => 0, AuditReplayStatus.Drift => 1, _ => 2 }; Environment.ExitCode = exitCode; return exitCode; } catch (Exception ex) { logger.LogError(ex, "Audit replay failed for bundle {BundlePath}", bundlePath); WriteAuditError($"Replay failed: {ex.Message}", format); Environment.ExitCode = 2; return 2; } } internal static async Task HandleAuditVerifyAsync( IServiceProvider services, string bundlePath, string format, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("audit-verify"); using var activity = CliActivitySource.Instance.StartActivity("cli.audit.verify", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("audit verify"); if (string.IsNullOrWhiteSpace(bundlePath)) { WriteAuditError("Bundle path is required.", format); Environment.ExitCode = 2; return 2; } if (!File.Exists(bundlePath)) { WriteAuditError($"Bundle not found: {bundlePath}", format); Environment.ExitCode = 2; return 2; } try { var importer = scope.ServiceProvider.GetService(); if (importer is null) { WriteAuditError("Audit pack importer not available.", format); Environment.ExitCode = 2; return 2; } var importOptions = new ImportOptions { VerifyOnly = true }; var pack = await importer.ImportAsync(bundlePath, importOptions, cancellationToken).ConfigureAwait(false); if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { var result = new { status = "valid", packId = pack.PackId, packDigest = pack.PackDigest, createdAt = pack.CreatedAt, fileCount = pack.Contents.FileCount, signatureValid = !string.IsNullOrWhiteSpace(pack.Signature) }; AnsiConsole.WriteLine(JsonSerializer.Serialize(result, AuditJsonOptions)); } else { AnsiConsole.MarkupLine("[green]Bundle verification passed![/]"); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine($"Pack ID: {Markup.Escape(pack.PackId)}"); AnsiConsole.MarkupLine($"Pack digest: {Markup.Escape(pack.PackDigest ?? "N/A")}"); AnsiConsole.MarkupLine($"Created: {pack.CreatedAt:u}"); AnsiConsole.MarkupLine($"Files: {pack.Contents.FileCount}"); AnsiConsole.MarkupLine($"Signed: {(!string.IsNullOrWhiteSpace(pack.Signature) ? "[green]Yes[/]" : "[yellow]No[/]")}"); if (verbose) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("Contents:"); AnsiConsole.MarkupLine($" Attestations: {pack.Attestations.Length}"); AnsiConsole.MarkupLine($" SBOMs: {pack.Sboms.Length}"); AnsiConsole.MarkupLine($" VEX documents: {pack.VexDocuments.Length}"); AnsiConsole.MarkupLine($" Trust roots: {pack.TrustRoots.Length}"); } } Environment.ExitCode = 0; return 0; } catch (Exception ex) { logger.LogError(ex, "Bundle verification failed for {BundlePath}", bundlePath); WriteAuditError($"Verification failed: {ex.Message}", format); Environment.ExitCode = 2; return 2; } } private static void WriteAuditReplayResult(AuditReplayResult result, string format, bool verbose) { if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { AnsiConsole.WriteLine(JsonSerializer.Serialize(result, AuditJsonOptions)); return; } AnsiConsole.WriteLine(); var statusColor = result.Status switch { AuditReplayStatus.Match => "green", AuditReplayStatus.Drift => "yellow", _ => "red" }; AnsiConsole.MarkupLine($"Replay Status: [{statusColor}]{result.Status}[/]"); AnsiConsole.WriteLine(); // Input validation table var inputTable = new Table().AddColumns("Input", "Expected", "Actual", "Match"); inputTable.AddRow( "SBOM Digest", TruncateDigest(result.ExpectedSbomDigest), TruncateDigest(result.ActualSbomDigest), FormatMatch(result.SbomMatches)); inputTable.AddRow( "Feeds Digest", TruncateDigest(result.ExpectedFeedsDigest), TruncateDigest(result.ActualFeedsDigest), FormatMatch(result.FeedsMatches)); inputTable.AddRow( "Policy Digest", TruncateDigest(result.ExpectedPolicyDigest), TruncateDigest(result.ActualPolicyDigest), FormatMatch(result.PolicyMatches)); AnsiConsole.Write(inputTable); AnsiConsole.WriteLine(); // Verdict comparison AnsiConsole.MarkupLine($"Original Verdict: [bold]{Markup.Escape(result.OriginalVerdictDigest ?? "-")}[/]"); AnsiConsole.MarkupLine($"Replayed Verdict: [bold]{Markup.Escape(result.ReplayedVerdictDigest ?? "-")}[/]"); AnsiConsole.MarkupLine($"Verdict Match: {FormatMatch(result.VerdictMatches)}"); if (verbose && result.Drifts.Count > 0) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[yellow]Detected Drifts:[/]"); foreach (var drift in result.Drifts) { AnsiConsole.MarkupLine($" - {Markup.Escape(drift)}"); } } if (result.Errors.Count > 0) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[red]Errors:[/]"); foreach (var error in result.Errors) { AnsiConsole.MarkupLine($" - {Markup.Escape(error)}"); } } } private static void WriteAuditError(string message, string format) { if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { var payload = new { status = "error", message }; AnsiConsole.WriteLine(JsonSerializer.Serialize(payload, AuditJsonOptions)); return; } AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}"); } } /// /// Result of an audit pack replay operation. /// public sealed record AuditReplayResult { public required string PackId { get; init; } public required AuditReplayStatus Status { get; init; } public string? ExpectedSbomDigest { get; init; } public string? ActualSbomDigest { get; init; } public bool? SbomMatches { get; init; } public string? ExpectedFeedsDigest { get; init; } public string? ActualFeedsDigest { get; init; } public bool? FeedsMatches { get; init; } public string? ExpectedPolicyDigest { get; init; } public string? ActualPolicyDigest { get; init; } public bool? PolicyMatches { get; init; } public string? OriginalVerdictDigest { get; init; } public string? ReplayedVerdictDigest { get; init; } public bool? VerdictMatches { get; init; } public IReadOnlyList Drifts { get; init; } = Array.Empty(); public IReadOnlyList Errors { get; init; } = Array.Empty(); public DateTimeOffset ReplayedAt { get; init; } } public enum AuditReplayStatus { Match, Drift, Error } /// /// Options for replay operation. /// public sealed record ReplayOptions { public bool Strict { get; init; } public bool Offline { get; init; } public DateTimeOffset? TimeAnchor { get; init; } public string? OutputDirectory { get; init; } } /// /// Options for import operation. /// public sealed record ImportOptions { public string? TrustStorePath { get; init; } public string? OutputDirectory { get; init; } public bool VerifyOnly { get; init; } } /// /// Interface for audit pack import. /// public interface IAuditPackImporter { Task ImportAsync(string bundlePath, ImportOptions options, CancellationToken ct = default); } /// /// Interface for audit pack replay. /// public interface IAuditPackReplayer { Task ReplayAsync(StellaOps.AuditPack.Models.AuditPack pack, ReplayOptions options, CancellationToken ct = default); }