// ----------------------------------------------------------------------------- // UnknownsCommandGroup.cs // Sprint: SPRINT_3500_0004_0001_cli_verbs, SPRINT_5100_0004_0001_unknowns_budget_ci_gates // Task: T3 - Unknowns List Command, T1 - CLI Budget Check Command // Description: CLI commands for unknowns registry operations and budget checking // ----------------------------------------------------------------------------- using System.CommandLine; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Cli.Extensions; using StellaOps.Policy.Unknowns.Models; namespace StellaOps.Cli.Commands; /// /// Command group for unknowns registry operations. /// Implements `stella unknowns` commands. /// public static class UnknownsCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Build the unknowns command tree. /// public static Command BuildUnknownsCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var unknownsCommand = new Command("unknowns", "Unknowns registry operations for unmatched vulnerabilities"); unknownsCommand.Add(BuildListCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildEscalateCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildResolveCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildBudgetCommand(services, verboseOption, cancellationToken)); // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001, CLI-UNK-002, CLI-UNK-003) unknownsCommand.Add(BuildSummaryCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildShowCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildProofCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildExportCommand(services, verboseOption, cancellationToken)); unknownsCommand.Add(BuildTriageCommand(services, verboseOption, cancellationToken)); return unknownsCommand; } /// /// Build the budget subcommand tree (stella unknowns budget). /// Sprint: SPRINT_5100_0004_0001 Task T1 /// private static Command BuildBudgetCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var budgetCommand = new Command("budget", "Unknowns budget operations for CI gates"); budgetCommand.Add(BuildBudgetCheckCommand(services, verboseOption, cancellationToken)); budgetCommand.Add(BuildBudgetStatusCommand(services, verboseOption, cancellationToken)); return budgetCommand; } private static Command BuildBudgetCheckCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var scanIdOption = new Option("--scan-id", new[] { "-s" }) { Description = "Scan ID to check budget against" }; var verdictPathOption = new Option("--verdict", new[] { "-v" }) { Description = "Path to verdict JSON file" }; var environmentOption = new Option("--environment", new[] { "-e" }) { Description = "Environment budget to use (prod, stage, dev)" }; environmentOption.SetDefaultValue("prod"); var configOption = new Option("--config", new[] { "-c" }) { Description = "Path to budget configuration file" }; var failOnExceedOption = new Option("--fail-on-exceed") { Description = "Exit with error code if budget exceeded" }; failOnExceedOption.SetDefaultValue(true); var outputOption = new Option("--output", new[] { "-o" }) { Description = "Output format: text, json, sarif" }; outputOption.SetDefaultValue("text"); var checkCommand = new Command("check", "Check scan results against unknowns budget"); checkCommand.Add(scanIdOption); checkCommand.Add(verdictPathOption); checkCommand.Add(environmentOption); checkCommand.Add(configOption); checkCommand.Add(failOnExceedOption); checkCommand.Add(outputOption); checkCommand.Add(verboseOption); checkCommand.SetAction(async (parseResult, ct) => { var scanId = parseResult.GetValue(scanIdOption); var verdictPath = parseResult.GetValue(verdictPathOption); var environment = parseResult.GetValue(environmentOption) ?? "prod"; var config = parseResult.GetValue(configOption); var failOnExceed = parseResult.GetValue(failOnExceedOption); var output = parseResult.GetValue(outputOption) ?? "text"; var verbose = parseResult.GetValue(verboseOption); return await HandleBudgetCheckAsync( services, scanId, verdictPath, environment, config, failOnExceed, output, verbose, cancellationToken); }); return checkCommand; } private static Command BuildBudgetStatusCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var environmentOption = new Option("--environment", new[] { "-e" }) { Description = "Environment to show budget status for" }; environmentOption.SetDefaultValue("prod"); var outputOption = new Option("--output", new[] { "-o" }) { Description = "Output format: text, json" }; outputOption.SetDefaultValue("text"); var statusCommand = new Command("status", "Show current budget status for an environment"); statusCommand.Add(environmentOption); statusCommand.Add(outputOption); statusCommand.Add(verboseOption); statusCommand.SetAction(async (parseResult, ct) => { var environment = parseResult.GetValue(environmentOption) ?? "prod"; var output = parseResult.GetValue(outputOption) ?? "text"; var verbose = parseResult.GetValue(verboseOption); return await HandleBudgetStatusAsync( services, environment, output, verbose, cancellationToken); }); return statusCommand; } private static Command BuildListCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var bandOption = new Option("--band", new[] { "-b" }) { Description = "Filter by band: HOT, WARM, COLD" }; var limitOption = new Option("--limit", new[] { "-l" }) { Description = "Maximum number of results to return" }; var offsetOption = new Option("--offset") { Description = "Number of results to skip" }; var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: table, json" }; var sortOption = new Option("--sort", new[] { "-s" }) { Description = "Sort by: age, band, cve, package" }; var listCommand = new Command("list", "List unknowns from the registry"); listCommand.Add(bandOption); listCommand.Add(limitOption); listCommand.Add(offsetOption); listCommand.Add(formatOption); listCommand.Add(sortOption); listCommand.Add(verboseOption); listCommand.SetAction(async (parseResult, ct) => { var band = parseResult.GetValue(bandOption); var limit = parseResult.GetValue(limitOption); var offset = parseResult.GetValue(offsetOption); var format = parseResult.GetValue(formatOption) ?? "table"; var sort = parseResult.GetValue(sortOption) ?? "age"; var verbose = parseResult.GetValue(verboseOption); if (limit <= 0) limit = 50; return await HandleListAsync( services, band, limit, offset, format, sort, verbose, cancellationToken); }); return listCommand; } private static Command BuildEscalateCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var idOption = new Option("--id", new[] { "-i" }) { Description = "Unknown ID to escalate", Required = true }; var reasonOption = new Option("--reason", new[] { "-r" }) { Description = "Reason for escalation" }; var escalateCommand = new Command("escalate", "Escalate an unknown for immediate attention"); escalateCommand.Add(idOption); escalateCommand.Add(reasonOption); escalateCommand.Add(verboseOption); escalateCommand.SetAction(async (parseResult, ct) => { var id = parseResult.GetValue(idOption) ?? string.Empty; var reason = parseResult.GetValue(reasonOption); var verbose = parseResult.GetValue(verboseOption); return await HandleEscalateAsync( services, id, reason, verbose, cancellationToken); }); return escalateCommand; } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001) private static Command BuildSummaryCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: table, json" }; formatOption.SetDefaultValue("table"); var summaryCommand = new Command("summary", "Show unknowns summary by band with counts and fingerprints"); summaryCommand.Add(formatOption); summaryCommand.Add(verboseOption); summaryCommand.SetAction(async (parseResult, ct) => { var format = parseResult.GetValue(formatOption) ?? "table"; var verbose = parseResult.GetValue(verboseOption); return await HandleSummaryAsync(services, format, verbose, cancellationToken); }); return summaryCommand; } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001) private static Command BuildShowCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var idOption = new Option("--id", new[] { "-i" }) { Description = "Unknown ID to show details for", Required = true }; var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: table, json" }; formatOption.SetDefaultValue("table"); var showCommand = new Command("show", "Show detailed unknown info including fingerprint, triggers, and next actions"); showCommand.Add(idOption); showCommand.Add(formatOption); showCommand.Add(verboseOption); showCommand.SetAction(async (parseResult, ct) => { var id = parseResult.GetValue(idOption) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "table"; var verbose = parseResult.GetValue(verboseOption); return await HandleShowAsync(services, id, format, verbose, cancellationToken); }); return showCommand; } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-002) private static Command BuildProofCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var idOption = new Option("--id", new[] { "-i" }) { Description = "Unknown ID to get proof for", Required = true }; var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: json, envelope" }; formatOption.SetDefaultValue("json"); var proofCommand = new Command("proof", "Get evidence proof for an unknown (fingerprint, triggers, evidence refs)"); proofCommand.Add(idOption); proofCommand.Add(formatOption); proofCommand.Add(verboseOption); proofCommand.SetAction(async (parseResult, ct) => { var id = parseResult.GetValue(idOption) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "json"; var verbose = parseResult.GetValue(verboseOption); return await HandleProofAsync(services, id, format, verbose, cancellationToken); }); return proofCommand; } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-002) private static Command BuildExportCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var bandOption = new Option("--band", new[] { "-b" }) { Description = "Filter by band: HOT, WARM, COLD, all" }; var formatOption = new Option("--format", new[] { "-f" }) { Description = "Output format: json, csv, ndjson" }; formatOption.SetDefaultValue("json"); var outputOption = new Option("--output", new[] { "-o" }) { Description = "Output file path (default: stdout)" }; var exportCommand = new Command("export", "Export unknowns with fingerprints and triggers for offline analysis"); exportCommand.Add(bandOption); exportCommand.Add(formatOption); exportCommand.Add(outputOption); exportCommand.Add(verboseOption); exportCommand.SetAction(async (parseResult, ct) => { var band = parseResult.GetValue(bandOption); var format = parseResult.GetValue(formatOption) ?? "json"; var output = parseResult.GetValue(outputOption); var verbose = parseResult.GetValue(verboseOption); return await HandleExportAsync(services, band, format, output, verbose, cancellationToken); }); return exportCommand; } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-003) private static Command BuildTriageCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var idOption = new Option("--id", new[] { "-i" }) { Description = "Unknown ID to triage", Required = true }; var actionOption = new Option("--action", new[] { "-a" }) { Description = "Triage action: accept-risk, require-fix, defer, escalate, dispute", Required = true }; var reasonOption = new Option("--reason", new[] { "-r" }) { Description = "Reason for triage decision", Required = true }; var durationOption = new Option("--duration-days", new[] { "-d" }) { Description = "Duration in days for defer/accept-risk actions" }; var triageCommand = new Command("triage", "Apply manual triage decision to an unknown (grey queue adjudication)"); triageCommand.Add(idOption); triageCommand.Add(actionOption); triageCommand.Add(reasonOption); triageCommand.Add(durationOption); triageCommand.Add(verboseOption); triageCommand.SetAction(async (parseResult, ct) => { var id = parseResult.GetValue(idOption) ?? string.Empty; var action = parseResult.GetValue(actionOption) ?? string.Empty; var reason = parseResult.GetValue(reasonOption) ?? string.Empty; var duration = parseResult.GetValue(durationOption); var verbose = parseResult.GetValue(verboseOption); return await HandleTriageAsync(services, id, action, reason, duration, verbose, cancellationToken); }); return triageCommand; } private static Command BuildResolveCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var idOption = new Option("--id", new[] { "-i" }) { Description = "Unknown ID to resolve", Required = true }; var resolutionOption = new Option("--resolution", new[] { "-r" }) { Description = "Resolution type: matched, not_applicable, deferred", Required = true }; var noteOption = new Option("--note", new[] { "-n" }) { Description = "Resolution note" }; var resolveCommand = new Command("resolve", "Resolve an unknown"); resolveCommand.Add(idOption); resolveCommand.Add(resolutionOption); resolveCommand.Add(noteOption); resolveCommand.Add(verboseOption); resolveCommand.SetAction(async (parseResult, ct) => { var id = parseResult.GetValue(idOption) ?? string.Empty; var resolution = parseResult.GetValue(resolutionOption) ?? string.Empty; var note = parseResult.GetValue(noteOption); var verbose = parseResult.GetValue(verboseOption); return await HandleResolveAsync( services, id, resolution, note, verbose, cancellationToken); }); return resolveCommand; } private static async Task HandleListAsync( IServiceProvider services, string? band, int limit, int offset, string format, string sort, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Listing unknowns: band={Band}, limit={Limit}, offset={Offset}", band ?? "all", limit, offset); } var client = httpClientFactory.CreateClient("PolicyApi"); var query = $"/api/v1/policy/unknowns?limit={limit}&offset={offset}&sort={sort}"; if (!string.IsNullOrEmpty(band)) { query += $"&band={band.ToUpperInvariant()}"; } var response = await client.GetAsync(query, ct); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(ct); logger?.LogError("List unknowns failed: {Status}", response.StatusCode); if (format == "json") { Console.WriteLine(JsonSerializer.Serialize(new { success = false, error = error, statusCode = (int)response.StatusCode }, JsonOptions)); } else { Console.WriteLine($"Error: List unknowns failed ({response.StatusCode})"); } return 1; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, ct); if (result is null) { logger?.LogError("Empty response from list unknowns"); return 1; } if (format == "json") { Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); } else { PrintUnknownsTable(result); } return 0; } catch (Exception ex) { logger?.LogError(ex, "List unknowns failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } private static void PrintUnknownsTable(LegacyUnknownsListResponse result) { Console.WriteLine($"Unknowns Registry ({result.TotalCount} total, showing {result.Items.Count})"); Console.WriteLine(new string('=', 80)); if (result.Items.Count == 0) { Console.WriteLine("No unknowns found."); return; } // Header Console.WriteLine($"{"ID",-36} {"CVE",-15} {"BAND",-6} {"PACKAGE",-20} {"AGE"}"); Console.WriteLine(new string('-', 80)); foreach (var item in result.Items) { var age = FormatAge(item.CreatedAt); var packageDisplay = item.Package?.Length > 20 ? item.Package[..17] + "..." : item.Package ?? "-"; Console.WriteLine($"{item.Id,-36} {item.CveId,-15} {item.Band,-6} {packageDisplay,-20} {age}"); } Console.WriteLine(new string('-', 80)); // Summary by band var byBand = result.Items.GroupBy(x => x.Band).OrderBy(g => g.Key); Console.WriteLine($"Summary: {string.Join(", ", byBand.Select(g => $"{g.Key}: {g.Count()}"))}"); } private static string FormatAge(DateTimeOffset createdAt) { var age = DateTimeOffset.UtcNow - createdAt; if (age.TotalDays >= 30) return $"{(int)(age.TotalDays / 30)}mo"; if (age.TotalDays >= 1) return $"{(int)age.TotalDays}d"; if (age.TotalHours >= 1) return $"{(int)age.TotalHours}h"; return $"{(int)age.TotalMinutes}m"; } private static async Task HandleEscalateAsync( IServiceProvider services, string id, string? reason, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Escalating unknown {Id}", id); } var client = httpClientFactory.CreateClient("PolicyApi"); var request = new EscalateRequest(reason); var response = await client.PostAsJsonAsync( $"/api/v1/policy/unknowns/{id}/escalate", request, JsonOptions, ct); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(ct); logger?.LogError("Escalate failed: {Status}", response.StatusCode); Console.WriteLine($"Error: Escalation failed ({response.StatusCode})"); return 1; } Console.WriteLine($"Unknown {id} escalated to HOT band successfully."); return 0; } catch (Exception ex) { logger?.LogError(ex, "Escalate failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } private static async Task HandleResolveAsync( IServiceProvider services, string id, string resolution, string? note, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Resolving unknown {Id} as {Resolution}", id, resolution); } var client = httpClientFactory.CreateClient("PolicyApi"); var request = new ResolveRequest(resolution, note); var response = await client.PostAsJsonAsync( $"/api/v1/policy/unknowns/{id}/resolve", request, JsonOptions, ct); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(ct); logger?.LogError("Resolve failed: {Status}", response.StatusCode); Console.WriteLine($"Error: Resolution failed ({response.StatusCode})"); return 1; } Console.WriteLine($"Unknown {id} resolved as {resolution}."); return 0; } catch (Exception ex) { logger?.LogError(ex, "Resolve failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001) private static async Task HandleSummaryAsync( IServiceProvider services, string format, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Fetching unknowns summary"); } var client = httpClientFactory.CreateClient("PolicyApi"); var response = await client.GetAsync("/api/v1/policy/unknowns/summary", ct); if (!response.IsSuccessStatusCode) { Console.WriteLine($"Error: Failed to fetch summary ({response.StatusCode})"); return 1; } var summary = await response.Content.ReadFromJsonAsync(JsonOptions, ct); if (summary is null) { Console.WriteLine("Error: Empty response from server"); return 1; } if (format == "json") { Console.WriteLine(JsonSerializer.Serialize(summary, JsonOptions)); } else { Console.WriteLine("Unknowns Summary"); Console.WriteLine("================"); Console.WriteLine($" HOT: {summary.Hot,6}"); Console.WriteLine($" WARM: {summary.Warm,6}"); Console.WriteLine($" COLD: {summary.Cold,6}"); Console.WriteLine($" Resolved: {summary.Resolved,6}"); Console.WriteLine($" ----------------"); Console.WriteLine($" Total: {summary.Total,6}"); } return 0; } catch (Exception ex) { logger?.LogError(ex, "Summary failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001) private static async Task HandleShowAsync( IServiceProvider services, string id, string format, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Fetching unknown {Id}", id); } var client = httpClientFactory.CreateClient("PolicyApi"); var response = await client.GetAsync($"/api/v1/policy/unknowns/{id}", ct); if (!response.IsSuccessStatusCode) { Console.WriteLine($"Error: Unknown not found ({response.StatusCode})"); return 1; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, ct); if (result?.Unknown is null) { Console.WriteLine("Error: Empty response from server"); return 1; } var unknown = result.Unknown; if (format == "json") { Console.WriteLine(JsonSerializer.Serialize(unknown, JsonOptions)); } else { Console.WriteLine($"Unknown: {unknown.Id}"); Console.WriteLine(new string('=', 60)); Console.WriteLine($" Package: {unknown.PackageId}@{unknown.PackageVersion}"); Console.WriteLine($" Band: {unknown.Band}"); Console.WriteLine($" Score: {unknown.Score:F2}"); Console.WriteLine($" Reason: {unknown.ReasonCode} ({unknown.ReasonCodeShort})"); Console.WriteLine($" First Seen: {unknown.FirstSeenAt:u}"); Console.WriteLine($" Last Evaluated: {unknown.LastEvaluatedAt:u}"); if (!string.IsNullOrEmpty(unknown.FingerprintId)) { Console.WriteLine(); Console.WriteLine("Fingerprint"); Console.WriteLine($" ID: {unknown.FingerprintId}"); } if (unknown.Triggers?.Count > 0) { Console.WriteLine(); Console.WriteLine("Triggers"); foreach (var trigger in unknown.Triggers) { Console.WriteLine($" - {trigger.EventType}@{trigger.EventVersion} ({trigger.ReceivedAt:u})"); } } if (unknown.NextActions?.Count > 0) { Console.WriteLine(); Console.WriteLine("Next Actions"); foreach (var action in unknown.NextActions) { Console.WriteLine($" - {action}"); } } if (unknown.ConflictInfo?.HasConflict == true) { Console.WriteLine(); Console.WriteLine("Conflicts"); Console.WriteLine($" Severity: {unknown.ConflictInfo.Severity:F2}"); Console.WriteLine($" Suggested Path: {unknown.ConflictInfo.SuggestedPath}"); foreach (var conflict in unknown.ConflictInfo.Conflicts) { Console.WriteLine($" - {conflict.Type}: {conflict.Signal1} vs {conflict.Signal2}"); } } if (!string.IsNullOrEmpty(unknown.RemediationHint)) { Console.WriteLine(); Console.WriteLine($"Hint: {unknown.RemediationHint}"); } } return 0; } catch (Exception ex) { logger?.LogError(ex, "Show failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-002) private static async Task HandleProofAsync( IServiceProvider services, string id, string format, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Fetching proof for unknown {Id}", id); } var client = httpClientFactory.CreateClient("PolicyApi"); var response = await client.GetAsync($"/api/v1/policy/unknowns/{id}", ct); if (!response.IsSuccessStatusCode) { Console.WriteLine($"Error: Unknown not found ({response.StatusCode})"); return 1; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, ct); if (result?.Unknown is null) { Console.WriteLine("Error: Empty response from server"); return 1; } var unknown = result.Unknown; // Build proof object with deterministic ordering var proof = new UnknownProof { Id = unknown.Id, FingerprintId = unknown.FingerprintId, PackageId = unknown.PackageId, PackageVersion = unknown.PackageVersion, Band = unknown.Band, Score = unknown.Score, ReasonCode = unknown.ReasonCode, Triggers = unknown.Triggers?.OrderBy(t => t.ReceivedAt).ToList() ?? [], EvidenceRefs = unknown.EvidenceRefs?.OrderBy(e => e.Type).ThenBy(e => e.Uri).ToList() ?? [], ObservationState = unknown.ObservationState, ConflictInfo = unknown.ConflictInfo }; Console.WriteLine(JsonSerializer.Serialize(proof, JsonOptions)); return 0; } catch (Exception ex) { logger?.LogError(ex, "Proof failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-002) private static async Task HandleExportAsync( IServiceProvider services, string? band, string format, string? outputPath, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Exporting unknowns: band={Band}, format={Format}", band ?? "all", format); } var client = httpClientFactory.CreateClient("PolicyApi"); var url = string.IsNullOrEmpty(band) || band == "all" ? "/api/v1/policy/unknowns?limit=10000" : $"/api/v1/policy/unknowns?band={band}&limit=10000"; var response = await client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { Console.WriteLine($"Error: Failed to fetch unknowns ({response.StatusCode})"); return 1; } var result = await response.Content.ReadFromJsonAsync(JsonOptions, ct); if (result?.Items is null) { Console.WriteLine("Error: Empty response from server"); return 1; } // Deterministic ordering by band priority, then score descending var sorted = result.Items .OrderBy(u => u.Band switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 }) .ThenByDescending(u => u.Score) .ToList(); TextWriter writer = outputPath is not null ? new StreamWriter(outputPath) : Console.Out; try { switch (format.ToLowerInvariant()) { case "csv": await WriteCsvAsync(writer, sorted); break; case "ndjson": foreach (var item in sorted) { await writer.WriteLineAsync(JsonSerializer.Serialize(item, JsonOptions)); } break; case "json": default: await writer.WriteLineAsync(JsonSerializer.Serialize(sorted, JsonOptions)); break; } } finally { if (outputPath is not null) { await writer.DisposeAsync(); } } if (verbose && outputPath is not null) { Console.WriteLine($"Exported {sorted.Count} unknowns to {outputPath}"); } return 0; } catch (Exception ex) { logger?.LogError(ex, "Export failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } private static async Task WriteCsvAsync(TextWriter writer, IReadOnlyList items) { // CSV header await writer.WriteLineAsync("id,package_id,package_version,band,score,reason_code,fingerprint_id,first_seen_at,last_evaluated_at"); foreach (var item in items) { await writer.WriteLineAsync(string.Format( System.Globalization.CultureInfo.InvariantCulture, "{0},{1},{2},{3},{4:F2},{5},{6},{7:u},{8:u}", item.Id, EscapeCsv(item.PackageId), EscapeCsv(item.PackageVersion), item.Band, item.Score, item.ReasonCode, item.FingerprintId ?? "", item.FirstSeenAt, item.LastEvaluatedAt)); } } private static string EscapeCsv(string value) { if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) { return $"\"{value.Replace("\"", "\"\"")}\""; } return value; } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-003) private static async Task HandleTriageAsync( IServiceProvider services, string id, string action, string reason, int? durationDays, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } // Validate action var validActions = new[] { "accept-risk", "require-fix", "defer", "escalate", "dispute" }; if (!validActions.Contains(action.ToLowerInvariant())) { Console.WriteLine($"Error: Invalid action '{action}'. Valid actions: {string.Join(", ", validActions)}"); return 1; } try { if (verbose) { logger?.LogDebug("Triaging unknown {Id} with action {Action}", id, action); } var client = httpClientFactory.CreateClient("PolicyApi"); var request = new TriageRequest(action, reason, durationDays); var response = await client.PostAsJsonAsync( $"/api/v1/policy/unknowns/{id}/triage", request, JsonOptions, ct); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(ct); logger?.LogError("Triage failed: {Status}", response.StatusCode); Console.WriteLine($"Error: Triage failed ({response.StatusCode})"); return 1; } Console.WriteLine($"Unknown {id} triaged with action '{action}'."); if (durationDays.HasValue) { Console.WriteLine($"Duration: {durationDays} days"); } return 0; } catch (Exception ex) { logger?.LogError(ex, "Triage failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } /// /// Handle budget check command. /// Sprint: SPRINT_5100_0004_0001 Task T1 /// Exit codes: 0=pass, 1=error, 2=budget exceeded /// private static async Task HandleBudgetCheckAsync( IServiceProvider services, string? scanId, string? verdictPath, string environment, string? configPath, bool failOnExceed, string output, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Checking budget for environment {Environment}", environment); } // Load unknowns from verdict file or API IReadOnlyList unknowns; if (!string.IsNullOrEmpty(verdictPath)) { // Load from local verdict file if (!File.Exists(verdictPath)) { Console.WriteLine($"Error: Verdict file not found: {verdictPath}"); return 1; } var json = await File.ReadAllTextAsync(verdictPath, ct); var verdict = JsonSerializer.Deserialize(json, JsonOptions); if (verdict?.Unknowns is null) { Console.WriteLine("Error: No unknowns found in verdict file"); return 1; } unknowns = verdict.Unknowns; } else if (!string.IsNullOrEmpty(scanId)) { // Fetch from API var client = httpClientFactory.CreateClient("PolicyApi"); var response = await client.GetAsync($"/api/v1/policy/unknowns?scanId={scanId}&limit=1000", ct); if (!response.IsSuccessStatusCode) { logger?.LogError("Failed to fetch unknowns: {Status}", response.StatusCode); Console.WriteLine($"Error: Failed to fetch unknowns ({response.StatusCode})"); return 1; } var listResponse = await response.Content.ReadFromJsonAsync(JsonOptions, ct); unknowns = listResponse?.Items?.Select(i => new BudgetUnknownDto { Id = i.Id.ToString("D"), ReasonCode = "Reachability" // Default if not provided }).ToList() ?? []; } else { Console.WriteLine("Error: Either --scan-id or --verdict must be specified"); return 1; } // Check budget via API var budgetClient = httpClientFactory.CreateClient("PolicyApi"); var checkRequest = new BudgetCheckRequest(environment, unknowns); var checkResponse = await budgetClient.PostAsJsonAsync( "/api/v1/policy/unknowns/budget/check", checkRequest, JsonOptions, ct); BudgetCheckResultDto result; if (checkResponse.IsSuccessStatusCode) { result = await checkResponse.Content.ReadFromJsonAsync(JsonOptions, ct) ?? new BudgetCheckResultDto { IsWithinBudget = true, Environment = environment, TotalUnknowns = unknowns.Count }; } else { // Fallback to local check if API unavailable result = PerformLocalBudgetCheck(environment, unknowns.Count); } // Output result OutputBudgetResult(result, output); // Return exit code if (failOnExceed && !result.IsWithinBudget) { Console.Error.WriteLine($"Budget exceeded: {result.Message ?? "Unknown budget exceeded"}"); return 2; // Distinct exit code for budget failure } return 0; } catch (Exception ex) { logger?.LogError(ex, "Budget check failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } private static BudgetCheckResultDto PerformLocalBudgetCheck(string environment, int unknownCount) { // Default budgets if API unavailable var limits = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["prod"] = 0, ["stage"] = 5, ["dev"] = 20 }; var limit = limits.TryGetValue(environment, out var l) ? l : 10; var exceeded = unknownCount > limit; return new BudgetCheckResultDto { IsWithinBudget = !exceeded, Environment = environment, TotalUnknowns = unknownCount, TotalLimit = limit, Message = exceeded ? $"Budget exceeded: {unknownCount} unknowns exceed limit of {limit}" : null }; } private static void OutputBudgetResult(BudgetCheckResultDto result, string format) { switch (format.ToLowerInvariant()) { case "json": Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); break; case "sarif": OutputSarifResult(result); break; default: OutputTextResult(result); break; } } private static void OutputTextResult(BudgetCheckResultDto result) { var status = result.IsWithinBudget ? "[PASS]" : "[FAIL]"; Console.WriteLine($"{status} Unknowns Budget Check"); Console.WriteLine($" Environment: {result.Environment}"); Console.WriteLine($" Total Unknowns: {result.TotalUnknowns}"); if (result.TotalLimit.HasValue) Console.WriteLine($" Budget Limit: {result.TotalLimit}"); if (result.Violations?.Count > 0) { Console.WriteLine("\n Violations:"); foreach (var violation in result.Violations) { Console.WriteLine($" - {violation.ReasonCode}: {violation.Count}/{violation.Limit}"); } } if (!string.IsNullOrEmpty(result.Message)) Console.WriteLine($"\n Message: {result.Message}"); } private static void OutputSarifResult(BudgetCheckResultDto result) { var violations = result.Violations ?? []; var sarif = new { version = "2.1.0", schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", runs = new[] { new { tool = new { driver = new { name = "StellaOps Budget Check", version = "1.0.0", informationUri = "https://stellaops.io" } }, results = violations.Select(v => new { ruleId = $"UNKNOWN_{v.ReasonCode}", level = "error", message = new { text = $"{v.ReasonCode}: {v.Count} unknowns exceed limit of {v.Limit}" } }).ToArray() } } }; Console.WriteLine(JsonSerializer.Serialize(sarif, JsonOptions)); } private static async Task HandleBudgetStatusAsync( IServiceProvider services, string environment, string output, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup)); var httpClientFactory = services.GetService(); if (httpClientFactory is null) { logger?.LogError("HTTP client factory not available"); return 1; } try { if (verbose) { logger?.LogDebug("Getting budget status for environment {Environment}", environment); } var client = httpClientFactory.CreateClient("PolicyApi"); var response = await client.GetAsync($"/api/v1/policy/unknowns/budget/status?environment={environment}", ct); if (!response.IsSuccessStatusCode) { logger?.LogError("Failed to get budget status: {Status}", response.StatusCode); Console.WriteLine($"Error: Failed to get budget status ({response.StatusCode})"); return 1; } var status = await response.Content.ReadFromJsonAsync(JsonOptions, ct); if (status is null) { Console.WriteLine("Error: Empty response from budget status"); return 1; } if (output == "json") { Console.WriteLine(JsonSerializer.Serialize(status, JsonOptions)); } else { Console.WriteLine($"Budget Status: {status.Environment}"); Console.WriteLine(new string('=', 40)); Console.WriteLine($" Total Unknowns: {status.TotalUnknowns}"); Console.WriteLine($" Budget Limit: {status.TotalLimit?.ToString() ?? "Unlimited"}"); Console.WriteLine($" Usage: {status.PercentageUsed:F1}%"); Console.WriteLine($" Status: {(status.IsExceeded ? "EXCEEDED" : "OK")}"); if (status.ByReasonCode?.Count > 0) { Console.WriteLine("\n By Reason Code:"); foreach (var kvp in status.ByReasonCode) { Console.WriteLine($" - {kvp.Key}: {kvp.Value}"); } } } return 0; } catch (Exception ex) { logger?.LogError(ex, "Budget status failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } #region DTOs private sealed record LegacyUnknownsListResponse( IReadOnlyList Items, int TotalCount, int Offset, int Limit); private sealed record UnknownItem( string Id, string CveId, string? Package, string Band, double? Score, DateTimeOffset CreatedAt, DateTimeOffset? EscalatedAt); private sealed record EscalateRequest(string? Reason); private sealed record ResolveRequest(string Resolution, string? Note); // Budget DTOs - Sprint: SPRINT_5100_0004_0001 Task T1 private sealed record VerdictFileDto { public IReadOnlyList? Unknowns { get; init; } } private sealed record BudgetUnknownDto { public string Id { get; init; } = string.Empty; public string ReasonCode { get; init; } = "Reachability"; } private sealed record BudgetCheckRequest( string Environment, IReadOnlyList Unknowns); private sealed record BudgetCheckResultDto { public bool IsWithinBudget { get; init; } public string Environment { get; init; } = string.Empty; public int TotalUnknowns { get; init; } public int? TotalLimit { get; init; } public IReadOnlyList? Violations { get; init; } public string? Message { get; init; } } private sealed record BudgetViolationDto { public string ReasonCode { get; init; } = string.Empty; public int Count { get; init; } public int Limit { get; init; } } private sealed record BudgetStatusDto { public string Environment { get; init; } = string.Empty; public int TotalUnknowns { get; init; } public int? TotalLimit { get; init; } public decimal PercentageUsed { get; init; } public bool IsExceeded { get; init; } public IReadOnlyDictionary? ByReasonCode { get; init; } } // Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-001, CLI-UNK-002, CLI-UNK-003) private sealed record UnknownsSummaryResponse { public int Hot { get; init; } public int Warm { get; init; } public int Cold { get; init; } public int Resolved { get; init; } public int Total { get; init; } } private sealed record UnknownDetailResponse { public UnknownDto? Unknown { get; init; } } private sealed record UnknownsListResponse { public IReadOnlyList? Items { get; init; } public int TotalCount { get; init; } } private sealed record UnknownDto { public Guid Id { get; init; } public string PackageId { get; init; } = string.Empty; public string PackageVersion { get; init; } = string.Empty; public string Band { get; init; } = string.Empty; public decimal Score { get; init; } public decimal UncertaintyFactor { get; init; } public decimal ExploitPressure { get; init; } public DateTimeOffset FirstSeenAt { get; init; } public DateTimeOffset LastEvaluatedAt { get; init; } public string? ResolutionReason { get; init; } public DateTimeOffset? ResolvedAt { get; init; } public string ReasonCode { get; init; } = string.Empty; public string ReasonCodeShort { get; init; } = string.Empty; public string? RemediationHint { get; init; } public string? DetailedHint { get; init; } public string? AutomationCommand { get; init; } public IReadOnlyList? EvidenceRefs { get; init; } public string? FingerprintId { get; init; } public IReadOnlyList? Triggers { get; init; } public IReadOnlyList? NextActions { get; init; } public ConflictInfoDto? ConflictInfo { get; init; } public string? ObservationState { get; init; } } private sealed record EvidenceRefDto { public string Type { get; init; } = string.Empty; public string Uri { get; init; } = string.Empty; public string? Digest { get; init; } } private sealed record TriggerDto { public string EventType { get; init; } = string.Empty; public int EventVersion { get; init; } public string? Source { get; init; } public DateTimeOffset ReceivedAt { get; init; } public string? CorrelationId { get; init; } } private sealed record ConflictInfoDto { public bool HasConflict { get; init; } public double Severity { get; init; } public string SuggestedPath { get; init; } = string.Empty; public IReadOnlyList Conflicts { get; init; } = []; } private sealed record ConflictDetailDto { public string Signal1 { get; init; } = string.Empty; public string Signal2 { get; init; } = string.Empty; public string Type { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; public double Severity { get; init; } } private sealed record UnknownProof { public Guid Id { get; init; } public string? FingerprintId { get; init; } public string PackageId { get; init; } = string.Empty; public string PackageVersion { get; init; } = string.Empty; public string Band { get; init; } = string.Empty; public decimal Score { get; init; } public string ReasonCode { get; init; } = string.Empty; public IReadOnlyList Triggers { get; init; } = []; public IReadOnlyList EvidenceRefs { get; init; } = []; public string? ObservationState { get; init; } public ConflictInfoDto? ConflictInfo { get; init; } } private sealed record TriageRequest(string Action, string Reason, int? DurationDays); #endregion }