Files
git.stella-ops.org/src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/VexCliCommandModule.cs
2026-01-22 19:08:46 +02:00

1275 lines
43 KiB
C#

// -----------------------------------------------------------------------------
// 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 <image>
// 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;
/// <summary>
/// CLI plugin module for VEX management commands.
/// Provides 'stella vex auto-downgrade', 'stella vex check', 'stella vex list',
/// and 'stella vex not-reachable' commands.
/// </summary>
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<bool> 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<bool> 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<bool> verboseOption)
{
var imageOption = new Option<string?>("--image")
{
Description = "Container image digest or reference to check"
};
var checkOption = new Option<string?>("--check")
{
Description = "Image to check for hot vulnerable symbols"
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Dry run mode - show what would be downgraded without making changes"
};
var minObservationsOption = new Option<int>("--min-observations")
{
Description = "Minimum observation count threshold",
DefaultValueFactory = _ => 10
};
var minCpuOption = new Option<double>("--min-cpu")
{
Description = "Minimum CPU percentage threshold",
DefaultValueFactory = _ => 1.0
};
var minConfidenceOption = new Option<double>("--min-confidence")
{
Description = "Minimum confidence threshold (0.0-1.0)",
DefaultValueFactory = _ => 0.7
};
var outputOption = new Option<string?>("--output")
{
Description = "Output file path for results (default: stdout)"
};
var formatOption = new Option<OutputFormat>("--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<bool> verboseOption)
{
var imageOption = new Option<string?>("--image")
{
Description = "Container image to check"
};
var cveOption = new Option<string?>("--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<string?>("--product")
{
Description = "Filter by product identifier"
};
var statusOption = new Option<string?>("--status")
{
Description = "Filter by VEX status (affected, not_affected, fixed, under_investigation)"
};
var limitOption = new Option<int>("--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;
}
/// <summary>
/// Build the 'vex verify' command for VEX document validation.
/// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-001)
/// </summary>
private static Command BuildVerifyCommand(
IServiceProvider services,
Option<bool> verboseOption)
{
var documentArg = new Argument<string>("document")
{
Description = "Path to VEX document to verify"
};
var formatOption = new Option<OutputFormat>("--format")
{
Description = "Output format",
DefaultValueFactory = _ => OutputFormat.Table
};
var schemaOption = new Option<string?>("--schema")
{
Description = "Schema version to validate against (e.g., openvex-0.2, csaf-2.0)"
};
schemaOption.AddAlias("-s");
var strictOption = new Option<bool>("--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;
}
/// <summary>
/// Build the 'vex evidence export' command for VEX evidence extraction.
/// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-002)
/// </summary>
private static Command BuildEvidenceCommand(Option<bool> verboseOption)
{
var evidence = new Command("evidence", "VEX evidence export commands.");
var targetArg = new Argument<string>("target")
{
Description = "Digest or component identifier (e.g., sha256:..., pkg:npm/...)"
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: json (default), openvex"
};
formatOption.AddAlias("-f");
formatOption.SetDefaultValue("json");
var outputOption = new Option<string?>("--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<int> 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<string, object?>
{
["@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<string, object?>
{
["vulnerability"] = new Dictionary<string, object?> { ["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;
}
/// <summary>
/// Execute VEX document verification.
/// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-001)
/// </summary>
private static async Task<int> ExecuteVerifyAsync(
IServiceProvider services,
string documentPath,
OutputFormat format,
string? schemaVersion,
bool strict,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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);
}
}
/// <summary>
/// Validate VEX document structure and content.
/// </summary>
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<VexVerificationCheck> 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<bool> verboseOption)
{
var imageOption = new Option<string>("--image")
{
Description = "Container image to analyze",
Required = true
};
var windowOption = new Option<int>("--window")
{
Description = "Observation window in hours",
DefaultValueFactory = _ => 24
};
var minConfidenceOption = new Option<double>("--min-confidence")
{
Description = "Minimum confidence threshold",
DefaultValueFactory = _ => 0.6
};
var outputOption = new Option<string?>("--output")
{
Description = "Output file path for generated VEX statements"
};
var dryRunOption = new Option<bool>("--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<int> 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<ILogger<VexCliCommandModule>>();
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<int> 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<IAutoVexClient>();
if (client != null)
{
return new AutoVexClientScope(client, null);
}
var httpClientFactory = services.GetService<IHttpClientFactory>();
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)
/// <summary>
/// Build the 'vex webhooks' command group.
/// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-003)
/// </summary>
private static Command BuildWebhooksCommand(Option<bool> 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<bool> verboseOption)
{
var formatOption = new Option<string>("--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<WebhookInfo>
{
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<bool> verboseOption)
{
var urlOption = new Option<string>("--url", ["-u"])
{
Description = "Webhook URL",
Required = true
};
var eventsOption = new Option<string[]>("--events", ["-e"])
{
Description = "Event types to subscribe to (vex.created, vex.updated, vex.revoked)",
Required = true
};
eventsOption.AllowMultipleArgumentsPerToken = true;
var secretOption = new Option<string?>("--secret", ["-s"])
{
Description = "Shared secret for webhook signature verification"
};
var nameOption = new Option<string?>("--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<bool> verboseOption)
{
var idArg = new Argument<string>("id")
{
Description = "Webhook ID to remove"
};
var forceOption = new Option<bool>("--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
}