572 lines
21 KiB
C#
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; }
|
|
}
|