save progress
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user