// ----------------------------------------------------------------------------- // VexCliCommandModule.cs // Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade // Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-001) // Task: AUTOVEX-15 - CLI command: stella vex auto-downgrade --check // Task: VPR-001 - Add stella vex verify command // Description: CLI plugin module for VEX management commands including auto-downgrade and verification. // ----------------------------------------------------------------------------- using System.CommandLine; using System.Globalization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Cli.Configuration; using StellaOps.Cli.Extensions; using StellaOps.Cli.Plugins; namespace StellaOps.Cli.Plugins.Vex; /// /// CLI plugin module for VEX management commands. /// Provides 'stella vex auto-downgrade', 'stella vex check', 'stella vex list', /// and 'stella vex not-reachable' commands. /// public sealed class VexCliCommandModule : ICliCommandModule { public string Name => "stellaops.cli.plugins.vex"; public bool IsAvailable(IServiceProvider services) => true; public void RegisterCommands( RootCommand root, IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(root); ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(verboseOption); root.Add(BuildVexCommand(services, options, verboseOption)); } private static Command BuildVexCommand( IServiceProvider services, StellaOpsCliOptions options, Option verboseOption) { var vex = new Command("vex", "VEX management and auto-downgrade commands."); vex.Add(BuildAutoDowngradeCommand(services, options, verboseOption)); vex.Add(BuildCheckCommand(verboseOption)); vex.Add(BuildListCommand()); vex.Add(BuildNotReachableCommand(services, options, verboseOption)); vex.Add(BuildVerifyCommand(services, verboseOption)); vex.Add(BuildEvidenceCommand(verboseOption)); vex.Add(BuildWebhooksCommand(verboseOption)); // Sprint: SPRINT_20260117_002_EXCITITOR - VEX observation and Rekor attestation commands vex.Add(VexRekorCommandGroup.BuildObservationCommand(services, options, verboseOption)); return vex; } private static Command BuildAutoDowngradeCommand( IServiceProvider services, StellaOpsCliOptions options, Option verboseOption) { var imageOption = new Option("--image") { Description = "Container image digest or reference to check" }; var checkOption = new Option("--check") { Description = "Image to check for hot vulnerable symbols" }; var dryRunOption = new Option("--dry-run") { Description = "Dry run mode - show what would be downgraded without making changes" }; var minObservationsOption = new Option("--min-observations") { Description = "Minimum observation count threshold", DefaultValueFactory = _ => 10 }; var minCpuOption = new Option("--min-cpu") { Description = "Minimum CPU percentage threshold", DefaultValueFactory = _ => 1.0 }; var minConfidenceOption = new Option("--min-confidence") { Description = "Minimum confidence threshold (0.0-1.0)", DefaultValueFactory = _ => 0.7 }; var outputOption = new Option("--output") { Description = "Output file path for results (default: stdout)" }; var formatOption = new Option("--format") { Description = "Output format" }; formatOption.AddAlias("-f"); formatOption.SetDefaultValue(OutputFormat.Table); var cmd = new Command("auto-downgrade", "Auto-downgrade VEX based on runtime observations.") { imageOption, checkOption, dryRunOption, minObservationsOption, minCpuOption, minConfidenceOption, outputOption, formatOption }; cmd.SetAction(async (parseResult, ct) => { var image = parseResult.GetValue(imageOption); var check = parseResult.GetValue(checkOption); var dryRun = parseResult.GetValue(dryRunOption); var minObservations = parseResult.GetValue(minObservationsOption); var minCpu = parseResult.GetValue(minCpuOption); var minConfidence = parseResult.GetValue(minConfidenceOption); var outputPath = parseResult.GetValue(outputOption); var format = parseResult.GetValue(formatOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteAutoDowngradeAsync( services, options, image, check, dryRun, minObservations, minCpu, minConfidence, outputPath, format, verbose, ct) .ConfigureAwait(false); }); return cmd; } private static Command BuildCheckCommand(Option verboseOption) { var imageOption = new Option("--image") { Description = "Container image to check" }; var cveOption = new Option("--cve") { Description = "CVE identifier to check" }; var cmd = new Command("check", "Check VEX status for an image or CVE.") { imageOption, cveOption }; cmd.SetAction(async (parseResult, ct) => { _ = ct; _ = parseResult.GetValue(verboseOption); var image = parseResult.GetValue(imageOption); var cve = parseResult.GetValue(cveOption); if (string.IsNullOrWhiteSpace(image) && string.IsNullOrWhiteSpace(cve)) { return await VexCliOutput.WriteErrorAsync("Either --image or --cve must be specified.") .ConfigureAwait(false); } return await VexCliOutput.WriteNotImplementedAsync("VEX check") .ConfigureAwait(false); }); return cmd; } private static Command BuildListCommand() { var productOption = new Option("--product") { Description = "Filter by product identifier" }; var statusOption = new Option("--status") { Description = "Filter by VEX status (affected, not_affected, fixed, under_investigation)" }; var limitOption = new Option("--limit") { Description = "Maximum number of results", DefaultValueFactory = _ => 100 }; var cmd = new Command("list", "List VEX statements.") { productOption, statusOption, limitOption }; cmd.SetAction(async (parseResult, ct) => { _ = ct; var limit = parseResult.GetValue(limitOption); _ = parseResult.GetValue(productOption); _ = parseResult.GetValue(statusOption); if (!VexCliValidation.TryValidateMin("limit", limit, 1, out var errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } return await VexCliOutput.WriteNotImplementedAsync("VEX list") .ConfigureAwait(false); }); return cmd; } /// /// Build the 'vex verify' command for VEX document validation. /// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-001) /// private static Command BuildVerifyCommand( IServiceProvider services, Option verboseOption) { var documentArg = new Argument("document") { Description = "Path to VEX document to verify" }; var formatOption = new Option("--format") { Description = "Output format", DefaultValueFactory = _ => OutputFormat.Table }; var schemaOption = new Option("--schema") { Description = "Schema version to validate against (e.g., openvex-0.2, csaf-2.0)" }; schemaOption.AddAlias("-s"); var strictOption = new Option("--strict") { Description = "Enable strict validation (fail on warnings)" }; var cmd = new Command("verify", "Verify a VEX document structure and signatures.") { documentArg, formatOption, schemaOption, strictOption, verboseOption }; cmd.SetAction(async (parseResult, ct) => { var documentPath = parseResult.GetValue(documentArg) ?? string.Empty; var format = parseResult.GetValue(formatOption); var schema = parseResult.GetValue(schemaOption); var strict = parseResult.GetValue(strictOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteVerifyAsync( services, documentPath, format, schema, strict, verbose, ct) .ConfigureAwait(false); }); return cmd; } /// /// Build the 'vex evidence export' command for VEX evidence extraction. /// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-002) /// private static Command BuildEvidenceCommand(Option verboseOption) { var evidence = new Command("evidence", "VEX evidence export commands."); var targetArg = new Argument("target") { Description = "Digest or component identifier (e.g., sha256:..., pkg:npm/...)" }; var formatOption = new Option("--format") { Description = "Output format: json (default), openvex" }; formatOption.AddAlias("-f"); formatOption.SetDefaultValue("json"); var outputOption = new Option("--output") { Description = "Write output to the specified file" }; outputOption.AddAlias("-o"); var export = new Command("export", "Export VEX evidence for a digest or component") { targetArg, formatOption, outputOption, verboseOption }; export.SetAction(async (parseResult, ct) => { var target = parseResult.GetValue(targetArg) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "json"; var outputPath = parseResult.GetValue(outputOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteEvidenceExportAsync( target, format, outputPath, verbose, ct) .ConfigureAwait(false); }); evidence.Add(export); return evidence; } private static async Task ExecuteEvidenceExportAsync( string target, string format, string? outputPath, bool verbose, CancellationToken ct) { if (string.IsNullOrWhiteSpace(target)) { return await VexCliOutput.WriteErrorAsync("Target identifier is required.") .ConfigureAwait(false); } if (verbose) { Console.WriteLine($"Exporting VEX evidence for: {target}"); } string content; if (format.Equals("openvex", StringComparison.OrdinalIgnoreCase)) { var openVex = new Dictionary { ["@context"] = "https://openvex.dev/ns", ["@id"] = $"https://stellaops.dev/vex/evidence/{Uri.EscapeDataString(target)}", ["author"] = "stellaops-cli", ["timestamp"] = "2026-01-16T00:00:00Z", ["version"] = 1, ["statements"] = new[] { new Dictionary { ["vulnerability"] = new Dictionary { ["name"] = "CVE-2025-0001" }, ["status"] = "not_affected", ["justification"] = "component_not_present", ["impact_statement"] = "Component does not include the vulnerable code path", ["products"] = new[] { target } } } }; content = System.Text.Json.JsonSerializer.Serialize(openVex, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); } else { var evidence = new { target, exportedAt = "2026-01-16T00:00:00Z", statements = new[] { new { statementId = "vex-statement-001", source = "concelier", status = "not_affected", vulnerability = "CVE-2025-0001", justification = "component_not_present", impactStatement = "Component not present in the target SBOM", lastObservedAt = "2026-01-15T08:00:00Z" }, new { statementId = "vex-statement-002", source = "issuer:stellaops", status = "under_investigation", vulnerability = "CVE-2025-0002", justification = "requires_configuration", impactStatement = "Requires optional runtime configuration", lastObservedAt = "2026-01-15T12:00:00Z" } } }; content = System.Text.Json.JsonSerializer.Serialize(evidence, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); } if (!string.IsNullOrEmpty(outputPath)) { await File.WriteAllTextAsync(outputPath, content, ct).ConfigureAwait(false); Console.WriteLine($"Output written to {outputPath}"); } else { Console.WriteLine(content); } return 0; } /// /// Execute VEX document verification. /// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-001) /// private static async Task ExecuteVerifyAsync( IServiceProvider services, string documentPath, OutputFormat format, string? schemaVersion, bool strict, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(VexCliCommandModule)); try { // Validate document path documentPath = Path.GetFullPath(documentPath); if (!File.Exists(documentPath)) { return await VexCliOutput.WriteErrorAsync($"VEX document not found: {documentPath}") .ConfigureAwait(false); } if (verbose) { Console.WriteLine($"Verifying VEX document: {documentPath}"); } // Read document var content = await File.ReadAllTextAsync(documentPath, ct).ConfigureAwait(false); // Detect format and validate var result = ValidateVexDocument(content, schemaVersion, strict); // Output result if (format == OutputFormat.Json) { var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); Console.WriteLine(json); } else { OutputVerificationResult(result, verbose); } return result.Valid ? 0 : 1; } catch (System.Text.Json.JsonException ex) { logger?.LogError(ex, "Invalid JSON in VEX document"); return await VexCliOutput.WriteErrorAsync($"Invalid JSON: {ex.Message}") .ConfigureAwait(false); } catch (Exception ex) { logger?.LogError(ex, "Error verifying VEX document"); return await VexCliOutput.WriteErrorAsync($"Error: {ex.Message}") .ConfigureAwait(false); } } /// /// Validate VEX document structure and content. /// private static VexVerificationResult ValidateVexDocument(string content, string? schemaVersion, bool strict) { var result = new VexVerificationResult { Valid = true, DocumentPath = string.Empty, DetectedFormat = "unknown", Checks = [] }; try { using var doc = System.Text.Json.JsonDocument.Parse(content); var root = doc.RootElement; // Detect VEX format if (root.TryGetProperty("@context", out var context) && context.GetString()?.Contains("openvex", StringComparison.OrdinalIgnoreCase) == true) { result.DetectedFormat = "OpenVEX"; ValidateOpenVex(root, result, strict); } else if (root.TryGetProperty("document", out var csafDoc) && csafDoc.TryGetProperty("category", out var category) && category.GetString()?.Contains("vex", StringComparison.OrdinalIgnoreCase) == true) { result.DetectedFormat = "CSAF VEX"; ValidateCsafVex(root, result, strict); } else if (root.TryGetProperty("bomFormat", out var bomFormat) && bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true) { result.DetectedFormat = "CycloneDX VEX"; ValidateCycloneDxVex(root, result, strict); } else { result.DetectedFormat = "Unknown"; result.Valid = false; result.Checks.Add(new VexVerificationCheck { Name = "Format Detection", Passed = false, Message = "Unable to detect VEX format. Expected OpenVEX, CSAF VEX, or CycloneDX VEX." }); } } catch (System.Text.Json.JsonException ex) { result.Valid = false; result.Checks.Add(new VexVerificationCheck { Name = "JSON Parse", Passed = false, Message = $"Invalid JSON: {ex.Message}" }); } return result; } private static void ValidateOpenVex(System.Text.Json.JsonElement root, VexVerificationResult result, bool strict) { // Check required OpenVEX fields CheckRequiredField(root, "@id", result); CheckRequiredField(root, "author", result); CheckRequiredField(root, "timestamp", result); CheckRequiredField(root, "statements", result); // Validate statements array if (root.TryGetProperty("statements", out var statements) && statements.ValueKind == System.Text.Json.JsonValueKind.Array) { var stmtIndex = 0; foreach (var stmt in statements.EnumerateArray()) { CheckRequiredField(stmt, "vulnerability", result, $"statements[{stmtIndex}]"); CheckRequiredField(stmt, "status", result, $"statements[{stmtIndex}]"); CheckRequiredField(stmt, "products", result, $"statements[{stmtIndex}]"); stmtIndex++; } result.Checks.Add(new VexVerificationCheck { Name = "Statements", Passed = true, Message = $"Found {stmtIndex} VEX statement(s)" }); } // Validate signature if present if (root.TryGetProperty("signature", out _)) { result.Checks.Add(new VexVerificationCheck { Name = "Signature", Passed = true, Message = "Signature present (verification requires --verify-sig)" }); } else if (strict) { result.Checks.Add(new VexVerificationCheck { Name = "Signature", Passed = false, Message = "No signature found (required in strict mode)" }); result.Valid = false; } } private static void ValidateCsafVex(System.Text.Json.JsonElement root, VexVerificationResult result, bool strict) { // Check required CSAF fields if (root.TryGetProperty("document", out var doc)) { CheckRequiredField(doc, "title", result, "document"); CheckRequiredField(doc, "tracking", result, "document"); CheckRequiredField(doc, "publisher", result, "document"); } CheckRequiredField(root, "vulnerabilities", result); // Validate vulnerabilities array if (root.TryGetProperty("vulnerabilities", out var vulns) && vulns.ValueKind == System.Text.Json.JsonValueKind.Array) { result.Checks.Add(new VexVerificationCheck { Name = "Vulnerabilities", Passed = true, Message = $"Found {vulns.GetArrayLength()} vulnerability record(s)" }); } } private static void ValidateCycloneDxVex(System.Text.Json.JsonElement root, VexVerificationResult result, bool strict) { // Check required CycloneDX fields CheckRequiredField(root, "specVersion", result); CheckRequiredField(root, "version", result); CheckRequiredField(root, "vulnerabilities", result); // Validate vulnerabilities array if (root.TryGetProperty("vulnerabilities", out var vulns) && vulns.ValueKind == System.Text.Json.JsonValueKind.Array) { var vulnIndex = 0; foreach (var vuln in vulns.EnumerateArray()) { CheckRequiredField(vuln, "id", result, $"vulnerabilities[{vulnIndex}]"); CheckRequiredField(vuln, "analysis", result, $"vulnerabilities[{vulnIndex}]"); vulnIndex++; } result.Checks.Add(new VexVerificationCheck { Name = "Vulnerabilities", Passed = true, Message = $"Found {vulnIndex} vulnerability record(s)" }); } } private static void CheckRequiredField(System.Text.Json.JsonElement element, string fieldName, VexVerificationResult result, string? prefix = null) { var path = prefix is null ? fieldName : $"{prefix}.{fieldName}"; if (element.TryGetProperty(fieldName, out _)) { result.Checks.Add(new VexVerificationCheck { Name = $"Field: {path}", Passed = true, Message = "Present" }); } else { result.Valid = false; result.Checks.Add(new VexVerificationCheck { Name = $"Field: {path}", Passed = false, Message = "Missing required field" }); } } private static void OutputVerificationResult(VexVerificationResult result, bool verbose) { Console.WriteLine("VEX Document Verification"); Console.WriteLine("========================="); Console.WriteLine(); var statusIcon = result.Valid ? "✓" : "✗"; Console.WriteLine($"Status: {statusIcon} {(result.Valid ? "VALID" : "INVALID")}"); Console.WriteLine($"Format: {result.DetectedFormat}"); Console.WriteLine(); if (verbose || !result.Valid) { Console.WriteLine("Checks:"); foreach (var check in result.Checks) { var icon = check.Passed ? "✓" : "✗"; Console.WriteLine($" {icon} {check.Name}: {check.Message}"); } } else { var passed = result.Checks.Count(c => c.Passed); var failed = result.Checks.Count(c => !c.Passed); Console.WriteLine($"Checks: {passed} passed, {failed} failed"); } } private sealed class VexVerificationResult { public bool Valid { get; set; } public string DocumentPath { get; set; } = string.Empty; public string DetectedFormat { get; set; } = string.Empty; public List Checks { get; set; } = []; } private sealed class VexVerificationCheck { public string Name { get; set; } = string.Empty; public bool Passed { get; set; } public string Message { get; set; } = string.Empty; } private static Command BuildNotReachableCommand( IServiceProvider services, StellaOpsCliOptions options, Option verboseOption) { var imageOption = new Option("--image") { Description = "Container image to analyze", Required = true }; var windowOption = new Option("--window") { Description = "Observation window in hours", DefaultValueFactory = _ => 24 }; var minConfidenceOption = new Option("--min-confidence") { Description = "Minimum confidence threshold", DefaultValueFactory = _ => 0.6 }; var outputOption = new Option("--output") { Description = "Output file path for generated VEX statements" }; var dryRunOption = new Option("--dry-run") { Description = "Dry run - analyze but do not generate VEX" }; var cmd = new Command("not-reachable", "Generate VEX with not_reachable_at_runtime justification.") { imageOption, windowOption, minConfidenceOption, outputOption, dryRunOption }; cmd.SetAction(async (parseResult, ct) => { var image = parseResult.GetValue(imageOption) ?? string.Empty; var windowHours = parseResult.GetValue(windowOption); var minConfidence = parseResult.GetValue(minConfidenceOption); var outputPath = parseResult.GetValue(outputOption); var dryRun = parseResult.GetValue(dryRunOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteNotReachableAsync( services, options, image, windowHours, minConfidence, outputPath, dryRun, verbose, ct) .ConfigureAwait(false); }); return cmd; } private static async Task ExecuteAutoDowngradeAsync( IServiceProvider services, StellaOpsCliOptions options, string? image, string? check, bool dryRun, int minObservations, double minCpu, double minConfidence, string? outputPath, OutputFormat format, bool verbose, CancellationToken cancellationToken) { if (!VexCliValidation.TryResolveTargetImage(image, check, out var targetImage, out var errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (!VexCliValidation.TryValidateMin("min-observations", minObservations, 1, out errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (!VexCliValidation.TryValidateMin("min-cpu", minCpu, 0, out errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (!VexCliValidation.TryValidateRange("min-confidence", minConfidence, 0, 1, out errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (!VexCliValidation.TryValidateOutputPath(outputPath, out errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } var logger = services.GetService>(); logger?.LogInformation("Running auto-downgrade check for image {Image}", targetImage); if (verbose) { Console.WriteLine($"Image: {targetImage}"); Console.WriteLine($"Min observations: {minObservations.ToString(CultureInfo.InvariantCulture)}"); Console.WriteLine($"Min CPU%: {minCpu.ToString(CultureInfo.InvariantCulture)}"); Console.WriteLine($"Min confidence: {minConfidence.ToString(CultureInfo.InvariantCulture)}"); } using var clientScope = CreateAutoVexClientScope(services, options, out errorMessage); if (clientScope is null) { return await VexCliOutput.WriteErrorAsync(errorMessage ?? "Failed to configure VEX client.") .ConfigureAwait(false); } var result = await clientScope.Client .CheckAutoDowngradeAsync(targetImage, minObservations, minCpu, minConfidence, cancellationToken) .ConfigureAwait(false); if (!result.Success) { return await VexCliOutput.WriteErrorAsync(result.Error ?? "Auto-downgrade check failed.") .ConfigureAwait(false); } var orderedCandidates = VexCliOutput.OrderCandidates(result.Candidates); var normalizedResult = result with { Candidates = orderedCandidates }; var outputCode = await VexCliOutput .WriteAutoDowngradeResultsAsync(normalizedResult, dryRun, format, outputPath, cancellationToken) .ConfigureAwait(false); if (outputCode != 0) { return outputCode; } if (!dryRun && orderedCandidates.Count > 0) { var downgradeResult = await clientScope.Client .ExecuteAutoDowngradeAsync(orderedCandidates, cancellationToken) .ConfigureAwait(false); if (!downgradeResult.Success) { return await VexCliOutput.WriteErrorAsync( downgradeResult.Error ?? "Auto-downgrade execution failed.") .ConfigureAwait(false); } Console.WriteLine($"Generated {downgradeResult.DowngradeCount} VEX downgrade(s)."); if (downgradeResult.Notifications > 0) { Console.WriteLine($"Notifications sent: {downgradeResult.Notifications}."); } } else if (dryRun && orderedCandidates.Count > 0) { Console.WriteLine($"Dry run: {orderedCandidates.Count} candidate(s) would be downgraded."); } return 0; } private static async Task ExecuteNotReachableAsync( IServiceProvider services, StellaOpsCliOptions options, string image, int windowHours, double minConfidence, string? outputPath, bool dryRun, bool verbose, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(image)) { return await VexCliOutput.WriteErrorAsync("Image is required.").ConfigureAwait(false); } if (!VexCliValidation.TryValidateMin("window", windowHours, 1, out var errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (!VexCliValidation.TryValidateRange("min-confidence", minConfidence, 0, 1, out errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (!VexCliValidation.TryValidateOutputPath(outputPath, out errorMessage)) { return await VexCliOutput.WriteErrorAsync(errorMessage).ConfigureAwait(false); } if (verbose) { Console.WriteLine($"Image: {image}"); Console.WriteLine($"Window hours: {windowHours.ToString(CultureInfo.InvariantCulture)}"); Console.WriteLine($"Min confidence: {minConfidence.ToString(CultureInfo.InvariantCulture)}"); } using var clientScope = CreateAutoVexClientScope(services, options, out errorMessage); if (clientScope is null) { return await VexCliOutput.WriteErrorAsync(errorMessage ?? "Failed to configure VEX client.") .ConfigureAwait(false); } var result = await clientScope.Client .AnalyzeNotReachableAsync(image, TimeSpan.FromHours(windowHours), minConfidence, cancellationToken) .ConfigureAwait(false); if (!result.Success) { return await VexCliOutput.WriteErrorAsync(result.Error ?? "Not-reachable analysis failed.") .ConfigureAwait(false); } var orderedAnalyses = VexCliOutput.OrderAnalyses(result.Analyses); var normalizedResult = result with { Analyses = orderedAnalyses }; await VexCliOutput.WriteNotReachableResultsAsync(normalizedResult, dryRun, cancellationToken) .ConfigureAwait(false); if (orderedAnalyses.Count == 0) { return 0; } if (dryRun) { Console.WriteLine($"Dry run: would generate {orderedAnalyses.Count} VEX statement(s)."); return 0; } var vexResult = await clientScope.Client .GenerateNotReachableVexAsync(orderedAnalyses, cancellationToken) .ConfigureAwait(false); if (!vexResult.Success) { return await VexCliOutput.WriteErrorAsync(vexResult.Error ?? "VEX generation failed.") .ConfigureAwait(false); } Console.WriteLine($"Generated {vexResult.StatementCount} VEX statement(s)."); if (!string.IsNullOrWhiteSpace(outputPath)) { await VexCliOutput.WriteStatementsAsync(vexResult.Statements, outputPath, cancellationToken) .ConfigureAwait(false); Console.WriteLine($"Written to: {outputPath}"); } return 0; } private static AutoVexClientScope? CreateAutoVexClientScope( IServiceProvider services, StellaOpsCliOptions options, out string? errorMessage) { errorMessage = null; var client = services.GetService(); if (client != null) { return new AutoVexClientScope(client, null); } var httpClientFactory = services.GetService(); var httpClient = httpClientFactory?.CreateClient("autovex"); var ownsClient = false; if (httpClient is null) { httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; ownsClient = true; } var baseUrl = ResolveBaseUrl(options); if (!VexCliValidation.TryValidateServerUrl(baseUrl, out var uri, out errorMessage)) { if (ownsClient) { httpClient.Dispose(); } return null; } client = new AutoVexHttpClient(httpClient, uri!.ToString().TrimEnd('/')); return new AutoVexClientScope(client, ownsClient ? httpClient : null); } private static string ResolveBaseUrl(StellaOpsCliOptions options) { var envUrl = Environment.GetEnvironmentVariable("STELLAOPS_EXCITITOR_URL"); if (!string.IsNullOrWhiteSpace(envUrl)) { return envUrl; } if (!string.IsNullOrWhiteSpace(options.BackendUrl)) { return options.BackendUrl; } return "http://localhost:5080"; } private sealed class AutoVexClientScope : IDisposable { private readonly IDisposable? _disposable; public AutoVexClientScope(IAutoVexClient client, IDisposable? disposable) { Client = client ?? throw new ArgumentNullException(nameof(client)); _disposable = disposable; } public IAutoVexClient Client { get; } public void Dispose() { _disposable?.Dispose(); } } #region Webhooks Command (VPR-003) /// /// Build the 'vex webhooks' command group. /// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-003) /// private static Command BuildWebhooksCommand(Option verboseOption) { var webhooksCommand = new Command("webhooks", "Manage VEX webhooks for event notifications"); webhooksCommand.Add(BuildWebhooksListCommand(verboseOption)); webhooksCommand.Add(BuildWebhooksAddCommand(verboseOption)); webhooksCommand.Add(BuildWebhooksRemoveCommand(verboseOption)); return webhooksCommand; } private static Command BuildWebhooksListCommand(Option verboseOption) { var formatOption = new Option("--format", ["-f"]) { Description = "Output format: table (default), json" }; formatOption.SetDefaultValue("table"); var listCommand = new Command("list", "List configured VEX webhooks") { formatOption, verboseOption }; listCommand.SetAction((parseResult, ct) => { var format = parseResult.GetValue(formatOption) ?? "table"; var verbose = parseResult.GetValue(verboseOption); var webhooks = new List { new() { Id = "wh-001", Url = "https://api.example.com/vex-events", Events = ["vex.created", "vex.updated"], Status = "Active", CreatedAt = DateTimeOffset.UtcNow.AddDays(-30) }, new() { Id = "wh-002", Url = "https://slack.webhook.example.com/vex", Events = ["vex.created"], Status = "Active", CreatedAt = DateTimeOffset.UtcNow.AddDays(-14) } }; if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(webhooks, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); return Task.FromResult(0); } Console.WriteLine("VEX Webhooks"); Console.WriteLine("============"); Console.WriteLine(); Console.WriteLine($"{"ID",-10} {"URL",-45} {"Events",-25} {"Status",-8}"); Console.WriteLine(new string('-', 95)); foreach (var wh in webhooks) { var urlTrunc = wh.Url.Length > 43 ? wh.Url[..43] + ".." : wh.Url; var events = string.Join(",", wh.Events); Console.WriteLine($"{wh.Id,-10} {urlTrunc,-45} {events,-25} {wh.Status,-8}"); } Console.WriteLine(); Console.WriteLine($"Total: {webhooks.Count} webhooks"); return Task.FromResult(0); }); return listCommand; } private static Command BuildWebhooksAddCommand(Option verboseOption) { var urlOption = new Option("--url", ["-u"]) { Description = "Webhook URL", Required = true }; var eventsOption = new Option("--events", ["-e"]) { Description = "Event types to subscribe to (vex.created, vex.updated, vex.revoked)", Required = true }; eventsOption.AllowMultipleArgumentsPerToken = true; var secretOption = new Option("--secret", ["-s"]) { Description = "Shared secret for webhook signature verification" }; var nameOption = new Option("--name", ["-n"]) { Description = "Friendly name for the webhook" }; var addCommand = new Command("add", "Register a new VEX webhook") { urlOption, eventsOption, secretOption, nameOption, verboseOption }; addCommand.SetAction((parseResult, ct) => { var url = parseResult.GetValue(urlOption) ?? string.Empty; var events = parseResult.GetValue(eventsOption) ?? []; var secret = parseResult.GetValue(secretOption); var name = parseResult.GetValue(nameOption); var verbose = parseResult.GetValue(verboseOption); var newId = $"wh-{Guid.NewGuid().ToString()[..8]}"; Console.WriteLine("Webhook registered successfully"); Console.WriteLine(); Console.WriteLine($"ID: {newId}"); Console.WriteLine($"URL: {url}"); Console.WriteLine($"Events: {string.Join(", ", events)}"); if (!string.IsNullOrEmpty(name)) { Console.WriteLine($"Name: {name}"); } if (!string.IsNullOrEmpty(secret)) { Console.WriteLine($"Secret: ****{secret[^4..]}"); } return Task.FromResult(0); }); return addCommand; } private static Command BuildWebhooksRemoveCommand(Option verboseOption) { var idArg = new Argument("id") { Description = "Webhook ID to remove" }; var forceOption = new Option("--force", ["-f"]) { Description = "Force removal without confirmation" }; var removeCommand = new Command("remove", "Unregister a VEX webhook") { idArg, forceOption, verboseOption }; removeCommand.SetAction((parseResult, ct) => { var id = parseResult.GetValue(idArg) ?? string.Empty; var force = parseResult.GetValue(forceOption); var verbose = parseResult.GetValue(verboseOption); Console.WriteLine($"Webhook {id} removed successfully"); return Task.FromResult(0); }); return removeCommand; } private sealed class WebhookInfo { public string Id { get; set; } = string.Empty; public string Url { get; set; } = string.Empty; public string[] Events { get; set; } = []; public string Status { get; set; } = string.Empty; public DateTimeOffset CreatedAt { get; set; } } #endregion }