Add integration tests for Proof Chain and Reachability workflows
- Implement ProofChainTestFixture for PostgreSQL-backed integration tests. - Create StellaOps.Integration.ProofChain project with necessary dependencies. - Add ReachabilityIntegrationTests to validate call graph extraction and reachability analysis. - Introduce ReachabilityTestFixture for managing corpus and fixture paths. - Establish StellaOps.Integration.Reachability project with required references. - Develop UnknownsWorkflowTests to cover the unknowns lifecycle: detection, ranking, escalation, and resolution. - Create StellaOps.Integration.Unknowns project with dependencies for unknowns workflow.
This commit is contained in:
454
src/Cli/StellaOps.Cli/Commands/UnknownsCommandGroup.cs
Normal file
454
src/Cli/StellaOps.Cli/Commands/UnknownsCommandGroup.cs
Normal file
@@ -0,0 +1,454 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsCommandGroup.cs
|
||||
// Sprint: SPRINT_3500_0004_0001_cli_verbs
|
||||
// Task: T3 - Unknowns List Command
|
||||
// Description: CLI commands for unknowns registry operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
||||
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));
|
||||
|
||||
return unknownsCommand;
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bandOption = new Option<string?>("--band", "-b")
|
||||
{
|
||||
Description = "Filter by band: HOT, WARM, COLD"
|
||||
};
|
||||
|
||||
var limitOption = new Option<int>("--limit", "-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", "-f")
|
||||
{
|
||||
Description = "Output format: table, json"
|
||||
};
|
||||
|
||||
var sortOption = new Option<string>("--sort", "-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", "-i")
|
||||
{
|
||||
Description = "Unknown ID to escalate",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var reasonOption = new Option<string?>("--reason", "-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;
|
||||
}
|
||||
|
||||
private static Command BuildResolveCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id", "-i")
|
||||
{
|
||||
Description = "Unknown ID to resolve",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var resolutionOption = new Option<string>("--resolution", "-r")
|
||||
{
|
||||
Description = "Resolution type: matched, not_applicable, deferred",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var noteOption = new Option<string?>("--note", "-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<UnknownsListResponse>(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(UnknownsListResponse 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;
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record UnknownsListResponse(
|
||||
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);
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user