save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -9,7 +9,7 @@ using System.CommandLine;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StellaOps.Canonical.Json;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
using StellaOps.Verdict.Schema;
@@ -128,8 +128,7 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
if (string.IsNullOrWhiteSpace(verdict))
{
AnsiConsole.MarkupLine("[red]Error:[/] --verdict is required.");
return 1;
return await ValidationFailedAsync("--verdict is required.").ConfigureAwait(false);
}
return await RunVerdictVerifyAsync(
@@ -165,168 +164,150 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
CancellationToken cancellationToken)
{
var logger = services.GetService<ILogger<VerdictCliCommandModule>>();
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
var result = new VerdictVerificationResult();
try
{
// Step 1: Load the verdict
Console.WriteLine("Loading verdict...");
StellaVerdict? loadedVerdict = null;
string? loadError = null;
await AnsiConsole.Status()
.StartAsync("Loading verdict...", async ctx =>
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
{
ctx.Spinner(Spinner.Known.Dots);
if (verdictPath.StartsWith("urn:stella:verdict:", StringComparison.OrdinalIgnoreCase))
{
// Fetch from API
ctx.Status("Fetching verdict from API...");
loadedVerdict = await FetchVerdictFromApiAsync(services, verdictPath, options, cancellationToken);
}
else if (File.Exists(verdictPath))
{
// Load from file
ctx.Status("Loading verdict from file...");
var json = await File.ReadAllTextAsync(verdictPath, cancellationToken);
loadedVerdict = JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
}
else
{
result.Error = $"Verdict not found: {verdictPath}";
}
});
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)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {result.Error ?? "Failed to load verdict"}");
return 1;
result.Error = loadError ?? "Failed to load verdict.";
return await ValidationFailedAsync(result.Error).ConfigureAwait(false);
}
result.VerdictId = loadedVerdict.VerdictId;
// Step 2: Verify content-addressable ID
await AnsiConsole.Status()
.StartAsync("Verifying content ID...", ctx =>
{
ctx.Spinner(Spinner.Known.Dots);
var computedId = loadedVerdict.ComputeVerdictId();
result.ContentIdValid = string.Equals(loadedVerdict.VerdictId, computedId, StringComparison.Ordinal);
if (!result.ContentIdValid)
{
result.ContentIdMismatch = $"Expected {computedId}, got {loadedVerdict.VerdictId}";
}
return Task.CompletedTask;
});
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
await AnsiConsole.Status()
.StartAsync("Checking signatures...", ctx =>
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))
{
ctx.Spinner(Spinner.Known.Dots);
return await ValidationFailedAsync($"Trusted keys file not found: {trustedKeysPath}")
.ConfigureAwait(false);
}
result.HasSignatures = !loadedVerdict.Signatures.IsDefaultOrEmpty && loadedVerdict.Signatures.Length > 0;
result.SignatureCount = result.HasSignatures ? loadedVerdict.Signatures.Length : 0;
if (result.HasSignatures && !string.IsNullOrEmpty(trustedKeysPath))
{
// TODO: Implement full signature verification with trusted keys
result.SignaturesVerified = false;
result.SignatureMessage = "Signature verification with trusted keys not yet implemented";
}
else if (result.HasSignatures)
{
result.SignaturesVerified = false;
result.SignatureMessage = "Signatures present but no trusted keys provided for verification";
}
else
{
result.SignatureMessage = "Verdict has no signatures";
}
return Task.CompletedTask;
});
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))
{
await AnsiConsole.Status()
.StartAsync("Verifying inputs hash...", async ctx =>
{
ctx.Spinner(Spinner.Known.Dots);
Console.WriteLine("Verifying inputs hash...");
if (File.Exists(inputsPath))
{
var inputsJson = await File.ReadAllTextAsync(inputsPath, cancellationToken);
var inputsHash = ComputeHash(inputsJson);
if (File.Exists(inputsPath))
{
var inputsBytes = await File.ReadAllBytesAsync(inputsPath, cancellationToken).ConfigureAwait(false);
var inputsHash = VerdictCliHashing.ComputeInputsHashFromJson(inputsBytes);
var verdictInputsHash = VerdictCliHashing.ComputeInputsHashFromVerdict(loadedVerdict.Inputs);
// Compare with verdict's deterministic inputs hash
var verdictInputsJson = JsonSerializer.Serialize(loadedVerdict.Inputs, JsonOptions);
var verdictInputsHash = ComputeHash(verdictInputsJson);
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}";
}
});
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))
{
await AnsiConsole.Status()
.StartAsync("Verifying replay bundle...", async ctx =>
{
ctx.Spinner(Spinner.Known.Dots);
Console.WriteLine("Verifying replay bundle...");
if (Directory.Exists(replayPath))
{
// Check for manifest
var manifestPath = Path.Combine(replayPath, "manifest.json");
if (File.Exists(manifestPath))
{
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
// TODO: Parse manifest and verify all referenced files
result.ReplayBundleValid = true;
result.ReplayBundleMessage = "Replay bundle structure valid";
}
else
{
result.ReplayBundleValid = false;
result.ReplayBundleMessage = "Replay bundle missing manifest.json";
}
}
else
{
result.ReplayBundleValid = false;
result.ReplayBundleMessage = $"Replay bundle directory not found: {replayPath}";
}
});
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 (!string.IsNullOrEmpty(loadedVerdict.Result.ExpiresAt))
if (VerdictCliHashing.TryParseExpiration(
loadedVerdict.Result.ExpiresAt,
timeProvider,
out var expiresAt,
out var isExpired))
{
if (DateTimeOffset.TryParse(loadedVerdict.Result.ExpiresAt, out var expiresAt))
{
result.IsExpired = expiresAt < DateTimeOffset.UtcNow;
result.ExpiresAt = expiresAt;
}
result.ExpiresAt = expiresAt;
result.IsExpired = isExpired;
}
// Determine overall validity
result.IsValid = result.ContentIdValid
&& (!result.HasSignatures || result.SignaturesVerified == true)
&& !result.IsExpired
&& (string.IsNullOrEmpty(inputsPath) || result.InputsHashValid == true)
&& (string.IsNullOrEmpty(replayPath) || result.ReplayBundleValid == true);
@@ -346,12 +327,13 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
inputsHashValid = result.InputsHashValid,
replayBundleValid = result.ReplayBundleValid,
verdict = loadedVerdict
}, new JsonSerializerOptions { WriteIndented = true });
}, JsonOutputOptions);
if (!string.IsNullOrEmpty(outputPath))
{
await File.WriteAllTextAsync(outputPath, resultJson, cancellationToken);
AnsiConsole.MarkupLine($"[green]Results written to:[/] {outputPath}");
EnsureOutputDirectory(outputPath);
await File.WriteAllTextAsync(outputPath, resultJson, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"Results written to: {outputPath}");
}
else
{
@@ -360,226 +342,123 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
}
else
{
RenderTableResult(loadedVerdict, result, showTrace, showEvidence, verbose);
RenderTextResult(loadedVerdict, result, showTrace, showEvidence, verbose);
}
// Return appropriate exit code
if (!result.IsValid)
{
return 1; // Invalid
}
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);
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
return 1;
return await ValidationFailedAsync($"Failed to verify verdict: {ex.Message}").ConfigureAwait(false);
}
}
private static void RenderTableResult(
private static void RenderTextResult(
StellaVerdict verdict,
VerdictVerificationResult result,
bool showTrace,
bool showEvidence,
bool verbose)
{
// Status panel
var statusColor = result.IsValid ? "green" : (result.IsExpired ? "yellow" : "red");
var statusText = result.IsValid ? "VALID" : (result.IsExpired ? "EXPIRED" : "INVALID");
var statusPanel = new Panel(
new Markup($"[bold {statusColor}]{statusText}[/]"))
.Header("[bold]Verification Result[/]")
.Border(BoxBorder.Rounded)
.Padding(1, 0);
AnsiConsole.Write(statusPanel);
AnsiConsole.WriteLine();
// Subject info
var subjectTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Subject[/]")
.AddColumn("Property")
.AddColumn("Value");
subjectTable.AddRow("Verdict ID", verdict.VerdictId);
subjectTable.AddRow("Vulnerability", verdict.Subject.VulnerabilityId);
subjectTable.AddRow("Component", verdict.Subject.Purl);
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))
{
subjectTable.AddRow("Image", verdict.Subject.ImageDigest);
Console.WriteLine($"Image: {verdict.Subject.ImageDigest}");
}
AnsiConsole.Write(subjectTable);
AnsiConsole.WriteLine();
// Claim info
var claimTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Claim[/]")
.AddColumn("Property")
.AddColumn("Value");
var claimStatusColor = verdict.Claim.Status switch
{
VerdictStatus.Pass => "green",
VerdictStatus.Blocked => "red",
VerdictStatus.Warned => "yellow",
VerdictStatus.Ignored => "grey",
VerdictStatus.Deferred => "blue",
VerdictStatus.Escalated => "orange1",
VerdictStatus.RequiresVex => "purple",
_ => "white"
};
claimTable.AddRow("Status", $"[{claimStatusColor}]{verdict.Claim.Status}[/]");
claimTable.AddRow("Disposition", verdict.Result.Disposition);
claimTable.AddRow("Score", $"{verdict.Result.Score:F2}");
claimTable.AddRow("Confidence", $"{verdict.Claim.Confidence:P0}");
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))
{
claimTable.AddRow("Reason", verdict.Claim.Reason);
Console.WriteLine($"Reason: {verdict.Claim.Reason}");
}
AnsiConsole.Write(claimTable);
AnsiConsole.WriteLine();
// Verification checks
var checksTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Verification Checks[/]")
.AddColumn("Check")
.AddColumn("Result")
.AddColumn("Details");
checksTable.AddRow(
"Content ID",
result.ContentIdValid ? "[green]PASS[/]" : "[red]FAIL[/]",
result.ContentIdValid ? "Hash matches" : result.ContentIdMismatch ?? "Hash mismatch");
checksTable.AddRow(
"Signatures",
result.HasSignatures
? (result.SignaturesVerified == true ? "[green]VERIFIED[/]" : "[yellow]PRESENT[/]")
: "[grey]NONE[/]",
result.SignatureMessage ?? (result.HasSignatures ? $"{result.SignatureCount} signature(s)" : "No signatures"));
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)
{
checksTable.AddRow(
"Inputs Hash",
result.InputsHashValid.Value ? "[green]PASS[/]" : "[red]FAIL[/]",
result.InputsHashMessage ?? "");
Console.WriteLine($"Inputs hash: {(result.InputsHashValid.Value ? "PASS" : "FAIL")} {result.InputsHashMessage}");
}
if (result.ReplayBundleValid.HasValue)
{
checksTable.AddRow(
"Replay Bundle",
result.ReplayBundleValid.Value ? "[green]VALID[/]" : "[red]INVALID[/]",
result.ReplayBundleMessage ?? "");
Console.WriteLine($"Replay bundle: {(result.ReplayBundleValid.Value ? "VALID" : "INVALID")} {result.ReplayBundleMessage}");
}
checksTable.AddRow(
"Expiration",
result.IsExpired ? "[red]EXPIRED[/]" : "[green]VALID[/]",
result.ExpiresAt.HasValue
? (result.IsExpired ? $"Expired {result.ExpiresAt:g}" : $"Expires {result.ExpiresAt:g}")
: "No expiration");
Console.WriteLine(result.ExpiresAt.HasValue
? (result.IsExpired ? $"Expired: {result.ExpiresAt:O}" : $"Expires: {result.ExpiresAt:O}")
: "Expiration: none");
AnsiConsole.Write(checksTable);
AnsiConsole.WriteLine();
// Policy trace
if (showTrace && !verdict.PolicyPath.IsDefaultOrEmpty)
{
var traceTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Policy Evaluation Trace[/]")
.AddColumn("#")
.AddColumn("Rule")
.AddColumn("Matched")
.AddColumn("Action")
.AddColumn("Reason");
Console.WriteLine("Policy trace:");
foreach (var step in verdict.PolicyPath.OrderBy(s => s.Order))
{
traceTable.AddRow(
step.Order.ToString(),
step.RuleName ?? step.RuleId,
step.Matched ? "[green]Yes[/]" : "[grey]No[/]",
step.Action ?? "-",
step.Reason ?? "-");
Console.WriteLine($" {step.Order}: {step.RuleName ?? step.RuleId} matched={step.Matched} action={step.Action ?? "-"} reason={step.Reason ?? "-"}");
}
AnsiConsole.Write(traceTable);
AnsiConsole.WriteLine();
}
// Evidence graph
if (showEvidence && verdict.EvidenceGraph is not null)
{
var evidenceTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Evidence Graph[/]")
.AddColumn("Node ID")
.AddColumn("Type")
.AddColumn("Label");
foreach (var node in verdict.EvidenceGraph.Nodes)
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;
evidenceTable.AddRow(
shortId,
node.Type,
node.Label ?? "-");
Console.WriteLine($" {shortId} {node.Type} {node.Label ?? "-"}");
}
AnsiConsole.Write(evidenceTable);
AnsiConsole.WriteLine();
}
// Provenance
if (verbose)
{
var provTable = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Provenance[/]")
.AddColumn("Property")
.AddColumn("Value");
provTable.AddRow("Generator", verdict.Provenance.Generator);
Console.WriteLine("Provenance:");
Console.WriteLine($" Generator: {verdict.Provenance.Generator}");
if (!string.IsNullOrEmpty(verdict.Provenance.GeneratorVersion))
{
provTable.AddRow("Version", verdict.Provenance.GeneratorVersion);
Console.WriteLine($" Version: {verdict.Provenance.GeneratorVersion}");
}
if (!string.IsNullOrEmpty(verdict.Provenance.RunId))
{
provTable.AddRow("Run ID", verdict.Provenance.RunId);
Console.WriteLine($" Run ID: {verdict.Provenance.RunId}");
}
provTable.AddRow("Created", verdict.Provenance.CreatedAt);
AnsiConsole.Write(provTable);
Console.WriteLine($" Created: {verdict.Provenance.CreatedAt}");
}
}
private static async Task<StellaVerdict?> FetchVerdictFromApiAsync(
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") ?? new HttpClient();
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")
@@ -590,26 +469,29 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
try
{
var response = await httpClient.GetAsync(url, cancellationToken);
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
return (null, $"Verdict fetch failed ({(int)response.StatusCode} {response.ReasonPhrase}).");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
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
catch (Exception ex)
{
return null;
return (null, $"Verdict fetch failed: {ex.Message}");
}
finally
{
if (disposeClient)
{
httpClient.Dispose();
}
}
}
private static string ComputeHash(string content)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
@@ -618,6 +500,41 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
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>