Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.Witness.cs

532 lines
19 KiB
C#

// -----------------------------------------------------------------------------
// CommandHandlers.Witness.cs
// Sprint: SPRINT_3700_0005_0001_witness_ui_cli
// Tasks: CLI-001, CLI-002, CLI-003, CLI-004
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002)
// Description: Command handlers for reachability witness CLI.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Commands;
internal static partial class CommandHandlers
{
private static readonly JsonSerializerOptions WitnessJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Handler for `witness show` command.
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002)
/// </summary>
internal static async Task HandleWitnessShowAsync(
IServiceProvider services,
string witnessId,
string format,
bool noColor,
bool pathOnly,
bool verbose,
CancellationToken cancellationToken)
{
var console = AnsiConsole.Console;
if (verbose)
{
console.MarkupLine($"[dim]Fetching witness: {witnessId}[/]");
}
using var scope = services.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var response = await client.GetWitnessAsync(witnessId, cancellationToken).ConfigureAwait(false);
if (response is null)
{
console.MarkupLine($"[red]Witness not found: {witnessId}[/]");
Environment.ExitCode = 1;
return;
}
// Convert API response to internal DTO for display
var witness = ConvertToWitnessDto(response);
switch (format)
{
case "json":
var json = JsonSerializer.Serialize(response, WitnessJsonOptions);
console.WriteLine(json);
break;
case "yaml":
WriteWitnessYaml(console, witness);
break;
default:
WriteWitnessText(console, witness, pathOnly, noColor);
break;
}
}
/// <summary>
/// Handler for `witness verify` command.
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-004)
/// </summary>
internal static async Task HandleWitnessVerifyAsync(
IServiceProvider services,
string witnessId,
string? publicKeyPath,
bool offline,
bool verbose,
CancellationToken cancellationToken)
{
var console = AnsiConsole.Console;
if (verbose)
{
console.MarkupLine($"[dim]Verifying witness: {witnessId}[/]");
if (publicKeyPath != null)
{
console.MarkupLine($"[dim]Using public key: {publicKeyPath}[/]");
}
}
if (offline && publicKeyPath == null)
{
console.MarkupLine("[yellow]Warning: Offline mode requires --public-key to verify signatures locally.[/]");
console.MarkupLine("[dim]Skipping signature verification.[/]");
return;
}
using var scope = services.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var response = await client.VerifyWitnessAsync(witnessId, cancellationToken).ConfigureAwait(false);
if (response.Verified)
{
// ASCII-only output per AGENTS.md rules
console.MarkupLine("[green][OK] Signature VALID[/]");
if (response.Dsse?.SignerIdentities?.Count > 0)
{
console.MarkupLine($" Signers: {string.Join(", ", response.Dsse.SignerIdentities)}");
}
if (response.Dsse?.PredicateType != null)
{
console.MarkupLine($" Predicate Type: {response.Dsse.PredicateType}");
}
if (response.ContentHash?.Match == true)
{
console.MarkupLine(" Content Hash: [green]MATCH[/]");
}
}
else
{
console.MarkupLine("[red][FAIL] Signature INVALID[/]");
if (response.Message != null)
{
console.MarkupLine($" Error: {response.Message}");
}
Environment.ExitCode = 1;
}
}
/// <summary>
/// Handler for `witness list` command.
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002)
/// </summary>
internal static async Task HandleWitnessListAsync(
IServiceProvider services,
string scanId,
string? vuln,
string? tier,
bool reachableOnly,
string format,
int limit,
bool verbose,
CancellationToken cancellationToken)
{
var console = AnsiConsole.Console;
if (verbose)
{
console.MarkupLine($"[dim]Listing witnesses for scan: {scanId}[/]");
if (vuln != null) console.MarkupLine($"[dim]Filtering by vuln: {vuln}[/]");
if (tier != null) console.MarkupLine($"[dim]Filtering by tier: {tier}[/]");
if (reachableOnly) console.MarkupLine("[dim]Showing reachable witnesses only[/]");
}
using var scope = services.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var request = new WitnessListRequest
{
ScanId = scanId,
VulnerabilityId = vuln,
Limit = limit
};
var response = await client.ListWitnessesAsync(request, cancellationToken).ConfigureAwait(false);
// Convert to internal DTOs and apply deterministic ordering
var witnesses = response.Witnesses
.Select(w => new WitnessListItemDto
{
WitnessId = w.WitnessId,
CveId = w.VulnerabilityId ?? "N/A",
PackageName = ExtractPackageName(w.ComponentPurl),
ConfidenceTier = tier ?? "N/A",
Entrypoint = w.Entrypoint ?? "N/A",
Sink = w.Sink ?? "N/A"
})
.OrderBy(w => w.CveId, StringComparer.Ordinal)
.ThenBy(w => w.WitnessId, StringComparer.Ordinal)
.ToArray();
switch (format)
{
case "json":
var json = JsonSerializer.Serialize(new { witnesses, total = response.TotalCount }, WitnessJsonOptions);
console.WriteLine(json);
break;
default:
WriteWitnessListTable(console, witnesses);
break;
}
}
/// <summary>
/// Handler for `witness export` command.
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-003)
/// </summary>
internal static async Task HandleWitnessExportAsync(
IServiceProvider services,
string witnessId,
string format,
string? outputPath,
bool includeDsse,
bool verbose,
CancellationToken cancellationToken)
{
var console = AnsiConsole.Console;
if (verbose)
{
console.MarkupLine($"[dim]Exporting witness: {witnessId} as {format}[/]");
if (outputPath != null) console.MarkupLine($"[dim]Output: {outputPath}[/]");
}
using var scope = services.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var exportFormat = format switch
{
"sarif" => WitnessExportFormat.Sarif,
"dsse" => WitnessExportFormat.Dsse,
_ => includeDsse ? WitnessExportFormat.Dsse : WitnessExportFormat.Json
};
try
{
await using var stream = await client.DownloadWitnessAsync(witnessId, exportFormat, cancellationToken).ConfigureAwait(false);
if (outputPath != null)
{
await using var fileStream = File.Create(outputPath);
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
console.MarkupLine($"[green]Exported to {outputPath}[/]");
}
else
{
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
console.WriteLine(content);
}
}
catch (HttpRequestException ex)
{
console.MarkupLine($"[red]Export failed: {ex.Message}[/]");
Environment.ExitCode = 1;
}
}
private static string ExtractPackageName(string? purl)
{
if (string.IsNullOrEmpty(purl)) return "N/A";
// Extract name from PURL like pkg:nuget/Newtonsoft.Json@12.0.3
var parts = purl.Split('/');
if (parts.Length < 2) return purl;
var nameVersion = parts[^1].Split('@');
return nameVersion[0];
}
private static WitnessDto ConvertToWitnessDto(WitnessDetailResponse response)
{
return new WitnessDto
{
WitnessId = response.WitnessId,
WitnessSchema = response.WitnessSchema ?? "stellaops.witness.v1",
CveId = response.Vuln?.Id ?? "N/A",
PackageName = ExtractPackageName(response.Artifact?.ComponentPurl),
PackageVersion = ExtractPackageVersion(response.Artifact?.ComponentPurl),
ConfidenceTier = "confirmed", // TODO: map from response
ObservedAt = response.ObservedAt.ToString("O", CultureInfo.InvariantCulture),
Entrypoint = new WitnessEntrypointDto
{
Type = response.Entrypoint?.Kind ?? "unknown",
Route = response.Entrypoint?.Name ?? "N/A",
Symbol = response.Entrypoint?.SymbolId ?? "N/A",
File = null,
Line = 0
},
Sink = new WitnessSinkDto
{
Symbol = response.Sink?.Symbol ?? "N/A",
Package = ExtractPackageName(response.Artifact?.ComponentPurl),
IsTrigger = true
},
Path = (response.Path ?? [])
.Select(p => new PathStepDto
{
Symbol = p.Symbol ?? p.SymbolId ?? "N/A",
File = p.File,
Line = p.Line ?? 0,
Package = null
})
.ToArray(),
Gates = (response.Gates ?? [])
.Select(g => new GateDto
{
Type = g.Type ?? "unknown",
Detail = g.Detail ?? "",
Confidence = (decimal)g.Confidence
})
.ToArray(),
Evidence = new WitnessEvidenceDto
{
CallgraphDigest = response.Evidence?.CallgraphDigest ?? "N/A",
SurfaceDigest = response.Evidence?.SurfaceDigest ?? "N/A",
SignedBy = response.DsseEnvelope?.Signatures?.FirstOrDefault()?.KeyId ?? "unsigned"
}
};
}
private static string ExtractPackageVersion(string? purl)
{
if (string.IsNullOrEmpty(purl)) return "N/A";
var parts = purl.Split('@');
return parts.Length > 1 ? parts[^1] : "N/A";
}
private static void WriteWitnessText(IAnsiConsole console, WitnessDto witness, bool pathOnly, bool noColor)
{
if (!pathOnly)
{
console.WriteLine();
console.MarkupLine($"[bold]WITNESS:[/] {witness.WitnessId}");
console.WriteLine(new string('═', 70));
console.WriteLine();
var tierColor = witness.ConfidenceTier switch
{
"confirmed" => "red",
"likely" => "yellow",
"present" => "grey",
"unreachable" => "green",
_ => "white"
};
console.MarkupLine($"Vulnerability: [bold]{witness.CveId}[/] ({witness.PackageName} <={witness.PackageVersion})");
console.MarkupLine($"Confidence: [{tierColor}]{witness.ConfidenceTier.ToUpperInvariant()}[/]");
console.MarkupLine($"Observed: {witness.ObservedAt}");
console.WriteLine();
}
console.MarkupLine("[bold]CALL PATH[/]");
console.WriteLine(new string('─', 70));
// Entrypoint
console.MarkupLine($"[green][ENTRYPOINT][/] {witness.Entrypoint.Route}");
console.MarkupLine(" │");
// Path steps
for (var i = 0; i < witness.Path.Length; i++)
{
var step = witness.Path[i];
var isLast = i == witness.Path.Length - 1;
var prefix = isLast ? "└──" : "├──";
if (isLast)
{
console.MarkupLine($" {prefix} [red][SINK][/] {step.Symbol}");
if (step.Package != null)
{
console.MarkupLine($" └── {step.Package} (TRIGGER METHOD)");
}
}
else
{
console.MarkupLine($" {prefix} {step.Symbol}");
if (step.File != null)
{
console.MarkupLine($" │ └── {step.File}:{step.Line}");
}
// Check for gates after this step
if (i < witness.Gates.Length)
{
var gate = witness.Gates[i];
console.MarkupLine(" │");
console.MarkupLine($" │ [yellow][GATE: {gate.Type}][/] {gate.Detail} ({gate.Confidence:P0})");
}
}
if (!isLast)
{
console.MarkupLine(" │");
}
}
if (!pathOnly)
{
console.WriteLine();
console.MarkupLine("[bold]EVIDENCE[/]");
console.WriteLine(new string('─', 70));
console.MarkupLine($"Call Graph: {witness.Evidence.CallgraphDigest}");
console.MarkupLine($"Surface: {witness.Evidence.SurfaceDigest}");
console.MarkupLine($"Signed By: {witness.Evidence.SignedBy}");
console.WriteLine();
}
}
private static void WriteWitnessYaml(IAnsiConsole console, WitnessDto witness)
{
console.WriteLine($"witnessId: {witness.WitnessId}");
console.WriteLine($"witnessSchema: {witness.WitnessSchema}");
console.WriteLine($"cveId: {witness.CveId}");
console.WriteLine($"packageName: {witness.PackageName}");
console.WriteLine($"packageVersion: {witness.PackageVersion}");
console.WriteLine($"confidenceTier: {witness.ConfidenceTier}");
console.WriteLine($"observedAt: {witness.ObservedAt}");
console.WriteLine("entrypoint:");
console.WriteLine($" type: {witness.Entrypoint.Type}");
console.WriteLine($" route: {witness.Entrypoint.Route}");
console.WriteLine($" symbol: {witness.Entrypoint.Symbol}");
console.WriteLine("path:");
foreach (var step in witness.Path)
{
console.WriteLine($" - symbol: {step.Symbol}");
if (step.File != null) console.WriteLine($" file: {step.File}");
if (step.Line > 0) console.WriteLine($" line: {step.Line}");
}
console.WriteLine("evidence:");
console.WriteLine($" callgraphDigest: {witness.Evidence.CallgraphDigest}");
console.WriteLine($" surfaceDigest: {witness.Evidence.SurfaceDigest}");
console.WriteLine($" signedBy: {witness.Evidence.SignedBy}");
}
private static void WriteWitnessListTable(IAnsiConsole console, WitnessListItemDto[] witnesses)
{
var table = new Table();
table.AddColumn("Witness ID");
table.AddColumn("CVE");
table.AddColumn("Package");
table.AddColumn("Tier");
table.AddColumn("Entrypoint");
table.AddColumn("Sink");
foreach (var w in witnesses)
{
var tierColor = w.ConfidenceTier switch
{
"confirmed" => "red",
"likely" => "yellow",
"present" => "grey",
"unreachable" => "green",
_ => "white"
};
table.AddRow(
w.WitnessId[..20] + "...",
w.CveId,
w.PackageName,
$"[{tierColor}]{w.ConfidenceTier}[/]",
w.Entrypoint.Length > 25 ? w.Entrypoint[..25] + "..." : w.Entrypoint,
w.Sink.Length > 25 ? w.Sink[..25] + "..." : w.Sink
);
}
console.Write(table);
}
// DTO classes for witness commands
private sealed record WitnessDto
{
public required string WitnessId { get; init; }
public required string WitnessSchema { get; init; }
public required string CveId { get; init; }
public required string PackageName { get; init; }
public required string PackageVersion { get; init; }
public required string ConfidenceTier { get; init; }
public required string ObservedAt { get; init; }
public required WitnessEntrypointDto Entrypoint { get; init; }
public required WitnessSinkDto Sink { get; init; }
public required PathStepDto[] Path { get; init; }
public required GateDto[] Gates { get; init; }
public required WitnessEvidenceDto Evidence { get; init; }
}
private sealed record WitnessEntrypointDto
{
public required string Type { get; init; }
public required string Route { get; init; }
public required string Symbol { get; init; }
public string? File { get; init; }
public int Line { get; init; }
}
private sealed record WitnessSinkDto
{
public required string Symbol { get; init; }
public string? Package { get; init; }
public bool IsTrigger { get; init; }
}
private sealed record PathStepDto
{
public required string Symbol { get; init; }
public string? File { get; init; }
public int Line { get; init; }
public string? Package { get; init; }
}
private sealed record GateDto
{
public required string Type { get; init; }
public required string Detail { get; init; }
public decimal Confidence { get; init; }
}
private sealed record WitnessEvidenceDto
{
public required string CallgraphDigest { get; init; }
public required string SurfaceDigest { get; init; }
public required string SignedBy { get; init; }
}
private sealed record WitnessListItemDto
{
public required string WitnessId { get; init; }
public required string CveId { get; init; }
public required string PackageName { get; init; }
public required string ConfidenceTier { get; init; }
public required string Entrypoint { get; init; }
public required string Sink { get; init; }
}
}