// ----------------------------------------------------------------------------- // VerdictCliCommandModule.cs // Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation // Task: CLI verify command - stella verify --verdict // Description: CLI plugin module for offline verdict verification. // ----------------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Canonical.Json; using StellaOps.Cli.Configuration; using StellaOps.Cli.Plugins; using StellaOps.Verdict.Schema; using System.CommandLine; using System.Globalization; using System.Text.Json; namespace StellaOps.Cli.Plugins.Verdict; /// /// CLI plugin module for verdict verification commands. /// Provides 'stella verify --verdict' for offline and online verdict verification. /// public sealed class VerdictCliCommandModule : ICliCommandModule { public string Name => "stellaops.cli.plugins.verdict"; public bool IsAvailable(IServiceProvider services) => true; public void RegisterCommands( RootCommand root, IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(root); ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(verboseOption); root.Add(BuildVerifyCommand(services, verboseOption, options, cancellationToken)); } private static Command BuildVerifyCommand( IServiceProvider services, Option verboseOption, StellaOpsCliOptions options, CancellationToken cancellationToken) { var verify = new Command("verify", "Verify signatures, attestations, and verdicts."); // Add subcommands verify.Add(BuildVerdictVerifyCommand(services, verboseOption, options, cancellationToken)); return verify; } private static Command BuildVerdictVerifyCommand( IServiceProvider services, Option verboseOption, StellaOpsCliOptions options, CancellationToken cancellationToken) { var verdictOption = new Option("--verdict", new[] { "-v" }) { Description = "Verdict ID (urn:stella:verdict:sha256:...) or path to verdict.json file", Required = true }; var replayOption = new Option("--replay") { Description = "Path to replay bundle directory for full verification" }; var inputsOption = new Option("--inputs") { Description = "Path to knowledge-snapshot.json for inputs hash verification" }; var trustedKeysOption = new Option("--trusted-keys") { Description = "Path to trusted public keys file (PEM or JSON)" }; var showTraceOption = new Option("--show-trace") { Description = "Show full policy evaluation trace" }; var showEvidenceOption = new Option("--show-evidence") { Description = "Show evidence graph details" }; var formatOption = new Option("--format") { Description = "Output format", DefaultValueFactory = _ => VerdictOutputFormat.Table }; var outputOption = new Option("--output") { Description = "Output file path (default: stdout)" }; var cmd = new Command("verdict", "Verify a StellaVerdict artifact.") { verdictOption, replayOption, inputsOption, trustedKeysOption, showTraceOption, showEvidenceOption, formatOption, outputOption }; cmd.SetAction(async (parseResult, ct) => { var verdict = parseResult.GetValue(verdictOption); var replay = parseResult.GetValue(replayOption); var inputs = parseResult.GetValue(inputsOption); var trustedKeys = parseResult.GetValue(trustedKeysOption); var showTrace = parseResult.GetValue(showTraceOption); var showEvidence = parseResult.GetValue(showEvidenceOption); var format = parseResult.GetValue(formatOption); var output = parseResult.GetValue(outputOption); var verbose = parseResult.GetValue(verboseOption); if (string.IsNullOrWhiteSpace(verdict)) { return await ValidationFailedAsync("--verdict is required.").ConfigureAwait(false); } return await RunVerdictVerifyAsync( services, verdict!, replay, inputs, trustedKeys, showTrace, showEvidence, format, output, verbose, options, ct); }); return cmd; } private static async Task RunVerdictVerifyAsync( IServiceProvider services, string verdictPath, string? replayPath, string? inputsPath, string? trustedKeysPath, bool showTrace, bool showEvidence, VerdictOutputFormat format, string? outputPath, bool verbose, StellaOpsCliOptions options, CancellationToken cancellationToken) { var logger = services.GetService>(); var timeProvider = services.GetService() ?? TimeProvider.System; var result = new VerdictVerificationResult(); try { Console.WriteLine("Loading verdict..."); StellaVerdict? loadedVerdict = null; string? loadError = null; if (verdictPath.StartsWith("urn:stella:verdict:", StringComparison.OrdinalIgnoreCase)) { var fetchResult = await FetchVerdictFromApiAsync(services, verdictPath, options, cancellationToken) .ConfigureAwait(false); loadedVerdict = fetchResult.Verdict; loadError = fetchResult.Error; } else if (File.Exists(verdictPath)) { try { var json = await File.ReadAllTextAsync(verdictPath, cancellationToken).ConfigureAwait(false); loadedVerdict = JsonSerializer.Deserialize(json, JsonOptions); } catch (JsonException ex) { loadError = $"Verdict JSON error: {ex.Message}"; } catch (IOException ex) { loadError = $"Verdict read error: {ex.Message}"; } } else { loadError = $"Verdict not found: {verdictPath}"; } if (loadedVerdict is null) { result.Error = loadError ?? "Failed to load verdict."; return await ValidationFailedAsync(result.Error).ConfigureAwait(false); } result.VerdictId = loadedVerdict.VerdictId; // Step 2: Verify content-addressable ID Console.WriteLine("Verifying content ID..."); var computedId = loadedVerdict.ComputeVerdictId(); result.ContentIdValid = string.Equals(loadedVerdict.VerdictId, computedId, StringComparison.Ordinal); if (!result.ContentIdValid) { result.ContentIdMismatch = $"Expected {computedId}, got {loadedVerdict.VerdictId}"; } // Step 3: Check signature Console.WriteLine("Checking signatures..."); result.HasSignatures = !loadedVerdict.Signatures.IsDefaultOrEmpty && loadedVerdict.Signatures.Length > 0; result.SignatureCount = result.HasSignatures ? loadedVerdict.Signatures.Length : 0; if (result.HasSignatures && !string.IsNullOrEmpty(trustedKeysPath)) { if (!File.Exists(trustedKeysPath)) { return await ValidationFailedAsync($"Trusted keys file not found: {trustedKeysPath}") .ConfigureAwait(false); } result.SignaturesVerified = false; result.SignatureMessage = "Signature verification not implemented."; } else if (result.HasSignatures) { result.SignaturesVerified = false; result.SignatureMessage = "Signatures present but no trusted keys provided."; } else { result.SignatureMessage = "Verdict has no signatures."; } // Step 4: Verify inputs hash if provided if (!string.IsNullOrEmpty(inputsPath)) { Console.WriteLine("Verifying inputs hash..."); if (File.Exists(inputsPath)) { var inputsBytes = await File.ReadAllBytesAsync(inputsPath, cancellationToken).ConfigureAwait(false); var inputsHash = VerdictCliHashing.ComputeInputsHashFromJson(inputsBytes); var verdictInputsHash = VerdictCliHashing.ComputeInputsHashFromVerdict(loadedVerdict.Inputs); result.InputsHashValid = string.Equals(inputsHash, verdictInputsHash, StringComparison.OrdinalIgnoreCase); result.InputsHashMessage = result.InputsHashValid == true ? "Inputs hash matches" : $"Inputs hash mismatch: file={inputsHash[..16]}..., verdict={verdictInputsHash[..16]}..."; } else { result.InputsHashValid = false; result.InputsHashMessage = $"Inputs file not found: {inputsPath}"; } } // Step 5: Verify replay bundle if provided if (!string.IsNullOrEmpty(replayPath)) { Console.WriteLine("Verifying replay bundle..."); if (Directory.Exists(replayPath)) { var manifestPath = Path.Combine(replayPath, "manifest.json"); if (File.Exists(manifestPath)) { result.ReplayBundleValid = true; result.ReplayBundleMessage = "Replay bundle structure valid (manifest.json present)."; } else { result.ReplayBundleValid = false; result.ReplayBundleMessage = "Replay bundle missing manifest.json."; } } else { result.ReplayBundleValid = false; result.ReplayBundleMessage = $"Replay bundle directory not found: {replayPath}"; } } // Step 6: Check expiration result.IsExpired = false; if (VerdictCliHashing.TryParseExpiration( loadedVerdict.Result.ExpiresAt, timeProvider, out var expiresAt, out var isExpired)) { result.ExpiresAt = expiresAt; result.IsExpired = isExpired; } // Determine overall validity result.IsValid = result.ContentIdValid && (!result.HasSignatures || result.SignaturesVerified == true) && (string.IsNullOrEmpty(inputsPath) || result.InputsHashValid == true) && (string.IsNullOrEmpty(replayPath) || result.ReplayBundleValid == true); // Output results if (format == VerdictOutputFormat.Json) { var resultJson = JsonSerializer.Serialize(new { verdictId = result.VerdictId, isValid = result.IsValid, contentIdValid = result.ContentIdValid, hasSignatures = result.HasSignatures, signatureCount = result.SignatureCount, signaturesVerified = result.SignaturesVerified, isExpired = result.IsExpired, expiresAt = result.ExpiresAt?.ToString("O", CultureInfo.InvariantCulture), inputsHashValid = result.InputsHashValid, replayBundleValid = result.ReplayBundleValid, verdict = loadedVerdict }, JsonOutputOptions); if (!string.IsNullOrEmpty(outputPath)) { EnsureOutputDirectory(outputPath); await File.WriteAllTextAsync(outputPath, resultJson, cancellationToken).ConfigureAwait(false); Console.WriteLine($"Results written to: {outputPath}"); } else { Console.WriteLine(resultJson); } } else { RenderTextResult(loadedVerdict, result, showTrace, showEvidence, verbose); } // Return appropriate exit code if (result.IsExpired) { return 2; // Expired } if (!result.IsValid) { return 1; // Invalid } return 0; // Valid } catch (Exception ex) { logger?.LogError(ex, "Failed to verify verdict: {Path}", verdictPath); return await ValidationFailedAsync($"Failed to verify verdict: {ex.Message}").ConfigureAwait(false); } } private static void RenderTextResult( StellaVerdict verdict, VerdictVerificationResult result, bool showTrace, bool showEvidence, bool verbose) { var statusText = result.IsExpired ? "EXPIRED" : (result.IsValid ? "VALID" : "INVALID"); Console.WriteLine($"Verdict verification result: {statusText}"); Console.WriteLine($"Verdict ID: {verdict.VerdictId}"); Console.WriteLine($"Vulnerability: {verdict.Subject.VulnerabilityId}"); Console.WriteLine($"Component: {verdict.Subject.Purl}"); if (!string.IsNullOrEmpty(verdict.Subject.ImageDigest)) { Console.WriteLine($"Image: {verdict.Subject.ImageDigest}"); } Console.WriteLine($"Claim status: {verdict.Claim.Status}"); Console.WriteLine($"Disposition: {verdict.Result.Disposition}"); Console.WriteLine($"Score: {verdict.Result.Score:F2}"); Console.WriteLine($"Confidence: {verdict.Claim.Confidence:P0}"); if (!string.IsNullOrEmpty(verdict.Claim.Reason)) { Console.WriteLine($"Reason: {verdict.Claim.Reason}"); } Console.WriteLine($"Content ID: {(result.ContentIdValid ? "PASS" : "FAIL")} {result.ContentIdMismatch ?? ""}".TrimEnd()); Console.WriteLine($"Signatures: {FormatSignatureStatus(result)}"); if (!string.IsNullOrEmpty(result.SignatureMessage)) { Console.WriteLine($"Signature detail: {result.SignatureMessage}"); } if (result.InputsHashValid.HasValue) { Console.WriteLine($"Inputs hash: {(result.InputsHashValid.Value ? "PASS" : "FAIL")} {result.InputsHashMessage}"); } if (result.ReplayBundleValid.HasValue) { Console.WriteLine($"Replay bundle: {(result.ReplayBundleValid.Value ? "VALID" : "INVALID")} {result.ReplayBundleMessage}"); } Console.WriteLine(result.ExpiresAt.HasValue ? (result.IsExpired ? $"Expired: {result.ExpiresAt:O}" : $"Expires: {result.ExpiresAt:O}") : "Expiration: none"); if (showTrace && !verdict.PolicyPath.IsDefaultOrEmpty) { Console.WriteLine("Policy trace:"); foreach (var step in verdict.PolicyPath.OrderBy(s => s.Order)) { Console.WriteLine($" {step.Order}: {step.RuleName ?? step.RuleId} matched={step.Matched} action={step.Action ?? "-"} reason={step.Reason ?? "-"}"); } } if (showEvidence && verdict.EvidenceGraph is not null) { Console.WriteLine("Evidence graph:"); foreach (var node in verdict.EvidenceGraph.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal)) { var shortId = node.Id.Length > 16 ? node.Id[..16] + "..." : node.Id; Console.WriteLine($" {shortId} {node.Type} {node.Label ?? "-"}"); } } if (verbose) { Console.WriteLine("Provenance:"); Console.WriteLine($" Generator: {verdict.Provenance.Generator}"); if (!string.IsNullOrEmpty(verdict.Provenance.GeneratorVersion)) { Console.WriteLine($" Version: {verdict.Provenance.GeneratorVersion}"); } if (!string.IsNullOrEmpty(verdict.Provenance.RunId)) { Console.WriteLine($" Run ID: {verdict.Provenance.RunId}"); } Console.WriteLine($" Created: {verdict.Provenance.CreatedAt}"); } } private static async Task<(StellaVerdict? Verdict, string? Error)> FetchVerdictFromApiAsync( IServiceProvider services, string verdictId, StellaOpsCliOptions options, CancellationToken cancellationToken) { var httpClientFactory = services.GetService(); var httpClient = httpClientFactory?.CreateClient("verdict"); var disposeClient = false; if (httpClient is null) { httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; disposeClient = true; } var baseUrl = options.BackendUrl?.TrimEnd('/') ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL") ?? "http://localhost:5000"; var escapedId = Uri.EscapeDataString(verdictId); var url = $"{baseUrl}/v1/verdicts/{escapedId}"; try { var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { return (null, $"Verdict fetch failed ({(int)response.StatusCode} {response.ReasonPhrase})."); } var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var verdict = JsonSerializer.Deserialize(json, JsonOptions); return verdict is null ? (null, "Verdict response could not be parsed.") : (verdict, null); } catch (Exception ex) { return (null, $"Verdict fetch failed: {ex.Message}"); } finally { if (disposeClient) { httpClient.Dispose(); } } } private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = false }; private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonOptions) { WriteIndented = true }; private static string FormatSignatureStatus(VerdictVerificationResult result) { if (!result.HasSignatures) { return "NONE"; } if (result.SignaturesVerified == true) { return "VERIFIED"; } return "PRESENT"; } private static void EnsureOutputDirectory(string outputPath) { var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } } private static async Task ValidationFailedAsync(string message) { await Console.Error.WriteLineAsync(message).ConfigureAwait(false); return 1; } } /// /// Output format for verdict verification. /// public enum VerdictOutputFormat { Table, Json } /// /// Result of verdict verification. /// internal sealed class VerdictVerificationResult { public string? VerdictId { get; set; } public bool IsValid { get; set; } public bool ContentIdValid { get; set; } public string? ContentIdMismatch { get; set; } public bool HasSignatures { get; set; } public int SignatureCount { get; set; } public bool? SignaturesVerified { get; set; } public string? SignatureMessage { get; set; } public bool? InputsHashValid { get; set; } public string? InputsHashMessage { get; set; } public bool? ReplayBundleValid { get; set; } public string? ReplayBundleMessage { get; set; } public bool IsExpired { get; set; } public DateTimeOffset? ExpiresAt { get; set; } public string? Error { get; set; } }