Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
2026-02-01 21:37:40 +02:00

463 lines
18 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Command group for patch verification operations under the scan command.
/// Implements `stella scan verify-patches` for on-demand patch verification.
/// </summary>
public static class PatchVerifyCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the verify-patches command for scan command group.
/// </summary>
public static Command BuildVerifyPatchesCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var scanIdOption = new Option<string?>("--scan-id", "-s")
{
Description = "Scan ID to verify patches for (retrieves CVEs from existing scan)"
};
var cveOption = new Option<string[]>("--cve", "-c")
{
Description = "Specific CVE IDs to verify (comma-separated or multiple --cve flags)",
AllowMultipleArgumentsPerToken = true
};
var binaryPathOption = new Option<string?>("--binary", "-b")
{
Description = "Path to binary file to verify"
};
var imageOption = new Option<string?>("--image", "-i")
{
Description = "OCI image reference to verify patches in"
};
var confidenceThresholdOption = new Option<double>("--confidence-threshold")
{
Description = "Minimum confidence threshold (0.0-1.0, default: 0.7)"
};
confidenceThresholdOption.SetDefaultValue(0.7);
var similarityThresholdOption = new Option<double>("--similarity-threshold")
{
Description = "Minimum similarity threshold for fingerprint match (0.0-1.0, default: 0.85)"
};
similarityThresholdOption.SetDefaultValue(0.85);
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output format: table (default), json, summary"
};
outputOption.SetDefaultValue("table");
var outputFileOption = new Option<string?>("--output-file", "-f")
{
Description = "Write output to file instead of stdout"
};
var includeEvidenceOption = new Option<bool>("--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<string>();
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<int> 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<ILoggerFactory>();
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<IPatchVerificationOrchestrator>();
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<string, string>()
};
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<PatchVerificationEvidence>();
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
}
}