// ----------------------------------------------------------------------------- // CommandHandlers.VerdictVerify.cs // Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push // Description: Command handlers for verdict verification operations. // ----------------------------------------------------------------------------- using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; 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 VerdictJsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; internal static async Task HandleVerdictVerifyAsync( IServiceProvider services, string reference, string? sbomDigest, string? feedsDigest, string? policyDigest, string? expectedDecision, bool strict, string? trustPolicy, string output, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("verdict-verify"); var options = scope.ServiceProvider.GetRequiredService(); using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.verify", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("verdict verify"); if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict verify")) { WriteVerdictVerifyError("Offline mode enabled. Use offline evidence verification instead.", output); Environment.ExitCode = 2; return 2; } if (string.IsNullOrWhiteSpace(reference)) { WriteVerdictVerifyError("Image reference is required.", output); Environment.ExitCode = 2; return 2; } try { var verifier = scope.ServiceProvider.GetRequiredService(); var request = new VerdictVerificationRequest { Reference = reference, ExpectedSbomDigest = sbomDigest, ExpectedFeedsDigest = feedsDigest, ExpectedPolicyDigest = policyDigest, ExpectedDecision = expectedDecision, Strict = strict, TrustPolicyPath = trustPolicy }; var result = await verifier.VerifyAsync(request, cancellationToken).ConfigureAwait(false); WriteVerdictVerifyResult(result, output, verbose); var exitCode = result.IsValid ? 0 : 1; Environment.ExitCode = exitCode; return exitCode; } catch (Exception ex) { logger.LogError(ex, "Verdict verify failed for {Reference}", reference); WriteVerdictVerifyError($"Verification failed: {ex.Message}", output); Environment.ExitCode = 2; return 2; } } internal static async Task HandleVerdictListAsync( IServiceProvider services, string reference, string output, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("verdict-list"); var options = scope.ServiceProvider.GetRequiredService(); using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.list", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("verdict list"); if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict list")) { WriteVerdictListError("Offline mode enabled. Use offline evidence verification instead.", output); Environment.ExitCode = 2; return 2; } if (string.IsNullOrWhiteSpace(reference)) { WriteVerdictListError("Image reference is required.", output); Environment.ExitCode = 2; return 2; } try { var verifier = scope.ServiceProvider.GetRequiredService(); var verdicts = await verifier.ListAsync(reference, cancellationToken).ConfigureAwait(false); WriteVerdictListResult(reference, verdicts, output, verbose); Environment.ExitCode = 0; return 0; } catch (Exception ex) { logger.LogError(ex, "Verdict list failed for {Reference}", reference); WriteVerdictListError($"Failed to list verdicts: {ex.Message}", output); Environment.ExitCode = 2; return 2; } } /// /// Handle verdict push command. /// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013 /// internal static async Task HandleVerdictPushAsync( IServiceProvider services, string reference, string? verdictFile, string? registry, bool insecure, bool dryRun, bool force, int timeout, bool verbose, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetRequiredService(); var logger = loggerFactory.CreateLogger("verdict-push"); var options = scope.ServiceProvider.GetRequiredService(); var console = AnsiConsole.Console; using var activity = CliActivitySource.Instance.StartActivity("cli.verdict.push", ActivityKind.Client); using var duration = CliMetrics.MeasureCommandDuration("verdict push"); if (!OfflineModeGuard.IsNetworkAllowed(options, "verdict push")) { console.MarkupLine("[red]Error:[/] Offline mode enabled. Cannot push verdicts."); Environment.ExitCode = 2; return 2; } if (string.IsNullOrWhiteSpace(reference)) { console.MarkupLine("[red]Error:[/] Image reference is required."); Environment.ExitCode = 2; return 2; } if (string.IsNullOrWhiteSpace(verdictFile)) { console.MarkupLine("[red]Error:[/] Verdict file path is required (--verdict-file)."); Environment.ExitCode = 2; return 2; } if (!File.Exists(verdictFile)) { console.MarkupLine($"[red]Error:[/] Verdict file not found: {Markup.Escape(verdictFile)}"); Environment.ExitCode = 2; return 2; } try { var verifier = scope.ServiceProvider.GetRequiredService(); if (verbose) { console.MarkupLine($"Reference: [bold]{Markup.Escape(reference)}[/]"); console.MarkupLine($"Verdict file: [bold]{Markup.Escape(verdictFile)}[/]"); if (!string.IsNullOrWhiteSpace(registry)) { console.MarkupLine($"Registry override: [bold]{Markup.Escape(registry)}[/]"); } if (dryRun) { console.MarkupLine("[yellow]Dry run mode - no changes will be made[/]"); } } var request = new VerdictPushRequest { Reference = reference, VerdictFilePath = verdictFile, Registry = registry, Insecure = insecure, DryRun = dryRun, Force = force, TimeoutSeconds = timeout }; var result = await verifier.PushAsync(request, cancellationToken).ConfigureAwait(false); if (result.Success) { if (result.DryRun) { console.MarkupLine("[green]Dry run:[/] Verdict would be pushed successfully."); } else { console.MarkupLine("[green]Success:[/] Verdict pushed successfully."); } if (!string.IsNullOrWhiteSpace(result.VerdictDigest)) { console.MarkupLine($"Verdict digest: [bold]{Markup.Escape(result.VerdictDigest)}[/]"); } if (!string.IsNullOrWhiteSpace(result.ManifestDigest)) { console.MarkupLine($"Manifest digest: [bold]{Markup.Escape(result.ManifestDigest)}[/]"); } Environment.ExitCode = 0; return 0; } else { console.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Push failed")}"); Environment.ExitCode = 1; return 1; } } catch (Exception ex) { logger.LogError(ex, "Verdict push failed for {Reference}", reference); console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); Environment.ExitCode = 2; return 2; } } private static void WriteVerdictVerifyResult(VerdictVerificationResult result, string output, bool verbose) { var console = AnsiConsole.Console; switch (output) { case "json": console.WriteLine(JsonSerializer.Serialize(result, VerdictJsonOptions)); break; case "sarif": console.WriteLine(JsonSerializer.Serialize(BuildVerdictSarif(result), VerdictJsonOptions)); break; default: WriteVerdictVerifyTable(console, result, verbose); break; } } private static void WriteVerdictVerifyError(string message, string output) { var console = AnsiConsole.Console; if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase)) { var payload = new { status = "error", message }; console.WriteLine(JsonSerializer.Serialize(payload, VerdictJsonOptions)); return; } if (string.Equals(output, "sarif", StringComparison.OrdinalIgnoreCase)) { var sarif = new { version = "2.1.0", schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", runs = new[] { new { tool = new { driver = new { name = "StellaOps Verdict Verify", version = "1.0.0" } }, results = new[] { new { level = "error", message = new { text = message } } } } } }; console.WriteLine(JsonSerializer.Serialize(sarif, VerdictJsonOptions)); return; } console.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}"); } private static void WriteVerdictVerifyTable(IAnsiConsole console, VerdictVerificationResult result, bool verbose) { console.MarkupLine($"Image: [bold]{Markup.Escape(result.ImageReference)}[/]"); console.MarkupLine($"Image Digest: [bold]{Markup.Escape(result.ImageDigest)}[/]"); console.WriteLine(); if (result.VerdictFound) { console.MarkupLine($"Verdict Found: [green]Yes[/]"); console.MarkupLine($"Verdict Digest: {Markup.Escape(result.VerdictDigest ?? "-")}"); console.MarkupLine($"Decision: {FormatDecision(result.Decision)}"); console.WriteLine(); var table = new Table().AddColumns("Input", "Expected", "Actual", "Match"); table.AddRow("SBOM Digest", result.ExpectedSbomDigest ?? "-", result.ActualSbomDigest ?? "-", FormatMatch(result.SbomDigestMatches)); table.AddRow("Feeds Digest", result.ExpectedFeedsDigest ?? "-", result.ActualFeedsDigest ?? "-", FormatMatch(result.FeedsDigestMatches)); table.AddRow("Policy Digest", result.ExpectedPolicyDigest ?? "-", result.ActualPolicyDigest ?? "-", FormatMatch(result.PolicyDigestMatches)); table.AddRow("Decision", result.ExpectedDecision ?? "-", result.Decision ?? "-", FormatMatch(result.DecisionMatches)); console.Write(table); console.WriteLine(); if (result.SignatureValid.HasValue) { console.MarkupLine($"Signature: {(result.SignatureValid.Value ? "[green]VALID[/]" : "[red]INVALID[/]")}"); if (!string.IsNullOrWhiteSpace(result.SignerIdentity)) { console.MarkupLine($"Signer: {Markup.Escape(result.SignerIdentity)}"); } } } else { console.MarkupLine($"Verdict Found: [yellow]No[/]"); } console.WriteLine(); var headline = result.IsValid ? "[green]Verification PASSED[/]" : "[red]Verification FAILED[/]"; console.MarkupLine(headline); if (verbose && result.Errors.Count > 0) { console.MarkupLine("[red]Errors:[/]"); foreach (var error in result.Errors) { console.MarkupLine($" - {Markup.Escape(error)}"); } } } private static void WriteVerdictListResult(string reference, IReadOnlyList verdicts, string output, bool verbose) { var console = AnsiConsole.Console; if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase)) { var payload = new { imageReference = reference, verdicts }; console.WriteLine(JsonSerializer.Serialize(payload, VerdictJsonOptions)); return; } console.MarkupLine($"Image: [bold]{Markup.Escape(reference)}[/]"); console.WriteLine(); if (verdicts.Count == 0) { console.MarkupLine("[yellow]No verdict attestations found.[/]"); return; } var table = new Table().AddColumns("Digest", "Decision", "Created", "SBOM Digest", "Feeds Digest"); foreach (var verdict in verdicts) { table.AddRow( TruncateDigest(verdict.Digest), FormatDecision(verdict.Decision), verdict.CreatedAt?.ToString("u") ?? "-", TruncateDigest(verdict.SbomDigest), TruncateDigest(verdict.FeedsDigest)); } console.Write(table); console.MarkupLine($"\nTotal: [bold]{verdicts.Count}[/] verdict(s)"); } private static void WriteVerdictListError(string message, string output) { var console = AnsiConsole.Console; if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase)) { var payload = new { status = "error", message }; console.WriteLine(JsonSerializer.Serialize(payload, VerdictJsonOptions)); return; } console.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}"); } private static string FormatDecision(string? decision) => decision?.ToLowerInvariant() switch { "pass" => "[green]PASS[/]", "warn" => "[yellow]WARN[/]", "block" => "[red]BLOCK[/]", _ => decision ?? "-" }; private static string FormatMatch(bool? matches) => matches switch { true => "[green]PASS[/]", false => "[red]FAIL[/]", null => "[dim]-[/]" }; private static string TruncateDigest(string? digest) { if (string.IsNullOrWhiteSpace(digest)) { return "-"; } if (digest.Length > 20) { return $"{digest[..17]}..."; } return digest; } private static object BuildVerdictSarif(VerdictVerificationResult result) { var results = new List(); if (result.VerdictFound) { results.Add(new { ruleId = "stellaops.verdict.found", level = "note", message = new { text = $"Verdict found with decision: {result.Decision}" }, properties = new { verdict_digest = result.VerdictDigest, decision = result.Decision } }); if (!result.SbomDigestMatches.GetValueOrDefault(true)) { results.Add(new { ruleId = "stellaops.verdict.sbom_mismatch", level = "error", message = new { text = "SBOM digest does not match expected value" } }); } if (!result.FeedsDigestMatches.GetValueOrDefault(true)) { results.Add(new { ruleId = "stellaops.verdict.feeds_mismatch", level = "error", message = new { text = "Feeds digest does not match expected value" } }); } if (!result.PolicyDigestMatches.GetValueOrDefault(true)) { results.Add(new { ruleId = "stellaops.verdict.policy_mismatch", level = "error", message = new { text = "Policy digest does not match expected value" } }); } } else { results.Add(new { ruleId = "stellaops.verdict.missing", level = "error", message = new { text = "No verdict attestation found for image" } }); } return new { version = "2.1.0", schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", runs = new[] { new { tool = new { driver = new { name = "StellaOps Verdict Verify", version = "1.0.0" } }, results = results.ToArray() } } }; } } /// /// Request for verdict verification. /// public sealed record VerdictVerificationRequest { public required string Reference { get; init; } public string? ExpectedSbomDigest { get; init; } public string? ExpectedFeedsDigest { get; init; } public string? ExpectedPolicyDigest { get; init; } public string? ExpectedDecision { get; init; } public bool Strict { get; init; } public string? TrustPolicyPath { get; init; } } /// /// Result of verdict verification. /// public sealed record VerdictVerificationResult { public required string ImageReference { get; init; } public required string ImageDigest { get; init; } public required bool VerdictFound { get; init; } public required bool IsValid { get; init; } public string? VerdictDigest { get; init; } public string? Decision { get; init; } public string? ExpectedSbomDigest { get; init; } public string? ActualSbomDigest { get; init; } public bool? SbomDigestMatches { get; init; } public string? ExpectedFeedsDigest { get; init; } public string? ActualFeedsDigest { get; init; } public bool? FeedsDigestMatches { get; init; } public string? ExpectedPolicyDigest { get; init; } public string? ActualPolicyDigest { get; init; } public bool? PolicyDigestMatches { get; init; } public string? ExpectedDecision { get; init; } public bool? DecisionMatches { get; init; } public bool? SignatureValid { get; init; } public string? SignerIdentity { get; init; } public IReadOnlyList Errors { get; init; } = Array.Empty(); } /// /// Summary information about a verdict attestation. /// public sealed record VerdictSummary { public required string Digest { get; init; } public string? Decision { get; init; } public DateTimeOffset? CreatedAt { get; init; } public string? SbomDigest { get; init; } public string? FeedsDigest { get; init; } public string? PolicyDigest { get; init; } public string? GraphRevisionId { get; init; } } /// /// Interface for verdict attestation verification. /// public interface IVerdictAttestationVerifier { Task VerifyAsync( VerdictVerificationRequest request, CancellationToken cancellationToken = default); Task> ListAsync( string reference, CancellationToken cancellationToken = default); /// /// Push a verdict attestation to an OCI registry. /// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013 /// Task PushAsync( VerdictPushRequest request, CancellationToken cancellationToken = default); } /// /// Request for verdict push. /// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013 /// public sealed record VerdictPushRequest { public required string Reference { get; init; } public string? VerdictFilePath { get; init; } public byte[]? VerdictBytes { get; init; } public string? Registry { get; init; } public bool Insecure { get; init; } public bool DryRun { get; init; } public bool Force { get; init; } public int TimeoutSeconds { get; init; } = 300; } /// /// Result of verdict push. /// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013 /// public sealed record VerdictPushResult { public required bool Success { get; init; } public string? VerdictDigest { get; init; } public string? ManifestDigest { get; init; } public string? Error { get; init; } public bool DryRun { get; init; } }