571 lines
20 KiB
C#
571 lines
20 KiB
C#
// <copyright file="ProveCommandGroup.cs" company="Stella Operations">
|
|
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// ProveCommandGroup.cs
|
|
// Sprint: SPRINT_20260105_002_001_REPLAY
|
|
// Task: RPL-015 - Create ProveCommandGroup.cs with command structure
|
|
// Description: CLI command for generating replay proofs for image verdicts.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.CommandLine;
|
|
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Cli.Replay;
|
|
using StellaOps.Replay.Core.Models;
|
|
using StellaOps.Verdict;
|
|
using Spectre.Console;
|
|
|
|
namespace StellaOps.Cli.Commands;
|
|
|
|
/// <summary>
|
|
/// Command group for replay proof operations.
|
|
/// Implements: stella prove --image sha256:... [--at timestamp] [--snapshot id] [--output format]
|
|
/// </summary>
|
|
public static class ProveCommandGroup
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Build the prove command tree.
|
|
/// </summary>
|
|
public static Command BuildProveCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var imageOption = new Option<string>("--image", "-i")
|
|
{
|
|
Description = "Image digest (sha256:...) to generate proof for",
|
|
Required = true
|
|
};
|
|
|
|
var atOption = new Option<string?>("--at", "-a")
|
|
{
|
|
Description = "Point-in-time for snapshot lookup (ISO 8601 format, e.g., 2026-01-05T10:00:00Z)"
|
|
};
|
|
|
|
var snapshotOption = new Option<string?>("--snapshot", "-s")
|
|
{
|
|
Description = "Explicit snapshot ID to use instead of time lookup"
|
|
};
|
|
|
|
var bundleOption = new Option<string?>("--bundle", "-b")
|
|
{
|
|
Description = "Path to local replay bundle directory (offline mode)"
|
|
};
|
|
|
|
var outputOption = new Option<string>("--output", "-o")
|
|
{
|
|
Description = "Output format: compact, json, full"
|
|
};
|
|
outputOption.SetDefaultValue("compact");
|
|
outputOption.FromAmong("compact", "json", "full");
|
|
|
|
var proveCommand = new Command("prove", "Generate replay proof for an image verdict")
|
|
{
|
|
imageOption,
|
|
atOption,
|
|
snapshotOption,
|
|
bundleOption,
|
|
outputOption,
|
|
verboseOption
|
|
};
|
|
|
|
proveCommand.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var image = parseResult.GetValue(imageOption) ?? string.Empty;
|
|
var at = parseResult.GetValue(atOption);
|
|
var snapshot = parseResult.GetValue(snapshotOption);
|
|
var bundle = parseResult.GetValue(bundleOption);
|
|
var output = parseResult.GetValue(outputOption) ?? "compact";
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await HandleProveAsync(
|
|
services,
|
|
image,
|
|
at,
|
|
snapshot,
|
|
bundle,
|
|
output,
|
|
verbose,
|
|
cancellationToken);
|
|
});
|
|
|
|
return proveCommand;
|
|
}
|
|
|
|
private static async Task<int> HandleProveAsync(
|
|
IServiceProvider services,
|
|
string imageDigest,
|
|
string? atTimestamp,
|
|
string? snapshotId,
|
|
string? bundlePath,
|
|
string outputFormat,
|
|
bool verbose,
|
|
CancellationToken ct)
|
|
{
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
var logger = loggerFactory?.CreateLogger(typeof(ProveCommandGroup));
|
|
|
|
try
|
|
{
|
|
// Validate image digest format
|
|
if (!imageDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) &&
|
|
!imageDigest.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Image digest must start with sha256: or sha512:");
|
|
return ProveExitCodes.InvalidInput;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Generating replay proof for image: {ImageDigest}", imageDigest);
|
|
}
|
|
|
|
// Mode 1: Local bundle path specified (offline mode)
|
|
if (!string.IsNullOrEmpty(bundlePath))
|
|
{
|
|
return await HandleLocalBundleProveAsync(
|
|
services,
|
|
bundlePath,
|
|
imageDigest,
|
|
outputFormat,
|
|
verbose,
|
|
logger,
|
|
ct);
|
|
}
|
|
|
|
// Mode 2: Resolve snapshot from timeline
|
|
string resolvedSnapshotId;
|
|
if (!string.IsNullOrEmpty(snapshotId))
|
|
{
|
|
resolvedSnapshotId = snapshotId;
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Using explicit snapshot ID: {SnapshotId}", snapshotId);
|
|
}
|
|
}
|
|
else if (!string.IsNullOrEmpty(atTimestamp))
|
|
{
|
|
// Parse timestamp
|
|
if (!DateTimeOffset.TryParse(atTimestamp, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal, out var pointInTime))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid timestamp format: {atTimestamp}");
|
|
AnsiConsole.MarkupLine("[yellow]Expected:[/] ISO 8601 format (e.g., 2026-01-05T10:00:00Z)");
|
|
return ProveExitCodes.InvalidInput;
|
|
}
|
|
|
|
// Query timeline for snapshot at timestamp
|
|
var timelineAdapter = services.GetService<ITimelineQueryAdapter>();
|
|
if (timelineAdapter is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Timeline service not available.");
|
|
AnsiConsole.MarkupLine("[yellow]Hint:[/] Use --bundle to specify a local bundle path for offline mode.");
|
|
return ProveExitCodes.ServiceUnavailable;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Querying timeline for snapshot at {Timestamp}", pointInTime);
|
|
}
|
|
|
|
var snapshotResult = await timelineAdapter.GetSnapshotAtAsync(imageDigest, pointInTime, ct);
|
|
if (snapshotResult is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] No verdict snapshot found for image at {pointInTime:O}");
|
|
return ProveExitCodes.SnapshotNotFound;
|
|
}
|
|
|
|
resolvedSnapshotId = snapshotResult.SnapshotId;
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Resolved snapshot ID: {SnapshotId}", resolvedSnapshotId);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Get latest snapshot for image
|
|
var timelineAdapter = services.GetService<ITimelineQueryAdapter>();
|
|
if (timelineAdapter is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Timeline service not available.");
|
|
AnsiConsole.MarkupLine("[yellow]Hint:[/] Use --bundle to specify a local bundle path for offline mode.");
|
|
return ProveExitCodes.ServiceUnavailable;
|
|
}
|
|
|
|
var latestSnapshot = await timelineAdapter.GetLatestSnapshotAsync(imageDigest, ct);
|
|
if (latestSnapshot is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] No verdict snapshots found for image: {imageDigest}");
|
|
return ProveExitCodes.SnapshotNotFound;
|
|
}
|
|
|
|
resolvedSnapshotId = latestSnapshot.SnapshotId;
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Using latest snapshot ID: {SnapshotId}", resolvedSnapshotId);
|
|
}
|
|
}
|
|
|
|
// Fetch bundle from CAS
|
|
var bundleStore = services.GetService<IReplayBundleStoreAdapter>();
|
|
if (bundleStore is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Replay bundle store not available.");
|
|
return ProveExitCodes.ServiceUnavailable;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Fetching bundle for snapshot: {SnapshotId}", resolvedSnapshotId);
|
|
}
|
|
|
|
var bundleInfo = await bundleStore.GetBundleAsync(resolvedSnapshotId, ct);
|
|
if (bundleInfo is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle not found for snapshot: {resolvedSnapshotId}");
|
|
return ProveExitCodes.BundleNotFound;
|
|
}
|
|
|
|
// Execute replay and generate proof
|
|
return await ExecuteReplayAndOutputProofAsync(
|
|
services,
|
|
bundleInfo.BundlePath,
|
|
imageDigest,
|
|
resolvedSnapshotId,
|
|
bundleInfo.PolicyVersion,
|
|
outputFormat,
|
|
verbose,
|
|
logger,
|
|
ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
|
|
return ProveExitCodes.Cancelled;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Failed to generate replay proof");
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
|
return ProveExitCodes.SystemError;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> HandleLocalBundleProveAsync(
|
|
IServiceProvider services,
|
|
string bundlePath,
|
|
string imageDigest,
|
|
string outputFormat,
|
|
bool verbose,
|
|
ILogger? logger,
|
|
CancellationToken ct)
|
|
{
|
|
bundlePath = Path.GetFullPath(bundlePath);
|
|
|
|
if (!Directory.Exists(bundlePath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle directory not found: {bundlePath}");
|
|
return ProveExitCodes.FileNotFound;
|
|
}
|
|
|
|
// Load manifest to get policy version
|
|
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle manifest not found: {manifestPath}");
|
|
return ProveExitCodes.FileNotFound;
|
|
}
|
|
|
|
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
|
var manifest = JsonSerializer.Deserialize<ReplayBundleManifest>(manifestJson, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
|
|
if (manifest is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse bundle manifest.");
|
|
return ProveExitCodes.InvalidBundle;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Loaded local bundle: {BundleId}", manifest.BundleId);
|
|
}
|
|
|
|
return await ExecuteReplayAndOutputProofAsync(
|
|
services,
|
|
bundlePath,
|
|
imageDigest,
|
|
manifest.BundleId,
|
|
manifest.Scan.PolicyDigest,
|
|
outputFormat,
|
|
verbose,
|
|
logger,
|
|
ct);
|
|
}
|
|
|
|
private static async Task<int> ExecuteReplayAndOutputProofAsync(
|
|
IServiceProvider services,
|
|
string bundlePath,
|
|
string imageDigest,
|
|
string snapshotId,
|
|
string policyVersion,
|
|
string outputFormat,
|
|
bool verbose,
|
|
ILogger? logger,
|
|
CancellationToken ct)
|
|
{
|
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
|
|
|
// Load manifest
|
|
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
|
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
|
var manifest = JsonSerializer.Deserialize<ReplayBundleManifest>(manifestJson, new JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
}) ?? throw new InvalidOperationException("Failed to deserialize bundle manifest");
|
|
|
|
// Create VerdictBuilder and execute replay
|
|
var verdictBuilder = new VerdictBuilderService(
|
|
Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger<VerdictBuilderService>(),
|
|
signer: null);
|
|
|
|
var sbomPath = Path.Combine(bundlePath, manifest.Inputs.Sbom.Path);
|
|
var feedsPath = manifest.Inputs.Feeds is not null
|
|
? Path.Combine(bundlePath, manifest.Inputs.Feeds.Path)
|
|
: null;
|
|
var vexPath = manifest.Inputs.Vex is not null
|
|
? Path.Combine(bundlePath, manifest.Inputs.Vex.Path)
|
|
: null;
|
|
var policyPath = manifest.Inputs.Policy is not null
|
|
? Path.Combine(bundlePath, manifest.Inputs.Policy.Path)
|
|
: null;
|
|
|
|
var replayRequest = new VerdictReplayRequest
|
|
{
|
|
SbomPath = sbomPath,
|
|
FeedsPath = feedsPath,
|
|
VexPath = vexPath,
|
|
PolicyPath = policyPath,
|
|
ImageDigest = manifest.Scan.ImageDigest,
|
|
PolicyDigest = manifest.Scan.PolicyDigest,
|
|
FeedSnapshotDigest = manifest.Scan.FeedSnapshotDigest
|
|
};
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogDebug("Executing verdict replay...");
|
|
}
|
|
|
|
var result = await verdictBuilder.ReplayFromBundleAsync(replayRequest, ct);
|
|
|
|
stopwatch.Stop();
|
|
|
|
if (!result.Success)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Replay failed: {result.Error}");
|
|
return ProveExitCodes.ReplayFailed;
|
|
}
|
|
|
|
// Compute bundle hash
|
|
var bundleHash = await ComputeBundleHashAsync(bundlePath, ct);
|
|
|
|
// Check if verdict matches expected
|
|
var verdictMatches = manifest.ExpectedOutputs?.VerdictHash is not null &&
|
|
string.Equals(result.VerdictHash, manifest.ExpectedOutputs.VerdictHash, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Generate ReplayProof
|
|
var proof = ReplayProof.FromExecutionResult(
|
|
bundleHash: bundleHash,
|
|
policyVersion: policyVersion,
|
|
verdictRoot: result.VerdictHash ?? "unknown",
|
|
verdictMatches: verdictMatches,
|
|
durationMs: stopwatch.ElapsedMilliseconds,
|
|
replayedAt: DateTimeOffset.UtcNow,
|
|
engineVersion: result.EngineVersion ?? "1.0.0",
|
|
artifactDigest: imageDigest,
|
|
signatureVerified: null,
|
|
signatureKeyId: null,
|
|
metadata: ImmutableDictionary<string, string>.Empty
|
|
.Add("snapshotId", snapshotId)
|
|
.Add("bundleId", manifest.BundleId));
|
|
|
|
// Output proof based on format
|
|
OutputProof(proof, outputFormat, verbose);
|
|
|
|
return verdictMatches ? ProveExitCodes.Success : ProveExitCodes.VerdictMismatch;
|
|
}
|
|
|
|
private static async Task<string> ComputeBundleHashAsync(string bundlePath, CancellationToken ct)
|
|
{
|
|
var files = Directory.GetFiles(bundlePath, "*", SearchOption.AllDirectories)
|
|
.OrderBy(f => f, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
if (files.Length == 0)
|
|
{
|
|
return "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
}
|
|
|
|
using var hasher = System.Security.Cryptography.SHA256.Create();
|
|
foreach (var file in files)
|
|
{
|
|
var fileBytes = await File.ReadAllBytesAsync(file, ct);
|
|
hasher.TransformBlock(fileBytes, 0, fileBytes.Length, null, 0);
|
|
}
|
|
|
|
hasher.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
|
return $"sha256:{Convert.ToHexString(hasher.Hash!).ToLowerInvariant()}";
|
|
}
|
|
|
|
private static void OutputProof(ReplayProof proof, string outputFormat, bool verbose)
|
|
{
|
|
switch (outputFormat.ToLowerInvariant())
|
|
{
|
|
case "compact":
|
|
AnsiConsole.WriteLine(proof.ToCompactString());
|
|
break;
|
|
|
|
case "json":
|
|
var json = proof.ToCanonicalJson();
|
|
AnsiConsole.WriteLine(json);
|
|
break;
|
|
|
|
case "full":
|
|
OutputFullProof(proof);
|
|
break;
|
|
|
|
default:
|
|
AnsiConsole.WriteLine(proof.ToCompactString());
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static void OutputFullProof(ReplayProof proof)
|
|
{
|
|
var table = new Table().AddColumns("Field", "Value");
|
|
table.BorderColor(Color.Grey);
|
|
|
|
table.AddRow("Bundle Hash", proof.BundleHash);
|
|
table.AddRow("Policy Version", proof.PolicyVersion);
|
|
table.AddRow("Verdict Root", proof.VerdictRoot);
|
|
table.AddRow("Duration", $"{proof.DurationMs}ms");
|
|
|
|
var matchDisplay = proof.VerdictMatches ? "[green]Yes[/]" : "[red]No[/]";
|
|
table.AddRow("Verdict Matches", matchDisplay);
|
|
|
|
table.AddRow("Engine Version", proof.EngineVersion);
|
|
table.AddRow("Replayed At", proof.ReplayedAt.ToString("O", CultureInfo.InvariantCulture));
|
|
|
|
if (!string.IsNullOrEmpty(proof.ArtifactDigest))
|
|
{
|
|
table.AddRow("Artifact Digest", proof.ArtifactDigest);
|
|
}
|
|
|
|
if (proof.SignatureVerified.HasValue)
|
|
{
|
|
var sigDisplay = proof.SignatureVerified.Value ? "[green]Yes[/]" : "[red]No[/]";
|
|
table.AddRow("Signature Verified", sigDisplay);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(proof.SignatureKeyId))
|
|
{
|
|
table.AddRow("Signature Key ID", proof.SignatureKeyId);
|
|
}
|
|
|
|
if (proof.Metadata is { Count: > 0 })
|
|
{
|
|
foreach (var kvp in proof.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal))
|
|
{
|
|
table.AddRow($"[grey]meta:{kvp.Key}[/]", kvp.Value);
|
|
}
|
|
}
|
|
|
|
AnsiConsole.Write(table);
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold]Compact Proof:[/]");
|
|
AnsiConsole.WriteLine(proof.ToCompactString());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exit codes for the prove command.
|
|
/// </summary>
|
|
internal static class ProveExitCodes
|
|
{
|
|
public const int Success = 0;
|
|
public const int InvalidInput = 1;
|
|
public const int SnapshotNotFound = 2;
|
|
public const int BundleNotFound = 3;
|
|
public const int ReplayFailed = 4;
|
|
public const int VerdictMismatch = 5;
|
|
public const int ServiceUnavailable = 6;
|
|
public const int FileNotFound = 7;
|
|
public const int InvalidBundle = 8;
|
|
public const int SystemError = 99;
|
|
public const int Cancelled = 130;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adapter interface for timeline query operations in CLI context.
|
|
/// RPL-016: Timeline query service adapter.
|
|
/// </summary>
|
|
public interface ITimelineQueryAdapter
|
|
{
|
|
/// <summary>
|
|
/// Get the snapshot ID for an image at a specific point in time.
|
|
/// </summary>
|
|
Task<SnapshotInfo?> GetSnapshotAtAsync(string imageDigest, DateTimeOffset pointInTime, CancellationToken ct);
|
|
|
|
/// <summary>
|
|
/// Get the latest snapshot for an image.
|
|
/// </summary>
|
|
Task<SnapshotInfo?> GetLatestSnapshotAsync(string imageDigest, CancellationToken ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot information returned by timeline queries.
|
|
/// </summary>
|
|
public sealed record SnapshotInfo(
|
|
string SnapshotId,
|
|
string ImageDigest,
|
|
DateTimeOffset CreatedAt,
|
|
string PolicyVersion);
|
|
|
|
/// <summary>
|
|
/// Adapter interface for replay bundle store operations in CLI context.
|
|
/// RPL-017: Replay bundle store adapter.
|
|
/// </summary>
|
|
public interface IReplayBundleStoreAdapter
|
|
{
|
|
/// <summary>
|
|
/// Get bundle information and download path for a snapshot.
|
|
/// </summary>
|
|
Task<BundleInfo?> GetBundleAsync(string snapshotId, CancellationToken ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bundle information returned by the bundle store.
|
|
/// </summary>
|
|
public sealed record BundleInfo(
|
|
string SnapshotId,
|
|
string BundlePath,
|
|
string BundleHash,
|
|
string PolicyVersion,
|
|
long SizeBytes);
|