- 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.
476 lines
17 KiB
C#
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);
|
|
}
|