old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Cli.Commands.Admin;
|
||||
using StellaOps.Cli.Commands.Budget;
|
||||
using StellaOps.Cli.Commands.Chain;
|
||||
@@ -3324,6 +3331,7 @@ internal static class CommandFactory
|
||||
advise.Add(explain);
|
||||
advise.Add(remediate);
|
||||
advise.Add(batch);
|
||||
advise.Add(BuildOpenPrCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260113_005_CLI_advise_chat - Chat commands
|
||||
advise.Add(AdviseChatCommandGroup.BuildAskCommand(services, options, verboseOption, cancellationToken));
|
||||
@@ -3333,6 +3341,217 @@ internal static class CommandFactory
|
||||
return advise;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the open-pr command for remediation PR generation.
|
||||
/// Sprint: SPRINT_20260112_011_CLI_evidence_card_remediate_cli (REMPR-CLI-001)
|
||||
/// </summary>
|
||||
private static Command BuildOpenPrCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var planIdArg = new Argument<string>("plan-id")
|
||||
{
|
||||
Description = "Remediation plan ID to apply"
|
||||
};
|
||||
|
||||
var scmTypeOption = new Option<string>("--scm-type", ["-s"])
|
||||
{
|
||||
Description = "SCM type (github, gitlab, azure-devops, gitea)"
|
||||
};
|
||||
scmTypeOption.SetDefaultValue("github");
|
||||
|
||||
var outputOption = new Option<string>("--output", ["-o"])
|
||||
{
|
||||
Description = "Output format: table (default), json, markdown"
|
||||
};
|
||||
outputOption.SetDefaultValue("table");
|
||||
|
||||
var openPr = new Command("open-pr", "Apply a remediation plan by creating a PR/MR in the target SCM")
|
||||
{
|
||||
planIdArg,
|
||||
scmTypeOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
openPr.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var planId = parseResult.GetValue(planIdArg) ?? string.Empty;
|
||||
var scmType = parseResult.GetValue(scmTypeOption) ?? "github";
|
||||
var outputFormat = parseResult.GetValue(outputOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleOpenPrAsync(services, options, planId, scmType, outputFormat, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return openPr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the open-pr command execution.
|
||||
/// </summary>
|
||||
private static async Task<int> HandleOpenPrAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string planId,
|
||||
string scmType,
|
||||
string outputFormat,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(planId))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Plan ID is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient("AdvisoryAI");
|
||||
|
||||
var backendUrl = options.BackendUrl
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_ADVISORY_URL")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
|
||||
?? "http://localhost:5000";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Backend URL: {backendUrl}[/]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PrResultDto? prResult = null;
|
||||
|
||||
await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("yellow"))
|
||||
.StartAsync("Creating pull request...", async ctx =>
|
||||
{
|
||||
var requestUrl = $"{backendUrl}/v1/advisory-ai/remediation/apply";
|
||||
var payload = new { planId, scmType };
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Request: POST {requestUrl}[/]");
|
||||
}
|
||||
|
||||
var response = await client.PostAsJsonAsync(requestUrl, payload, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"API error: {response.StatusCode} - {error}");
|
||||
}
|
||||
|
||||
prResult = await response.Content.ReadFromJsonAsync<PrResultDto>(cancellationToken);
|
||||
});
|
||||
|
||||
if (prResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse response");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Output results based on format
|
||||
if (outputFormat == "json")
|
||||
{
|
||||
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(prResult, jsonOptions));
|
||||
}
|
||||
else if (outputFormat == "markdown")
|
||||
{
|
||||
OutputPrResultMarkdown(prResult);
|
||||
}
|
||||
else
|
||||
{
|
||||
OutputPrResultTable(prResult);
|
||||
}
|
||||
|
||||
return prResult.Status == "Open" || prResult.Status == "Creating" ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OutputPrResultTable(PrResultDto result)
|
||||
{
|
||||
var table = new Table();
|
||||
table.AddColumn("Property");
|
||||
table.AddColumn("Value");
|
||||
table.Border(TableBorder.Rounded);
|
||||
|
||||
table.AddRow("PR ID", result.PrId ?? "(unknown)");
|
||||
table.AddRow("PR Number", result.PrNumber.ToString(CultureInfo.InvariantCulture));
|
||||
table.AddRow("URL", result.Url ?? "(not created)");
|
||||
table.AddRow("Branch", result.BranchName ?? "(unknown)");
|
||||
table.AddRow("Status", result.Status ?? "unknown");
|
||||
if (!string.IsNullOrEmpty(result.StatusMessage))
|
||||
table.AddRow("Message", result.StatusMessage);
|
||||
table.AddRow("Created At", result.CreatedAt ?? "(unknown)");
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
private static void OutputPrResultMarkdown(PrResultDto result)
|
||||
{
|
||||
var status = result.Status == "Open" ? "[green]Open[/]" :
|
||||
result.Status == "Failed" ? "[red]Failed[/]" : result.Status;
|
||||
|
||||
AnsiConsole.MarkupLine($"# PR Result");
|
||||
AnsiConsole.MarkupLine($"");
|
||||
AnsiConsole.MarkupLine($"- **PR ID:** {result.PrId}");
|
||||
AnsiConsole.MarkupLine($"- **PR Number:** {result.PrNumber}");
|
||||
AnsiConsole.MarkupLine($"- **URL:** {result.Url}");
|
||||
AnsiConsole.MarkupLine($"- **Branch:** {result.BranchName}");
|
||||
AnsiConsole.MarkupLine($"- **Status:** {status}");
|
||||
if (!string.IsNullOrEmpty(result.StatusMessage))
|
||||
AnsiConsole.MarkupLine($"- **Message:** {result.StatusMessage}");
|
||||
AnsiConsole.MarkupLine($"- **Created:** {result.CreatedAt}");
|
||||
|
||||
if (!string.IsNullOrEmpty(result.PrBody))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"");
|
||||
AnsiConsole.MarkupLine($"## PR Body");
|
||||
AnsiConsole.MarkupLine($"");
|
||||
AnsiConsole.WriteLine(result.PrBody);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PrResultDto
|
||||
{
|
||||
[JsonPropertyName("prId")]
|
||||
public string? PrId { get; init; }
|
||||
|
||||
[JsonPropertyName("prNumber")]
|
||||
public int PrNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
[JsonPropertyName("branchName")]
|
||||
public string? BranchName { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("statusMessage")]
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("prBody")]
|
||||
public string? PrBody { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public string? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public string? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
private static AdvisoryCommandOptions CreateAdvisoryOptions()
|
||||
{
|
||||
var advisoryKey = new Option<string>("--advisory-key")
|
||||
|
||||
303
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Config.cs
Normal file
303
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Config.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
// <copyright file="CommandHandlers.Config.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010, CLI-CONFIG-011, CLI-CONFIG-012, CLI-CONFIG-013)
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cli.Services;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
public static partial class CommandHandlers
|
||||
{
|
||||
public static class Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists all available configuration paths.
|
||||
/// </summary>
|
||||
public static Task<int> ListAsync(string? category)
|
||||
{
|
||||
var catalog = ConfigCatalog.GetAll();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
catalog = catalog
|
||||
.Where(c => c.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Deterministic ordering: category, then path
|
||||
var sorted = catalog
|
||||
.OrderBy(c => c.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(c => c.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (sorted.Count == 0)
|
||||
{
|
||||
Console.WriteLine(category is null
|
||||
? "No configuration paths found."
|
||||
: $"No configuration paths found for category '{category}'.");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
// Calculate column widths for deterministic table output
|
||||
var pathWidth = Math.Max(sorted.Max(c => c.Path.Length), 4);
|
||||
var categoryWidth = Math.Max(sorted.Max(c => c.Category.Length), 8);
|
||||
var aliasWidth = Math.Max(sorted.Max(c => string.Join(", ", c.Aliases).Length), 7);
|
||||
|
||||
// Header
|
||||
Console.WriteLine(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0,-" + pathWidth + "} {1,-" + categoryWidth + "} {2,-" + aliasWidth + "} {3}",
|
||||
"PATH", "CATEGORY", "ALIASES", "DESCRIPTION"));
|
||||
Console.WriteLine(new string('-', pathWidth + categoryWidth + aliasWidth + 40));
|
||||
|
||||
// Rows
|
||||
foreach (var entry in sorted)
|
||||
{
|
||||
var aliases = entry.Aliases.Count > 0 ? string.Join(", ", entry.Aliases) : "-";
|
||||
Console.WriteLine(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0,-" + pathWidth + "} {1,-" + categoryWidth + "} {2,-" + aliasWidth + "} {3}",
|
||||
entry.Path,
|
||||
entry.Category,
|
||||
aliases,
|
||||
entry.Description));
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Total: {sorted.Count} configuration paths");
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows configuration for a specific path.
|
||||
/// </summary>
|
||||
public static async Task<int> ShowAsync(
|
||||
IBackendOperationsClient client,
|
||||
string path,
|
||||
string format,
|
||||
bool showSecrets)
|
||||
{
|
||||
// Normalize path (. and : interchangeable, case-insensitive)
|
||||
var normalizedPath = NormalizePath(path);
|
||||
|
||||
// Look up in catalog
|
||||
var entry = ConfigCatalog.Find(normalizedPath);
|
||||
if (entry is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown configuration path: {path}");
|
||||
Console.Error.WriteLine("Run 'stella config list' to see available paths.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fetch config (try API first, fall back to local)
|
||||
Dictionary<string, object?> config;
|
||||
string source;
|
||||
try
|
||||
{
|
||||
if (entry.ApiEndpoint is not null)
|
||||
{
|
||||
config = await FetchFromApiAsync(client, entry.ApiEndpoint);
|
||||
source = "api";
|
||||
}
|
||||
else
|
||||
{
|
||||
config = FetchFromLocal(entry.SectionName);
|
||||
source = "local";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to fetch configuration: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Redact secrets unless --show-secrets
|
||||
if (!showSecrets)
|
||||
{
|
||||
config = RedactSecrets(config);
|
||||
}
|
||||
|
||||
// Output with deterministic ordering
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
OutputJson(config, entry);
|
||||
break;
|
||||
case "yaml":
|
||||
OutputYaml(config, entry);
|
||||
break;
|
||||
case "table":
|
||||
default:
|
||||
OutputTable(config, entry, source);
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
// . and : are interchangeable, case-insensitive
|
||||
return path.Replace(':', '.').ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, object?>> FetchFromApiAsync(
|
||||
IBackendOperationsClient client,
|
||||
string endpoint)
|
||||
{
|
||||
// TODO: Implement actual API call when endpoints are available
|
||||
// For now, return placeholder
|
||||
await Task.CompletedTask;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["_source"] = "api",
|
||||
["_endpoint"] = endpoint,
|
||||
["_note"] = "API config fetch not yet implemented"
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> FetchFromLocal(string sectionName)
|
||||
{
|
||||
// TODO: Read from local appsettings.yaml/json
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["_source"] = "local",
|
||||
["_section"] = sectionName,
|
||||
["_note"] = "Local config fetch not yet implemented"
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> RedactSecrets(Dictionary<string, object?> config)
|
||||
{
|
||||
var redacted = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
if (IsSecretKey(key))
|
||||
{
|
||||
redacted[key] = "[REDACTED]";
|
||||
}
|
||||
else if (value is Dictionary<string, object?> nested)
|
||||
{
|
||||
redacted[key] = RedactSecrets(nested);
|
||||
}
|
||||
else
|
||||
{
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
|
||||
private static bool IsSecretKey(string key)
|
||||
{
|
||||
var lowerKey = key.ToLowerInvariant();
|
||||
return lowerKey.Contains("secret") ||
|
||||
lowerKey.Contains("password") ||
|
||||
lowerKey.Contains("apikey") ||
|
||||
lowerKey.Contains("api_key") ||
|
||||
lowerKey.Contains("token") ||
|
||||
lowerKey.Contains("credential") ||
|
||||
lowerKey.Contains("connectionstring") ||
|
||||
lowerKey.Contains("connection_string") ||
|
||||
lowerKey.Contains("privatekey") ||
|
||||
lowerKey.Contains("private_key");
|
||||
}
|
||||
|
||||
private static void OutputTable(
|
||||
Dictionary<string, object?> config,
|
||||
ConfigCatalogEntry entry,
|
||||
string source)
|
||||
{
|
||||
Console.WriteLine($"Configuration: {entry.Path}");
|
||||
Console.WriteLine($"Category: {entry.Category}");
|
||||
Console.WriteLine($"Source: {source}");
|
||||
Console.WriteLine($"Section: {entry.SectionName}");
|
||||
Console.WriteLine();
|
||||
|
||||
// Deterministic key ordering
|
||||
var sortedKeys = config.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var keyWidth = Math.Max(sortedKeys.Max(k => k.Length), 3);
|
||||
|
||||
Console.WriteLine(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0,-" + keyWidth + "} {1}",
|
||||
"KEY", "VALUE"));
|
||||
Console.WriteLine(new string('-', keyWidth + 40));
|
||||
|
||||
foreach (var key in sortedKeys)
|
||||
{
|
||||
var value = config[key];
|
||||
var valueStr = value switch
|
||||
{
|
||||
null => "(null)",
|
||||
string s => s,
|
||||
_ => JsonSerializer.Serialize(value)
|
||||
};
|
||||
Console.WriteLine(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0,-" + keyWidth + "} {1}",
|
||||
key,
|
||||
valueStr));
|
||||
}
|
||||
}
|
||||
|
||||
private static void OutputJson(Dictionary<string, object?> config, ConfigCatalogEntry entry)
|
||||
{
|
||||
var output = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["path"] = entry.Path,
|
||||
["category"] = entry.Category,
|
||||
["section"] = entry.SectionName,
|
||||
["config"] = SortDictionary(config)
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
|
||||
private static void OutputYaml(Dictionary<string, object?> config, ConfigCatalogEntry entry)
|
||||
{
|
||||
// Simple YAML output (no external dependency)
|
||||
Console.WriteLine($"path: {entry.Path}");
|
||||
Console.WriteLine($"category: {entry.Category}");
|
||||
Console.WriteLine($"section: {entry.SectionName}");
|
||||
Console.WriteLine("config:");
|
||||
|
||||
var sortedKeys = config.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var key in sortedKeys)
|
||||
{
|
||||
var value = config[key];
|
||||
var valueStr = value switch
|
||||
{
|
||||
null => "null",
|
||||
string s => s.Contains(' ') ? $"\"{s}\"" : s,
|
||||
bool b => b.ToString().ToLowerInvariant(),
|
||||
_ => JsonSerializer.Serialize(value)
|
||||
};
|
||||
Console.WriteLine($" {key}: {valueStr}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> SortDictionary(Dictionary<string, object?> dict)
|
||||
{
|
||||
var sorted = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var key in dict.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
sorted[key] = dict[key] is Dictionary<string, object?> nested
|
||||
? SortDictionary(nested)
|
||||
: dict[key];
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
// CommandHandlers.Witness.cs
|
||||
// Sprint: SPRINT_3700_0005_0001_witness_ui_cli
|
||||
// Tasks: CLI-001, CLI-002, CLI-003, CLI-004
|
||||
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002)
|
||||
// Description: Command handlers for reachability witness CLI.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -21,6 +25,7 @@ internal static partial class CommandHandlers
|
||||
|
||||
/// <summary>
|
||||
/// Handler for `witness show` command.
|
||||
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002)
|
||||
/// </summary>
|
||||
internal static async Task HandleWitnessShowAsync(
|
||||
IServiceProvider services,
|
||||
@@ -38,52 +43,25 @@ internal static partial class CommandHandlers
|
||||
console.MarkupLine($"[dim]Fetching witness: {witnessId}[/]");
|
||||
}
|
||||
|
||||
// TODO: Replace with actual service call when witness API is available
|
||||
var witness = new WitnessDto
|
||||
using var scope = services.CreateScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
var response = await client.GetWitnessAsync(witnessId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
WitnessId = witnessId,
|
||||
WitnessSchema = "stellaops.witness.v1",
|
||||
CveId = "CVE-2024-12345",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
PackageVersion = "12.0.3",
|
||||
ConfidenceTier = "confirmed",
|
||||
ObservedAt = DateTimeOffset.UtcNow.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
|
||||
Entrypoint = new WitnessEntrypointDto
|
||||
{
|
||||
Type = "http",
|
||||
Route = "GET /api/users/{id}",
|
||||
Symbol = "UserController.GetUser()",
|
||||
File = "src/Controllers/UserController.cs",
|
||||
Line = 42
|
||||
},
|
||||
Sink = new WitnessSinkDto
|
||||
{
|
||||
Symbol = "JsonConvert.DeserializeObject<User>()",
|
||||
Package = "Newtonsoft.Json",
|
||||
IsTrigger = true
|
||||
},
|
||||
Path = new[]
|
||||
{
|
||||
new PathStepDto { Symbol = "UserController.GetUser()", File = "src/Controllers/UserController.cs", Line = 42 },
|
||||
new PathStepDto { Symbol = "UserService.GetUserById()", File = "src/Services/UserService.cs", Line = 88 },
|
||||
new PathStepDto { Symbol = "JsonConvert.DeserializeObject<User>()", Package = "Newtonsoft.Json" }
|
||||
},
|
||||
Gates = new[]
|
||||
{
|
||||
new GateDto { Type = "authRequired", Detail = "[Authorize] attribute", Confidence = 0.95m }
|
||||
},
|
||||
Evidence = new WitnessEvidenceDto
|
||||
{
|
||||
CallgraphDigest = "blake3:a1b2c3d4e5f6...",
|
||||
SurfaceDigest = "sha256:9f8e7d6c5b4a...",
|
||||
SignedBy = "attestor-stellaops-ed25519"
|
||||
}
|
||||
};
|
||||
console.MarkupLine($"[red]Witness not found: {witnessId}[/]");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert API response to internal DTO for display
|
||||
var witness = ConvertToWitnessDto(response);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case "json":
|
||||
var json = JsonSerializer.Serialize(witness, WitnessJsonOptions);
|
||||
var json = JsonSerializer.Serialize(response, WitnessJsonOptions);
|
||||
console.WriteLine(json);
|
||||
break;
|
||||
case "yaml":
|
||||
@@ -93,12 +71,11 @@ internal static partial class CommandHandlers
|
||||
WriteWitnessText(console, witness, pathOnly, noColor);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for `witness verify` command.
|
||||
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-004)
|
||||
/// </summary>
|
||||
internal static async Task HandleWitnessVerifyAsync(
|
||||
IServiceProvider services,
|
||||
@@ -119,30 +96,49 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Replace with actual verification when DSSE verification is wired up
|
||||
await Task.Delay(100, cancellationToken); // Simulate verification
|
||||
|
||||
// Placeholder result
|
||||
var valid = true;
|
||||
var keyId = "attestor-stellaops-ed25519";
|
||||
var algorithm = "Ed25519";
|
||||
|
||||
if (valid)
|
||||
if (offline && publicKeyPath == null)
|
||||
{
|
||||
console.MarkupLine("[green]✓ Signature VALID[/]");
|
||||
console.MarkupLine($" Key ID: {keyId}");
|
||||
console.MarkupLine($" Algorithm: {algorithm}");
|
||||
console.MarkupLine("[yellow]Warning: Offline mode requires --public-key to verify signatures locally.[/]");
|
||||
console.MarkupLine("[dim]Skipping signature verification.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = services.CreateScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
var response = await client.VerifyWitnessAsync(witnessId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Verified)
|
||||
{
|
||||
// ASCII-only output per AGENTS.md rules
|
||||
console.MarkupLine("[green][OK] Signature VALID[/]");
|
||||
if (response.Dsse?.SignerIdentities?.Count > 0)
|
||||
{
|
||||
console.MarkupLine($" Signers: {string.Join(", ", response.Dsse.SignerIdentities)}");
|
||||
}
|
||||
if (response.Dsse?.PredicateType != null)
|
||||
{
|
||||
console.MarkupLine($" Predicate Type: {response.Dsse.PredicateType}");
|
||||
}
|
||||
if (response.ContentHash?.Match == true)
|
||||
{
|
||||
console.MarkupLine(" Content Hash: [green]MATCH[/]");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
console.MarkupLine("[red]✗ Signature INVALID[/]");
|
||||
console.MarkupLine(" Error: Signature verification failed");
|
||||
console.MarkupLine("[red][FAIL] Signature INVALID[/]");
|
||||
if (response.Message != null)
|
||||
{
|
||||
console.MarkupLine($" Error: {response.Message}");
|
||||
}
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for `witness list` command.
|
||||
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-002)
|
||||
/// </summary>
|
||||
internal static async Task HandleWitnessListAsync(
|
||||
IServiceProvider services,
|
||||
@@ -165,45 +161,48 @@ internal static partial class CommandHandlers
|
||||
if (reachableOnly) console.MarkupLine("[dim]Showing reachable witnesses only[/]");
|
||||
}
|
||||
|
||||
// TODO: Replace with actual service call
|
||||
var witnesses = new[]
|
||||
using var scope = services.CreateScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
var request = new WitnessListRequest
|
||||
{
|
||||
new WitnessListItemDto
|
||||
{
|
||||
WitnessId = "wit:sha256:abc123",
|
||||
CveId = "CVE-2024-12345",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
ConfidenceTier = "confirmed",
|
||||
Entrypoint = "GET /api/users/{id}",
|
||||
Sink = "JsonConvert.DeserializeObject()"
|
||||
},
|
||||
new WitnessListItemDto
|
||||
{
|
||||
WitnessId = "wit:sha256:def456",
|
||||
CveId = "CVE-2024-12346",
|
||||
PackageName = "lodash",
|
||||
ConfidenceTier = "likely",
|
||||
Entrypoint = "POST /api/data",
|
||||
Sink = "_.template()"
|
||||
}
|
||||
ScanId = scanId,
|
||||
VulnerabilityId = vuln,
|
||||
Limit = limit
|
||||
};
|
||||
|
||||
var response = await client.ListWitnessesAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert to internal DTOs and apply deterministic ordering
|
||||
var witnesses = response.Witnesses
|
||||
.Select(w => new WitnessListItemDto
|
||||
{
|
||||
WitnessId = w.WitnessId,
|
||||
CveId = w.VulnerabilityId ?? "N/A",
|
||||
PackageName = ExtractPackageName(w.ComponentPurl),
|
||||
ConfidenceTier = tier ?? "N/A",
|
||||
Entrypoint = w.Entrypoint ?? "N/A",
|
||||
Sink = w.Sink ?? "N/A"
|
||||
})
|
||||
.OrderBy(w => w.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(w => w.WitnessId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case "json":
|
||||
var json = JsonSerializer.Serialize(new { witnesses, total = witnesses.Length }, WitnessJsonOptions);
|
||||
var json = JsonSerializer.Serialize(new { witnesses, total = response.TotalCount }, WitnessJsonOptions);
|
||||
console.WriteLine(json);
|
||||
break;
|
||||
default:
|
||||
WriteWitnessListTable(console, witnesses);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for `witness export` command.
|
||||
/// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-003)
|
||||
/// </summary>
|
||||
internal static async Task HandleWitnessExportAsync(
|
||||
IServiceProvider services,
|
||||
@@ -222,24 +221,108 @@ internal static partial class CommandHandlers
|
||||
if (outputPath != null) console.MarkupLine($"[dim]Output: {outputPath}[/]");
|
||||
}
|
||||
|
||||
// TODO: Replace with actual witness fetch and export
|
||||
var exportContent = format switch
|
||||
using var scope = services.CreateScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
var exportFormat = format switch
|
||||
{
|
||||
"sarif" => GenerateWitnessSarif(witnessId),
|
||||
_ => GenerateWitnessJson(witnessId, includeDsse)
|
||||
"sarif" => WitnessExportFormat.Sarif,
|
||||
"dsse" => WitnessExportFormat.Dsse,
|
||||
_ => includeDsse ? WitnessExportFormat.Dsse : WitnessExportFormat.Json
|
||||
};
|
||||
|
||||
if (outputPath != null)
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, exportContent, cancellationToken);
|
||||
console.MarkupLine($"[green]Exported to {outputPath}[/]");
|
||||
await using var stream = await client.DownloadWitnessAsync(witnessId, exportFormat, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (outputPath != null)
|
||||
{
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
console.MarkupLine($"[green]Exported to {outputPath}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
console.WriteLine(content);
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
console.WriteLine(exportContent);
|
||||
console.MarkupLine($"[red]Export failed: {ex.Message}[/]");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractPackageName(string? purl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl)) return "N/A";
|
||||
// Extract name from PURL like pkg:nuget/Newtonsoft.Json@12.0.3
|
||||
var parts = purl.Split('/');
|
||||
if (parts.Length < 2) return purl;
|
||||
var nameVersion = parts[^1].Split('@');
|
||||
return nameVersion[0];
|
||||
}
|
||||
|
||||
private static WitnessDto ConvertToWitnessDto(WitnessDetailResponse response)
|
||||
{
|
||||
return new WitnessDto
|
||||
{
|
||||
WitnessId = response.WitnessId,
|
||||
WitnessSchema = response.WitnessSchema ?? "stellaops.witness.v1",
|
||||
CveId = response.Vuln?.Id ?? "N/A",
|
||||
PackageName = ExtractPackageName(response.Artifact?.ComponentPurl),
|
||||
PackageVersion = ExtractPackageVersion(response.Artifact?.ComponentPurl),
|
||||
ConfidenceTier = "confirmed", // TODO: map from response
|
||||
ObservedAt = response.ObservedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
Entrypoint = new WitnessEntrypointDto
|
||||
{
|
||||
Type = response.Entrypoint?.Kind ?? "unknown",
|
||||
Route = response.Entrypoint?.Name ?? "N/A",
|
||||
Symbol = response.Entrypoint?.SymbolId ?? "N/A",
|
||||
File = null,
|
||||
Line = 0
|
||||
},
|
||||
Sink = new WitnessSinkDto
|
||||
{
|
||||
Symbol = response.Sink?.Symbol ?? "N/A",
|
||||
Package = ExtractPackageName(response.Artifact?.ComponentPurl),
|
||||
IsTrigger = true
|
||||
},
|
||||
Path = (response.Path ?? [])
|
||||
.Select(p => new PathStepDto
|
||||
{
|
||||
Symbol = p.Symbol ?? p.SymbolId ?? "N/A",
|
||||
File = p.File,
|
||||
Line = p.Line ?? 0,
|
||||
Package = null
|
||||
})
|
||||
.ToArray(),
|
||||
Gates = (response.Gates ?? [])
|
||||
.Select(g => new GateDto
|
||||
{
|
||||
Type = g.Type ?? "unknown",
|
||||
Detail = g.Detail ?? "",
|
||||
Confidence = (decimal)g.Confidence
|
||||
})
|
||||
.ToArray(),
|
||||
Evidence = new WitnessEvidenceDto
|
||||
{
|
||||
CallgraphDigest = response.Evidence?.CallgraphDigest ?? "N/A",
|
||||
SurfaceDigest = response.Evidence?.SurfaceDigest ?? "N/A",
|
||||
SignedBy = response.DsseEnvelope?.Signatures?.FirstOrDefault()?.KeyId ?? "unsigned"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractPackageVersion(string? purl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl)) return "N/A";
|
||||
var parts = purl.Split('@');
|
||||
return parts.Length > 1 ? parts[^1] : "N/A";
|
||||
}
|
||||
|
||||
private static void WriteWitnessText(IAnsiConsole console, WitnessDto witness, bool pathOnly, bool noColor)
|
||||
{
|
||||
if (!pathOnly)
|
||||
@@ -381,58 +464,6 @@ internal static partial class CommandHandlers
|
||||
console.Write(table);
|
||||
}
|
||||
|
||||
private static string GenerateWitnessJson(string witnessId, bool includeDsse)
|
||||
{
|
||||
var witness = new
|
||||
{
|
||||
witness_schema = "stellaops.witness.v1",
|
||||
witness_id = witnessId,
|
||||
artifact = new { sbom_digest = "sha256:...", component_purl = "pkg:nuget/Newtonsoft.Json@12.0.3" },
|
||||
vuln = new { id = "CVE-2024-12345", source = "NVD" },
|
||||
entrypoint = new { type = "http", route = "GET /api/users/{id}" },
|
||||
path = new[] { new { symbol = "UserController.GetUser" }, new { symbol = "JsonConvert.DeserializeObject" } },
|
||||
evidence = new { callgraph_digest = "blake3:...", surface_digest = "sha256:..." }
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(witness, WitnessJsonOptions);
|
||||
}
|
||||
|
||||
private static string GenerateWitnessSarif(string witnessId)
|
||||
{
|
||||
var sarif = new
|
||||
{
|
||||
version = "2.1.0",
|
||||
schema = "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
runs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = "StellaOps Reachability",
|
||||
version = "1.0.0",
|
||||
informationUri = "https://stellaops.dev"
|
||||
}
|
||||
},
|
||||
results = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ruleId = "REACH001",
|
||||
level = "warning",
|
||||
message = new { text = "Reachable vulnerability: CVE-2024-12345" },
|
||||
properties = new { witnessId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(sarif, WitnessJsonOptions);
|
||||
}
|
||||
|
||||
// DTO classes for witness commands
|
||||
private sealed record WitnessDto
|
||||
{
|
||||
|
||||
431
src/Cli/StellaOps.Cli/Commands/ConfigCatalog.cs
Normal file
431
src/Cli/StellaOps.Cli/Commands/ConfigCatalog.cs
Normal file
@@ -0,0 +1,431 @@
|
||||
// <copyright file="ConfigCatalog.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010)
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration path catalog entry.
|
||||
/// </summary>
|
||||
public sealed record ConfigCatalogEntry(
|
||||
string Path,
|
||||
string SectionName,
|
||||
string Category,
|
||||
string Description,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string? ApiEndpoint = null);
|
||||
|
||||
/// <summary>
|
||||
/// Catalog of all StellaOps configuration paths.
|
||||
/// Derived from SectionName constants across all modules.
|
||||
/// </summary>
|
||||
public static class ConfigCatalog
|
||||
{
|
||||
private static readonly List<ConfigCatalogEntry> Entries =
|
||||
[
|
||||
// Policy module
|
||||
new("policy.determinization", "Determinization", "Policy",
|
||||
"Determinization options (entropy thresholds, signal weights, reanalysis triggers)",
|
||||
["pol.det", "determinization"],
|
||||
"/api/policy/config/determinization"),
|
||||
new("policy.exceptions", "Policy:Exceptions:Approval", "Policy",
|
||||
"Exception approval settings",
|
||||
["pol.exc", "exceptions"]),
|
||||
new("policy.exceptions.expiry", "Policy:Exceptions:Expiry", "Policy",
|
||||
"Exception expiry configuration",
|
||||
["pol.exc.exp"]),
|
||||
new("policy.gates", "PolicyGates", "Policy",
|
||||
"Policy gate configuration",
|
||||
["pol.gates", "gates"]),
|
||||
new("policy.engine", "PolicyEngine", "Policy",
|
||||
"Policy engine core settings",
|
||||
["pol.engine"]),
|
||||
new("policy.engine.evidenceweighted", "PolicyEngine:EvidenceWeightedScore", "Policy",
|
||||
"Evidence-weighted score configuration",
|
||||
["pol.ews"]),
|
||||
new("policy.engine.tenancy", "PolicyEngine:Tenancy", "Policy",
|
||||
"Policy engine tenancy settings",
|
||||
["pol.tenancy"]),
|
||||
new("policy.attestation", "PolicyDecisionAttestation", "Policy",
|
||||
"Policy decision attestation settings",
|
||||
["pol.attest"]),
|
||||
new("policy.confidenceweights", "ConfidenceWeights", "Policy",
|
||||
"Confidence weight configuration",
|
||||
["pol.cw"]),
|
||||
new("policy.reachability", "ReachabilitySignals", "Policy",
|
||||
"Reachability signal settings",
|
||||
["pol.reach"]),
|
||||
new("policy.smartdiff", "SmartDiff:Gates", "Policy",
|
||||
"SmartDiff gate configuration",
|
||||
["pol.smartdiff"]),
|
||||
new("policy.toollattice", "ToolLattice", "Policy",
|
||||
"Tool lattice configuration",
|
||||
["pol.lattice"]),
|
||||
new("policy.unknownbudgets", "UnknownBudgets", "Policy",
|
||||
"Unknown budgets configuration",
|
||||
["pol.budgets"]),
|
||||
new("policy.vexsigning", "VexSigning", "Policy",
|
||||
"VEX signing configuration",
|
||||
["pol.vexsign"]),
|
||||
new("policy.gatebypass", "Policy:GateBypassAudit", "Policy",
|
||||
"Gate bypass audit settings",
|
||||
["pol.bypass"]),
|
||||
new("policy.ratelimiting", "RateLimiting", "Policy",
|
||||
"Rate limiting configuration",
|
||||
["pol.rate"]),
|
||||
|
||||
// Scanner module
|
||||
new("scanner", "scanner", "Scanner",
|
||||
"Scanner core configuration",
|
||||
["scan"]),
|
||||
new("scanner.epss", "Epss", "Scanner",
|
||||
"EPSS scoring configuration",
|
||||
["scan.epss"]),
|
||||
new("scanner.epss.enrichment", "Epss:Enrichment", "Scanner",
|
||||
"EPSS enrichment settings",
|
||||
["scan.epss.enrich"]),
|
||||
new("scanner.epss.ingest", "Epss:Ingest", "Scanner",
|
||||
"EPSS ingest configuration",
|
||||
["scan.epss.ing"]),
|
||||
new("scanner.epss.signal", "Epss:Signal", "Scanner",
|
||||
"EPSS signal configuration",
|
||||
["scan.epss.sig"]),
|
||||
new("scanner.reachability", "Scanner:ReachabilitySubgraph", "Scanner",
|
||||
"Reachability subgraph settings",
|
||||
["scan.reach"]),
|
||||
new("scanner.reachability.witness", "Scanner:ReachabilityWitness", "Scanner",
|
||||
"Reachability witness configuration",
|
||||
["scan.reach.wit"]),
|
||||
new("scanner.reachability.prgate", "Scanner:Reachability:PrGate", "Scanner",
|
||||
"PR gate reachability settings",
|
||||
["scan.reach.pr"]),
|
||||
new("scanner.analyzers.native", "Scanner:Analyzers:Native", "Scanner",
|
||||
"Native analyzer configuration",
|
||||
["scan.native"]),
|
||||
new("scanner.analyzers.secrets", "Scanner:Analyzers:Secrets", "Scanner",
|
||||
"Secrets analyzer configuration",
|
||||
["scan.secrets"]),
|
||||
new("scanner.analyzers.entrytrace", "Scanner:Analyzers:EntryTrace", "Scanner",
|
||||
"Entry trace analyzer settings",
|
||||
["scan.entry"]),
|
||||
new("scanner.entrytrace.semantic", "Scanner:EntryTrace:Semantic", "Scanner",
|
||||
"Semantic entry trace configuration",
|
||||
["scan.entry.sem"]),
|
||||
new("scanner.funcproof", "Scanner:FuncProof:Generation", "Scanner",
|
||||
"Function proof generation settings",
|
||||
["scan.funcproof"]),
|
||||
new("scanner.funcproof.dsse", "Scanner:FuncProof:Dsse", "Scanner",
|
||||
"Function proof DSSE configuration",
|
||||
["scan.funcproof.dsse"]),
|
||||
new("scanner.funcproof.oci", "Scanner:FuncProof:Oci", "Scanner",
|
||||
"Function proof OCI settings",
|
||||
["scan.funcproof.oci"]),
|
||||
new("scanner.funcproof.transparency", "Scanner:FuncProof:Transparency", "Scanner",
|
||||
"Function proof transparency log settings",
|
||||
["scan.funcproof.tlog"]),
|
||||
new("scanner.idempotency", "Scanner:Idempotency", "Scanner",
|
||||
"Idempotency configuration",
|
||||
["scan.idemp"]),
|
||||
new("scanner.offlinekit", "Scanner:OfflineKit", "Scanner",
|
||||
"Offline kit configuration",
|
||||
["scan.offline"]),
|
||||
new("scanner.proofspine", "scanner:proofSpine:dsse", "Scanner",
|
||||
"Proof spine DSSE settings",
|
||||
["scan.spine"]),
|
||||
new("scanner.worker", "Scanner:Worker", "Scanner",
|
||||
"Scanner worker configuration",
|
||||
["scan.worker"]),
|
||||
new("scanner.worker.nativeanalyzers", "Scanner:Worker:NativeAnalyzers", "Scanner",
|
||||
"Worker native analyzer settings",
|
||||
["scan.worker.native"]),
|
||||
new("scanner.concelier", "scanner:concelier", "Scanner",
|
||||
"Scanner Concelier integration",
|
||||
["scan.concel"]),
|
||||
new("scanner.drift", "DriftAttestation", "Scanner",
|
||||
"Drift attestation settings",
|
||||
["scan.drift"]),
|
||||
new("scanner.validationgate", "ValidationGate", "Scanner",
|
||||
"Validation gate configuration",
|
||||
["scan.valgate"]),
|
||||
new("scanner.vexgate", "VexGate", "Scanner",
|
||||
"VEX gate configuration",
|
||||
["scan.vexgate"]),
|
||||
|
||||
// Notifier module
|
||||
new("notifier", "Notifier:Tenant", "Notifier",
|
||||
"Notifier tenant configuration",
|
||||
["notify", "notif"]),
|
||||
new("notifier.channels", "ChannelAdapters", "Notifier",
|
||||
"Channel adapter configuration",
|
||||
["notify.chan"]),
|
||||
new("notifier.inapp", "InAppChannel", "Notifier",
|
||||
"In-app notification channel settings",
|
||||
["notify.inapp"]),
|
||||
new("notifier.ackbridge", "Notifier:AckBridge", "Notifier",
|
||||
"Acknowledgment bridge configuration",
|
||||
["notify.ack"]),
|
||||
new("notifier.correlation", "Notifier:Correlation", "Notifier",
|
||||
"Correlation settings",
|
||||
["notify.corr"]),
|
||||
new("notifier.digest", "Notifier:Digest", "Notifier",
|
||||
"Digest notification settings",
|
||||
["notify.digest"]),
|
||||
new("notifier.digestschedule", "Notifier:DigestSchedule", "Notifier",
|
||||
"Digest schedule configuration",
|
||||
["notify.digest.sched"]),
|
||||
new("notifier.fallback", "Notifier:Fallback", "Notifier",
|
||||
"Fallback channel configuration",
|
||||
["notify.fallback"]),
|
||||
new("notifier.incidentmanager", "Notifier:IncidentManager", "Notifier",
|
||||
"Incident manager settings",
|
||||
["notify.incident"]),
|
||||
new("notifier.integrations.opsgenie", "Notifier:Integrations:OpsGenie", "Notifier",
|
||||
"OpsGenie integration settings",
|
||||
["notify.opsgenie"]),
|
||||
new("notifier.integrations.pagerduty", "Notifier:Integrations:PagerDuty", "Notifier",
|
||||
"PagerDuty integration settings",
|
||||
["notify.pagerduty"]),
|
||||
new("notifier.localization", "Notifier:Localization", "Notifier",
|
||||
"Localization settings",
|
||||
["notify.l10n"]),
|
||||
new("notifier.quiethours", "Notifier:QuietHours", "Notifier",
|
||||
"Quiet hours configuration",
|
||||
["notify.quiet"]),
|
||||
new("notifier.stormbreaker", "Notifier:StormBreaker", "Notifier",
|
||||
"Storm breaker settings",
|
||||
["notify.storm"]),
|
||||
new("notifier.throttler", "Notifier:Throttler", "Notifier",
|
||||
"Throttler configuration",
|
||||
["notify.throttle"]),
|
||||
new("notifier.template", "TemplateRenderer", "Notifier",
|
||||
"Template renderer settings",
|
||||
["notify.template"]),
|
||||
|
||||
// Concelier module
|
||||
new("concelier.cache", "Concelier:Cache", "Concelier",
|
||||
"Concelier cache configuration",
|
||||
["concel.cache"]),
|
||||
new("concelier.epss", "Concelier:Epss", "Concelier",
|
||||
"Concelier EPSS settings",
|
||||
["concel.epss"]),
|
||||
new("concelier.interest", "Concelier:Interest", "Concelier",
|
||||
"Interest tracking configuration",
|
||||
["concel.interest"]),
|
||||
new("concelier.federation", "Federation", "Concelier",
|
||||
"Federation settings",
|
||||
["concel.fed"]),
|
||||
|
||||
// Attestor module
|
||||
new("attestor.binarydiff", "Attestor:BinaryDiff", "Attestor",
|
||||
"Binary diff attestation settings",
|
||||
["attest.bindiff"]),
|
||||
new("attestor.graphroot", "Attestor:GraphRoot", "Attestor",
|
||||
"Graph root attestation configuration",
|
||||
["attest.graph"]),
|
||||
new("attestor.rekor", "Attestor:Rekor", "Attestor",
|
||||
"Rekor transparency log settings",
|
||||
["attest.rekor"]),
|
||||
|
||||
// BinaryIndex module
|
||||
new("binaryindex.builders", "BinaryIndex:Builders", "BinaryIndex",
|
||||
"Binary index builder configuration",
|
||||
["binidx.build"]),
|
||||
new("binaryindex.funcextraction", "BinaryIndex:FunctionExtraction", "BinaryIndex",
|
||||
"Function extraction settings",
|
||||
["binidx.func"]),
|
||||
new("binaryindex.goldenset", "BinaryIndex:GoldenSet", "BinaryIndex",
|
||||
"Golden set configuration",
|
||||
["binidx.golden"]),
|
||||
new("binaryindex.bsim", "BSim", "BinaryIndex",
|
||||
"BSim configuration",
|
||||
["binidx.bsim"]),
|
||||
new("binaryindex.disassembly", "Disassembly", "BinaryIndex",
|
||||
"Disassembly settings",
|
||||
["binidx.disasm"]),
|
||||
new("binaryindex.ghidra", "Ghidra", "BinaryIndex",
|
||||
"Ghidra configuration",
|
||||
["binidx.ghidra"]),
|
||||
new("binaryindex.ghidriff", "Ghidriff", "BinaryIndex",
|
||||
"Ghidriff settings",
|
||||
["binidx.ghidriff"]),
|
||||
new("binaryindex.resolution", "Resolution", "BinaryIndex",
|
||||
"Resolution configuration",
|
||||
["binidx.res"]),
|
||||
|
||||
// Signals module
|
||||
new("signals", "Signals", "Signals",
|
||||
"Signals core configuration",
|
||||
["sig"]),
|
||||
new("signals.evidencenorm", "EvidenceNormalization", "Signals",
|
||||
"Evidence normalization settings",
|
||||
["sig.evnorm"]),
|
||||
new("signals.evidenceweighted", "EvidenceWeightedScore", "Signals",
|
||||
"Evidence-weighted score settings",
|
||||
["sig.ews"]),
|
||||
new("signals.retention", "Signals:Retention", "Signals",
|
||||
"Signal retention configuration",
|
||||
["sig.ret"]),
|
||||
new("signals.unknownsdecay", "Signals:UnknownsDecay", "Signals",
|
||||
"Unknowns decay settings",
|
||||
["sig.decay"]),
|
||||
new("signals.unknownsrescan", "Signals:UnknownsRescan", "Signals",
|
||||
"Unknowns rescan configuration",
|
||||
["sig.rescan"]),
|
||||
new("signals.unknownsscoring", "Signals:UnknownsScoring", "Signals",
|
||||
"Unknowns scoring settings",
|
||||
["sig.scoring"]),
|
||||
|
||||
// Signer module
|
||||
new("signer.keyless", "Signer:Keyless", "Signer",
|
||||
"Keyless signing configuration",
|
||||
["sign.keyless"]),
|
||||
new("signer.sigstore", "Sigstore", "Signer",
|
||||
"Sigstore configuration",
|
||||
["sign.sigstore"]),
|
||||
|
||||
// AdvisoryAI module
|
||||
new("advisoryai.chat", "AdvisoryAI:Chat", "AdvisoryAI",
|
||||
"Chat configuration",
|
||||
["ai.chat"]),
|
||||
new("advisoryai.inference", "AdvisoryAI:Inference:Offline", "AdvisoryAI",
|
||||
"Offline inference settings",
|
||||
["ai.inference"]),
|
||||
new("advisoryai.llmproviders", "AdvisoryAI:LlmProviders", "AdvisoryAI",
|
||||
"LLM provider configuration",
|
||||
["ai.llm"]),
|
||||
new("advisoryai.ratelimits", "AdvisoryAI:RateLimits", "AdvisoryAI",
|
||||
"Rate limits for AI features",
|
||||
["ai.rate"]),
|
||||
|
||||
// AirGap module
|
||||
new("airgap.bundlesigning", "AirGap:BundleSigning", "AirGap",
|
||||
"Bundle signing configuration",
|
||||
["air.sign"]),
|
||||
new("airgap.quarantine", "AirGap:Quarantine", "AirGap",
|
||||
"Quarantine settings",
|
||||
["air.quar"]),
|
||||
|
||||
// Excititor module
|
||||
new("excititor.autovex", "AutoVex:Downgrade", "Excititor",
|
||||
"Auto VEX downgrade settings",
|
||||
["exc.autovex"]),
|
||||
new("excititor.airgap", "Excititor:Airgap", "Excititor",
|
||||
"Excititor airgap configuration",
|
||||
["exc.airgap"]),
|
||||
new("excititor.evidence", "Excititor:Evidence:Linking", "Excititor",
|
||||
"Evidence linking settings",
|
||||
["exc.evidence"]),
|
||||
new("excititor.mirror", "Excititor:Mirror", "Excititor",
|
||||
"Mirror configuration",
|
||||
["exc.mirror"]),
|
||||
new("excititor.vexverify", "VexSignatureVerification", "Excititor",
|
||||
"VEX signature verification settings",
|
||||
["exc.vexverify"]),
|
||||
|
||||
// ExportCenter module
|
||||
new("exportcenter", "ExportCenter", "ExportCenter",
|
||||
"Export center core configuration",
|
||||
["export"]),
|
||||
new("exportcenter.trivy", "ExportCenter:Adapters:Trivy", "ExportCenter",
|
||||
"Trivy adapter settings",
|
||||
["export.trivy"]),
|
||||
new("exportcenter.oci", "ExportCenter:Distribution:Oci", "ExportCenter",
|
||||
"OCI distribution configuration",
|
||||
["export.oci"]),
|
||||
new("exportcenter.encryption", "ExportCenter:Encryption", "ExportCenter",
|
||||
"Encryption settings",
|
||||
["export.encrypt"]),
|
||||
|
||||
// Orchestrator module
|
||||
new("orchestrator", "Orchestrator", "Orchestrator",
|
||||
"Orchestrator core configuration",
|
||||
["orch"]),
|
||||
new("orchestrator.firstsignal", "FirstSignal", "Orchestrator",
|
||||
"First signal configuration",
|
||||
["orch.first"]),
|
||||
new("orchestrator.incidentmode", "Orchestrator:IncidentMode", "Orchestrator",
|
||||
"Incident mode settings",
|
||||
["orch.incident"]),
|
||||
new("orchestrator.stream", "Orchestrator:Stream", "Orchestrator",
|
||||
"Stream processing configuration",
|
||||
["orch.stream"]),
|
||||
|
||||
// Scheduler module
|
||||
new("scheduler.hlc", "Scheduler:HlcOrdering", "Scheduler",
|
||||
"HLC ordering configuration",
|
||||
["sched.hlc"]),
|
||||
|
||||
// VexLens module
|
||||
new("vexlens", "VexLens", "VexLens",
|
||||
"VexLens core configuration",
|
||||
["lens"]),
|
||||
new("vexlens.noisegate", "VexLens:NoiseGate", "VexLens",
|
||||
"Noise gate configuration",
|
||||
["lens.noise"]),
|
||||
|
||||
// Zastava module
|
||||
new("zastava.agent", "zastava:agent", "Zastava",
|
||||
"Zastava agent configuration",
|
||||
["zast.agent"]),
|
||||
new("zastava.observer", "zastava:observer", "Zastava",
|
||||
"Observer configuration",
|
||||
["zast.obs"]),
|
||||
new("zastava.runtime", "zastava:runtime", "Zastava",
|
||||
"Runtime configuration",
|
||||
["zast.runtime"]),
|
||||
new("zastava.webhook", "zastava:webhook", "Zastava",
|
||||
"Webhook configuration",
|
||||
["zast.webhook"]),
|
||||
|
||||
// Platform module
|
||||
new("platform", "Platform", "Platform",
|
||||
"Platform core configuration",
|
||||
["plat"]),
|
||||
|
||||
// Authority module
|
||||
new("authority", "Authority", "Authority",
|
||||
"Authority core configuration",
|
||||
["auth"]),
|
||||
new("authority.plugins", "Authority:Plugins", "Authority",
|
||||
"Authority plugins configuration",
|
||||
["auth.plugins"]),
|
||||
new("authority.passwordpolicy", "Authority:PasswordPolicy", "Authority",
|
||||
"Password policy configuration",
|
||||
["auth.password"]),
|
||||
|
||||
// Setup prefixes
|
||||
new("setup.database", "database", "Setup",
|
||||
"Database connection settings",
|
||||
["db"]),
|
||||
new("setup.cache", "cache", "Setup",
|
||||
"Cache configuration",
|
||||
["cache"]),
|
||||
new("setup.registry", "registry", "Setup",
|
||||
"Registry configuration",
|
||||
["reg"])
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all catalog entries.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ConfigCatalogEntry> GetAll() => Entries;
|
||||
|
||||
/// <summary>
|
||||
/// Finds a catalog entry by path or alias.
|
||||
/// </summary>
|
||||
public static ConfigCatalogEntry? Find(string pathOrAlias)
|
||||
{
|
||||
var normalized = pathOrAlias.Replace(':', '.').ToLowerInvariant();
|
||||
|
||||
return Entries.FirstOrDefault(e =>
|
||||
e.Path.Equals(normalized, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Aliases.Any(a => a.Equals(normalized, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all categories.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> GetCategories() =>
|
||||
Entries.Select(e => e.Category).Distinct().OrderBy(c => c).ToList();
|
||||
}
|
||||
54
src/Cli/StellaOps.Cli/Commands/ConfigCommandGroup.cs
Normal file
54
src/Cli/StellaOps.Cli/Commands/ConfigCommandGroup.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
// <copyright file="ConfigCommandGroup.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010, CLI-CONFIG-011)
|
||||
// </copyright>
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Services;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for inspecting StellaOps configuration.
|
||||
/// </summary>
|
||||
public static class ConfigCommandGroup
|
||||
{
|
||||
public static Command Create(IBackendOperationsClient client)
|
||||
{
|
||||
var configCommand = new Command("config", "Inspect StellaOps configuration");
|
||||
|
||||
// stella config list
|
||||
var listCommand = new Command("list", "List all available configuration paths");
|
||||
var categoryOption = new Option<string?>(
|
||||
["--category", "-c"],
|
||||
"Filter by category (e.g., policy, scanner, notifier)");
|
||||
listCommand.AddOption(categoryOption);
|
||||
listCommand.SetHandler(
|
||||
async (string? category) => await CommandHandlers.Config.ListAsync(category),
|
||||
categoryOption);
|
||||
|
||||
// stella config <path> show
|
||||
var pathArgument = new Argument<string>("path", "Configuration path (e.g., policy.determinization, scanner.epss)");
|
||||
var showCommand = new Command("show", "Show configuration for a specific path");
|
||||
showCommand.AddArgument(pathArgument);
|
||||
var formatOption = new Option<string>(
|
||||
["--format", "-f"],
|
||||
() => "table",
|
||||
"Output format: table, json, yaml");
|
||||
var showSecretsOption = new Option<bool>(
|
||||
"--show-secrets",
|
||||
() => false,
|
||||
"Show secret values (default: redacted)");
|
||||
showCommand.AddOption(formatOption);
|
||||
showCommand.AddOption(showSecretsOption);
|
||||
showCommand.SetHandler(
|
||||
async (string path, string format, bool showSecrets) =>
|
||||
await CommandHandlers.Config.ShowAsync(client, path, format, showSecrets),
|
||||
pathArgument, formatOption, showSecretsOption);
|
||||
|
||||
configCommand.AddCommand(listCommand);
|
||||
configCommand.AddCommand(showCommand);
|
||||
|
||||
return configCommand;
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,141 @@ public static class EvidenceCommandGroup
|
||||
{
|
||||
BuildExportCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildVerifyCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildStatusCommand(services, options, verboseOption, cancellationToken)
|
||||
BuildStatusCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildCardCommand(services, options, verboseOption, cancellationToken)
|
||||
};
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the card subcommand group for evidence-card operations.
|
||||
/// Sprint: SPRINT_20260112_011_CLI_evidence_card_remediate_cli (EVPCARD-CLI-001, EVPCARD-CLI-002)
|
||||
/// </summary>
|
||||
public static Command BuildCardCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var card = new Command("card", "Single-file evidence card export and verification")
|
||||
{
|
||||
BuildCardExportCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildCardVerifyCommand(services, options, verboseOption, cancellationToken)
|
||||
};
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the card export command.
|
||||
/// EVPCARD-CLI-001: stella evidence card export
|
||||
/// </summary>
|
||||
public static Command BuildCardExportCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packIdArg = new Argument<string>("pack-id")
|
||||
{
|
||||
Description = "Evidence pack ID to export as card (e.g., evp-2026-01-14-abc123)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", ["-o"])
|
||||
{
|
||||
Description = "Output file path (defaults to <pack-id>.evidence-card.json)",
|
||||
Required = false
|
||||
};
|
||||
|
||||
var compactOption = new Option<bool>("--compact")
|
||||
{
|
||||
Description = "Export compact format without full SBOM excerpt"
|
||||
};
|
||||
|
||||
var outputFormatOption = new Option<string>("--format", ["-f"])
|
||||
{
|
||||
Description = "Output format: json (default), yaml"
|
||||
};
|
||||
|
||||
var export = new Command("export", "Export evidence pack as single-file evidence card")
|
||||
{
|
||||
packIdArg,
|
||||
outputOption,
|
||||
compactOption,
|
||||
outputFormatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
export.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var packId = parseResult.GetValue(packIdArg) ?? string.Empty;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var compact = parseResult.GetValue(compactOption);
|
||||
var format = parseResult.GetValue(outputFormatOption) ?? "json";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleCardExportAsync(
|
||||
services, options, packId, output, compact, format, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the card verify command.
|
||||
/// EVPCARD-CLI-002: stella evidence card verify
|
||||
/// </summary>
|
||||
public static Command BuildCardVerifyCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pathArg = new Argument<string>("path")
|
||||
{
|
||||
Description = "Path to evidence card file (.evidence-card.json)"
|
||||
};
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Skip Rekor transparency log verification (for air-gapped environments)"
|
||||
};
|
||||
|
||||
var trustRootOption = new Option<string>("--trust-root")
|
||||
{
|
||||
Description = "Path to offline trust root bundle for signature verification"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", ["-o"])
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
|
||||
var verify = new Command("verify", "Verify DSSE signatures and Rekor receipts in an evidence card")
|
||||
{
|
||||
pathArg,
|
||||
offlineOption,
|
||||
trustRootOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
verify.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var path = parseResult.GetValue(pathArg) ?? string.Empty;
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var trustRoot = parseResult.GetValue(trustRootOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleCardVerifyAsync(
|
||||
services, options, path, offline, trustRoot, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return verify;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the export command.
|
||||
/// T025: stella evidence export --bundle <id> --output <path>
|
||||
@@ -854,4 +983,369 @@ public static class EvidenceCommandGroup
|
||||
}
|
||||
|
||||
private sealed record VerificationResult(string Check, bool Passed, string Message);
|
||||
|
||||
// ========== Evidence Card Handlers ==========
|
||||
// Sprint: SPRINT_20260112_011_CLI_evidence_card_remediate_cli (EVPCARD-CLI-001, EVPCARD-CLI-002)
|
||||
|
||||
private static async Task<int> HandleCardExportAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string packId,
|
||||
string? outputPath,
|
||||
bool compact,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(packId))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient("EvidencePack");
|
||||
|
||||
var backendUrl = options.BackendUrl
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_EVIDENCE_URL")
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
|
||||
?? "http://localhost:5000";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Backend URL: {backendUrl}[/]");
|
||||
}
|
||||
|
||||
var exportFormat = compact ? "card-compact" : "evidence-card";
|
||||
var extension = compact ? ".evidence-card-compact.json" : ".evidence-card.json";
|
||||
outputPath ??= $"{packId}{extension}";
|
||||
|
||||
try
|
||||
{
|
||||
await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("yellow"))
|
||||
.StartAsync("Exporting evidence card...", async ctx =>
|
||||
{
|
||||
var requestUrl = $"{backendUrl}/v1/evidence-packs/{packId}/export?format={exportFormat}";
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Request: GET {requestUrl}[/]");
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(requestUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"Export failed: {response.StatusCode} - {error}");
|
||||
}
|
||||
|
||||
// Get headers for metadata
|
||||
var contentDigest = response.Headers.TryGetValues("X-Content-Digest", out var digestValues)
|
||||
? digestValues.FirstOrDefault()
|
||||
: null;
|
||||
var cardVersion = response.Headers.TryGetValues("X-Evidence-Card-Version", out var versionValues)
|
||||
? versionValues.FirstOrDefault()
|
||||
: null;
|
||||
var rekorIndex = response.Headers.TryGetValues("X-Rekor-Log-Index", out var rekorValues)
|
||||
? rekorValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
ctx.Status("Writing evidence card to disk...");
|
||||
|
||||
await using var fileStream = File.Create(outputPath);
|
||||
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||
|
||||
// Display export summary
|
||||
AnsiConsole.MarkupLine($"[green]Success:[/] Evidence card exported to [blue]{outputPath}[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Property");
|
||||
table.AddColumn("Value");
|
||||
table.Border(TableBorder.Rounded);
|
||||
|
||||
table.AddRow("Pack ID", packId);
|
||||
table.AddRow("Format", compact ? "Compact" : "Full");
|
||||
if (cardVersion != null)
|
||||
table.AddRow("Card Version", cardVersion);
|
||||
if (contentDigest != null)
|
||||
table.AddRow("Content Digest", contentDigest);
|
||||
if (rekorIndex != null)
|
||||
table.AddRow("Rekor Log Index", rekorIndex);
|
||||
table.AddRow("Output File", outputPath);
|
||||
table.AddRow("File Size", FormatSize(new FileInfo(outputPath).Length));
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
});
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> HandleCardVerifyAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string path,
|
||||
bool offline,
|
||||
string? trustRoot,
|
||||
string output,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Evidence card path is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = new List<CardVerificationResult>();
|
||||
|
||||
await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("yellow"))
|
||||
.StartAsync("Verifying evidence card...", async ctx =>
|
||||
{
|
||||
// Read and parse the evidence card
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var card = JsonDocument.Parse(content);
|
||||
var root = card.RootElement;
|
||||
|
||||
// Verify card structure
|
||||
ctx.Status("Checking card structure...");
|
||||
results.Add(VerifyCardStructure(root));
|
||||
|
||||
// Verify content digest
|
||||
ctx.Status("Verifying content digest...");
|
||||
results.Add(await VerifyCardDigestAsync(path, root, cancellationToken));
|
||||
|
||||
// Verify DSSE envelope
|
||||
ctx.Status("Verifying DSSE envelope...");
|
||||
results.Add(VerifyDsseEnvelope(root, verbose));
|
||||
|
||||
// Verify Rekor receipt (if present and not offline)
|
||||
if (!offline && root.TryGetProperty("rekorReceipt", out var rekorReceipt))
|
||||
{
|
||||
ctx.Status("Verifying Rekor receipt...");
|
||||
results.Add(VerifyRekorReceipt(rekorReceipt, verbose));
|
||||
}
|
||||
else if (offline)
|
||||
{
|
||||
results.Add(new CardVerificationResult("Rekor Receipt", true, "Skipped (offline mode)"));
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new CardVerificationResult("Rekor Receipt", true, "Not present"));
|
||||
}
|
||||
|
||||
// Verify SBOM excerpt (if present)
|
||||
if (root.TryGetProperty("sbomExcerpt", out var sbomExcerpt))
|
||||
{
|
||||
ctx.Status("Verifying SBOM excerpt...");
|
||||
results.Add(VerifySbomExcerpt(sbomExcerpt, verbose));
|
||||
}
|
||||
});
|
||||
|
||||
// Output results
|
||||
var allPassed = results.All(r => r.Passed);
|
||||
|
||||
if (output == "json")
|
||||
{
|
||||
var jsonResult = new
|
||||
{
|
||||
file = path,
|
||||
valid = allPassed,
|
||||
checks = results.Select(r => new
|
||||
{
|
||||
check = r.Check,
|
||||
passed = r.Passed,
|
||||
message = r.Message
|
||||
})
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(jsonResult, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Table output
|
||||
var table = new Table();
|
||||
table.AddColumn("Check");
|
||||
table.AddColumn("Status");
|
||||
table.AddColumn("Details");
|
||||
table.Border(TableBorder.Rounded);
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var status = result.Passed
|
||||
? "[green]PASS[/]"
|
||||
: "[red]FAIL[/]";
|
||||
table.AddRow(result.Check, status, result.Message);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (allPassed)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]All verification checks passed[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]One or more verification checks failed[/]");
|
||||
}
|
||||
}
|
||||
|
||||
return allPassed ? 0 : 1;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid JSON in evidence card: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static CardVerificationResult VerifyCardStructure(JsonElement root)
|
||||
{
|
||||
var requiredProps = new[] { "cardId", "version", "packId", "createdAt", "subject", "contentDigest" };
|
||||
var missing = requiredProps.Where(p => !root.TryGetProperty(p, out _)).ToList();
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return new CardVerificationResult("Card Structure", false, $"Missing required properties: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
var cardId = root.GetProperty("cardId").GetString();
|
||||
var version = root.GetProperty("version").GetString();
|
||||
|
||||
return new CardVerificationResult("Card Structure", true, $"Card {cardId} v{version}");
|
||||
}
|
||||
|
||||
private static async Task<CardVerificationResult> VerifyCardDigestAsync(
|
||||
string path,
|
||||
JsonElement root,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!root.TryGetProperty("contentDigest", out var digestProp))
|
||||
{
|
||||
return new CardVerificationResult("Content Digest", false, "Missing contentDigest property");
|
||||
}
|
||||
|
||||
var expectedDigest = digestProp.GetString();
|
||||
if (string.IsNullOrEmpty(expectedDigest))
|
||||
{
|
||||
return new CardVerificationResult("Content Digest", false, "Empty contentDigest");
|
||||
}
|
||||
|
||||
// Note: The content digest is computed over the payload, not the full file
|
||||
// For now, just validate the format
|
||||
if (!expectedDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new CardVerificationResult("Content Digest", false, $"Invalid digest format: {expectedDigest}");
|
||||
}
|
||||
|
||||
return new CardVerificationResult("Content Digest", true, expectedDigest);
|
||||
}
|
||||
|
||||
private static CardVerificationResult VerifyDsseEnvelope(JsonElement root, bool verbose)
|
||||
{
|
||||
if (!root.TryGetProperty("envelope", out var envelope))
|
||||
{
|
||||
return new CardVerificationResult("DSSE Envelope", true, "No envelope present (unsigned)");
|
||||
}
|
||||
|
||||
var requiredEnvelopeProps = new[] { "payloadType", "payload", "payloadDigest", "signatures" };
|
||||
var missing = requiredEnvelopeProps.Where(p => !envelope.TryGetProperty(p, out _)).ToList();
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return new CardVerificationResult("DSSE Envelope", false, $"Invalid envelope: missing {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
var payloadType = envelope.GetProperty("payloadType").GetString();
|
||||
var signatures = envelope.GetProperty("signatures");
|
||||
var sigCount = signatures.GetArrayLength();
|
||||
|
||||
if (sigCount == 0)
|
||||
{
|
||||
return new CardVerificationResult("DSSE Envelope", false, "No signatures in envelope");
|
||||
}
|
||||
|
||||
// Validate signature structure
|
||||
foreach (var sig in signatures.EnumerateArray())
|
||||
{
|
||||
if (!sig.TryGetProperty("keyId", out _) || !sig.TryGetProperty("sig", out _))
|
||||
{
|
||||
return new CardVerificationResult("DSSE Envelope", false, "Invalid signature structure");
|
||||
}
|
||||
}
|
||||
|
||||
return new CardVerificationResult("DSSE Envelope", true, $"Payload type: {payloadType}, {sigCount} signature(s)");
|
||||
}
|
||||
|
||||
private static CardVerificationResult VerifyRekorReceipt(JsonElement receipt, bool verbose)
|
||||
{
|
||||
if (!receipt.TryGetProperty("logIndex", out var logIndexProp))
|
||||
{
|
||||
return new CardVerificationResult("Rekor Receipt", false, "Missing logIndex");
|
||||
}
|
||||
|
||||
if (!receipt.TryGetProperty("logId", out var logIdProp))
|
||||
{
|
||||
return new CardVerificationResult("Rekor Receipt", false, "Missing logId");
|
||||
}
|
||||
|
||||
var logIndex = logIndexProp.GetInt64();
|
||||
var logId = logIdProp.GetString();
|
||||
|
||||
// Check for inclusion proof
|
||||
var hasInclusionProof = receipt.TryGetProperty("inclusionProof", out _);
|
||||
var hasInclusionPromise = receipt.TryGetProperty("inclusionPromise", out _);
|
||||
|
||||
var proofStatus = hasInclusionProof ? "with inclusion proof" :
|
||||
hasInclusionPromise ? "with inclusion promise" :
|
||||
"no proof attached";
|
||||
|
||||
return new CardVerificationResult("Rekor Receipt", true, $"Log index {logIndex}, {proofStatus}");
|
||||
}
|
||||
|
||||
private static CardVerificationResult VerifySbomExcerpt(JsonElement excerpt, bool verbose)
|
||||
{
|
||||
if (!excerpt.TryGetProperty("format", out var formatProp))
|
||||
{
|
||||
return new CardVerificationResult("SBOM Excerpt", false, "Missing format");
|
||||
}
|
||||
|
||||
var format = formatProp.GetString();
|
||||
var componentPurl = excerpt.TryGetProperty("componentPurl", out var purlProp)
|
||||
? purlProp.GetString()
|
||||
: null;
|
||||
var componentName = excerpt.TryGetProperty("componentName", out var nameProp)
|
||||
? nameProp.GetString()
|
||||
: null;
|
||||
|
||||
var description = componentPurl ?? componentName ?? "no component info";
|
||||
|
||||
return new CardVerificationResult("SBOM Excerpt", true, $"Format: {format}, Component: {description}");
|
||||
}
|
||||
|
||||
private sealed record CardVerificationResult(string Check, bool Passed, string Message);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ public static class UnknownsCommandGroup
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -274,6 +281,194 @@ public static class UnknownsCommandGroup
|
||||
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,
|
||||
@@ -558,6 +753,452 @@ public static class UnknownsCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -927,5 +1568,102 @@ public static class UnknownsCommandGroup
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4909,4 +4909,98 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-001)
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WitnessListResponse> ListWitnessesAsync(WitnessListRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
queryParams.Add($"scan_id={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.VulnerabilityId))
|
||||
queryParams.Add($"vuln_id={Uri.EscapeDataString(request.VulnerabilityId)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ComponentPurl))
|
||||
queryParams.Add($"purl={Uri.EscapeDataString(request.ComponentPurl)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.PredicateType))
|
||||
queryParams.Add($"predicate_type={Uri.EscapeDataString(request.PredicateType)}");
|
||||
if (request.Limit.HasValue)
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ContinuationToken))
|
||||
queryParams.Add($"continuation={Uri.EscapeDataString(request.ContinuationToken)}");
|
||||
|
||||
var url = "api/witnesses";
|
||||
if (queryParams.Count > 0)
|
||||
url += "?" + string.Join("&", queryParams);
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, url);
|
||||
ApplyTenantHeader(httpRequest, request.TenantId);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<WitnessListResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new WitnessListResponse();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WitnessDetailResponse?> GetWitnessAsync(string witnessId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(witnessId);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var url = $"api/witnesses/{Uri.EscapeDataString(witnessId)}";
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<WitnessDetailResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WitnessVerifyResponse> VerifyWitnessAsync(string witnessId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(witnessId);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var url = $"api/witnesses/{Uri.EscapeDataString(witnessId)}/verify";
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<WitnessVerifyResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new WitnessVerifyResponse { Verified = false, Status = "unknown", Message = "Empty response from server" };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> DownloadWitnessAsync(string witnessId, WitnessExportFormat format, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(witnessId);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var formatParam = format switch
|
||||
{
|
||||
WitnessExportFormat.Json => "json",
|
||||
WitnessExportFormat.Dsse => "dsse",
|
||||
WitnessExportFormat.Sarif => "sarif",
|
||||
_ => "json"
|
||||
};
|
||||
|
||||
var url = $"api/witnesses/{Uri.EscapeDataString(witnessId)}/export?format={formatParam}";
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,4 +136,11 @@ internal interface IBackendOperationsClient
|
||||
|
||||
// SDIFF-BIN-030: SARIF export
|
||||
Task<string?> GetScanSarifAsync(string scanId, bool includeHardening, bool includeReachability, string? minSeverity, CancellationToken cancellationToken);
|
||||
|
||||
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-001)
|
||||
// Witness operations
|
||||
Task<WitnessListResponse> ListWitnessesAsync(WitnessListRequest request, CancellationToken cancellationToken);
|
||||
Task<WitnessDetailResponse?> GetWitnessAsync(string witnessId, CancellationToken cancellationToken);
|
||||
Task<WitnessVerifyResponse> VerifyWitnessAsync(string witnessId, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadWitnessAsync(string witnessId, WitnessExportFormat format, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
468
src/Cli/StellaOps.Cli/Services/Models/WitnessModels.cs
Normal file
468
src/Cli/StellaOps.Cli/Services/Models/WitnessModels.cs
Normal file
@@ -0,0 +1,468 @@
|
||||
// <copyright file="WitnessModels.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-001)
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for listing witnesses.
|
||||
/// </summary>
|
||||
public sealed record WitnessListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by scan ID.
|
||||
/// </summary>
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by vulnerability ID (e.g., CVE-2024-1234).
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by component PURL.
|
||||
/// </summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by predicate type.
|
||||
/// </summary>
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Continuation token for pagination.
|
||||
/// </summary>
|
||||
public string? ContinuationToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing witnesses.
|
||||
/// </summary>
|
||||
public sealed record WitnessListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// List of witness summaries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witnesses")]
|
||||
public IReadOnlyList<WitnessSummary> Witnesses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Continuation token for next page.
|
||||
/// </summary>
|
||||
[JsonPropertyName("continuation_token")]
|
||||
public string? ContinuationToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count of matching witnesses.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total_count")]
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a witness for list views.
|
||||
/// </summary>
|
||||
public sealed record WitnessSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed witness ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_id")]
|
||||
public required string WitnessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("component_purl")]
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint")]
|
||||
public string? Entrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink symbol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink")]
|
||||
public string? Sink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path length.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path_length")]
|
||||
public int PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate_type")]
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the witness has a valid DSSE signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_signed")]
|
||||
public bool IsSigned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the witness was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed witness response.
|
||||
/// </summary>
|
||||
public sealed record WitnessDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_schema")]
|
||||
public string? WitnessSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed witness ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("witness_id")]
|
||||
public required string WitnessId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifact")]
|
||||
public WitnessArtifactInfo? Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vuln")]
|
||||
public WitnessVulnInfo? Vuln { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint")]
|
||||
public WitnessEntrypointInfo? Entrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call path from entrypoint to sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<WitnessPathStep>? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink")]
|
||||
public WitnessSinkInfo? Sink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected gates along the path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gates")]
|
||||
public IReadOnlyList<WitnessGateInfo>? Gates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence digests.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public WitnessEvidenceInfo? Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the witness was observed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("observed_at")]
|
||||
public DateTimeOffset ObservedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path hash for deterministic joining.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash
|
||||
/// </summary>
|
||||
[JsonPropertyName("path_hash")]
|
||||
public string? PathHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top-K node hashes along the path.
|
||||
/// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash
|
||||
/// </summary>
|
||||
[JsonPropertyName("node_hashes")]
|
||||
public IReadOnlyList<string>? NodeHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence URIs for traceability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence_uris")]
|
||||
public IReadOnlyList<string>? EvidenceUris { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate_type")]
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope if signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsse_envelope")]
|
||||
public WitnessDsseEnvelope? DsseEnvelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact information in a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessArtifactInfo
|
||||
{
|
||||
[JsonPropertyName("sbom_digest")]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("component_purl")]
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability information in a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessVulnInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("affected_range")]
|
||||
public string? AffectedRange { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint information in a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessEntrypointInfo
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public string? SymbolId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A step in the call path.
|
||||
/// </summary>
|
||||
public sealed record WitnessPathStep
|
||||
{
|
||||
[JsonPropertyName("symbol")]
|
||||
public string? Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public string? SymbolId { get; init; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sink information in a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessSinkInfo
|
||||
{
|
||||
[JsonPropertyName("symbol")]
|
||||
public string? Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public string? SymbolId { get; init; }
|
||||
|
||||
[JsonPropertyName("sink_type")]
|
||||
public string? SinkType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate (guard/control) information in a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessGateInfo
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("guard_symbol")]
|
||||
public string? GuardSymbol { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence information in a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessEvidenceInfo
|
||||
{
|
||||
[JsonPropertyName("callgraph_digest")]
|
||||
public string? CallgraphDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("surface_digest")]
|
||||
public string? SurfaceDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("analysis_config_digest")]
|
||||
public string? AnalysisConfigDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("build_id")]
|
||||
public string? BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope information.
|
||||
/// </summary>
|
||||
public sealed record WitnessDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payload_type")]
|
||||
public string? PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string? Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<WitnessDsseSignature>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information.
|
||||
/// </summary>
|
||||
public sealed record WitnessDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for witness verification.
|
||||
/// </summary>
|
||||
public sealed record WitnessVerifyResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification succeeded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification status code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE verification details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsse")]
|
||||
public WitnessDsseVerifyInfo? Dsse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content_hash")]
|
||||
public WitnessContentHashInfo? ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE verification details.
|
||||
/// </summary>
|
||||
public sealed record WitnessDsseVerifyInfo
|
||||
{
|
||||
[JsonPropertyName("envelope_valid")]
|
||||
public bool EnvelopeValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signature_count")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("valid_signatures")]
|
||||
public int ValidSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("signer_identities")]
|
||||
public IReadOnlyList<string>? SignerIdentities { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate_type")]
|
||||
public string? PredicateType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content hash verification details.
|
||||
/// </summary>
|
||||
public sealed record WitnessContentHashInfo
|
||||
{
|
||||
[JsonPropertyName("expected")]
|
||||
public string? Expected { get; init; }
|
||||
|
||||
[JsonPropertyName("actual")]
|
||||
public string? Actual { get; init; }
|
||||
|
||||
[JsonPropertyName("match")]
|
||||
public bool Match { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export format for witnesses.
|
||||
/// </summary>
|
||||
public enum WitnessExportFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw JSON witness payload.
|
||||
/// </summary>
|
||||
Json,
|
||||
|
||||
/// <summary>
|
||||
/// DSSE-signed envelope.
|
||||
/// </summary>
|
||||
Dsse,
|
||||
|
||||
/// <summary>
|
||||
/// SARIF format.
|
||||
/// </summary>
|
||||
Sarif
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// <copyright file="ConfigCommandTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-014)
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class ConfigCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigCatalog_GetAll_ReturnsNonEmptyList()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(entries);
|
||||
Assert.True(entries.Count > 50, "Expected at least 50 config entries");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_GetAll_EntriesHaveRequiredProperties()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
|
||||
// Assert
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.Path), "Path should not be empty");
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.SectionName), "SectionName should not be empty");
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.Category), "Category should not be empty");
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.Description), "Description should not be empty");
|
||||
Assert.NotNull(entry.Aliases);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_GetAll_PathsAreLowerCase()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
|
||||
// Assert - paths should be lowercase for determinism
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Assert.Equal(entry.Path.ToLowerInvariant(), entry.Path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_GetAll_NoDuplicatePaths()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
var paths = entries.Select(e => e.Path).ToList();
|
||||
|
||||
// Assert
|
||||
var duplicates = paths.GroupBy(p => p).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
|
||||
Assert.Empty(duplicates);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("policy.determinization")]
|
||||
[InlineData("pol.det")]
|
||||
[InlineData("determinization")]
|
||||
[InlineData("scanner")]
|
||||
[InlineData("scan")]
|
||||
[InlineData("notifier")]
|
||||
[InlineData("notify")]
|
||||
public void ConfigCatalog_Find_ByPathOrAlias_ReturnsEntry(string pathOrAlias)
|
||||
{
|
||||
// Act
|
||||
var entry = ConfigCatalog.Find(pathOrAlias);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(entry);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("POLICY.DETERMINIZATION")]
|
||||
[InlineData("Policy.Determinization")]
|
||||
[InlineData("POL.DET")]
|
||||
public void ConfigCatalog_Find_IsCaseInsensitive(string pathOrAlias)
|
||||
{
|
||||
// Act
|
||||
var entry = ConfigCatalog.Find(pathOrAlias);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("policy.determinization", entry.Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("policy:determinization")]
|
||||
[InlineData("policy.determinization")]
|
||||
public void ConfigCatalog_Find_TreatsColonAndDotAsEquivalent(string pathOrAlias)
|
||||
{
|
||||
// Act
|
||||
var entry = ConfigCatalog.Find(pathOrAlias);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("policy.determinization", entry.Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nonexistent")]
|
||||
[InlineData("foo.bar.baz")]
|
||||
[InlineData("")]
|
||||
public void ConfigCatalog_Find_UnknownPath_ReturnsNull(string pathOrAlias)
|
||||
{
|
||||
// Act
|
||||
var entry = ConfigCatalog.Find(pathOrAlias);
|
||||
|
||||
// Assert
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_GetCategories_ReturnsExpectedCategories()
|
||||
{
|
||||
// Act
|
||||
var categories = ConfigCatalog.GetCategories();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Policy", categories);
|
||||
Assert.Contains("Scanner", categories);
|
||||
Assert.Contains("Notifier", categories);
|
||||
Assert.Contains("Attestor", categories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_GetCategories_IsSorted()
|
||||
{
|
||||
// Act
|
||||
var categories = ConfigCatalog.GetCategories();
|
||||
|
||||
// Assert
|
||||
var sorted = categories.OrderBy(c => c).ToList();
|
||||
Assert.Equal(sorted, categories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_PolicyDeterminization_HasApiEndpoint()
|
||||
{
|
||||
// Act
|
||||
var entry = ConfigCatalog.Find("policy.determinization");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.ApiEndpoint);
|
||||
Assert.Contains("/api/policy/config/determinization", entry.ApiEndpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_Entries_HaveConsistentCategoryNaming()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
var categories = entries.Select(e => e.Category).Distinct().ToList();
|
||||
|
||||
// Assert - categories should be PascalCase
|
||||
foreach (var category in categories)
|
||||
{
|
||||
Assert.Matches("^[A-Z][a-zA-Z]*$", category);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_AllAliases_AreUnique()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
var allAliases = entries.SelectMany(e => e.Aliases).ToList();
|
||||
|
||||
// Assert - aliases should not collide
|
||||
var duplicates = allAliases
|
||||
.GroupBy(a => a.ToLowerInvariant())
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
Assert.Empty(duplicates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigCatalog_AliasesDontOverlapWithPaths()
|
||||
{
|
||||
// Act
|
||||
var entries = ConfigCatalog.GetAll();
|
||||
var paths = entries.Select(e => e.Path.ToLowerInvariant()).ToHashSet();
|
||||
var aliases = entries.SelectMany(e => e.Aliases.Select(a => a.ToLowerInvariant())).ToList();
|
||||
|
||||
// Assert - aliases should not match any path (to avoid ambiguity)
|
||||
var overlaps = aliases.Where(a => paths.Contains(a)).ToList();
|
||||
Assert.Empty(overlaps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// <copyright file="UnknownsGreyQueueCommandTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_010_CLI_unknowns_grey_queue_cli (CLI-UNK-005)
|
||||
// </copyright>
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class UnknownsGreyQueueCommandTests
|
||||
{
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public UnknownsGreyQueueCommandTests()
|
||||
{
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpHandlerMock.Object)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8080")
|
||||
};
|
||||
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient("PolicyApi"))
|
||||
.Returns(httpClient);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_httpClientFactoryMock.Object);
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
_services = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsSummaryResponse_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"hot": 5,
|
||||
"warm": 10,
|
||||
"cold": 25,
|
||||
"resolved": 100,
|
||||
"total": 140
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var response = JsonSerializer.Deserialize<TestUnknownsSummaryResponse>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(5, response.Hot);
|
||||
Assert.Equal(10, response.Warm);
|
||||
Assert.Equal(25, response.Cold);
|
||||
Assert.Equal(100, response.Resolved);
|
||||
Assert.Equal(140, response.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownDto_WithGreyQueueFields_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"packageId": "pkg:npm/lodash",
|
||||
"packageVersion": "4.17.21",
|
||||
"band": "hot",
|
||||
"score": 85.5,
|
||||
"uncertaintyFactor": 0.7,
|
||||
"exploitPressure": 0.9,
|
||||
"firstSeenAt": "2026-01-10T12:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T08:00:00Z",
|
||||
"reasonCode": "Reachability",
|
||||
"reasonCodeShort": "U-RCH",
|
||||
"fingerprintId": "sha256:abc123",
|
||||
"triggers": [
|
||||
{
|
||||
"eventType": "epss.updated",
|
||||
"eventVersion": 1,
|
||||
"source": "concelier",
|
||||
"receivedAt": "2026-01-15T07:00:00Z",
|
||||
"correlationId": "corr-123"
|
||||
}
|
||||
],
|
||||
"nextActions": ["request_vex", "verify_reachability"],
|
||||
"conflictInfo": {
|
||||
"hasConflict": true,
|
||||
"severity": 0.8,
|
||||
"suggestedPath": "RequireManualReview",
|
||||
"conflicts": [
|
||||
{
|
||||
"signal1": "VEX:not_affected",
|
||||
"signal2": "Reachability:reachable",
|
||||
"type": "VexReachabilityContradiction",
|
||||
"description": "VEX says not affected but reachability shows path",
|
||||
"severity": 0.8
|
||||
}
|
||||
]
|
||||
},
|
||||
"observationState": "Disputed"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var unknown = JsonSerializer.Deserialize<TestUnknownDto>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(unknown);
|
||||
Assert.Equal("pkg:npm/lodash", unknown.PackageId);
|
||||
Assert.Equal("4.17.21", unknown.PackageVersion);
|
||||
Assert.Equal("hot", unknown.Band);
|
||||
Assert.Equal(85.5m, unknown.Score);
|
||||
Assert.Equal("sha256:abc123", unknown.FingerprintId);
|
||||
Assert.NotNull(unknown.Triggers);
|
||||
Assert.Single(unknown.Triggers);
|
||||
Assert.Equal("epss.updated", unknown.Triggers[0].EventType);
|
||||
Assert.Equal(1, unknown.Triggers[0].EventVersion);
|
||||
Assert.NotNull(unknown.NextActions);
|
||||
Assert.Equal(2, unknown.NextActions.Count);
|
||||
Assert.Contains("request_vex", unknown.NextActions);
|
||||
Assert.NotNull(unknown.ConflictInfo);
|
||||
Assert.True(unknown.ConflictInfo.HasConflict);
|
||||
Assert.Equal(0.8, unknown.ConflictInfo.Severity);
|
||||
Assert.Equal("RequireManualReview", unknown.ConflictInfo.SuggestedPath);
|
||||
Assert.Single(unknown.ConflictInfo.Conflicts);
|
||||
Assert.Equal("Disputed", unknown.ObservationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownProof_HasDeterministicStructure()
|
||||
{
|
||||
// Arrange
|
||||
var proof = new TestUnknownProof
|
||||
{
|
||||
Id = Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
|
||||
FingerprintId = "sha256:abc123",
|
||||
PackageId = "pkg:npm/lodash",
|
||||
PackageVersion = "4.17.21",
|
||||
Band = "hot",
|
||||
Score = 85.5m,
|
||||
ReasonCode = "Reachability",
|
||||
Triggers = new List<TestTriggerDto>
|
||||
{
|
||||
new() { EventType = "vex.updated", EventVersion = 1, ReceivedAt = DateTimeOffset.Parse("2026-01-15T08:00:00Z") },
|
||||
new() { EventType = "epss.updated", EventVersion = 1, ReceivedAt = DateTimeOffset.Parse("2026-01-15T07:00:00Z") }
|
||||
},
|
||||
EvidenceRefs = new List<TestEvidenceRefDto>
|
||||
{
|
||||
new() { Type = "sbom", Uri = "oci://registry/sbom@sha256:def" },
|
||||
new() { Type = "attestation", Uri = "oci://registry/att@sha256:ghi" }
|
||||
},
|
||||
ObservationState = "PendingDeterminization"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(proof, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"fingerprintId\"", json.ToLowerInvariant());
|
||||
Assert.Contains("\"triggers\"", json.ToLowerInvariant());
|
||||
Assert.Contains("\"evidencerefs\"", json.ToLowerInvariant());
|
||||
Assert.Contains("\"observationstate\"", json.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("accept-risk")]
|
||||
[InlineData("require-fix")]
|
||||
[InlineData("defer")]
|
||||
[InlineData("escalate")]
|
||||
[InlineData("dispute")]
|
||||
public void TriageAction_ValidActions_AreRecognized(string action)
|
||||
{
|
||||
// Arrange
|
||||
var validActions = new[] { "accept-risk", "require-fix", "defer", "escalate", "dispute" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Contains(action, validActions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("approve")]
|
||||
[InlineData("reject")]
|
||||
[InlineData("")]
|
||||
public void TriageAction_InvalidActions_AreNotRecognized(string action)
|
||||
{
|
||||
// Arrange
|
||||
var validActions = new[] { "accept-risk", "require-fix", "defer", "escalate", "dispute" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.DoesNotContain(action, validActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriageRequest_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TestTriageRequest("accept-risk", "Low priority, mitigated by WAF", 90);
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"action\":\"accept-risk\"", json);
|
||||
Assert.Contains("\"reason\":\"Low priority, mitigated by WAF\"", json);
|
||||
Assert.Contains("\"durationDays\":90", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportFormat_CsvEscaping_HandlesSpecialCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var testCases = new[]
|
||||
{
|
||||
("simple", "simple"),
|
||||
("with,comma", "\"with,comma\""),
|
||||
("with\"quote", "\"with\"\"quote\""),
|
||||
("with\nnewline", "\"with\nnewline\""),
|
||||
("normal-value", "normal-value")
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
foreach (var (input, expected) in testCases)
|
||||
{
|
||||
var result = EscapeCsv(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
{
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Test DTOs matching the CLI internal types
|
||||
private sealed record TestUnknownsSummaryResponse
|
||||
{
|
||||
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 TestUnknownDto
|
||||
{
|
||||
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 ReasonCode { get; init; } = string.Empty;
|
||||
public string ReasonCodeShort { get; init; } = string.Empty;
|
||||
public string? FingerprintId { get; init; }
|
||||
public IReadOnlyList<TestTriggerDto>? Triggers { get; init; }
|
||||
public IReadOnlyList<string>? NextActions { get; init; }
|
||||
public TestConflictInfoDto? ConflictInfo { get; init; }
|
||||
public string? ObservationState { get; init; }
|
||||
}
|
||||
|
||||
private sealed record TestTriggerDto
|
||||
{
|
||||
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 TestConflictInfoDto
|
||||
{
|
||||
public bool HasConflict { get; init; }
|
||||
public double Severity { get; init; }
|
||||
public string SuggestedPath { get; init; } = string.Empty;
|
||||
public IReadOnlyList<TestConflictDetailDto> Conflicts { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed record TestConflictDetailDto
|
||||
{
|
||||
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 TestEvidenceRefDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed record TestUnknownProof
|
||||
{
|
||||
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<TestTriggerDto> Triggers { get; init; } = [];
|
||||
public IReadOnlyList<TestEvidenceRefDto> EvidenceRefs { get; init; } = [];
|
||||
public string? ObservationState { get; init; }
|
||||
public TestConflictInfoDto? ConflictInfo { get; init; }
|
||||
}
|
||||
|
||||
private sealed record TestTriageRequest(string Action, string Reason, int? DurationDays);
|
||||
}
|
||||
244
src/Cli/__Tests/StellaOps.Cli.Tests/OpenPrCommandTests.cs
Normal file
244
src/Cli/__Tests/StellaOps.Cli.Tests/OpenPrCommandTests.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_011_CLI_evidence_card_remediate_cli (REMPR-CLI-003)
|
||||
// Task: REMPR-CLI-003 - CLI tests for open-pr command
|
||||
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using Xunit;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the `stella advise open-pr` command argument validation and structure.
|
||||
/// These tests verify the command structure and argument parsing behavior.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class OpenPrCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldRequirePlanIdArgument()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldAcceptPlanIdArgument()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123");
|
||||
|
||||
// Assert - should have no parse errors
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldHaveScmTypeOption()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
|
||||
// Act - find any option that responds to --scm-type
|
||||
var result = openPrCommand.Parse("plan-abc123 --scm-type gitlab");
|
||||
|
||||
// Assert - should parse without errors
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldDefaultScmTypeToGithub()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
var scmOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--scm-type"));
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123");
|
||||
var scmType = result.GetValue(scmOption);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("github", scmType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldAcceptCustomScmType()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
var scmOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--scm-type"));
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123 --scm-type gitlab");
|
||||
var scmType = result.GetValue(scmOption);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("gitlab", scmType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldAcceptShortScmTypeAlias()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
var scmOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--scm-type"));
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123 -s azure-devops");
|
||||
var scmType = result.GetValue(scmOption);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("azure-devops", scmType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldHaveOutputFormatOption()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
|
||||
// Act - find any option that responds to --output
|
||||
var result = openPrCommand.Parse("plan-abc123 --output json");
|
||||
|
||||
// Assert - should parse without errors
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldDefaultOutputFormatToTable()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
var outputOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--output"));
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123");
|
||||
var outputFormat = result.GetValue(outputOption);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("table", outputFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldAcceptJsonOutputFormat()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
var outputOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--output"));
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123 --output json");
|
||||
var outputFormat = result.GetValue(outputOption);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("json", outputFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldAcceptMarkdownOutputFormat()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
var outputOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--output"));
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123 -o markdown");
|
||||
var outputFormat = result.GetValue(outputOption);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("markdown", outputFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldHaveVerboseOption()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-abc123 --verbose");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenPrCommand_ShouldParseAllOptionsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var openPrCommand = BuildOpenPrCommand();
|
||||
|
||||
// Act
|
||||
var result = openPrCommand.Parse("plan-test-789 --scm-type azure-devops --output json --verbose");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
|
||||
var planIdArg = openPrCommand.Arguments.OfType<Argument<string>>().First(a => a.Name == "plan-id");
|
||||
Assert.NotNull(planIdArg);
|
||||
Assert.Equal("plan-test-789", result.GetValue(planIdArg));
|
||||
|
||||
var scmOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--scm-type"));
|
||||
Assert.NotNull(scmOption);
|
||||
Assert.Equal("azure-devops", result.GetValue(scmOption));
|
||||
|
||||
var outputOption = openPrCommand.Options.OfType<Option<string>>().First(o => o.Aliases.Contains("--output"));
|
||||
Assert.NotNull(outputOption);
|
||||
Assert.Equal("json", result.GetValue(outputOption));
|
||||
|
||||
var verboseOption = openPrCommand.Options.OfType<Option<bool>>().First(o => o.Aliases.Contains("--verbose"));
|
||||
Assert.NotNull(verboseOption);
|
||||
Assert.True(result.GetValue(verboseOption));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the open-pr command structure for testing.
|
||||
/// This mirrors the structure in CommandFactory.BuildOpenPrCommand.
|
||||
/// Note: Defaults are verified through the actual parsing behavior, not Option properties.
|
||||
/// </summary>
|
||||
private static Command BuildOpenPrCommand()
|
||||
{
|
||||
var planIdArg = new Argument<string>("plan-id")
|
||||
{
|
||||
Description = "Remediation plan ID to apply"
|
||||
};
|
||||
|
||||
// Use correct System.CommandLine 2.x constructors
|
||||
var scmTypeOption = new Option<string>("--scm-type", new[] { "-s" })
|
||||
{
|
||||
Description = "SCM type (github, gitlab, azure-devops, gitea)"
|
||||
};
|
||||
scmTypeOption.SetDefaultValue("github");
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json, markdown"
|
||||
};
|
||||
outputOption.SetDefaultValue("table");
|
||||
|
||||
var verboseOption = new Option<bool>("--verbose", new[] { "-v" })
|
||||
{
|
||||
Description = "Enable verbose output"
|
||||
};
|
||||
|
||||
var openPrCommand = new Command("open-pr", "Apply a remediation plan by creating a PR/MR in the target SCM")
|
||||
{
|
||||
planIdArg,
|
||||
scmTypeOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
return openPrCommand;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Spectre.Console.Testing" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user