Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.Audit.cs
StellaOps Bot 7e384ab610 feat: Implement IsolatedReplayContext for deterministic audit replay
- Added IsolatedReplayContext class to provide an isolated environment for replaying audit bundles without external calls.
- Introduced methods for initializing the context, verifying input digests, and extracting inputs for policy evaluation.
- Created supporting interfaces and options for context configuration.

feat: Create ReplayExecutor for executing policy re-evaluation and verdict comparison

- Developed ReplayExecutor class to handle the execution of replay processes, including input verification and verdict comparison.
- Implemented detailed drift detection and error handling during replay execution.
- Added interfaces for policy evaluation and replay execution options.

feat: Add ScanSnapshotFetcher for fetching scan data and snapshots

- Introduced ScanSnapshotFetcher class to retrieve necessary scan data and snapshots for audit bundle creation.
- Implemented methods to fetch scan metadata, advisory feeds, policy snapshots, and VEX statements.
- Created supporting interfaces for scan data, feed snapshots, and policy snapshots.
2025-12-23 07:46:40 +02:00

476 lines
17 KiB
C#

// -----------------------------------------------------------------------------
// 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<int> 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<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("audit-export");
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
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<IAuditPackBuilder>();
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<int> 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<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("audit-replay");
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
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<IAuditPackImporter>();
var replayer = scope.ServiceProvider.GetService<IAuditPackReplayer>();
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<int> HandleAuditVerifyAsync(
IServiceProvider services,
string bundlePath,
string format,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
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<IAuditPackImporter>();
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)}");
}
}
/// <summary>
/// Result of an audit pack replay operation.
/// </summary>
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<string> Drifts { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
public DateTimeOffset ReplayedAt { get; init; }
}
public enum AuditReplayStatus
{
Match,
Drift,
Error
}
/// <summary>
/// Options for replay operation.
/// </summary>
public sealed record ReplayOptions
{
public bool Strict { get; init; }
public bool Offline { get; init; }
public DateTimeOffset? TimeAnchor { get; init; }
public string? OutputDirectory { get; init; }
}
/// <summary>
/// Options for import operation.
/// </summary>
public sealed record ImportOptions
{
public string? TrustStorePath { get; init; }
public string? OutputDirectory { get; init; }
public bool VerifyOnly { get; init; }
}
/// <summary>
/// Interface for audit pack import.
/// </summary>
public interface IAuditPackImporter
{
Task<StellaOps.AuditPack.Models.AuditPack> ImportAsync(string bundlePath, ImportOptions options, CancellationToken ct = default);
}
/// <summary>
/// Interface for audit pack replay.
/// </summary>
public interface IAuditPackReplayer
{
Task<AuditReplayResult> ReplayAsync(StellaOps.AuditPack.Models.AuditPack pack, ReplayOptions options, CancellationToken ct = default);
}