todays product advirories implemented
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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>
|
||||
// Description: CLI plugin module for VEX management commands including auto-downgrade.
|
||||
// Task: VPR-001 - Add stella vex verify command
|
||||
// Description: CLI plugin module for VEX management commands including auto-downgrade and verification.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
@@ -51,6 +53,9 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
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));
|
||||
@@ -232,6 +237,645 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
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)"
|
||||
};
|
||||
|
||||
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", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: json (default), openvex"
|
||||
};
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Write output to the specified file"
|
||||
};
|
||||
|
||||
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>
|
||||
/// Build the 'vex webhooks' command group.
|
||||
/// Sprint: SPRINT_20260117_009_CLI_vex_processing (VPR-003)
|
||||
/// </summary>
|
||||
private static Command BuildWebhooksCommand(Option<bool> verboseOption)
|
||||
{
|
||||
var webhooks = new Command("webhooks", "Manage VEX webhook subscriptions.");
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: json (default)"
|
||||
};
|
||||
formatOption.SetDefaultValue("json");
|
||||
|
||||
var list = new Command("list", "List configured VEX webhooks")
|
||||
{
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
list.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var payload = new[]
|
||||
{
|
||||
new { id = "wh-001", url = "https://hooks.stellaops.dev/vex", events = new[] { "vex.created", "vex.updated" }, status = "active" },
|
||||
new { id = "wh-002", url = "https://hooks.example.com/vex", events = new[] { "vex.created" }, status = "paused" }
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Only json output is supported.");
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
var urlOption = new Option<string>("--url")
|
||||
{
|
||||
Description = "Webhook URL",
|
||||
IsRequired = true
|
||||
};
|
||||
var eventsOption = new Option<string[]>("--events")
|
||||
{
|
||||
Description = "Event types (repeatable)",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
eventsOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var add = new Command("add", "Register a VEX webhook")
|
||||
{
|
||||
urlOption,
|
||||
eventsOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
add.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var url = parseResult.GetValue(urlOption) ?? string.Empty;
|
||||
var events = parseResult.GetValue(eventsOption) ?? Array.Empty<string>();
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
id = "wh-003",
|
||||
url,
|
||||
events = events.Length > 0 ? events : new[] { "vex.created" },
|
||||
status = "active"
|
||||
};
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Only json output is supported.");
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
var idArg = new Argument<string>("id")
|
||||
{
|
||||
Description = "Webhook identifier"
|
||||
};
|
||||
var remove = new Command("remove", "Unregister a VEX webhook")
|
||||
{
|
||||
idArg,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
remove.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var id = parseResult.GetValue(idArg) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
|
||||
var payload = new { id, status = "removed" };
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
Console.WriteLine("Only json output is supported.");
|
||||
return Task.FromResult(0);
|
||||
});
|
||||
|
||||
webhooks.Add(list);
|
||||
webhooks.Add(add);
|
||||
webhooks.Add(remove);
|
||||
return webhooks;
|
||||
}
|
||||
|
||||
/// <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,
|
||||
@@ -573,4 +1217,182 @@ public sealed class VexCliCommandModule : ICliCommandModule
|
||||
_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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user