// ----------------------------------------------------------------------------- // PatchVerifyCommandGroup.cs // Sprint: SPRINT_20260111_001_004_CLI_verify_patches // Task: CLI integration for patch verification // Description: CLI commands for patch verification under scan command // ----------------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; using StellaOps.Scanner.PatchVerification; using StellaOps.Scanner.PatchVerification.Models; using System.CommandLine; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Cli.Commands; /// /// Command group for patch verification operations under the scan command. /// Implements `stella scan verify-patches` for on-demand patch verification. /// public static class PatchVerifyCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Build the verify-patches command for scan command group. /// public static Command BuildVerifyPatchesCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var scanIdOption = new Option("--scan-id", "-s") { Description = "Scan ID to verify patches for (retrieves CVEs from existing scan)" }; var cveOption = new Option("--cve", "-c") { Description = "Specific CVE IDs to verify (comma-separated or multiple --cve flags)", AllowMultipleArgumentsPerToken = true }; var binaryPathOption = new Option("--binary", "-b") { Description = "Path to binary file to verify" }; var imageOption = new Option("--image", "-i") { Description = "OCI image reference to verify patches in" }; var confidenceThresholdOption = new Option("--confidence-threshold") { Description = "Minimum confidence threshold (0.0-1.0, default: 0.7)" }; confidenceThresholdOption.SetDefaultValue(0.7); var similarityThresholdOption = new Option("--similarity-threshold") { Description = "Minimum similarity threshold for fingerprint match (0.0-1.0, default: 0.85)" }; similarityThresholdOption.SetDefaultValue(0.85); var outputOption = new Option("--output", "-o") { Description = "Output format: table (default), json, summary" }; outputOption.SetDefaultValue("table"); var outputFileOption = new Option("--output-file", "-f") { Description = "Write output to file instead of stdout" }; var includeEvidenceOption = new Option("--include-evidence") { Description = "Include detailed fingerprint evidence in output" }; var verifyPatches = new Command("verify-patches", "Verify that security patches are present in binaries") { scanIdOption, cveOption, binaryPathOption, imageOption, confidenceThresholdOption, similarityThresholdOption, outputOption, outputFileOption, includeEvidenceOption, verboseOption }; verifyPatches.SetAction(async (parseResult, _) => { var scanId = parseResult.GetValue(scanIdOption); var cves = parseResult.GetValue(cveOption) ?? Array.Empty(); var binaryPath = parseResult.GetValue(binaryPathOption); var image = parseResult.GetValue(imageOption); var confidenceThreshold = parseResult.GetValue(confidenceThresholdOption); var similarityThreshold = parseResult.GetValue(similarityThresholdOption); var output = parseResult.GetValue(outputOption) ?? "table"; var outputFile = parseResult.GetValue(outputFileOption); var includeEvidence = parseResult.GetValue(includeEvidenceOption); var verbose = parseResult.GetValue(verboseOption); return await HandleVerifyPatchesAsync( services, scanId, cves, binaryPath, image, confidenceThreshold, similarityThreshold, output, outputFile, includeEvidence, verbose, cancellationToken); }); return verifyPatches; } private static async Task HandleVerifyPatchesAsync( IServiceProvider services, string? scanId, string[] cves, string? binaryPath, string? image, double confidenceThreshold, double similarityThreshold, string output, string? outputFile, bool includeEvidence, bool verbose, CancellationToken ct) { await using var scope = services.CreateAsyncScope(); var loggerFactory = scope.ServiceProvider.GetService(); var logger = loggerFactory?.CreateLogger(typeof(PatchVerifyCommandGroup)); var console = AnsiConsole.Console; try { // Validate input if (string.IsNullOrWhiteSpace(scanId) && cves.Length == 0) { console.MarkupLine("[red]Error:[/] Either --scan-id or at least one --cve must be specified."); return 1; } if (string.IsNullOrWhiteSpace(binaryPath) && string.IsNullOrWhiteSpace(image) && string.IsNullOrWhiteSpace(scanId)) { console.MarkupLine("[red]Error:[/] Either --binary, --image, or --scan-id must be specified."); return 1; } if (verbose) { console.MarkupLine("[dim]Patch Verification Options:[/]"); if (!string.IsNullOrWhiteSpace(scanId)) console.MarkupLine($"[dim] Scan ID: {scanId}[/]"); if (cves.Length > 0) console.MarkupLine($"[dim] CVEs: {string.Join(", ", cves)}[/]"); if (!string.IsNullOrWhiteSpace(binaryPath)) console.MarkupLine($"[dim] Binary: {binaryPath}[/]"); if (!string.IsNullOrWhiteSpace(image)) console.MarkupLine($"[dim] Image: {image}[/]"); console.MarkupLine($"[dim] Confidence threshold: {confidenceThreshold:P0}[/]"); console.MarkupLine($"[dim] Similarity threshold: {similarityThreshold:P0}[/]"); } // Get the patch verification orchestrator var orchestrator = scope.ServiceProvider.GetService(); if (orchestrator is null) { console.MarkupLine("[yellow]Warning:[/] Patch verification service not available."); console.MarkupLine("[dim]Patch verification requires the Scanner.PatchVerification library to be configured.[/]"); return 1; } // Create verification options var options = new PatchVerificationOptions { MinConfidenceThreshold = confidenceThreshold, MinSimilarityThreshold = similarityThreshold }; // Perform verification PatchVerificationResult? result = null; if (!string.IsNullOrWhiteSpace(scanId)) { // TODO: Fetch CVEs and binary paths from scan results via backend API // For now, show a placeholder message console.MarkupLine($"[dim]Fetching scan results for {scanId}...[/]"); // This would normally fetch from the backend var context = new PatchVerificationContext { ScanId = scanId, TenantId = "default", ImageDigest = "sha256:placeholder", ArtifactPurl = "pkg:oci/placeholder", CveIds = cves.Length > 0 ? cves : new[] { "CVE-2024-0001" }, BinaryPaths = new Dictionary() }; result = await orchestrator.VerifyAsync(context, ct); } else if (!string.IsNullOrWhiteSpace(binaryPath)) { // Verify single binary if (!File.Exists(binaryPath)) { console.MarkupLine($"[red]Error:[/] Binary file not found: {binaryPath}"); return 1; } var evidenceList = new List(); foreach (var cve in cves) { var evidence = await orchestrator.VerifySingleAsync( cve, binaryPath, "pkg:generic/binary", options, ct); evidenceList.Add(evidence); } result = new PatchVerificationResult { ScanId = $"cli-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}", Evidence = evidenceList, PatchedCves = evidenceList .Where(e => e.Status == PatchVerificationStatus.Verified) .Select(e => e.CveId) .ToHashSet(), UnpatchedCves = evidenceList .Where(e => e.Status == PatchVerificationStatus.NotPatched) .Select(e => e.CveId) .ToHashSet(), InconclusiveCves = evidenceList .Where(e => e.Status == PatchVerificationStatus.Inconclusive) .Select(e => e.CveId) .ToHashSet(), NoPatchDataCves = evidenceList .Where(e => e.Status == PatchVerificationStatus.NoPatchData) .Select(e => e.CveId) .ToHashSet(), VerifiedAt = DateTimeOffset.UtcNow, VerifierVersion = "1.0.0" }; } else { console.MarkupLine("[red]Error:[/] Image-based verification not yet implemented. Use --binary instead."); return 1; } if (result is null) { console.MarkupLine("[red]Error:[/] Verification failed to produce results."); return 1; } // Output results var outputText = output.ToLowerInvariant() switch { "json" => FormatJsonOutput(result, includeEvidence), "summary" => FormatSummaryOutput(result), _ => FormatTableOutput(result, includeEvidence, console) }; if (!string.IsNullOrWhiteSpace(outputFile)) { await File.WriteAllTextAsync(outputFile, outputText, ct); console.MarkupLine($"[green]Output written to {outputFile}[/]"); } else if (output.ToLowerInvariant() != "table") { console.WriteLine(outputText); } // Return exit code based on results if (result.UnpatchedCves.Count > 0) { return 2; // Unpatched vulnerabilities found } if (result.InconclusiveCves.Count > 0 && result.PatchedCves.Count == 0) { return 3; // Only inconclusive results } return 0; // Success } catch (Exception ex) { logger?.LogError(ex, "Patch verification failed"); console.MarkupLine($"[red]Error:[/] {ex.Message}"); return 1; } } private static string FormatJsonOutput(PatchVerificationResult result, bool includeEvidence) { var output = new { scanId = result.ScanId, verifiedAt = result.VerifiedAt.ToString("O", CultureInfo.InvariantCulture), verifierVersion = result.VerifierVersion, summary = new { totalCves = result.Evidence.Count, patched = result.PatchedCves.Count, unpatched = result.UnpatchedCves.Count, inconclusive = result.InconclusiveCves.Count, noPatchData = result.NoPatchDataCves.Count }, patchedCves = result.PatchedCves, unpatchedCves = result.UnpatchedCves, inconclusiveCves = result.InconclusiveCves, noPatchDataCves = result.NoPatchDataCves, evidence = includeEvidence ? result.Evidence.Select(e => new { evidenceId = e.EvidenceId, cveId = e.CveId, binaryPath = e.BinaryPath, status = e.Status.ToString(), similarity = e.Similarity, confidence = e.Confidence, method = e.Method.ToString(), reason = e.Reason, trustScore = e.ComputeTrustScore(), verifiedAt = e.VerifiedAt.ToString("O", CultureInfo.InvariantCulture) }) : null }; return JsonSerializer.Serialize(output, JsonOptions); } private static string FormatSummaryOutput(PatchVerificationResult result) { var sb = new System.Text.StringBuilder(); sb.AppendLine("Patch Verification Summary"); sb.AppendLine("=========================="); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"Scan ID: {result.ScanId}"); sb.AppendLine(CultureInfo.InvariantCulture, $"Verified at: {result.VerifiedAt:yyyy-MM-dd HH:mm:ss} UTC"); sb.AppendLine(CultureInfo.InvariantCulture, $"Verifier version: {result.VerifierVersion}"); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"Total CVEs checked: {result.Evidence.Count}"); sb.AppendLine(CultureInfo.InvariantCulture, $" Patched: {result.PatchedCves.Count}"); sb.AppendLine(CultureInfo.InvariantCulture, $" Unpatched: {result.UnpatchedCves.Count}"); sb.AppendLine(CultureInfo.InvariantCulture, $" Inconclusive: {result.InconclusiveCves.Count}"); sb.AppendLine(CultureInfo.InvariantCulture, $" No patch data: {result.NoPatchDataCves.Count}"); if (result.PatchedCves.Count > 0) { sb.AppendLine(); sb.AppendLine("Patched CVEs:"); foreach (var cve in result.PatchedCves.OrderBy(c => c)) { sb.AppendLine(CultureInfo.InvariantCulture, $" [PATCHED] {cve}"); } } if (result.UnpatchedCves.Count > 0) { sb.AppendLine(); sb.AppendLine("Unpatched CVEs:"); foreach (var cve in result.UnpatchedCves.OrderBy(c => c)) { sb.AppendLine(CultureInfo.InvariantCulture, $" [UNPATCHED] {cve}"); } } return sb.ToString(); } private static string FormatTableOutput(PatchVerificationResult result, bool includeEvidence, IAnsiConsole console) { // Header var header = new Panel(new Markup($"[bold]Patch Verification Results[/] - {result.ScanId}")) .Border(BoxBorder.Rounded) .Padding(1, 0); console.Write(header); // Summary table var summaryTable = new Table() .Border(TableBorder.Rounded) .Title("[bold]Summary[/]") .AddColumn("Metric") .AddColumn("Count"); summaryTable.AddRow("Total CVEs", result.Evidence.Count.ToString(CultureInfo.InvariantCulture)); summaryTable.AddRow("[green]Patched[/]", result.PatchedCves.Count.ToString(CultureInfo.InvariantCulture)); summaryTable.AddRow("[red]Unpatched[/]", result.UnpatchedCves.Count.ToString(CultureInfo.InvariantCulture)); summaryTable.AddRow("[yellow]Inconclusive[/]", result.InconclusiveCves.Count.ToString(CultureInfo.InvariantCulture)); summaryTable.AddRow("[dim]No patch data[/]", result.NoPatchDataCves.Count.ToString(CultureInfo.InvariantCulture)); console.Write(summaryTable); // Evidence table if (result.Evidence.Count > 0) { console.WriteLine(); var evidenceTable = new Table() .Border(TableBorder.Rounded) .Title("[bold]Verification Evidence[/]") .AddColumn("CVE") .AddColumn("Status") .AddColumn("Similarity") .AddColumn("Confidence") .AddColumn("Method") .AddColumn("Trust Score"); foreach (var evidence in result.Evidence.OrderBy(e => e.CveId)) { var statusColor = evidence.Status switch { PatchVerificationStatus.Verified => "green", PatchVerificationStatus.NotPatched => "red", PatchVerificationStatus.PartialMatch => "yellow", PatchVerificationStatus.Inconclusive => "yellow", _ => "dim" }; evidenceTable.AddRow( evidence.CveId, $"[{statusColor}]{evidence.Status}[/]", evidence.Similarity.ToString("P0", CultureInfo.InvariantCulture), evidence.Confidence.ToString("P0", CultureInfo.InvariantCulture), evidence.Method.ToString(), evidence.ComputeTrustScore().ToString("P0", CultureInfo.InvariantCulture)); } console.Write(evidenceTable); } // Verified timestamp console.WriteLine(); console.MarkupLine($"[dim]Verified at: {result.VerifiedAt:yyyy-MM-dd HH:mm:ss} UTC[/]"); console.MarkupLine($"[dim]Verifier version: {result.VerifierVersion}[/]"); return string.Empty; // Table output is written directly to console } }