1275 lines
43 KiB
C#
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
|
|
}
|