463 lines
18 KiB
C#
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
|
|
}
|
|
}
|