Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/UnknownsCommandGroup.cs
2026-01-22 19:08:46 +02:00

1670 lines
58 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Command group for unknowns registry operations.
/// Implements `stella unknowns` commands.
/// </summary>
public static class UnknownsCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the unknowns command tree.
/// </summary>
public static Command BuildUnknownsCommand(
IServiceProvider services,
Option<bool> 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;
}
/// <summary>
/// Build the budget subcommand tree (stella unknowns budget).
/// Sprint: SPRINT_5100_0004_0001 Task T1
/// </summary>
private static Command BuildBudgetCommand(
IServiceProvider services,
Option<bool> 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<bool> verboseOption,
CancellationToken cancellationToken)
{
var scanIdOption = new Option<string?>("--scan-id", new[] { "-s" })
{
Description = "Scan ID to check budget against"
};
var verdictPathOption = new Option<string?>("--verdict", new[] { "-v" })
{
Description = "Path to verdict JSON file"
};
var environmentOption = new Option<string>("--environment", new[] { "-e" })
{
Description = "Environment budget to use (prod, stage, dev)"
};
environmentOption.SetDefaultValue("prod");
var configOption = new Option<string?>("--config", new[] { "-c" })
{
Description = "Path to budget configuration file"
};
var failOnExceedOption = new Option<bool>("--fail-on-exceed")
{
Description = "Exit with error code if budget exceeded"
};
failOnExceedOption.SetDefaultValue(true);
var outputOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var environmentOption = new Option<string>("--environment", new[] { "-e" })
{
Description = "Environment to show budget status for"
};
environmentOption.SetDefaultValue("prod");
var outputOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var bandOption = new Option<string?>("--band", new[] { "-b" })
{
Description = "Filter by band: HOT, WARM, COLD"
};
var limitOption = new Option<int>("--limit", new[] { "-l" })
{
Description = "Maximum number of results to return"
};
var offsetOption = new Option<int>("--offset")
{
Description = "Number of results to skip"
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: table, json"
};
var sortOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var idOption = new Option<string>("--id", new[] { "-i" })
{
Description = "Unknown ID to escalate",
Required = true
};
var reasonOption = new Option<string?>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var formatOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var idOption = new Option<string>("--id", new[] { "-i" })
{
Description = "Unknown ID to show details for",
Required = true
};
var formatOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var idOption = new Option<string>("--id", new[] { "-i" })
{
Description = "Unknown ID to get proof for",
Required = true
};
var formatOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var bandOption = new Option<string?>("--band", new[] { "-b" })
{
Description = "Filter by band: HOT, WARM, COLD, all"
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: json, csv, ndjson"
};
formatOption.SetDefaultValue("json");
var outputOption = new Option<string?>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var idOption = new Option<string>("--id", new[] { "-i" })
{
Description = "Unknown ID to triage",
Required = true
};
var actionOption = new Option<string>("--action", new[] { "-a" })
{
Description = "Triage action: accept-risk, require-fix, defer, escalate, dispute",
Required = true
};
var reasonOption = new Option<string>("--reason", new[] { "-r" })
{
Description = "Reason for triage decision",
Required = true
};
var durationOption = new Option<int?>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var idOption = new Option<string>("--id", new[] { "-i" })
{
Description = "Unknown ID to resolve",
Required = true
};
var resolutionOption = new Option<string>("--resolution", new[] { "-r" })
{
Description = "Resolution type: matched, not_applicable, deferred",
Required = true
};
var noteOption = new Option<string?>("--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<int> HandleListAsync(
IServiceProvider services,
string? band,
int limit,
int offset,
string format,
string sort,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<LegacyUnknownsListResponse>(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<int> HandleEscalateAsync(
IServiceProvider services,
string id,
string? reason,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<int> HandleResolveAsync(
IServiceProvider services,
string id,
string resolution,
string? note,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<int> HandleSummaryAsync(
IServiceProvider services,
string format,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<UnknownsSummaryResponse>(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<int> HandleShowAsync(
IServiceProvider services,
string id,
string format,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<UnknownDetailResponse>(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<int> HandleProofAsync(
IServiceProvider services,
string id,
string format,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<UnknownDetailResponse>(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<int> HandleExportAsync(
IServiceProvider services,
string? band,
string format,
string? outputPath,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<UnknownsListResponse>(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<UnknownDto> 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<int> HandleTriageAsync(
IServiceProvider services,
string id,
string action,
string reason,
int? durationDays,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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;
}
}
/// <summary>
/// Handle budget check command.
/// Sprint: SPRINT_5100_0004_0001 Task T1
/// Exit codes: 0=pass, 1=error, 2=budget exceeded
/// </summary>
private static async Task<int> HandleBudgetCheckAsync(
IServiceProvider services,
string? scanId,
string? verdictPath,
string environment,
string? configPath,
bool failOnExceed,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<BudgetUnknownDto> 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<VerdictFileDto>(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<UnknownsListResponse>(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<BudgetCheckResultDto>(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<string, int>(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<int> HandleBudgetStatusAsync(
IServiceProvider services,
string environment,
string output,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger(typeof(UnknownsCommandGroup));
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<BudgetStatusDto>(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<UnknownItem> 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<BudgetUnknownDto>? 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<BudgetUnknownDto> 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<BudgetViolationDto>? 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<string, int>? 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<UnknownDto>? 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<EvidenceRefDto>? EvidenceRefs { get; init; }
public string? FingerprintId { get; init; }
public IReadOnlyList<TriggerDto>? Triggers { get; init; }
public IReadOnlyList<string>? 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<ConflictDetailDto> 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<TriggerDto> Triggers { get; init; } = [];
public IReadOnlyList<EvidenceRefDto> EvidenceRefs { get; init; } = [];
public string? ObservationState { get; init; }
public ConflictInfoDto? ConflictInfo { get; init; }
}
private sealed record TriageRequest(string Action, string Reason, int? DurationDays);
#endregion
}