532 lines
19 KiB
C#
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; }
|
|
}
|
|
}
|