1670 lines
58 KiB
C#
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
|
|
}
|