Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- StellaOps.Cli.Plugins.Verdict: CLI plugin for offline verdict verification -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Verdict\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Verdict\StellaOps.Verdict.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,652 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Verdict.Schema;
|
||||
|
||||
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))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] --verdict is required.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
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 result = new VerdictVerificationResult();
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Load the verdict
|
||||
StellaVerdict? loadedVerdict = null;
|
||||
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Loading verdict...", async ctx =>
|
||||
{
|
||||
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}";
|
||||
}
|
||||
});
|
||||
|
||||
if (loadedVerdict is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {result.Error ?? "Failed to load verdict"}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Step 3: Check signature
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Checking signatures...", ctx =>
|
||||
{
|
||||
ctx.Spinner(Spinner.Known.Dots);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
if (File.Exists(inputsPath))
|
||||
{
|
||||
var inputsJson = await File.ReadAllTextAsync(inputsPath, cancellationToken);
|
||||
var inputsHash = ComputeHash(inputsJson);
|
||||
|
||||
// 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}";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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}";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Check expiration
|
||||
result.IsExpired = false;
|
||||
if (!string.IsNullOrEmpty(loadedVerdict.Result.ExpiresAt))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(loadedVerdict.Result.ExpiresAt, out var expiresAt))
|
||||
{
|
||||
result.IsExpired = expiresAt < DateTimeOffset.UtcNow;
|
||||
result.ExpiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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"),
|
||||
inputsHashValid = result.InputsHashValid,
|
||||
replayBundleValid = result.ReplayBundleValid,
|
||||
verdict = loadedVerdict
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, resultJson, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[green]Results written to:[/] {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(resultJson);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderTableResult(loadedVerdict, result, showTrace, showEvidence, verbose);
|
||||
}
|
||||
|
||||
// Return appropriate exit code
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return 1; // Invalid
|
||||
}
|
||||
|
||||
if (result.IsExpired)
|
||||
{
|
||||
return 2; // Expired
|
||||
}
|
||||
|
||||
return 0; // Valid
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to verify verdict: {Path}", verdictPath);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderTableResult(
|
||||
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);
|
||||
if (!string.IsNullOrEmpty(verdict.Subject.ImageDigest))
|
||||
{
|
||||
subjectTable.AddRow("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}");
|
||||
if (!string.IsNullOrEmpty(verdict.Claim.Reason))
|
||||
{
|
||||
claimTable.AddRow("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"));
|
||||
|
||||
if (result.InputsHashValid.HasValue)
|
||||
{
|
||||
checksTable.AddRow(
|
||||
"Inputs Hash",
|
||||
result.InputsHashValid.Value ? "[green]PASS[/]" : "[red]FAIL[/]",
|
||||
result.InputsHashMessage ?? "");
|
||||
}
|
||||
|
||||
if (result.ReplayBundleValid.HasValue)
|
||||
{
|
||||
checksTable.AddRow(
|
||||
"Replay Bundle",
|
||||
result.ReplayBundleValid.Value ? "[green]VALID[/]" : "[red]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");
|
||||
|
||||
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");
|
||||
|
||||
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 ?? "-");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var shortId = node.Id.Length > 16 ? node.Id[..16] + "..." : node.Id;
|
||||
evidenceTable.AddRow(
|
||||
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);
|
||||
if (!string.IsNullOrEmpty(verdict.Provenance.GeneratorVersion))
|
||||
{
|
||||
provTable.AddRow("Version", verdict.Provenance.GeneratorVersion);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(verdict.Provenance.RunId))
|
||||
{
|
||||
provTable.AddRow("Run ID", verdict.Provenance.RunId);
|
||||
}
|
||||
provTable.AddRow("Created", verdict.Provenance.CreatedAt);
|
||||
|
||||
AnsiConsole.Write(provTable);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StellaVerdict?> FetchVerdictFromApiAsync(
|
||||
IServiceProvider services,
|
||||
string verdictId,
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory?.CreateClient("verdict") ?? new HttpClient();
|
||||
|
||||
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);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
|
||||
@@ -62,18 +62,14 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new Command("auto-downgrade", "Auto-downgrade VEX based on runtime observations.");
|
||||
|
||||
var imageOption = new Option<string>("--image")
|
||||
var imageOption = new Option<string?>("--image")
|
||||
{
|
||||
Description = "Container image digest or reference to check",
|
||||
IsRequired = false
|
||||
Description = "Container image digest or reference to check"
|
||||
};
|
||||
|
||||
var checkOption = new Option<string>("--check")
|
||||
var checkOption = new Option<string?>("--check")
|
||||
{
|
||||
Description = "Image to check for hot vulnerable symbols",
|
||||
IsRequired = false
|
||||
Description = "Image to check for hot vulnerable symbols"
|
||||
};
|
||||
|
||||
var dryRunOption = new Option<bool>("--dry-run")
|
||||
@@ -84,20 +80,20 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
var minObservationsOption = new Option<int>("--min-observations")
|
||||
{
|
||||
Description = "Minimum observation count threshold",
|
||||
DefaultValueFactory = _ => 10
|
||||
};
|
||||
minObservationsOption.SetDefaultValue(10);
|
||||
|
||||
var minCpuOption = new Option<double>("--min-cpu")
|
||||
{
|
||||
Description = "Minimum CPU percentage threshold",
|
||||
DefaultValueFactory = _ => 1.0
|
||||
};
|
||||
minCpuOption.SetDefaultValue(1.0);
|
||||
|
||||
var minConfidenceOption = new Option<double>("--min-confidence")
|
||||
{
|
||||
Description = "Minimum confidence threshold (0.0-1.0)",
|
||||
DefaultValueFactory = _ => 0.7
|
||||
};
|
||||
minConfidenceOption.SetDefaultValue(0.7);
|
||||
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
@@ -106,39 +102,40 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
|
||||
var formatOption = new Option<OutputFormat>("--format")
|
||||
{
|
||||
Description = "Output format"
|
||||
Description = "Output format",
|
||||
DefaultValueFactory = _ => OutputFormat.Table
|
||||
};
|
||||
formatOption.SetDefaultValue(OutputFormat.Table);
|
||||
|
||||
cmd.AddOption(imageOption);
|
||||
cmd.AddOption(checkOption);
|
||||
cmd.AddOption(dryRunOption);
|
||||
cmd.AddOption(minObservationsOption);
|
||||
cmd.AddOption(minCpuOption);
|
||||
cmd.AddOption(minConfidenceOption);
|
||||
cmd.AddOption(outputOption);
|
||||
cmd.AddOption(formatOption);
|
||||
cmd.AddOption(verboseOption);
|
||||
|
||||
cmd.SetHandler(async (context) =>
|
||||
var cmd = new Command("auto-downgrade", "Auto-downgrade VEX based on runtime observations.")
|
||||
{
|
||||
var image = context.ParseResult.GetValueForOption(imageOption);
|
||||
var check = context.ParseResult.GetValueForOption(checkOption);
|
||||
var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
|
||||
var minObs = context.ParseResult.GetValueForOption(minObservationsOption);
|
||||
var minCpu = context.ParseResult.GetValueForOption(minCpuOption);
|
||||
var minConf = context.ParseResult.GetValueForOption(minConfidenceOption);
|
||||
var output = context.ParseResult.GetValueForOption(outputOption);
|
||||
var format = context.ParseResult.GetValueForOption(formatOption);
|
||||
var verbose = context.ParseResult.GetValueForOption(verboseOption);
|
||||
imageOption,
|
||||
checkOption,
|
||||
dryRunOption,
|
||||
minObservationsOption,
|
||||
minCpuOption,
|
||||
minConfidenceOption,
|
||||
outputOption,
|
||||
formatOption
|
||||
};
|
||||
|
||||
cmd.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var check = parseResult.GetValue(checkOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var minObs = parseResult.GetValue(minObservationsOption);
|
||||
var minCpu = parseResult.GetValue(minCpuOption);
|
||||
var minConf = parseResult.GetValue(minConfidenceOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
// Use --check if --image not provided
|
||||
var targetImage = image ?? check;
|
||||
if (string.IsNullOrWhiteSpace(targetImage))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Either --image or --check must be specified.");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
var logger = services.GetService<ILogger<VexCliCommandModule>>();
|
||||
@@ -155,9 +152,9 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
format,
|
||||
verbose,
|
||||
options,
|
||||
cancellationToken);
|
||||
ct);
|
||||
|
||||
context.ExitCode = 0;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -322,8 +319,6 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new Command("check", "Check VEX status for an image or CVE.");
|
||||
|
||||
var imageOption = new Option<string?>("--image")
|
||||
{
|
||||
Description = "Container image to check"
|
||||
@@ -334,25 +329,26 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
Description = "CVE identifier to check"
|
||||
};
|
||||
|
||||
cmd.AddOption(imageOption);
|
||||
cmd.AddOption(cveOption);
|
||||
cmd.AddOption(verboseOption);
|
||||
|
||||
cmd.SetHandler(async (context) =>
|
||||
var cmd = new Command("check", "Check VEX status for an image or CVE.")
|
||||
{
|
||||
var image = context.ParseResult.GetValueForOption(imageOption);
|
||||
var cve = context.ParseResult.GetValueForOption(cveOption);
|
||||
var verbose = context.ParseResult.GetValueForOption(verboseOption);
|
||||
imageOption,
|
||||
cveOption
|
||||
};
|
||||
|
||||
cmd.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var cve = parseResult.GetValue(cveOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(image) && string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Either --image or --cve must be specified.");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[grey]VEX check not yet implemented[/]");
|
||||
context.ExitCode = 0;
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -363,8 +359,6 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new Command("list", "List VEX statements.");
|
||||
|
||||
var productOption = new Option<string?>("--product")
|
||||
{
|
||||
Description = "Filter by product identifier"
|
||||
@@ -377,23 +371,25 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
|
||||
var limitOption = new Option<int>("--limit")
|
||||
{
|
||||
Description = "Maximum number of results"
|
||||
Description = "Maximum number of results",
|
||||
DefaultValueFactory = _ => 100
|
||||
};
|
||||
limitOption.SetDefaultValue(100);
|
||||
|
||||
cmd.AddOption(productOption);
|
||||
cmd.AddOption(statusOption);
|
||||
cmd.AddOption(limitOption);
|
||||
cmd.AddOption(verboseOption);
|
||||
|
||||
cmd.SetHandler(async (context) =>
|
||||
var cmd = new Command("list", "List VEX statements.")
|
||||
{
|
||||
var product = context.ParseResult.GetValueForOption(productOption);
|
||||
var status = context.ParseResult.GetValueForOption(statusOption);
|
||||
var limit = context.ParseResult.GetValueForOption(limitOption);
|
||||
productOption,
|
||||
statusOption,
|
||||
limitOption
|
||||
};
|
||||
|
||||
cmd.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var product = parseResult.GetValue(productOption);
|
||||
var status = parseResult.GetValue(statusOption);
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
|
||||
AnsiConsole.MarkupLine("[grey]VEX list not yet implemented[/]");
|
||||
context.ExitCode = 0;
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -405,25 +401,23 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new Command("not-reachable", "Generate VEX with not_reachable_at_runtime justification.");
|
||||
|
||||
var imageOption = new Option<string>("--image")
|
||||
{
|
||||
Description = "Container image to analyze",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var windowOption = new Option<int>("--window")
|
||||
{
|
||||
Description = "Observation window in hours"
|
||||
Description = "Observation window in hours",
|
||||
DefaultValueFactory = _ => 24
|
||||
};
|
||||
windowOption.SetDefaultValue(24);
|
||||
|
||||
var minConfidenceOption = new Option<double>("--min-confidence")
|
||||
{
|
||||
Description = "Minimum confidence threshold"
|
||||
Description = "Minimum confidence threshold",
|
||||
DefaultValueFactory = _ => 0.6
|
||||
};
|
||||
minConfidenceOption.SetDefaultValue(0.6);
|
||||
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
@@ -435,27 +429,28 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
Description = "Dry run - analyze but don't generate VEX"
|
||||
};
|
||||
|
||||
cmd.AddOption(imageOption);
|
||||
cmd.AddOption(windowOption);
|
||||
cmd.AddOption(minConfidenceOption);
|
||||
cmd.AddOption(outputOption);
|
||||
cmd.AddOption(dryRunOption);
|
||||
cmd.AddOption(verboseOption);
|
||||
|
||||
cmd.SetHandler(async (context) =>
|
||||
var cmd = new Command("not-reachable", "Generate VEX with not_reachable_at_runtime justification.")
|
||||
{
|
||||
var image = context.ParseResult.GetValueForOption(imageOption);
|
||||
var window = context.ParseResult.GetValueForOption(windowOption);
|
||||
var minConf = context.ParseResult.GetValueForOption(minConfidenceOption);
|
||||
var output = context.ParseResult.GetValueForOption(outputOption);
|
||||
var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
|
||||
var verbose = context.ParseResult.GetValueForOption(verboseOption);
|
||||
imageOption,
|
||||
windowOption,
|
||||
minConfidenceOption,
|
||||
outputOption,
|
||||
dryRunOption
|
||||
};
|
||||
|
||||
cmd.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var window = parseResult.GetValue(windowOption);
|
||||
var minConf = parseResult.GetValue(minConfidenceOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] --image is required.");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
await RunNotReachableAnalysisAsync(
|
||||
@@ -467,9 +462,9 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
dryRun,
|
||||
verbose,
|
||||
options,
|
||||
cancellationToken);
|
||||
ct);
|
||||
|
||||
context.ExitCode = 0;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -584,9 +579,8 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
var httpClient = services.GetService<IHttpClientFactory>()?.CreateClient("autovex")
|
||||
?? new HttpClient();
|
||||
|
||||
var baseUrl = options.ExcititorApiBaseUrl
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_EXCITITOR_URL")
|
||||
?? "http://localhost:5080";
|
||||
var baseUrl = Environment.GetEnvironmentVariable("STELLAOPS_EXCITITOR_URL")
|
||||
?? (string.IsNullOrEmpty(options.BackendUrl) ? "http://localhost:5080" : options.BackendUrl);
|
||||
|
||||
return new AutoVexHttpClient(httpClient, baseUrl);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user