Files
git.stella-ops.org/src/Cli/__Libraries/StellaOps.Cli.Plugins.Verdict/VerdictCliCommandModule.cs
2026-02-01 21:37:40 +02:00

572 lines
21 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// CLI plugin module for verdict verification commands.
/// Provides 'stella verify --verdict' for offline and online verdict verification.
/// </summary>
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<bool> 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<bool> 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<bool> verboseOption,
StellaOpsCliOptions options,
CancellationToken cancellationToken)
{
var verdictOption = new Option<string>("--verdict", new[] { "-v" })
{
Description = "Verdict ID (urn:stella:verdict:sha256:...) or path to verdict.json file",
Required = true
};
var replayOption = new Option<string?>("--replay")
{
Description = "Path to replay bundle directory for full verification"
};
var inputsOption = new Option<string?>("--inputs")
{
Description = "Path to knowledge-snapshot.json for inputs hash verification"
};
var trustedKeysOption = new Option<string?>("--trusted-keys")
{
Description = "Path to trusted public keys file (PEM or JSON)"
};
var showTraceOption = new Option<bool>("--show-trace")
{
Description = "Show full policy evaluation trace"
};
var showEvidenceOption = new Option<bool>("--show-evidence")
{
Description = "Show evidence graph details"
};
var formatOption = new Option<VerdictOutputFormat>("--format")
{
Description = "Output format",
DefaultValueFactory = _ => VerdictOutputFormat.Table
};
var outputOption = new Option<string?>("--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<int> 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<ILogger<VerdictCliCommandModule>>();
var timeProvider = services.GetService<TimeProvider>() ?? 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<StellaVerdict>(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<IHttpClientFactory>();
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<StellaVerdict>(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<int> ValidationFailedAsync(string message)
{
await Console.Error.WriteLineAsync(message).ConfigureAwait(false);
return 1;
}
}
/// <summary>
/// Output format for verdict verification.
/// </summary>
public enum VerdictOutputFormat
{
Table,
Json
}
/// <summary>
/// Result of verdict verification.
/// </summary>
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; }
}