release orchestrator v1 draft and build fixes
This commit is contained in:
461
src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
Normal file
461
src/Cli/StellaOps.Cli/Commands/PatchVerifyCommandGroup.cs
Normal file
@@ -0,0 +1,461 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.PatchVerification;
|
||||
using StellaOps.Scanner.PatchVerification.Models;
|
||||
using Spectre.Console;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user