// ----------------------------------------------------------------------------- // 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 }; /// /// Handler for `witness show` command. /// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002) /// 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(); 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; } } /// /// Handler for `witness verify` command. /// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-004) /// 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(); 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; } } /// /// Handler for `witness list` command. /// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002) /// 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(); 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; } } /// /// Handler for `witness export` command. /// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-003) /// 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(); 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; } } }