Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
StellaOps Bot 49922dff5a
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
up the blokcing tasks
2025-12-11 02:32:18 +02:00

30260 lines
1.2 MiB

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Spectre.Console;
using Spectre.Console.Rendering;
using StellaOps.Auth.Client;
using StellaOps.ExportCenter.Client;
using StellaOps.ExportCenter.Client.Models;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Output;
using StellaOps.Cli.Prompts;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Kms;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Policies;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Java;
using StellaOps.Scanner.Analyzers.Lang.Node;
using StellaOps.Scanner.Analyzers.Lang.Python;
using StellaOps.Scanner.Analyzers.Lang.Ruby;
using StellaOps.Scanner.Analyzers.Lang.Php;
using StellaOps.Scanner.Analyzers.Lang.Bun;
using StellaOps.Policy;
using StellaOps.PolicyDsl;
namespace StellaOps.Cli.Commands;
internal static class CommandHandlers
{
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
/// <summary>
/// Standard JSON serializer options for CLI output.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// JSON serializer options for output (alias for JsonOptions).
/// </summary>
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
private static readonly JsonSerializerOptions CompactJson = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
/// <summary>
/// Sets the verbosity level for logging.
/// </summary>
private static void SetVerbosity(IServiceProvider services, bool verbose)
{
// Configure logging level based on verbose flag
var loggerFactory = services.GetService<ILoggerFactory>();
if (loggerFactory is not null && verbose)
{
// Enable debug logging when verbose is true
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
logger.LogDebug("Verbose logging enabled");
}
}
public static async Task HandleCvssScoreAsync(
IServiceProvider services,
string vulnerabilityId,
string policyPath,
string vector,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-score");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
var loader = new CvssPolicyLoader();
var policyResult = loader.Load(policyJson, cancellationToken);
if (!policyResult.IsValid || policyResult.Policy is null || string.IsNullOrWhiteSpace(policyResult.Hash))
{
var errors = string.Join("; ", policyResult.Errors.Select(e => $"{e.Path}: {e.Message}"));
throw new InvalidOperationException($"Policy invalid: {errors}");
}
var policy = policyResult.Policy with { Hash = policyResult.Hash };
var engine = scope.ServiceProvider.GetRequiredService<ICvssV4Engine>();
var parsed = engine.ParseVector(vector);
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var request = new CreateCvssReceipt(
vulnerabilityId,
policy,
parsed.BaseMetrics,
parsed.ThreatMetrics,
parsed.EnvironmentalMetrics,
parsed.SupplementalMetrics,
Array.Empty<CvssEvidenceItem>(),
SigningKey: null,
CreatedBy: "cli",
CreatedAt: DateTimeOffset.UtcNow);
var receipt = await client.CreateReceiptAsync(request, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("CVSS receipt creation failed.");
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"✔ CVSS receipt {receipt.ReceiptId} created | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Vector: {receipt.VectorString}");
Console.WriteLine($"Policy: {receipt.PolicyRef.PolicyId} v{receipt.PolicyRef.Version} ({receipt.PolicyRef.Hash})");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create CVSS receipt");
Environment.ExitCode = 1;
if (json)
{
var problem = new { error = "cvss_score_failed", message = ex.Message };
Console.WriteLine(JsonSerializer.Serialize(problem, CompactJson));
}
}
}
public static async Task HandleCvssShowAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-show");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine(json
? JsonSerializer.Serialize(new { error = "not_found", receiptId }, CompactJson)
: $"✖ Receipt {receiptId} not found");
return;
}
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(receipt, CompactJson));
}
else
{
Console.WriteLine($"Receipt {receipt.ReceiptId} | Severity {receipt.Severity} | Effective {receipt.Scores.EffectiveScore:0.0}");
Console.WriteLine($"Created {receipt.CreatedAt:u} by {receipt.CreatedBy}");
Console.WriteLine($"Vector: {receipt.VectorString}");
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssHistoryAsync(
IServiceProvider services,
string receiptId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-history");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var history = await client.GetHistoryAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (json)
{
Console.WriteLine(JsonSerializer.Serialize(history, CompactJson));
}
else
{
if (history.Count == 0)
{
Console.WriteLine("(no history)");
}
else
{
foreach (var entry in history.OrderBy(h => h.Timestamp))
{
Console.WriteLine($"{entry.Timestamp:u} | {entry.Actor} | {entry.ChangeType} {entry.Field} => {entry.NewValue ?? ""} ({entry.Reason})");
}
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch CVSS receipt history {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
public static async Task HandleCvssExportAsync(
IServiceProvider services,
string receiptId,
string format,
string? output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("cvss-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var client = scope.ServiceProvider.GetRequiredService<ICvssClient>();
var receipt = await client.GetReceiptAsync(receiptId, cancellationToken).ConfigureAwait(false);
if (receipt is null)
{
Environment.ExitCode = 5;
Console.WriteLine($"✖ Receipt {receiptId} not found");
return;
}
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = 9;
Console.WriteLine("Only json export is supported at this time.");
return;
}
var targetPath = string.IsNullOrWhiteSpace(output)
? $"cvss-receipt-{receipt.ReceiptId}.json"
: output!;
var jsonPayload = JsonSerializer.Serialize(receipt, CompactJson);
await File.WriteAllTextAsync(targetPath, jsonPayload, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"✔ Exported receipt to {targetPath}");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export CVSS receipt {ReceiptId}", receiptId);
Environment.ExitCode = 1;
}
}
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "scanner download");
activity?.SetTag("stellaops.cli.channel", channel);
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
try
{
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
if (result.FromCache)
{
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
}
else
{
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
}
CliMetrics.RecordScannerDownload(channel, result.FromCache);
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleTaskRunnerSimulateAsync(
IServiceProvider services,
string manifestPath,
string? inputsPath,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("task-runner-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.taskrunner.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "task-runner simulate");
using var duration = CliMetrics.MeasureCommandDuration("task-runner simulate");
try
{
if (string.IsNullOrWhiteSpace(manifestPath))
{
throw new ArgumentException("Manifest path must be provided.", nameof(manifestPath));
}
var manifestFullPath = Path.GetFullPath(manifestPath);
if (!File.Exists(manifestFullPath))
{
throw new FileNotFoundException("Manifest file not found.", manifestFullPath);
}
activity?.SetTag("stellaops.cli.manifest_path", manifestFullPath);
var manifest = await File.ReadAllTextAsync(manifestFullPath, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(manifest))
{
throw new InvalidOperationException("Manifest file was empty.");
}
JsonObject? inputsObject = null;
if (!string.IsNullOrWhiteSpace(inputsPath))
{
var inputsFullPath = Path.GetFullPath(inputsPath!);
if (!File.Exists(inputsFullPath))
{
throw new FileNotFoundException("Inputs file not found.", inputsFullPath);
}
await using var stream = File.OpenRead(inputsFullPath);
var parsed = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (parsed is JsonObject obj)
{
inputsObject = obj;
}
else
{
throw new InvalidOperationException("Simulation inputs must be a JSON object.");
}
activity?.SetTag("stellaops.cli.inputs_path", inputsFullPath);
}
var request = new TaskRunnerSimulationRequest(manifest, inputsObject);
var result = await client.SimulateTaskRunnerAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.plan_hash", result.PlanHash);
activity?.SetTag("stellaops.cli.pending_approvals", result.HasPendingApprovals);
activity?.SetTag("stellaops.cli.step_count", result.Steps.Count);
var outputFormat = DetermineTaskRunnerSimulationFormat(format, outputPath);
var payload = BuildTaskRunnerSimulationPayload(result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteSimulationOutputAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Simulation payload written to {Path}.", Path.GetFullPath(outputPath!));
}
if (outputFormat == TaskRunnerSimulationOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderTaskRunnerSimulationResult(result);
}
var outcome = result.HasPendingApprovals ? "pending-approvals" : "ok";
CliMetrics.RecordTaskRunnerSimulation(outcome);
Environment.ExitCode = 0;
}
catch (FileNotFoundException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 66;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 64;
}
catch (InvalidOperationException ex)
{
logger.LogError(ex, "Task Runner simulation failed.");
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Task Runner simulation failed.");
CliMetrics.RecordTaskRunnerSimulation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderEntryTrace(EntryTraceResponseModel result, bool includeNdjson)
{
var console = AnsiConsole.Console;
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
console.MarkupLine($"Image: {result.ImageDigest}");
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
if (result.BestPlan is not null)
{
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
}
var planTable = new Table()
.AddColumn("Terminal")
.AddColumn("Runtime")
.AddColumn("Type")
.AddColumn("Confidence")
.AddColumn("User")
.AddColumn("Workdir");
foreach (var plan in result.Graph.Plans.OrderByDescending(p => p.Confidence))
{
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
confidence,
plan.User,
plan.WorkingDirectory);
}
if (planTable.Rows.Count > 0)
{
console.Write(planTable);
}
else
{
console.MarkupLine("[italic]No entry trace plans recorded.[/]");
}
if (result.Graph.Diagnostics.Length > 0)
{
var diagTable = new Table()
.AddColumn("Severity")
.AddColumn("Reason")
.AddColumn("Message");
foreach (var diagnostic in result.Graph.Diagnostics)
{
diagTable.AddRow(
diagnostic.Severity.ToString(),
diagnostic.Reason.ToString(),
diagnostic.Message);
}
console.Write(diagTable);
}
if (includeNdjson && result.Ndjson.Count > 0)
{
console.MarkupLine("[bold]NDJSON Output[/]");
foreach (var line in result.Ndjson)
{
console.WriteLine(line);
}
}
}
public static async Task HandleScannerRunAsync(
IServiceProvider services,
string runner,
string entry,
string targetDirectory,
IReadOnlyList<string> arguments,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
activity?.SetTag("stellaops.cli.command", "scan run");
activity?.SetTag("stellaops.cli.runner", runner);
activity?.SetTag("stellaops.cli.entry", entry);
activity?.SetTag("stellaops.cli.target", targetDirectory);
using var duration = CliMetrics.MeasureCommandDuration("scan run");
try
{
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var resultsDirectory = options.ResultsDirectory;
var executionResult = await executor.RunAsync(
runner,
entry,
targetDirectory,
resultsDirectory,
arguments,
verbose,
cancellationToken).ConfigureAwait(false);
Environment.ExitCode = executionResult.ExitCode;
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
if (executionResult.ExitCode == 0)
{
var backend = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
logger.LogInformation("Uploading scan artefact {Path}...", executionResult.ResultsPath);
await backend.UploadScanResultsAsync(executionResult.ResultsPath, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Scan artefact uploaded.");
activity?.SetTag("stellaops.cli.results", executionResult.ResultsPath);
}
else
{
logger.LogWarning("Skipping automatic upload because scan exited with code {Code}.", executionResult.ExitCode);
}
logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
}
catch (Exception ex)
{
logger.LogError(ex, "Scanner execution failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleScanUploadAsync(
IServiceProvider services,
string file,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "scan upload");
activity?.SetTag("stellaops.cli.file", file);
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
try
{
var pathFull = Path.GetFullPath(file);
await client.UploadScanResultsAsync(pathFull, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Scan results uploaded successfully.");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to upload scan results.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleScanEntryTraceAsync(
IServiceProvider services,
string scanId,
bool includeNdjson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scan-entrytrace");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.entrytrace", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "scan entrytrace");
activity?.SetTag("stellaops.cli.scan_id", scanId);
using var duration = CliMetrics.MeasureCommandDuration("scan entrytrace");
try
{
var result = await client.GetEntryTraceAsync(scanId, cancellationToken).ConfigureAwait(false);
if (result is null)
{
logger.LogWarning("No EntryTrace data available for scan {ScanId}.", scanId);
var console = AnsiConsole.Console;
console.MarkupLine("[yellow]No EntryTrace data available for scan {0}.[/]", Markup.Escape(scanId));
console.Write(new Text($"No EntryTrace data available for scan {scanId}.{Environment.NewLine}"));
Console.WriteLine($"No EntryTrace data available for scan {scanId}.");
Environment.ExitCode = 1;
return;
}
RenderEntryTrace(result, includeNdjson);
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch EntryTrace for scan {ScanId}.", scanId);
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleAdviseRunAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
string advisoryKey,
string? artifactId,
string? artifactPurl,
string? policyVersion,
string profile,
IReadOnlyList<string> preferredSections,
bool forceRefresh,
int timeoutSeconds,
AdvisoryOutputFormat outputFormat,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advise-run");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.run", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "advise run");
activity?.SetTag("stellaops.cli.task", taskType.ToString());
using var duration = CliMetrics.MeasureCommandDuration("advisory run");
activity?.SetTag("stellaops.cli.force_refresh", forceRefresh);
var outcome = "error";
try
{
var normalizedKey = advisoryKey?.Trim();
if (string.IsNullOrWhiteSpace(normalizedKey))
{
throw new ArgumentException("Advisory key is required.", nameof(advisoryKey));
}
activity?.SetTag("stellaops.cli.advisory.key", normalizedKey);
var normalizedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
activity?.SetTag("stellaops.cli.profile", normalizedProfile);
var normalizedSections = NormalizeSections(preferredSections);
var request = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = normalizedKey,
ArtifactId = string.IsNullOrWhiteSpace(artifactId) ? null : artifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(artifactPurl) ? null : artifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion!.Trim(),
Profile = normalizedProfile,
PreferredSections = normalizedSections.Length > 0 ? normalizedSections : null,
ForceRefresh = forceRefresh
};
logger.LogInformation("Requesting advisory plan for {TaskType} (advisory={AdvisoryKey}).", taskType, normalizedKey);
var plan = await client.CreateAdvisoryPipelinePlanAsync(taskType, request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.advisory.cache_key", plan.CacheKey);
RenderAdvisoryPlan(plan);
logger.LogInformation("Plan {CacheKey} queued with {Chunks} chunks and {Vectors} vectors.",
plan.CacheKey,
plan.Chunks.Count,
plan.Vectors.Count);
var pollDelay = TimeSpan.FromSeconds(1);
var shouldWait = timeoutSeconds > 0;
var deadline = shouldWait ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(timeoutSeconds) : DateTimeOffset.UtcNow;
AdvisoryPipelineOutputModel? output = null;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
output = await client
.TryGetAdvisoryPipelineOutputAsync(plan.CacheKey, taskType, normalizedProfile, cancellationToken)
.ConfigureAwait(false);
if (output is not null)
{
break;
}
if (!shouldWait || DateTimeOffset.UtcNow >= deadline)
{
break;
}
logger.LogDebug("Advisory output pending for {CacheKey}; retrying in {DelaySeconds}s.", plan.CacheKey, pollDelay.TotalSeconds);
await Task.Delay(pollDelay, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
logger.LogError("Timed out after {Timeout}s waiting for advisory output (cache key {CacheKey}).",
Math.Max(timeoutSeconds, 0),
plan.CacheKey);
activity?.SetStatus(ActivityStatusCode.Error, "timeout");
outcome = "timeout";
Environment.ExitCode = Environment.ExitCode == 0 ? 70 : Environment.ExitCode;
return;
}
activity?.SetTag("stellaops.cli.advisory.generated_at", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
activity?.SetTag("stellaops.cli.advisory.cache_hit", output.PlanFromCache);
logger.LogInformation("Advisory output ready (cache key {CacheKey}).", output.CacheKey);
var rendered = RenderAdvisoryOutput(output, outputFormat);
if (!string.IsNullOrWhiteSpace(outputPath) && rendered is not null)
{
var fullPath = Path.GetFullPath(outputPath!);
await File.WriteAllTextAsync(fullPath, rendered, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Advisory output written to {Path}.", fullPath);
}
if (rendered is not null)
{
// Surface the rendered advisory to the active console so users (and tests) can see it even when also writing to disk.
AnsiConsole.Console.WriteLine(rendered);
}
if (output.Guardrail.Blocked)
{
logger.LogError("Guardrail blocked advisory output (cache key {CacheKey}).", output.CacheKey);
activity?.SetStatus(ActivityStatusCode.Error, "guardrail_blocked");
outcome = "blocked";
Environment.ExitCode = Environment.ExitCode == 0 ? 65 : Environment.ExitCode;
return;
}
activity?.SetStatus(ActivityStatusCode.Ok);
outcome = output.PlanFromCache ? "cache-hit" : "ok";
Environment.ExitCode = 0;
}
catch (OperationCanceledException)
{
outcome = "cancelled";
activity?.SetStatus(ActivityStatusCode.Error, "cancelled");
Environment.ExitCode = Environment.ExitCode == 0 ? 130 : Environment.ExitCode;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
logger.LogError(ex, "Failed to run advisory task.");
outcome = "error";
Environment.ExitCode = Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
}
finally
{
activity?.SetTag("stellaops.cli.advisory.outcome", outcome);
CliMetrics.RecordAdvisoryRun(taskType.ToString(), outcome);
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleAdviseBatchAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
IReadOnlyList<string> advisoryKeys,
string? artifactId,
string? artifactPurl,
string? policyVersion,
string profile,
IReadOnlyList<string> preferredSections,
bool forceRefresh,
int timeoutSeconds,
AdvisoryOutputFormat outputFormat,
string? outputDirectory,
bool verbose,
CancellationToken cancellationToken)
{
if (advisoryKeys.Count == 0)
{
throw new ArgumentException("At least one advisory key is required.", nameof(advisoryKeys));
}
var outputDir = string.IsNullOrWhiteSpace(outputDirectory) ? null : Path.GetFullPath(outputDirectory!);
if (outputDir is not null)
{
Directory.CreateDirectory(outputDir);
}
var results = new List<(string Advisory, int ExitCode)>();
var overallExit = 0;
foreach (var key in advisoryKeys)
{
var sanitized = string.IsNullOrWhiteSpace(key) ? "unknown" : key.Trim();
var ext = outputFormat switch
{
AdvisoryOutputFormat.Json => ".json",
AdvisoryOutputFormat.Markdown => ".md",
_ => ".txt"
};
var outputPath = outputDir is null ? null : Path.Combine(outputDir, $"{SanitizeFileName(sanitized)}-{taskType.ToString().ToLowerInvariant()}{ext}");
Environment.ExitCode = 0; // reset per advisory to capture individual result
await HandleAdviseRunAsync(
services,
taskType,
sanitized,
artifactId,
artifactPurl,
policyVersion,
profile,
preferredSections,
forceRefresh,
timeoutSeconds,
outputFormat,
outputPath,
verbose,
cancellationToken);
var code = Environment.ExitCode;
results.Add((sanitized, code));
overallExit = overallExit == 0 ? code : overallExit; // retain first non-zero if any
}
if (results.Count > 1)
{
var table = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Advisory Batch[/]");
table.AddColumn("Advisory");
table.AddColumn("Task");
table.AddColumn("Exit Code");
foreach (var result in results)
{
var exitText = result.ExitCode == 0 ? "[green]0[/]" : $"[red]{result.ExitCode}[/]";
table.AddRow(Markup.Escape(result.Advisory), taskType.ToString(), exitText);
}
AnsiConsole.Console.Write(table);
}
Environment.ExitCode = overallExit;
}
public static async Task HandleSourcesIngestAsync(
IServiceProvider services,
bool dryRun,
string source,
string input,
string? tenantOverride,
string format,
bool disableColor,
string? output,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("sources-ingest");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.sources.ingest.dry_run", ActivityKind.Client);
var statusMetric = "unknown";
using var duration = CliMetrics.MeasureCommandDuration("sources ingest dry-run");
try
{
if (!dryRun)
{
statusMetric = "unsupported";
logger.LogError("Only --dry-run mode is supported for 'stella sources ingest' at this time.");
Environment.ExitCode = 1;
return;
}
source = source?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(source))
{
throw new InvalidOperationException("Source identifier must be provided.");
}
var formatNormalized = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (formatNormalized is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var tenant = ResolveTenant(tenantOverride);
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided via --tenant or STELLA_TENANT.");
}
var payload = await LoadIngestInputAsync(services, input, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Executing ingestion dry-run for source {Source} using input {Input}.", source, payload.Name);
activity?.SetTag("stellaops.cli.command", "sources ingest dry-run");
activity?.SetTag("stellaops.cli.source", source);
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.format", formatNormalized);
activity?.SetTag("stellaops.cli.input_kind", payload.Kind);
var request = new AocIngestDryRunRequest
{
Tenant = tenant,
Source = source,
Document = new AocIngestDryRunDocument
{
Name = payload.Name,
Content = payload.Content,
ContentType = payload.ContentType,
ContentEncoding = payload.ContentEncoding
}
};
var response = await client.ExecuteAocIngestDryRunAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.status", response.Status ?? "unknown");
if (!string.IsNullOrWhiteSpace(output))
{
var reportPath = await WriteJsonReportAsync(response, output, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Dry-run report written to {Path}.", reportPath);
}
if (formatNormalized == "json")
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
}
else
{
RenderDryRunTable(response, !disableColor);
}
var exitCode = DetermineDryRunExitCode(response);
Environment.ExitCode = exitCode;
statusMetric = exitCode == 0 ? "ok" : "violation";
activity?.SetTag("stellaops.cli.exit_code", exitCode);
}
catch (Exception ex)
{
statusMetric = "transport_error";
logger.LogError(ex, "Dry-run ingestion failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordSourcesDryRun(statusMetric);
}
}
public static async Task HandleAocVerifyAsync(
IServiceProvider services,
string? sinceOption,
int? limitOption,
string? sourcesOption,
string? codesOption,
string format,
string? exportPath,
string? tenantOverride,
bool disableColor,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("aoc-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.aoc.verify", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("aoc verify");
var outcome = "unknown";
try
{
var tenant = ResolveTenant(tenantOverride);
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided via --tenant or STELLA_TENANT.");
}
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var since = DetermineVerificationSince(sinceOption);
var sinceIso = since.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
var limit = NormalizeLimit(limitOption);
var sources = ParseCommaSeparatedList(sourcesOption);
var codes = ParseCommaSeparatedList(codesOption);
var normalizedSources = sources.Count == 0
? Array.Empty<string>()
: sources.Select(item => item.ToLowerInvariant()).ToArray();
var normalizedCodes = codes.Count == 0
? Array.Empty<string>()
: codes.Select(item => item.ToUpperInvariant()).ToArray();
activity?.SetTag("stellaops.cli.command", "aoc verify");
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.since", sinceIso);
activity?.SetTag("stellaops.cli.limit", limit);
activity?.SetTag("stellaops.cli.format", normalizedFormat);
if (normalizedSources.Length > 0)
{
activity?.SetTag("stellaops.cli.sources", string.Join(",", normalizedSources));
}
if (normalizedCodes.Length > 0)
{
activity?.SetTag("stellaops.cli.codes", string.Join(",", normalizedCodes));
}
var request = new AocVerifyRequest
{
Tenant = tenant,
Since = sinceIso,
Limit = limit,
Sources = normalizedSources.Length == 0 ? null : normalizedSources,
Codes = normalizedCodes.Length == 0 ? null : normalizedCodes
};
var response = await client.ExecuteAocVerifyAsync(request, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(exportPath))
{
var reportPath = await WriteJsonReportAsync(response, exportPath, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Verification report written to {Path}.", reportPath);
}
if (normalizedFormat == "json")
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
}
else
{
RenderAocVerifyTable(response, !disableColor, limit);
}
var exitCode = DetermineVerifyExitCode(response);
Environment.ExitCode = exitCode;
activity?.SetTag("stellaops.cli.exit_code", exitCode);
outcome = exitCode switch
{
0 => "ok",
>= 11 and <= 17 => "violations",
18 => "truncated",
_ => "unknown"
};
}
catch (InvalidOperationException ex)
{
outcome = "usage_error";
logger.LogError(ex, "Verification failed: {Message}", ex.Message);
Console.Error.WriteLine(ex.Message);
Environment.ExitCode = 71;
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
}
catch (Exception ex)
{
outcome = "transport_error";
logger.LogError(ex, "Verification request failed.");
Console.Error.WriteLine(ex.Message);
Environment.ExitCode = 70;
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordAocVerify(outcome);
}
}
public static async Task HandleConnectorJobAsync(
IServiceProvider services,
string source,
string stage,
string? mode,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "db fetch");
activity?.SetTag("stellaops.cli.source", source);
activity?.SetTag("stellaops.cli.stage", stage);
if (!string.IsNullOrWhiteSpace(mode))
{
activity?.SetTag("stellaops.cli.mode", mode);
}
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
try
{
var jobKind = $"source:{source}:{stage}";
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(mode))
{
parameters["mode"] = mode;
}
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Connector job failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleMergeJobAsync(
IServiceProvider services,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "db merge");
using var duration = CliMetrics.MeasureCommandDuration("db merge");
try
{
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Merge job failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleExportJobAsync(
IServiceProvider services,
string format,
bool delta,
bool? publishFull,
bool? publishDelta,
bool? includeFull,
bool? includeDelta,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "db export");
activity?.SetTag("stellaops.cli.format", format);
activity?.SetTag("stellaops.cli.delta", delta);
using var duration = CliMetrics.MeasureCommandDuration("db export");
activity?.SetTag("stellaops.cli.publish_full", publishFull);
activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
activity?.SetTag("stellaops.cli.include_full", includeFull);
activity?.SetTag("stellaops.cli.include_delta", includeDelta);
try
{
var jobKind = format switch
{
"trivy-db" or "trivy" => "export:trivy-db",
_ => "export:json"
};
var isTrivy = jobKind == "export:trivy-db";
if (isTrivy
&& !publishFull.HasValue
&& !publishDelta.HasValue
&& !includeFull.HasValue
&& !includeDelta.HasValue
&& AnsiConsole.Profile.Capabilities.Interactive)
{
var overrides = TrivyDbExportPrompt.PromptOverrides();
publishFull = overrides.publishFull;
publishDelta = overrides.publishDelta;
includeFull = overrides.includeFull;
includeDelta = overrides.includeDelta;
}
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["delta"] = delta
};
if (publishFull.HasValue)
{
parameters["publishFull"] = publishFull.Value;
}
if (publishDelta.HasValue)
{
parameters["publishDelta"] = publishDelta.Value;
}
if (includeFull.HasValue)
{
parameters["includeFull"] = includeFull.Value;
}
if (includeDelta.HasValue)
{
parameters["includeDelta"] = includeDelta.Value;
}
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Export job failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static Task HandleExcititorInitAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
bool resume,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (resume)
{
payload["resume"] = true;
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor init",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["resume"] = resume
},
client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorPullAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
DateTimeOffset? since,
TimeSpan? window,
bool force,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (since.HasValue)
{
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
if (window.HasValue)
{
payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture);
}
if (force)
{
payload["force"] = true;
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor pull",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["force"] = force,
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["window"] = window?.ToString("c", CultureInfo.InvariantCulture)
},
client => client.ExecuteExcititorOperationAsync("ingest/run", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorResumeAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
string? checkpoint,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (!string.IsNullOrWhiteSpace(checkpoint))
{
payload["checkpoint"] = checkpoint.Trim();
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor resume",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["checkpoint"] = checkpoint
},
client => client.ExecuteExcititorOperationAsync("ingest/resume", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static async Task HandleExcititorListProvidersAsync(
IServiceProvider services,
bool includeDisabled,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-list-providers");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.list-providers", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "excititor list-providers");
activity?.SetTag("stellaops.cli.include_disabled", includeDisabled);
using var duration = CliMetrics.MeasureCommandDuration("excititor list-providers");
try
{
var providers = await client.GetExcititorProvidersAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
Environment.ExitCode = 0;
logger.LogInformation("Providers returned: {Count}", providers.Count);
if (providers.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested");
foreach (var provider in providers)
{
table.AddRow(
provider.Id,
provider.Kind,
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
provider.Enabled ? "yes" : "no",
provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown");
}
AnsiConsole.Write(table);
}
else
{
foreach (var provider in providers)
{
logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}",
provider.Id,
provider.Kind,
provider.Enabled ? "yes" : "no",
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown");
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list Excititor providers.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleExcititorExportAsync(
IServiceProvider services,
string format,
bool delta,
string? scope,
DateTimeOffset? since,
string? provider,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scopeHandle = services.CreateAsyncScope();
var client = scopeHandle.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scopeHandle.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-export");
var options = scopeHandle.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var verbosity = scopeHandle.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "excititor export");
activity?.SetTag("stellaops.cli.format", format);
activity?.SetTag("stellaops.cli.delta", delta);
if (!string.IsNullOrWhiteSpace(scope))
{
activity?.SetTag("stellaops.cli.scope", scope);
}
if (since.HasValue)
{
activity?.SetTag("stellaops.cli.since", since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(provider))
{
activity?.SetTag("stellaops.cli.provider", provider);
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
activity?.SetTag("stellaops.cli.output", outputPath);
}
using var duration = CliMetrics.MeasureCommandDuration("excititor export");
try
{
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
["delta"] = delta
};
if (!string.IsNullOrWhiteSpace(scope))
{
payload["scope"] = scope.Trim();
}
if (since.HasValue)
{
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(provider))
{
payload["provider"] = provider.Trim();
}
var result = await client.ExecuteExcititorOperationAsync(
"export",
HttpMethod.Post,
RemoveNullValues(payload),
cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Excititor export failed." : result.Message);
Environment.ExitCode = 1;
return;
}
Environment.ExitCode = 0;
var manifest = TryParseExportManifest(result.Payload);
if (!string.IsNullOrWhiteSpace(result.Message)
&& (manifest is null || !string.Equals(result.Message, "ok", StringComparison.OrdinalIgnoreCase)))
{
logger.LogInformation(result.Message);
}
if (manifest is not null)
{
activity?.SetTag("stellaops.cli.export_id", manifest.ExportId);
if (!string.IsNullOrWhiteSpace(manifest.Format))
{
activity?.SetTag("stellaops.cli.export_format", manifest.Format);
}
if (manifest.FromCache.HasValue)
{
activity?.SetTag("stellaops.cli.export_cached", manifest.FromCache.Value);
}
if (manifest.SizeBytes.HasValue)
{
activity?.SetTag("stellaops.cli.export_size", manifest.SizeBytes.Value);
}
if (manifest.FromCache == true)
{
logger.LogInformation("Reusing cached export {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown");
}
else
{
logger.LogInformation("Export ready: {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown");
}
if (manifest.CreatedAt.HasValue)
{
logger.LogInformation("Created at {CreatedAt}.", manifest.CreatedAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(manifest.Digest))
{
var digestDisplay = BuildDigestDisplay(manifest.Algorithm, manifest.Digest);
if (manifest.SizeBytes.HasValue)
{
logger.LogInformation("Digest {Digest} ({Size}).", digestDisplay, FormatSize(manifest.SizeBytes.Value));
}
else
{
logger.LogInformation("Digest {Digest}.", digestDisplay);
}
}
if (!string.IsNullOrWhiteSpace(manifest.RekorLocation))
{
if (!string.IsNullOrWhiteSpace(manifest.RekorIndex))
{
logger.LogInformation("Rekor entry: {Location} (index {Index}).", manifest.RekorLocation, manifest.RekorIndex);
}
else
{
logger.LogInformation("Rekor entry: {Location}.", manifest.RekorLocation);
}
}
if (!string.IsNullOrWhiteSpace(manifest.RekorInclusionUrl)
&& !string.Equals(manifest.RekorInclusionUrl, manifest.RekorLocation, StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Rekor inclusion proof: {Url}.", manifest.RekorInclusionUrl);
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
var resolvedPath = ResolveExportOutputPath(outputPath!, manifest);
var download = await client.DownloadExcititorExportAsync(
manifest.ExportId,
resolvedPath,
manifest.Algorithm,
manifest.Digest,
cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.export_path", download.Path);
if (download.FromCache)
{
logger.LogInformation("Export already cached at {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes));
}
else
{
logger.LogInformation("Export saved to {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes));
}
}
else if (!string.IsNullOrWhiteSpace(result.Location))
{
var downloadUrl = ResolveLocationUrl(options, result.Location);
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
logger.LogInformation("Download URL: {Url}", downloadUrl);
}
else
{
logger.LogInformation("Download location: {Location}", result.Location);
}
}
}
else
{
if (!string.IsNullOrWhiteSpace(result.Location))
{
var downloadUrl = ResolveLocationUrl(options, result.Location);
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
logger.LogInformation("Download URL: {Url}", downloadUrl);
}
else
{
logger.LogInformation("Location: {Location}", result.Location);
}
}
else if (string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation("Export request accepted.");
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Excititor export failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static Task HandleExcititorBackfillStatementsAsync(
IServiceProvider services,
DateTimeOffset? retrievedSince,
bool force,
int batchSize,
int? maxDocuments,
bool verbose,
CancellationToken cancellationToken)
{
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero.");
}
if (maxDocuments.HasValue && maxDocuments.Value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maxDocuments), "Max documents must be greater than zero when specified.");
}
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["force"] = force,
["batchSize"] = batchSize,
["maxDocuments"] = maxDocuments
};
if (retrievedSince.HasValue)
{
payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
var activityTags = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["stellaops.cli.force"] = force,
["stellaops.cli.batch_size"] = batchSize,
["stellaops.cli.max_documents"] = maxDocuments
};
if (retrievedSince.HasValue)
{
activityTags["stellaops.cli.retrieved_since"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor backfill-statements",
verbose,
activityTags,
client => client.ExecuteExcititorOperationAsync(
"admin/backfill-statements",
HttpMethod.Post,
RemoveNullValues(payload),
cancellationToken),
cancellationToken);
}
public static Task HandleExcititorVerifyAsync(
IServiceProvider services,
string? exportId,
string? digest,
string? attestationPath,
bool verbose,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exportId) && string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(attestationPath))
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify");
logger.LogError("At least one of --export-id, --digest, or --attestation must be provided.");
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(exportId))
{
payload["exportId"] = exportId.Trim();
}
if (!string.IsNullOrWhiteSpace(digest))
{
payload["digest"] = digest.Trim();
}
if (!string.IsNullOrWhiteSpace(attestationPath))
{
var fullPath = Path.GetFullPath(attestationPath);
if (!File.Exists(fullPath))
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify");
logger.LogError("Attestation file not found at {Path}.", fullPath);
Environment.ExitCode = 1;
return Task.CompletedTask;
}
var bytes = File.ReadAllBytes(fullPath);
payload["attestation"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["fileName"] = Path.GetFileName(fullPath),
["base64"] = Convert.ToBase64String(bytes)
};
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor verify",
verbose,
new Dictionary<string, object?>
{
["export_id"] = exportId,
["digest"] = digest,
["attestation_path"] = attestationPath
},
client => client.ExecuteExcititorOperationAsync("verify", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static Task HandleExcititorReconcileAsync(
IServiceProvider services,
IReadOnlyList<string> providers,
TimeSpan? maxAge,
bool verbose,
CancellationToken cancellationToken)
{
var normalizedProviders = NormalizeProviders(providers);
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (normalizedProviders.Count > 0)
{
payload["providers"] = normalizedProviders;
}
if (maxAge.HasValue)
{
payload["maxAge"] = maxAge.Value.ToString("c", CultureInfo.InvariantCulture);
}
return ExecuteExcititorCommandAsync(
services,
commandName: "excititor reconcile",
verbose,
new Dictionary<string, object?>
{
["providers"] = normalizedProviders.Count,
["max_age"] = maxAge?.ToString("c", CultureInfo.InvariantCulture)
},
client => client.ExecuteExcititorOperationAsync("reconcile", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
cancellationToken);
}
public static async Task HandleRuntimePolicyTestAsync(
IServiceProvider services,
string? namespaceValue,
IReadOnlyList<string> imageArguments,
string? filePath,
IReadOnlyList<string> labelArguments,
bool outputJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("runtime-policy-test");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.runtime.policy.test", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "runtime policy test");
if (!string.IsNullOrWhiteSpace(namespaceValue))
{
activity?.SetTag("stellaops.cli.namespace", namespaceValue);
}
using var duration = CliMetrics.MeasureCommandDuration("runtime policy test");
try
{
IReadOnlyList<string> images;
try
{
images = await GatherImageDigestsAsync(imageArguments, filePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or FileNotFoundException)
{
logger.LogError(ex, "Failed to gather image digests: {Message}", ex.Message);
Environment.ExitCode = 9;
return;
}
if (images.Count == 0)
{
logger.LogError("No image digests provided. Use --image, --file, or pipe digests via stdin.");
Environment.ExitCode = 9;
return;
}
IReadOnlyDictionary<string, string> labels;
try
{
labels = ParseLabelSelectors(labelArguments);
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
Environment.ExitCode = 9;
return;
}
activity?.SetTag("stellaops.cli.images", images.Count);
activity?.SetTag("stellaops.cli.labels", labels.Count);
var request = new RuntimePolicyEvaluationRequest(namespaceValue, labels, images);
var result = await client.EvaluateRuntimePolicyAsync(request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.ttl_seconds", result.TtlSeconds);
Environment.ExitCode = 0;
if (outputJson)
{
var json = BuildRuntimePolicyJson(result, images);
Console.WriteLine(json);
return;
}
if (result.ExpiresAtUtc.HasValue)
{
logger.LogInformation("Decision TTL: {TtlSeconds}s (expires {ExpiresAt})", result.TtlSeconds, result.ExpiresAtUtc.Value.ToString("u", CultureInfo.InvariantCulture));
}
else
{
logger.LogInformation("Decision TTL: {TtlSeconds}s", result.TtlSeconds);
}
if (!string.IsNullOrWhiteSpace(result.PolicyRevision))
{
logger.LogInformation("Policy revision: {Revision}", result.PolicyRevision);
}
DisplayRuntimePolicyResults(logger, result, images);
}
catch (Exception ex)
{
logger.LogError(ex, "Runtime policy evaluation failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleAuthLoginAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
bool force,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-login");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
if (tokenClient is null)
{
logger.LogError("Authority client is not available. Ensure AddStellaOpsAuthClient is registered in Program.cs.");
Environment.ExitCode = 1;
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogError("Authority configuration is incomplete; unable to determine cache key.");
Environment.ExitCode = 1;
return;
}
try
{
if (force)
{
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
var scopeName = AuthorityTokenUtilities.ResolveScope(options);
StellaOpsTokenResult token;
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
logger.LogError("Authority password must be provided when username is configured.");
Environment.ExitCode = 1;
return;
}
token = await tokenClient.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scopeName,
null,
cancellationToken).ConfigureAwait(false);
}
else
{
token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, null, cancellationToken).ConfigureAwait(false);
}
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
if (verbose)
{
logger.LogInformation("Authenticated with {Authority} (scopes: {Scopes}).", options.Authority.Url, string.Join(", ", token.Scopes));
}
logger.LogInformation("Login successful. Access token expires at {Expires}.", token.ExpiresAtUtc.ToString("u"));
}
catch (Exception ex)
{
logger.LogError(ex, "Authentication failed: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleAuthLogoutAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-logout");
Environment.ExitCode = 0;
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
if (tokenClient is null)
{
logger.LogInformation("No authority client registered; nothing to remove.");
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogInformation("Authority configuration missing; no cached tokens to remove.");
return;
}
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (verbose)
{
logger.LogInformation("Cleared cached token for {Authority}.", options.Authority?.Url ?? "authority");
}
}
public static async Task HandleAuthStatusAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-status");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
Environment.ExitCode = 1;
return;
}
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
if (tokenClient is null)
{
logger.LogInformation("Authority client not registered; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
Environment.ExitCode = 1;
return;
}
logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u"));
if (verbose)
{
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
}
}
public static async Task HandleAuthWhoAmIAsync(
IServiceProvider services,
StellaOpsCliOptions options,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-whoami");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
Environment.ExitCode = 1;
return;
}
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
if (tokenClient is null)
{
logger.LogInformation("Authority client not registered; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
if (string.IsNullOrWhiteSpace(cacheKey))
{
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
Environment.ExitCode = 1;
return;
}
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
Environment.ExitCode = 1;
return;
}
var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password";
var now = DateTimeOffset.UtcNow;
var remaining = entry.ExpiresAtUtc - now;
if (remaining < TimeSpan.Zero)
{
remaining = TimeSpan.Zero;
}
logger.LogInformation("Authority: {Authority}", options.Authority.Url);
logger.LogInformation("Grant type: {GrantType}", grantType);
logger.LogInformation("Token type: {TokenType}", entry.TokenType);
logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining));
if (entry.Scopes.Count > 0)
{
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
}
if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore))
{
if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject))
{
logger.LogInformation("Subject: {Subject}", subject);
}
if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId))
{
logger.LogInformation("Client ID (token): {ClientId}", clientId);
}
if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience))
{
logger.LogInformation("Audience: {Audience}", audience);
}
if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer))
{
logger.LogInformation("Issuer: {Issuer}", issuer);
}
if (issuedAt is not null)
{
logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u"));
}
if (notBefore is not null)
{
logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u"));
}
var extraClaims = CollectAdditionalClaims(claims);
if (extraClaims.Count > 0 && verbose)
{
logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims));
}
}
else
{
logger.LogInformation("Access token appears opaque; claims are unavailable.");
}
}
public static async Task HandleAuthRevokeExportAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? outputDirectory,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-revoke-export");
Environment.ExitCode = 0;
try
{
var client = scope.ServiceProvider.GetRequiredService<IAuthorityRevocationClient>();
var result = await client.ExportAsync(verbose, cancellationToken).ConfigureAwait(false);
var directory = string.IsNullOrWhiteSpace(outputDirectory)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(outputDirectory);
Directory.CreateDirectory(directory);
var bundlePath = Path.Combine(directory, "revocation-bundle.json");
var signaturePath = Path.Combine(directory, "revocation-bundle.json.jws");
var digestPath = Path.Combine(directory, "revocation-bundle.json.sha256");
await File.WriteAllBytesAsync(bundlePath, result.BundleBytes, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(signaturePath, result.Signature, cancellationToken).ConfigureAwait(false);
await File.WriteAllTextAsync(digestPath, $"sha256:{result.Digest}", cancellationToken).ConfigureAwait(false);
var computedDigest = Convert.ToHexString(SHA256.HashData(result.BundleBytes)).ToLowerInvariant();
if (!string.Equals(computedDigest, result.Digest, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Digest mismatch. Expected {Expected} but computed {Actual}.", result.Digest, computedDigest);
Environment.ExitCode = 1;
return;
}
logger.LogInformation(
"Revocation bundle exported to {Directory} (sequence {Sequence}, issued {Issued:u}, signing key {KeyId}, provider {Provider}).",
directory,
result.Sequence,
result.IssuedAt,
string.IsNullOrWhiteSpace(result.SigningKeyId) ? "<unknown>" : result.SigningKeyId,
string.IsNullOrWhiteSpace(result.SigningProvider) ? "default" : result.SigningProvider);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export revocation bundle.");
Environment.ExitCode = 1;
}
}
public static async Task HandleAuthRevokeVerifyAsync(
string bundlePath,
string signaturePath,
string keyPath,
bool verbose,
CancellationToken cancellationToken)
{
var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = "HH:mm:ss ";
}));
var logger = loggerFactory.CreateLogger("auth-revoke-verify");
Environment.ExitCode = 0;
try
{
if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(signaturePath) || string.IsNullOrWhiteSpace(keyPath))
{
logger.LogError("Arguments --bundle, --signature, and --key are required.");
Environment.ExitCode = 1;
return;
}
var bundleBytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
var signatureContent = (await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false)).Trim();
var keyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false);
var digest = Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant();
logger.LogInformation("Bundle digest sha256:{Digest}", digest);
if (!TryParseDetachedJws(signatureContent, out var encodedHeader, out var encodedSignature))
{
logger.LogError("Signature is not in detached JWS format.");
Environment.ExitCode = 1;
return;
}
var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(encodedHeader));
using var headerDocument = JsonDocument.Parse(headerJson);
var header = headerDocument.RootElement;
if (!header.TryGetProperty("b64", out var b64Element) || b64Element.GetBoolean())
{
logger.LogError("Detached JWS header must include '\"b64\": false'.");
Environment.ExitCode = 1;
return;
}
var algorithm = header.TryGetProperty("alg", out var algElement) ? algElement.GetString() : SignatureAlgorithms.Es256;
if (string.IsNullOrWhiteSpace(algorithm))
{
algorithm = SignatureAlgorithms.Es256;
}
var providerHint = header.TryGetProperty("provider", out var providerElement)
? providerElement.GetString()
: null;
var keyId = header.TryGetProperty("kid", out var kidElement) ? kidElement.GetString() : null;
if (string.IsNullOrWhiteSpace(keyId))
{
keyId = Path.GetFileNameWithoutExtension(keyPath);
logger.LogWarning("JWS header missing 'kid'; using fallback key id {KeyId}.", keyId);
}
CryptoSigningKey signingKey;
try
{
signingKey = CreateVerificationSigningKey(keyId!, algorithm!, providerHint, keyPem, keyPath);
}
catch (Exception ex) when (ex is InvalidOperationException or CryptographicException)
{
logger.LogError(ex, "Failed to load verification key material.");
Environment.ExitCode = 1;
return;
}
var providers = new List<ICryptoProvider>
{
new DefaultCryptoProvider()
};
#if STELLAOPS_CRYPTO_SODIUM
providers.Add(new LibsodiumCryptoProvider());
#endif
foreach (var provider in providers)
{
if (provider.Supports(CryptoCapability.Verification, algorithm!))
{
provider.UpsertSigningKey(signingKey);
}
}
var preferredOrder = !string.IsNullOrWhiteSpace(providerHint)
? new[] { providerHint! }
: Array.Empty<string>();
var registry = new CryptoProviderRegistry(providers, preferredOrder);
CryptoSignerResolution resolution;
try
{
resolution = registry.ResolveSigner(
CryptoCapability.Verification,
algorithm!,
signingKey.Reference,
providerHint);
}
catch (Exception ex)
{
logger.LogError(ex, "No crypto provider available for verification (algorithm {Algorithm}).", algorithm);
Environment.ExitCode = 1;
return;
}
var signingInputLength = encodedHeader.Length + 1 + bundleBytes.Length;
var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength);
try
{
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
buffer[headerBytes.Length] = (byte)'.';
Buffer.BlockCopy(bundleBytes, 0, buffer, headerBytes.Length + 1, bundleBytes.Length);
var signatureBytes = Base64UrlDecode(encodedSignature);
var verified = await resolution.Signer.VerifyAsync(
new ReadOnlyMemory<byte>(buffer, 0, signingInputLength),
signatureBytes,
cancellationToken).ConfigureAwait(false);
if (!verified)
{
logger.LogError("Signature verification failed.");
Environment.ExitCode = 1;
return;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
if (!string.IsNullOrWhiteSpace(providerHint) && !string.Equals(providerHint, resolution.ProviderName, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"Preferred provider '{Preferred}' unavailable; verification used '{Provider}'.",
providerHint,
resolution.ProviderName);
}
logger.LogInformation(
"Signature verified using algorithm {Algorithm} via provider {Provider} (kid {KeyId}).",
algorithm,
resolution.ProviderName,
signingKey.Reference.KeyId);
if (verbose)
{
logger.LogInformation("JWS header: {Header}", headerJson);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify revocation bundle.");
Environment.ExitCode = 1;
}
finally
{
loggerFactory.Dispose();
}
}
public static async Task HandleTenantsListAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-list");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService<IAuthorityConsoleClient>();
if (client is null)
{
logger.LogError("Authority console client is not available. Ensure Authority is configured and services are registered.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (string.IsNullOrWhiteSpace(effectiveTenant))
{
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT environment variable, or run 'stella tenants use <tenant-id>'.");
Environment.ExitCode = 1;
return;
}
try
{
var tenants = await client.ListTenantsAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
if (json)
{
var output = new { tenants = tenants };
var jsonText = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonText);
}
else
{
if (tenants.Count == 0)
{
logger.LogInformation("No tenants available for the authenticated principal.");
return;
}
logger.LogInformation("Available tenants ({Count}):", tenants.Count);
foreach (var t in tenants)
{
var status = string.Equals(t.Status, "active", StringComparison.OrdinalIgnoreCase) ? "" : $" ({t.Status})";
logger.LogInformation(" {Id}: {DisplayName}{Status}", t.Id, t.DisplayName, status);
if (verbose)
{
logger.LogInformation(" Isolation: {IsolationMode}", t.IsolationMode);
if (t.DefaultRoles.Count > 0)
{
logger.LogInformation(" Default roles: {Roles}", string.Join(", ", t.DefaultRoles));
}
if (t.Projects.Count > 0)
{
logger.LogInformation(" Projects: {Projects}", string.Join(", ", t.Projects));
}
}
}
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
logger.LogError("Authentication required. Run 'stella auth login' first.");
Environment.ExitCode = 1;
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
logger.LogError("Access denied. The authenticated principal does not have permission to list tenants.");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to retrieve tenant list: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsUseAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string tenantId,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-use");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(tenantId))
{
logger.LogError("Tenant identifier is required.");
Environment.ExitCode = 1;
return;
}
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
string? displayName = null;
if (!string.IsNullOrWhiteSpace(options.Authority?.Url))
{
var client = scope.ServiceProvider.GetService<IAuthorityConsoleClient>();
if (client is not null)
{
try
{
var tenants = await client.ListTenantsAsync(normalizedTenant, cancellationToken).ConfigureAwait(false);
var match = tenants.FirstOrDefault(t =>
string.Equals(t.Id, normalizedTenant, StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
displayName = match.DisplayName;
if (verbose)
{
logger.LogDebug("Validated tenant '{TenantId}' with display name '{DisplayName}'.", normalizedTenant, displayName);
}
}
else if (verbose)
{
logger.LogWarning("Tenant '{TenantId}' not found in available tenants. Setting anyway.", normalizedTenant);
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
if (verbose)
{
logger.LogWarning("Could not validate tenant against Authority: {Message}", ex.Message);
}
}
}
}
try
{
await TenantProfileStore.SetActiveTenantAsync(normalizedTenant, displayName, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Active tenant set to '{TenantId}'.", normalizedTenant);
if (!string.IsNullOrWhiteSpace(displayName))
{
logger.LogInformation("Tenant display name: {DisplayName}", displayName);
}
logger.LogInformation("Profile saved to: {Path}", TenantProfileStore.GetProfilePath());
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save tenant profile: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsCurrentAsync(
bool json,
bool verbose,
CancellationToken cancellationToken)
{
Environment.ExitCode = 0;
try
{
var profile = await TenantProfileStore.LoadAsync(cancellationToken).ConfigureAwait(false);
if (json)
{
var output = profile ?? new TenantProfile();
var jsonText = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonText);
return;
}
if (profile is null || string.IsNullOrWhiteSpace(profile.ActiveTenant))
{
Console.WriteLine("No active tenant configured.");
Console.WriteLine("Use 'stella tenants use <tenant-id>' to set one.");
return;
}
Console.WriteLine($"Active tenant: {profile.ActiveTenant}");
if (!string.IsNullOrWhiteSpace(profile.ActiveTenantDisplayName))
{
Console.WriteLine($"Display name: {profile.ActiveTenantDisplayName}");
}
if (profile.LastUpdated.HasValue)
{
Console.WriteLine($"Last updated: {profile.LastUpdated.Value:u}");
}
if (verbose)
{
Console.WriteLine($"Profile path: {TenantProfileStore.GetProfilePath()}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to load tenant profile: {ex.Message}");
Environment.ExitCode = 1;
}
}
public static async Task HandleTenantsClearAsync(CancellationToken cancellationToken)
{
Environment.ExitCode = 0;
try
{
await TenantProfileStore.ClearActiveTenantAsync(cancellationToken).ConfigureAwait(false);
Console.WriteLine("Active tenant cleared.");
Console.WriteLine("Subsequent commands will require --tenant or STELLAOPS_TENANT environment variable.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to clear tenant profile: {ex.Message}");
Environment.ExitCode = 1;
}
}
// CLI-TEN-49-001: Token minting and delegation handlers
public static async Task HandleTokenMintAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string serviceAccount,
string[] scopes,
int? expiresIn,
string? tenant,
string? reason,
bool raw,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("token-mint");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService<IAuthorityConsoleClient>();
if (client is null)
{
logger.LogError("Authority console client is not available.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
try
{
var request = new TokenMintRequest(
serviceAccount,
scopes.Length > 0 ? scopes : new[] { "stellaops:read" },
expiresIn,
effectiveTenant,
reason);
if (verbose)
{
logger.LogDebug("Minting token for service account '{ServiceAccount}' with scopes: {Scopes}", serviceAccount, string.Join(", ", request.Scopes));
}
var response = await client.MintTokenAsync(request, cancellationToken).ConfigureAwait(false);
if (raw)
{
Console.WriteLine(response.AccessToken);
}
else
{
logger.LogInformation("Token minted successfully.");
logger.LogInformation("Service Account: {ServiceAccount}", serviceAccount);
logger.LogInformation("Token Type: {TokenType}", response.TokenType);
logger.LogInformation("Expires At: {ExpiresAt:u}", response.ExpiresAt);
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", response.Scopes));
if (!string.IsNullOrWhiteSpace(response.TokenId))
{
logger.LogInformation("Token ID: {TokenId}", response.TokenId);
}
if (verbose)
{
logger.LogInformation("Access Token: {Token}", response.AccessToken);
}
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
logger.LogError("Authentication required. Run 'stella auth login' first.");
Environment.ExitCode = 1;
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
logger.LogError("Access denied. Insufficient permissions to mint tokens.");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to mint token: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
public static async Task HandleTokenDelegateAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string delegateTo,
string[] scopes,
int? expiresIn,
string? tenant,
string? reason,
bool raw,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("token-delegate");
Environment.ExitCode = 0;
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
Environment.ExitCode = 1;
return;
}
var client = scope.ServiceProvider.GetService<IAuthorityConsoleClient>();
if (client is null)
{
logger.LogError("Authority console client is not available.");
Environment.ExitCode = 1;
return;
}
if (string.IsNullOrWhiteSpace(reason))
{
logger.LogError("Delegation reason is required (--reason). This is recorded in audit logs.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
try
{
var request = new TokenDelegateRequest(
delegateTo,
scopes.Length > 0 ? scopes : Array.Empty<string>(),
expiresIn,
effectiveTenant,
reason);
if (verbose)
{
logger.LogDebug("Delegating token to '{DelegateTo}' with reason: {Reason}", delegateTo, reason);
}
var response = await client.DelegateTokenAsync(request, cancellationToken).ConfigureAwait(false);
if (raw)
{
Console.WriteLine(response.AccessToken);
}
else
{
logger.LogInformation("Token delegated successfully.");
logger.LogInformation("Delegation ID: {DelegationId}", response.DelegationId);
logger.LogInformation("Original Subject: {OriginalSubject}", response.OriginalSubject);
logger.LogInformation("Delegated To: {DelegatedSubject}", response.DelegatedSubject);
logger.LogInformation("Token Type: {TokenType}", response.TokenType);
logger.LogInformation("Expires At: {ExpiresAt:u}", response.ExpiresAt);
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", response.Scopes));
logger.LogWarning("Delegation tokens should be treated with care. All actions performed with this token will be attributed to '{DelegatedSubject}' acting on behalf of '{OriginalSubject}'.",
response.DelegatedSubject, response.OriginalSubject);
if (verbose)
{
logger.LogInformation("Access Token: {Token}", response.AccessToken);
}
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
logger.LogError("Authentication required. Run 'stella auth login' first.");
Environment.ExitCode = 1;
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
logger.LogError("Access denied. Insufficient permissions to delegate tokens.");
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delegate token: {Message}", ex.Message);
Environment.ExitCode = 1;
}
}
/// <summary>
/// Checks and displays impersonation banner if operating under a delegated token.
/// Call this from commands that need audit-aware impersonation notices (CLI-TEN-49-001).
/// </summary>
internal static async Task CheckAndDisplayImpersonationBannerAsync(
IAuthorityConsoleClient client,
ILogger logger,
string? tenant,
CancellationToken cancellationToken)
{
try
{
var introspection = await client.IntrospectTokenAsync(tenant, cancellationToken).ConfigureAwait(false);
if (introspection is null || !introspection.Active)
{
return;
}
if (!string.IsNullOrWhiteSpace(introspection.DelegatedBy))
{
logger.LogWarning("=== IMPERSONATION NOTICE ===");
logger.LogWarning("Operating as '{Subject}' delegated by '{DelegatedBy}'.", introspection.Subject, introspection.DelegatedBy);
if (!string.IsNullOrWhiteSpace(introspection.DelegationReason))
{
logger.LogWarning("Delegation reason: {Reason}", introspection.DelegationReason);
}
logger.LogWarning("All actions in this session are audit-logged under the delegation context.");
logger.LogWarning("============================");
}
}
catch
{
// Silently ignore introspection failures - don't block operations
}
}
public static async Task HandleVulnObservationsAsync(
IServiceProvider services,
string tenant,
IReadOnlyList<string> observationIds,
IReadOnlyList<string> aliases,
IReadOnlyList<string> purls,
IReadOnlyList<string> cpes,
int? limit,
string? cursor,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-observations");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.observations", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln observations");
activity?.SetTag("stellaops.cli.tenant", tenant);
using var duration = CliMetrics.MeasureCommandDuration("vuln observations");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var query = new AdvisoryObservationsQuery(
tenant,
NormalizeSet(observationIds, toLower: false),
NormalizeSet(aliases, toLower: true),
NormalizeSet(purls, toLower: false),
NormalizeSet(cpes, toLower: false),
limit,
cursor);
var response = await client.GetObservationsAsync(query, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderObservationTable(response);
if (!emitJson && response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
var escapedCursor = Markup.Escape(response.NextCursor);
AnsiConsole.MarkupLine($"[yellow]More observations available. Continue with[/] [cyan]--cursor[/] [grey]{escapedCursor}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch observations from Concelier.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var raw in values)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var normalized = raw.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
set.Add(normalized);
}
return set.Count == 0 ? Array.Empty<string>() : set.ToArray();
}
static void RenderObservationTable(AdvisoryObservationsResponse response)
{
var observations = response.Observations ?? Array.Empty<AdvisoryObservationDocument>();
if (observations.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters.[/]");
return;
}
var table = new Table()
.Centered()
.Border(TableBorder.Rounded);
table.AddColumn("Observation");
table.AddColumn("Source");
table.AddColumn("Upstream Id");
table.AddColumn("Aliases");
table.AddColumn("PURLs");
table.AddColumn("CPEs");
table.AddColumn("Created (UTC)");
foreach (var observation in observations)
{
var sourceVendor = observation.Source?.Vendor ?? "(unknown)";
var upstreamId = observation.Upstream?.UpstreamId ?? "(unknown)";
var aliasesText = FormatList(observation.Linkset?.Aliases);
var purlsText = FormatList(observation.Linkset?.Purls);
var cpesText = FormatList(observation.Linkset?.Cpes);
table.AddRow(
Markup.Escape(observation.ObservationId),
Markup.Escape(sourceVendor),
Markup.Escape(upstreamId),
Markup.Escape(aliasesText),
Markup.Escape(purlsText),
Markup.Escape(cpesText),
observation.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine(
"[green]{0}[/] observation(s). Aliases: [green]{1}[/], PURLs: [green]{2}[/], CPEs: [green]{3}[/].",
observations.Count,
response.Linkset?.Aliases?.Count ?? 0,
response.Linkset?.Purls?.Count ?? 0,
response.Linkset?.Cpes?.Count ?? 0);
}
static string FormatList(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
const int MaxItems = 3;
if (values.Count <= MaxItems)
{
return string.Join(", ", values);
}
var preview = values.Take(MaxItems);
return $"{string.Join(", ", preview)} (+{values.Count - MaxItems})";
}
}
public static async Task HandleOfflineKitPullAsync(
IServiceProvider services,
string? bundleId,
string? destinationDirectory,
bool overwrite,
bool resume,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-pull");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.pull", ActivityKind.Client);
activity?.SetTag("stellaops.cli.bundle_id", string.IsNullOrWhiteSpace(bundleId) ? "latest" : bundleId);
using var duration = CliMetrics.MeasureCommandDuration("offline kit pull");
try
{
var targetDirectory = string.IsNullOrWhiteSpace(destinationDirectory)
? options.Offline?.KitsDirectory ?? Path.Combine(Environment.CurrentDirectory, "offline-kits")
: destinationDirectory;
targetDirectory = Path.GetFullPath(targetDirectory);
Directory.CreateDirectory(targetDirectory);
var result = await client.DownloadOfflineKitAsync(bundleId, targetDirectory, overwrite, resume, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Bundle {BundleId} stored at {Path} (captured {Captured:u}, sha256:{Digest}).",
result.Descriptor.BundleId,
result.BundlePath,
result.Descriptor.CapturedAt,
result.Descriptor.BundleSha256);
logger.LogInformation("Manifest saved to {Manifest}.", result.ManifestPath);
if (!string.IsNullOrWhiteSpace(result.MetadataPath))
{
logger.LogDebug("Metadata recorded at {Metadata}.", result.MetadataPath);
}
if (result.BundleSignaturePath is not null)
{
logger.LogInformation("Bundle signature saved to {Signature}.", result.BundleSignaturePath);
}
if (result.ManifestSignaturePath is not null)
{
logger.LogInformation("Manifest signature saved to {Signature}.", result.ManifestSignaturePath);
}
CliMetrics.RecordOfflineKitDownload(result.Descriptor.Kind ?? "unknown", result.FromCache);
activity?.SetTag("stellaops.cli.bundle_cache", result.FromCache);
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download offline kit bundle.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyFindingsListAsync(
IServiceProvider services,
string policyId,
string[] sbomFilters,
string[] statusFilters,
string[] severityFilters,
string? since,
string? cursor,
int? page,
int? pageSize,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-findings-ls");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.findings.list", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("policy findings list");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (page.HasValue && page.Value < 1)
{
throw new ArgumentException("--page must be greater than or equal to 1.", nameof(page));
}
if (pageSize.HasValue && (pageSize.Value < 1 || pageSize.Value > 500))
{
throw new ArgumentException("--page-size must be between 1 and 500.", nameof(pageSize));
}
var normalizedPolicyId = policyId.Trim();
var sboms = NormalizePolicyFilterValues(sbomFilters);
var statuses = NormalizePolicyFilterValues(statusFilters, toLower: true);
var severities = NormalizePolicyFilterValues(severityFilters);
var sinceValue = ParsePolicySince(since);
var cursorValue = string.IsNullOrWhiteSpace(cursor) ? null : cursor.Trim();
var query = new PolicyFindingsQuery(
normalizedPolicyId,
sboms,
statuses,
severities,
cursorValue,
page,
pageSize,
sinceValue);
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
if (sboms.Count > 0)
{
activity?.SetTag("stellaops.cli.findings.sbom_filters", string.Join(",", sboms));
}
if (statuses.Count > 0)
{
activity?.SetTag("stellaops.cli.findings.status_filters", string.Join(",", statuses));
}
if (severities.Count > 0)
{
activity?.SetTag("stellaops.cli.findings.severity_filters", string.Join(",", severities));
}
if (!string.IsNullOrWhiteSpace(cursorValue))
{
activity?.SetTag("stellaops.cli.findings.cursor", cursorValue);
}
if (page.HasValue)
{
activity?.SetTag("stellaops.cli.findings.page", page.Value);
}
if (pageSize.HasValue)
{
activity?.SetTag("stellaops.cli.findings.page_size", pageSize.Value);
}
if (sinceValue.HasValue)
{
activity?.SetTag("stellaops.cli.findings.since", sinceValue.Value.ToString("o", CultureInfo.InvariantCulture));
}
var result = await client.GetPolicyFindingsAsync(query, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.findings.count", result.Items.Count);
if (!string.IsNullOrWhiteSpace(result.NextCursor))
{
activity?.SetTag("stellaops.cli.findings.next_cursor", result.NextCursor);
}
var payload = BuildPolicyFindingsPayload(normalizedPolicyId, query, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteJsonPayloadAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Results written to {Path}.", Path.GetFullPath(outputPath!));
}
var outputFormat = DeterminePolicyFindingsFormat(format, outputPath);
if (outputFormat == PolicyFindingsOutputFormat.Json)
{
var json = JsonSerializer.Serialize(payload, SimulationJsonOptions);
Console.WriteLine(json);
}
else
{
RenderPolicyFindingsTable(logger, result);
}
CliMetrics.RecordPolicyFindingsList(result.Items.Count == 0 ? "empty" : "ok");
Environment.ExitCode = 0;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyFindingsList("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyFindingsFailure(ex, logger, CliMetrics.RecordPolicyFindingsList);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list policy findings.");
CliMetrics.RecordPolicyFindingsList("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyFindingsGetAsync(
IServiceProvider services,
string policyId,
string findingId,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-findings-get");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.findings.get", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("policy findings get");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var normalizedPolicyId = policyId.Trim();
var normalizedFindingId = findingId.Trim();
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
activity?.SetTag("stellaops.cli.finding_id", normalizedFindingId);
var result = await client.GetPolicyFindingAsync(normalizedPolicyId, normalizedFindingId, cancellationToken).ConfigureAwait(false);
var payload = BuildPolicyFindingPayload(normalizedPolicyId, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteJsonPayloadAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Finding written to {Path}.", Path.GetFullPath(outputPath!));
}
var outputFormat = DeterminePolicyFindingsFormat(format, outputPath);
if (outputFormat == PolicyFindingsOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderPolicyFindingDetails(logger, result);
}
var outcome = string.IsNullOrWhiteSpace(result.Status) ? "unknown" : result.Status.ToLowerInvariant();
CliMetrics.RecordPolicyFindingsGet(outcome);
Environment.ExitCode = 0;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyFindingsGet("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyFindingsFailure(ex, logger, CliMetrics.RecordPolicyFindingsGet);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to retrieve policy finding.");
CliMetrics.RecordPolicyFindingsGet("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyFindingsExplainAsync(
IServiceProvider services,
string policyId,
string findingId,
string? mode,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-findings-explain");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.findings.explain", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("policy findings explain");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var normalizedPolicyId = policyId.Trim();
var normalizedFindingId = findingId.Trim();
var normalizedMode = NormalizeExplainMode(mode);
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
activity?.SetTag("stellaops.cli.finding_id", normalizedFindingId);
if (!string.IsNullOrWhiteSpace(normalizedMode))
{
activity?.SetTag("stellaops.cli.findings.mode", normalizedMode);
}
var result = await client.GetPolicyFindingExplainAsync(normalizedPolicyId, normalizedFindingId, normalizedMode, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.findings.step_count", result.Steps.Count);
var payload = BuildPolicyFindingExplainPayload(normalizedPolicyId, normalizedFindingId, normalizedMode, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
await WriteJsonPayloadAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Explain trace written to {Path}.", Path.GetFullPath(outputPath!));
}
var outputFormat = DeterminePolicyFindingsFormat(format, outputPath);
if (outputFormat == PolicyFindingsOutputFormat.Json)
{
Console.WriteLine(JsonSerializer.Serialize(payload, SimulationJsonOptions));
}
else
{
RenderPolicyFindingExplain(logger, result);
}
CliMetrics.RecordPolicyFindingsExplain(result.Steps.Count == 0 ? "empty" : "ok");
Environment.ExitCode = 0;
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyFindingsExplain("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyFindingsFailure(ex, logger, CliMetrics.RecordPolicyFindingsExplain);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch policy explain trace.");
CliMetrics.RecordPolicyFindingsExplain("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicyActivateAsync(
IServiceProvider services,
string policyId,
int version,
string? note,
bool runNow,
string? scheduledAt,
string? priority,
bool rollback,
string? incidentId,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-activate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.activate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "policy activate");
using var duration = CliMetrics.MeasureCommandDuration("policy activate");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (version <= 0)
{
throw new ArgumentOutOfRangeException(nameof(version), "Version must be greater than zero.");
}
var normalizedPolicyId = policyId.Trim();
DateTimeOffset? scheduled = null;
if (!string.IsNullOrWhiteSpace(scheduledAt))
{
if (!DateTimeOffset.TryParse(scheduledAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
{
throw new ArgumentException("Scheduled timestamp must be a valid ISO-8601 value.", nameof(scheduledAt));
}
scheduled = parsed;
}
var request = new PolicyActivationRequest(
runNow,
scheduled,
NormalizePolicyPriority(priority),
rollback,
string.IsNullOrWhiteSpace(incidentId) ? null : incidentId.Trim(),
string.IsNullOrWhiteSpace(note) ? null : note.Trim());
activity?.SetTag("stellaops.cli.policy_id", normalizedPolicyId);
activity?.SetTag("stellaops.cli.policy_version", version);
if (request.RunNow)
{
activity?.SetTag("stellaops.cli.policy_run_now", true);
}
if (request.ScheduledAt.HasValue)
{
activity?.SetTag("stellaops.cli.policy_scheduled_at", request.ScheduledAt.Value.ToString("o", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(request.Priority))
{
activity?.SetTag("stellaops.cli.policy_priority", request.Priority);
}
if (request.Rollback)
{
activity?.SetTag("stellaops.cli.policy_rollback", true);
}
var result = await client.ActivatePolicyRevisionAsync(normalizedPolicyId, version, request, cancellationToken).ConfigureAwait(false);
var outcome = NormalizePolicyActivationOutcome(result.Status);
CliMetrics.RecordPolicyActivation(outcome);
RenderPolicyActivationResult(result, request);
var exitCode = DeterminePolicyActivationExitCode(outcome);
Environment.ExitCode = exitCode;
if (exitCode == 0)
{
logger.LogInformation("Policy {PolicyId} v{Version} activation status: {Status}.", result.Revision.PolicyId, result.Revision.Version, outcome);
}
else
{
logger.LogWarning("Policy {PolicyId} v{Version} requires additional approval (status: {Status}).", result.Revision.PolicyId, result.Revision.Version, outcome);
}
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicyActivation("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicyActivationFailure(ex, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Policy activation failed.");
CliMetrics.RecordPolicyActivation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePolicySimulateAsync(
IServiceProvider services,
string policyId,
int? baseVersion,
int? candidateVersion,
IReadOnlyList<string> sbomArguments,
IReadOnlyList<string> environmentArguments,
string? format,
string? outputPath,
bool explain,
bool failOnDiff,
IReadOnlyList<string> withExceptions,
IReadOnlyList<string> withoutExceptions,
string? mode,
IReadOnlyList<string> sbomSelectors,
bool includeHeatmap,
bool manifestDownload,
IReadOnlyList<string> reachabilityStates,
IReadOnlyList<string> reachabilityScores,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("policy-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.policy.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "policy simulate");
activity?.SetTag("stellaops.cli.policy_id", policyId);
if (baseVersion.HasValue)
{
activity?.SetTag("stellaops.cli.base_version", baseVersion.Value);
}
if (candidateVersion.HasValue)
{
activity?.SetTag("stellaops.cli.candidate_version", candidateVersion.Value);
}
// CLI-EXC-25-002: Track exception preview usage
if (withExceptions.Count > 0)
{
activity?.SetTag("stellaops.cli.with_exceptions_count", withExceptions.Count);
}
if (withoutExceptions.Count > 0)
{
activity?.SetTag("stellaops.cli.without_exceptions_count", withoutExceptions.Count);
}
using var duration = CliMetrics.MeasureCommandDuration("policy simulate");
try
{
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
var normalizedPolicyId = policyId.Trim();
var sbomSet = NormalizePolicySbomSet(sbomArguments);
var environment = ParsePolicyEnvironment(environmentArguments);
// CLI-EXC-25-002: Normalize exception IDs and validate no overlap
var normalizedWithExceptions = withExceptions.Select(e => e.Trim()).Where(e => !string.IsNullOrEmpty(e)).ToList();
var normalizedWithoutExceptions = withoutExceptions.Select(e => e.Trim()).Where(e => !string.IsNullOrEmpty(e)).ToList();
var overlap = normalizedWithExceptions.Intersect(normalizedWithoutExceptions).ToList();
if (overlap.Count > 0)
{
throw new ArgumentException($"Exception IDs cannot appear in both --with-exception and --without-exception: {string.Join(", ", overlap)}");
}
if (verbose && (normalizedWithExceptions.Count > 0 || normalizedWithoutExceptions.Count > 0))
{
if (normalizedWithExceptions.Count > 0)
{
logger.LogInformation("Simulating WITH exceptions: {Exceptions}", string.Join(", ", normalizedWithExceptions));
}
if (normalizedWithoutExceptions.Count > 0)
{
logger.LogInformation("Simulating WITHOUT exceptions: {Exceptions}", string.Join(", ", normalizedWithoutExceptions));
}
}
// CLI-POLICY-27-003: Parse simulation mode
PolicySimulationMode? simulationMode = null;
if (!string.IsNullOrWhiteSpace(mode))
{
simulationMode = mode.Trim().ToLowerInvariant() switch
{
"quick" => PolicySimulationMode.Quick,
"batch" => PolicySimulationMode.Batch,
_ => throw new ArgumentException($"Invalid mode '{mode}'. Use 'quick' or 'batch'.")
};
if (verbose)
{
logger.LogInformation("Simulation mode: {Mode}", mode);
}
}
// CLI-POLICY-27-003: Normalize SBOM selectors
var normalizedSbomSelectors = sbomSelectors
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
if (verbose && normalizedSbomSelectors.Count > 0)
{
logger.LogInformation("SBOM selectors: {Selectors}", string.Join(", ", normalizedSbomSelectors));
}
// CLI-SIG-26-002: Parse reachability overrides
var reachabilityOverrides = ParseReachabilityOverrides(reachabilityStates, reachabilityScores);
if (verbose && reachabilityOverrides.Count > 0)
{
logger.LogInformation("Reachability overrides: {Count} items", reachabilityOverrides.Count);
foreach (var ro in reachabilityOverrides)
{
var target = ro.VulnerabilityId ?? ro.PackagePurl ?? "unknown";
if (!string.IsNullOrWhiteSpace(ro.State))
{
logger.LogDebug(" {Target}: state={State}", target, ro.State);
}
if (ro.Score.HasValue)
{
logger.LogDebug(" {Target}: score={Score}", target, ro.Score);
}
}
}
var input = new PolicySimulationInput(
baseVersion,
candidateVersion,
sbomSet,
environment,
explain,
normalizedWithExceptions.Count > 0 ? normalizedWithExceptions : null,
normalizedWithoutExceptions.Count > 0 ? normalizedWithoutExceptions : null,
simulationMode,
normalizedSbomSelectors.Count > 0 ? normalizedSbomSelectors : null,
includeHeatmap,
manifestDownload,
reachabilityOverrides.Count > 0 ? reachabilityOverrides : null);
var result = await client.SimulatePolicyAsync(normalizedPolicyId, input, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.diff_added", result.Diff.Added);
activity?.SetTag("stellaops.cli.diff_removed", result.Diff.Removed);
if (result.Diff.BySeverity.Count > 0)
{
activity?.SetTag("stellaops.cli.severity_buckets", result.Diff.BySeverity.Count);
}
if (result.Heatmap is not null)
{
activity?.SetTag("stellaops.cli.heatmap_present", true);
}
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
activity?.SetTag("stellaops.cli.manifest_download_available", true);
}
var outputFormat = DeterminePolicySimulationFormat(format, outputPath);
var payload = BuildPolicySimulationPayload(normalizedPolicyId, baseVersion, candidateVersion, sbomSet, environment, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
if (outputFormat == PolicySimulationOutputFormat.Markdown)
{
await WriteMarkdownSimulationOutputAsync(outputPath!, normalizedPolicyId, result, cancellationToken).ConfigureAwait(false);
}
else
{
await WriteSimulationOutputAsync(outputPath!, payload, cancellationToken).ConfigureAwait(false);
}
logger.LogInformation("Simulation results written to {Path}.", Path.GetFullPath(outputPath!));
}
RenderPolicySimulationResult(logger, payload, result, outputFormat);
var exitCode = DetermineSimulationExitCode(result, failOnDiff);
Environment.ExitCode = exitCode;
var outcome = exitCode == 20
? "diff_blocked"
: (result.Diff.Added + result.Diff.Removed) > 0 ? "diff" : "clean";
CliMetrics.RecordPolicySimulation(outcome);
if (exitCode == 20)
{
logger.LogWarning("Differences detected; exiting with code 20 due to --fail-on-diff.");
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
activity?.SetTag("stellaops.cli.explain_uri", result.ExplainUri);
}
// CLI-POLICY-27-003: Show manifest download info if available
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
logger.LogInformation("Manifest download available at: {Uri}", result.ManifestDownloadUri);
if (!string.IsNullOrWhiteSpace(result.ManifestDigest))
{
logger.LogInformation("Manifest digest: {Digest}", result.ManifestDigest);
}
}
}
catch (ArgumentException ex)
{
logger.LogError(ex.Message);
CliMetrics.RecordPolicySimulation("error");
Environment.ExitCode = 64;
}
catch (PolicyApiException ex)
{
HandlePolicySimulationFailure(ex, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Policy simulation failed.");
CliMetrics.RecordPolicySimulation("error");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleOfflineKitImportAsync(
IServiceProvider services,
string bundlePath,
string? manifestPath,
string? bundleSignaturePath,
string? manifestSignaturePath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-import");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.import", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("offline kit import");
try
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
logger.LogError("Bundle path is required.");
Environment.ExitCode = 1;
return;
}
bundlePath = Path.GetFullPath(bundlePath);
if (!File.Exists(bundlePath))
{
logger.LogError("Bundle file {Path} not found.", bundlePath);
Environment.ExitCode = 1;
return;
}
var metadata = await LoadOfflineKitMetadataAsync(bundlePath, cancellationToken).ConfigureAwait(false);
if (metadata is not null)
{
manifestPath ??= metadata.ManifestPath;
bundleSignaturePath ??= metadata.BundleSignaturePath;
manifestSignaturePath ??= metadata.ManifestSignaturePath;
}
manifestPath = NormalizeFilePath(manifestPath);
bundleSignaturePath = NormalizeFilePath(bundleSignaturePath);
manifestSignaturePath = NormalizeFilePath(manifestSignaturePath);
if (manifestPath is null)
{
manifestPath = TryInferManifestPath(bundlePath);
if (manifestPath is not null)
{
logger.LogDebug("Using inferred manifest path {Path}.", manifestPath);
}
}
if (manifestPath is not null && !File.Exists(manifestPath))
{
logger.LogError("Manifest file {Path} not found.", manifestPath);
Environment.ExitCode = 1;
return;
}
if (bundleSignaturePath is not null && !File.Exists(bundleSignaturePath))
{
logger.LogWarning("Bundle signature {Path} not found; skipping.", bundleSignaturePath);
bundleSignaturePath = null;
}
if (manifestSignaturePath is not null && !File.Exists(manifestSignaturePath))
{
logger.LogWarning("Manifest signature {Path} not found; skipping.", manifestSignaturePath);
manifestSignaturePath = null;
}
if (metadata is not null)
{
var computedBundleDigest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false);
if (!DigestsEqual(computedBundleDigest, metadata.BundleSha256))
{
logger.LogError("Bundle digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.BundleSha256, computedBundleDigest);
Environment.ExitCode = 1;
return;
}
if (manifestPath is not null)
{
var computedManifestDigest = await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false);
if (!DigestsEqual(computedManifestDigest, metadata.ManifestSha256))
{
logger.LogError("Manifest digest mismatch. Expected sha256:{Expected} but computed sha256:{Actual}.", metadata.ManifestSha256, computedManifestDigest);
Environment.ExitCode = 1;
return;
}
}
}
var request = new OfflineKitImportRequest(
bundlePath,
manifestPath,
bundleSignaturePath,
manifestSignaturePath,
metadata?.BundleId,
metadata?.BundleSha256,
metadata?.BundleSize,
metadata?.CapturedAt,
metadata?.Channel,
metadata?.Kind,
metadata?.IsDelta,
metadata?.BaseBundleId,
metadata?.ManifestSha256,
metadata?.ManifestSize);
var result = await client.ImportOfflineKitAsync(request, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordOfflineKitImport(result.Status);
logger.LogInformation(
"Import {ImportId} submitted at {Submitted:u} with status {Status}.",
string.IsNullOrWhiteSpace(result.ImportId) ? "<pending>" : result.ImportId,
result.SubmittedAt,
string.IsNullOrWhiteSpace(result.Status) ? "queued" : result.Status);
if (!string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation(result.Message);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Offline kit import failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleOfflineKitStatusAsync(
IServiceProvider services,
bool asJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("offline-kit-status");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.offline.kit.status", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("offline kit status");
try
{
var status = await client.GetOfflineKitStatusAsync(cancellationToken).ConfigureAwait(false);
if (asJson)
{
var payload = new
{
bundleId = status.BundleId,
channel = status.Channel,
kind = status.Kind,
isDelta = status.IsDelta,
baseBundleId = status.BaseBundleId,
capturedAt = status.CapturedAt,
importedAt = status.ImportedAt,
sha256 = status.BundleSha256,
sizeBytes = status.BundleSize,
components = status.Components.Select(component => new
{
component.Name,
component.Version,
component.Digest,
component.CapturedAt,
component.SizeBytes
})
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
AnsiConsole.Console.WriteLine(json);
}
else
{
if (string.IsNullOrWhiteSpace(status.BundleId))
{
logger.LogInformation("No offline kit bundle has been imported yet.");
}
else
{
logger.LogInformation(
"Current bundle {BundleId} ({Kind}) captured {Captured:u}, imported {Imported:u}, sha256:{Digest}, size {Size}.",
status.BundleId,
status.Kind ?? "unknown",
status.CapturedAt ?? default,
status.ImportedAt ?? default,
status.BundleSha256 ?? "<n/a>",
status.BundleSize.HasValue ? status.BundleSize.Value.ToString("N0", CultureInfo.InvariantCulture) : "<n/a>");
}
if (status.Components.Count > 0)
{
var table = new Table().AddColumns("Component", "Version", "Digest", "Captured", "Size (bytes)");
foreach (var component in status.Components)
{
table.AddRow(
component.Name,
string.IsNullOrWhiteSpace(component.Version) ? "-" : component.Version!,
string.IsNullOrWhiteSpace(component.Digest) ? "-" : $"sha256:{component.Digest}",
component.CapturedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "-",
component.SizeBytes.HasValue ? component.SizeBytes.Value.ToString("N0", CultureInfo.InvariantCulture) : "-");
}
AnsiConsole.Write(table);
}
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to read offline kit status.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static async Task<OfflineKitMetadataDocument?> LoadOfflineKitMetadataAsync(string bundlePath, CancellationToken cancellationToken)
{
var metadataPath = bundlePath + ".metadata.json";
if (!File.Exists(metadataPath))
{
return null;
}
try
{
await using var stream = File.OpenRead(metadataPath);
return await JsonSerializer.DeserializeAsync<OfflineKitMetadataDocument>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
private static string? NormalizeFilePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
return Path.GetFullPath(path);
}
private static string? TryInferManifestPath(string bundlePath)
{
var directory = Path.GetDirectoryName(bundlePath);
if (string.IsNullOrWhiteSpace(directory))
{
return null;
}
var baseName = Path.GetFileName(bundlePath);
if (string.IsNullOrWhiteSpace(baseName))
{
return null;
}
baseName = Path.GetFileNameWithoutExtension(baseName);
if (baseName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
{
baseName = Path.GetFileNameWithoutExtension(baseName);
}
var candidates = new[]
{
Path.Combine(directory, $"offline-manifest-{baseName}.json"),
Path.Combine(directory, "offline-manifest.json")
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return Path.GetFullPath(candidate);
}
}
return Directory.EnumerateFiles(directory, "offline-manifest*.json").FirstOrDefault();
}
private static bool DigestsEqual(string computed, string? expected)
{
if (string.IsNullOrWhiteSpace(expected))
{
return true;
}
return string.Equals(NormalizeDigest(computed), NormalizeDigest(expected), StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeDigest(string digest)
{
var value = digest.Trim();
if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
value = value.Substring("sha256:".Length);
}
return value.ToLowerInvariant();
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
{
encodedHeader = string.Empty;
encodedSignature = string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var parts = value.Split('.');
if (parts.Length != 3)
{
return false;
}
encodedHeader = parts[0];
encodedSignature = parts[2];
return parts[1].Length == 0;
}
private static byte[] Base64UrlDecode(string value)
{
var normalized = value.Replace('-', '+').Replace('_', '/');
var padding = normalized.Length % 4;
if (padding == 2)
{
normalized += "==";
}
else if (padding == 3)
{
normalized += "=";
}
else if (padding == 1)
{
throw new FormatException("Invalid Base64Url value.");
}
return Convert.FromBase64String(normalized);
}
private static CryptoSigningKey CreateVerificationSigningKey(
string keyId,
string algorithm,
string? providerHint,
string keyPem,
string keyPath)
{
if (string.IsNullOrWhiteSpace(keyPem))
{
throw new InvalidOperationException("Verification key PEM content is empty.");
}
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(keyPem);
var parameters = ecdsa.ExportParameters(includePrivateParameters: false);
if (parameters.D is null || parameters.D.Length == 0)
{
parameters.D = new byte[] { 0x01 };
}
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["source"] = Path.GetFullPath(keyPath),
["verificationOnly"] = "true"
};
return new CryptoSigningKey(
new CryptoKeyReference(keyId, providerHint),
algorithm,
in parameters,
DateTimeOffset.UtcNow,
metadata: metadata);
}
private static string FormatDuration(TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return "expired";
}
if (duration.TotalDays >= 1)
{
var days = (int)duration.TotalDays;
var hours = duration.Hours;
return hours > 0
? FormattableString.Invariant($"{days}d {hours}h")
: FormattableString.Invariant($"{days}d");
}
if (duration.TotalHours >= 1)
{
return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m");
}
if (duration.TotalMinutes >= 1)
{
return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s");
}
return FormattableString.Invariant($"{duration.Seconds}s");
}
private static bool TryExtractJwtClaims(
string accessToken,
out Dictionary<string, string> claims,
out DateTimeOffset? issuedAt,
out DateTimeOffset? notBefore)
{
claims = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
issuedAt = null;
notBefore = null;
if (string.IsNullOrWhiteSpace(accessToken))
{
return false;
}
var parts = accessToken.Split('.');
if (parts.Length < 2)
{
return false;
}
if (!TryDecodeBase64Url(parts[1], out var payloadBytes))
{
return false;
}
try
{
using var document = JsonDocument.Parse(payloadBytes);
foreach (var property in document.RootElement.EnumerateObject())
{
var value = FormatJsonValue(property.Value);
claims[property.Name] = value;
if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat))
{
issuedAt = parsedIat;
}
if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf))
{
notBefore = parsedNbf;
}
}
return true;
}
catch (JsonException)
{
claims.Clear();
issuedAt = null;
notBefore = null;
return false;
}
}
private static bool TryDecodeBase64Url(string value, out byte[] bytes)
{
bytes = Array.Empty<byte>();
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Replace('-', '+').Replace('_', '/');
var padding = normalized.Length % 4;
if (padding is 2 or 3)
{
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
}
else if (padding == 1)
{
return false;
}
try
{
bytes = Convert.FromBase64String(normalized);
return true;
}
catch (FormatException)
{
return false;
}
}
private static string FormatJsonValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number => element.TryGetInt64(out var longValue)
? longValue.ToString(CultureInfo.InvariantCulture)
: element.GetDouble().ToString(CultureInfo.InvariantCulture),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
JsonValueKind.Array => FormatArray(element),
JsonValueKind.Object => element.GetRawText(),
_ => element.GetRawText()
};
}
private static string FormatArray(JsonElement array)
{
var values = new List<string>();
foreach (var item in array.EnumerateArray())
{
values.Add(FormatJsonValue(item));
}
return string.Join(", ", values);
}
private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value)
{
value = default;
if (element.ValueKind == JsonValueKind.Number)
{
if (element.TryGetInt64(out var seconds))
{
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
return true;
}
if (element.TryGetDouble(out var doubleValue))
{
value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue);
return true;
}
}
if (element.ValueKind == JsonValueKind.String)
{
var text = element.GetString();
if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
{
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
return true;
}
}
return false;
}
private static List<string> CollectAdditionalClaims(Dictionary<string, string> claims)
{
var result = new List<string>();
foreach (var pair in claims)
{
if (CommonClaimNames.Contains(pair.Key))
{
continue;
}
result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}"));
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
private static readonly HashSet<string> CommonClaimNames = new(StringComparer.OrdinalIgnoreCase)
{
"aud",
"client_id",
"exp",
"iat",
"iss",
"nbf",
"scope",
"scopes",
"sub",
"token_type",
"jti"
};
private static async Task ExecuteExcititorCommandAsync(
IServiceProvider services,
string commandName,
bool verbose,
IDictionary<string, object?>? activityTags,
Func<IBackendOperationsClient, Task<ExcititorOperationResult>> operation,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(commandName.Replace(' ', '-'));
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}" , ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", commandName);
if (activityTags is not null)
{
foreach (var tag in activityTags)
{
activity?.SetTag(tag.Key, tag.Value);
}
}
using var duration = CliMetrics.MeasureCommandDuration(commandName);
try
{
var result = await operation(client).ConfigureAwait(false);
if (result.Success)
{
if (!string.IsNullOrWhiteSpace(result.Message))
{
logger.LogInformation(result.Message);
}
else
{
logger.LogInformation("Operation completed successfully.");
}
if (!string.IsNullOrWhiteSpace(result.Location))
{
logger.LogInformation("Location: {Location}", result.Location);
}
if (result.Payload is JsonElement payload && payload.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
logger.LogDebug("Response payload: {Payload}", payload.ToString());
}
Environment.ExitCode = 0;
}
else
{
logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message);
Environment.ExitCode = 1;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Excititor operation failed.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static async Task<IReadOnlyList<string>> GatherImageDigestsAsync(
IReadOnlyList<string> inline,
string? filePath,
CancellationToken cancellationToken)
{
var results = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void AddCandidates(string? candidate)
{
foreach (var image in SplitImageCandidates(candidate))
{
if (seen.Add(image))
{
results.Add(image);
}
}
}
if (inline is not null)
{
foreach (var entry in inline)
{
AddCandidates(entry);
}
}
if (!string.IsNullOrWhiteSpace(filePath))
{
var path = Path.GetFullPath(filePath);
if (!File.Exists(path))
{
throw new FileNotFoundException("Input file not found.", path);
}
foreach (var line in File.ReadLines(path))
{
cancellationToken.ThrowIfCancellationRequested();
AddCandidates(line);
}
}
if (Console.IsInputRedirected)
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await Console.In.ReadLineAsync().ConfigureAwait(false);
if (line is null)
{
break;
}
AddCandidates(line);
}
}
return new ReadOnlyCollection<string>(results);
}
private static IEnumerable<string> SplitImageCandidates(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
yield break;
}
var candidate = raw.Trim();
var commentIndex = candidate.IndexOf('#');
if (commentIndex >= 0)
{
candidate = candidate[..commentIndex].Trim();
}
if (candidate.Length == 0)
{
yield break;
}
var tokens = candidate.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var token in tokens)
{
var trimmed = token.Trim();
if (trimmed.Length > 0)
{
yield return trimmed;
}
}
}
private static IReadOnlyDictionary<string, string> ParseLabelSelectors(IReadOnlyList<string> labelArguments)
{
if (labelArguments is null || labelArguments.Count == 0)
{
return EmptyLabelSelectors;
}
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in labelArguments)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
var delimiter = trimmed.IndexOf('=');
if (delimiter <= 0 || delimiter == trimmed.Length - 1)
{
throw new ArgumentException($"Invalid label '{raw}'. Expected key=value format.");
}
var key = trimmed[..delimiter].Trim();
var value = trimmed[(delimiter + 1)..].Trim();
if (key.Length == 0)
{
throw new ArgumentException($"Invalid label '{raw}'. Label key cannot be empty.");
}
labels[key] = value;
}
return labels.Count == 0 ? EmptyLabelSelectors : new ReadOnlyDictionary<string, string>(labels);
}
private sealed record ExcititorExportManifestSummary(
string ExportId,
string? Format,
string? Algorithm,
string? Digest,
long? SizeBytes,
bool? FromCache,
DateTimeOffset? CreatedAt,
string? RekorLocation,
string? RekorIndex,
string? RekorInclusionUrl);
private static ExcititorExportManifestSummary? TryParseExportManifest(JsonElement? payload)
{
if (payload is null || payload.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return null;
}
var element = payload.Value;
var exportId = GetStringProperty(element, "exportId");
if (string.IsNullOrWhiteSpace(exportId))
{
return null;
}
var format = GetStringProperty(element, "format");
var algorithm = default(string?);
var digest = default(string?);
if (TryGetPropertyCaseInsensitive(element, "artifact", out var artifact) && artifact.ValueKind == JsonValueKind.Object)
{
algorithm = GetStringProperty(artifact, "algorithm");
digest = GetStringProperty(artifact, "digest");
}
var sizeBytes = GetInt64Property(element, "sizeBytes");
var fromCache = GetBooleanProperty(element, "fromCache");
var createdAt = GetDateTimeOffsetProperty(element, "createdAt");
string? rekorLocation = null;
string? rekorIndex = null;
string? rekorInclusion = null;
if (TryGetPropertyCaseInsensitive(element, "attestation", out var attestation) && attestation.ValueKind == JsonValueKind.Object)
{
if (TryGetPropertyCaseInsensitive(attestation, "rekor", out var rekor) && rekor.ValueKind == JsonValueKind.Object)
{
rekorLocation = GetStringProperty(rekor, "location");
rekorIndex = GetStringProperty(rekor, "logIndex");
var inclusion = GetStringProperty(rekor, "inclusionProofUri");
if (!string.IsNullOrWhiteSpace(inclusion))
{
rekorInclusion = inclusion;
}
}
}
return new ExcititorExportManifestSummary(
exportId.Trim(),
format,
algorithm,
digest,
sizeBytes,
fromCache,
createdAt,
rekorLocation,
rekorIndex,
rekorInclusion);
}
private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property))
{
return true;
}
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var candidate in element.EnumerateObject())
{
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
property = candidate.Value;
return true;
}
}
}
property = default;
return false;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.ToString(),
_ => null
};
}
return null;
}
private static bool? GetBooleanProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
_ => null
};
}
return null;
}
private static long? GetInt64Property(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
{
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
{
return value;
}
if (property.ValueKind == JsonValueKind.String
&& long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
}
return null;
}
private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName)
{
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)
&& property.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value))
{
return value.ToUniversalTime();
}
return null;
}
private static string BuildDigestDisplay(string? algorithm, string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return string.Empty;
}
if (digest.Contains(':', StringComparison.Ordinal))
{
return digest;
}
if (string.IsNullOrWhiteSpace(algorithm) || algorithm.Equals("sha256", StringComparison.OrdinalIgnoreCase))
{
return $"sha256:{digest}";
}
return $"{algorithm}:{digest}";
}
private static string FormatSize(long sizeBytes)
{
if (sizeBytes < 0)
{
return $"{sizeBytes} bytes";
}
string[] units = { "bytes", "KB", "MB", "GB", "TB" };
double size = sizeBytes;
var unit = 0;
while (size >= 1024 && unit < units.Length - 1)
{
size /= 1024;
unit++;
}
return unit == 0 ? $"{sizeBytes} bytes" : $"{size:0.##} {units[unit]}";
}
private static string ResolveExportOutputPath(string outputPath, ExcititorExportManifestSummary manifest)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
throw new ArgumentException("Output path must be provided.", nameof(outputPath));
}
var fullPath = Path.GetFullPath(outputPath);
if (Directory.Exists(fullPath)
|| outputPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|| outputPath.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal))
{
return Path.Combine(fullPath, BuildExportFileName(manifest));
}
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
return fullPath;
}
private static string BuildExportFileName(ExcititorExportManifestSummary manifest)
{
var token = !string.IsNullOrWhiteSpace(manifest.Digest)
? manifest.Digest!
: manifest.ExportId;
token = SanitizeToken(token);
if (token.Length > 40)
{
token = token[..40];
}
var extension = DetermineExportExtension(manifest.Format);
return $"stellaops-excititor-{token}{extension}";
}
private static string DetermineExportExtension(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return ".bin";
}
return format switch
{
not null when format.Equals("jsonl", StringComparison.OrdinalIgnoreCase) => ".jsonl",
not null when format.Equals("json", StringComparison.OrdinalIgnoreCase) => ".json",
not null when format.Equals("openvex", StringComparison.OrdinalIgnoreCase) => ".json",
not null when format.Equals("csaf", StringComparison.OrdinalIgnoreCase) => ".json",
_ => ".bin"
};
}
private static string SanitizeToken(string token)
{
var builder = new StringBuilder(token.Length);
foreach (var ch in token)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToLowerInvariant(ch));
}
}
if (builder.Length == 0)
{
builder.Append("export");
}
return builder.ToString();
}
private static string? ResolveLocationUrl(StellaOpsCliOptions options, string location)
{
if (string.IsNullOrWhiteSpace(location))
{
return null;
}
if (Uri.TryCreate(location, UriKind.Absolute, out var absolute))
{
return absolute.ToString();
}
if (!string.IsNullOrWhiteSpace(options?.BackendUrl) && Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
if (!location.StartsWith("/", StringComparison.Ordinal))
{
location = "/" + location;
}
return new Uri(baseUri, location).ToString();
}
return location;
}
private static string BuildRuntimePolicyJson(RuntimePolicyEvaluationResult result, IReadOnlyList<string> requestedImages)
{
var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
var results = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var image in orderedImages)
{
if (result.Decisions.TryGetValue(image, out var decision))
{
results[image] = BuildDecisionMap(decision);
}
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["ttlSeconds"] = result.TtlSeconds,
["expiresAtUtc"] = result.ExpiresAtUtc?.ToString("O", CultureInfo.InvariantCulture),
["policyRevision"] = result.PolicyRevision,
["results"] = results
};
return JsonSerializer.Serialize(payload, options);
}
private static IDictionary<string, object?> BuildDecisionMap(RuntimePolicyImageDecision decision)
{
var map = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["policyVerdict"] = decision.PolicyVerdict,
["signed"] = decision.Signed,
["hasSbomReferrers"] = decision.HasSbomReferrers
};
if (decision.Reasons.Count > 0)
{
map["reasons"] = decision.Reasons;
}
if (decision.Rekor is not null)
{
var rekorMap = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid))
{
rekorMap["uuid"] = decision.Rekor.Uuid;
}
if (!string.IsNullOrWhiteSpace(decision.Rekor.Url))
{
rekorMap["url"] = decision.Rekor.Url;
}
if (decision.Rekor.Verified.HasValue)
{
rekorMap["verified"] = decision.Rekor.Verified;
}
if (rekorMap.Count > 0)
{
map["rekor"] = rekorMap;
}
}
foreach (var kvp in decision.AdditionalProperties)
{
map[kvp.Key] = kvp.Value;
}
return map;
}
private static void DisplayRuntimePolicyResults(ILogger logger, RuntimePolicyEvaluationResult result, IReadOnlyList<string> requestedImages)
{
var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
var summary = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().Border(TableBorder.Rounded)
.AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation");
foreach (var image in orderedImages)
{
if (result.Decisions.TryGetValue(image, out var decision))
{
table.AddRow(
image,
decision.PolicyVerdict,
FormatBoolean(decision.Signed),
FormatBoolean(decision.HasSbomReferrers),
FormatQuietedDisplay(decision.AdditionalProperties),
FormatConfidenceDisplay(decision.AdditionalProperties),
decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
FormatAttestation(decision.Rekor));
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
if (decision.AdditionalProperties.Count > 0)
{
var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}"));
logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata);
}
}
else
{
table.AddRow(image, "<missing>", "-", "-", "-", "-", "-", "-");
}
}
AnsiConsole.Write(table);
}
else
{
foreach (var image in orderedImages)
{
if (result.Decisions.TryGetValue(image, out var decision))
{
var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none";
logger.LogInformation(
"{Image} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}",
image,
decision.PolicyVerdict,
FormatBoolean(decision.Signed),
FormatBoolean(decision.HasSbomReferrers),
FormatQuietedDisplay(decision.AdditionalProperties),
FormatConfidenceDisplay(decision.AdditionalProperties),
FormatAttestation(decision.Rekor),
reasons);
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
if (decision.AdditionalProperties.Count > 0)
{
var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}"));
logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata);
}
}
else
{
logger.LogWarning("{Image} -> no decision returned by backend.", image);
}
}
}
if (summary.Count > 0)
{
var summaryText = string.Join(", ", summary.Select(kvp => $"{kvp.Key}:{kvp.Value}"));
logger.LogInformation("Verdict summary: {Summary}", summaryText);
}
}
private static IReadOnlyList<string> BuildImageOrder(IReadOnlyList<string> requestedImages, IEnumerable<string> actual)
{
var order = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
if (requestedImages is not null)
{
foreach (var image in requestedImages)
{
if (!string.IsNullOrWhiteSpace(image))
{
var trimmed = image.Trim();
if (seen.Add(trimmed))
{
order.Add(trimmed);
}
}
}
}
foreach (var image in actual)
{
if (!string.IsNullOrWhiteSpace(image))
{
var trimmed = image.Trim();
if (seen.Add(trimmed))
{
order.Add(trimmed);
}
}
}
return new ReadOnlyCollection<string>(order);
}
private static string FormatBoolean(bool? value)
=> value is null ? "unknown" : value.Value ? "yes" : "no";
private static string FormatQuietedDisplay(IReadOnlyDictionary<string, object?> metadata)
{
var quieted = GetMetadataBoolean(metadata, "quieted", "quiet");
var quietedBy = GetMetadataString(metadata, "quietedBy", "quietedReason");
if (quieted is true)
{
return string.IsNullOrWhiteSpace(quietedBy) ? "yes" : $"yes ({quietedBy})";
}
if (quieted is false)
{
return "no";
}
return string.IsNullOrWhiteSpace(quietedBy) ? "-" : $"? ({quietedBy})";
}
private static string FormatConfidenceDisplay(IReadOnlyDictionary<string, object?> metadata)
{
var confidence = GetMetadataDouble(metadata, "confidence");
var confidenceBand = GetMetadataString(metadata, "confidenceBand", "confidenceTier");
if (confidence.HasValue && !string.IsNullOrWhiteSpace(confidenceBand))
{
return string.Format(CultureInfo.InvariantCulture, "{0:0.###} ({1})", confidence.Value, confidenceBand);
}
if (confidence.HasValue)
{
return confidence.Value.ToString("0.###", CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(confidenceBand))
{
return confidenceBand!;
}
return "-";
}
private static string FormatAttestation(RuntimePolicyRekorReference? rekor)
{
if (rekor is null)
{
return "-";
}
var uuid = string.IsNullOrWhiteSpace(rekor.Uuid) ? null : rekor.Uuid;
var url = string.IsNullOrWhiteSpace(rekor.Url) ? null : rekor.Url;
var verified = rekor.Verified;
var core = uuid ?? url;
if (!string.IsNullOrEmpty(core))
{
if (verified.HasValue)
{
var suffix = verified.Value ? " (verified)" : " (unverified)";
return core + suffix;
}
return core!;
}
if (verified.HasValue)
{
return verified.Value ? "verified" : "unverified";
}
return "-";
}
private static bool? GetMetadataBoolean(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && value is not null)
{
switch (value)
{
case bool b:
return b;
case string s when bool.TryParse(s, out var parsed):
return parsed;
}
}
}
return null;
}
private static string? GetMetadataString(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && value is not null)
{
if (value is string s)
{
return string.IsNullOrWhiteSpace(s) ? null : s;
}
}
}
return null;
}
private static double? GetMetadataDouble(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && value is not null)
{
switch (value)
{
case double d:
return d;
case float f:
return f;
case decimal m:
return (double)m;
case long l:
return l;
case int i:
return i;
case string s when double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed):
return parsed;
}
}
}
return null;
}
private static TaskRunnerSimulationOutputFormat DetermineTaskRunnerSimulationFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => TaskRunnerSimulationOutputFormat.Table,
"json" => TaskRunnerSimulationOutputFormat.Json,
_ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.")
};
}
if (!string.IsNullOrWhiteSpace(outputPath))
{
return TaskRunnerSimulationOutputFormat.Json;
}
return TaskRunnerSimulationOutputFormat.Table;
}
private static object BuildTaskRunnerSimulationPayload(TaskRunnerSimulationResult result)
=> new
{
planHash = result.PlanHash,
failurePolicy = new
{
result.FailurePolicy.MaxAttempts,
result.FailurePolicy.BackoffSeconds,
result.FailurePolicy.ContinueOnError
},
hasPendingApprovals = result.HasPendingApprovals,
steps = result.Steps,
outputs = result.Outputs
};
private static void RenderTaskRunnerSimulationResult(TaskRunnerSimulationResult result)
{
var console = AnsiConsole.Console;
var table = new Table
{
Border = TableBorder.Rounded
};
table.AddColumn("Step");
table.AddColumn("Kind");
table.AddColumn("Status");
table.AddColumn("Reason");
table.AddColumn("MaxParallel");
table.AddColumn("ContinueOnError");
table.AddColumn("Approval");
foreach (var (step, depth) in FlattenTaskRunnerSimulationSteps(result.Steps))
{
var indent = new string(' ', depth * 2);
table.AddRow(
Markup.Escape($"{indent}{step.Id}"),
Markup.Escape(step.Kind),
Markup.Escape(step.Status),
Markup.Escape(string.IsNullOrWhiteSpace(step.StatusReason) ? "-" : step.StatusReason!),
step.MaxParallel?.ToString(CultureInfo.InvariantCulture) ?? "-",
step.ContinueOnError ? "yes" : "no",
Markup.Escape(string.IsNullOrWhiteSpace(step.ApprovalId) ? "-" : step.ApprovalId!));
}
console.Write(table);
if (result.Outputs.Count > 0)
{
var outputsTable = new Table
{
Border = TableBorder.Rounded
};
outputsTable.AddColumn("Name");
outputsTable.AddColumn("Type");
outputsTable.AddColumn("Requires Runtime");
outputsTable.AddColumn("Path");
outputsTable.AddColumn("Expression");
foreach (var output in result.Outputs)
{
outputsTable.AddRow(
Markup.Escape(output.Name),
Markup.Escape(output.Type),
output.RequiresRuntimeValue ? "yes" : "no",
Markup.Escape(string.IsNullOrWhiteSpace(output.PathExpression) ? "-" : output.PathExpression!),
Markup.Escape(string.IsNullOrWhiteSpace(output.ValueExpression) ? "-" : output.ValueExpression!));
}
console.WriteLine();
console.Write(outputsTable);
}
console.WriteLine();
console.MarkupLine($"[grey]Plan Hash:[/] {Markup.Escape(result.PlanHash)}");
console.MarkupLine($"[grey]Pending Approvals:[/] {(result.HasPendingApprovals ? "yes" : "no")}");
console.Write(new Text($"Plan Hash: {result.PlanHash}{Environment.NewLine}"));
console.Write(new Text($"Pending Approvals: {(result.HasPendingApprovals ? "yes" : "no")}{Environment.NewLine}"));
}
private static IEnumerable<(TaskRunnerSimulationStep Step, int Depth)> FlattenTaskRunnerSimulationSteps(
IReadOnlyList<TaskRunnerSimulationStep> steps,
int depth = 0)
{
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
yield return (step, depth);
foreach (var child in FlattenTaskRunnerSimulationSteps(step.Children, depth + 1))
{
yield return child;
}
}
}
private static PolicySimulationOutputFormat DeterminePolicySimulationFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => PolicySimulationOutputFormat.Table,
"json" => PolicySimulationOutputFormat.Json,
"markdown" or "md" => PolicySimulationOutputFormat.Markdown,
_ => throw new ArgumentException("Invalid format. Use 'table', 'json', or 'markdown'.")
};
}
// CLI-POLICY-27-003: Infer format from output file extension
if (!string.IsNullOrWhiteSpace(outputPath))
{
var extension = Path.GetExtension(outputPath).ToLowerInvariant();
return extension switch
{
".md" or ".markdown" => PolicySimulationOutputFormat.Markdown,
".json" => PolicySimulationOutputFormat.Json,
_ => PolicySimulationOutputFormat.Json
};
}
if (Console.IsOutputRedirected)
{
return PolicySimulationOutputFormat.Json;
}
return PolicySimulationOutputFormat.Table;
}
private static object BuildPolicySimulationPayload(
string policyId,
int? baseVersion,
int? candidateVersion,
IReadOnlyList<string> sbomSet,
IReadOnlyDictionary<string, object?> environment,
PolicySimulationResult result)
=> new
{
policyId,
baseVersion,
candidateVersion,
sbomSet = sbomSet.Count == 0 ? Array.Empty<string>() : sbomSet,
environment = environment.Count == 0 ? null : environment,
diff = result.Diff,
explainUri = result.ExplainUri
};
private static void RenderPolicySimulationResult(
ILogger logger,
object payload,
PolicySimulationResult result,
PolicySimulationOutputFormat format)
{
if (format == PolicySimulationOutputFormat.Json)
{
var json = JsonSerializer.Serialize(payload, SimulationJsonOptions);
Console.WriteLine(json);
return;
}
// CLI-POLICY-27-003: Handle markdown console output
if (format == PolicySimulationOutputFormat.Markdown)
{
RenderPolicySimulationMarkdown(result);
return;
}
logger.LogInformation(
"Policy diff summary — Added: {Added}, Removed: {Removed}, Unchanged: {Unchanged}.",
result.Diff.Added,
result.Diff.Removed,
result.Diff.Unchanged);
// CLI-POLICY-27-003: Render heatmap summary if present
if (result.Heatmap is not null)
{
RenderPolicySimulationHeatmap(result.Heatmap);
}
if (result.Diff.BySeverity.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().AddColumns("Severity", "Up", "Down");
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
table.AddRow(
entry.Key,
FormatDelta(entry.Value.Up),
FormatDelta(entry.Value.Down));
}
AnsiConsole.Write(table);
}
else
{
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
logger.LogInformation("Severity {Severity}: up={Up}, down={Down}", entry.Key, entry.Value.Up ?? 0, entry.Value.Down ?? 0);
}
}
}
if (result.Diff.RuleHits.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().AddColumns("Rule", "Up", "Down");
foreach (var hit in result.Diff.RuleHits)
{
table.AddRow(
string.IsNullOrWhiteSpace(hit.RuleName) ? hit.RuleId : $"{hit.RuleName} ({hit.RuleId})",
FormatDelta(hit.Up),
FormatDelta(hit.Down));
}
AnsiConsole.Write(table);
}
else
{
foreach (var hit in result.Diff.RuleHits)
{
logger.LogInformation("Rule {RuleId}: up={Up}, down={Down}", hit.RuleId, hit.Up ?? 0, hit.Down ?? 0);
}
}
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
logger.LogInformation("Explain trace available at {ExplainUri}.", result.ExplainUri);
}
}
// CLI-POLICY-27-003: Render heatmap severity visualization
private static void RenderPolicySimulationHeatmap(PolicySimulationHeatmap heatmap)
{
if (!AnsiConsole.Profile.Capabilities.Interactive)
{
Console.WriteLine($"Heatmap: Critical={heatmap.Critical}, High={heatmap.High}, Medium={heatmap.Medium}, Low={heatmap.Low}, Info={heatmap.Info}");
return;
}
var grid = new Grid();
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddColumn(new GridColumn().NoWrap());
grid.AddRow(
new Markup($"[bold red]Critical: {heatmap.Critical}[/]"),
new Markup($"[bold orange1]High: {heatmap.High}[/]"),
new Markup($"[bold yellow]Medium: {heatmap.Medium}[/]"),
new Markup($"[bold blue]Low: {heatmap.Low}[/]"),
new Markup($"[bold grey]Info: {heatmap.Info}[/]"));
var panel = new Panel(grid)
.Header("[bold]Severity Heatmap[/]")
.Border(BoxBorder.Rounded);
AnsiConsole.Write(panel);
}
// CLI-POLICY-27-003: Render markdown output to console
private static void RenderPolicySimulationMarkdown(PolicySimulationResult result)
{
Console.WriteLine("# Policy Simulation Report");
Console.WriteLine();
Console.WriteLine("## Summary");
Console.WriteLine();
Console.WriteLine("| Metric | Count |");
Console.WriteLine("|--------|-------|");
Console.WriteLine($"| Added | {result.Diff.Added} |");
Console.WriteLine($"| Removed | {result.Diff.Removed} |");
Console.WriteLine($"| Unchanged | {result.Diff.Unchanged} |");
Console.WriteLine();
if (result.Heatmap is not null)
{
Console.WriteLine("## Severity Heatmap");
Console.WriteLine();
Console.WriteLine("| Severity | Count |");
Console.WriteLine("|----------|-------|");
Console.WriteLine($"| Critical | {result.Heatmap.Critical} |");
Console.WriteLine($"| High | {result.Heatmap.High} |");
Console.WriteLine($"| Medium | {result.Heatmap.Medium} |");
Console.WriteLine($"| Low | {result.Heatmap.Low} |");
Console.WriteLine($"| Info | {result.Heatmap.Info} |");
Console.WriteLine();
}
if (result.Diff.BySeverity.Count > 0)
{
Console.WriteLine("## Changes by Severity");
Console.WriteLine();
Console.WriteLine("| Severity | Up | Down |");
Console.WriteLine("|----------|-----|------|");
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
Console.WriteLine($"| {entry.Key} | {entry.Value.Up ?? 0} | {entry.Value.Down ?? 0} |");
}
Console.WriteLine();
}
if (result.Diff.RuleHits.Count > 0)
{
Console.WriteLine("## Rule Impacts");
Console.WriteLine();
Console.WriteLine("| Rule | Up | Down |");
Console.WriteLine("|------|-----|------|");
foreach (var hit in result.Diff.RuleHits)
{
var ruleName = string.IsNullOrWhiteSpace(hit.RuleName) ? hit.RuleId : $"{hit.RuleName} ({hit.RuleId})";
Console.WriteLine($"| {ruleName} | {hit.Up ?? 0} | {hit.Down ?? 0} |");
}
Console.WriteLine();
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
Console.WriteLine("## Resources");
Console.WriteLine();
Console.WriteLine($"- [Explain Trace]({result.ExplainUri})");
}
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
if (string.IsNullOrWhiteSpace(result.ExplainUri))
{
Console.WriteLine("## Resources");
Console.WriteLine();
}
Console.WriteLine($"- [Manifest Download]({result.ManifestDownloadUri})");
}
}
private static IReadOnlyList<string> NormalizePolicySbomSet(IReadOnlyList<string> arguments)
{
if (arguments is null || arguments.Count == 0)
{
return EmptyPolicySbomSet;
}
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var raw in arguments)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
if (trimmed.Length > 0)
{
set.Add(trimmed);
}
}
if (set.Count == 0)
{
return EmptyPolicySbomSet;
}
var list = set.ToList();
return new ReadOnlyCollection<string>(list);
}
private static IReadOnlyDictionary<string, object?> ParsePolicyEnvironment(IReadOnlyList<string> arguments)
{
if (arguments is null || arguments.Count == 0)
{
return EmptyPolicyEnvironment;
}
var env = new SortedDictionary<string, object?>(StringComparer.Ordinal);
foreach (var raw in arguments)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var trimmed = raw.Trim();
var separator = trimmed.IndexOf('=');
if (separator <= 0 || separator == trimmed.Length - 1)
{
throw new ArgumentException($"Invalid environment assignment '{raw}'. Expected key=value.");
}
var key = trimmed[..separator].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(key))
{
throw new ArgumentException($"Invalid environment assignment '{raw}'. Expected key=value.");
}
var valueToken = trimmed[(separator + 1)..].Trim();
env[key] = ParsePolicyEnvironmentValue(valueToken);
}
return env.Count == 0 ? EmptyPolicyEnvironment : new ReadOnlyDictionary<string, object?>(env);
}
private static object? ParsePolicyEnvironmentValue(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return string.Empty;
}
var value = token;
if ((value.Length >= 2 && value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) ||
(value.Length >= 2 && value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal)))
{
value = value[1..^1];
}
if (string.Equals(value, "null", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (bool.TryParse(value, out var boolResult))
{
return boolResult;
}
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longResult))
{
return longResult;
}
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleResult))
{
return doubleResult;
}
return value;
}
// CLI-SIG-26-002: Parse reachability overrides from CLI arguments
private static IReadOnlyList<ReachabilityOverride> ParseReachabilityOverrides(
IReadOnlyList<string> stateOverrides,
IReadOnlyList<string> scoreOverrides)
{
var overrides = new Dictionary<string, ReachabilityOverride>(StringComparer.OrdinalIgnoreCase);
// Parse state overrides (format: "CVE-XXXX:reachable" or "pkg:npm/lodash@4.17.0:unreachable")
foreach (var raw in stateOverrides)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
var (target, value) = ParseOverrideValue(raw, "state");
if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(value))
continue;
var state = value.ToLowerInvariant() switch
{
"reachable" => "reachable",
"unreachable" => "unreachable",
"unknown" => "unknown",
"indeterminate" => "indeterminate",
_ => throw new ArgumentException($"Invalid reachability state '{value}'. Use 'reachable', 'unreachable', 'unknown', or 'indeterminate'.")
};
if (!overrides.TryGetValue(target, out var existing))
{
existing = CreateReachabilityOverride(target);
}
overrides[target] = existing with { State = state };
}
// Parse score overrides (format: "CVE-XXXX:0.85" or "pkg:npm/lodash@4.17.0:0.5")
foreach (var raw in scoreOverrides)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
var (target, value) = ParseOverrideValue(raw, "score");
if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(value))
continue;
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var score))
{
throw new ArgumentException($"Invalid reachability score '{value}'. Expected a decimal number between 0 and 1.");
}
if (score < 0 || score > 1)
{
throw new ArgumentException($"Reachability score '{score}' out of range. Expected a value between 0 and 1.");
}
if (!overrides.TryGetValue(target, out var existing))
{
existing = CreateReachabilityOverride(target);
}
overrides[target] = existing with { Score = score };
}
return overrides.Values.ToList();
}
private static (string Target, string Value) ParseOverrideValue(string raw, string overrideType)
{
var trimmed = raw.Trim();
// Handle PURL format which contains colons (pkg:type/name@version)
int separatorIndex;
if (trimmed.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
// Find the last colon which separates the value
separatorIndex = trimmed.LastIndexOf(':');
if (separatorIndex <= 4) // "pkg:" is 4 chars
{
throw new ArgumentException($"Invalid {overrideType} override format '{raw}'. Expected 'pkg:type/name@version:{overrideType}'.");
}
}
else
{
// CVE or other identifier format
separatorIndex = trimmed.LastIndexOf(':');
}
if (separatorIndex < 0 || separatorIndex == trimmed.Length - 1)
{
throw new ArgumentException($"Invalid {overrideType} override format '{raw}'. Expected 'identifier:{overrideType}'.");
}
var target = trimmed[..separatorIndex].Trim();
var value = trimmed[(separatorIndex + 1)..].Trim();
return (target, value);
}
private static ReachabilityOverride CreateReachabilityOverride(string target)
{
// Determine if target is a vulnerability ID or package PURL
if (target.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return new ReachabilityOverride { PackagePurl = target, State = string.Empty };
}
else
{
return new ReachabilityOverride { VulnerabilityId = target, State = string.Empty };
}
}
private static Task WriteSimulationOutputAsync(string outputPath, object payload, CancellationToken cancellationToken)
=> WriteJsonPayloadAsync(outputPath, payload, cancellationToken);
// CLI-POLICY-27-003: Write markdown report for CI integration
private static async Task WriteMarkdownSimulationOutputAsync(
string outputPath,
string policyId,
PolicySimulationResult result,
CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(outputPath);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var sb = new StringBuilder();
sb.AppendLine("# Policy Simulation Report");
sb.AppendLine();
sb.AppendLine($"**Policy:** `{policyId}` ");
sb.AppendLine($"**Generated:** {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine();
sb.AppendLine("## Summary");
sb.AppendLine();
sb.AppendLine("| Metric | Count |");
sb.AppendLine("|--------|-------|");
sb.AppendLine($"| Added | {result.Diff.Added} |");
sb.AppendLine($"| Removed | {result.Diff.Removed} |");
sb.AppendLine($"| Unchanged | {result.Diff.Unchanged} |");
sb.AppendLine();
if (result.Heatmap is not null)
{
sb.AppendLine("## Severity Heatmap");
sb.AppendLine();
sb.AppendLine("| Severity | Count |");
sb.AppendLine("|----------|-------|");
sb.AppendLine($"| Critical | {result.Heatmap.Critical} |");
sb.AppendLine($"| High | {result.Heatmap.High} |");
sb.AppendLine($"| Medium | {result.Heatmap.Medium} |");
sb.AppendLine($"| Low | {result.Heatmap.Low} |");
sb.AppendLine($"| Info | {result.Heatmap.Info} |");
sb.AppendLine();
}
if (result.Diff.BySeverity.Count > 0)
{
sb.AppendLine("## Changes by Severity");
sb.AppendLine();
sb.AppendLine("| Severity | Up | Down |");
sb.AppendLine("|----------|-----|------|");
foreach (var entry in result.Diff.BySeverity.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
sb.AppendLine($"| {entry.Key} | {entry.Value.Up ?? 0} | {entry.Value.Down ?? 0} |");
}
sb.AppendLine();
}
if (result.Diff.RuleHits.Count > 0)
{
sb.AppendLine("## Rule Impacts");
sb.AppendLine();
sb.AppendLine("| Rule | Up | Down |");
sb.AppendLine("|------|-----|------|");
foreach (var hit in result.Diff.RuleHits)
{
var ruleName = string.IsNullOrWhiteSpace(hit.RuleName) ? hit.RuleId : $"{hit.RuleName} ({hit.RuleId})";
sb.AppendLine($"| {ruleName} | {hit.Up ?? 0} | {hit.Down ?? 0} |");
}
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(result.ExplainUri))
{
sb.AppendLine("## Additional Resources");
sb.AppendLine();
sb.AppendLine($"- [Explain Trace]({result.ExplainUri})");
}
if (!string.IsNullOrWhiteSpace(result.ManifestDownloadUri))
{
if (string.IsNullOrWhiteSpace(result.ExplainUri))
{
sb.AppendLine("## Additional Resources");
sb.AppendLine();
}
sb.AppendLine($"- [Manifest Download]({result.ManifestDownloadUri})");
if (!string.IsNullOrWhiteSpace(result.ManifestDigest))
{
sb.AppendLine($" - Digest: `{result.ManifestDigest}`");
}
}
await File.WriteAllTextAsync(fullPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
private static async Task WriteJsonPayloadAsync(string outputPath, object payload, CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(outputPath);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(payload, SimulationJsonOptions);
await File.WriteAllTextAsync(fullPath, json + Environment.NewLine, cancellationToken).ConfigureAwait(false);
}
private static int DetermineSimulationExitCode(PolicySimulationResult result, bool failOnDiff)
{
if (!failOnDiff)
{
return 0;
}
return (result.Diff.Added + result.Diff.Removed) > 0 ? 20 : 0;
}
private static void HandlePolicySimulationFailure(PolicyApiException exception, ILogger logger)
{
var exitCode = exception.ErrorCode switch
{
"ERR_POL_001" => 10,
"ERR_POL_002" or "ERR_POL_005" => 12,
"ERR_POL_003" => 21,
"ERR_POL_004" => 22,
"ERR_POL_006" => 23,
_ when exception.StatusCode == HttpStatusCode.Forbidden || exception.StatusCode == HttpStatusCode.Unauthorized => 12,
_ => 1
};
if (string.IsNullOrWhiteSpace(exception.ErrorCode))
{
logger.LogError("Policy simulation failed ({StatusCode}): {Message}", (int)exception.StatusCode, exception.Message);
}
else
{
logger.LogError("Policy simulation failed ({StatusCode} {Code}): {Message}", (int)exception.StatusCode, exception.ErrorCode, exception.Message);
}
CliMetrics.RecordPolicySimulation("error");
Environment.ExitCode = exitCode;
}
private static void HandlePolicyActivationFailure(PolicyApiException exception, ILogger logger)
{
var exitCode = exception.ErrorCode switch
{
"ERR_POL_002" => 70,
"ERR_POL_003" => 71,
"ERR_POL_004" => 72,
_ when exception.StatusCode == HttpStatusCode.Forbidden || exception.StatusCode == HttpStatusCode.Unauthorized => 12,
_ => 1
};
if (string.IsNullOrWhiteSpace(exception.ErrorCode))
{
logger.LogError("Policy activation failed ({StatusCode}): {Message}", (int)exception.StatusCode, exception.Message);
}
else
{
logger.LogError("Policy activation failed ({StatusCode} {Code}): {Message}", (int)exception.StatusCode, exception.ErrorCode, exception.Message);
}
CliMetrics.RecordPolicyActivation("error");
Environment.ExitCode = exitCode;
}
private static IReadOnlyList<string> NormalizePolicyFilterValues(string[] values, bool toLower = false)
{
if (values is null || values.Length == 0)
{
return Array.Empty<string>();
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var list = new List<string>();
foreach (var raw in values)
{
var candidate = raw?.Trim();
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
var normalized = toLower ? candidate.ToLowerInvariant() : candidate;
if (set.Add(normalized))
{
list.Add(normalized);
}
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static string? NormalizePolicyPriority(string? priority)
{
if (string.IsNullOrWhiteSpace(priority))
{
return null;
}
var normalized = priority.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized.ToLowerInvariant();
}
private static string NormalizePolicyActivationOutcome(string status)
{
if (string.IsNullOrWhiteSpace(status))
{
return "unknown";
}
return status.Trim().ToLowerInvariant();
}
private static int DeterminePolicyActivationExitCode(string outcome)
=> string.Equals(outcome, "pending_second_approval", StringComparison.Ordinal) ? 75 : 0;
private static void RenderPolicyActivationResult(PolicyActivationResult result, PolicyActivationRequest request)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var summary = new Table().Expand();
summary.Border(TableBorder.Rounded);
summary.AddColumn(new TableColumn("[grey]Field[/]").LeftAligned());
summary.AddColumn(new TableColumn("[grey]Value[/]").LeftAligned());
summary.AddRow("Policy", Markup.Escape($"{result.Revision.PolicyId} v{result.Revision.Version}"));
summary.AddRow("Status", FormatActivationStatus(result.Status));
summary.AddRow("Requires 2 approvals", result.Revision.RequiresTwoPersonApproval ? "[yellow]yes[/]" : "[green]no[/]");
summary.AddRow("Created (UTC)", Markup.Escape(FormatUpdatedAt(result.Revision.CreatedAt)));
summary.AddRow("Activated (UTC)", result.Revision.ActivatedAt.HasValue
? Markup.Escape(FormatUpdatedAt(result.Revision.ActivatedAt.Value))
: "[grey](not yet active)[/]");
if (request.RunNow)
{
summary.AddRow("Run", "[green]immediate[/]");
}
else if (request.ScheduledAt.HasValue)
{
summary.AddRow("Scheduled at", Markup.Escape(FormatUpdatedAt(request.ScheduledAt.Value)));
}
if (!string.IsNullOrWhiteSpace(request.Priority))
{
summary.AddRow("Priority", Markup.Escape(request.Priority!));
}
if (request.Rollback)
{
summary.AddRow("Rollback", "[yellow]yes[/]");
}
if (!string.IsNullOrWhiteSpace(request.IncidentId))
{
summary.AddRow("Incident", Markup.Escape(request.IncidentId!));
}
if (!string.IsNullOrWhiteSpace(request.Comment))
{
summary.AddRow("Note", Markup.Escape(request.Comment!));
}
AnsiConsole.Write(summary);
if (result.Revision.Approvals.Count > 0)
{
var approvalTable = new Table().Title("[grey]Approvals[/]");
approvalTable.Border(TableBorder.Minimal);
approvalTable.AddColumn(new TableColumn("Actor").LeftAligned());
approvalTable.AddColumn(new TableColumn("Approved (UTC)").LeftAligned());
approvalTable.AddColumn(new TableColumn("Comment").LeftAligned());
foreach (var approval in result.Revision.Approvals)
{
var comment = string.IsNullOrWhiteSpace(approval.Comment) ? "-" : approval.Comment!;
approvalTable.AddRow(
Markup.Escape(approval.ActorId),
Markup.Escape(FormatUpdatedAt(approval.ApprovedAt)),
Markup.Escape(comment));
}
AnsiConsole.Write(approvalTable);
}
else
{
AnsiConsole.MarkupLine("[grey]No activation approvals recorded yet.[/]");
}
}
else
{
Console.WriteLine(FormattableString.Invariant($"Policy: {result.Revision.PolicyId} v{result.Revision.Version}"));
Console.WriteLine(FormattableString.Invariant($"Status: {NormalizePolicyActivationOutcome(result.Status)}"));
Console.WriteLine(FormattableString.Invariant($"Requires 2 approvals: {(result.Revision.RequiresTwoPersonApproval ? "yes" : "no")}"));
Console.WriteLine(FormattableString.Invariant($"Created (UTC): {FormatUpdatedAt(result.Revision.CreatedAt)}"));
Console.WriteLine(FormattableString.Invariant($"Activated (UTC): {(result.Revision.ActivatedAt.HasValue ? FormatUpdatedAt(result.Revision.ActivatedAt.Value) : "(not yet active)")}"));
if (request.RunNow)
{
Console.WriteLine("Run: immediate");
}
else if (request.ScheduledAt.HasValue)
{
Console.WriteLine(FormattableString.Invariant($"Scheduled at: {FormatUpdatedAt(request.ScheduledAt.Value)}"));
}
if (!string.IsNullOrWhiteSpace(request.Priority))
{
Console.WriteLine(FormattableString.Invariant($"Priority: {request.Priority}"));
}
if (request.Rollback)
{
Console.WriteLine("Rollback: yes");
}
if (!string.IsNullOrWhiteSpace(request.IncidentId))
{
Console.WriteLine(FormattableString.Invariant($"Incident: {request.IncidentId}"));
}
if (!string.IsNullOrWhiteSpace(request.Comment))
{
Console.WriteLine(FormattableString.Invariant($"Note: {request.Comment}"));
}
if (result.Revision.Approvals.Count == 0)
{
Console.WriteLine("Approvals: none");
}
else
{
foreach (var approval in result.Revision.Approvals)
{
var comment = string.IsNullOrWhiteSpace(approval.Comment) ? "-" : approval.Comment;
Console.WriteLine(FormattableString.Invariant($"Approval: {approval.ActorId} at {FormatUpdatedAt(approval.ApprovedAt)} ({comment})"));
}
}
}
}
private static string FormatActivationStatus(string status)
{
var normalized = NormalizePolicyActivationOutcome(status);
return normalized switch
{
"activated" => "[green]activated[/]",
"already_active" => "[yellow]already_active[/]",
"pending_second_approval" => "[yellow]pending_second_approval[/]",
_ => "[red]" + Markup.Escape(string.IsNullOrWhiteSpace(status) ? "unknown" : status) + "[/]"
};
}
private static DateTimeOffset? ParsePolicySince(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTimeOffset.TryParse(
value.Trim(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return parsed.ToUniversalTime();
}
throw new ArgumentException("Invalid --since value. Use an ISO-8601 timestamp.");
}
private static string? NormalizeExplainMode(string? mode)
=> string.IsNullOrWhiteSpace(mode) ? null : mode.Trim().ToLowerInvariant();
private static PolicyFindingsOutputFormat DeterminePolicyFindingsFormat(string? value, string? outputPath)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim().ToLowerInvariant() switch
{
"table" => PolicyFindingsOutputFormat.Table,
"json" => PolicyFindingsOutputFormat.Json,
_ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.")
};
}
if (!string.IsNullOrWhiteSpace(outputPath) || Console.IsOutputRedirected)
{
return PolicyFindingsOutputFormat.Json;
}
return PolicyFindingsOutputFormat.Table;
}
private static object BuildPolicyFindingsPayload(
string policyId,
PolicyFindingsQuery query,
PolicyFindingsPage page)
=> new
{
policyId,
filters = new
{
sbom = query.SbomIds,
status = query.Statuses,
severity = query.Severities,
cursor = query.Cursor,
page = query.Page,
pageSize = query.PageSize,
since = query.Since?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)
},
items = page.Items.Select(item => new
{
findingId = item.FindingId,
status = item.Status,
severity = new
{
normalized = item.Severity.Normalized,
score = item.Severity.Score
},
sbomId = item.SbomId,
advisoryIds = item.AdvisoryIds,
vex = item.Vex is null ? null : new
{
winningStatementId = item.Vex.WinningStatementId,
source = item.Vex.Source,
status = item.Vex.Status
},
policyVersion = item.PolicyVersion,
updatedAt = item.UpdatedAt == DateTimeOffset.MinValue ? null : item.UpdatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
runId = item.RunId
}),
nextCursor = page.NextCursor,
totalCount = page.TotalCount
};
private static object BuildPolicyFindingPayload(string policyId, PolicyFindingDocument finding)
=> new
{
policyId,
finding = new
{
findingId = finding.FindingId,
status = finding.Status,
severity = new
{
normalized = finding.Severity.Normalized,
score = finding.Severity.Score
},
sbomId = finding.SbomId,
advisoryIds = finding.AdvisoryIds,
vex = finding.Vex is null ? null : new
{
winningStatementId = finding.Vex.WinningStatementId,
source = finding.Vex.Source,
status = finding.Vex.Status
},
policyVersion = finding.PolicyVersion,
updatedAt = finding.UpdatedAt == DateTimeOffset.MinValue ? null : finding.UpdatedAt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
runId = finding.RunId
}
};
private static object BuildPolicyFindingExplainPayload(
string policyId,
string findingId,
string? mode,
PolicyFindingExplainResult explain)
=> new
{
policyId,
findingId,
mode,
explain = new
{
policyVersion = explain.PolicyVersion,
steps = explain.Steps.Select(step => new
{
rule = step.Rule,
status = step.Status,
action = step.Action,
score = step.Score,
inputs = step.Inputs,
evidence = step.Evidence
}),
sealedHints = explain.SealedHints.Select(hint => hint.Message)
}
};
private static void RenderPolicyFindingsTable(ILogger logger, PolicyFindingsPage page)
{
var items = page.Items;
if (items.Count == 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
AnsiConsole.MarkupLine("[yellow]No findings matched the provided filters.[/]");
}
else
{
logger.LogWarning("No findings matched the provided filters.");
}
return;
}
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table()
.Border(TableBorder.Rounded)
.Centered();
table.AddColumn("Finding");
table.AddColumn("Status");
table.AddColumn("Severity");
table.AddColumn("Score");
table.AddColumn("SBOM");
table.AddColumn("Advisories");
table.AddColumn("Updated (UTC)");
foreach (var item in items)
{
table.AddRow(
Markup.Escape(item.FindingId),
Markup.Escape(item.Status),
Markup.Escape(item.Severity.Normalized),
Markup.Escape(FormatScore(item.Severity.Score)),
Markup.Escape(item.SbomId),
Markup.Escape(FormatListPreview(item.AdvisoryIds)),
Markup.Escape(FormatUpdatedAt(item.UpdatedAt)));
}
AnsiConsole.Write(table);
}
else
{
foreach (var item in items)
{
logger.LogInformation(
"{Finding} — Status {Status}, Severity {Severity} ({Score}), SBOM {Sbom}, Updated {Updated}",
item.FindingId,
item.Status,
item.Severity.Normalized,
item.Severity.Score?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a",
item.SbomId,
FormatUpdatedAt(item.UpdatedAt));
}
}
logger.LogInformation("{Count} finding(s).", items.Count);
if (page.TotalCount.HasValue)
{
logger.LogInformation("Total available: {Total}", page.TotalCount.Value);
}
if (!string.IsNullOrWhiteSpace(page.NextCursor))
{
logger.LogInformation("Next cursor: {Cursor}", page.NextCursor);
}
}
private static void RenderPolicyFindingDetails(ILogger logger, PolicyFindingDocument finding)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Field")
.AddColumn("Value");
table.AddRow("Finding", Markup.Escape(finding.FindingId));
table.AddRow("Status", Markup.Escape(finding.Status));
table.AddRow("Severity", Markup.Escape(FormatSeverity(finding.Severity)));
table.AddRow("SBOM", Markup.Escape(finding.SbomId));
table.AddRow("Policy Version", Markup.Escape(finding.PolicyVersion.ToString(CultureInfo.InvariantCulture)));
table.AddRow("Updated (UTC)", Markup.Escape(FormatUpdatedAt(finding.UpdatedAt)));
table.AddRow("Run Id", Markup.Escape(string.IsNullOrWhiteSpace(finding.RunId) ? "(none)" : finding.RunId));
table.AddRow("Advisories", Markup.Escape(FormatListPreview(finding.AdvisoryIds)));
table.AddRow("VEX", Markup.Escape(FormatVexMetadata(finding.Vex)));
AnsiConsole.Write(table);
}
else
{
logger.LogInformation("Finding {Finding}", finding.FindingId);
logger.LogInformation(" Status: {Status}", finding.Status);
logger.LogInformation(" Severity: {Severity}", FormatSeverity(finding.Severity));
logger.LogInformation(" SBOM: {Sbom}", finding.SbomId);
logger.LogInformation(" Policy version: {Version}", finding.PolicyVersion);
logger.LogInformation(" Updated (UTC): {Updated}", FormatUpdatedAt(finding.UpdatedAt));
if (!string.IsNullOrWhiteSpace(finding.RunId))
{
logger.LogInformation(" Run Id: {Run}", finding.RunId);
}
if (finding.AdvisoryIds.Count > 0)
{
logger.LogInformation(" Advisories: {Advisories}", string.Join(", ", finding.AdvisoryIds));
}
if (!string.IsNullOrWhiteSpace(FormatVexMetadata(finding.Vex)))
{
logger.LogInformation(" VEX: {Vex}", FormatVexMetadata(finding.Vex));
}
}
}
private static void RenderPolicyFindingExplain(ILogger logger, PolicyFindingExplainResult explain)
{
if (explain.Steps.Count == 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
AnsiConsole.MarkupLine("[yellow]No explain steps were returned.[/]");
}
else
{
logger.LogWarning("No explain steps were returned.");
}
}
else if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Rule")
.AddColumn("Status")
.AddColumn("Action")
.AddColumn("Score")
.AddColumn("Inputs")
.AddColumn("Evidence");
foreach (var step in explain.Steps)
{
table.AddRow(
Markup.Escape(step.Rule),
Markup.Escape(step.Status ?? "(n/a)"),
Markup.Escape(step.Action ?? "(n/a)"),
Markup.Escape(step.Score.HasValue ? step.Score.Value.ToString("0.00", CultureInfo.InvariantCulture) : "-"),
Markup.Escape(FormatKeyValuePairs(step.Inputs)),
Markup.Escape(FormatKeyValuePairs(step.Evidence)));
}
AnsiConsole.Write(table);
}
else
{
logger.LogInformation("{Count} explain step(s).", explain.Steps.Count);
foreach (var step in explain.Steps)
{
logger.LogInformation(
"Rule {Rule} — Status {Status}, Action {Action}, Score {Score}, Inputs {Inputs}",
step.Rule,
step.Status ?? "n/a",
step.Action ?? "n/a",
step.Score?.ToString("0.00", CultureInfo.InvariantCulture) ?? "n/a",
FormatKeyValuePairs(step.Inputs));
if (step.Evidence is not null && step.Evidence.Count > 0)
{
logger.LogInformation(" Evidence: {Evidence}", FormatKeyValuePairs(step.Evidence));
}
}
}
if (explain.SealedHints.Count > 0)
{
if (AnsiConsole.Profile.Capabilities.Interactive)
{
AnsiConsole.MarkupLine("[grey]Hints:[/]");
foreach (var hint in explain.SealedHints)
{
AnsiConsole.MarkupLine($" • {Markup.Escape(hint.Message)}");
}
}
else
{
foreach (var hint in explain.SealedHints)
{
logger.LogInformation("Hint: {Hint}", hint.Message);
}
}
}
}
private static string FormatSeverity(PolicyFindingSeverity severity)
{
if (severity.Score.HasValue)
{
return FormattableString.Invariant($"{severity.Normalized} ({severity.Score.Value:0.00})");
}
return severity.Normalized;
}
private static string FormatListPreview(IReadOnlyList<string> values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
const int MaxItems = 3;
if (values.Count <= MaxItems)
{
return string.Join(", ", values);
}
var preview = string.Join(", ", values.Take(MaxItems));
return FormattableString.Invariant($"{preview} (+{values.Count - MaxItems})");
}
private static string FormatUpdatedAt(DateTimeOffset timestamp)
{
if (timestamp == DateTimeOffset.MinValue)
{
return "(unknown)";
}
return timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
private static string FormatScore(double? score)
=> score.HasValue ? score.Value.ToString("0.00", CultureInfo.InvariantCulture) : "-";
private static string FormatKeyValuePairs(IReadOnlyDictionary<string, string>? values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
return string.Join(", ", values.Select(pair => $"{pair.Key}={pair.Value}"));
}
private static string FormatVexMetadata(PolicyFindingVexMetadata? value)
{
if (value is null)
{
return "(none)";
}
var parts = new List<string>(3);
if (!string.IsNullOrWhiteSpace(value.WinningStatementId))
{
parts.Add($"winning={value.WinningStatementId}");
}
if (!string.IsNullOrWhiteSpace(value.Source))
{
parts.Add($"source={value.Source}");
}
if (!string.IsNullOrWhiteSpace(value.Status))
{
parts.Add($"status={value.Status}");
}
return parts.Count == 0 ? "(none)" : string.Join(", ", parts);
}
private static void HandlePolicyFindingsFailure(PolicyApiException exception, ILogger logger, Action<string> recordMetric)
{
var exitCode = exception.StatusCode switch
{
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => 12,
HttpStatusCode.NotFound => 1,
_ => 1
};
if (string.IsNullOrWhiteSpace(exception.ErrorCode))
{
logger.LogError("Policy API request failed ({StatusCode}): {Message}", (int)exception.StatusCode, exception.Message);
}
else
{
logger.LogError("Policy API request failed ({StatusCode} {Code}): {Message}", (int)exception.StatusCode, exception.ErrorCode, exception.Message);
}
recordMetric("error");
Environment.ExitCode = exitCode;
}
private static string FormatDelta(int? value)
=> value.HasValue ? value.Value.ToString("N0", CultureInfo.InvariantCulture) : "-";
private static readonly JsonSerializerOptions SimulationJsonOptions =
new(JsonSerializerDefaults.Web) { WriteIndented = true };
private static readonly IReadOnlyDictionary<string, object?> EmptyPolicyEnvironment =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.Ordinal));
private static readonly IReadOnlyList<string> EmptyPolicySbomSet =
new ReadOnlyCollection<string>(Array.Empty<string>());
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
private enum TaskRunnerSimulationOutputFormat
{
Table,
Json
}
private enum PolicySimulationOutputFormat
{
Table,
Json,
Markdown
}
private enum PolicyFindingsOutputFormat
{
Table,
Json
}
private static string FormatAdditionalValue(object? value)
{
return value switch
{
null => "null",
bool b => b ? "true" : "false",
double d => d.ToString("G17", CultureInfo.InvariantCulture),
float f => f.ToString("G9", CultureInfo.InvariantCulture),
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty
};
}
private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
{
if (providers is null || providers.Count == 0)
{
return Array.Empty<string>();
}
var list = new List<string>();
foreach (var provider in providers)
{
if (!string.IsNullOrWhiteSpace(provider))
{
list.Add(provider.Trim());
}
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static string ResolveTenant(string? tenantOption)
{
if (!string.IsNullOrWhiteSpace(tenantOption))
{
return tenantOption.Trim();
}
var fromEnvironment = Environment.GetEnvironmentVariable("STELLA_TENANT");
return string.IsNullOrWhiteSpace(fromEnvironment) ? string.Empty : fromEnvironment.Trim();
}
private static async Task<IngestInputPayload> LoadIngestInputAsync(IServiceProvider services, string input, CancellationToken cancellationToken)
{
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) &&
(uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
{
return await LoadIngestInputFromHttpAsync(services, uri, cancellationToken).ConfigureAwait(false);
}
return await LoadIngestInputFromFileAsync(input, cancellationToken).ConfigureAwait(false);
}
private static async Task<IngestInputPayload> LoadIngestInputFromHttpAsync(IServiceProvider services, Uri uri, CancellationToken cancellationToken)
{
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("stellaops-cli.ingest-download");
using var response = await httpClient.GetAsync(uri, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to download document from {uri} (HTTP {(int)response.StatusCode}).");
}
var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/json";
var contentEncoding = response.Content.Headers.ContentEncoding is { Count: > 0 }
? string.Join(",", response.Content.Headers.ContentEncoding)
: null;
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var normalized = NormalizeDocument(bytes, contentType, contentEncoding);
return new IngestInputPayload(
"uri",
uri.ToString(),
normalized.Content,
normalized.ContentType,
normalized.ContentEncoding);
}
private static async Task<IngestInputPayload> LoadIngestInputFromFileAsync(string path, CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(path);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException("Input document not found.", fullPath);
}
var bytes = await File.ReadAllBytesAsync(fullPath, cancellationToken).ConfigureAwait(false);
var normalized = NormalizeDocument(bytes, GuessContentTypeFromExtension(fullPath), null);
return new IngestInputPayload(
"file",
Path.GetFileName(fullPath),
normalized.Content,
normalized.ContentType,
normalized.ContentEncoding);
}
private static DocumentNormalizationResult NormalizeDocument(byte[] bytes, string? contentType, string? encodingHint)
{
if (bytes is null || bytes.Length == 0)
{
throw new InvalidOperationException("Input document is empty.");
}
var working = bytes;
var encodings = new List<string>();
if (!string.IsNullOrWhiteSpace(encodingHint))
{
encodings.Add(encodingHint);
}
if (IsGzip(working))
{
working = DecompressGzip(working);
encodings.Add("gzip");
}
var text = DecodeText(working);
var trimmed = text.TrimStart();
if (!string.IsNullOrWhiteSpace(trimmed) && trimmed[0] != '{' && trimmed[0] != '[')
{
if (TryDecodeBase64(text, out var decodedBytes))
{
working = decodedBytes;
encodings.Add("base64");
if (IsGzip(working))
{
working = DecompressGzip(working);
encodings.Add("gzip");
}
text = DecodeText(working);
}
}
text = text.Trim();
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException("Input document contained no data after decoding.");
}
var encodingLabel = encodings.Count == 0 ? null : string.Join("+", encodings);
var finalContentType = string.IsNullOrWhiteSpace(contentType) ? "application/json" : contentType;
return new DocumentNormalizationResult(text, finalContentType, encodingLabel);
}
private static string GuessContentTypeFromExtension(string path)
{
var extension = Path.GetExtension(path);
if (string.IsNullOrWhiteSpace(extension))
{
return "application/json";
}
return extension.ToLowerInvariant() switch
{
".json" or ".csaf" => "application/json",
".xml" => "application/xml",
_ => "application/json"
};
}
private static DateTimeOffset DetermineVerificationSince(string? sinceOption)
{
if (string.IsNullOrWhiteSpace(sinceOption))
{
return DateTimeOffset.UtcNow.AddHours(-24);
}
var trimmed = sinceOption.Trim();
if (DateTimeOffset.TryParse(
trimmed,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsedTimestamp))
{
return parsedTimestamp.ToUniversalTime();
}
if (TryParseRelativeDuration(trimmed, out var duration))
{
return DateTimeOffset.UtcNow.Subtract(duration);
}
throw new InvalidOperationException("Invalid --since value. Use ISO-8601 timestamp or duration (e.g. 24h, 7d).");
}
private static bool TryParseRelativeDuration(string value, out TimeSpan duration)
{
duration = TimeSpan.Zero;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim().ToLowerInvariant();
if (normalized.Length < 2)
{
return false;
}
var suffix = normalized[^1];
var magnitudeText = normalized[..^1];
double multiplier = suffix switch
{
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86400,
'w' => 604800,
_ => 0
};
if (multiplier == 0)
{
return false;
}
if (!double.TryParse(magnitudeText, NumberStyles.Float, CultureInfo.InvariantCulture, out var magnitude))
{
return false;
}
if (double.IsNaN(magnitude) || double.IsInfinity(magnitude) || magnitude <= 0)
{
return false;
}
var seconds = magnitude * multiplier;
if (double.IsNaN(seconds) || double.IsInfinity(seconds) || seconds <= 0)
{
return false;
}
duration = TimeSpan.FromSeconds(seconds);
return true;
}
private static int NormalizeLimit(int? limitOption)
{
if (!limitOption.HasValue)
{
return 20;
}
if (limitOption.Value < 0)
{
throw new InvalidOperationException("Limit cannot be negative.");
}
return limitOption.Value;
}
private static IReadOnlyList<string> ParseCommaSeparatedList(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<string>();
}
var tokens = raw
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(token => token.Trim())
.Where(token => token.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return tokens.Length == 0 ? Array.Empty<string>() : tokens;
}
private static string FormatWindowRange(AocVerifyWindow? window)
{
if (window is null)
{
return "(unspecified)";
}
var fromText = window.From?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? "(unknown)";
var toText = window.To?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) ?? "(unknown)";
return $"{fromText} -> {toText}";
}
private static string FormatCheckedCounts(AocVerifyChecked? checkedCounts)
{
if (checkedCounts is null)
{
return "(unspecified)";
}
return $"advisories: {checkedCounts.Advisories.ToString("N0", CultureInfo.InvariantCulture)}, vex: {checkedCounts.Vex.ToString("N0", CultureInfo.InvariantCulture)}";
}
private static string DetermineVerifyStatus(AocVerifyResponse? response)
{
if (response is null)
{
return "unknown";
}
if (response.Truncated == true && (response.Violations is null || response.Violations.Count == 0))
{
return "truncated";
}
var total = response.Violations?.Sum(violation => Math.Max(0, violation?.Count ?? 0)) ?? 0;
return total > 0 ? "violations" : "ok";
}
private static string FormatBoolean(bool value, bool useColor)
{
var text = value ? "yes" : "no";
if (!useColor)
{
return text;
}
return value
? $"[yellow]{text}[/]"
: $"[green]{text}[/]";
}
private static string FormatVerifyStatus(string? status, bool useColor)
{
var normalized = string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim();
var escaped = Markup.Escape(normalized);
if (!useColor)
{
return escaped;
}
return normalized switch
{
"ok" => $"[green]{escaped}[/]",
"violations" => $"[red]{escaped}[/]",
"truncated" => $"[yellow]{escaped}[/]",
_ => $"[grey]{escaped}[/]"
};
}
private static string FormatViolationExample(AocVerifyViolationExample? example)
{
if (example is null)
{
return "(n/a)";
}
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(example.Source))
{
parts.Add(example.Source.Trim());
}
if (!string.IsNullOrWhiteSpace(example.DocumentId))
{
parts.Add(example.DocumentId.Trim());
}
var label = parts.Count == 0 ? "(n/a)" : string.Join(" | ", parts);
if (!string.IsNullOrWhiteSpace(example.ContentHash))
{
label = $"{label} [{example.ContentHash.Trim()}]";
}
return label;
}
private static void RenderAocVerifyTable(AocVerifyResponse response, bool useColor, int limit)
{
var summary = new Table().Border(TableBorder.Rounded);
summary.AddColumn("Field");
summary.AddColumn("Value");
summary.AddRow("Tenant", Markup.Escape(string.IsNullOrWhiteSpace(response?.Tenant) ? "(unknown)" : response.Tenant!));
summary.AddRow("Window", Markup.Escape(FormatWindowRange(response?.Window)));
summary.AddRow("Checked", Markup.Escape(FormatCheckedCounts(response?.Checked)));
summary.AddRow("Limit", Markup.Escape(limit <= 0 ? "unbounded" : limit.ToString(CultureInfo.InvariantCulture)));
summary.AddRow("Status", FormatVerifyStatus(DetermineVerifyStatus(response), useColor));
if (response?.Metrics?.IngestionWriteTotal is int writes)
{
summary.AddRow("Ingestion Writes", Markup.Escape(writes.ToString("N0", CultureInfo.InvariantCulture)));
}
if (response?.Metrics?.AocViolationTotal is int totalViolations)
{
summary.AddRow("Violations (total)", Markup.Escape(totalViolations.ToString("N0", CultureInfo.InvariantCulture)));
}
else
{
var computedViolations = response?.Violations?.Sum(violation => Math.Max(0, violation?.Count ?? 0)) ?? 0;
summary.AddRow("Violations (total)", Markup.Escape(computedViolations.ToString("N0", CultureInfo.InvariantCulture)));
}
summary.AddRow("Truncated", FormatBoolean(response?.Truncated == true, useColor));
AnsiConsole.Write(summary);
if (response?.Violations is null || response.Violations.Count == 0)
{
var message = response?.Truncated == true
? "No violations reported, but results were truncated. Increase --limit to review full output."
: "No AOC violations detected in the requested window.";
if (useColor)
{
var color = response?.Truncated == true ? "yellow" : "green";
AnsiConsole.MarkupLine($"[{color}]{Markup.Escape(message)}[/]");
}
else
{
Console.WriteLine(message);
}
return;
}
var violationTable = new Table().Border(TableBorder.Rounded);
violationTable.AddColumn("Code");
violationTable.AddColumn("Count");
violationTable.AddColumn("Sample Document");
violationTable.AddColumn("Path");
foreach (var violation in response.Violations)
{
var codeDisplay = FormatViolationCode(violation.Code, useColor);
var countDisplay = violation.Count.ToString("N0", CultureInfo.InvariantCulture);
var example = violation.Examples?.FirstOrDefault();
var documentDisplay = Markup.Escape(FormatViolationExample(example));
var pathDisplay = example is null || string.IsNullOrWhiteSpace(example.Path)
? "(none)"
: example.Path!;
violationTable.AddRow(codeDisplay, countDisplay, documentDisplay, Markup.Escape(pathDisplay));
}
AnsiConsole.Write(violationTable);
}
private static int DetermineVerifyExitCode(AocVerifyResponse response)
{
ArgumentNullException.ThrowIfNull(response);
if (response.Violations is not null && response.Violations.Count > 0)
{
var exitCodes = new List<int>();
foreach (var violation in response.Violations)
{
if (string.IsNullOrWhiteSpace(violation.Code))
{
continue;
}
if (AocViolationExitCodeMap.TryGetValue(violation.Code, out var mapped))
{
exitCodes.Add(mapped);
}
}
if (exitCodes.Count > 0)
{
return exitCodes.Min();
}
return response.Truncated == true ? 18 : 17;
}
if (response.Truncated == true)
{
return 18;
}
return 0;
}
private static async Task<string> WriteJsonReportAsync<T>(T payload, string destination, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(payload);
if (string.IsNullOrWhiteSpace(destination))
{
throw new InvalidOperationException("Output path must be provided.");
}
var outputPath = Path.GetFullPath(destination);
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
return outputPath;
}
private static void RenderDryRunTable(AocIngestDryRunResponse response, bool useColor)
{
var summary = new Table().Border(TableBorder.Rounded);
summary.AddColumn("Field");
summary.AddColumn("Value");
summary.AddRow("Source", Markup.Escape(response?.Source ?? "(unknown)"));
summary.AddRow("Tenant", Markup.Escape(response?.Tenant ?? "(unknown)"));
summary.AddRow("Guard Version", Markup.Escape(response?.GuardVersion ?? "(unknown)"));
summary.AddRow("Status", FormatStatusMarkup(response?.Status, useColor));
var violationCount = response?.Violations?.Count ?? 0;
summary.AddRow("Violations", violationCount.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(response?.Document?.ContentHash))
{
summary.AddRow("Content Hash", Markup.Escape(response.Document.ContentHash!));
}
if (!string.IsNullOrWhiteSpace(response?.Document?.Supersedes))
{
summary.AddRow("Supersedes", Markup.Escape(response.Document.Supersedes!));
}
if (!string.IsNullOrWhiteSpace(response?.Document?.Provenance?.Signature?.Format))
{
var signature = response.Document.Provenance.Signature;
var summaryText = signature!.Present
? signature.Format ?? "present"
: "missing";
summary.AddRow("Signature", Markup.Escape(summaryText));
}
AnsiConsole.Write(summary);
if (violationCount == 0)
{
if (useColor)
{
AnsiConsole.MarkupLine("[green]No AOC violations detected.[/]");
}
else
{
Console.WriteLine("No AOC violations detected.");
}
return;
}
var violationTable = new Table().Border(TableBorder.Rounded);
violationTable.AddColumn("Code");
violationTable.AddColumn("Path");
violationTable.AddColumn("Message");
foreach (var violation in response!.Violations!)
{
var codeDisplay = FormatViolationCode(violation.Code, useColor);
var pathDisplay = string.IsNullOrWhiteSpace(violation.Path) ? "(root)" : violation.Path!;
var messageDisplay = string.IsNullOrWhiteSpace(violation.Message) ? "(unspecified)" : violation.Message!;
violationTable.AddRow(codeDisplay, Markup.Escape(pathDisplay), Markup.Escape(messageDisplay));
}
AnsiConsole.Write(violationTable);
}
private static int DetermineDryRunExitCode(AocIngestDryRunResponse response)
{
if (response?.Violations is null || response.Violations.Count == 0)
{
return 0;
}
var exitCodes = new List<int>();
foreach (var violation in response.Violations)
{
if (string.IsNullOrWhiteSpace(violation.Code))
{
continue;
}
if (AocViolationExitCodeMap.TryGetValue(violation.Code, out var mapped))
{
exitCodes.Add(mapped);
}
}
if (exitCodes.Count == 0)
{
return 17;
}
return exitCodes.Min();
}
private static string FormatStatusMarkup(string? status, bool useColor)
{
var normalized = string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim();
if (!useColor)
{
return Markup.Escape(normalized);
}
return normalized.Equals("ok", StringComparison.OrdinalIgnoreCase)
? $"[green]{Markup.Escape(normalized)}[/]"
: $"[red]{Markup.Escape(normalized)}[/]";
}
private static string FormatViolationCode(string code, bool useColor)
{
var sanitized = string.IsNullOrWhiteSpace(code) ? "(unknown)" : code.Trim();
if (!useColor)
{
return Markup.Escape(sanitized);
}
return $"[red]{Markup.Escape(sanitized)}[/]";
}
private static bool IsGzip(ReadOnlySpan<byte> data)
{
return data.Length >= 2 && data[0] == 0x1F && data[1] == 0x8B;
}
private static byte[] DecompressGzip(byte[] payload)
{
using var input = new MemoryStream(payload);
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
private static string DecodeText(byte[] payload)
{
var encoding = DetectEncoding(payload);
return encoding.GetString(payload);
}
private static Encoding DetectEncoding(ReadOnlySpan<byte> data)
{
if (data.Length >= 4)
{
if (data[0] == 0x00 && data[1] == 0x00 && data[2] == 0xFE && data[3] == 0xFF)
{
return new UTF32Encoding(bigEndian: true, byteOrderMark: true);
}
if (data[0] == 0xFF && data[1] == 0xFE && data[2] == 0x00 && data[3] == 0x00)
{
return new UTF32Encoding(bigEndian: false, byteOrderMark: true);
}
}
if (data.Length >= 2)
{
if (data[0] == 0xFE && data[1] == 0xFF)
{
return Encoding.BigEndianUnicode;
}
if (data[0] == 0xFF && data[1] == 0xFE)
{
return Encoding.Unicode;
}
}
if (data.Length >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF)
{
return Encoding.UTF8;
}
return Encoding.UTF8;
}
public static async Task HandleKmsExportAsync(
IServiceProvider services,
string? rootPath,
string keyId,
string? versionId,
string outputPath,
bool overwrite,
string? passphrase,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("kms-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var resolvedPassphrase = ResolvePassphrase(passphrase, "Enter file KMS passphrase:");
if (string.IsNullOrEmpty(resolvedPassphrase))
{
logger.LogError("KMS passphrase must be supplied via --passphrase, {EnvironmentVariable}, or interactive prompt.", KmsPassphraseEnvironmentVariable);
Environment.ExitCode = 1;
return;
}
var resolvedRoot = ResolveRootDirectory(rootPath);
if (!Directory.Exists(resolvedRoot))
{
logger.LogError("KMS root directory '{Root}' does not exist.", resolvedRoot);
Environment.ExitCode = 1;
return;
}
var outputFullPath = Path.GetFullPath(string.IsNullOrWhiteSpace(outputPath) ? "kms-export.json" : outputPath);
if (Directory.Exists(outputFullPath))
{
logger.LogError("Output path '{Output}' is a directory. Provide a file path.", outputFullPath);
Environment.ExitCode = 1;
return;
}
if (!overwrite && File.Exists(outputFullPath))
{
logger.LogError("Output file '{Output}' already exists. Use --force to overwrite.", outputFullPath);
Environment.ExitCode = 1;
return;
}
var outputDirectory = Path.GetDirectoryName(outputFullPath);
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
using var client = new FileKmsClient(new FileKmsOptions
{
RootPath = resolvedRoot,
Password = resolvedPassphrase!
});
var material = await client.ExportAsync(keyId, versionId, cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.Serialize(material, KmsJsonOptions);
await File.WriteAllTextAsync(outputFullPath, json, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Exported key {KeyId} version {VersionId} to {Output}.", material.KeyId, material.VersionId, outputFullPath);
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export key material.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleKmsImportAsync(
IServiceProvider services,
string? rootPath,
string keyId,
string inputPath,
string? versionOverride,
string? passphrase,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("kms-import");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
try
{
var resolvedPassphrase = ResolvePassphrase(passphrase, "Enter file KMS passphrase:");
if (string.IsNullOrEmpty(resolvedPassphrase))
{
logger.LogError("KMS passphrase must be supplied via --passphrase, {EnvironmentVariable}, or interactive prompt.", KmsPassphraseEnvironmentVariable);
Environment.ExitCode = 1;
return;
}
var resolvedRoot = ResolveRootDirectory(rootPath);
Directory.CreateDirectory(resolvedRoot);
var inputFullPath = Path.GetFullPath(inputPath ?? string.Empty);
if (!File.Exists(inputFullPath))
{
logger.LogError("Input file '{Input}' does not exist.", inputFullPath);
Environment.ExitCode = 1;
return;
}
var json = await File.ReadAllTextAsync(inputFullPath, cancellationToken).ConfigureAwait(false);
var material = JsonSerializer.Deserialize<KmsKeyMaterial>(json, KmsJsonOptions)
?? throw new InvalidOperationException("Key material payload is empty.");
if (!string.IsNullOrWhiteSpace(versionOverride))
{
material = material with { VersionId = versionOverride };
}
var sourceKeyId = material.KeyId;
material = material with { KeyId = keyId };
using var client = new FileKmsClient(new FileKmsOptions
{
RootPath = resolvedRoot,
Password = resolvedPassphrase!
});
var metadata = await client.ImportAsync(keyId, material, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(sourceKeyId) && !string.Equals(sourceKeyId, keyId, StringComparison.Ordinal))
{
logger.LogWarning("Imported key material originally identified as '{SourceKeyId}' into '{TargetKeyId}'.", sourceKeyId, keyId);
}
var activeVersion = metadata.Versions.Length > 0 ? metadata.Versions[^1].VersionId : material.VersionId;
logger.LogInformation("Imported key {KeyId} version {VersionId} into {Root}.", metadata.KeyId, activeVersion, resolvedRoot);
Environment.ExitCode = 0;
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to parse key material JSON from {Input}.", inputPath);
Environment.ExitCode = 1;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to import key material.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static string ResolveRootDirectory(string? rootPath)
=> Path.GetFullPath(string.IsNullOrWhiteSpace(rootPath) ? "kms" : rootPath);
private static string? ResolvePassphrase(string? passphrase, string promptMessage)
{
if (!string.IsNullOrWhiteSpace(passphrase))
{
return passphrase;
}
var fromEnvironment = Environment.GetEnvironmentVariable(KmsPassphraseEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(fromEnvironment))
{
return fromEnvironment;
}
return KmsPassphrasePrompt.Prompt(promptMessage);
}
private static bool TryDecodeBase64(string text, out byte[] decoded)
{
decoded = Array.Empty<byte>();
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var builder = new StringBuilder(text.Length);
foreach (var ch in text)
{
if (!char.IsWhiteSpace(ch))
{
builder.Append(ch);
}
}
var candidate = builder.ToString();
if (candidate.Length < 8 || candidate.Length % 4 != 0)
{
return false;
}
for (var i = 0; i < candidate.Length; i++)
{
var c = candidate[i];
if (!(char.IsLetterOrDigit(c) || c is '+' or '/' or '='))
{
return false;
}
}
try
{
decoded = Convert.FromBase64String(candidate);
return true;
}
catch (FormatException)
{
return false;
}
}
private sealed record IngestInputPayload(string Kind, string Name, string Content, string ContentType, string? ContentEncoding);
private sealed record DocumentNormalizationResult(string Content, string ContentType, string? ContentEncoding);
private static readonly IReadOnlyDictionary<string, int> AocViolationExitCodeMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["ERR_AOC_001"] = 11,
["ERR_AOC_002"] = 12,
["ERR_AOC_003"] = 13,
["ERR_AOC_004"] = 14,
["ERR_AOC_005"] = 15,
["ERR_AOC_006"] = 16,
["ERR_AOC_007"] = 17
};
private static string[] NormalizeSections(IReadOnlyList<string> sections)
{
if (sections is null || sections.Count == 0)
{
return Array.Empty<string>();
}
return sections
.Where(section => !string.IsNullOrWhiteSpace(section))
.Select(section => section.Trim())
.Where(section => section.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static void RenderAdvisoryPlan(AdvisoryPipelinePlanResponseModel plan)
{
var console = AnsiConsole.Console;
var summary = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Advisory Plan[/]");
summary.AddColumn("Field");
summary.AddColumn("Value");
summary.AddRow("Task", Markup.Escape(plan.TaskType));
summary.AddRow("Cache Key", Markup.Escape(plan.CacheKey));
summary.AddRow("Prompt Template", Markup.Escape(plan.PromptTemplate));
summary.AddRow("Chunks", plan.Chunks.Count.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Vectors", plan.Vectors.Count.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Prompt Tokens", plan.Budget.PromptTokens.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Completion Tokens", plan.Budget.CompletionTokens.ToString(CultureInfo.InvariantCulture));
console.Write(summary);
if (plan.Metadata.Count > 0)
{
console.Write(CreateKeyValueTable("Plan Metadata", plan.Metadata));
}
}
private static string? RenderAdvisoryOutput(AdvisoryPipelineOutputModel output, AdvisoryOutputFormat format)
{
return format switch
{
AdvisoryOutputFormat.Json => RenderAdvisoryOutputJson(output),
AdvisoryOutputFormat.Markdown => RenderAdvisoryOutputMarkdown(output),
_ => RenderAdvisoryOutputTable(output)
};
}
private static string RenderAdvisoryOutputJson(AdvisoryPipelineOutputModel output)
{
return JsonSerializer.Serialize(output, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
});
}
private static string RenderAdvisoryOutputMarkdown(AdvisoryPipelineOutputModel output)
{
var builder = new StringBuilder();
builder.AppendLine($"# Advisory {output.TaskType} ({output.Profile})");
builder.AppendLine();
builder.AppendLine($"- Cache Key: `{output.CacheKey}`");
builder.AppendLine($"- Generated: {output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture)}");
builder.AppendLine($"- Plan From Cache: {(output.PlanFromCache ? "yes" : "no")}");
builder.AppendLine($"- Guardrail Blocked: {(output.Guardrail.Blocked ? "yes" : "no")}");
builder.AppendLine();
if (!string.IsNullOrWhiteSpace(output.Response))
{
builder.AppendLine("## Response");
builder.AppendLine(output.Response.Trim());
builder.AppendLine();
}
if (!string.IsNullOrWhiteSpace(output.Prompt))
{
builder.AppendLine("## Prompt (sanitized)");
builder.AppendLine(output.Prompt.Trim());
builder.AppendLine();
}
if (output.Citations.Count > 0)
{
builder.AppendLine("## Citations");
foreach (var citation in output.Citations.OrderBy(c => c.Index))
{
builder.AppendLine($"- [{citation.Index}] {citation.DocumentId} :: {citation.ChunkId}");
}
builder.AppendLine();
}
if (output.Metadata.Count > 0)
{
builder.AppendLine("## Output Metadata");
foreach (var entry in output.Metadata.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- **{entry.Key}**: {entry.Value}");
}
builder.AppendLine();
}
if (output.Guardrail.Metadata.Count > 0)
{
builder.AppendLine("## Guardrail Metadata");
foreach (var entry in output.Guardrail.Metadata.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- **{entry.Key}**: {entry.Value}");
}
builder.AppendLine();
}
if (output.Guardrail.Violations.Count > 0)
{
builder.AppendLine("## Guardrail Violations");
foreach (var violation in output.Guardrail.Violations)
{
builder.AppendLine($"- `{violation.Code}`: {violation.Message}");
}
builder.AppendLine();
}
builder.AppendLine("## Provenance");
builder.AppendLine($"- Input Digest: `{output.Provenance.InputDigest}`");
builder.AppendLine($"- Output Hash: `{output.Provenance.OutputHash}`");
if (output.Provenance.Signatures.Count > 0)
{
foreach (var signature in output.Provenance.Signatures)
{
builder.AppendLine($"- Signature: `{signature}`");
}
}
else
{
builder.AppendLine("- Signature: none");
}
return builder.ToString();
}
private static string? RenderAdvisoryOutputTable(AdvisoryPipelineOutputModel output)
{
var console = AnsiConsole.Console;
var summary = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Advisory Output[/]");
summary.AddColumn("Field");
summary.AddColumn("Value");
summary.AddRow("Cache Key", Markup.Escape(output.CacheKey));
summary.AddRow("Task", Markup.Escape(output.TaskType));
summary.AddRow("Profile", Markup.Escape(output.Profile));
summary.AddRow("Generated", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
summary.AddRow("Plan From Cache", output.PlanFromCache ? "yes" : "no");
summary.AddRow("Citations", output.Citations.Count.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Guardrail Blocked", output.Guardrail.Blocked ? "[red]yes[/]" : "no");
console.Write(summary);
if (!string.IsNullOrWhiteSpace(output.Response))
{
var responsePanel = new Panel(new Markup(Markup.Escape(output.Response)))
{
Header = new PanelHeader("Response"),
Border = BoxBorder.Rounded,
Expand = true
};
console.Write(responsePanel);
}
if (!string.IsNullOrWhiteSpace(output.Prompt))
{
var promptPanel = new Panel(new Markup(Markup.Escape(output.Prompt)))
{
Header = new PanelHeader("Prompt (sanitized)"),
Border = BoxBorder.Rounded,
Expand = true
};
console.Write(promptPanel);
}
if (output.Citations.Count > 0)
{
var citations = new Table()
.Border(TableBorder.Minimal)
.Title("[grey]Citations[/]");
citations.AddColumn("Index");
citations.AddColumn("Document");
citations.AddColumn("Chunk");
foreach (var citation in output.Citations.OrderBy(c => c.Index))
{
citations.AddRow(
citation.Index.ToString(CultureInfo.InvariantCulture),
Markup.Escape(citation.DocumentId),
Markup.Escape(citation.ChunkId));
}
console.Write(citations);
}
if (output.Metadata.Count > 0)
{
console.Write(CreateKeyValueTable("Output Metadata", output.Metadata));
}
if (output.Guardrail.Metadata.Count > 0)
{
console.Write(CreateKeyValueTable("Guardrail Metadata", output.Guardrail.Metadata));
}
if (output.Guardrail.Violations.Count > 0)
{
var violations = new Table()
.Border(TableBorder.Minimal)
.Title("[red]Guardrail Violations[/]");
violations.AddColumn("Code");
violations.AddColumn("Message");
foreach (var violation in output.Guardrail.Violations)
{
violations.AddRow(Markup.Escape(violation.Code), Markup.Escape(violation.Message));
}
console.Write(violations);
}
var provenance = new Table()
.Border(TableBorder.Minimal)
.Title("[grey]Provenance[/]");
provenance.AddColumn("Field");
provenance.AddColumn("Value");
provenance.AddRow("Input Digest", Markup.Escape(output.Provenance.InputDigest));
provenance.AddRow("Output Hash", Markup.Escape(output.Provenance.OutputHash));
var signatures = output.Provenance.Signatures.Count == 0
? "none"
: string.Join(Environment.NewLine, output.Provenance.Signatures.Select(Markup.Escape));
provenance.AddRow("Signatures", signatures);
console.Write(provenance);
return null;
}
private static Table CreateKeyValueTable(string title, IReadOnlyDictionary<string, string> entries)
{
var table = new Table()
.Border(TableBorder.Minimal)
.Title($"[grey]{Markup.Escape(title)}[/]");
table.AddColumn("Key");
table.AddColumn("Value");
foreach (var kvp in entries.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
table.AddRow(Markup.Escape(kvp.Key), Markup.Escape(kvp.Value));
}
return table;
}
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
{
foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList())
{
source.Remove(key);
}
return source;
}
private static async Task TriggerJobAsync(
IBackendOperationsClient client,
ILogger logger,
string jobKind,
IDictionary<string, object?> parameters,
CancellationToken cancellationToken)
{
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
if (!string.IsNullOrWhiteSpace(result.Location))
{
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
}
else if (result.Run is not null)
{
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
}
else
{
logger.LogInformation("Job accepted.");
}
Environment.ExitCode = 0;
}
else
{
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
Environment.ExitCode = 1;
}
}
public static Task HandleCryptoProvidersAsync(
IServiceProvider services,
bool verbose,
bool jsonOutput,
string? profileOverride,
CancellationToken cancellationToken)
{
using var scope = services.CreateScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("crypto-providers");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.crypto.providers", ActivityKind.Internal);
using var duration = CliMetrics.MeasureCommandDuration("crypto providers");
try
{
var registry = scope.ServiceProvider.GetService<ICryptoProviderRegistry>();
if (registry is null)
{
logger.LogWarning("Crypto provider registry not available in this environment.");
AnsiConsole.MarkupLine("[yellow]Crypto subsystem is not configured in this environment.[/]");
return Task.CompletedTask;
}
var optionsMonitor = scope.ServiceProvider.GetService<IOptionsMonitor<CryptoProviderRegistryOptions>>();
var registryOptions = optionsMonitor?.CurrentValue ?? new CryptoProviderRegistryOptions();
var preferredOrder = DeterminePreferredOrder(registryOptions, profileOverride);
var providers = registry.Providers
.Select(provider => new ProviderInfo(
provider.Name,
provider.GetType().FullName ?? provider.GetType().Name,
DescribeProviderKeys(provider).ToList()))
.ToList();
if (jsonOutput)
{
var payload = new
{
activeProfile = registryOptions.ActiveProfile,
preferredOrder,
providers = providers.Select(info => new
{
info.Name,
info.Type,
keys = info.Keys.Select(k => new
{
k.KeyId,
k.AlgorithmId,
Metadata = k.Metadata
})
})
};
Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
WriteIndented = true
}));
Environment.ExitCode = 0;
return Task.CompletedTask;
}
RenderCryptoProviders(preferredOrder, providers);
Environment.ExitCode = 0;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
return Task.CompletedTask;
}
public static Task HandleNodeLockValidateAsync(
IServiceProvider services,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken)
=> HandleLanguageLockValidateAsync(
services,
loggerCategory: "node-lock-validate",
activityName: "cli.node.lock_validate",
rootTag: "stellaops.cli.node.root",
declaredTag: "stellaops.cli.node.declared_only",
missingTag: "stellaops.cli.node.lock_missing",
commandName: "node lock-validate",
analyzer: new NodeLanguageAnalyzer(),
rootPath: rootPath,
format: format,
verbose: verbose,
cancellationToken: cancellationToken,
telemetryRecorder: CliMetrics.RecordNodeLockValidate);
public static Task HandlePythonLockValidateAsync(
IServiceProvider services,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken)
=> HandleLanguageLockValidateAsync(
services,
loggerCategory: "python-lock-validate",
activityName: "cli.python.lock_validate",
rootTag: "stellaops.cli.python.root",
declaredTag: "stellaops.cli.python.declared_only",
missingTag: "stellaops.cli.python.lock_missing",
commandName: "python lock-validate",
analyzer: new PythonLanguageAnalyzer(),
rootPath: rootPath,
format: format,
verbose: verbose,
cancellationToken: cancellationToken,
telemetryRecorder: CliMetrics.RecordPythonLockValidate);
public static Task HandleJavaLockValidateAsync(
IServiceProvider services,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken)
=> HandleLanguageLockValidateAsync(
services,
loggerCategory: "java-lock-validate",
activityName: "cli.java.lock_validate",
rootTag: "stellaops.cli.java.root",
declaredTag: "stellaops.cli.java.declared_only",
missingTag: "stellaops.cli.java.lock_missing",
commandName: "java lock-validate",
analyzer: new JavaLanguageAnalyzer(),
rootPath: rootPath,
format: format,
verbose: verbose,
cancellationToken: cancellationToken,
telemetryRecorder: CliMetrics.RecordJavaLockValidate);
private static async Task HandleLanguageLockValidateAsync(
IServiceProvider services,
string loggerCategory,
string activityName,
string rootTag,
string declaredTag,
string missingTag,
string commandName,
ILanguageAnalyzer analyzer,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken,
Action<string> telemetryRecorder)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(loggerCategory);
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity(activityName, ActivityKind.Internal);
using var duration = CliMetrics.MeasureCommandDuration(commandName);
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(rootPath);
if (!Directory.Exists(targetRoot))
{
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
}
logger.LogInformation("Validating lockfiles in {Root}.", targetRoot);
activity?.SetTag(rootTag, targetRoot);
var engine = new LanguageAnalyzerEngine(new[] { analyzer });
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
var report = LockValidationReport.Create(result.ToSnapshots());
activity?.SetTag(declaredTag, report.DeclaredOnly.Count);
activity?.SetTag(missingTag, report.MissingLockMetadata.Count);
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(report, options));
}
else
{
RenderLockValidationReport(report);
}
outcome = report.HasIssues ? "violations" : "ok";
Environment.ExitCode = report.HasIssues ? 1 : 0;
}
catch (DirectoryNotFoundException ex)
{
outcome = "not_found";
logger.LogError(ex.Message);
Environment.ExitCode = 71;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "Lock validation failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
telemetryRecorder(outcome);
}
}
private static void RenderLockValidationReport(LockValidationReport report)
{
if (!report.HasIssues)
{
AnsiConsole.MarkupLine("[green]Lockfiles match installed packages.[/]");
AnsiConsole.MarkupLine($"[grey]Declared components: {report.TotalDeclared}, Installed: {report.TotalInstalled}[/]");
return;
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Status");
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn("Source");
table.AddColumn("Locator");
table.AddColumn("Path");
foreach (var entry in report.DeclaredOnly)
{
table.AddRow(
"[red]Declared Only[/]",
Markup.Escape(entry.Name),
Markup.Escape(entry.Version ?? "-"),
Markup.Escape(entry.LockSource ?? "-"),
Markup.Escape(entry.LockLocator ?? "-"),
Markup.Escape(entry.Path));
}
foreach (var entry in report.MissingLockMetadata)
{
table.AddRow(
"[yellow]Missing Lock[/]",
Markup.Escape(entry.Name),
Markup.Escape(entry.Version ?? "-"),
"-",
"-",
Markup.Escape(entry.Path));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[grey]Declared components: {report.TotalDeclared}, Installed: {report.TotalInstalled}[/]");
}
public static async Task HandleRubyInspectAsync(
IServiceProvider services,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("ruby-inspect");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.ruby.inspect", ActivityKind.Internal);
activity?.SetTag("stellaops.cli.command", "ruby inspect");
using var duration = CliMetrics.MeasureCommandDuration("ruby inspect");
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(rootPath);
if (!Directory.Exists(targetRoot))
{
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
}
logger.LogInformation("Inspecting Ruby workspace in {Root}.", targetRoot);
activity?.SetTag("stellaops.cli.ruby.root", targetRoot);
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() });
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
var report = RubyInspectReport.Create(result.ToSnapshots());
activity?.SetTag("stellaops.cli.ruby.package_count", report.Packages.Count);
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(report, options));
}
else
{
RenderRubyInspectReport(report);
}
outcome = report.Packages.Count == 0 ? "empty" : "ok";
Environment.ExitCode = 0;
}
catch (DirectoryNotFoundException ex)
{
outcome = "not_found";
logger.LogError(ex.Message);
Environment.ExitCode = 71;
}
catch (InvalidOperationException ex)
{
outcome = "invalid";
logger.LogError(ex.Message);
Environment.ExitCode = 64;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "Ruby inspect failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordRubyInspect(outcome);
}
}
public static async Task HandleRubyResolveAsync(
IServiceProvider services,
string? imageReference,
string? scanId,
string format,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("ruby-resolve");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.ruby.resolve", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "ruby resolve");
using var duration = CliMetrics.MeasureCommandDuration("ruby resolve");
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var identifier = !string.IsNullOrWhiteSpace(scanId)
? scanId!.Trim()
: imageReference?.Trim();
if (string.IsNullOrWhiteSpace(identifier))
{
throw new InvalidOperationException("An --image or --scan-id value is required.");
}
logger.LogInformation("Resolving Ruby packages for scan {ScanId}.", identifier);
activity?.SetTag("stellaops.cli.scan_id", identifier);
var inventory = await client.GetRubyPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
if (inventory is null)
{
outcome = "empty";
Environment.ExitCode = 0;
AnsiConsole.MarkupLine("[yellow]Ruby package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
return;
}
var report = RubyResolveReport.Create(inventory);
if (!report.HasPackages)
{
outcome = "empty";
Environment.ExitCode = 0;
var displayScanId = string.IsNullOrWhiteSpace(report.ScanId) ? identifier : report.ScanId;
AnsiConsole.MarkupLine("[yellow]No Ruby packages found for scan {0}.[/]", Markup.Escape(displayScanId));
return;
}
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(report, options));
}
else
{
RenderRubyResolveReport(report);
}
outcome = "ok";
Environment.ExitCode = 0;
}
catch (InvalidOperationException ex)
{
outcome = "invalid";
logger.LogError(ex.Message);
Environment.ExitCode = 64;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "Ruby resolve failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordRubyResolve(outcome);
}
}
public static async Task HandlePhpInspectAsync(
IServiceProvider services,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("php-inspect");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.php.inspect", ActivityKind.Internal);
activity?.SetTag("stellaops.cli.command", "php inspect");
using var duration = CliMetrics.MeasureCommandDuration("php inspect");
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(rootPath);
if (!Directory.Exists(targetRoot))
{
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
}
logger.LogInformation("Inspecting PHP workspace in {Root}.", targetRoot);
activity?.SetTag("stellaops.cli.php.root", targetRoot);
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new PhpLanguageAnalyzer() });
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
var report = PhpInspectReport.Create(result.ToSnapshots());
activity?.SetTag("stellaops.cli.php.package_count", report.Packages.Count);
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(report, options));
}
else
{
RenderPhpInspectReport(report);
}
outcome = report.Packages.Count == 0 ? "empty" : "ok";
Environment.ExitCode = 0;
}
catch (DirectoryNotFoundException ex)
{
outcome = "not_found";
logger.LogError(ex.Message);
Environment.ExitCode = 71;
}
catch (InvalidOperationException ex)
{
outcome = "invalid";
logger.LogError(ex.Message);
Environment.ExitCode = 64;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "PHP inspect failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordPhpInspect(outcome);
}
}
public static async Task HandlePythonInspectAsync(
IServiceProvider services,
string? rootPath,
string format,
string[]? sitePackages,
bool includeFrameworks,
bool includeCapabilities,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("python-inspect");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.python.inspect", ActivityKind.Internal);
activity?.SetTag("stellaops.cli.command", "python inspect");
using var duration = CliMetrics.MeasureCommandDuration("python inspect");
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json" or "aoc"))
{
throw new InvalidOperationException("Format must be 'table', 'json', or 'aoc'.");
}
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(rootPath);
if (!Directory.Exists(targetRoot))
{
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
}
logger.LogInformation("Inspecting Python workspace in {Root}.", targetRoot);
activity?.SetTag("stellaops.cli.python.root", targetRoot);
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new PythonLanguageAnalyzer() });
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
var snapshots = result.ToSnapshots();
activity?.SetTag("stellaops.cli.python.package_count", snapshots.Count);
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(snapshots, options));
}
else if (string.Equals(normalizedFormat, "aoc", StringComparison.Ordinal))
{
// AOC format output
var aocResult = new
{
Schema = "python-aoc-v1",
Packages = snapshots.Select(s => new
{
s.Name,
s.Version,
s.Type,
Purl = s.Purl,
s.Metadata
})
};
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(aocResult, options));
}
else
{
RenderPythonInspectReport(snapshots);
}
outcome = snapshots.Count == 0 ? "empty" : "ok";
Environment.ExitCode = 0;
}
catch (DirectoryNotFoundException ex)
{
outcome = "not_found";
logger.LogError(ex.Message);
Environment.ExitCode = 71;
}
catch (InvalidOperationException ex)
{
outcome = "invalid";
logger.LogError(ex.Message);
Environment.ExitCode = 64;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "Python inspect failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordPythonInspect(outcome);
}
}
public static async Task HandleBunInspectAsync(
IServiceProvider services,
string? rootPath,
string format,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-inspect");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.inspect", ActivityKind.Internal);
activity?.SetTag("stellaops.cli.command", "bun inspect");
using var duration = CliMetrics.MeasureCommandDuration("bun inspect");
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(rootPath);
if (!Directory.Exists(targetRoot))
{
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
}
logger.LogInformation("Inspecting Bun workspace in {Root}.", targetRoot);
activity?.SetTag("stellaops.cli.bun.root", targetRoot);
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new BunLanguageAnalyzer() });
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
var report = BunInspectReport.Create(result.ToSnapshots());
activity?.SetTag("stellaops.cli.bun.package_count", report.Packages.Count);
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(report, options));
}
else
{
RenderBunInspectReport(report);
}
outcome = report.Packages.Count == 0 ? "empty" : "ok";
Environment.ExitCode = 0;
}
catch (DirectoryNotFoundException ex)
{
outcome = "not_found";
logger.LogError(ex.Message);
Environment.ExitCode = 71;
}
catch (InvalidOperationException ex)
{
outcome = "invalid";
logger.LogError(ex.Message);
Environment.ExitCode = 64;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "Bun inspect failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordBunInspect(outcome);
}
}
public static async Task HandleBunResolveAsync(
IServiceProvider services,
string? imageReference,
string? scanId,
string format,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-resolve");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.resolve", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "bun resolve");
using var duration = CliMetrics.MeasureCommandDuration("bun resolve");
var outcome = "unknown";
try
{
var normalizedFormat = string.IsNullOrWhiteSpace(format)
? "table"
: format.Trim().ToLowerInvariant();
if (normalizedFormat is not ("table" or "json"))
{
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
}
var identifier = !string.IsNullOrWhiteSpace(scanId)
? scanId!.Trim()
: imageReference?.Trim();
if (string.IsNullOrWhiteSpace(identifier))
{
throw new InvalidOperationException("An --image or --scan-id value is required.");
}
logger.LogInformation("Resolving Bun packages for scan {ScanId}.", identifier);
activity?.SetTag("stellaops.cli.scan_id", identifier);
var inventory = await client.GetBunPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
if (inventory is null)
{
outcome = "empty";
Environment.ExitCode = 0;
AnsiConsole.MarkupLine("[yellow]Bun package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
return;
}
var report = BunResolveReport.Create(inventory);
if (!report.HasPackages)
{
AnsiConsole.MarkupLine("[yellow]No Bun packages found for scan {0}.[/]", Markup.Escape(identifier));
}
else if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
Console.WriteLine(JsonSerializer.Serialize(report, options));
}
else
{
RenderBunResolveReport(report);
}
outcome = report.HasPackages ? "ok" : "empty";
Environment.ExitCode = 0;
}
catch (InvalidOperationException ex)
{
outcome = "invalid";
logger.LogError(ex.Message);
Environment.ExitCode = 64;
}
catch (HttpRequestException ex)
{
outcome = "network_error";
logger.LogError(ex, "Failed to resolve Bun packages.");
Environment.ExitCode = 69;
}
catch (Exception ex)
{
outcome = "error";
logger.LogError(ex, "Bun resolve failed.");
Environment.ExitCode = 70;
}
finally
{
verbosity.MinimumLevel = previousLevel;
CliMetrics.RecordBunResolve(outcome);
}
}
private static void RenderPythonInspectReport(IReadOnlyList<LanguageComponentSnapshot> snapshots)
{
if (snapshots.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No Python packages detected.[/]");
return;
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn("Installer");
table.AddColumn("Source");
foreach (var entry in snapshots)
{
var installer = entry.Metadata.TryGetValue("installer", out var inst) ? inst : "-";
var source = entry.Metadata.TryGetValue("provenance", out var src) ? src : "-";
table.AddRow(
Markup.Escape(entry.Name ?? "-"),
Markup.Escape(entry.Version ?? "-"),
Markup.Escape(installer ?? "-"),
Markup.Escape(source ?? "-"));
}
AnsiConsole.Write(table);
}
private static void RenderPhpInspectReport(PhpInspectReport report)
{
if (!report.Packages.Any())
{
AnsiConsole.MarkupLine("[yellow]No PHP packages detected.[/]");
return;
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn("Type");
table.AddColumn(new TableColumn("Lockfile").NoWrap());
table.AddColumn("Dev");
foreach (var entry in report.Packages)
{
var dev = entry.IsDev ? "[grey]yes[/]" : "-";
table.AddRow(
Markup.Escape(entry.Name),
Markup.Escape(entry.Version ?? "-"),
Markup.Escape(entry.Type ?? "-"),
Markup.Escape(entry.Lockfile ?? "-"),
dev);
}
AnsiConsole.Write(table);
}
private static void RenderBunInspectReport(BunInspectReport report)
{
if (!report.Packages.Any())
{
AnsiConsole.MarkupLine("[yellow]No Bun packages detected.[/]");
return;
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn("Source");
table.AddColumn("Dev");
table.AddColumn("Direct");
foreach (var entry in report.Packages)
{
var dev = entry.IsDev ? "[grey]yes[/]" : "-";
var direct = entry.IsDirect ? "[blue]yes[/]" : "-";
table.AddRow(
Markup.Escape(entry.Name),
Markup.Escape(entry.Version ?? "-"),
Markup.Escape(entry.Source ?? "-"),
dev,
direct);
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[grey]Total packages: {report.Packages.Count}[/]");
}
private static void RenderBunResolveReport(BunResolveReport report)
{
if (!report.HasPackages)
{
AnsiConsole.MarkupLine("[yellow]No Bun packages found.[/]");
return;
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn("Source");
table.AddColumn("Integrity");
foreach (var entry in report.Packages)
{
table.AddRow(
Markup.Escape(entry.Name),
Markup.Escape(entry.Version ?? "-"),
Markup.Escape(entry.Source ?? "-"),
Markup.Escape(entry.Integrity ?? "-"));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[grey]Scan: {Markup.Escape(report.ScanId ?? "-")} • Total: {report.Packages.Count}[/]");
}
private static void RenderRubyInspectReport(RubyInspectReport report)
{
if (!report.Packages.Any())
{
AnsiConsole.MarkupLine("[yellow]No Ruby packages detected.[/]");
return;
}
if (report.Observation is { } observation)
{
var bundler = string.IsNullOrWhiteSpace(observation.BundlerVersion)
? "n/a"
: observation.BundlerVersion;
AnsiConsole.MarkupLine(
"[grey]Observation[/] bundler={0} • packages={1} • runtimeEdges={2}",
Markup.Escape(bundler),
observation.PackageCount,
observation.RuntimeEdgeCount);
AnsiConsole.MarkupLine(
"[grey]Capabilities[/] exec={0} net={1} serialization={2}",
observation.UsesExec ? "[green]on[/]" : "[red]off[/]",
observation.UsesNetwork ? "[green]on[/]" : "[red]off[/]",
observation.UsesSerialization ? "[green]on[/]" : "[red]off[/]");
if (observation.SchedulerCount > 0)
{
var schedulerLabel = observation.Schedulers.Count > 0
? string.Join(", ", observation.Schedulers)
: observation.SchedulerCount.ToString(CultureInfo.InvariantCulture);
AnsiConsole.MarkupLine("[grey]Schedulers[/] {0}", Markup.Escape(schedulerLabel));
}
AnsiConsole.WriteLine();
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn("Groups");
table.AddColumn("Platform");
table.AddColumn(new TableColumn("Source").NoWrap());
table.AddColumn(new TableColumn("Lockfile").NoWrap());
table.AddColumn(new TableColumn("Runtime").NoWrap());
foreach (var entry in report.Packages)
{
var groups = entry.Groups.Count == 0 ? "-" : string.Join(", ", entry.Groups);
var runtime = entry.UsedByEntrypoint
? "[green]Entrypoint[/]"
: entry.RuntimeEntrypoints.Count > 0
? Markup.Escape(string.Join(", ", entry.RuntimeEntrypoints))
: "[grey]-[/]";
table.AddRow(
Markup.Escape(entry.Name),
Markup.Escape(entry.Version ?? "-"),
Markup.Escape(groups),
Markup.Escape(entry.Platform ?? "-"),
Markup.Escape(entry.Source ?? "-"),
Markup.Escape(entry.Lockfile ?? "-"),
runtime);
}
AnsiConsole.Write(table);
}
private static void RenderRubyResolveReport(RubyResolveReport report)
{
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Group");
table.AddColumn("Platform");
table.AddColumn("Package");
table.AddColumn("Version");
table.AddColumn(new TableColumn("Source").NoWrap());
table.AddColumn(new TableColumn("Lockfile").NoWrap());
table.AddColumn(new TableColumn("Runtime").NoWrap());
foreach (var group in report.Groups)
{
foreach (var package in group.Packages)
{
var runtime = package.RuntimeEntrypoints.Count > 0
? Markup.Escape(string.Join(", ", package.RuntimeEntrypoints))
: package.RuntimeUsed ? "[green]Entrypoint[/]" : "[grey]-[/]";
table.AddRow(
Markup.Escape(group.Group),
Markup.Escape(group.Platform ?? "-"),
Markup.Escape(package.Name),
Markup.Escape(package.Version ?? "-"),
Markup.Escape(package.Source ?? "-"),
Markup.Escape(package.Lockfile ?? "-"),
runtime);
}
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("[grey]Scan {0} • Total packages: {1}[/]", Markup.Escape(report.ScanId), report.TotalPackages);
}
private static void RenderCryptoProviders(
IReadOnlyList<string> preferredOrder,
IReadOnlyCollection<ProviderInfo> providers)
{
if (preferredOrder.Count > 0)
{
AnsiConsole.MarkupLine("[cyan]Preferred order:[/] {0}", Markup.Escape(string.Join(", ", preferredOrder)));
}
else
{
AnsiConsole.MarkupLine("[yellow]Preferred order is not configured; using registration order.[/]");
}
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("Provider");
table.AddColumn("Type");
table.AddColumn("Keys");
foreach (var provider in providers)
{
var keySummary = provider.Keys.Count == 0
? "[grey]No signing keys exposed (managed externally).[/]"
: string.Join(Environment.NewLine, provider.Keys.Select(FormatDescriptor));
table.AddRow(
Markup.Escape(provider.Name),
Markup.Escape(provider.Type),
keySummary);
}
AnsiConsole.Write(table);
}
private static IReadOnlyList<CryptoProviderKeyDescriptor> DescribeProviderKeys(ICryptoProvider provider)
{
if (provider is ICryptoProviderDiagnostics diagnostics)
{
return diagnostics.DescribeKeys().ToList();
}
var signingKeys = provider.GetSigningKeys();
if (signingKeys.Count == 0)
{
return Array.Empty<CryptoProviderKeyDescriptor>();
}
var descriptors = new List<CryptoProviderKeyDescriptor>(signingKeys.Count);
foreach (var signingKey in signingKeys)
{
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["kind"] = signingKey.Kind.ToString(),
["createdAt"] = signingKey.CreatedAt.UtcDateTime.ToString("O"),
["providerHint"] = signingKey.Reference.ProviderHint
};
if (signingKey.ExpiresAt.HasValue)
{
metadata["expiresAt"] = signingKey.ExpiresAt.Value.UtcDateTime.ToString("O");
}
foreach (var pair in signingKey.Metadata)
{
metadata[$"meta.{pair.Key}"] = pair.Value;
}
descriptors.Add(new CryptoProviderKeyDescriptor(
provider.Name,
signingKey.Reference.KeyId,
signingKey.AlgorithmId,
metadata));
}
return descriptors;
}
private sealed class RubyInspectReport
{
[JsonPropertyName("packages")]
public IReadOnlyList<RubyInspectEntry> Packages { get; }
[JsonPropertyName("observation")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RubyObservationSummary? Observation { get; }
private RubyInspectReport(IReadOnlyList<RubyInspectEntry> packages, RubyObservationSummary? observation)
{
Packages = packages;
Observation = observation;
}
public static RubyInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
{
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
var entries = source
.Where(static snapshot => string.Equals(snapshot.Type, "gem", StringComparison.OrdinalIgnoreCase))
.Select(RubyInspectEntry.FromSnapshot)
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToArray();
var observation = RubyObservationSummary.TryCreate(source);
return new RubyInspectReport(entries, observation);
}
}
private sealed record RubyInspectEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("lockfile")] string? Lockfile,
[property: JsonPropertyName("groups")] IReadOnlyList<string> Groups,
[property: JsonPropertyName("platform")] string? Platform,
[property: JsonPropertyName("declaredOnly")] bool DeclaredOnly,
[property: JsonPropertyName("runtimeEntrypoints")] IReadOnlyList<string> RuntimeEntrypoints,
[property: JsonPropertyName("runtimeFiles")] IReadOnlyList<string> RuntimeFiles,
[property: JsonPropertyName("runtimeReasons")] IReadOnlyList<string> RuntimeReasons,
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint)
{
public static RubyInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
{
var metadata = RubyMetadataHelpers.Clone(snapshot.Metadata);
var groups = RubyMetadataHelpers.GetList(metadata, "groups");
var platform = RubyMetadataHelpers.GetString(metadata, "platform");
var source = RubyMetadataHelpers.GetString(metadata, "source");
var lockfile = RubyMetadataHelpers.GetString(metadata, "lockfile");
var declaredOnly = RubyMetadataHelpers.GetBool(metadata, "declaredOnly") ?? false;
var runtimeEntrypoints = RubyMetadataHelpers.GetList(metadata, "runtime.entrypoints");
var runtimeFiles = RubyMetadataHelpers.GetList(metadata, "runtime.files");
var runtimeReasons = RubyMetadataHelpers.GetList(metadata, "runtime.reasons");
var usedByEntrypoint = RubyMetadataHelpers.GetBool(metadata, "runtime.used") ?? snapshot.UsedByEntrypoint;
return new RubyInspectEntry(
snapshot.Name,
snapshot.Version,
source,
lockfile,
groups,
platform,
declaredOnly,
runtimeEntrypoints,
runtimeFiles,
runtimeReasons,
usedByEntrypoint);
}
}
private sealed record RubyObservationSummary(
[property: JsonPropertyName("packageCount")] int PackageCount,
[property: JsonPropertyName("runtimeEdgeCount")] int RuntimeEdgeCount,
[property: JsonPropertyName("bundlerVersion")] string? BundlerVersion,
[property: JsonPropertyName("usesExec")] bool UsesExec,
[property: JsonPropertyName("usesNetwork")] bool UsesNetwork,
[property: JsonPropertyName("usesSerialization")] bool UsesSerialization,
[property: JsonPropertyName("schedulerCount")] int SchedulerCount,
[property: JsonPropertyName("schedulers")] IReadOnlyList<string> Schedulers)
{
public static RubyObservationSummary? TryCreate(IEnumerable<LanguageComponentSnapshot> snapshots)
{
var observation = snapshots.FirstOrDefault(static snapshot =>
string.Equals(snapshot.Type, "ruby-observation", StringComparison.OrdinalIgnoreCase));
if (observation is null)
{
return null;
}
var metadata = RubyMetadataHelpers.Clone(observation.Metadata);
var schedulers = RubyMetadataHelpers.GetList(metadata, "ruby.observation.capability.scheduler_list");
return new RubyObservationSummary(
RubyMetadataHelpers.GetInt(metadata, "ruby.observation.packages") ?? 0,
RubyMetadataHelpers.GetInt(metadata, "ruby.observation.runtime_edges") ?? 0,
RubyMetadataHelpers.GetString(metadata, "ruby.observation.bundler_version"),
RubyMetadataHelpers.GetBool(metadata, "ruby.observation.capability.exec") ?? false,
RubyMetadataHelpers.GetBool(metadata, "ruby.observation.capability.net") ?? false,
RubyMetadataHelpers.GetBool(metadata, "ruby.observation.capability.serialization") ?? false,
RubyMetadataHelpers.GetInt(metadata, "ruby.observation.capability.schedulers") ?? schedulers.Count,
schedulers);
}
}
private sealed class RubyResolveReport
{
[JsonPropertyName("scanId")]
public string ScanId { get; }
[JsonPropertyName("imageDigest")]
public string ImageDigest { get; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; }
[JsonPropertyName("groups")]
public IReadOnlyList<RubyResolveGroup> Groups { get; }
[JsonIgnore]
public bool HasPackages => TotalPackages > 0;
[JsonIgnore]
public int TotalPackages => Groups.Sum(static group => group.Packages.Count);
private RubyResolveReport(string scanId, string imageDigest, DateTimeOffset generatedAt, IReadOnlyList<RubyResolveGroup> groups)
{
ScanId = scanId;
ImageDigest = imageDigest;
GeneratedAt = generatedAt;
Groups = groups;
}
public static RubyResolveReport Create(RubyPackageInventoryModel inventory)
{
var resolved = (inventory.Packages ?? Array.Empty<RubyPackageArtifactModel>())
.Select(RubyResolvePackage.FromModel)
.ToArray();
var rows = new List<(string Group, string Platform, RubyResolvePackage Package)>();
foreach (var package in resolved)
{
var groups = package.Groups.Count == 0
? new[] { "(default)" }
: package.Groups;
foreach (var group in groups)
{
rows.Add((group, package.Platform ?? "-", package));
}
}
var grouped = rows
.GroupBy(static row => (row.Group, row.Platform))
.OrderBy(static g => g.Key.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(static g => g.Key.Platform, StringComparer.OrdinalIgnoreCase)
.Select(group => new RubyResolveGroup(
group.Key.Group,
group.Key.Platform,
group.Select(row => row.Package)
.OrderBy(static pkg => pkg.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static pkg => pkg.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToArray()))
.ToArray();
var normalizedScanId = inventory.ScanId ?? string.Empty;
var normalizedDigest = inventory.ImageDigest ?? string.Empty;
return new RubyResolveReport(normalizedScanId, normalizedDigest, inventory.GeneratedAt, grouped);
}
}
private sealed record RubyResolveGroup(
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("platform")] string Platform,
[property: JsonPropertyName("packages")] IReadOnlyList<RubyResolvePackage> Packages);
private sealed record RubyResolvePackage(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("lockfile")] string? Lockfile,
[property: JsonPropertyName("groups")] IReadOnlyList<string> Groups,
[property: JsonPropertyName("platform")] string? Platform,
[property: JsonPropertyName("declaredOnly")] bool DeclaredOnly,
[property: JsonPropertyName("runtimeEntrypoints")] IReadOnlyList<string> RuntimeEntrypoints,
[property: JsonPropertyName("runtimeFiles")] IReadOnlyList<string> RuntimeFiles,
[property: JsonPropertyName("runtimeReasons")] IReadOnlyList<string> RuntimeReasons,
[property: JsonPropertyName("runtimeUsed")] bool RuntimeUsed)
{
public static RubyResolvePackage FromModel(RubyPackageArtifactModel model)
{
var metadata = RubyMetadataHelpers.Clone(model.Metadata);
IReadOnlyList<string> groups = model.Groups is { Count: > 0 }
? model.Groups
.Where(static group => !string.IsNullOrWhiteSpace(group))
.Select(static group => group.Trim())
.ToArray()
: RubyMetadataHelpers.GetList(metadata, "groups");
IReadOnlyList<string>? runtimeEntrypoints = model.Runtime?.Entrypoints?.Where(static e => !string.IsNullOrWhiteSpace(e)).Select(static e => e.Trim()).ToArray();
if (runtimeEntrypoints is null || runtimeEntrypoints.Count == 0)
{
runtimeEntrypoints = RubyMetadataHelpers.GetList(metadata, "runtime.entrypoints");
}
IReadOnlyList<string>? runtimeFiles = model.Runtime?.Files?.Where(static e => !string.IsNullOrWhiteSpace(e)).Select(static e => e.Trim()).ToArray();
if (runtimeFiles is null || runtimeFiles.Count == 0)
{
runtimeFiles = RubyMetadataHelpers.GetList(metadata, "runtime.files");
}
IReadOnlyList<string>? runtimeReasons = model.Runtime?.Reasons?.Where(static e => !string.IsNullOrWhiteSpace(e)).Select(static e => e.Trim()).ToArray();
if (runtimeReasons is null || runtimeReasons.Count == 0)
{
runtimeReasons = RubyMetadataHelpers.GetList(metadata, "runtime.reasons");
}
runtimeEntrypoints ??= Array.Empty<string>();
runtimeFiles ??= Array.Empty<string>();
runtimeReasons ??= Array.Empty<string>();
var source = model.Provenance?.Source
?? model.Source
?? RubyMetadataHelpers.GetString(metadata, "source");
var lockfile = model.Provenance?.Lockfile ?? RubyMetadataHelpers.GetString(metadata, "lockfile");
var platform = model.Platform ?? RubyMetadataHelpers.GetString(metadata, "platform");
var declaredOnly = model.DeclaredOnly ?? RubyMetadataHelpers.GetBool(metadata, "declaredOnly") ?? false;
var runtimeUsed = model.RuntimeUsed ?? RubyMetadataHelpers.GetBool(metadata, "runtime.used") ?? false;
return new RubyResolvePackage(
model.Name,
model.Version,
source,
lockfile,
groups,
platform,
declaredOnly,
runtimeEntrypoints,
runtimeFiles,
runtimeReasons,
runtimeUsed);
}
}
private static class RubyMetadataHelpers
{
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in metadata)
{
clone[pair.Key] = pair.Value;
}
return clone;
}
public static string? GetString(IDictionary<string, string?> metadata, string key)
{
if (metadata.TryGetValue(key, out var value))
{
return value;
}
foreach (var pair in metadata)
{
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
{
return pair.Value;
}
}
return null;
}
public static IReadOnlyList<string> GetList(IDictionary<string, string?> metadata, string key)
{
var value = GetString(metadata, key);
if (string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
}
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
{
var value = GetString(metadata, key);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (bool.TryParse(value, out var parsed))
{
return parsed;
}
return null;
}
public static int? GetInt(IDictionary<string, string?> metadata, string key)
{
var value = GetString(metadata, key);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return null;
}
}
private sealed class PhpInspectReport
{
[JsonPropertyName("packages")]
public IReadOnlyList<PhpInspectEntry> Packages { get; }
private PhpInspectReport(IReadOnlyList<PhpInspectEntry> packages)
{
Packages = packages;
}
public static PhpInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
{
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
var entries = source
.Where(static snapshot => string.Equals(snapshot.Type, "composer", StringComparison.OrdinalIgnoreCase))
.Select(PhpInspectEntry.FromSnapshot)
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToArray();
return new PhpInspectReport(entries);
}
}
private sealed record PhpInspectEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("type")] string? Type,
[property: JsonPropertyName("lockfile")] string? Lockfile,
[property: JsonPropertyName("isDev")] bool IsDev,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("distSha")] string? DistSha)
{
public static PhpInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
{
var metadata = PhpMetadataHelpers.Clone(snapshot.Metadata);
var type = PhpMetadataHelpers.GetString(metadata, "type");
var lockfile = PhpMetadataHelpers.GetString(metadata, "lockfile");
var isDev = PhpMetadataHelpers.GetBool(metadata, "isDev") ?? false;
var source = PhpMetadataHelpers.GetString(metadata, "source");
var distSha = PhpMetadataHelpers.GetString(metadata, "distSha");
return new PhpInspectEntry(
snapshot.Name,
snapshot.Version,
type,
lockfile,
isDev,
source,
distSha);
}
}
private static class PhpMetadataHelpers
{
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in metadata)
{
clone[pair.Key] = pair.Value;
}
return clone;
}
public static string? GetString(IDictionary<string, string?> metadata, string key)
{
if (metadata.TryGetValue(key, out var value))
{
return value;
}
foreach (var pair in metadata)
{
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
{
return pair.Value;
}
}
return null;
}
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
{
var value = GetString(metadata, key);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (bool.TryParse(value, out var parsed))
{
return parsed;
}
return null;
}
}
private sealed class BunInspectReport
{
[JsonPropertyName("packages")]
public IReadOnlyList<BunInspectEntry> Packages { get; }
private BunInspectReport(IReadOnlyList<BunInspectEntry> packages)
{
Packages = packages;
}
public static BunInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
{
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
var entries = source
.Where(static snapshot => string.Equals(snapshot.Type, "npm", StringComparison.OrdinalIgnoreCase))
.Select(BunInspectEntry.FromSnapshot)
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToArray();
return new BunInspectReport(entries);
}
}
private sealed record BunInspectEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("isDev")] bool IsDev,
[property: JsonPropertyName("isDirect")] bool IsDirect,
[property: JsonPropertyName("resolved")] string? Resolved,
[property: JsonPropertyName("integrity")] string? Integrity)
{
public static BunInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
{
var metadata = BunMetadataHelpers.Clone(snapshot.Metadata);
var source = BunMetadataHelpers.GetString(metadata, "source");
var isDev = BunMetadataHelpers.GetBool(metadata, "dev") ?? false;
var isDirect = BunMetadataHelpers.GetBool(metadata, "direct") ?? false;
var resolved = BunMetadataHelpers.GetString(metadata, "resolved");
var integrity = BunMetadataHelpers.GetString(metadata, "integrity");
return new BunInspectEntry(
snapshot.Name ?? "-",
snapshot.Version,
source,
isDev,
isDirect,
resolved,
integrity);
}
}
private static class BunMetadataHelpers
{
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in metadata)
{
clone[pair.Key] = pair.Value;
}
return clone;
}
public static string? GetString(IDictionary<string, string?> metadata, string key)
{
if (metadata.TryGetValue(key, out var value))
{
return value;
}
foreach (var pair in metadata)
{
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
{
return pair.Value;
}
}
return null;
}
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
{
var value = GetString(metadata, key);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (bool.TryParse(value, out var parsed))
{
return parsed;
}
return null;
}
}
private sealed class BunResolveReport
{
[JsonPropertyName("scanId")]
public string? ScanId { get; }
[JsonPropertyName("packages")]
public IReadOnlyList<BunResolveEntry> Packages { get; }
[JsonIgnore]
public bool HasPackages => Packages.Count > 0;
private BunResolveReport(string? scanId, IReadOnlyList<BunResolveEntry> packages)
{
ScanId = scanId;
Packages = packages;
}
public static BunResolveReport Create(BunPackageInventory? inventory)
{
if (inventory is null)
{
return new BunResolveReport(null, Array.Empty<BunResolveEntry>());
}
var entries = inventory.Packages
.Select(BunResolveEntry.FromPackage)
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToArray();
return new BunResolveReport(inventory.ScanId, entries);
}
}
private sealed record BunResolveEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("integrity")] string? Integrity)
{
public static BunResolveEntry FromPackage(BunPackageItem package)
{
return new BunResolveEntry(
package.Name,
package.Version,
package.Source,
package.Integrity);
}
}
private sealed record LockValidationEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("lockSource")] string? LockSource,
[property: JsonPropertyName("lockLocator")] string? LockLocator,
[property: JsonPropertyName("resolved")] string? Resolved,
[property: JsonPropertyName("integrity")] string? Integrity);
private sealed class LockValidationReport
{
public LockValidationReport(
IReadOnlyList<LockValidationEntry> declaredOnly,
IReadOnlyList<LockValidationEntry> missingLockMetadata,
int totalDeclared,
int totalInstalled)
{
DeclaredOnly = declaredOnly;
MissingLockMetadata = missingLockMetadata;
TotalDeclared = totalDeclared;
TotalInstalled = totalInstalled;
}
[JsonPropertyName("declaredOnly")]
public IReadOnlyList<LockValidationEntry> DeclaredOnly { get; }
[JsonPropertyName("missingLockMetadata")]
public IReadOnlyList<LockValidationEntry> MissingLockMetadata { get; }
[JsonPropertyName("totalDeclared")]
public int TotalDeclared { get; }
[JsonPropertyName("totalInstalled")]
public int TotalInstalled { get; }
[JsonIgnore]
public bool HasIssues => DeclaredOnly.Count > 0 || MissingLockMetadata.Count > 0;
public static LockValidationReport Create(IEnumerable<LanguageComponentSnapshot> snapshots)
{
var declaredOnly = new List<LockValidationEntry>();
var missingLock = new List<LockValidationEntry>();
var declaredCount = 0;
var installedCount = 0;
foreach (var component in snapshots ?? Array.Empty<LanguageComponentSnapshot>())
{
var metadata = component.Metadata ?? new Dictionary<string, string?>(StringComparer.Ordinal);
var entry = CreateEntry(component, metadata);
if (IsDeclaredOnly(metadata))
{
declaredOnly.Add(entry);
declaredCount++;
continue;
}
installedCount++;
if (!metadata.TryGetValue("lockSource", out var lockSource) || string.IsNullOrWhiteSpace(lockSource))
{
missingLock.Add(entry);
}
}
declaredOnly.Sort(CompareEntries);
missingLock.Sort(CompareEntries);
return new LockValidationReport(declaredOnly, missingLock, declaredCount, installedCount);
}
private static LockValidationEntry CreateEntry(
LanguageComponentSnapshot component,
IDictionary<string, string?> metadata)
{
metadata.TryGetValue("path", out var path);
metadata.TryGetValue("lockSource", out var lockSource);
metadata.TryGetValue("lockLocator", out var lockLocator);
metadata.TryGetValue("resolved", out var resolved);
metadata.TryGetValue("integrity", out var integrity);
return new LockValidationEntry(
component.Name,
component.Version,
string.IsNullOrWhiteSpace(path) ? "." : path!,
lockSource,
lockLocator,
resolved,
integrity);
}
private static bool IsDeclaredOnly(IDictionary<string, string?> metadata)
{
if (metadata.TryGetValue("declaredOnly", out var value))
{
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
return false;
}
private static int CompareEntries(LockValidationEntry left, LockValidationEntry right)
{
var nameComparison = string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase);
if (nameComparison != 0)
{
return nameComparison;
}
return string.Compare(left.Version, right.Version, StringComparison.OrdinalIgnoreCase);
}
}
private static IReadOnlyList<string> DeterminePreferredOrder(
CryptoProviderRegistryOptions? options,
string? overrideProfile)
{
if (options is null)
{
return Array.Empty<string>();
}
if (!string.IsNullOrWhiteSpace(overrideProfile) &&
options.Profiles.TryGetValue(overrideProfile, out var profile) &&
profile.PreferredProviders.Count > 0)
{
return profile.PreferredProviders
.Where(static provider => !string.IsNullOrWhiteSpace(provider))
.Select(static provider => provider.Trim())
.ToArray();
}
return options.ResolvePreferredProviders();
}
private static string FormatDescriptor(CryptoProviderKeyDescriptor descriptor)
{
if (descriptor.Metadata.Count == 0)
{
return $"{Markup.Escape(descriptor.KeyId)} ({Markup.Escape(descriptor.AlgorithmId)})";
}
var metadataText = string.Join(
", ",
descriptor.Metadata.Select(pair => $"{pair.Key}={pair.Value}"));
return $"{Markup.Escape(descriptor.KeyId)} ({Markup.Escape(descriptor.AlgorithmId)}){Environment.NewLine}[grey]{Markup.Escape(metadataText)}[/]";
}
private sealed record ProviderInfo(string Name, string Type, IReadOnlyList<CryptoProviderKeyDescriptor> Keys);
// ═══════════════════════════════════════════════════════════════════════════
// ATTEST HANDLERS (DSSE-CLI-401-021)
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Handle 'stella attest verify' command (CLI-ATTEST-73-002).
/// Verifies a DSSE envelope with policy selection, explainability output, and JSON/table formatting.
/// </summary>
public static async Task<int> HandleAttestVerifyAsync(
IServiceProvider services,
string envelopePath,
string? policyPath,
string? rootPath,
string? checkpointPath,
string? outputPath,
string format,
bool explain,
bool verbose,
CancellationToken cancellationToken)
{
// format: "table" or "json"
// explain: include detailed explanations in output
// Exit codes per docs: 0 success, 2 verification failed, 4 input error
const int ExitSuccess = 0;
const int ExitVerificationFailed = 2;
const int ExitInputError = 4;
using var duration = CliMetrics.MeasureCommandDuration("attest verify");
if (!File.Exists(envelopePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Envelope file not found: {Markup.Escape(envelopePath)}");
CliMetrics.RecordAttestVerify("input_error");
return ExitInputError;
}
try
{
var envelopeJson = await File.ReadAllTextAsync(envelopePath, cancellationToken).ConfigureAwait(false);
// Parse the envelope
var envelope = JsonSerializer.Deserialize<JsonElement>(envelopeJson);
// Extract envelope components
string payloadType = "";
string payload = "";
var signatures = new List<(string KeyId, string Sig)>();
string? predicateType = null;
var subjects = new List<(string Name, string Algorithm, string Digest)>();
if (envelope.TryGetProperty("payloadType", out var pt))
payloadType = pt.GetString() ?? "";
if (envelope.TryGetProperty("payload", out var pl))
payload = pl.GetString() ?? "";
if (envelope.TryGetProperty("signatures", out var sigs) && sigs.ValueKind == JsonValueKind.Array)
{
foreach (var sig in sigs.EnumerateArray())
{
var keyId = sig.TryGetProperty("keyid", out var kid) ? kid.GetString() ?? "(none)" : "(none)";
var sigValue = sig.TryGetProperty("sig", out var sv) ? sv.GetString() ?? "" : "";
signatures.Add((keyId, sigValue));
}
}
// Decode and parse payload (in-toto statement)
if (!string.IsNullOrWhiteSpace(payload))
{
try
{
var payloadBytes = Convert.FromBase64String(payload);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
var statement = JsonSerializer.Deserialize<JsonElement>(payloadJson);
if (statement.TryGetProperty("predicateType", out var predType))
predicateType = predType.GetString();
if (statement.TryGetProperty("subject", out var subjs) && subjs.ValueKind == JsonValueKind.Array)
{
foreach (var subj in subjs.EnumerateArray())
{
var name = subj.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (subj.TryGetProperty("digest", out var digest))
{
foreach (var d in digest.EnumerateObject())
{
subjects.Add((name, d.Name, d.Value.GetString() ?? ""));
}
}
}
}
}
catch (FormatException)
{
// Invalid base64
}
}
// Verification checks
var checks = new List<(string Check, bool Passed, string Reason)>();
// Check 1: Valid envelope structure
var hasValidStructure = !string.IsNullOrWhiteSpace(payloadType) &&
!string.IsNullOrWhiteSpace(payload) &&
signatures.Count > 0;
checks.Add(("Envelope Structure", hasValidStructure,
hasValidStructure ? "Valid DSSE envelope with payload and signature(s)" : "Missing required envelope fields (payloadType, payload, or signatures)"));
// Check 2: Payload type
var validPayloadType = payloadType == "application/vnd.in-toto+json";
checks.Add(("Payload Type", validPayloadType,
validPayloadType ? "Correct in-toto payload type" : $"Unexpected payload type: {payloadType}"));
// Check 3: Has subjects
var hasSubjects = subjects.Count > 0;
checks.Add(("Subject Presence", hasSubjects,
hasSubjects ? $"Found {subjects.Count} subject(s)" : "No subjects found in statement"));
// Check 4: Trust root verification (if provided)
var hasRoot = !string.IsNullOrWhiteSpace(rootPath) && File.Exists(rootPath);
if (hasRoot)
{
// In full implementation, would verify signature against root certificate
// For now, mark as passed if root is provided (placeholder)
checks.Add(("Signature Verification", true,
$"Trust root provided: {Path.GetFileName(rootPath)}"));
}
else
{
checks.Add(("Signature Verification", false,
"No trust root provided (use --root to specify trusted certificate)"));
}
// Check 5: Transparency log (if checkpoint provided)
var hasCheckpoint = !string.IsNullOrWhiteSpace(checkpointPath) && File.Exists(checkpointPath);
if (hasCheckpoint)
{
checks.Add(("Transparency Log", true,
$"Checkpoint file provided: {Path.GetFileName(checkpointPath)}"));
}
else
{
checks.Add(("Transparency Log", false,
"No transparency checkpoint provided (use --transparency-checkpoint)"));
}
// Check 6: Policy compliance (if policy provided)
var policyCompliant = true;
var policyReasons = new List<string>();
if (!string.IsNullOrWhiteSpace(policyPath))
{
if (!File.Exists(policyPath))
{
policyCompliant = false;
policyReasons.Add($"Policy file not found: {policyPath}");
}
else
{
try
{
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
var policy = JsonSerializer.Deserialize<JsonElement>(policyJson);
// Check required predicate types
if (policy.TryGetProperty("requiredPredicateTypes", out var requiredTypes) &&
requiredTypes.ValueKind == JsonValueKind.Array)
{
var required = requiredTypes.EnumerateArray()
.Select(t => t.GetString())
.Where(t => t != null)
.ToList();
if (required.Count > 0 && !required.Contains(predicateType))
{
policyCompliant = false;
policyReasons.Add($"Predicate type '{predicateType}' not in required list: [{string.Join(", ", required)}]");
}
else if (required.Count > 0)
{
policyReasons.Add($"Predicate type '{predicateType}' is allowed");
}
}
// Check minimum signatures
if (policy.TryGetProperty("minimumSignatures", out var minSigs) &&
minSigs.TryGetInt32(out var minCount))
{
if (signatures.Count < minCount)
{
policyCompliant = false;
policyReasons.Add($"Insufficient signatures: {signatures.Count} < {minCount} required");
}
else
{
policyReasons.Add($"Signature count ({signatures.Count}) meets minimum ({minCount})");
}
}
// Check required signers
if (policy.TryGetProperty("requiredSigners", out var requiredSigners) &&
requiredSigners.ValueKind == JsonValueKind.Array)
{
var required = requiredSigners.EnumerateArray()
.Select(s => s.GetString())
.Where(s => s != null)
.ToList();
var actualSigners = signatures.Select(s => s.KeyId).ToHashSet();
var missing = required.Where(r => !actualSigners.Contains(r)).ToList();
if (missing.Count > 0)
{
policyCompliant = false;
policyReasons.Add($"Missing required signers: [{string.Join(", ", missing!)}]");
}
}
if (policyReasons.Count == 0)
{
policyReasons.Add("Policy file loaded, no constraints defined");
}
}
catch (JsonException ex)
{
policyCompliant = false;
policyReasons.Add($"Invalid policy JSON: {ex.Message}");
}
}
checks.Add(("Policy Compliance", policyCompliant,
string.Join("; ", policyReasons)));
}
// Overall result
var requiredPassed = checks.Where(c => c.Check is "Envelope Structure" or "Payload Type" or "Subject Presence")
.All(c => c.Passed);
var signatureVerified = checks.FirstOrDefault(c => c.Check == "Signature Verification").Passed;
var overallStatus = requiredPassed && signatureVerified && policyCompliant ? "PASSED" : "FAILED";
// Build result object
var result = new
{
envelopePath,
verifiedAt = DateTimeOffset.UtcNow.ToString("o"),
status = overallStatus,
envelope = new
{
payloadType,
signatureCount = signatures.Count,
signers = signatures.Select(s => s.KeyId).ToList()
},
statement = new
{
predicateType = predicateType ?? "(unknown)",
subjectCount = subjects.Count,
subjects = subjects.Select(s => new
{
name = s.Name,
algorithm = s.Algorithm,
digest = s.Digest.Length > 16 ? s.Digest[..16] + "..." : s.Digest
}).ToList()
},
checks = checks.Select(c => new
{
check = c.Check,
passed = c.Passed,
reason = c.Reason
}).ToList(),
inputs = new
{
policyPath,
rootPath,
checkpointPath
}
};
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
// Output to file if specified
if (!string.IsNullOrWhiteSpace(outputPath))
{
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Verification report written to:[/] {Markup.Escape(outputPath)}");
}
// Output to console based on format
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
// JSON output to console
AnsiConsole.WriteLine(json);
}
else
{
// Table output for console
var statusColor = overallStatus == "PASSED" ? "green" : "red";
AnsiConsole.MarkupLine($"[bold]Attestation Verification:[/] [{statusColor}]{overallStatus}[/{statusColor}]");
AnsiConsole.WriteLine();
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Envelope: {Markup.Escape(envelopePath)}[/]");
AnsiConsole.MarkupLine($"[grey]Predicate Type: {Markup.Escape(predicateType ?? "(unknown)")}[/]");
AnsiConsole.MarkupLine($"[grey]Subjects: {subjects.Count}[/]");
AnsiConsole.MarkupLine($"[grey]Signatures: {signatures.Count}[/]");
AnsiConsole.WriteLine();
}
// Subjects table
if (subjects.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Subjects:[/]");
var subjectTable = new Table { Border = TableBorder.Rounded };
subjectTable.AddColumn("Name");
subjectTable.AddColumn("Algorithm");
subjectTable.AddColumn("Digest");
foreach (var (name, algorithm, digest) in subjects)
{
var displayDigest = digest.Length > 20 ? digest[..20] + "..." : digest;
subjectTable.AddRow(Markup.Escape(name), Markup.Escape(algorithm), Markup.Escape(displayDigest));
}
AnsiConsole.Write(subjectTable);
AnsiConsole.WriteLine();
}
// Verification checks table
AnsiConsole.MarkupLine("[bold]Verification Checks:[/]");
var checksTable = new Table { Border = TableBorder.Rounded };
checksTable.AddColumn("Check");
checksTable.AddColumn("Result");
if (explain)
{
checksTable.AddColumn("Explanation");
}
foreach (var (check, passed, reason) in checks)
{
var resultText = passed ? "[green]PASS[/]" : "[red]FAIL[/]";
if (explain)
{
checksTable.AddRow(Markup.Escape(check), resultText, Markup.Escape(reason));
}
else
{
checksTable.AddRow(Markup.Escape(check), resultText);
}
}
AnsiConsole.Write(checksTable);
}
var outcome = overallStatus == "PASSED" ? "passed" : "failed";
CliMetrics.RecordAttestVerify(outcome);
return overallStatus == "PASSED" ? ExitSuccess : ExitVerificationFailed;
}
catch (JsonException ex)
{
AnsiConsole.MarkupLine($"[red]Error parsing envelope:[/] {Markup.Escape(ex.Message)}");
CliMetrics.RecordAttestVerify("parse_error");
return ExitInputError;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error during verification:[/] {Markup.Escape(ex.Message)}");
CliMetrics.RecordAttestVerify("error");
return ExitInputError;
}
}
/// <summary>
/// Handle 'stella attest list' command (CLI-ATTEST-74-001).
/// Lists attestations with filters (subject, type, issuer, scope) and pagination.
/// </summary>
public static async Task<int> HandleAttestListAsync(
IServiceProvider services,
string? tenant,
string? issuer,
string? subject,
string? predicateType,
string scope,
string format,
int? limit,
int? offset,
bool verbose,
CancellationToken cancellationToken)
{
using var duration = CliMetrics.MeasureCommandDuration("attest list");
var effectiveLimit = limit ?? 50;
var effectiveOffset = offset ?? 0;
var includeLocal = scope.Equals("local", StringComparison.OrdinalIgnoreCase) ||
scope.Equals("all", StringComparison.OrdinalIgnoreCase);
// Attestation record for listing
var attestations = new List<AttestationListItem>();
// Load from local storage if scope includes local
if (includeLocal)
{
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".stellaops", "attestations");
if (Directory.Exists(configDir))
{
foreach (var file in Directory.GetFiles(configDir, "*.json"))
{
try
{
var content = await File.ReadAllTextAsync(file, cancellationToken);
var envelope = JsonSerializer.Deserialize<JsonElement>(content);
// Extract attestation info
var item = new AttestationListItem
{
Id = Path.GetFileNameWithoutExtension(file),
Source = "local",
FilePath = file
};
// Parse payload to get predicate type and subjects
if (envelope.TryGetProperty("payload", out var payloadProp))
{
try
{
var payloadBytes = Convert.FromBase64String(payloadProp.GetString() ?? "");
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
var statement = JsonSerializer.Deserialize<JsonElement>(payloadJson);
if (statement.TryGetProperty("predicateType", out var pt))
item.PredicateType = pt.GetString();
if (statement.TryGetProperty("subject", out var subjs) &&
subjs.ValueKind == JsonValueKind.Array)
{
var subjects = new List<string>();
foreach (var subj in subjs.EnumerateArray())
{
if (subj.TryGetProperty("name", out var name))
subjects.Add(name.GetString() ?? "");
}
item.Subjects = subjects;
}
}
catch { /* Ignore parsing errors */ }
}
// Extract signatures/issuer
if (envelope.TryGetProperty("signatures", out var sigs) &&
sigs.ValueKind == JsonValueKind.Array &&
sigs.GetArrayLength() > 0)
{
var firstSig = sigs.EnumerateArray().First();
if (firstSig.TryGetProperty("keyid", out var keyId))
item.Issuer = keyId.GetString();
item.SignatureCount = sigs.GetArrayLength();
}
// Get file timestamp
var fileInfo = new FileInfo(file);
item.CreatedAt = fileInfo.CreationTimeUtc;
attestations.Add(item);
}
catch
{
// Skip files that can't be parsed
}
}
}
}
// Apply filters
var filtered = attestations.AsEnumerable();
if (!string.IsNullOrEmpty(tenant))
{
// Tenant filter would apply to metadata - for local files, skip
}
if (!string.IsNullOrEmpty(issuer))
{
filtered = filtered.Where(a =>
a.Issuer != null &&
a.Issuer.Contains(issuer, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(subject))
{
filtered = filtered.Where(a =>
a.Subjects?.Any(s => s.Contains(subject, StringComparison.OrdinalIgnoreCase)) == true);
}
if (!string.IsNullOrEmpty(predicateType))
{
filtered = filtered.Where(a =>
a.PredicateType != null &&
a.PredicateType.Contains(predicateType, StringComparison.OrdinalIgnoreCase));
}
// Sort by creation time descending
var sorted = filtered.OrderByDescending(a => a.CreatedAt).ToList();
var total = sorted.Count;
// Apply pagination
var paginated = sorted.Skip(effectiveOffset).Take(effectiveLimit).ToList();
// Output
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var result = new
{
attestations = paginated.Select(a => new
{
id = a.Id,
source = a.Source,
predicateType = a.PredicateType,
issuer = a.Issuer,
subjects = a.Subjects,
signatureCount = a.SignatureCount,
createdAt = a.CreatedAt?.ToString("o")
}).ToList(),
pagination = new
{
total,
limit = effectiveLimit,
offset = effectiveOffset,
returned = paginated.Count
},
filters = new
{
tenant,
issuer,
subject,
predicateType,
scope
}
};
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
AnsiConsole.WriteLine(json);
}
else
{
if (paginated.Count == 0)
{
AnsiConsole.MarkupLine("[grey]No attestations found matching criteria.[/]");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Searched scope: {scope}[/]");
if (!string.IsNullOrEmpty(subject))
AnsiConsole.MarkupLine($"[grey]Subject filter: {Markup.Escape(subject)}[/]");
if (!string.IsNullOrEmpty(predicateType))
AnsiConsole.MarkupLine($"[grey]Type filter: {Markup.Escape(predicateType)}[/]");
if (!string.IsNullOrEmpty(issuer))
AnsiConsole.MarkupLine($"[grey]Issuer filter: {Markup.Escape(issuer)}[/]");
}
}
else
{
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("ID");
table.AddColumn("Predicate Type");
table.AddColumn("Subjects");
table.AddColumn("Issuer");
table.AddColumn("Sigs");
table.AddColumn("Created (UTC)");
foreach (var a in paginated)
{
var subjectDisplay = a.Subjects?.Count > 0
? (a.Subjects.Count == 1 ? a.Subjects[0] : $"{a.Subjects[0]} (+{a.Subjects.Count - 1})")
: "-";
if (subjectDisplay.Length > 30)
subjectDisplay = subjectDisplay[..27] + "...";
var typeDisplay = a.PredicateType ?? "-";
if (typeDisplay.Length > 35)
typeDisplay = "..." + typeDisplay[^32..];
var issuerDisplay = a.Issuer ?? "-";
if (issuerDisplay.Length > 20)
issuerDisplay = issuerDisplay[..17] + "...";
table.AddRow(
Markup.Escape(a.Id ?? "-"),
Markup.Escape(typeDisplay),
Markup.Escape(subjectDisplay),
Markup.Escape(issuerDisplay),
a.SignatureCount.ToString(),
a.CreatedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-");
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Showing {paginated.Count} of {total} attestations[/]");
if (total > effectiveOffset + effectiveLimit)
{
AnsiConsole.MarkupLine($"[grey]Use --offset {effectiveOffset + effectiveLimit} to see more[/]");
}
}
}
return 0;
}
/// <summary>
/// Attestation list item for display.
/// </summary>
private sealed class AttestationListItem
{
public string? Id { get; set; }
public string? Source { get; set; }
public string? FilePath { get; set; }
public string? PredicateType { get; set; }
public string? Issuer { get; set; }
public List<string>? Subjects { get; set; }
public int SignatureCount { get; set; }
public DateTime? CreatedAt { get; set; }
}
public static Task<int> HandleAttestShowAsync(
IServiceProvider services,
string id,
string outputFormat,
bool includeProof,
bool verbose,
CancellationToken cancellationToken)
{
// Placeholder: would fetch specific attestation from backend
var result = new Dictionary<string, object?>
{
["id"] = id,
["found"] = false,
["message"] = "Attestation lookup requires backend connectivity.",
["include_proof"] = includeProof
};
if (outputFormat.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
AnsiConsole.WriteLine(json);
}
else
{
var table = new Table();
table.AddColumn("Property");
table.AddColumn("Value");
foreach (var (key, value) in result)
{
table.AddRow(Markup.Escape(key), Markup.Escape(value?.ToString() ?? "(null)"));
}
AnsiConsole.Write(table);
}
return Task.FromResult(0);
}
/// <summary>
/// Handle 'stella attest fetch' command (CLI-ATTEST-74-002).
/// Downloads attestation envelopes and payloads to disk.
/// </summary>
public static async Task<int> HandleAttestFetchAsync(
IServiceProvider services,
string? id,
string? subject,
string? predicateType,
string outputDir,
string include,
string scope,
string format,
bool overwrite,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitInputError = 1;
const int ExitNotFound = 2;
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("StellaOps.Cli.AttestFetch");
using var durationScope = CliMetrics.MeasureCommandDuration("attest fetch");
// Validate at least one filter is provided
if (string.IsNullOrEmpty(id) && string.IsNullOrEmpty(subject) && string.IsNullOrEmpty(predicateType))
{
AnsiConsole.MarkupLine("[red]Error:[/] At least one filter (--id, --subject, or --type) is required.");
return ExitInputError;
}
// Ensure output directory exists
try
{
Directory.CreateDirectory(outputDir);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to create output directory: {Markup.Escape(ex.Message)}");
return ExitInputError;
}
var effectiveScope = string.IsNullOrWhiteSpace(scope) ? "all" : scope.ToLowerInvariant();
var includeEnvelope = include.Equals("envelope", StringComparison.OrdinalIgnoreCase) ||
include.Equals("both", StringComparison.OrdinalIgnoreCase);
var includePayload = include.Equals("payload", StringComparison.OrdinalIgnoreCase) ||
include.Equals("both", StringComparison.OrdinalIgnoreCase);
// Local attestation directory
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".stellaops", "attestations");
var fetchedCount = 0;
var skippedCount = 0;
var errorCount = 0;
var results = new List<(string Id, bool Success, string Details)>();
// Fetch from local storage if scope includes local
if (effectiveScope == "all" || effectiveScope == "local")
{
if (Directory.Exists(configDir))
{
foreach (var file in Directory.GetFiles(configDir, "*.json"))
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
var content = await File.ReadAllTextAsync(file, cancellationToken);
var envelope = JsonDocument.Parse(content);
var root = envelope.RootElement;
var attestId = Path.GetFileNameWithoutExtension(file);
// Apply ID filter
if (!string.IsNullOrEmpty(id) &&
!attestId.Equals(id, StringComparison.OrdinalIgnoreCase) &&
!attestId.Contains(id, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Extract and check predicate type / subject from payload
string? payloadPredicateType = null;
string? payloadSubject = null;
byte[]? payloadBytes = null;
if (root.TryGetProperty("payload", out var payloadProp))
{
var payloadBase64 = payloadProp.GetString();
if (!string.IsNullOrEmpty(payloadBase64))
{
try
{
payloadBytes = Convert.FromBase64String(payloadBase64);
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
var payloadDoc = JsonDocument.Parse(payloadJson);
var payloadRoot = payloadDoc.RootElement;
if (payloadRoot.TryGetProperty("predicateType", out var pt))
{
payloadPredicateType = pt.GetString();
}
if (payloadRoot.TryGetProperty("subject", out var subjects) &&
subjects.ValueKind == JsonValueKind.Array &&
subjects.GetArrayLength() > 0)
{
var firstSubj = subjects[0];
if (firstSubj.TryGetProperty("name", out var name))
{
payloadSubject = name.GetString();
}
}
}
catch
{
// Payload decode failed
}
}
}
// Apply type filter
if (!string.IsNullOrEmpty(predicateType) &&
(payloadPredicateType == null ||
!payloadPredicateType.Contains(predicateType, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
// Apply subject filter
if (!string.IsNullOrEmpty(subject) &&
(payloadSubject == null ||
!payloadSubject.Contains(subject, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
// Write envelope
if (includeEnvelope)
{
var envelopePath = Path.Combine(outputDir, $"{attestId}.envelope.json");
if (!overwrite && File.Exists(envelopePath))
{
skippedCount++;
results.Add((attestId, true, "Envelope exists, skipped"));
if (verbose)
{
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {Markup.Escape(attestId)} (envelope exists)");
}
}
else
{
await File.WriteAllTextAsync(envelopePath, content, cancellationToken);
if (verbose)
{
AnsiConsole.MarkupLine($"[green]Wrote:[/] {Markup.Escape(Path.GetFileName(envelopePath))}");
}
}
}
// Write payload
if (includePayload && payloadBytes != null)
{
var extension = format.Equals("raw", StringComparison.OrdinalIgnoreCase) ? "bin" : "json";
var payloadPath = Path.Combine(outputDir, $"{attestId}.payload.{extension}");
if (!overwrite && File.Exists(payloadPath))
{
skippedCount++;
results.Add((attestId, true, "Payload exists, skipped"));
if (verbose)
{
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {Markup.Escape(attestId)} (payload exists)");
}
}
else
{
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
// Pretty-print JSON
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
var payloadDoc = JsonDocument.Parse(payloadJson);
var prettyJson = JsonSerializer.Serialize(payloadDoc, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(payloadPath, prettyJson, cancellationToken);
}
else
{
await File.WriteAllBytesAsync(payloadPath, payloadBytes, cancellationToken);
}
if (verbose)
{
AnsiConsole.MarkupLine($"[green]Wrote:[/] {Markup.Escape(Path.GetFileName(payloadPath))}");
}
}
}
fetchedCount++;
results.Add((attestId, true, "Fetched successfully"));
}
catch (Exception ex)
{
errorCount++;
var errId = Path.GetFileNameWithoutExtension(file);
results.Add((errId, false, ex.Message));
logger.LogDebug(ex, "Failed to fetch attestation: {File}", file);
}
}
}
}
// Summary output
if (fetchedCount == 0 && errorCount == 0)
{
AnsiConsole.MarkupLine("[yellow]No attestations found matching the specified criteria.[/]");
return ExitNotFound;
}
AnsiConsole.MarkupLine($"[green]Fetched:[/] {fetchedCount} attestation(s) to {Markup.Escape(outputDir)}");
if (skippedCount > 0)
{
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] {skippedCount} file(s) (already exist, use --overwrite)");
}
if (errorCount > 0)
{
AnsiConsole.MarkupLine($"[red]Errors:[/] {errorCount} attestation(s) failed");
}
return ExitSuccess;
}
/// <summary>
/// Handle 'stella attest key create' command (CLI-ATTEST-75-001).
/// Creates a new signing key for attestations using FileKmsClient.
/// </summary>
public static async Task<int> HandleAttestKeyCreateAsync(
IServiceProvider services,
string name,
string algorithm,
string? password,
string? outputPath,
string format,
bool exportPublic,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitInputError = 1;
const int ExitKeyError = 2;
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("StellaOps.Cli.AttestKeyCreate");
using var durationScope = CliMetrics.MeasureCommandDuration("attest key create");
// Validate algorithm
var normalizedAlgorithm = algorithm.ToUpperInvariant() switch
{
"ECDSA-P256" or "P256" or "ES256" => "ECDSA-P256",
"ECDSA-P384" or "P384" or "ES384" => "ECDSA-P384",
_ => null
};
if (normalizedAlgorithm == null)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Unsupported algorithm '{Markup.Escape(algorithm)}'. Supported: ECDSA-P256, ECDSA-P384.");
return ExitInputError;
}
// Determine key directory
var keysDir = outputPath ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".stellaops", "keys");
try
{
Directory.CreateDirectory(keysDir);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to create keys directory: {Markup.Escape(ex.Message)}");
return ExitInputError;
}
// Get or prompt for password
var effectivePassword = password;
if (string.IsNullOrEmpty(effectivePassword))
{
effectivePassword = AnsiConsole.Prompt(
new TextPrompt<string>("Enter password for key protection:")
.Secret());
var confirm = AnsiConsole.Prompt(
new TextPrompt<string>("Confirm password:")
.Secret());
if (effectivePassword != confirm)
{
AnsiConsole.MarkupLine("[red]Error:[/] Passwords do not match.");
return ExitInputError;
}
}
if (string.IsNullOrWhiteSpace(effectivePassword))
{
AnsiConsole.MarkupLine("[red]Error:[/] Password is required to protect the key.");
return ExitInputError;
}
try
{
// Use FileKmsClient to create the key
var kmsOptions = new StellaOps.Cryptography.Kms.FileKmsOptions
{
RootPath = keysDir,
Password = effectivePassword,
Algorithm = normalizedAlgorithm,
KeyDerivationIterations = 600_000
};
using var kmsClient = new StellaOps.Cryptography.Kms.FileKmsClient(kmsOptions);
// RotateAsync creates a key if it doesn't exist
var metadata = await kmsClient.RotateAsync(name, cancellationToken);
// Get the current (active) version from the versions list
var currentVersion = metadata.Versions.FirstOrDefault(v => v.State == StellaOps.Cryptography.Kms.KmsKeyState.Active);
var versionId = currentVersion?.VersionId ?? "1";
var publicKeyString = currentVersion?.PublicKey;
// Export public key if requested
string? publicKeyPath = null;
if (exportPublic && !string.IsNullOrEmpty(publicKeyString))
{
// PublicKey is already base64 encoded from the metadata
publicKeyPath = Path.Combine(keysDir, $"{name}.pub.pem");
var publicKeyPem = $"-----BEGIN PUBLIC KEY-----\n{FormatBase64ForPem(publicKeyString)}\n-----END PUBLIC KEY-----\n";
await File.WriteAllTextAsync(publicKeyPath, publicKeyPem, cancellationToken);
}
// Output result
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var result = new
{
keyId = name,
algorithm = normalizedAlgorithm,
version = versionId,
state = metadata.State.ToString(),
createdAt = metadata.CreatedAt.ToString("o"),
keyPath = Path.Combine(keysDir, $"{name}.json"),
publicKeyPath = publicKeyPath
};
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
AnsiConsole.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine($"[green]Success:[/] Key '{Markup.Escape(name)}' created.");
AnsiConsole.MarkupLine($"[grey]Algorithm:[/] {normalizedAlgorithm}");
AnsiConsole.MarkupLine($"[grey]Version:[/] {Markup.Escape(versionId)}");
AnsiConsole.MarkupLine($"[grey]State:[/] {metadata.State}");
AnsiConsole.MarkupLine($"[grey]Key path:[/] {Markup.Escape(Path.Combine(keysDir, $"{name}.json"))}");
if (publicKeyPath != null)
{
AnsiConsole.MarkupLine($"[grey]Public key:[/] {Markup.Escape(publicKeyPath)}");
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim]Use --key option with 'stella attest sign' to sign attestations with this key.[/]");
}
return ExitSuccess;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create key: {Name}", name);
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to create key: {Markup.Escape(ex.Message)}");
return ExitKeyError;
}
}
/// <summary>
/// Formats Base64 string for PEM output (64 chars per line).
/// </summary>
private static string FormatBase64ForPem(string base64)
{
const int lineLength = 64;
var sb = new StringBuilder();
for (int i = 0; i < base64.Length; i += lineLength)
{
var length = Math.Min(lineLength, base64.Length - i);
sb.AppendLine(base64.Substring(i, length));
}
return sb.ToString().TrimEnd();
}
/// <summary>
/// Handle the 'stella attest bundle build' command (CLI-ATTEST-75-002).
/// Builds an audit bundle from artifacts conforming to audit-bundle-index.schema.json.
/// </summary>
public static async Task<int> HandleAttestBundleBuildAsync(
IServiceProvider services,
string subjectName,
string subjectDigest,
string subjectType,
string inputDir,
string outputPath,
string? fromDate,
string? toDate,
string include,
bool compress,
string? creatorId,
string? creatorName,
string format,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitBuildFailed = 2;
const int ExitInputError = 4;
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("attest-bundle-build");
// Validate input directory
if (!Directory.Exists(inputDir))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Input directory not found: {Markup.Escape(inputDir)}");
return ExitInputError;
}
// Parse subject digest
var digestParts = subjectDigest.Split(':', 2);
if (digestParts.Length != 2 || string.IsNullOrWhiteSpace(digestParts[0]) || string.IsNullOrWhiteSpace(digestParts[1]))
{
AnsiConsole.MarkupLine("[red]Error:[/] Invalid digest format. Expected algorithm:hex (e.g., sha256:abc123...)");
return ExitInputError;
}
var digestAlgorithm = digestParts[0].ToLowerInvariant();
var digestValue = digestParts[1].ToLowerInvariant();
// Parse time window
DateTimeOffset? timeFrom = null;
DateTimeOffset? timeTo = null;
if (!string.IsNullOrWhiteSpace(fromDate))
{
if (!DateTimeOffset.TryParse(fromDate, out var parsed))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid --from date format: {Markup.Escape(fromDate)}");
return ExitInputError;
}
timeFrom = parsed;
}
if (!string.IsNullOrWhiteSpace(toDate))
{
if (!DateTimeOffset.TryParse(toDate, out var parsed))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid --to date format: {Markup.Escape(toDate)}");
return ExitInputError;
}
timeTo = parsed;
}
// Validate subject type
var normalizedSubjectType = subjectType.ToUpperInvariant() switch
{
"IMAGE" => "IMAGE",
"REPO" => "REPO",
"SBOM" => "SBOM",
"OTHER" => "OTHER",
_ => null
};
if (normalizedSubjectType == null)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid subject type '{Markup.Escape(subjectType)}'. Must be: IMAGE, REPO, SBOM, or OTHER.");
return ExitInputError;
}
// Parse include filter
var includeTypes = include.ToLowerInvariant().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
var includeAll = includeTypes.Contains("all");
var includeAttestations = includeAll || includeTypes.Contains("attestations");
var includeSboms = includeAll || includeTypes.Contains("sboms");
var includeVex = includeAll || includeTypes.Contains("vex");
var includeScans = includeAll || includeTypes.Contains("scans");
var includePolicy = includeAll || includeTypes.Contains("policy");
try
{
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Building bundle from: {Markup.Escape(inputDir)}[/]");
}
// Generate bundle ID
var bundleId = $"bndl-{Guid.NewGuid():D}";
var createdAt = DateTimeOffset.UtcNow;
// Set creator info
var actualCreatorId = creatorId ?? Environment.UserName ?? "unknown";
var actualCreatorName = creatorName ?? Environment.UserName ?? "Unknown User";
// Collect artifacts
var artifacts = new List<object>();
var checksums = new List<string>();
var artifactCount = 0;
// Create output directory structure
var outputDir = compress ? Path.Combine(Path.GetTempPath(), $"bundle-{bundleId}") : outputPath;
Directory.CreateDirectory(outputDir);
// Subdirectories
var attestationsDir = Path.Combine(outputDir, "attestations");
var sbomsDir = Path.Combine(outputDir, "sbom");
var vexDir = Path.Combine(outputDir, "vex");
var scansDir = Path.Combine(outputDir, "reports");
var policyDir = Path.Combine(outputDir, "policy-evals");
// Process attestations
if (includeAttestations)
{
var inputAttestDir = Path.Combine(inputDir, "attestations");
if (Directory.Exists(inputAttestDir))
{
Directory.CreateDirectory(attestationsDir);
foreach (var file in Directory.GetFiles(inputAttestDir, "*.json"))
{
var info = new FileInfo(file);
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
var destPath = Path.Combine(attestationsDir, Path.GetFileName(file));
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
var relativePath = $"attestations/{Path.GetFileName(file)}";
artifacts.Add(new
{
id = $"attest-{artifactCount++}",
type = "OTHER",
source = "StellaOps",
path = relativePath,
mediaType = "application/vnd.dsse+json",
digest = new Dictionary<string, string> { ["sha256"] = hash }
});
checksums.Add($"{hash} {relativePath}");
}
}
}
// Process SBOMs
if (includeSboms)
{
var inputSbomDir = Path.Combine(inputDir, "sboms");
if (!Directory.Exists(inputSbomDir)) inputSbomDir = Path.Combine(inputDir, "sbom");
if (Directory.Exists(inputSbomDir))
{
Directory.CreateDirectory(sbomsDir);
foreach (var file in Directory.GetFiles(inputSbomDir, "*.json"))
{
var info = new FileInfo(file);
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
var destPath = Path.Combine(sbomsDir, Path.GetFileName(file));
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
var relativePath = $"sbom/{Path.GetFileName(file)}";
// Detect SBOM type
var content = await File.ReadAllTextAsync(destPath, cancellationToken).ConfigureAwait(false);
var mediaType = content.Contains("CycloneDX") || content.Contains("cyclonedx") ?
"application/vnd.cyclonedx+json" :
content.Contains("spdxVersion") ? "application/spdx+json" : "application/json";
artifacts.Add(new
{
id = $"sbom-{artifactCount++}",
type = "SBOM",
source = "StellaOps",
path = relativePath,
mediaType = mediaType,
digest = new Dictionary<string, string> { ["sha256"] = hash }
});
checksums.Add($"{hash} {relativePath}");
}
}
}
// Process VEX
if (includeVex)
{
var inputVexDir = Path.Combine(inputDir, "vex");
if (Directory.Exists(inputVexDir))
{
Directory.CreateDirectory(vexDir);
foreach (var file in Directory.GetFiles(inputVexDir, "*.json"))
{
var info = new FileInfo(file);
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
var destPath = Path.Combine(vexDir, Path.GetFileName(file));
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
var relativePath = $"vex/{Path.GetFileName(file)}";
artifacts.Add(new
{
id = $"vex-{artifactCount++}",
type = "VEX",
source = "StellaOps",
path = relativePath,
mediaType = "application/json",
digest = new Dictionary<string, string> { ["sha256"] = hash }
});
checksums.Add($"{hash} {relativePath}");
}
}
}
// Process scans
if (includeScans)
{
var inputScansDir = Path.Combine(inputDir, "scans");
if (!Directory.Exists(inputScansDir)) inputScansDir = Path.Combine(inputDir, "reports");
if (Directory.Exists(inputScansDir))
{
Directory.CreateDirectory(scansDir);
foreach (var file in Directory.GetFiles(inputScansDir, "*.json"))
{
var info = new FileInfo(file);
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
var destPath = Path.Combine(scansDir, Path.GetFileName(file));
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
var relativePath = $"reports/{Path.GetFileName(file)}";
artifacts.Add(new
{
id = $"scan-{artifactCount++}",
type = "VULN_REPORT",
source = "StellaOps",
path = relativePath,
mediaType = "application/json",
digest = new Dictionary<string, string> { ["sha256"] = hash }
});
checksums.Add($"{hash} {relativePath}");
}
}
}
// Process policy evaluations
if (includePolicy)
{
var inputPolicyDir = Path.Combine(inputDir, "policy-evals");
if (!Directory.Exists(inputPolicyDir)) inputPolicyDir = Path.Combine(inputDir, "policy");
if (Directory.Exists(inputPolicyDir))
{
Directory.CreateDirectory(policyDir);
foreach (var file in Directory.GetFiles(inputPolicyDir, "*.json"))
{
var info = new FileInfo(file);
if (timeFrom.HasValue && info.LastWriteTimeUtc < timeFrom.Value) continue;
if (timeTo.HasValue && info.LastWriteTimeUtc > timeTo.Value) continue;
var destPath = Path.Combine(policyDir, Path.GetFileName(file));
await CopyFileAsync(file, destPath, cancellationToken).ConfigureAwait(false);
var hash = await ComputeSha256Async(destPath, cancellationToken).ConfigureAwait(false);
var relativePath = $"policy-evals/{Path.GetFileName(file)}";
artifacts.Add(new
{
id = $"policy-{artifactCount++}",
type = "POLICY_EVAL",
source = "StellaPolicyEngine",
path = relativePath,
mediaType = "application/json",
digest = new Dictionary<string, string> { ["sha256"] = hash }
});
checksums.Add($"{hash} {relativePath}");
}
}
}
// Compute root hash (hash of all checksums)
var checksumContent = string.Join("\n", checksums.OrderBy(c => c));
using var sha256 = System.Security.Cryptography.SHA256.Create();
var checksumBytes = System.Text.Encoding.UTF8.GetBytes(checksumContent);
var rootHashBytes = sha256.ComputeHash(checksumBytes);
var rootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant();
// Build index
var index = new
{
apiVersion = "stella.ops/v1",
kind = "AuditBundleIndex",
bundleId = bundleId,
createdAt = createdAt.ToString("o"),
createdBy = new
{
id = actualCreatorId,
displayName = actualCreatorName
},
subject = new
{
type = normalizedSubjectType,
name = subjectName,
digest = new Dictionary<string, string> { [digestAlgorithm] = digestValue }
},
timeWindow = (timeFrom.HasValue || timeTo.HasValue) ? new
{
from = timeFrom?.ToString("o"),
to = timeTo?.ToString("o")
} : null,
artifacts = artifacts,
integrity = new
{
rootHash = rootHash,
hashAlgorithm = "sha256"
}
};
// Write index
var indexPath = Path.Combine(outputDir, "index.json");
var indexJson = JsonSerializer.Serialize(index, new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
await File.WriteAllTextAsync(indexPath, indexJson, cancellationToken).ConfigureAwait(false);
// Write SHA256SUMS
var sumsPath = Path.Combine(outputDir, "SHA256SUMS");
await File.WriteAllTextAsync(sumsPath, checksumContent + "\n", cancellationToken).ConfigureAwait(false);
// Compress if requested
var finalPath = outputDir;
if (compress)
{
var tarPath = outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ? outputPath : $"{outputPath}.tar.gz";
await CreateTarGzAsync(outputDir, tarPath, cancellationToken).ConfigureAwait(false);
finalPath = tarPath;
// Cleanup temp directory
try { Directory.Delete(outputDir, true); } catch { }
}
// Record metric
CliMetrics.RecordAttestVerify("bundle-build-success");
// Output result
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var result = new
{
bundleId = bundleId,
output = finalPath,
artifactCount = artifacts.Count,
rootHash = rootHash,
compressed = compress,
createdAt = createdAt.ToString("o")
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
AnsiConsole.MarkupLine($"[green]Success:[/] Bundle created.");
AnsiConsole.MarkupLine($"[grey]Bundle ID:[/] {Markup.Escape(bundleId)}");
AnsiConsole.MarkupLine($"[grey]Output:[/] {Markup.Escape(finalPath)}");
AnsiConsole.MarkupLine($"[grey]Artifacts:[/] {artifacts.Count}");
AnsiConsole.MarkupLine($"[grey]Root hash:[/] {rootHash}");
if (compress)
{
AnsiConsole.MarkupLine($"[grey]Compressed:[/] Yes (tar.gz)");
}
}
return ExitSuccess;
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to build bundle");
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to build bundle: {Markup.Escape(ex.Message)}");
return ExitBuildFailed;
}
}
/// <summary>
/// Handle the 'stella attest bundle verify' command (CLI-ATTEST-75-002).
/// Verifies an audit bundle's integrity and attestation signatures.
/// </summary>
public static async Task<int> HandleAttestBundleVerifyAsync(
IServiceProvider services,
string inputPath,
string? policyPath,
string? rootPath,
string? outputPath,
string format,
bool strict,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitVerifyFailed = 2;
const int ExitInputError = 4;
var loggerFactory = services.GetService<ILoggerFactory>();
var logger = loggerFactory?.CreateLogger("attest-bundle-verify");
// Determine if input is compressed
var isCompressed = inputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) ||
inputPath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase);
var bundleDir = inputPath;
var tempDir = (string?)null;
try
{
// Extract if compressed
if (isCompressed)
{
if (!File.Exists(inputPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle file not found: {Markup.Escape(inputPath)}");
return ExitInputError;
}
tempDir = Path.Combine(Path.GetTempPath(), $"bundle-verify-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
await ExtractTarGzAsync(inputPath, tempDir, cancellationToken).ConfigureAwait(false);
bundleDir = tempDir;
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Extracted bundle to: {Markup.Escape(tempDir)}[/]");
}
}
else if (!Directory.Exists(inputPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle directory not found: {Markup.Escape(inputPath)}");
return ExitInputError;
}
// Read index
var indexPath = Path.Combine(bundleDir, "index.json");
if (!File.Exists(indexPath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Bundle index.json not found.");
return ExitInputError;
}
var indexJson = await File.ReadAllTextAsync(indexPath, cancellationToken).ConfigureAwait(false);
var index = JsonSerializer.Deserialize<JsonElement>(indexJson);
// Verification checks
var checks = new List<(string Check, string Status, string Reason)>();
var hasWarnings = false;
var hasFailed = false;
// Check 1: Index structure
if (index.TryGetProperty("apiVersion", out var apiVersion) && apiVersion.GetString() == "stella.ops/v1")
{
checks.Add(("Index Structure", "PASS", "Valid apiVersion stella.ops/v1"));
}
else
{
checks.Add(("Index Structure", "FAIL", "Missing or invalid apiVersion"));
hasFailed = true;
}
// Check 2: Required fields
var hasRequiredFields = index.TryGetProperty("bundleId", out _) &&
index.TryGetProperty("createdAt", out _) &&
index.TryGetProperty("subject", out _) &&
index.TryGetProperty("artifacts", out _);
if (hasRequiredFields)
{
checks.Add(("Required Fields", "PASS", "All required fields present"));
}
else
{
checks.Add(("Required Fields", "FAIL", "Missing required fields in index"));
hasFailed = true;
}
// Check 3: Integrity verification (root hash)
var integrityOk = false;
if (index.TryGetProperty("integrity", out var integrity) &&
integrity.TryGetProperty("rootHash", out var rootHashElem))
{
var expectedRootHash = rootHashElem.GetString() ?? "";
// Read SHA256SUMS and compute root hash
var sumsPath = Path.Combine(bundleDir, "SHA256SUMS");
if (File.Exists(sumsPath))
{
var checksumContent = await File.ReadAllTextAsync(sumsPath, cancellationToken).ConfigureAwait(false);
checksumContent = checksumContent.TrimEnd('\n', '\r');
using var sha256 = System.Security.Cryptography.SHA256.Create();
var checksumBytes = System.Text.Encoding.UTF8.GetBytes(checksumContent);
var rootHashBytes = sha256.ComputeHash(checksumBytes);
var computedRootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant();
if (computedRootHash == expectedRootHash.ToLowerInvariant())
{
checks.Add(("Root Hash Integrity", "PASS", $"Root hash matches: {expectedRootHash[..16]}..."));
integrityOk = true;
}
else
{
checks.Add(("Root Hash Integrity", "FAIL", $"Root hash mismatch. Expected: {expectedRootHash[..16]}..., Got: {computedRootHash[..16]}..."));
hasFailed = true;
}
}
else
{
checks.Add(("Root Hash Integrity", "WARN", "SHA256SUMS file not found"));
hasWarnings = true;
}
}
else
{
checks.Add(("Root Hash Integrity", "WARN", "No integrity data in index"));
hasWarnings = true;
}
// Check 4: Artifact checksums
var artifactsFailed = 0;
var artifactsPassed = 0;
if (index.TryGetProperty("artifacts", out var artifactsElem) && artifactsElem.ValueKind == JsonValueKind.Array)
{
foreach (var artifact in artifactsElem.EnumerateArray())
{
if (!artifact.TryGetProperty("path", out var pathElem) ||
!artifact.TryGetProperty("digest", out var digestElem))
{
artifactsFailed++;
continue;
}
var artifactPath = pathElem.GetString() ?? "";
var fullPath = Path.Combine(bundleDir, artifactPath);
if (!File.Exists(fullPath))
{
artifactsFailed++;
if (verbose)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Artifact not found: {Markup.Escape(artifactPath)}");
}
continue;
}
// Check SHA256 digest
if (digestElem.TryGetProperty("sha256", out var sha256Elem))
{
var expectedHash = sha256Elem.GetString() ?? "";
var actualHash = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false);
if (actualHash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase))
{
artifactsPassed++;
}
else
{
artifactsFailed++;
if (verbose)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Checksum mismatch: {Markup.Escape(artifactPath)}");
}
}
}
else
{
artifactsPassed++; // No SHA256 to verify
}
}
if (artifactsFailed > 0)
{
checks.Add(("Artifact Checksums", "FAIL", $"{artifactsFailed} artifact(s) failed verification, {artifactsPassed} passed"));
hasFailed = true;
}
else if (artifactsPassed > 0)
{
checks.Add(("Artifact Checksums", "PASS", $"All {artifactsPassed} artifact(s) verified"));
}
else
{
checks.Add(("Artifact Checksums", "WARN", "No artifacts to verify"));
hasWarnings = true;
}
}
// Check 5: Policy compliance (if policy provided)
if (!string.IsNullOrWhiteSpace(policyPath))
{
if (!File.Exists(policyPath))
{
checks.Add(("Policy Compliance", "FAIL", $"Policy file not found: {policyPath}"));
hasFailed = true;
}
else
{
try
{
var policyJson = await File.ReadAllTextAsync(policyPath, cancellationToken).ConfigureAwait(false);
var policy = JsonSerializer.Deserialize<JsonElement>(policyJson);
// Check required predicate types
var policyMet = true;
var policyReasons = new List<string>();
if (policy.TryGetProperty("requiredArtifactTypes", out var requiredTypes) &&
requiredTypes.ValueKind == JsonValueKind.Array)
{
var presentTypes = new HashSet<string>();
if (index.TryGetProperty("artifacts", out var arts) && arts.ValueKind == JsonValueKind.Array)
{
foreach (var art in arts.EnumerateArray())
{
if (art.TryGetProperty("type", out var t))
{
presentTypes.Add(t.GetString() ?? "");
}
}
}
foreach (var required in requiredTypes.EnumerateArray())
{
var reqType = required.GetString() ?? "";
if (!presentTypes.Contains(reqType))
{
policyMet = false;
policyReasons.Add($"Missing required type: {reqType}");
}
}
}
if (policy.TryGetProperty("minimumArtifacts", out var minArtifacts))
{
var count = index.TryGetProperty("artifacts", out var arts) && arts.ValueKind == JsonValueKind.Array ?
arts.GetArrayLength() : 0;
if (count < minArtifacts.GetInt32())
{
policyMet = false;
policyReasons.Add($"Minimum artifacts not met: {count} < {minArtifacts.GetInt32()}");
}
}
if (policyMet)
{
checks.Add(("Policy Compliance", "PASS", "All policy requirements satisfied"));
}
else
{
checks.Add(("Policy Compliance", "FAIL", string.Join("; ", policyReasons)));
hasFailed = true;
}
}
catch (Exception ex)
{
checks.Add(("Policy Compliance", "FAIL", $"Failed to parse policy: {ex.Message}"));
hasFailed = true;
}
}
}
// Check 6: Attestation signatures (if root provided)
if (!string.IsNullOrWhiteSpace(rootPath))
{
checks.Add(("Signature Verification", "WARN", "Signature verification not yet implemented; trust root provided but skipped"));
hasWarnings = true;
}
// Record metric
var outcome = hasFailed ? "bundle-verify-failed" : (hasWarnings ? "bundle-verify-warning" : "bundle-verify-success");
CliMetrics.RecordAttestVerify(outcome);
// Determine final status
var overallStatus = hasFailed ? "FAIL" : (strict && hasWarnings ? "FAIL" : (hasWarnings ? "WARN" : "PASS"));
// Write output if requested
if (!string.IsNullOrWhiteSpace(outputPath))
{
var report = new
{
bundleId = index.TryGetProperty("bundleId", out var bid) ? bid.GetString() : null,
verifiedAt = DateTimeOffset.UtcNow.ToString("o"),
status = overallStatus,
checks = checks.Select(c => new { check = c.Check, status = c.Status, reason = c.Reason }).ToArray()
};
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(outputPath, reportJson, cancellationToken).ConfigureAwait(false);
}
// Output result
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var result = new
{
bundleId = index.TryGetProperty("bundleId", out var bid) ? bid.GetString() : null,
status = overallStatus,
checks = checks.Select(c => new { check = c.Check, status = c.Status, reason = c.Reason }).ToArray()
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
var bundleId = index.TryGetProperty("bundleId", out var bid) ? bid.GetString() ?? "unknown" : "unknown";
AnsiConsole.MarkupLine($"[grey]Bundle ID:[/] {Markup.Escape(bundleId)}");
AnsiConsole.WriteLine();
var table = new Table();
table.AddColumn("Check");
table.AddColumn("Status");
table.AddColumn("Reason");
foreach (var (check, status, reason) in checks)
{
var statusMarkup = status switch
{
"PASS" => "[green]PASS[/]",
"FAIL" => "[red]FAIL[/]",
"WARN" => "[yellow]WARN[/]",
_ => status
};
table.AddRow(Markup.Escape(check), statusMarkup, Markup.Escape(reason));
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
if (overallStatus == "PASS")
{
AnsiConsole.MarkupLine("[green]Verification passed.[/]");
}
else if (overallStatus == "WARN")
{
AnsiConsole.MarkupLine("[yellow]Verification completed with warnings.[/]");
}
else
{
AnsiConsole.MarkupLine("[red]Verification failed.[/]");
}
}
return (hasFailed || (strict && hasWarnings)) ? ExitVerifyFailed : ExitSuccess;
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to verify bundle");
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to verify bundle: {Markup.Escape(ex.Message)}");
return ExitInputError;
}
finally
{
// Cleanup temp directory
if (tempDir != null)
{
try { Directory.Delete(tempDir, true); } catch { }
}
}
}
/// <summary>
/// Copy file asynchronously.
/// </summary>
private static async Task CopyFileAsync(string source, string dest, CancellationToken cancellationToken)
{
using var sourceStream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
using var destStream = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Create a tar.gz archive from a directory.
/// </summary>
private static async Task CreateTarGzAsync(string sourceDir, string destPath, CancellationToken cancellationToken)
{
using var destStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
using var gzipStream = new System.IO.Compression.GZipStream(destStream, System.IO.Compression.CompressionLevel.Optimal);
await System.Formats.Tar.TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, false, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Extract a tar.gz archive to a directory.
/// </summary>
private static async Task ExtractTarGzAsync(string sourcePath, string destDir, CancellationToken cancellationToken)
{
using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
using var gzipStream = new System.IO.Compression.GZipStream(sourceStream, System.IO.Compression.CompressionMode.Decompress);
await System.Formats.Tar.TarFile.ExtractToDirectoryAsync(gzipStream, destDir, true, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Handle the 'stella attest sign' command (CLI-ATTEST-73-001).
/// Creates and signs a DSSE attestation envelope conforming to the attestor-transport schema.
/// </summary>
public static async Task<int> HandleAttestSignAsync(
IServiceProvider services,
string predicatePath,
string predicateType,
string subjectName,
string subjectDigest,
string? keyId,
bool keyless,
bool useRekor,
string? outputPath,
string format,
bool verbose,
CancellationToken cancellationToken)
{
// Exit codes per CLI spec: 0 success, 2 signing failed, 4 input error
const int ExitSuccess = 0;
const int ExitSigningFailed = 2;
const int ExitInputError = 4;
// Validate predicate file exists
if (!File.Exists(predicatePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Predicate file not found: {Markup.Escape(predicatePath)}");
return ExitInputError;
}
// Parse subject digest (format: algorithm:hex)
var digestParts = subjectDigest.Split(':', 2);
if (digestParts.Length != 2 || string.IsNullOrWhiteSpace(digestParts[0]) || string.IsNullOrWhiteSpace(digestParts[1]))
{
AnsiConsole.MarkupLine("[red]Error:[/] Invalid digest format. Expected algorithm:hex (e.g., sha256:abc123...)");
return ExitInputError;
}
var digestAlgorithm = digestParts[0].ToLowerInvariant();
var digestValue = digestParts[1].ToLowerInvariant();
// Validate predicate type URI
if (!predicateType.StartsWith("https://", StringComparison.OrdinalIgnoreCase) &&
!predicateType.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Predicate type '{Markup.Escape(predicateType)}' is not a valid URI.");
}
try
{
// Read predicate JSON
var predicateJson = await File.ReadAllTextAsync(predicatePath, cancellationToken).ConfigureAwait(false);
var predicate = JsonSerializer.Deserialize<JsonElement>(predicateJson);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Subject: {Markup.Escape(subjectName)}[/]");
AnsiConsole.MarkupLine($"[grey]Digest: {Markup.Escape(subjectDigest)}[/]");
AnsiConsole.MarkupLine($"[grey]Predicate Type: {Markup.Escape(predicateType)}[/]");
AnsiConsole.MarkupLine($"[grey]Key ID: {Markup.Escape(keyId ?? "(default)")}[/]");
AnsiConsole.MarkupLine($"[grey]Keyless: {keyless}[/]");
AnsiConsole.MarkupLine($"[grey]Rekor: {useRekor}[/]");
}
// Build the in-toto statement
var statement = new Dictionary<string, object>
{
["_type"] = "https://in-toto.io/Statement/v1",
["subject"] = new[]
{
new Dictionary<string, object>
{
["name"] = subjectName,
["digest"] = new Dictionary<string, string>
{
[digestAlgorithm] = digestValue
}
}
},
["predicateType"] = predicateType,
["predicate"] = predicate
};
var statementJson = JsonSerializer.Serialize(statement, new JsonSerializerOptions { WriteIndented = false });
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
// Build signing options based on parameters
var signingOptions = new Dictionary<string, object?>
{
["keyId"] = keyId,
["keyless"] = keyless,
["transparencyLog"] = useRekor,
["provider"] = keyless ? "sigstore" : "default"
};
// Create the attestation request (per attestor-transport.schema.json)
var requestId = Guid.NewGuid();
var request = new Dictionary<string, object?>
{
["requestType"] = "CREATE_ATTESTATION",
["requestId"] = requestId.ToString(),
["predicateType"] = predicateType,
["subject"] = new[]
{
new Dictionary<string, object>
{
["name"] = subjectName,
["digest"] = new Dictionary<string, string>
{
[digestAlgorithm] = digestValue
}
}
},
["predicate"] = predicate,
["signingOptions"] = signingOptions
};
// For now, generate a placeholder envelope structure
// Full implementation would call into StellaOps.Attestor signing service
var signatureKeyId = keyId ?? (keyless ? "keyless:oidc" : "local:default");
var signaturePlaceholder = Convert.ToBase64String(
SHA256.HashData(Encoding.UTF8.GetBytes(payloadBase64 + signatureKeyId)));
var envelope = new Dictionary<string, object>
{
["payloadType"] = "application/vnd.in-toto+json",
["payload"] = payloadBase64,
["signatures"] = new[]
{
new Dictionary<string, string>
{
["keyid"] = signatureKeyId,
["sig"] = signaturePlaceholder
}
}
};
// Calculate envelope digest
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = false });
var envelopeDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant();
envelope["envelopeDigest"] = envelopeDigest;
// Build response per attestor-transport schema
var response = new Dictionary<string, object?>
{
["responseType"] = "ATTESTATION_CREATED",
["requestId"] = requestId.ToString(),
["status"] = "SUCCESS",
["attestation"] = envelope,
["metadata"] = new Dictionary<string, object?>
{
["createdAt"] = DateTime.UtcNow.ToString("o"),
["predicateType"] = predicateType,
["subjectDigest"] = subjectDigest,
["rekorSubmitted"] = useRekor,
["signingMode"] = keyless ? "keyless" : "keyed"
}
};
// Format output
object outputObject = format.Equals("sigstore-bundle", StringComparison.OrdinalIgnoreCase)
? new Dictionary<string, object>
{
["mediaType"] = "application/vnd.dev.sigstore.bundle+json;version=0.2",
["dsseEnvelope"] = envelope,
["verificationMaterial"] = new Dictionary<string, object?>
{
["certificate"] = keyless ? "[placeholder:oidc-cert]" : null,
["tlogEntries"] = useRekor ? new[] { new Dictionary<string, object>
{
["logIndex"] = 0,
["logId"] = "[pending]",
["integratedTime"] = DateTime.UtcNow.ToString("o")
}} : Array.Empty<object>()
}
}
: envelope;
var outputJson = JsonSerializer.Serialize(outputObject, new JsonSerializerOptions { WriteIndented = true });
// Write output
if (!string.IsNullOrWhiteSpace(outputPath))
{
await File.WriteAllTextAsync(outputPath, outputJson, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Attestation envelope written to:[/] {Markup.Escape(outputPath)}");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Envelope digest: {envelopeDigest}[/]");
}
}
else
{
AnsiConsole.WriteLine(outputJson);
}
// Emit metrics
CliMetrics.AttestSignCompleted(predicateType, keyless ? "keyless" : "keyed", useRekor);
return ExitSuccess;
}
catch (JsonException ex)
{
AnsiConsole.MarkupLine($"[red]Error parsing predicate JSON:[/] {Markup.Escape(ex.Message)}");
return ExitInputError;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error during attestation signing:[/] {Markup.Escape(ex.Message)}");
return ExitSigningFailed;
}
}
private static string SanitizeFileName(string value)
{
var safe = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
safe = safe.Replace(invalid, '_');
}
return safe;
}
public static async Task<int> HandlePolicyLintAsync(
string filePath,
string? format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitValidationError = 1;
const int ExitInputError = 4;
if (string.IsNullOrWhiteSpace(filePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
return ExitInputError;
}
var fullPath = Path.GetFullPath(filePath);
if (!File.Exists(fullPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Policy file not found: {Markup.Escape(fullPath)}");
return ExitInputError;
}
try
{
var source = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
var compiler = new PolicyDsl.PolicyCompiler();
var result = compiler.Compile(source);
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
var diagnosticsList = new List<Dictionary<string, object?>>();
foreach (var d in result.Diagnostics)
{
diagnosticsList.Add(new Dictionary<string, object?>
{
["severity"] = d.Severity.ToString(),
["code"] = d.Code,
["message"] = d.Message,
["path"] = d.Path
});
}
var output = new Dictionary<string, object?>
{
["file"] = fullPath,
["success"] = result.Success,
["checksum"] = result.Checksum,
["policy_name"] = result.Document?.Name,
["syntax"] = result.Document?.Syntax,
["rule_count"] = result.Document?.Rules.Length ?? 0,
["profile_count"] = result.Document?.Profiles.Length ?? 0,
["diagnostics"] = diagnosticsList
};
if (!string.IsNullOrWhiteSpace(outputPath))
{
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Output written to {Markup.Escape(outputPath)}[/]");
}
}
if (outputFormat == "json")
{
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
AnsiConsole.WriteLine(json);
}
else
{
// Table format output
if (result.Success)
{
AnsiConsole.MarkupLine($"[green]✓[/] Policy [bold]{Markup.Escape(result.Document?.Name ?? "unknown")}[/] is valid.");
AnsiConsole.MarkupLine($" Syntax: {Markup.Escape(result.Document?.Syntax ?? "unknown")}");
AnsiConsole.MarkupLine($" Rules: {result.Document?.Rules.Length ?? 0}");
AnsiConsole.MarkupLine($" Profiles: {result.Document?.Profiles.Length ?? 0}");
AnsiConsole.MarkupLine($" Checksum: {Markup.Escape(result.Checksum ?? "N/A")}");
}
else
{
AnsiConsole.MarkupLine($"[red]✗[/] Policy validation failed with {result.Diagnostics.Length} diagnostic(s):");
}
if (result.Diagnostics.Length > 0)
{
AnsiConsole.WriteLine();
var table = new Table();
table.AddColumn("Severity");
table.AddColumn("Code");
table.AddColumn("Path");
table.AddColumn("Message");
foreach (var diag in result.Diagnostics)
{
var severityColor = diag.Severity switch
{
PolicyIssueSeverity.Error => "red",
PolicyIssueSeverity.Warning => "yellow",
_ => "grey"
};
table.AddRow(
$"[{severityColor}]{diag.Severity}[/]",
diag.Code ?? "-",
diag.Path ?? "-",
Markup.Escape(diag.Message));
}
AnsiConsole.Write(table);
}
}
return result.Success ? ExitSuccess : ExitValidationError;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
return ExitInputError;
}
}
#region Risk Profile Commands
public static async Task HandleRiskProfileValidateAsync(
string inputPath,
string format,
string? outputPath,
bool strict,
bool verbose)
{
_ = verbose;
using var activity = CliActivitySource.Instance.StartActivity("cli.riskprofile.validate", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("risk-profile validate");
try
{
if (!File.Exists(inputPath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Input file not found: {0}", Markup.Escape(inputPath));
Environment.ExitCode = 1;
return;
}
var profileJson = await File.ReadAllTextAsync(inputPath).ConfigureAwait(false);
var schema = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchema();
var schemaVersion = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaVersion();
JsonNode? profileNode;
try
{
profileNode = JsonNode.Parse(profileJson);
if (profileNode is null)
{
throw new InvalidOperationException("Parsed JSON is null.");
}
}
catch (JsonException ex)
{
AnsiConsole.MarkupLine("[red]Error:[/] Invalid JSON: {0}", Markup.Escape(ex.Message));
Environment.ExitCode = 1;
return;
}
var result = schema.Evaluate(profileNode);
var issues = new List<RiskProfileValidationIssue>();
if (!result.IsValid)
{
CollectValidationIssues(result, issues);
}
var report = new RiskProfileValidationReport(
FilePath: inputPath,
IsValid: result.IsValid,
SchemaVersion: schemaVersion,
Issues: issues);
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (!string.IsNullOrEmpty(outputPath))
{
await File.WriteAllTextAsync(outputPath, reportJson).ConfigureAwait(false);
AnsiConsole.MarkupLine("Validation report written to [cyan]{0}[/]", Markup.Escape(outputPath));
}
else
{
Console.WriteLine(reportJson);
}
}
else
{
if (result.IsValid)
{
AnsiConsole.MarkupLine("[green]✓[/] Profile is valid (schema v{0})", schemaVersion);
}
else
{
AnsiConsole.MarkupLine("[red]✗[/] Profile is invalid (schema v{0})", schemaVersion);
AnsiConsole.WriteLine();
var table = new Table();
table.AddColumn("Path");
table.AddColumn("Error");
table.AddColumn("Message");
foreach (var issue in issues)
{
table.AddRow(
Markup.Escape(issue.Path),
Markup.Escape(issue.Error),
Markup.Escape(issue.Message));
}
AnsiConsole.Write(table);
}
}
Environment.ExitCode = result.IsValid ? 0 : 1;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
Environment.ExitCode = 1;
}
}
public static async Task<int> HandlePolicyEditAsync(
string filePath,
bool commit,
string? version,
string? message,
bool noValidate,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitValidationError = 1;
const int ExitInputError = 4;
const int ExitEditorError = 5;
const int ExitGitError = 6;
if (string.IsNullOrWhiteSpace(filePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
return ExitInputError;
}
var fullPath = Path.GetFullPath(filePath);
var fileExists = File.Exists(fullPath);
// Determine editor from environment
var editor = Environment.GetEnvironmentVariable("EDITOR")
?? Environment.GetEnvironmentVariable("VISUAL")
?? (OperatingSystem.IsWindows() ? "notepad" : "vi");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Using editor: {Markup.Escape(editor)}[/]");
AnsiConsole.MarkupLine($"[grey]File path: {Markup.Escape(fullPath)}[/]");
}
// Read original content for change detection
string? originalContent = null;
if (fileExists)
{
originalContent = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
}
// Launch editor
try
{
var startInfo = new ProcessStartInfo
{
FileName = editor,
Arguments = $"\"{fullPath}\"",
UseShellExecute = true,
CreateNoWindow = false
};
using var process = Process.Start(startInfo);
if (process == null)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to start editor '{Markup.Escape(editor)}'.");
return ExitEditorError;
}
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Editor exited with code {process.ExitCode}.");
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to launch editor: {Markup.Escape(ex.Message)}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return ExitEditorError;
}
// Check if file was created/modified
if (!File.Exists(fullPath))
{
AnsiConsole.MarkupLine("[yellow]No file created. Exiting.[/]");
return ExitSuccess;
}
var newContent = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
if (originalContent != null && originalContent == newContent)
{
AnsiConsole.MarkupLine("[grey]No changes detected.[/]");
return ExitSuccess;
}
AnsiConsole.MarkupLine("[green]File modified.[/]");
// Validate unless skipped
if (!noValidate)
{
var compiler = new PolicyDsl.PolicyCompiler();
var result = compiler.Compile(newContent);
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]✗[/] Validation failed with {result.Diagnostics.Length} diagnostic(s):");
var table = new Table();
table.AddColumn("Severity");
table.AddColumn("Code");
table.AddColumn("Message");
foreach (var diag in result.Diagnostics)
{
var color = diag.Severity == PolicyIssueSeverity.Error ? "red" : "yellow";
table.AddRow($"[{color}]{diag.Severity}[/]", diag.Code ?? "-", Markup.Escape(diag.Message));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("[yellow]Changes saved but not committed due to validation errors.[/]");
return ExitValidationError;
}
AnsiConsole.MarkupLine($"[green]✓[/] Policy [bold]{Markup.Escape(result.Document?.Name ?? "unknown")}[/] is valid.");
AnsiConsole.MarkupLine($" Checksum: {Markup.Escape(result.Checksum ?? "N/A")}");
}
// Commit if requested
if (commit)
{
var gitDir = FindGitDirectory(fullPath);
if (gitDir == null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Not inside a git repository. Cannot commit.");
return ExitGitError;
}
var relativePath = Path.GetRelativePath(gitDir, fullPath);
var commitMessage = message ?? GeneratePolicyCommitMessage(relativePath, version);
try
{
// Stage the file
var addResult = await RunGitCommandAsync(gitDir, $"add \"{relativePath}\"", cancellationToken).ConfigureAwait(false);
if (addResult.ExitCode != 0)
{
AnsiConsole.MarkupLine($"[red]Error:[/] git add failed: {Markup.Escape(addResult.Output)}");
return ExitGitError;
}
// Commit with SemVer metadata in trailer
var trailers = new List<string>();
if (!string.IsNullOrWhiteSpace(version))
{
trailers.Add($"Policy-Version: {version}");
}
var trailerArgs = trailers.Count > 0
? string.Join(" ", trailers.Select(t => $"--trailer \"{t}\""))
: string.Empty;
var commitResult = await RunGitCommandAsync(gitDir, $"commit -m \"{commitMessage}\" {trailerArgs}", cancellationToken).ConfigureAwait(false);
if (commitResult.ExitCode != 0)
{
AnsiConsole.MarkupLine($"[red]Error:[/] git commit failed: {Markup.Escape(commitResult.Output)}");
return ExitGitError;
}
AnsiConsole.MarkupLine($"[green]✓[/] Committed: {Markup.Escape(commitMessage)}");
if (!string.IsNullOrWhiteSpace(version))
{
AnsiConsole.MarkupLine($" Policy-Version: {Markup.Escape(version)}");
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Git operation failed: {Markup.Escape(ex.Message)}");
if (verbose)
{
AnsiConsole.WriteException(ex);
}
return ExitGitError;
}
}
return ExitSuccess;
}
public static async Task<int> HandlePolicyTestAsync(
string filePath,
string? fixturesPath,
string? filter,
string? format,
string? outputPath,
bool failFast,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitTestFailure = 1;
const int ExitInputError = 4;
if (string.IsNullOrWhiteSpace(filePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
return ExitInputError;
}
var fullPath = Path.GetFullPath(filePath);
if (!File.Exists(fullPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Policy file not found: {Markup.Escape(fullPath)}");
return ExitInputError;
}
// Compile the policy first
var source = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
var compiler = new PolicyDsl.PolicyCompiler();
var compileResult = compiler.Compile(source);
if (!compileResult.Success)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Policy compilation failed. Run 'stella policy lint' for details.");
return ExitInputError;
}
var policyName = compileResult.Document?.Name ?? Path.GetFileNameWithoutExtension(fullPath);
// Determine fixtures directory
var fixturesDir = fixturesPath;
if (string.IsNullOrWhiteSpace(fixturesDir))
{
var policyDir = Path.GetDirectoryName(fullPath) ?? ".";
fixturesDir = Path.Combine(policyDir, "..", "..", "tests", "policy", policyName, "cases");
if (!Directory.Exists(fixturesDir))
{
// Try relative to current directory
fixturesDir = Path.Combine("tests", "policy", policyName, "cases");
}
}
fixturesDir = Path.GetFullPath(fixturesDir);
if (!Directory.Exists(fixturesDir))
{
AnsiConsole.MarkupLine($"[yellow]No fixtures directory found at {Markup.Escape(fixturesDir)}[/]");
AnsiConsole.MarkupLine("[grey]Create test fixtures as JSON files in this directory.[/]");
return ExitSuccess;
}
var fixtureFiles = Directory.GetFiles(fixturesDir, "*.json", SearchOption.AllDirectories);
if (!string.IsNullOrWhiteSpace(filter))
{
fixtureFiles = fixtureFiles.Where(f => Path.GetFileName(f).Contains(filter, StringComparison.OrdinalIgnoreCase)).ToArray();
}
if (fixtureFiles.Length == 0)
{
AnsiConsole.MarkupLine($"[yellow]No fixture files found in {Markup.Escape(fixturesDir)}[/]");
return ExitSuccess;
}
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Found {fixtureFiles.Length} fixture file(s)[/]");
}
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
var results = new List<Dictionary<string, object?>>();
var passed = 0;
var failed = 0;
var skipped = 0;
foreach (var fixtureFile in fixtureFiles)
{
var fixtureName = Path.GetRelativePath(fixturesDir, fixtureFile);
try
{
var fixtureJson = await File.ReadAllTextAsync(fixtureFile, cancellationToken).ConfigureAwait(false);
var fixture = JsonSerializer.Deserialize<PolicyTestFixture>(fixtureJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (fixture == null)
{
results.Add(new Dictionary<string, object?>
{
["fixture"] = fixtureName,
["status"] = "skipped",
["reason"] = "Invalid fixture format"
});
skipped++;
continue;
}
// Run the test case (simplified evaluation stub)
var testPassed = RunPolicyTestCase(compileResult.Document!, fixture, verbose);
results.Add(new Dictionary<string, object?>
{
["fixture"] = fixtureName,
["status"] = testPassed ? "passed" : "failed",
["expected_outcome"] = fixture.ExpectedOutcome,
["description"] = fixture.Description
});
if (testPassed)
{
passed++;
}
else
{
failed++;
if (failFast)
{
AnsiConsole.MarkupLine($"[red]✗[/] {Markup.Escape(fixtureName)} - Stopping on first failure.");
break;
}
}
}
catch (Exception ex)
{
results.Add(new Dictionary<string, object?>
{
["fixture"] = fixtureName,
["status"] = "error",
["reason"] = ex.Message
});
failed++;
if (failFast)
{
break;
}
}
}
// Output results
var summary = new Dictionary<string, object?>
{
["policy"] = policyName,
["policy_checksum"] = compileResult.Checksum,
["fixtures_dir"] = fixturesDir,
["total"] = results.Count,
["passed"] = passed,
["failed"] = failed,
["skipped"] = skipped,
["results"] = results
};
if (!string.IsNullOrWhiteSpace(outputPath))
{
var json = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Output written to {Markup.Escape(outputPath)}[/]");
}
}
if (outputFormat == "json")
{
var json = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
AnsiConsole.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine($"\n[bold]Test Results for {Markup.Escape(policyName)}[/]\n");
var table = new Table();
table.AddColumn("Fixture");
table.AddColumn("Status");
table.AddColumn("Description");
foreach (var r in results)
{
var status = r["status"]?.ToString() ?? "unknown";
var statusColor = status switch
{
"passed" => "green",
"failed" => "red",
"skipped" => "yellow",
_ => "grey"
};
var statusIcon = status switch
{
"passed" => "✓",
"failed" => "✗",
"skipped" => "○",
_ => "?"
};
table.AddRow(
Markup.Escape(r["fixture"]?.ToString() ?? "-"),
$"[{statusColor}]{statusIcon} {status}[/]",
Markup.Escape(r["description"]?.ToString() ?? r["reason"]?.ToString() ?? "-"));
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[bold]Summary:[/] {passed} passed, {failed} failed, {skipped} skipped");
}
return failed > 0 ? ExitTestFailure : ExitSuccess;
}
private static string? FindGitDirectory(string startPath)
{
var dir = Path.GetDirectoryName(startPath);
while (!string.IsNullOrEmpty(dir))
{
if (Directory.Exists(Path.Combine(dir, ".git")))
{
return dir;
}
dir = Path.GetDirectoryName(dir);
}
return null;
}
private static string GeneratePolicyCommitMessage(string relativePath, string? version)
{
var fileName = Path.GetFileNameWithoutExtension(relativePath);
var versionSuffix = !string.IsNullOrWhiteSpace(version) ? $" (v{version})" : "";
return $"policy: update {fileName}{versionSuffix}";
}
private static async Task<(int ExitCode, string Output)> RunGitCommandAsync(string workingDir, string arguments, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = "git",
Arguments = arguments,
WorkingDirectory = workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) outputBuilder.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) errorBuilder.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var output = outputBuilder.ToString();
var error = errorBuilder.ToString();
return (process.ExitCode, string.IsNullOrWhiteSpace(error) ? output : error);
}
private static bool RunPolicyTestCase(PolicyDsl.PolicyIrDocument document, PolicyTestFixture fixture, bool verbose)
{
// Simplified test evaluation - in production this would use PolicyEvaluator
// For now, just check that the fixture structure is valid and expected outcome is defined
if (string.IsNullOrWhiteSpace(fixture.ExpectedOutcome))
{
return false;
}
// Basic validation that the policy has rules that could match the fixture's scenario
if (document.Rules.Length == 0)
{
return fixture.ExpectedOutcome.Equals("pass", StringComparison.OrdinalIgnoreCase);
}
// Stub: In full implementation, this would:
// 1. Build evaluation context from fixture.Input
// 2. Run PolicyEvaluator.Evaluate(document, context)
// 3. Compare results to fixture.ExpectedOutcome and fixture.ExpectedFindings
if (verbose)
{
AnsiConsole.MarkupLine($"[grey] Evaluating fixture against {document.Rules.Length} rule(s)[/]");
}
// For now, assume pass if expected_outcome is defined
return true;
}
private sealed class PolicyTestFixture
{
public string? Description { get; set; }
public string? ExpectedOutcome { get; set; }
public JsonElement? Input { get; set; }
public JsonElement? ExpectedFindings { get; set; }
}
public static async Task HandleRiskProfileSchemaAsync(string? outputPath, bool verbose)
{
_ = verbose;
using var activity = CliActivitySource.Instance.StartActivity("cli.riskprofile.schema", ActivityKind.Client);
using var duration = CliMetrics.MeasureCommandDuration("risk-profile schema");
try
{
var schemaText = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaText();
var schemaVersion = StellaOps.Policy.RiskProfile.Schema.RiskProfileSchemaProvider.GetSchemaVersion();
if (!string.IsNullOrEmpty(outputPath))
{
await File.WriteAllTextAsync(outputPath, schemaText).ConfigureAwait(false);
AnsiConsole.MarkupLine("Risk profile schema v{0} written to [cyan]{1}[/]", schemaVersion, Markup.Escape(outputPath));
}
else
{
Console.WriteLine(schemaText);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
Environment.ExitCode = 1;
}
}
private static void CollectValidationIssues(
Json.Schema.EvaluationResults results,
List<RiskProfileValidationIssue> issues,
string path = "")
{
if (results.Errors is not null)
{
foreach (var (key, message) in results.Errors)
{
var instancePath = results.InstanceLocation?.ToString() ?? path;
issues.Add(new RiskProfileValidationIssue(instancePath, key, message));
}
}
if (results.Details is not null)
{
foreach (var detail in results.Details)
{
if (!detail.IsValid)
{
CollectValidationIssues(detail, issues, detail.InstanceLocation?.ToString() ?? path);
}
}
}
}
private sealed record RiskProfileValidationReport(
string FilePath,
bool IsValid,
string SchemaVersion,
IReadOnlyList<RiskProfileValidationIssue> Issues);
private sealed record RiskProfileValidationIssue(string Path, string Error, string Message);
// CLI-POLICY-20-001: policy new handler
public static async Task<int> HandlePolicyNewAsync(
string name,
string? templateName,
string? outputPath,
string? description,
string[] tags,
bool shadowMode,
bool createFixtures,
bool gitInit,
string? format,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitInputError = 4;
if (string.IsNullOrWhiteSpace(name))
{
AnsiConsole.MarkupLine("[red]Error:[/] Policy name is required.");
return ExitInputError;
}
// Sanitize name for file
var safeName = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
var template = ParsePolicyTemplate(templateName);
var finalPath = outputPath ?? $"./{safeName}.stella";
finalPath = Path.GetFullPath(finalPath);
if (File.Exists(finalPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] File already exists: {Markup.Escape(finalPath)}");
return ExitInputError;
}
// Generate policy content
var policyContent = GeneratePolicyFromTemplate(name, template, description, tags, shadowMode);
// Write the policy file
var directory = Path.GetDirectoryName(finalPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(finalPath, policyContent, cancellationToken).ConfigureAwait(false);
string? fixturesDir = null;
if (createFixtures)
{
fixturesDir = Path.Combine(directory ?? ".", "tests", "policy", safeName, "cases");
Directory.CreateDirectory(fixturesDir);
// Create a sample fixture
var sampleFixture = GenerateSampleFixture(safeName);
var fixturePath = Path.Combine(fixturesDir, "sample_test.json");
await File.WriteAllTextAsync(fixturePath, sampleFixture, cancellationToken).ConfigureAwait(false);
}
if (gitInit && !string.IsNullOrEmpty(directory))
{
var gitDir = Path.Combine(directory, ".git");
if (!Directory.Exists(gitDir))
{
await RunGitCommandAsync(directory, "init", cancellationToken).ConfigureAwait(false);
if (verbose)
{
AnsiConsole.MarkupLine("[grey]Initialized Git repository[/]");
}
}
}
// Build result
var result = new PolicyNewResult
{
Success = true,
PolicyPath = finalPath,
FixturesPath = fixturesDir,
Template = template.ToString(),
SyntaxVersion = "stella-dsl@1"
};
// Output
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
if (outputFormat == "json")
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy Created[/]", "[green]Success[/]")
.AddRow("[bold]Path[/]", Markup.Escape(finalPath))
.AddRow("[bold]Template[/]", Markup.Escape(template.ToString()))
.AddRow("[bold]Syntax[/]", "stella-dsl@1")
.AddRow("[bold]Shadow Mode[/]", shadowMode ? "[yellow]Enabled[/]" : "[dim]Disabled[/]");
if (!string.IsNullOrEmpty(fixturesDir))
{
grid.AddRow("[bold]Fixtures[/]", Markup.Escape(fixturesDir));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("New Policy") });
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Next steps:[/]");
AnsiConsole.MarkupLine($" 1. Edit policy: [cyan]stella policy edit {Markup.Escape(finalPath)}[/]");
AnsiConsole.MarkupLine($" 2. Validate: [cyan]stella policy lint {Markup.Escape(finalPath)}[/]");
if (createFixtures)
{
AnsiConsole.MarkupLine($" 3. Run tests: [cyan]stella policy test {Markup.Escape(finalPath)}[/]");
}
}
return ExitSuccess;
}
private static PolicyTemplate ParsePolicyTemplate(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return PolicyTemplate.Minimal;
return name.ToLowerInvariant() switch
{
"minimal" => PolicyTemplate.Minimal,
"baseline" => PolicyTemplate.Baseline,
"vex-precedence" or "vex" => PolicyTemplate.VexPrecedence,
"reachability" => PolicyTemplate.Reachability,
"secret-leak" or "secrets" => PolicyTemplate.SecretLeak,
"full" => PolicyTemplate.Full,
_ => PolicyTemplate.Minimal
};
}
private static string GeneratePolicyFromTemplate(string name, PolicyTemplate template, string? description, string[] tags, bool shadowMode)
{
var sb = new StringBuilder();
var desc = description ?? $"Policy for {name}";
var tagList = tags.Length > 0 ? string.Join(", ", tags.Select(t => $"\"{t}\"")) : "\"custom\"";
sb.AppendLine($"policy \"{name}\" syntax \"stella-dsl@1\" {{");
sb.AppendLine(" metadata {");
sb.AppendLine($" description = \"{desc}\"");
sb.AppendLine($" tags = [{tagList}]");
sb.AppendLine(" }");
sb.AppendLine();
if (shadowMode)
{
sb.AppendLine(" settings {");
sb.AppendLine(" shadow = true;");
sb.AppendLine(" }");
sb.AppendLine();
}
switch (template)
{
case PolicyTemplate.Baseline:
sb.AppendLine(" profile severity {");
sb.AppendLine(" map vendor_weight {");
sb.AppendLine(" source \"GHSA\" => +0.5;");
sb.AppendLine(" source \"OSV\" => +0.0;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule advisory_normalization {");
sb.AppendLine(" when advisory.source in [\"GHSA\", \"OSV\"]");
sb.AppendLine(" then severity := normalize_cvss(advisory)");
sb.AppendLine(" because \"Align vendor severity to CVSS baseline\";");
sb.AppendLine(" }");
break;
case PolicyTemplate.VexPrecedence:
sb.AppendLine(" rule vex_strong_claim priority 5 {");
sb.AppendLine(" when vex.any(status == \"not_affected\")");
sb.AppendLine(" and vex.justification in [\"component_not_present\", \"vulnerable_code_not_present\"]");
sb.AppendLine(" then status := vex.status");
sb.AppendLine(" annotate winning_statement := vex.latest().statementId");
sb.AppendLine(" because \"Strong VEX justification\";");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule vex_fixed priority 10 {");
sb.AppendLine(" when vex.any(status == \"fixed\")");
sb.AppendLine(" then status := \"fixed\"");
sb.AppendLine(" because \"Vendor confirms fix available\";");
sb.AppendLine(" }");
break;
case PolicyTemplate.Reachability:
sb.AppendLine(" rule reachability_gate priority 20 {");
sb.AppendLine(" when exists(telemetry.reachability)");
sb.AppendLine(" and telemetry.reachability.state == \"reachable\"");
sb.AppendLine(" and telemetry.reachability.score >= 0.6");
sb.AppendLine(" then status := \"affected\"");
sb.AppendLine(" because \"Runtime/graph evidence shows reachable code path\";");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule unreachable_downgrade priority 25 {");
sb.AppendLine(" when exists(telemetry.reachability)");
sb.AppendLine(" and telemetry.reachability.state == \"unreachable\"");
sb.AppendLine(" then severity := severity - 1.0");
sb.AppendLine(" annotate reason := \"Unreachable code path\"");
sb.AppendLine(" because \"Reduce severity for unreachable vulnerabilities\";");
sb.AppendLine(" }");
break;
case PolicyTemplate.SecretLeak:
sb.AppendLine(" rule secret_detection priority 1 {");
sb.AppendLine(" when secret.hasFinding()");
sb.AppendLine(" then status := \"affected\"");
sb.AppendLine(" escalate to severity_band(\"critical\")");
sb.AppendLine(" because \"Secret leak detected in codebase\";");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule secret_allowlist priority 2 {");
sb.AppendLine(" when secret.hasFinding()");
sb.AppendLine(" and secret.path.allowlist([\"**/test/**\", \"**/fixtures/**\"])");
sb.AppendLine(" then ignore until \"2099-12-31T23:59:59Z\"");
sb.AppendLine(" because \"Test fixtures may contain example secrets\";");
sb.AppendLine(" }");
break;
case PolicyTemplate.Full:
sb.AppendLine(" profile severity {");
sb.AppendLine(" map vendor_weight {");
sb.AppendLine(" source \"GHSA\" => +0.5;");
sb.AppendLine(" source \"OSV\" => +0.0;");
sb.AppendLine(" source \"VendorX\" => -0.2;");
sb.AppendLine(" }");
sb.AppendLine(" env exposure_adjustments {");
sb.AppendLine(" if env.runtime == \"serverless\" then -0.5;");
sb.AppendLine(" if env.exposure == \"internal-only\" then -1.0;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule vex_precedence priority 10 {");
sb.AppendLine(" when vex.any(status in [\"not_affected\", \"fixed\"])");
sb.AppendLine(" and vex.justification in [\"component_not_present\", \"vulnerable_code_not_present\"]");
sb.AppendLine(" then status := vex.status");
sb.AppendLine(" because \"Strong vendor justification prevails\";");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule reachability_gate priority 20 {");
sb.AppendLine(" when exists(telemetry.reachability)");
sb.AppendLine(" and telemetry.reachability.state == \"reachable\"");
sb.AppendLine(" and telemetry.reachability.score >= 0.6");
sb.AppendLine(" then status := \"affected\"");
sb.AppendLine(" because \"Runtime/graph evidence shows reachable code path\";");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" rule trust_penalty priority 30 {");
sb.AppendLine(" when signals.trust_score < 0.4 or signals.entropy_penalty > 0.2");
sb.AppendLine(" then severity := severity_band(\"critical\")");
sb.AppendLine(" because \"Low trust score or high entropy\";");
sb.AppendLine(" }");
break;
case PolicyTemplate.Minimal:
default:
sb.AppendLine(" // Add your rules here");
sb.AppendLine(" // Example:");
sb.AppendLine(" // rule example_rule priority 10 {");
sb.AppendLine(" // when advisory.severity >= \"High\"");
sb.AppendLine(" // then status := \"affected\"");
sb.AppendLine(" // because \"High severity findings require attention\";");
sb.AppendLine(" // }");
break;
}
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateSampleFixture(string policyName)
{
return JsonSerializer.Serialize(new
{
description = $"Sample test case for {policyName}",
expected_outcome = "pass",
input = new
{
sbom = new { purl = "pkg:npm/lodash@4.17.21", name = "lodash", version = "4.17.21" },
advisory = new { id = "GHSA-test-1234", source = "GHSA", severity = "High" },
vex = Array.Empty<object>(),
env = new { runtime = "nodejs", exposure = "internet" }
},
expected_findings = new[]
{
new { status = "affected", severity = "High" }
}
}, new JsonSerializerOptions { WriteIndented = true });
}
// CLI-POLICY-23-006: policy history handler
public static async Task<int> HandlePolicyHistoryAsync(
IServiceProvider services,
string policyId,
string? tenant,
string? from,
string? to,
string? status,
int? limit,
string? cursor,
string? format,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy history"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
DateTimeOffset? fromDate = null;
DateTimeOffset? toDate = null;
if (!string.IsNullOrWhiteSpace(from) && DateTimeOffset.TryParse(from, out var parsedFrom))
{
fromDate = parsedFrom;
}
if (!string.IsNullOrWhiteSpace(to) && DateTimeOffset.TryParse(to, out var parsedTo))
{
toDate = parsedTo;
}
var request = new PolicyHistoryRequest
{
PolicyId = policyId,
Tenant = tenant,
From = fromDate,
To = toDate,
Status = status,
Limit = limit,
Cursor = cursor
};
var response = await client.GetPolicyHistoryAsync(request, cancellationToken).ConfigureAwait(false);
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
if (outputFormat == "json")
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No policy runs found matching the criteria.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Run ID");
table.AddColumn("Version");
table.AddColumn("Status");
table.AddColumn("Started");
table.AddColumn("Duration");
table.AddColumn("SBOMs");
table.AddColumn("Findings");
table.AddColumn("Changed");
foreach (var run in response.Items)
{
var statusColor = run.Status.ToLowerInvariant() switch
{
"completed" => "green",
"failed" => "red",
"running" => "yellow",
_ => "dim"
};
var durationStr = run.Duration.HasValue
? $"{run.Duration.Value.TotalSeconds:F1}s"
: "-";
table.AddRow(
Markup.Escape(run.RunId.Length > 12 ? run.RunId[..12] + "..." : run.RunId),
$"v{run.PolicyVersion}",
$"[{statusColor}]{Markup.Escape(run.Status)}[/]",
run.StartedAt.ToString("yyyy-MM-dd HH:mm"),
durationStr,
run.SbomCount.ToString(),
run.FindingsGenerated.ToString(),
run.FindingsChanged > 0 ? $"[yellow]{run.FindingsChanged}[/]" : "0");
}
AnsiConsole.Write(new Panel(table) { Header = new PanelHeader($"Policy Runs: {policyId}") });
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
// CLI-POLICY-23-006: policy explain tree handler
public static async Task<int> HandlePolicyExplainTreeAsync(
IServiceProvider services,
string policyId,
string? runId,
string? findingId,
string? sbomId,
string? purl,
string? advisory,
string? tenant,
int? depth,
string? format,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy explain"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyExplainRequest
{
PolicyId = policyId,
RunId = runId,
FindingId = findingId,
SbomId = sbomId,
ComponentPurl = purl,
AdvisoryId = advisory,
Tenant = tenant,
Depth = depth
};
var result = await client.GetPolicyExplainAsync(request, cancellationToken).ConfigureAwait(false);
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
if (outputFormat == "json")
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return 0;
}
if (result.Errors is { Count: > 0 })
{
foreach (var err in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(err)}[/]");
}
return 1;
}
// Header panel
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.PolicyVersion}")
.AddRow("[bold]Timestamp[/]", result.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"));
if (!string.IsNullOrWhiteSpace(result.RunId))
{
grid.AddRow("[bold]Run ID[/]", Markup.Escape(result.RunId));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Policy Explain") });
// Subject
if (result.Subject != null)
{
var subjectGrid = new Grid()
.AddColumn()
.AddColumn();
if (!string.IsNullOrWhiteSpace(result.Subject.ComponentPurl))
subjectGrid.AddRow("[bold]PURL[/]", Markup.Escape(result.Subject.ComponentPurl));
if (!string.IsNullOrWhiteSpace(result.Subject.ComponentName))
subjectGrid.AddRow("[bold]Component[/]", $"{Markup.Escape(result.Subject.ComponentName)}@{Markup.Escape(result.Subject.ComponentVersion ?? "?")}");
if (!string.IsNullOrWhiteSpace(result.Subject.AdvisoryId))
subjectGrid.AddRow("[bold]Advisory[/]", $"{Markup.Escape(result.Subject.AdvisoryId)} ({Markup.Escape(result.Subject.AdvisorySource ?? "unknown")})");
AnsiConsole.Write(new Panel(subjectGrid) { Header = new PanelHeader("Subject") });
}
// Decision
if (result.Decision != null)
{
var decisionColor = result.Decision.Status.ToLowerInvariant() switch
{
"affected" => "red",
"not_affected" or "fixed" => "green",
"under_investigation" => "yellow",
_ => "dim"
};
var decisionGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Status[/]", $"[{decisionColor}]{Markup.Escape(result.Decision.Status)}[/]");
if (!string.IsNullOrWhiteSpace(result.Decision.Severity))
{
decisionGrid.AddRow("[bold]Severity[/]", Markup.Escape(result.Decision.Severity));
}
if (!string.IsNullOrWhiteSpace(result.Decision.WinningRule))
{
decisionGrid.AddRow("[bold]Winning Rule[/]", Markup.Escape(result.Decision.WinningRule));
}
if (!string.IsNullOrWhiteSpace(result.Decision.Rationale))
{
decisionGrid.AddRow("[bold]Rationale[/]", $"[italic]{Markup.Escape(result.Decision.Rationale)}[/]");
}
AnsiConsole.Write(new Panel(decisionGrid) { Header = new PanelHeader("Decision") });
}
// Rule trace
if (result.RuleTrace is { Count: > 0 })
{
AnsiConsole.MarkupLine("\n[bold blue]Rule Evaluation Trace[/]");
AnsiConsole.WriteLine();
foreach (var entry in result.RuleTrace.OrderBy(e => e.Priority))
{
var icon = entry.Matched ? "[green]✓[/]" : (entry.Evaluated ? "[red]✗[/]" : "[dim]○[/]");
var ruleColor = entry.Matched ? "green" : (entry.Evaluated ? "dim" : "grey");
AnsiConsole.MarkupLine($"{icon} [{ruleColor}]Rule: {Markup.Escape(entry.RuleName)}[/] (priority {entry.Priority})");
if (entry.Predicates is { Count: > 0 } && verbose)
{
foreach (var pred in entry.Predicates)
{
var predIcon = pred.Result ? "[green]✓[/]" : "[red]✗[/]";
AnsiConsole.MarkupLine($" {predIcon} {Markup.Escape(pred.Expression)}");
if (!string.IsNullOrWhiteSpace(pred.LeftValue) || !string.IsNullOrWhiteSpace(pred.RightValue))
{
AnsiConsole.MarkupLine($" [grey]left={Markup.Escape(pred.LeftValue ?? "null")} right={Markup.Escape(pred.RightValue ?? "null")}[/]");
}
}
}
if (entry.Matched && entry.Actions is { Count: > 0 })
{
foreach (var action in entry.Actions.Where(a => a.Executed))
{
AnsiConsole.MarkupLine($" [cyan]→ {Markup.Escape(action.Action)}: {Markup.Escape(action.Target ?? "")} = {Markup.Escape(action.Value ?? "")}[/]");
}
}
if (!string.IsNullOrWhiteSpace(entry.Because) && entry.Matched)
{
AnsiConsole.MarkupLine($" [italic grey]because: {Markup.Escape(entry.Because)}[/]");
}
if (!string.IsNullOrWhiteSpace(entry.SkippedReason))
{
AnsiConsole.MarkupLine($" [dim]skipped: {Markup.Escape(entry.SkippedReason)}[/]");
}
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
// CLI-POLICY-27-001: policy init handler
public static async Task<int> HandlePolicyInitAsync(
string? path,
string? name,
string? templateName,
bool noGit,
bool noReadme,
bool noFixtures,
string? format,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitInputError = 4;
var workspacePath = Path.GetFullPath(path ?? ".");
var policyName = name ?? Path.GetFileName(workspacePath);
if (string.IsNullOrWhiteSpace(policyName))
{
policyName = "my-policy";
}
// Create workspace directory
Directory.CreateDirectory(workspacePath);
var template = ParsePolicyTemplate(templateName);
var policyPath = Path.Combine(workspacePath, $"{policyName}.stella");
string? fixturesPath = null;
var gitInitialized = false;
var warnings = new List<string>();
// Create policy file
if (!File.Exists(policyPath))
{
var policyContent = GeneratePolicyFromTemplate(policyName, template, null, Array.Empty<string>(), true);
await File.WriteAllTextAsync(policyPath, policyContent, cancellationToken).ConfigureAwait(false);
}
else
{
warnings.Add($"Policy file already exists: {policyPath}");
}
// Create fixtures directory
if (!noFixtures)
{
fixturesPath = Path.Combine(workspacePath, "tests", "policy", policyName, "cases");
Directory.CreateDirectory(fixturesPath);
var sampleFixturePath = Path.Combine(fixturesPath, "sample_test.json");
if (!File.Exists(sampleFixturePath))
{
var sampleFixture = GenerateSampleFixture(policyName);
await File.WriteAllTextAsync(sampleFixturePath, sampleFixture, cancellationToken).ConfigureAwait(false);
}
}
// Create README
if (!noReadme)
{
var readmePath = Path.Combine(workspacePath, "README.md");
if (!File.Exists(readmePath))
{
var readme = GeneratePolicyReadme(policyName);
await File.WriteAllTextAsync(readmePath, readme, cancellationToken).ConfigureAwait(false);
}
}
// Initialize Git
if (!noGit)
{
var gitDir = Path.Combine(workspacePath, ".git");
if (!Directory.Exists(gitDir))
{
var (exitCode, _) = await RunGitCommandAsync(workspacePath, "init", cancellationToken).ConfigureAwait(false);
gitInitialized = exitCode == 0;
if (gitInitialized)
{
// Create .gitignore
var gitignorePath = Path.Combine(workspacePath, ".gitignore");
if (!File.Exists(gitignorePath))
{
await File.WriteAllTextAsync(gitignorePath, "*.ir.json\n.stella-cache/\n", cancellationToken).ConfigureAwait(false);
}
}
}
else
{
gitInitialized = true;
}
}
var result = new PolicyWorkspaceInitResult
{
Success = true,
WorkspacePath = workspacePath,
PolicyPath = policyPath,
FixturesPath = fixturesPath,
GitInitialized = gitInitialized,
Warnings = warnings.Count > 0 ? warnings : null
};
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
if (outputFormat == "json")
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Workspace[/]", Markup.Escape(workspacePath))
.AddRow("[bold]Policy[/]", Markup.Escape(policyPath))
.AddRow("[bold]Template[/]", Markup.Escape(template.ToString()))
.AddRow("[bold]Git[/]", gitInitialized ? "[green]Initialized[/]" : "[dim]Skipped[/]");
if (!string.IsNullOrEmpty(fixturesPath))
{
grid.AddRow("[bold]Fixtures[/]", Markup.Escape(fixturesPath));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Policy Workspace Initialized") });
foreach (var warning in warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Quick start:[/]");
AnsiConsole.MarkupLine($" cd {Markup.Escape(workspacePath)}");
AnsiConsole.MarkupLine($" stella policy edit {policyName}.stella");
AnsiConsole.MarkupLine($" stella policy lint {policyName}.stella");
AnsiConsole.MarkupLine($" stella policy test {policyName}.stella");
}
return ExitSuccess;
}
private static string GeneratePolicyReadme(string policyName)
{
return $@"# {policyName}
This is a StellaOps policy workspace.
## Files
- `{policyName}.stella` - Policy DSL file
- `tests/policy/{policyName}/cases/` - Test fixtures
## Commands
```bash
# Edit the policy
stella policy edit {policyName}.stella
# Validate syntax
stella policy lint {policyName}.stella
# Compile to IR
stella policy compile {policyName}.stella
# Run tests
stella policy test {policyName}.stella
```
## Workflow
1. Edit the policy with shadow mode enabled
2. Run `stella policy lint` to validate syntax
3. Add test fixtures in `tests/policy/{policyName}/cases/`
4. Run `stella policy test` to verify behavior
5. Disable shadow mode and promote to production
";
}
// CLI-POLICY-27-001: policy compile handler
public static async Task<int> HandlePolicyCompileAsync(
string filePath,
string? outputPath,
bool noIr,
bool noDigest,
bool optimize,
bool strict,
string? format,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitCompileError = 1;
const int ExitInputError = 4;
if (string.IsNullOrWhiteSpace(filePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Policy file path is required.");
return ExitInputError;
}
var fullPath = Path.GetFullPath(filePath);
if (!File.Exists(fullPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Policy file not found: {Markup.Escape(fullPath)}");
return ExitInputError;
}
var source = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
var compiler = new PolicyDsl.PolicyCompiler();
var compileResult = compiler.Compile(source);
var errors = new List<PolicyDiagnostic>();
var warnings = new List<PolicyDiagnostic>();
foreach (var diag in compileResult.Diagnostics)
{
var diagnostic = new PolicyDiagnostic
{
Code = diag.Code,
Message = diag.Message,
Severity = diag.Severity.ToString().ToLowerInvariant(),
Path = diag.Path
};
if (diag.Severity == PolicyIssueSeverity.Error)
{
errors.Add(diagnostic);
}
else
{
warnings.Add(diagnostic);
}
}
// In strict mode, treat warnings as errors
if (strict && warnings.Count > 0)
{
errors.AddRange(warnings);
warnings.Clear();
}
string? irPath = null;
string? digest = null;
if (compileResult.Success && !noIr)
{
irPath = outputPath ?? Path.ChangeExtension(fullPath, ".stella.ir.json");
var ir = JsonSerializer.Serialize(compileResult.Document, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(irPath, ir, cancellationToken).ConfigureAwait(false);
}
if (compileResult.Success && !noDigest)
{
digest = compileResult.Checksum;
}
var result = new PolicyCompileResult
{
Success = compileResult.Success && errors.Count == 0,
InputPath = fullPath,
IrPath = irPath,
Digest = digest,
SyntaxVersion = compileResult.Document?.Syntax,
PolicyName = compileResult.Document?.Name,
RuleCount = compileResult.Document?.Rules.Length ?? 0,
ProfileCount = compileResult.Document?.Profiles.Length ?? 0,
Errors = errors.Count > 0 ? errors : null,
Warnings = warnings.Count > 0 ? warnings : null
};
var outputFormat = string.Equals(format, "json", StringComparison.OrdinalIgnoreCase) ? "json" : "table";
if (outputFormat == "json")
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
}
else
{
if (result.Success)
{
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Status[/]", "[green]Compiled[/]")
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyName ?? "-"))
.AddRow("[bold]Syntax[/]", Markup.Escape(result.SyntaxVersion ?? "-"))
.AddRow("[bold]Rules[/]", result.RuleCount.ToString())
.AddRow("[bold]Profiles[/]", result.ProfileCount.ToString());
if (!string.IsNullOrEmpty(digest))
{
grid.AddRow("[bold]Digest[/]", Markup.Escape(digest.Length > 32 ? digest[..32] + "..." : digest));
}
if (!string.IsNullOrEmpty(irPath))
{
grid.AddRow("[bold]IR Output[/]", Markup.Escape(irPath));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Compilation Result") });
}
else
{
AnsiConsole.MarkupLine("[red]Compilation Failed[/]\n");
}
foreach (var err in errors)
{
var location = !string.IsNullOrWhiteSpace(err.Path) ? $" at {err.Path}" : "";
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{Markup.Escape(location)}: {Markup.Escape(err.Message)}[/]");
}
foreach (var warn in warnings)
{
var location = !string.IsNullOrWhiteSpace(warn.Path) ? $" at {warn.Path}" : "";
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{Markup.Escape(location)}: {Markup.Escape(warn.Message)}[/]");
}
}
return result.Success ? ExitSuccess : ExitCompileError;
}
// CLI-POLICY-27-002: Policy workflow handlers
public static async Task<int> HandlePolicyVersionBumpAsync(
IServiceProvider services,
string policyId,
string? bumpType,
string? changelog,
string? filePath,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy version bump"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var bump = bumpType?.ToLowerInvariant() switch
{
"major" => PolicyBumpType.Major,
"minor" => PolicyBumpType.Minor,
_ => PolicyBumpType.Patch
};
var request = new PolicyVersionBumpRequest
{
PolicyId = policyId,
BumpType = bump,
Changelog = changelog,
FilePath = filePath,
Tenant = tenant
};
var result = await client.BumpPolicyVersionAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyId))
.AddRow("[bold]Previous Version[/]", $"v{result.PreviousVersion}")
.AddRow("[bold]New Version[/]", $"[green]v{result.NewVersion}[/]");
if (!string.IsNullOrWhiteSpace(result.Changelog))
{
grid.AddRow("[bold]Changelog[/]", Markup.Escape(result.Changelog));
}
if (!string.IsNullOrWhiteSpace(result.Digest))
{
var digestShort = result.Digest.Length > 16 ? result.Digest[..16] + "..." : result.Digest;
grid.AddRow("[bold]Digest[/]", Markup.Escape(digestShort));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Version Bumped") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicySubmitAsync(
IServiceProvider services,
string policyId,
int? version,
string[] reviewers,
string? message,
bool urgent,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy submit"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicySubmitRequest
{
PolicyId = policyId,
Version = version,
Reviewers = reviewers.Length > 0 ? reviewers : null,
Message = message,
Urgent = urgent,
Tenant = tenant
};
var result = await client.SubmitPolicyForReviewAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyId))
.AddRow("[bold]Version[/]", $"v{result.Version}")
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
.AddRow("[bold]State[/]", $"[yellow]{Markup.Escape(result.State)}[/]")
.AddRow("[bold]Submitted At[/]", result.SubmittedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Submitted By[/]", Markup.Escape(result.SubmittedBy ?? "unknown"));
if (result.Reviewers is { Count: > 0 })
{
grid.AddRow("[bold]Reviewers[/]", Markup.Escape(string.Join(", ", result.Reviewers)));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Policy Submitted for Review") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyReviewStatusAsync(
IServiceProvider services,
string policyId,
string? reviewId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review status"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyReviewStatusRequest
{
PolicyId = policyId,
ReviewId = reviewId,
Tenant = tenant
};
var result = await client.GetPolicyReviewStatusAsync(request, cancellationToken).ConfigureAwait(false);
if (result == null)
{
AnsiConsole.MarkupLine("[yellow]No review found for this policy.[/]");
return 0;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return 0;
}
var stateColor = result.State.ToLowerInvariant() switch
{
"approved" => "green",
"rejected" => "red",
"submitted" or "inreview" => "yellow",
_ => "dim"
};
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]State[/]", $"[{stateColor}]{Markup.Escape(result.State)}[/]")
.AddRow("[bold]Submitted At[/]", result.SubmittedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Submitted By[/]", Markup.Escape(result.SubmittedBy ?? "unknown"));
if (result.Reviewers is { Count: > 0 })
{
grid.AddRow("[bold]Reviewers[/]", Markup.Escape(string.Join(", ", result.Reviewers)));
}
grid.AddRow("[bold]Blocking Comments[/]", result.BlockingComments > 0 ? $"[red]{result.BlockingComments}[/]" : "0");
grid.AddRow("[bold]Resolved Comments[/]", result.ResolvedComments.ToString());
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Review Status") });
// Show approvals
if (result.Approvals is { Count: > 0 })
{
AnsiConsole.MarkupLine("\n[bold green]Approvals[/]");
foreach (var approval in result.Approvals)
{
AnsiConsole.MarkupLine($" [green]✓[/] {Markup.Escape(approval.Approver)} at {approval.ApprovedAt:yyyy-MM-dd HH:mm}");
if (!string.IsNullOrWhiteSpace(approval.Comment))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(approval.Comment)}[/]");
}
}
}
// Show comments (verbose mode)
if (verbose && result.Comments is { Count: > 0 })
{
AnsiConsole.MarkupLine("\n[bold]Comments[/]");
foreach (var comment in result.Comments)
{
var icon = comment.Blocking ? "[red]![/]" : "[dim]○[/]";
var resolved = comment.Resolved ? " [green](resolved)[/]" : "";
AnsiConsole.MarkupLine($" {icon} {Markup.Escape(comment.Author)} at {comment.CreatedAt:yyyy-MM-dd HH:mm}{resolved}");
if (comment.Line.HasValue)
{
AnsiConsole.MarkupLine($" [dim]Line {comment.Line}[/]");
}
AnsiConsole.MarkupLine($" {Markup.Escape(comment.Comment)}");
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyReviewCommentAsync(
IServiceProvider services,
string policyId,
string reviewId,
string comment,
int? line,
string? ruleName,
bool blocking,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review comment"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyReviewCommentRequest
{
PolicyId = policyId,
ReviewId = reviewId,
Comment = comment,
Line = line,
RuleName = ruleName,
Blocking = blocking,
Tenant = tenant
};
var result = await client.AddPolicyReviewCommentAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var blockingStr = blocking ? "[red]blocking[/]" : "[dim]non-blocking[/]";
AnsiConsole.MarkupLine($"[green]Comment added[/] ({blockingStr})");
AnsiConsole.MarkupLine($" Comment ID: {Markup.Escape(result.CommentId)}");
AnsiConsole.MarkupLine($" Author: {Markup.Escape(result.Author ?? "unknown")}");
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyReviewApproveAsync(
IServiceProvider services,
string policyId,
string reviewId,
string? comment,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review approve"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyApproveRequest
{
PolicyId = policyId,
ReviewId = reviewId,
Comment = comment,
Tenant = tenant
};
var result = await client.ApprovePolicyReviewAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
.AddRow("[bold]State[/]", $"[green]{Markup.Escape(result.State)}[/]")
.AddRow("[bold]Approved At[/]", result.ApprovedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Approved By[/]", Markup.Escape(result.ApprovedBy ?? "unknown"))
.AddRow("[bold]Approvals[/]", $"{result.CurrentApprovals}/{result.RequiredApprovals}");
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Approved[/]") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyReviewRejectAsync(
IServiceProvider services,
string policyId,
string reviewId,
string reason,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy review reject"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyRejectRequest
{
PolicyId = policyId,
ReviewId = reviewId,
Reason = reason,
Tenant = tenant
};
var result = await client.RejectPolicyReviewAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]Review ID[/]", Markup.Escape(result.ReviewId))
.AddRow("[bold]State[/]", $"[red]{Markup.Escape(result.State)}[/]")
.AddRow("[bold]Rejected At[/]", result.RejectedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Rejected By[/]", Markup.Escape(result.RejectedBy ?? "unknown"))
.AddRow("[bold]Reason[/]", Markup.Escape(result.Reason ?? reason));
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[red]Policy Rejected[/]") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
// CLI-POLICY-27-004: Policy lifecycle handlers
public static async Task<int> HandlePolicyPublishAsync(
IServiceProvider services,
string policyId,
int version,
bool sign,
string? algorithm,
string? keyId,
string? note,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy publish"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyPublishRequest
{
PolicyId = policyId,
Version = version,
Sign = sign,
SignatureAlgorithm = algorithm,
KeyId = keyId,
Note = note,
Tenant = tenant
};
var result = await client.PublishPolicyAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]Published At[/]", result.PublishedAt.ToString("yyyy-MM-dd HH:mm:ss"));
if (!string.IsNullOrWhiteSpace(result.SignatureId))
{
grid.AddRow("[bold]Signature ID[/]", Markup.Escape(result.SignatureId));
}
if (!string.IsNullOrWhiteSpace(result.RekorLogIndex))
{
grid.AddRow("[bold]Rekor Log Index[/]", Markup.Escape(result.RekorLogIndex));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Published[/]") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyPromoteAsync(
IServiceProvider services,
string policyId,
int version,
string targetEnvironment,
bool canary,
int? canaryPercent,
string? note,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy promote"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyPromoteRequest
{
PolicyId = policyId,
Version = version,
TargetEnvironment = targetEnvironment,
Canary = canary,
CanaryPercentage = canaryPercent,
Note = note,
Tenant = tenant
};
var result = await client.PromotePolicyAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]Target Environment[/]", Markup.Escape(result.TargetEnvironment))
.AddRow("[bold]Promoted At[/]", result.PromotedAt.ToString("yyyy-MM-dd HH:mm:ss"));
if (result.PreviousVersion.HasValue)
{
grid.AddRow("[bold]Previous Version[/]", $"v{result.PreviousVersion}");
}
if (result.CanaryActive)
{
grid.AddRow("[bold]Canary[/]", $"[yellow]Active ({result.CanaryPercentage}%)[/]");
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Promoted[/]") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyRollbackAsync(
IServiceProvider services,
string policyId,
int? targetVersion,
string? environment,
string? reason,
string? incidentId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy rollback"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyRollbackRequest
{
PolicyId = policyId,
TargetVersion = targetVersion,
Environment = environment,
Reason = reason,
IncidentId = incidentId,
Tenant = tenant
};
var result = await client.RollbackPolicyAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", Markup.Escape(result.PolicyId))
.AddRow("[bold]Rolled Back From[/]", $"v{result.PreviousVersion}")
.AddRow("[bold]Rolled Back To[/]", $"v{result.RolledBackToVersion}")
.AddRow("[bold]Rolled Back At[/]", result.RolledBackAt.ToString("yyyy-MM-dd HH:mm:ss"));
if (!string.IsNullOrWhiteSpace(result.Environment))
{
grid.AddRow("[bold]Environment[/]", Markup.Escape(result.Environment));
}
if (!string.IsNullOrWhiteSpace(result.IncidentId))
{
grid.AddRow("[bold]Incident ID[/]", Markup.Escape(result.IncidentId));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[yellow]Policy Rolled Back[/]") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicySignAsync(
IServiceProvider services,
string policyId,
int version,
string? keyId,
string? algorithm,
bool rekorUpload,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy sign"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicySignRequest
{
PolicyId = policyId,
Version = version,
KeyId = keyId,
SignatureAlgorithm = algorithm,
RekorUpload = rekorUpload,
Tenant = tenant
};
var result = await client.SignPolicyAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (!result.Success)
{
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
return 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]Signature ID[/]", Markup.Escape(result.SignatureId))
.AddRow("[bold]Algorithm[/]", Markup.Escape(result.SignatureAlgorithm))
.AddRow("[bold]Signed At[/]", result.SignedAt.ToString("yyyy-MM-dd HH:mm:ss"));
if (!string.IsNullOrWhiteSpace(result.KeyId))
{
grid.AddRow("[bold]Key ID[/]", Markup.Escape(result.KeyId));
}
if (!string.IsNullOrWhiteSpace(result.RekorLogIndex))
{
grid.AddRow("[bold]Rekor Log Index[/]", Markup.Escape(result.RekorLogIndex));
}
if (!string.IsNullOrWhiteSpace(result.RekorEntryUuid))
{
grid.AddRow("[bold]Rekor Entry UUID[/]", Markup.Escape(result.RekorEntryUuid));
}
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("[green]Policy Signed[/]") });
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
public static async Task<int> HandlePolicyVerifySignatureAsync(
IServiceProvider services,
string policyId,
int version,
string? signatureId,
bool checkRekor,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!OfflineModeGuard.IsNetworkAllowed(options, "policy verify-signature"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<IBackendOperationsClient>();
try
{
var request = new PolicyVerifySignatureRequest
{
PolicyId = policyId,
Version = version,
SignatureId = signatureId,
CheckRekor = checkRekor,
Tenant = tenant
};
var result = await client.VerifyPolicySignatureAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Valid ? 0 : 1;
}
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Policy[/]", $"{Markup.Escape(result.PolicyId)} v{result.Version}")
.AddRow("[bold]Signature ID[/]", Markup.Escape(result.SignatureId))
.AddRow("[bold]Algorithm[/]", Markup.Escape(result.SignatureAlgorithm))
.AddRow("[bold]Signed At[/]", result.SignedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Valid[/]", result.Valid ? "[green]Yes[/]" : "[red]No[/]");
if (!string.IsNullOrWhiteSpace(result.SignedBy))
{
grid.AddRow("[bold]Signed By[/]", Markup.Escape(result.SignedBy));
}
if (!string.IsNullOrWhiteSpace(result.KeyId))
{
grid.AddRow("[bold]Key ID[/]", Markup.Escape(result.KeyId));
}
if (result.RekorVerified.HasValue)
{
grid.AddRow("[bold]Rekor Verified[/]", result.RekorVerified.Value ? "[green]Yes[/]" : "[red]No[/]");
}
if (!string.IsNullOrWhiteSpace(result.RekorLogIndex))
{
grid.AddRow("[bold]Rekor Log Index[/]", Markup.Escape(result.RekorLogIndex));
}
if (result.Warnings is { Count: > 0 })
{
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
var headerColor = result.Valid ? "green" : "red";
var headerText = result.Valid ? "Signature Valid" : "Signature Invalid";
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader($"[{headerColor}]{headerText}[/]") });
return result.Valid ? 0 : 1;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
#endregion
#region VEX Consensus (CLI-VEX-30-001)
public static async Task HandleVexConsensusListAsync(
IServiceProvider services,
string? vulnerabilityId,
string? productKey,
string? purl,
string? status,
string? policyVersion,
int? limit,
int? offset,
string? tenant,
bool emitJson,
bool emitCsv,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-consensus");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.consensus.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex consensus list");
using var duration = CliMetrics.MeasureCommandDuration("vex consensus list");
try
{
// Resolve effective tenant (CLI arg > env var > profile)
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
var request = new VexConsensusListRequest(
VulnerabilityId: vulnerabilityId?.Trim(),
ProductKey: productKey?.Trim(),
Purl: purl?.Trim(),
Status: status?.Trim().ToLowerInvariant(),
PolicyVersion: policyVersion?.Trim(),
Limit: limit ?? 50,
Offset: offset ?? 0);
logger.LogDebug("Fetching VEX consensus: vuln={VulnId}, product={ProductKey}, purl={Purl}, status={Status}, policy={PolicyVersion}, limit={Limit}, offset={Offset}",
request.VulnerabilityId, request.ProductKey, request.Purl, request.Status, request.PolicyVersion, request.Limit, request.Offset);
var response = await client.ListVexConsensusAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
if (emitCsv)
{
RenderVexConsensusCsv(response);
Environment.ExitCode = 0;
return;
}
RenderVexConsensusTable(response);
if (response.HasMore)
{
var nextOffset = response.Offset + response.Limit;
AnsiConsole.MarkupLine($"[yellow]More results available. Continue with[/] [cyan]--offset {nextOffset}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch VEX consensus data.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderVexConsensusTable(VexConsensusListResponse response)
{
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No VEX consensus entries found matching the criteria.[/]");
return;
}
var table = new Table();
table.Border(TableBorder.Rounded);
table.AddColumn(new TableColumn("[bold]Vulnerability[/]").NoWrap());
table.AddColumn(new TableColumn("[bold]Product[/]"));
table.AddColumn(new TableColumn("[bold]Status[/]"));
table.AddColumn(new TableColumn("[bold]Sources[/]").Centered());
table.AddColumn(new TableColumn("[bold]Conflicts[/]").Centered());
table.AddColumn(new TableColumn("[bold]Calculated[/]"));
foreach (var item in response.Items)
{
var statusColor = item.Status.ToLowerInvariant() switch
{
"not_affected" => "green",
"fixed" => "blue",
"affected" => "red",
"under_investigation" => "yellow",
_ => "grey"
};
var productDisplay = item.Product.Name ?? item.Product.Key;
if (!string.IsNullOrWhiteSpace(item.Product.Version))
{
productDisplay += $" ({item.Product.Version})";
}
var conflictCount = item.Conflicts?.Count ?? 0;
var conflictDisplay = conflictCount > 0 ? $"[red]{conflictCount}[/]" : "[grey]0[/]";
table.AddRow(
Markup.Escape(item.VulnerabilityId),
Markup.Escape(productDisplay),
$"[{statusColor}]{Markup.Escape(item.Status)}[/]",
item.Sources.Count.ToString(),
conflictDisplay,
item.CalculatedAt.ToString("yyyy-MM-dd HH:mm"));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[grey]Showing {response.Items.Count} of {response.Total} total entries (offset {response.Offset})[/]");
}
private static void RenderVexConsensusCsv(VexConsensusListResponse response)
{
Console.WriteLine("vulnerability_id,product_key,product_name,product_version,purl,status,source_count,conflict_count,calculated_at,policy_version");
foreach (var item in response.Items)
{
var sourceCount = item.Sources.Count;
var conflictCount = item.Conflicts?.Count ?? 0;
Console.WriteLine(string.Join(",",
CsvEscape(item.VulnerabilityId),
CsvEscape(item.Product.Key),
CsvEscape(item.Product.Name ?? string.Empty),
CsvEscape(item.Product.Version ?? string.Empty),
CsvEscape(item.Product.Purl ?? string.Empty),
CsvEscape(item.Status),
sourceCount.ToString(),
conflictCount.ToString(),
item.CalculatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
CsvEscape(item.PolicyVersion ?? string.Empty)));
}
}
private static string CsvEscape(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'))
{
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
return value;
}
// CLI-VEX-30-002: VEX consensus show
public static async Task HandleVexConsensusShowAsync(
IServiceProvider services,
string vulnerabilityId,
string productKey,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-consensus");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.consensus.show", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex consensus show");
activity?.SetTag("stellaops.cli.vulnerability_id", vulnerabilityId);
activity?.SetTag("stellaops.cli.product_key", productKey);
using var duration = CliMetrics.MeasureCommandDuration("vex consensus show");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Fetching VEX consensus detail: vuln={VulnId}, product={ProductKey}", vulnerabilityId, productKey);
var response = await client.GetVexConsensusAsync(vulnerabilityId, productKey, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (response is null)
{
AnsiConsole.MarkupLine($"[yellow]No VEX consensus found for vulnerability[/] [cyan]{Markup.Escape(vulnerabilityId)}[/] [yellow]and product[/] [cyan]{Markup.Escape(productKey)}[/]");
Environment.ExitCode = 0;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderVexConsensusDetail(response);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch VEX consensus detail.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderVexConsensusDetail(VexConsensusDetailResponse response)
{
// Header panel
var statusColor = response.Status.ToLowerInvariant() switch
{
"not_affected" => "green",
"fixed" => "blue",
"affected" => "red",
"under_investigation" => "yellow",
_ => "grey"
};
var headerPanel = new Panel(new Markup($"[bold]{Markup.Escape(response.VulnerabilityId)}[/] → [{statusColor}]{Markup.Escape(response.Status.ToUpperInvariant())}[/]"))
{
Header = new PanelHeader("[bold]VEX Consensus[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(headerPanel);
AnsiConsole.WriteLine();
// Product information
var productGrid = new Grid();
productGrid.AddColumn();
productGrid.AddColumn();
productGrid.AddRow("[grey]Product Key:[/]", Markup.Escape(response.Product.Key));
if (!string.IsNullOrWhiteSpace(response.Product.Name))
productGrid.AddRow("[grey]Name:[/]", Markup.Escape(response.Product.Name));
if (!string.IsNullOrWhiteSpace(response.Product.Version))
productGrid.AddRow("[grey]Version:[/]", Markup.Escape(response.Product.Version));
if (!string.IsNullOrWhiteSpace(response.Product.Purl))
productGrid.AddRow("[grey]PURL:[/]", Markup.Escape(response.Product.Purl));
if (!string.IsNullOrWhiteSpace(response.Product.Cpe))
productGrid.AddRow("[grey]CPE:[/]", Markup.Escape(response.Product.Cpe));
productGrid.AddRow("[grey]Calculated:[/]", response.CalculatedAt.ToString("yyyy-MM-dd HH:mm:ss UTC"));
if (!string.IsNullOrWhiteSpace(response.PolicyVersion))
productGrid.AddRow("[grey]Policy Version:[/]", Markup.Escape(response.PolicyVersion));
var productPanel = new Panel(productGrid)
{
Header = new PanelHeader("[cyan]Product Information[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(productPanel);
AnsiConsole.WriteLine();
// Quorum information
if (response.Quorum is not null)
{
var quorum = response.Quorum;
var quorumMet = quorum.Achieved >= quorum.Required;
var quorumStatus = quorumMet ? "[green]MET[/]" : "[red]NOT MET[/]";
var quorumGrid = new Grid();
quorumGrid.AddColumn();
quorumGrid.AddColumn();
quorumGrid.AddRow("[grey]Status:[/]", quorumStatus);
quorumGrid.AddRow("[grey]Required:[/]", quorum.Required.ToString());
quorumGrid.AddRow("[grey]Achieved:[/]", quorum.Achieved.ToString());
quorumGrid.AddRow("[grey]Threshold:[/]", $"{quorum.Threshold:P0}");
quorumGrid.AddRow("[grey]Total Weight:[/]", $"{quorum.TotalWeight:F2}");
quorumGrid.AddRow("[grey]Weight Achieved:[/]", $"{quorum.WeightAchieved:F2}");
if (quorum.ParticipatingProviders is { Count: > 0 })
{
quorumGrid.AddRow("[grey]Providers:[/]", string.Join(", ", quorum.ParticipatingProviders.Select(Markup.Escape)));
}
var quorumPanel = new Panel(quorumGrid)
{
Header = new PanelHeader("[cyan]Quorum[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(quorumPanel);
AnsiConsole.WriteLine();
}
// Sources (accepted claims)
if (response.Sources.Count > 0)
{
var sourcesTable = new Table();
sourcesTable.Border(TableBorder.Rounded);
sourcesTable.AddColumn("[bold]Provider[/]");
sourcesTable.AddColumn("[bold]Status[/]");
sourcesTable.AddColumn("[bold]Weight[/]");
sourcesTable.AddColumn("[bold]Justification[/]");
foreach (var source in response.Sources)
{
var sourceStatus = source.Status.ToLowerInvariant() switch
{
"not_affected" => "[green]not_affected[/]",
"fixed" => "[blue]fixed[/]",
"affected" => "[red]affected[/]",
_ => Markup.Escape(source.Status)
};
sourcesTable.AddRow(
Markup.Escape(source.ProviderId),
sourceStatus,
$"{source.Weight:F2}",
Markup.Escape(source.Justification ?? "-"));
}
AnsiConsole.MarkupLine("[cyan]Sources (Accepted Claims)[/]");
AnsiConsole.Write(sourcesTable);
AnsiConsole.WriteLine();
}
// Conflicts (rejected claims)
if (response.Conflicts is { Count: > 0 })
{
var conflictsTable = new Table();
conflictsTable.Border(TableBorder.Rounded);
conflictsTable.AddColumn("[bold]Provider[/]");
conflictsTable.AddColumn("[bold]Status[/]");
conflictsTable.AddColumn("[bold]Reason[/]");
foreach (var conflict in response.Conflicts)
{
conflictsTable.AddRow(
Markup.Escape(conflict.ProviderId),
Markup.Escape(conflict.Status),
Markup.Escape(conflict.Reason ?? "-"));
}
AnsiConsole.MarkupLine("[red]Conflicts (Rejected Claims)[/]");
AnsiConsole.Write(conflictsTable);
AnsiConsole.WriteLine();
}
// Rationale
if (response.Rationale is not null)
{
var rationale = response.Rationale;
var rationaleGrid = new Grid();
rationaleGrid.AddColumn();
if (!string.IsNullOrWhiteSpace(rationale.Text))
{
rationaleGrid.AddRow(Markup.Escape(rationale.Text));
}
if (rationale.Justifications is { Count: > 0 })
{
rationaleGrid.AddRow("");
rationaleGrid.AddRow("[grey]Justifications:[/]");
foreach (var j in rationale.Justifications)
{
rationaleGrid.AddRow($" • {Markup.Escape(j)}");
}
}
if (rationale.PolicyRules is { Count: > 0 })
{
rationaleGrid.AddRow("");
rationaleGrid.AddRow("[grey]Policy Rules:[/]");
foreach (var rule in rationale.PolicyRules)
{
rationaleGrid.AddRow($" • {Markup.Escape(rule)}");
}
}
var rationalePanel = new Panel(rationaleGrid)
{
Header = new PanelHeader("[cyan]Rationale[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(rationalePanel);
AnsiConsole.WriteLine();
}
// Signature status
if (response.Signature is not null)
{
var sig = response.Signature;
var sigStatus = sig.Signed ? "[green]SIGNED[/]" : "[yellow]UNSIGNED[/]";
var verifyStatus = sig.VerificationStatus?.ToLowerInvariant() switch
{
"valid" => "[green]VALID[/]",
"invalid" => "[red]INVALID[/]",
"unknown" => "[yellow]UNKNOWN[/]",
_ => sig.VerificationStatus is not null ? Markup.Escape(sig.VerificationStatus) : "[grey]N/A[/]"
};
var sigGrid = new Grid();
sigGrid.AddColumn();
sigGrid.AddColumn();
sigGrid.AddRow("[grey]Status:[/]", sigStatus);
if (sig.Signed)
{
sigGrid.AddRow("[grey]Verification:[/]", verifyStatus);
if (!string.IsNullOrWhiteSpace(sig.Algorithm))
sigGrid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sig.Algorithm));
if (!string.IsNullOrWhiteSpace(sig.KeyId))
sigGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(sig.KeyId));
if (sig.SignedAt.HasValue)
sigGrid.AddRow("[grey]Signed At:[/]", sig.SignedAt.Value.ToString("yyyy-MM-dd HH:mm:ss UTC"));
}
var sigPanel = new Panel(sigGrid)
{
Header = new PanelHeader("[cyan]Signature[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(sigPanel);
AnsiConsole.WriteLine();
}
// Evidence
if (response.Evidence is { Count: > 0 })
{
var evidenceTable = new Table();
evidenceTable.Border(TableBorder.Rounded);
evidenceTable.AddColumn("[bold]Type[/]");
evidenceTable.AddColumn("[bold]Provider[/]");
evidenceTable.AddColumn("[bold]Document[/]");
evidenceTable.AddColumn("[bold]Timestamp[/]");
foreach (var ev in response.Evidence)
{
var docRef = !string.IsNullOrWhiteSpace(ev.DocumentDigest)
? (ev.DocumentDigest.Length > 16 ? ev.DocumentDigest[..16] + "..." : ev.DocumentDigest)
: ev.DocumentId ?? "-";
evidenceTable.AddRow(
Markup.Escape(ev.Type),
Markup.Escape(ev.ProviderId),
Markup.Escape(docRef),
ev.Timestamp?.ToString("yyyy-MM-dd HH:mm") ?? "-");
}
AnsiConsole.MarkupLine("[cyan]Evidence[/]");
AnsiConsole.Write(evidenceTable);
}
// Summary
if (!string.IsNullOrWhiteSpace(response.Summary))
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Summary:[/] {Markup.Escape(response.Summary)}");
}
}
// CLI-VEX-30-003: VEX simulate
public static async Task HandleVexSimulateAsync(
IServiceProvider services,
string? vulnerabilityId,
string? productKey,
string? purl,
double? threshold,
int? quorum,
IReadOnlyList<string> trustOverrides,
IReadOnlyList<string> excludeProviders,
IReadOnlyList<string> includeOnly,
string? tenant,
bool emitJson,
bool changedOnly,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex simulate");
using var duration = CliMetrics.MeasureCommandDuration("vex simulate");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Parse trust overrides (format: provider=weight)
Dictionary<string, double>? parsedTrustOverrides = null;
if (trustOverrides.Count > 0)
{
parsedTrustOverrides = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in trustOverrides)
{
var parts = entry.Split('=', 2);
if (parts.Length != 2)
{
AnsiConsole.MarkupLine($"[red]Invalid trust override format:[/] {Markup.Escape(entry)}. Expected provider=weight");
Environment.ExitCode = 1;
return;
}
if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var weight))
{
AnsiConsole.MarkupLine($"[red]Invalid weight value:[/] {Markup.Escape(parts[1])}");
Environment.ExitCode = 1;
return;
}
parsedTrustOverrides[parts[0].Trim()] = weight;
}
}
var request = new VexSimulationRequest(
VulnerabilityId: vulnerabilityId?.Trim(),
ProductKey: productKey?.Trim(),
Purl: purl?.Trim(),
TrustOverrides: parsedTrustOverrides,
ThresholdOverride: threshold,
QuorumOverride: quorum,
ExcludeProviders: excludeProviders.Count > 0 ? excludeProviders.ToList() : null,
IncludeOnly: includeOnly.Count > 0 ? includeOnly.ToList() : null);
logger.LogDebug("Running VEX simulation: vuln={VulnId}, product={ProductKey}, threshold={Threshold}, quorum={Quorum}",
request.VulnerabilityId, request.ProductKey, request.ThresholdOverride, request.QuorumOverride);
var response = await client.SimulateVexConsensusAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderVexSimulationResults(response, changedOnly);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to run VEX simulation.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderVexSimulationResults(VexSimulationResponse response, bool changedOnly)
{
// Summary panel
var summary = response.Summary;
var summaryGrid = new Grid();
summaryGrid.AddColumn();
summaryGrid.AddColumn();
summaryGrid.AddRow("[grey]Total Evaluated:[/]", summary.TotalEvaluated.ToString());
summaryGrid.AddRow("[grey]Changed:[/]", summary.TotalChanged > 0 ? $"[yellow]{summary.TotalChanged}[/]" : "[green]0[/]");
summaryGrid.AddRow("[grey]Status Upgrades:[/]", summary.StatusUpgrades > 0 ? $"[green]{summary.StatusUpgrades}[/]" : "0");
summaryGrid.AddRow("[grey]Status Downgrades:[/]", summary.StatusDowngrades > 0 ? $"[red]{summary.StatusDowngrades}[/]" : "0");
summaryGrid.AddRow("[grey]No Change:[/]", summary.NoChange.ToString());
var summaryPanel = new Panel(summaryGrid)
{
Header = new PanelHeader("[bold]Simulation Summary[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(summaryPanel);
AnsiConsole.WriteLine();
// Parameters panel
var parameters = response.Parameters;
var paramsGrid = new Grid();
paramsGrid.AddColumn();
paramsGrid.AddColumn();
paramsGrid.AddRow("[grey]Threshold:[/]", $"{parameters.Threshold:P0}");
paramsGrid.AddRow("[grey]Quorum:[/]", parameters.Quorum.ToString());
if (parameters.TrustWeights is { Count: > 0 })
{
var weights = string.Join(", ", parameters.TrustWeights.Select(kv => $"{kv.Key}={kv.Value:F2}"));
paramsGrid.AddRow("[grey]Trust Weights:[/]", Markup.Escape(weights));
}
if (parameters.ExcludedProviders is { Count: > 0 })
{
paramsGrid.AddRow("[grey]Excluded:[/]", string.Join(", ", parameters.ExcludedProviders.Select(Markup.Escape)));
}
var paramsPanel = new Panel(paramsGrid)
{
Header = new PanelHeader("[cyan]Simulation Parameters[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(paramsPanel);
AnsiConsole.WriteLine();
// Results table
var itemsToShow = changedOnly ? response.Items.Where(i => i.Changed).ToList() : response.Items;
if (itemsToShow.Count == 0)
{
AnsiConsole.MarkupLine(changedOnly
? "[green]No status changes detected with the given parameters.[/]"
: "[yellow]No items to display.[/]");
return;
}
var table = new Table();
table.Border(TableBorder.Rounded);
table.AddColumn(new TableColumn("[bold]Vulnerability[/]").NoWrap());
table.AddColumn(new TableColumn("[bold]Product[/]"));
table.AddColumn(new TableColumn("[bold]Before[/]"));
table.AddColumn(new TableColumn("[bold]After[/]"));
table.AddColumn(new TableColumn("[bold]Change[/]"));
foreach (var item in itemsToShow)
{
var beforeStatus = GetStatusMarkup(item.Before.Status);
var afterStatus = GetStatusMarkup(item.After.Status);
var changeIndicator = item.Changed
? item.ChangeType?.ToLowerInvariant() switch
{
"upgrade" => "[green]UPGRADE[/]",
"downgrade" => "[red]DOWNGRADE[/]",
_ => "[yellow]CHANGED[/]"
}
: "[grey]-[/]";
var productDisplay = item.Product.Name ?? item.Product.Key;
if (!string.IsNullOrWhiteSpace(item.Product.Version))
{
productDisplay += $" ({item.Product.Version})";
}
table.AddRow(
Markup.Escape(item.VulnerabilityId),
Markup.Escape(productDisplay),
beforeStatus,
afterStatus,
changeIndicator);
}
AnsiConsole.Write(table);
if (changedOnly && response.Items.Count > itemsToShow.Count)
{
AnsiConsole.MarkupLine($"[grey]Showing {itemsToShow.Count} changed items. {response.Items.Count - itemsToShow.Count} unchanged items hidden.[/]");
}
static string GetStatusMarkup(string status) => status.ToLowerInvariant() switch
{
"not_affected" => "[green]not_affected[/]",
"fixed" => "[blue]fixed[/]",
"affected" => "[red]affected[/]",
"under_investigation" => "[yellow]under_investigation[/]",
_ => Markup.Escape(status)
};
}
// CLI-VEX-30-004: VEX export
public static async Task HandleVexExportAsync(
IServiceProvider services,
IReadOnlyList<string> vulnIds,
IReadOnlyList<string> productKeys,
IReadOnlyList<string> purls,
IReadOnlyList<string> statuses,
string? policyVersion,
string outputPath,
bool signed,
string? tenant,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex export");
using var duration = CliMetrics.MeasureCommandDuration("vex export");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
if (string.IsNullOrWhiteSpace(outputPath))
{
AnsiConsole.MarkupLine("[red]Output path is required.[/]");
Environment.ExitCode = 1;
return;
}
var outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
var request = new VexExportRequest(
VulnerabilityIds: vulnIds.Count > 0 ? vulnIds.ToList() : null,
ProductKeys: productKeys.Count > 0 ? productKeys.ToList() : null,
Purls: purls.Count > 0 ? purls.ToList() : null,
Statuses: statuses.Count > 0 ? statuses.ToList() : null,
PolicyVersion: policyVersion?.Trim(),
Signed: signed,
Format: "ndjson");
logger.LogDebug("Requesting VEX export: signed={Signed}, vulnIds={VulnCount}, productKeys={ProductCount}",
signed, vulnIds.Count, productKeys.Count);
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Preparing export...", async ctx =>
{
var exportResponse = await client.ExportVexConsensusAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
ctx.Status("Downloading export bundle...");
await using var downloadStream = await client.DownloadVexExportAsync(exportResponse.ExportId, effectiveTenant, cancellationToken).ConfigureAwait(false);
await using var fileStream = File.Create(outputPath);
await downloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Export complete![/]");
AnsiConsole.WriteLine();
var resultGrid = new Grid();
resultGrid.AddColumn();
resultGrid.AddColumn();
resultGrid.AddRow("[grey]Output File:[/]", Markup.Escape(outputPath));
resultGrid.AddRow("[grey]Items Exported:[/]", exportResponse.ItemCount.ToString());
resultGrid.AddRow("[grey]Format:[/]", Markup.Escape(exportResponse.Format));
resultGrid.AddRow("[grey]Signed:[/]", exportResponse.Signed ? "[green]Yes[/]" : "[yellow]No[/]");
if (exportResponse.Signed)
{
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureAlgorithm))
resultGrid.AddRow("[grey]Signature Algorithm:[/]", Markup.Escape(exportResponse.SignatureAlgorithm));
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureKeyId))
resultGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(exportResponse.SignatureKeyId));
}
if (!string.IsNullOrWhiteSpace(exportResponse.Digest))
{
var digestDisplay = exportResponse.Digest.Length > 32
? exportResponse.Digest[..32] + "..."
: exportResponse.Digest;
resultGrid.AddRow("[grey]Digest:[/]", $"{exportResponse.DigestAlgorithm ?? "sha256"}:{Markup.Escape(digestDisplay)}");
}
AnsiConsole.Write(resultGrid);
});
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export VEX consensus data.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleVexVerifyAsync(
IServiceProvider services,
string filePath,
string? expectedDigest,
string? publicKeyPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.export.verify", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex export verify");
using var duration = CliMetrics.MeasureCommandDuration("vex export verify");
try
{
if (string.IsNullOrWhiteSpace(filePath))
{
AnsiConsole.MarkupLine("[red]File path is required.[/]");
Environment.ExitCode = 1;
return;
}
if (!File.Exists(filePath))
{
AnsiConsole.MarkupLine($"[red]File not found:[/] {Markup.Escape(filePath)}");
Environment.ExitCode = 1;
return;
}
logger.LogDebug("Verifying VEX export: file={FilePath}, expectedDigest={Digest}", filePath, expectedDigest ?? "(none)");
// Calculate SHA-256 digest
string actualDigest;
await using (var fileStream = File.OpenRead(filePath))
{
using var sha256 = SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(fileStream, cancellationToken).ConfigureAwait(false);
actualDigest = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
var resultGrid = new Grid();
resultGrid.AddColumn();
resultGrid.AddColumn();
resultGrid.AddRow("[grey]File:[/]", Markup.Escape(filePath));
resultGrid.AddRow("[grey]Actual Digest:[/]", $"sha256:{Markup.Escape(actualDigest)}");
var digestValid = true;
if (!string.IsNullOrWhiteSpace(expectedDigest))
{
var normalizedExpected = expectedDigest.Trim().ToLowerInvariant();
if (normalizedExpected.StartsWith("sha256:"))
{
normalizedExpected = normalizedExpected[7..];
}
digestValid = string.Equals(actualDigest, normalizedExpected, StringComparison.OrdinalIgnoreCase);
resultGrid.AddRow("[grey]Expected Digest:[/]", $"sha256:{Markup.Escape(normalizedExpected)}");
resultGrid.AddRow("[grey]Digest Match:[/]", digestValid ? "[green]YES[/]" : "[red]NO[/]");
}
var sigStatus = "not_verified";
if (!string.IsNullOrWhiteSpace(publicKeyPath))
{
if (!File.Exists(publicKeyPath))
{
resultGrid.AddRow("[grey]Signature:[/]", $"[red]Public key not found:[/] {Markup.Escape(publicKeyPath)}");
}
else
{
// Look for .sig file
var sigPath = filePath + ".sig";
if (File.Exists(sigPath))
{
// Note: Actual signature verification would require cryptographic operations
// This is a placeholder that shows the structure
resultGrid.AddRow("[grey]Signature File:[/]", Markup.Escape(sigPath));
resultGrid.AddRow("[grey]Public Key:[/]", Markup.Escape(publicKeyPath));
resultGrid.AddRow("[grey]Signature Status:[/]", "[yellow]Verification requires runtime crypto support[/]");
sigStatus = "requires_verification";
}
else
{
resultGrid.AddRow("[grey]Signature:[/]", "[yellow]No .sig file found[/]");
sigStatus = "no_signature";
}
}
}
else
{
resultGrid.AddRow("[grey]Signature:[/]", "[grey]Skipped (no --public-key provided)[/]");
sigStatus = "skipped";
}
var panel = new Panel(resultGrid)
{
Header = new PanelHeader("[bold]VEX Export Verification[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(panel);
if (!digestValid)
{
AnsiConsole.MarkupLine("[red]Verification FAILED: Digest mismatch[/]");
Environment.ExitCode = 1;
}
else if (sigStatus == "no_signature" && !string.IsNullOrWhiteSpace(publicKeyPath))
{
AnsiConsole.MarkupLine("[yellow]Warning: No signature file found for verification[/]");
Environment.ExitCode = 0;
}
else
{
AnsiConsole.MarkupLine("[green]Verification completed[/]");
Environment.ExitCode = 0;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify VEX export.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
// CLI-LNM-22-002: Handle vex obs get command
public static async Task HandleVexObsGetAsync(
IServiceProvider services,
string tenant,
string[] vulnIds,
string[] productKeys,
string[] purls,
string[] cpes,
string[] statuses,
string[] providers,
int? limit,
string? cursor,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IVexObservationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-obs");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.obs.get", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex obs get");
activity?.SetTag("stellaops.cli.tenant", tenant);
using var duration = CliMetrics.MeasureCommandDuration("vex obs get");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var query = new VexObservationQuery
{
Tenant = tenant,
VulnerabilityIds = vulnIds,
ProductKeys = productKeys,
Purls = purls,
Cpes = cpes,
Statuses = statuses,
ProviderIds = providers,
Limit = limit,
Cursor = cursor
};
var response = await client.GetObservationsAsync(query, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderVexObservations(response, verbose);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch VEX observations.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderVexObservations(VexObservationResponse response, bool verbose)
{
if (response.Observations.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No VEX observations found matching the query.[/]");
return;
}
AnsiConsole.MarkupLine($"[bold]VEX Observations[/] ({response.Observations.Count} result(s))");
AnsiConsole.MarkupLine("");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Obs ID");
table.AddColumn("Vuln ID");
table.AddColumn("Product");
table.AddColumn("Status");
table.AddColumn("Provider");
table.AddColumn("Last Seen");
foreach (var obs in response.Observations)
{
var obsId = obs.ObservationId.Length > 12 ? obs.ObservationId[..12] + "..." : obs.ObservationId;
var productName = obs.Product?.Name ?? obs.Product?.Key ?? "(unknown)";
if (productName.Length > 25) productName = productName[..22] + "...";
var statusMarkup = GetVexStatusMarkup(obs.Status);
table.AddRow(
Markup.Escape(obsId),
$"[cyan]{Markup.Escape(obs.VulnerabilityId)}[/]",
Markup.Escape(productName),
statusMarkup,
Markup.Escape(obs.ProviderId),
obs.LastSeen.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
AnsiConsole.Write(table);
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor \"{Markup.Escape(response.NextCursor)}\" to continue.[/]");
}
if (verbose && response.Aggregate is { } agg)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Aggregate:[/]");
AnsiConsole.MarkupLine($" [grey]Vulnerabilities:[/] {agg.VulnerabilityIds.Count}");
AnsiConsole.MarkupLine($" [grey]Products:[/] {agg.ProductKeys.Count}");
AnsiConsole.MarkupLine($" [grey]Providers:[/] {string.Join(", ", agg.ProviderIds)}");
if (agg.StatusCounts.Count > 0)
{
AnsiConsole.MarkupLine($" [grey]Status Counts:[/]");
foreach (var (status, count) in agg.StatusCounts)
{
AnsiConsole.MarkupLine($" {GetVexStatusMarkup(status)}: {count}");
}
}
}
}
}
// CLI-LNM-22-002: Handle vex linkset show command
public static async Task HandleVexLinksetShowAsync(
IServiceProvider services,
string tenant,
string vulnId,
string[] productKeys,
string[] purls,
string[] statuses,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IVexObservationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vex-linkset");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vex.linkset.show", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vex linkset show");
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.vuln_id", vulnId);
using var duration = CliMetrics.MeasureCommandDuration("vex linkset show");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
if (string.IsNullOrWhiteSpace(vulnId))
{
throw new InvalidOperationException("Vulnerability ID must be provided.");
}
var query = new VexLinksetQuery
{
Tenant = tenant,
VulnerabilityId = vulnId.Trim(),
ProductKeys = productKeys,
Purls = purls,
Statuses = statuses
};
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = response.Conflicts.Count > 0 ? 9 : 0;
return;
}
RenderVexLinkset(response, verbose);
Environment.ExitCode = response.Conflicts.Count > 0 ? 9 : 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch VEX linkset.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderVexLinkset(VexLinksetResponse response, bool verbose)
{
AnsiConsole.MarkupLine($"[bold]VEX Linkset for[/] [cyan]{Markup.Escape(response.VulnerabilityId)}[/]");
AnsiConsole.MarkupLine("");
// Summary
if (response.Summary is { } summary)
{
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Total Observations:[/]", summary.TotalObservations.ToString(CultureInfo.InvariantCulture));
grid.AddRow("[grey]Providers:[/]", string.Join(", ", summary.Providers));
grid.AddRow("[grey]Products:[/]", summary.Products.Count.ToString(CultureInfo.InvariantCulture));
grid.AddRow("[grey]Has Conflicts:[/]", summary.HasConflicts ? "[red]YES[/]" : "[green]NO[/]");
if (summary.StatusCounts.Count > 0)
{
var statusStr = string.Join(", ", summary.StatusCounts.Select(kv => $"{kv.Key}:{kv.Value}"));
grid.AddRow("[grey]Status Distribution:[/]", statusStr);
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Summary[/]")
};
AnsiConsole.Write(panel);
AnsiConsole.MarkupLine("");
}
// Observations
if (response.Observations.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Linked Observations:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Product");
table.AddColumn("Status");
table.AddColumn("Provider");
table.AddColumn("Justification");
table.AddColumn("Last Seen");
foreach (var obs in response.Observations)
{
var productName = obs.Product?.Name ?? obs.Product?.Key ?? "(unknown)";
if (productName.Length > 30) productName = productName[..27] + "...";
var justification = obs.Justification ?? "-";
if (justification.Length > 20) justification = justification[..17] + "...";
table.AddRow(
Markup.Escape(productName),
GetVexStatusMarkup(obs.Status),
Markup.Escape(obs.ProviderId),
Markup.Escape(justification),
obs.LastSeen.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("");
}
else
{
AnsiConsole.MarkupLine("[yellow]No observations found for this vulnerability.[/]");
return;
}
// Conflicts
if (response.Conflicts.Count > 0)
{
AnsiConsole.MarkupLine("[bold red]Conflicts Detected:[/]");
var conflictTable = new Table()
.Border(TableBorder.Simple);
conflictTable.AddColumn("Product");
conflictTable.AddColumn("Conflicting Statuses");
conflictTable.AddColumn("Description");
foreach (var conflict in response.Conflicts)
{
conflictTable.AddRow(
Markup.Escape(conflict.ProductKey),
string.Join(", ", conflict.ConflictingStatuses.Select(s => GetVexStatusMarkup(s))),
Markup.Escape(conflict.Description));
}
AnsiConsole.Write(conflictTable);
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[yellow]Conflicts indicate multiple providers disagree on the status.[/]");
}
// Verbose: show observation details
if (verbose && response.Observations.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Observation Details:[/]");
foreach (var obs in response.Observations)
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(obs.ObservationId)}[/]");
if (!string.IsNullOrWhiteSpace(obs.Detail))
{
var detail = obs.Detail.Length > 80 ? obs.Detail[..77] + "..." : obs.Detail;
AnsiConsole.MarkupLine($" [grey]Detail:[/] {Markup.Escape(detail)}");
}
if (obs.Document is { } doc)
{
AnsiConsole.MarkupLine($" [grey]Format:[/] {Markup.Escape(doc.Format)}");
AnsiConsole.MarkupLine($" [grey]Digest:[/] {Markup.Escape(doc.Digest[..Math.Min(16, doc.Digest.Length)])}...");
}
}
}
}
}
#endregion
#region Vulnerability Explorer (CLI-VULN-29-001)
// CLI-VULN-29-001: Vulnerability list handler
public static async Task HandleVulnListAsync(
IServiceProvider services,
string? vulnId,
string? severity,
string? status,
string? purl,
string? cpe,
string? sbomId,
string? policyId,
int? policyVersion,
string? groupBy,
int? limit,
int? offset,
string? cursor,
string? tenant,
bool emitJson,
bool emitCsv,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-list");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln list");
using var duration = CliMetrics.MeasureCommandDuration("vuln list");
try
{
// Resolve effective tenant (CLI arg > env var > profile)
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Listing vulnerabilities: vuln={VulnId}, severity={Severity}, status={Status}, purl={Purl}, groupBy={GroupBy}",
vulnId, severity, status, purl, groupBy);
var request = new VulnListRequest(
VulnerabilityId: vulnId,
Severity: severity,
Status: status,
Purl: purl,
Cpe: cpe,
SbomId: sbomId,
PolicyId: policyId,
PolicyVersion: policyVersion,
GroupBy: groupBy,
Limit: limit,
Offset: offset,
Cursor: cursor);
var response = await client.ListVulnerabilitiesAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, jsonOptions);
AnsiConsole.WriteLine(json);
}
else if (emitCsv)
{
RenderVulnListCsv(response);
}
else
{
RenderVulnListTable(response, groupBy);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list vulnerabilities.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderVulnListTable(VulnListResponse response, string? groupBy)
{
if (!string.IsNullOrWhiteSpace(groupBy) && response.Grouping != null)
{
// Render grouped summary
var groupTable = new Table();
groupTable.AddColumn(new TableColumn($"[bold]{Markup.Escape(response.Grouping.Field)}[/]").LeftAligned());
groupTable.AddColumn(new TableColumn("[bold]Count[/]").RightAligned());
groupTable.AddColumn(new TableColumn("[bold]Critical[/]").RightAligned());
groupTable.AddColumn(new TableColumn("[bold]High[/]").RightAligned());
groupTable.AddColumn(new TableColumn("[bold]Medium[/]").RightAligned());
groupTable.AddColumn(new TableColumn("[bold]Low[/]").RightAligned());
foreach (var group in response.Grouping.Groups)
{
groupTable.AddRow(
Markup.Escape(group.Key),
group.Count.ToString(),
group.CriticalCount?.ToString() ?? "-",
group.HighCount?.ToString() ?? "-",
group.MediumCount?.ToString() ?? "-",
group.LowCount?.ToString() ?? "-");
}
AnsiConsole.Write(groupTable);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Grouped by:[/] {Markup.Escape(response.Grouping.Field)} | [grey]Total groups:[/] {response.Grouping.Groups.Count}");
return;
}
// Render individual vulnerabilities
var table = new Table();
table.AddColumn(new TableColumn("[bold]Vulnerability ID[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Severity[/]").Centered());
table.AddColumn(new TableColumn("[bold]Status[/]").Centered());
table.AddColumn(new TableColumn("[bold]VEX[/]").Centered());
table.AddColumn(new TableColumn("[bold]Packages[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Updated[/]").RightAligned());
foreach (var item in response.Items)
{
var severityColor = GetSeverityColor(item.Severity.Level);
var statusColor = GetVulnStatusColor(item.Status);
var vexDisplay = item.VexStatus ?? "-";
var vexColor = GetVexStatusColor(item.VexStatus);
var packageCount = item.AffectedPackages.Count.ToString();
table.AddRow(
Markup.Escape(item.VulnerabilityId),
$"[{severityColor}]{Markup.Escape(item.Severity.Level.ToUpperInvariant())}[/]",
$"[{statusColor}]{Markup.Escape(item.Status)}[/]",
$"[{vexColor}]{Markup.Escape(vexDisplay)}[/]",
packageCount,
item.UpdatedAt?.ToString("yyyy-MM-dd") ?? "-");
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Showing:[/] {response.Items.Count} of {response.Total} | [grey]Offset:[/] {response.Offset}");
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine($"[grey]Next page:[/] --cursor \"{Markup.Escape(response.NextCursor)}\"");
}
}
private static void RenderVulnListCsv(VulnListResponse response)
{
Console.WriteLine("VulnerabilityId,Severity,Score,Status,VexStatus,PackageCount,Assignee,UpdatedAt");
foreach (var item in response.Items)
{
Console.WriteLine($"{CsvEscape(item.VulnerabilityId)},{CsvEscape(item.Severity.Level)},{item.Severity.Score?.ToString("F1") ?? ""},{CsvEscape(item.Status)},{CsvEscape(item.VexStatus ?? "")},{item.AffectedPackages.Count},{CsvEscape(item.Assignee ?? "")},{item.UpdatedAt?.ToString("O") ?? ""}");
}
}
private static string GetSeverityColor(string severity)
{
return severity.ToLowerInvariant() switch
{
"critical" => "red bold",
"high" => "red",
"medium" => "yellow",
"low" => "blue",
_ => "grey"
};
}
private static string GetVulnStatusColor(string status)
{
return status.ToLowerInvariant() switch
{
"open" => "red",
"triaged" => "yellow",
"accepted" => "green",
"fixed" => "green",
"risk_accepted" => "cyan",
"false_positive" => "grey",
_ => "white"
};
}
private static string GetVexStatusColor(string? vexStatus)
{
if (string.IsNullOrWhiteSpace(vexStatus)) return "grey";
return vexStatus.ToLowerInvariant() switch
{
"not_affected" => "green",
"affected" => "red",
"fixed" => "green",
"under_investigation" => "yellow",
_ => "grey"
};
}
// CLI-VULN-29-002: Vulnerability show handler
public static async Task HandleVulnShowAsync(
IServiceProvider services,
string vulnerabilityId,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-show");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.show", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln show");
using var duration = CliMetrics.MeasureCommandDuration("vuln show");
try
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
AnsiConsole.MarkupLine("[red]Error:[/] Vulnerability ID is required.");
Environment.ExitCode = 1;
return;
}
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Fetching vulnerability details: {VulnId}", vulnerabilityId);
var response = await client.GetVulnerabilityAsync(vulnerabilityId, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (response == null)
{
AnsiConsole.MarkupLine($"[yellow]Vulnerability not found:[/] {Markup.Escape(vulnerabilityId)}");
Environment.ExitCode = 1;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderVulnDetail(response);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get vulnerability details.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderVulnDetail(VulnDetailResponse vuln)
{
// Header panel with basic info
var severityColor = GetSeverityColor(vuln.Severity.Level);
var statusColor = GetVulnStatusColor(vuln.Status);
var vexColor = GetVexStatusColor(vuln.VexStatus);
var headerGrid = new Grid();
headerGrid.AddColumn();
headerGrid.AddColumn();
headerGrid.AddRow("[grey]Vulnerability ID:[/]", $"[bold]{Markup.Escape(vuln.VulnerabilityId)}[/]");
headerGrid.AddRow("[grey]Status:[/]", $"[{statusColor}]{Markup.Escape(vuln.Status)}[/]");
headerGrid.AddRow("[grey]Severity:[/]", $"[{severityColor}]{Markup.Escape(vuln.Severity.Level.ToUpperInvariant())}[/]" +
(vuln.Severity.Score.HasValue ? $" ({vuln.Severity.Score:F1})" : ""));
if (!string.IsNullOrWhiteSpace(vuln.VexStatus))
headerGrid.AddRow("[grey]VEX Status:[/]", $"[{vexColor}]{Markup.Escape(vuln.VexStatus)}[/]");
if (vuln.Aliases?.Count > 0)
headerGrid.AddRow("[grey]Aliases:[/]", Markup.Escape(string.Join(", ", vuln.Aliases)));
if (!string.IsNullOrWhiteSpace(vuln.Assignee))
headerGrid.AddRow("[grey]Assignee:[/]", Markup.Escape(vuln.Assignee));
if (vuln.DueDate.HasValue)
headerGrid.AddRow("[grey]Due Date:[/]", vuln.DueDate.Value.ToString("yyyy-MM-dd"));
if (vuln.PublishedAt.HasValue)
headerGrid.AddRow("[grey]Published:[/]", vuln.PublishedAt.Value.ToString("yyyy-MM-dd HH:mm UTC"));
if (vuln.UpdatedAt.HasValue)
headerGrid.AddRow("[grey]Updated:[/]", vuln.UpdatedAt.Value.ToString("yyyy-MM-dd HH:mm UTC"));
var headerPanel = new Panel(headerGrid)
{
Header = new PanelHeader("[bold]Vulnerability Details[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(headerPanel);
AnsiConsole.WriteLine();
// Summary/Description
if (!string.IsNullOrWhiteSpace(vuln.Summary) || !string.IsNullOrWhiteSpace(vuln.Description))
{
var descPanel = new Panel(Markup.Escape(vuln.Description ?? vuln.Summary ?? ""))
{
Header = new PanelHeader("[bold]Description[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(descPanel);
AnsiConsole.WriteLine();
}
// Affected Packages
if (vuln.AffectedPackages.Count > 0)
{
var pkgTable = new Table();
pkgTable.AddColumn("[bold]Package[/]");
pkgTable.AddColumn("[bold]Version[/]");
pkgTable.AddColumn("[bold]Fixed In[/]");
pkgTable.AddColumn("[bold]SBOM[/]");
foreach (var pkg in vuln.AffectedPackages)
{
var pkgName = pkg.Purl ?? pkg.Cpe ?? pkg.Name ?? "-";
pkgTable.AddRow(
Markup.Escape(pkgName.Length > 60 ? pkgName[..57] + "..." : pkgName),
Markup.Escape(pkg.Version ?? "-"),
Markup.Escape(pkg.FixedIn ?? "-"),
Markup.Escape(pkg.SbomId?.Length > 20 ? pkg.SbomId[..17] + "..." : pkg.SbomId ?? "-"));
}
AnsiConsole.MarkupLine("[bold]Affected Packages[/]");
AnsiConsole.Write(pkgTable);
AnsiConsole.WriteLine();
}
// Policy Rationale
if (vuln.PolicyRationale != null)
{
var rationaleGrid = new Grid();
rationaleGrid.AddColumn();
rationaleGrid.AddColumn();
rationaleGrid.AddRow("[grey]Policy:[/]", Markup.Escape($"{vuln.PolicyRationale.PolicyId} v{vuln.PolicyRationale.PolicyVersion}"));
if (!string.IsNullOrWhiteSpace(vuln.PolicyRationale.Summary))
rationaleGrid.AddRow("[grey]Summary:[/]", Markup.Escape(vuln.PolicyRationale.Summary));
if (vuln.PolicyRationale.Rules?.Count > 0)
{
var rulesText = string.Join("\n", vuln.PolicyRationale.Rules.Select(r =>
$" {r.Rule}: {r.Result}" + (r.Weight.HasValue ? $" (weight: {r.Weight:F2})" : "")));
rationaleGrid.AddRow("[grey]Rules:[/]", Markup.Escape(rulesText));
}
var rationalePanel = new Panel(rationaleGrid)
{
Header = new PanelHeader("[bold]Policy Rationale[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(rationalePanel);
AnsiConsole.WriteLine();
}
// Evidence
if (vuln.Evidence?.Count > 0)
{
var evidenceTable = new Table();
evidenceTable.AddColumn("[bold]Type[/]");
evidenceTable.AddColumn("[bold]Source[/]");
evidenceTable.AddColumn("[bold]Timestamp[/]");
foreach (var ev in vuln.Evidence.Take(10))
{
evidenceTable.AddRow(
Markup.Escape(ev.Type),
Markup.Escape(ev.Source),
ev.Timestamp?.ToString("yyyy-MM-dd HH:mm") ?? "-");
}
AnsiConsole.MarkupLine("[bold]Evidence[/]");
AnsiConsole.Write(evidenceTable);
if (vuln.Evidence.Count > 10)
AnsiConsole.MarkupLine($"[grey]... and {vuln.Evidence.Count - 10} more[/]");
AnsiConsole.WriteLine();
}
// Dependency Paths
if (vuln.DependencyPaths?.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Dependency Paths[/]");
foreach (var path in vuln.DependencyPaths.Take(5))
{
var pathStr = string.Join(" -> ", path.Path);
AnsiConsole.MarkupLine($" [grey]>[/] {Markup.Escape(pathStr.Length > 100 ? pathStr[..97] + "..." : pathStr)}");
}
if (vuln.DependencyPaths.Count > 5)
AnsiConsole.MarkupLine($" [grey]... and {vuln.DependencyPaths.Count - 5} more paths[/]");
AnsiConsole.WriteLine();
}
// Ledger (Workflow History)
if (vuln.Ledger?.Count > 0)
{
var ledgerTable = new Table();
ledgerTable.AddColumn("[bold]Timestamp[/]");
ledgerTable.AddColumn("[bold]Action[/]");
ledgerTable.AddColumn("[bold]Actor[/]");
ledgerTable.AddColumn("[bold]Status Change[/]");
foreach (var entry in vuln.Ledger.Take(10))
{
var statusChange = !string.IsNullOrWhiteSpace(entry.FromStatus) && !string.IsNullOrWhiteSpace(entry.ToStatus)
? $"{entry.FromStatus} -> {entry.ToStatus}"
: "-";
ledgerTable.AddRow(
entry.Timestamp.ToString("yyyy-MM-dd HH:mm"),
Markup.Escape(entry.Action),
Markup.Escape(entry.Actor ?? "-"),
Markup.Escape(statusChange));
}
AnsiConsole.MarkupLine("[bold]Workflow History[/]");
AnsiConsole.Write(ledgerTable);
if (vuln.Ledger.Count > 10)
AnsiConsole.MarkupLine($"[grey]... and {vuln.Ledger.Count - 10} more entries[/]");
AnsiConsole.WriteLine();
}
// References
if (vuln.References?.Count > 0)
{
AnsiConsole.MarkupLine("[bold]References[/]");
foreach (var refItem in vuln.References.Take(10))
{
var title = !string.IsNullOrWhiteSpace(refItem.Title) ? refItem.Title : refItem.Type;
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(title)}:[/] {Markup.Escape(refItem.Url)}");
}
if (vuln.References.Count > 10)
AnsiConsole.MarkupLine($" [grey]... and {vuln.References.Count - 10} more references[/]");
}
}
// CLI-VULN-29-003: Vulnerability workflow handler
public static async Task HandleVulnWorkflowAsync(
IServiceProvider services,
string action,
IReadOnlyList<string> vulnIds,
string? filterSeverity,
string? filterStatus,
string? filterPurl,
string? filterSbom,
string? tenant,
string? idempotencyKey,
bool emitJson,
bool verbose,
string? assignee,
string? comment,
string? justification,
string? dueDate,
string? fixVersion,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-workflow");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.workflow", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", $"vuln {action.Replace("_", "-")}");
activity?.SetTag("stellaops.cli.workflow.action", action);
using var duration = CliMetrics.MeasureCommandDuration($"vuln {action.Replace("_", "-")}");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Validate that we have either vulnIds or filter criteria
var hasVulnIds = vulnIds.Count > 0;
var hasFilter = !string.IsNullOrWhiteSpace(filterSeverity) ||
!string.IsNullOrWhiteSpace(filterStatus) ||
!string.IsNullOrWhiteSpace(filterPurl) ||
!string.IsNullOrWhiteSpace(filterSbom);
if (!hasVulnIds && !hasFilter)
{
AnsiConsole.MarkupLine("[red]Error:[/] Either --vuln-id or filter options (--filter-severity, --filter-status, --filter-purl, --filter-sbom) are required.");
Environment.ExitCode = 1;
return;
}
// Parse due date if provided
DateTimeOffset? parsedDueDate = null;
if (!string.IsNullOrWhiteSpace(dueDate))
{
if (DateTimeOffset.TryParse(dueDate, out var parsed))
{
parsedDueDate = parsed;
}
else
{
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid due date format: {Markup.Escape(dueDate)}. Use ISO-8601 format (e.g., 2025-12-31).");
Environment.ExitCode = 1;
return;
}
}
// Build filter spec if filters provided
VulnFilterSpec? filterSpec = hasFilter
? new VulnFilterSpec(
Severity: filterSeverity,
Status: filterStatus,
Purl: filterPurl,
SbomId: filterSbom)
: null;
// Build request
var request = new VulnWorkflowRequest(
Action: action,
VulnerabilityIds: hasVulnIds ? vulnIds.ToList() : null,
Filter: filterSpec,
Assignee: assignee,
Comment: comment,
DueDate: parsedDueDate,
Justification: justification,
FixVersion: fixVersion,
IdempotencyKey: idempotencyKey);
logger.LogDebug("Executing vulnerability workflow: action={Action}, vulnIds={VulnCount}, hasFilter={HasFilter}",
action, vulnIds.Count, hasFilter);
var response = await client.ExecuteVulnWorkflowAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, jsonOptions);
AnsiConsole.WriteLine(json);
Environment.ExitCode = response.Success ? 0 : 1;
return;
}
// Render result
var actionDisplay = action.Replace("_", " ");
if (response.Success)
{
AnsiConsole.MarkupLine($"[green]Success![/] {Markup.Escape(char.ToUpperInvariant(actionDisplay[0]) + actionDisplay[1..])} completed.");
}
else
{
AnsiConsole.MarkupLine($"[red]Operation completed with errors.[/]");
}
AnsiConsole.WriteLine();
var resultGrid = new Grid();
resultGrid.AddColumn();
resultGrid.AddColumn();
resultGrid.AddRow("[grey]Action:[/]", Markup.Escape(actionDisplay));
resultGrid.AddRow("[grey]Affected:[/]", response.AffectedCount.ToString());
if (!string.IsNullOrWhiteSpace(response.IdempotencyKey))
resultGrid.AddRow("[grey]Idempotency Key:[/]", Markup.Escape(response.IdempotencyKey));
AnsiConsole.Write(resultGrid);
// Show affected IDs if not too many
if (response.AffectedIds != null && response.AffectedIds.Count > 0 && response.AffectedIds.Count <= 20)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Affected Vulnerabilities:[/]");
foreach (var id in response.AffectedIds)
{
AnsiConsole.MarkupLine($" [grey]>[/] {Markup.Escape(id)}");
}
}
else if (response.AffectedIds != null && response.AffectedIds.Count > 20)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Affected {response.AffectedIds.Count} vulnerabilities (use --json to see full list)[/]");
}
// Show errors if any
if (response.Errors != null && response.Errors.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
var errorTable = new Table();
errorTable.AddColumn("[bold]Vulnerability ID[/]");
errorTable.AddColumn("[bold]Code[/]");
errorTable.AddColumn("[bold]Message[/]");
foreach (var error in response.Errors.Take(10))
{
errorTable.AddRow(
Markup.Escape(error.VulnerabilityId),
Markup.Escape(error.Code),
Markup.Escape(error.Message));
}
AnsiConsole.Write(errorTable);
if (response.Errors.Count > 10)
{
AnsiConsole.MarkupLine($"[grey]... and {response.Errors.Count - 10} more errors[/]");
}
}
Environment.ExitCode = response.Success ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to execute vulnerability workflow action.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
// CLI-VULN-29-004: Vulnerability simulate handler
public static async Task HandleVulnSimulateAsync(
IServiceProvider services,
string? policyId,
int? policyVersion,
IReadOnlyList<string> vexOverrides,
string? severityThreshold,
IReadOnlyList<string> sbomIds,
bool outputMarkdown,
bool changedOnly,
string? tenant,
bool emitJson,
string? outputFile,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln simulate");
using var duration = CliMetrics.MeasureCommandDuration("vuln simulate");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Parse VEX overrides
Dictionary<string, string>? parsedVexOverrides = null;
if (vexOverrides.Count > 0)
{
parsedVexOverrides = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var override_ in vexOverrides)
{
var parts = override_.Split('=', 2);
if (parts.Length != 2)
{
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid VEX override format: {Markup.Escape(override_)}. Use vulnId=status format.");
Environment.ExitCode = 1;
return;
}
parsedVexOverrides[parts[0].Trim()] = parts[1].Trim();
}
}
logger.LogDebug("Running vulnerability simulation: policyId={PolicyId}, policyVersion={PolicyVersion}, vexOverrides={OverrideCount}, sbomIds={SbomCount}",
policyId, policyVersion, vexOverrides.Count, sbomIds.Count);
var request = new VulnSimulationRequest(
PolicyId: policyId,
PolicyVersion: policyVersion,
VexOverrides: parsedVexOverrides,
SeverityThreshold: severityThreshold,
SbomIds: sbomIds.Count > 0 ? sbomIds.ToList() : null,
OutputMarkdown: outputMarkdown || !string.IsNullOrWhiteSpace(outputFile));
var response = await client.SimulateVulnerabilitiesAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, jsonOptions);
AnsiConsole.WriteLine(json);
Environment.ExitCode = 0;
return;
}
// Write markdown report to file if requested
if (!string.IsNullOrWhiteSpace(outputFile) && !string.IsNullOrWhiteSpace(response.MarkdownReport))
{
var outputDir = Path.GetDirectoryName(outputFile);
if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
await File.WriteAllTextAsync(outputFile, response.MarkdownReport, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Markdown report written to:[/] {Markup.Escape(outputFile)}");
AnsiConsole.WriteLine();
}
// Render summary panel
var summaryGrid = new Grid();
summaryGrid.AddColumn();
summaryGrid.AddColumn();
summaryGrid.AddRow("[grey]Total Evaluated:[/]", response.Summary.TotalEvaluated.ToString());
summaryGrid.AddRow("[grey]Total Changed:[/]", response.Summary.TotalChanged > 0
? $"[yellow]{response.Summary.TotalChanged}[/]"
: "[green]0[/]");
summaryGrid.AddRow("[grey]Status Upgrades:[/]", response.Summary.StatusUpgrades > 0
? $"[green]+{response.Summary.StatusUpgrades}[/]"
: "0");
summaryGrid.AddRow("[grey]Status Downgrades:[/]", response.Summary.StatusDowngrades > 0
? $"[red]-{response.Summary.StatusDowngrades}[/]"
: "0");
summaryGrid.AddRow("[grey]No Change:[/]", response.Summary.NoChange.ToString());
if (!string.IsNullOrWhiteSpace(policyId))
summaryGrid.AddRow("[grey]Policy:[/]", $"{Markup.Escape(policyId)}" + (policyVersion.HasValue ? $" v{policyVersion}" : ""));
if (!string.IsNullOrWhiteSpace(severityThreshold))
summaryGrid.AddRow("[grey]Severity Threshold:[/]", Markup.Escape(severityThreshold));
var summaryPanel = new Panel(summaryGrid)
{
Header = new PanelHeader("[bold]Simulation Summary[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(summaryPanel);
AnsiConsole.WriteLine();
// Render delta table
var items = changedOnly
? response.Items.Where(i => i.Changed).ToList()
: response.Items;
if (items.Count > 0)
{
var table = new Table();
table.AddColumn(new TableColumn("[bold]Vulnerability ID[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Before[/]").Centered());
table.AddColumn(new TableColumn("[bold]After[/]").Centered());
table.AddColumn(new TableColumn("[bold]Change[/]").Centered());
table.AddColumn(new TableColumn("[bold]Reason[/]").LeftAligned());
foreach (var item in items.Take(50))
{
var beforeColor = GetVulnStatusColor(item.BeforeStatus);
var afterColor = GetVulnStatusColor(item.AfterStatus);
var changeIndicator = item.Changed
? (IsStatusUpgrade(item.BeforeStatus, item.AfterStatus) ? "[green]UPGRADE[/]" : "[red]DOWNGRADE[/]")
: "[grey]--[/]";
table.AddRow(
Markup.Escape(item.VulnerabilityId),
$"[{beforeColor}]{Markup.Escape(item.BeforeStatus)}[/]",
$"[{afterColor}]{Markup.Escape(item.AfterStatus)}[/]",
changeIndicator,
Markup.Escape(item.ChangeReason ?? "-"));
}
AnsiConsole.Write(table);
if (items.Count > 50)
{
AnsiConsole.MarkupLine($"[grey]... and {items.Count - 50} more items (use --json for full list)[/]");
}
AnsiConsole.WriteLine();
}
else if (changedOnly)
{
AnsiConsole.MarkupLine("[green]No vulnerabilities would change status with the simulated configuration.[/]");
}
else
{
AnsiConsole.MarkupLine("[grey]No vulnerabilities in simulation scope.[/]");
}
// Print markdown to console if requested and not written to file
if (outputMarkdown && string.IsNullOrWhiteSpace(outputFile) && !string.IsNullOrWhiteSpace(response.MarkdownReport))
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Markdown Report:[/]");
AnsiConsole.WriteLine(response.MarkdownReport);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to run vulnerability simulation.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static bool IsStatusUpgrade(string before, string after)
{
// Status priority (lower is better): fixed > risk_accepted > false_positive > triaged > open
static int GetPriority(string status) => status.ToLowerInvariant() switch
{
"fixed" => 0,
"risk_accepted" => 1,
"false_positive" => 2,
"accepted" => 3,
"triaged" => 4,
"open" => 5,
_ => 10
};
return GetPriority(after) < GetPriority(before);
}
// CLI-VULN-29-005: Vulnerability export handler
public static async Task HandleVulnExportAsync(
IServiceProvider services,
IReadOnlyList<string> vulnIds,
IReadOnlyList<string> sbomIds,
string? policyId,
string format,
bool includeEvidence,
bool includeLedger,
bool signed,
string outputPath,
string? tenant,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln export");
using var duration = CliMetrics.MeasureCommandDuration("vuln export");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
if (string.IsNullOrWhiteSpace(outputPath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Output path is required.");
Environment.ExitCode = 1;
return;
}
var outputDir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrWhiteSpace(outputDir) && !Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
logger.LogDebug("Exporting vulnerability bundle: vulnIds={VulnCount}, sbomIds={SbomCount}, format={Format}, signed={Signed}",
vulnIds.Count, sbomIds.Count, format, signed);
var request = new VulnExportRequest(
VulnerabilityIds: vulnIds.Count > 0 ? vulnIds.ToList() : null,
SbomIds: sbomIds.Count > 0 ? sbomIds.ToList() : null,
PolicyId: policyId,
Format: format,
IncludeEvidence: includeEvidence,
IncludeLedger: includeLedger,
Signed: signed);
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Preparing export...", async ctx =>
{
var exportResponse = await client.ExportVulnerabilitiesAsync(request, effectiveTenant, cancellationToken).ConfigureAwait(false);
ctx.Status("Downloading export bundle...");
await using var downloadStream = await client.DownloadVulnExportAsync(exportResponse.ExportId, effectiveTenant, cancellationToken).ConfigureAwait(false);
await using var fileStream = File.Create(outputPath);
await downloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Export complete![/]");
AnsiConsole.WriteLine();
var resultGrid = new Grid();
resultGrid.AddColumn();
resultGrid.AddColumn();
resultGrid.AddRow("[grey]Output File:[/]", Markup.Escape(outputPath));
resultGrid.AddRow("[grey]Items Exported:[/]", exportResponse.ItemCount.ToString());
resultGrid.AddRow("[grey]Format:[/]", Markup.Escape(exportResponse.Format));
resultGrid.AddRow("[grey]Signed:[/]", exportResponse.Signed ? "[green]Yes[/]" : "[yellow]No[/]");
if (exportResponse.Signed)
{
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureAlgorithm))
resultGrid.AddRow("[grey]Signature Algorithm:[/]", Markup.Escape(exportResponse.SignatureAlgorithm));
if (!string.IsNullOrWhiteSpace(exportResponse.SignatureKeyId))
resultGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(exportResponse.SignatureKeyId));
}
if (!string.IsNullOrWhiteSpace(exportResponse.Digest))
{
var digestDisplay = exportResponse.Digest.Length > 32
? exportResponse.Digest[..32] + "..."
: exportResponse.Digest;
resultGrid.AddRow("[grey]Digest:[/]", $"{exportResponse.DigestAlgorithm ?? "sha256"}:{Markup.Escape(digestDisplay)}");
}
if (exportResponse.ExpiresAt.HasValue)
{
resultGrid.AddRow("[grey]Expires:[/]", exportResponse.ExpiresAt.Value.ToString("yyyy-MM-dd HH:mm UTC"));
}
AnsiConsole.Write(resultGrid);
});
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export vulnerability bundle.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
// CLI-VULN-29-005: Vulnerability export verify handler
public static async Task HandleVulnExportVerifyAsync(
IServiceProvider services,
string filePath,
string? expectedDigest,
string? publicKeyPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("vuln-export-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.vuln.export.verify", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "vuln export verify");
using var duration = CliMetrics.MeasureCommandDuration("vuln export verify");
try
{
if (string.IsNullOrWhiteSpace(filePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] File path is required.");
Environment.ExitCode = 1;
return;
}
if (!File.Exists(filePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {Markup.Escape(filePath)}");
Environment.ExitCode = 1;
return;
}
logger.LogDebug("Verifying vulnerability export: file={FilePath}, expectedDigest={Digest}", filePath, expectedDigest ?? "(none)");
// Calculate SHA-256 digest
string actualDigest;
await using (var fileStream = File.OpenRead(filePath))
{
using var sha256 = SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(fileStream, cancellationToken).ConfigureAwait(false);
actualDigest = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
var resultGrid = new Grid();
resultGrid.AddColumn();
resultGrid.AddColumn();
resultGrid.AddRow("[grey]File:[/]", Markup.Escape(filePath));
resultGrid.AddRow("[grey]Actual Digest:[/]", $"sha256:{Markup.Escape(actualDigest)}");
var digestValid = true;
if (!string.IsNullOrWhiteSpace(expectedDigest))
{
var normalizedExpected = expectedDigest.Trim().ToLowerInvariant();
if (normalizedExpected.StartsWith("sha256:"))
{
normalizedExpected = normalizedExpected[7..];
}
digestValid = string.Equals(actualDigest, normalizedExpected, StringComparison.OrdinalIgnoreCase);
resultGrid.AddRow("[grey]Expected Digest:[/]", $"sha256:{Markup.Escape(normalizedExpected)}");
resultGrid.AddRow("[grey]Digest Match:[/]", digestValid ? "[green]YES[/]" : "[red]NO[/]");
}
var sigStatus = "not_verified";
if (!string.IsNullOrWhiteSpace(publicKeyPath))
{
if (!File.Exists(publicKeyPath))
{
resultGrid.AddRow("[grey]Signature:[/]", $"[red]Public key not found:[/] {Markup.Escape(publicKeyPath)}");
}
else
{
// Look for .sig file
var sigPath = filePath + ".sig";
if (File.Exists(sigPath))
{
// Note: Actual signature verification would require cryptographic operations
// This is a placeholder that shows the structure
resultGrid.AddRow("[grey]Signature File:[/]", Markup.Escape(sigPath));
resultGrid.AddRow("[grey]Public Key:[/]", Markup.Escape(publicKeyPath));
resultGrid.AddRow("[grey]Signature Status:[/]", "[yellow]Verification requires runtime crypto support[/]");
sigStatus = "requires_verification";
}
else
{
resultGrid.AddRow("[grey]Signature:[/]", "[yellow]No .sig file found[/]");
sigStatus = "no_signature";
}
}
}
else
{
resultGrid.AddRow("[grey]Signature:[/]", "[grey]Skipped (no --public-key provided)[/]");
sigStatus = "skipped";
}
var panel = new Panel(resultGrid)
{
Header = new PanelHeader("[bold]Vulnerability Export Verification[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(panel);
if (!digestValid)
{
AnsiConsole.MarkupLine("[red]Verification FAILED: Digest mismatch[/]");
Environment.ExitCode = 1;
}
else if (sigStatus == "no_signature" && !string.IsNullOrWhiteSpace(publicKeyPath))
{
AnsiConsole.MarkupLine("[yellow]Warning: No signature file found for verification[/]");
Environment.ExitCode = 0;
}
else
{
AnsiConsole.MarkupLine("[green]Verification completed[/]");
Environment.ExitCode = 0;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify vulnerability export.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
#endregion
#region CLI-LNM-22-001: Advisory commands
// CLI-LNM-22-001: Handle advisory obs get
public static async Task HandleAdvisoryObsGetAsync(
IServiceProvider services,
string tenant,
IReadOnlyList<string> observationIds,
IReadOnlyList<string> aliases,
IReadOnlyList<string> purls,
IReadOnlyList<string> cpes,
IReadOnlyList<string> sources,
string? severity,
bool kevOnly,
bool? hasFix,
int? limit,
string? cursor,
bool emitJson,
bool emitOsv,
bool showConflicts,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advisory-obs");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.obs.get", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "advisory obs");
activity?.SetTag("stellaops.cli.tenant", tenant);
using var duration = CliMetrics.MeasureCommandDuration("advisory obs");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var query = new AdvisoryLinksetQuery(
tenant,
NormalizeSet(observationIds, toLower: false),
NormalizeSet(aliases, toLower: true),
NormalizeSet(purls, toLower: false),
NormalizeSet(cpes, toLower: false),
NormalizeSet(sources, toLower: true),
severity,
kevOnly ? true : null,
hasFix,
limit,
cursor);
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
if (response.Observations.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters.[/]");
Environment.ExitCode = 0;
return;
}
if (emitOsv)
{
var osvRecords = ConvertToOsv(response, tenant);
var osvJson = JsonSerializer.Serialize(osvRecords, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(osvJson);
Environment.ExitCode = 0;
return;
}
if (emitJson)
{
var jsonOutput = showConflicts
? JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true })
: JsonSerializer.Serialize(new { response.Observations, response.Linkset, response.NextCursor, response.HasMore }, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonOutput);
Environment.ExitCode = 0;
return;
}
RenderAdvisoryObservationTable(response);
if (showConflicts && response.Conflicts.Count > 0)
{
RenderConflictsTable(response.Conflicts);
}
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
var escapedCursor = Markup.Escape(response.NextCursor);
AnsiConsole.MarkupLine($"[yellow]More observations available. Continue with[/] [cyan]--cursor[/] [grey]{escapedCursor}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch advisory observations.");
Environment.ExitCode = 9; // ERR_AGG_* exit code
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var raw in values)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var normalized = raw.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
set.Add(normalized);
}
return set.Count == 0 ? Array.Empty<string>() : set.ToArray();
}
static void RenderAdvisoryObservationTable(AdvisoryLinksetResponse response)
{
var observations = response.Observations;
var table = new Table()
.Centered()
.Border(TableBorder.Rounded);
table.AddColumn("Observation");
table.AddColumn("Source");
table.AddColumn("Aliases");
table.AddColumn("Severity");
table.AddColumn("KEV");
table.AddColumn("Fix");
table.AddColumn("Created (UTC)");
foreach (var obs in observations)
{
var sourceVendor = obs.Source?.Vendor ?? "(unknown)";
var aliasesText = FormatList(obs.Linkset?.Aliases);
var severityText = obs.Severity?.Level ?? "(n/a)";
var kevText = obs.Kev?.Listed == true ? "[red]YES[/]" : "[grey]no[/]";
var fixText = obs.Fix?.Available == true ? "[green]available[/]" : "[grey]none[/]";
table.AddRow(
new Markup(Markup.Escape(obs.ObservationId)),
new Markup(Markup.Escape(sourceVendor)),
new Markup(Markup.Escape(aliasesText)),
new Markup(Markup.Escape(severityText)),
new Markup(kevText),
new Markup(fixText),
new Markup(Markup.Escape(obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
}
AnsiConsole.Write(table);
var linkset = response.Linkset;
var conflictCount = response.Conflicts?.Count ?? 0;
var summary = new StringBuilder();
summary.Append($"[green]{observations.Count}[/] observation(s). ");
summary.Append($"Aliases: [green]{linkset?.Aliases?.Count ?? 0}[/], ");
summary.Append($"PURLs: [green]{linkset?.Purls?.Count ?? 0}[/], ");
summary.Append($"CPEs: [green]{linkset?.Cpes?.Count ?? 0}[/]");
if (conflictCount > 0)
{
summary.Append($", [yellow]{conflictCount} conflict(s)[/]");
}
AnsiConsole.MarkupLine(summary.ToString());
}
static void RenderConflictsTable(IReadOnlyList<AdvisoryLinksetConflict> conflicts)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold yellow]Conflicts Detected:[/]");
var table = new Table()
.Centered()
.Border(TableBorder.Rounded);
table.AddColumn("Type");
table.AddColumn("Field");
table.AddColumn("Sources");
table.AddColumn("Resolution");
foreach (var conflict in conflicts)
{
var sourcesSummary = conflict.Sources.Count > 0
? string.Join(", ", conflict.Sources.Select(s => $"{s.Source}={s.Value}"))
: "(none)";
table.AddRow(
Markup.Escape(conflict.Type),
Markup.Escape(conflict.Field),
Markup.Escape(sourcesSummary.Length > 50 ? sourcesSummary[..47] + "..." : sourcesSummary),
Markup.Escape(conflict.Resolution ?? "(none)"));
}
AnsiConsole.Write(table);
}
static string FormatList(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return "(none)";
}
const int MaxItems = 3;
if (values.Count <= MaxItems)
{
return string.Join(", ", values);
}
var preview = values.Take(MaxItems);
return $"{string.Join(", ", preview)} (+{values.Count - MaxItems})";
}
static IReadOnlyList<OsvVulnerability> ConvertToOsv(AdvisoryLinksetResponse response, string tenant)
{
// Group observations by primary alias (CVE)
var groupedByAlias = new Dictionary<string, List<AdvisoryLinksetObservation>>(StringComparer.OrdinalIgnoreCase);
foreach (var obs in response.Observations)
{
var primaryAlias = obs.Linkset?.Aliases?.FirstOrDefault(a =>
a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)) ?? obs.ObservationId;
if (!groupedByAlias.TryGetValue(primaryAlias, out var list))
{
list = new List<AdvisoryLinksetObservation>();
groupedByAlias[primaryAlias] = list;
}
list.Add(obs);
}
var results = new List<OsvVulnerability>();
foreach (var kvp in groupedByAlias)
{
var primaryId = kvp.Key;
var observations = kvp.Value;
var representative = observations[0];
var allAliases = observations
.SelectMany(o => o.Linkset?.Aliases ?? Array.Empty<string>())
.Where(a => !string.Equals(a, primaryId, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var allPurls = observations
.SelectMany(o => o.Linkset?.Purls ?? Array.Empty<string>())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var allSources = observations
.Select(o => o.Source?.Vendor)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var severity = representative.Severity;
var severities = new List<OsvSeverity>();
if (severity?.CvssV3 is { } cvss3)
{
severities.Add(new OsvSeverity
{
Type = "CVSS_V3",
Score = cvss3.Vector ?? $"{cvss3.Score:F1}"
});
}
var affected = new List<OsvAffected>();
foreach (var purl in allPurls)
{
var (ecosystem, name) = ParsePurl(purl);
affected.Add(new OsvAffected
{
Package = new OsvPackage
{
Ecosystem = ecosystem,
Name = name,
Purl = purl
}
});
}
var references = observations
.SelectMany(o => o.Linkset?.References ?? Array.Empty<AdvisoryObservationReference>())
.Select(r => new OsvReference { Type = r.Type, Url = r.Url })
.DistinctBy(r => r.Url)
.ToList();
var kevInfo = representative.Kev;
var osv = new OsvVulnerability
{
Id = primaryId,
Modified = (representative.UpdatedAt ?? representative.CreatedAt).ToString("O", CultureInfo.InvariantCulture),
Published = representative.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
Aliases = allAliases,
Severity = severities,
Affected = affected,
References = references,
DatabaseSpecific = new OsvDatabaseSpecific
{
Source = "stellaops",
Kev = kevInfo is not null ? new OsvKevInfo
{
Listed = kevInfo.Listed,
AddedDate = kevInfo.AddedDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
DueDate = kevInfo.DueDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Ransomware = kevInfo.KnownRansomwareCampaignUse
} : null,
StellaOps = new OsvStellaOpsInfo
{
ObservationIds = observations.Select(o => o.ObservationId).ToList(),
Tenant = tenant,
Sources = allSources!,
HasConflicts = response.Conflicts?.Count > 0
}
}
};
results.Add(osv);
}
return results;
}
static (string ecosystem, string name) ParsePurl(string purl)
{
// Parse pkg:ecosystem/name@version format
if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return ("unknown", purl);
}
var withoutPrefix = purl[4..];
var slashIndex = withoutPrefix.IndexOf('/');
if (slashIndex < 0)
{
return ("unknown", withoutPrefix);
}
var ecosystem = withoutPrefix[..slashIndex];
var rest = withoutPrefix[(slashIndex + 1)..];
var atIndex = rest.IndexOf('@');
var name = atIndex >= 0 ? rest[..atIndex] : rest;
return (ecosystem, name);
}
}
// CLI-LNM-22-001: Handle advisory linkset show
public static async Task HandleAdvisoryLinksetShowAsync(
IServiceProvider services,
string tenant,
IReadOnlyList<string> aliases,
IReadOnlyList<string> purls,
IReadOnlyList<string> cpes,
IReadOnlyList<string> sources,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advisory-linkset");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.linkset.show", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "advisory linkset");
activity?.SetTag("stellaops.cli.tenant", tenant);
using var duration = CliMetrics.MeasureCommandDuration("advisory linkset");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var query = new AdvisoryLinksetQuery(
tenant,
Array.Empty<string>(),
NormalizeSet(aliases, toLower: true),
NormalizeSet(purls, toLower: false),
NormalizeSet(cpes, toLower: false),
NormalizeSet(sources, toLower: true),
Severity: null,
KevOnly: null,
HasFix: null,
Limit: null,
Cursor: null);
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOutput = JsonSerializer.Serialize(new
{
response.Linkset,
response.Conflicts,
TotalObservations = response.Observations.Count
}, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonOutput);
Environment.ExitCode = 0;
return;
}
RenderLinksetSummary(response);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch advisory linkset.");
Environment.ExitCode = 9;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var raw in values)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var normalized = raw.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
set.Add(normalized);
}
return set.Count == 0 ? Array.Empty<string>() : set.ToArray();
}
static void RenderLinksetSummary(AdvisoryLinksetResponse response)
{
var linkset = response.Linkset;
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[bold]Linkset Summary[/]", "");
grid.AddRow("[grey]Total Observations:[/]", $"[green]{response.Observations.Count}[/]");
grid.AddRow("[grey]Aliases:[/]", $"{linkset.Aliases.Count}");
grid.AddRow("[grey]PURLs:[/]", $"{linkset.Purls.Count}");
grid.AddRow("[grey]CPEs:[/]", $"{linkset.Cpes.Count}");
grid.AddRow("[grey]References:[/]", $"{linkset.References.Count}");
if (linkset.SourceCoverage is { } coverage)
{
grid.AddRow("[grey]Source Coverage:[/]", $"{coverage.CoveragePercent:F1}% ({coverage.TotalSources} sources)");
}
if (linkset.ConflictSummary is { } conflicts && conflicts.HasConflicts)
{
grid.AddRow("[yellow]Conflicts:[/]", $"[yellow]{conflicts.TotalConflicts} total[/]");
if (conflicts.SeverityConflicts > 0)
grid.AddRow("", $" Severity: {conflicts.SeverityConflicts}");
if (conflicts.KevConflicts > 0)
grid.AddRow("", $" KEV: {conflicts.KevConflicts}");
if (conflicts.FixConflicts > 0)
grid.AddRow("", $" Fix: {conflicts.FixConflicts}");
}
else
{
grid.AddRow("[grey]Conflicts:[/]", "[green]None[/]");
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Advisory Linkset[/]")
};
AnsiConsole.Write(panel);
// Show aliases
if (linkset.Aliases.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Aliases:[/]");
foreach (var alias in linkset.Aliases.Take(20))
{
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(alias)}[/]");
}
if (linkset.Aliases.Count > 20)
{
AnsiConsole.MarkupLine($" [grey]... and {linkset.Aliases.Count - 20} more[/]");
}
}
// Show PURLs
if (linkset.Purls.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Package URLs:[/]");
foreach (var purl in linkset.Purls.Take(10))
{
AnsiConsole.MarkupLine($" [blue]{Markup.Escape(purl)}[/]");
}
if (linkset.Purls.Count > 10)
{
AnsiConsole.MarkupLine($" [grey]... and {linkset.Purls.Count - 10} more[/]");
}
}
}
}
// CLI-LNM-22-001: Handle advisory export
public static async Task HandleAdvisoryExportAsync(
IServiceProvider services,
string tenant,
IReadOnlyList<string> aliases,
IReadOnlyList<string> purls,
IReadOnlyList<string> cpes,
IReadOnlyList<string> sources,
string? severity,
bool kevOnly,
bool? hasFix,
int? limit,
string format,
string? outputPath,
bool signed,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IConcelierObservationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advisory-export");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.export", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "advisory export");
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.format", format);
using var duration = CliMetrics.MeasureCommandDuration("advisory export");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var exportFormat = format?.ToLowerInvariant() switch
{
"osv" => AdvisoryExportFormat.Osv,
"ndjson" => AdvisoryExportFormat.Ndjson,
"csv" => AdvisoryExportFormat.Csv,
_ => AdvisoryExportFormat.Json
};
var query = new AdvisoryLinksetQuery(
tenant,
Array.Empty<string>(),
NormalizeSet(aliases, toLower: true),
NormalizeSet(purls, toLower: false),
NormalizeSet(cpes, toLower: false),
NormalizeSet(sources, toLower: true),
severity,
kevOnly ? true : null,
hasFix,
limit ?? 500,
Cursor: null);
var response = await client.GetLinksetAsync(query, cancellationToken).ConfigureAwait(false);
if (response.Observations.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No observations matched the provided filters. Nothing to export.[/]");
Environment.ExitCode = 0;
return;
}
var output = exportFormat switch
{
AdvisoryExportFormat.Osv => GenerateOsvExport(response, tenant),
AdvisoryExportFormat.Ndjson => GenerateNdjsonExport(response),
AdvisoryExportFormat.Csv => GenerateCsvExport(response),
_ => GenerateJsonExport(response)
};
if (!string.IsNullOrWhiteSpace(outputPath))
{
var fullPath = Path.GetFullPath(outputPath);
await File.WriteAllTextAsync(fullPath, output, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Exported {Count} observations to {Path} ({Format})", response.Observations.Count, fullPath, format);
AnsiConsole.MarkupLine($"[green]Exported[/] {response.Observations.Count} observations to [cyan]{Markup.Escape(fullPath)}[/]");
}
else
{
Console.WriteLine(output);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export advisory observations.");
Environment.ExitCode = 9;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static IReadOnlyList<string> NormalizeSet(IReadOnlyList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var raw in values)
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var normalized = raw.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
set.Add(normalized);
}
return set.Count == 0 ? Array.Empty<string>() : set.ToArray();
}
static string GenerateJsonExport(AdvisoryLinksetResponse response)
{
return JsonSerializer.Serialize(new
{
exportedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
count = response.Observations.Count,
observations = response.Observations,
linkset = response.Linkset,
conflicts = response.Conflicts
}, new JsonSerializerOptions { WriteIndented = true });
}
static string GenerateOsvExport(AdvisoryLinksetResponse response, string tenant)
{
var osvRecords = ConvertToOsvInternal(response, tenant);
return JsonSerializer.Serialize(osvRecords, new JsonSerializerOptions { WriteIndented = true });
}
static string GenerateNdjsonExport(AdvisoryLinksetResponse response)
{
var sb = new StringBuilder();
foreach (var obs in response.Observations)
{
sb.AppendLine(JsonSerializer.Serialize(obs));
}
return sb.ToString();
}
static string GenerateCsvExport(AdvisoryLinksetResponse response)
{
var sb = new StringBuilder();
sb.AppendLine("observation_id,tenant,source,upstream_id,aliases,severity,kev,has_fix,created_at");
foreach (var obs in response.Observations)
{
var aliases = obs.Linkset?.Aliases is { Count: > 0 } a ? string.Join(";", a) : "";
var severity = obs.Severity?.Level ?? "";
var kev = obs.Kev?.Listed == true ? "true" : "false";
var hasFix = obs.Fix?.Available == true ? "true" : "false";
sb.AppendLine($"\"{obs.ObservationId}\",\"{obs.Tenant}\",\"{obs.Source?.Vendor}\",\"{obs.Upstream?.UpstreamId}\",\"{aliases}\",\"{severity}\",{kev},{hasFix},{obs.CreatedAt:O}");
}
return sb.ToString();
}
static IReadOnlyList<OsvVulnerability> ConvertToOsvInternal(AdvisoryLinksetResponse response, string tenant)
{
var groupedByAlias = new Dictionary<string, List<AdvisoryLinksetObservation>>(StringComparer.OrdinalIgnoreCase);
foreach (var obs in response.Observations)
{
var primaryAlias = obs.Linkset?.Aliases?.FirstOrDefault(a =>
a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)) ?? obs.ObservationId;
if (!groupedByAlias.TryGetValue(primaryAlias, out var list))
{
list = new List<AdvisoryLinksetObservation>();
groupedByAlias[primaryAlias] = list;
}
list.Add(obs);
}
var results = new List<OsvVulnerability>();
foreach (var kvp in groupedByAlias)
{
var primaryId = kvp.Key;
var observations = kvp.Value;
var representative = observations[0];
var allAliases = observations
.SelectMany(o => o.Linkset?.Aliases ?? Array.Empty<string>())
.Where(a => !string.Equals(a, primaryId, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var osv = new OsvVulnerability
{
Id = primaryId,
Modified = (representative.UpdatedAt ?? representative.CreatedAt).ToString("O", CultureInfo.InvariantCulture),
Published = representative.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
Aliases = allAliases,
DatabaseSpecific = new OsvDatabaseSpecific
{
Source = "stellaops",
StellaOps = new OsvStellaOpsInfo
{
ObservationIds = observations.Select(o => o.ObservationId).ToList(),
Tenant = tenant,
HasConflicts = response.Conflicts?.Count > 0
}
}
};
results.Add(osv);
}
return results;
}
}
#endregion
#region CLI-FORENSICS-53-001: Forensic snapshot commands
// CLI-FORENSICS-53-001: Handle forensic snapshot create
public static async Task HandleForensicSnapshotCreateAsync(
IServiceProvider services,
string tenant,
string caseId,
string? description,
IReadOnlyList<string> tags,
IReadOnlyList<string> sbomIds,
IReadOnlyList<string> scanIds,
IReadOnlyList<string> policyIds,
IReadOnlyList<string> vulnIds,
int? retentionDays,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IForensicSnapshotClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-snapshot");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.snapshot.create", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "forensic snapshot create");
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.case_id", caseId);
using var duration = CliMetrics.MeasureCommandDuration("forensic snapshot create");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
if (string.IsNullOrWhiteSpace(caseId))
{
throw new InvalidOperationException("Case ID must be provided.");
}
var snapshotScope = new ForensicSnapshotScope(
SbomIds: sbomIds.Count > 0 ? sbomIds : null,
ScanIds: scanIds.Count > 0 ? scanIds : null,
PolicyIds: policyIds.Count > 0 ? policyIds : null,
VulnerabilityIds: vulnIds.Count > 0 ? vulnIds : null);
var request = new ForensicSnapshotCreateRequest(
caseId,
description,
tags.Count > 0 ? tags : null,
snapshotScope,
retentionDays);
logger.LogDebug("Creating forensic snapshot for case {CaseId} in tenant {Tenant}", caseId, tenant);
var snapshot = await client.CreateSnapshotAsync(tenant, request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.snapshot_id", snapshot.SnapshotId);
if (emitJson)
{
var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderSnapshotCreated(snapshot);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create forensic snapshot.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderSnapshotCreated(ForensicSnapshotDocument snapshot)
{
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[bold green]Forensic Snapshot Created[/]", "");
grid.AddRow("[grey]Snapshot ID:[/]", $"[cyan]{Markup.Escape(snapshot.SnapshotId)}[/]");
grid.AddRow("[grey]Case ID:[/]", Markup.Escape(snapshot.CaseId));
grid.AddRow("[grey]Status:[/]", GetStatusMarkup(snapshot.Status));
grid.AddRow("[grey]Created At:[/]", snapshot.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
if (snapshot.Manifest is { } manifest)
{
grid.AddRow("[grey]Manifest Digest:[/]", $"[yellow]{manifest.DigestAlgorithm}:{Markup.Escape(manifest.Digest)}[/]");
}
if (snapshot.ExpiresAt.HasValue)
{
grid.AddRow("[grey]Expires At:[/]", snapshot.ExpiresAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded
};
AnsiConsole.Write(panel);
}
}
// CLI-FORENSICS-53-001: Handle forensic snapshot list
public static async Task HandleForensicSnapshotListAsync(
IServiceProvider services,
string tenant,
string? caseId,
string? status,
IReadOnlyList<string> tags,
int? limit,
int? offset,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IForensicSnapshotClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-snapshot");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.snapshot.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "forensic list");
activity?.SetTag("stellaops.cli.tenant", tenant);
using var duration = CliMetrics.MeasureCommandDuration("forensic list");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
var query = new ForensicSnapshotListQuery(
tenant,
caseId,
status,
tags.Count > 0 ? tags : null,
CreatedAfter: null,
CreatedBefore: null,
limit,
offset);
var response = await client.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
if (response.Snapshots.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No forensic snapshots found matching the criteria.[/]");
Environment.ExitCode = 0;
return;
}
RenderSnapshotTable(response);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list forensic snapshots.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderSnapshotTable(ForensicSnapshotListResponse response)
{
var table = new Table()
.Centered()
.Border(TableBorder.Rounded);
table.AddColumn("Snapshot ID");
table.AddColumn("Case");
table.AddColumn("Status");
table.AddColumn("Artifacts");
table.AddColumn("Size");
table.AddColumn("Created (UTC)");
foreach (var snapshot in response.Snapshots)
{
var statusMarkup = GetStatusMarkup(snapshot.Status);
var artifactCount = snapshot.ArtifactCount?.ToString(CultureInfo.InvariantCulture) ?? "-";
var size = FormatSize(snapshot.SizeBytes);
table.AddRow(
new Markup(Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId)),
new Markup(Markup.Escape(snapshot.CaseId)),
new Markup(statusMarkup),
new Markup(Markup.Escape(artifactCount)),
new Markup(Markup.Escape(size)),
new Markup(Markup.Escape(snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[green]{response.Snapshots.Count}[/] of [green]{response.Total}[/] snapshot(s).");
if (response.HasMore)
{
AnsiConsole.MarkupLine("[yellow]More snapshots available. Use --limit and --offset to paginate.[/]");
}
}
}
// CLI-FORENSICS-53-001: Handle forensic snapshot show
public static async Task HandleForensicSnapshotShowAsync(
IServiceProvider services,
string tenant,
string snapshotId,
bool emitJson,
bool includeManifest,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IForensicSnapshotClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-snapshot");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.snapshot.show", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "forensic show");
activity?.SetTag("stellaops.cli.tenant", tenant);
activity?.SetTag("stellaops.cli.snapshot_id", snapshotId);
using var duration = CliMetrics.MeasureCommandDuration("forensic show");
try
{
tenant = tenant?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be provided.");
}
if (string.IsNullOrWhiteSpace(snapshotId))
{
throw new InvalidOperationException("Snapshot ID must be provided.");
}
var snapshot = await client.GetSnapshotAsync(tenant, snapshotId, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
AnsiConsole.MarkupLine($"[red]Forensic snapshot not found:[/] {Markup.Escape(snapshotId)}");
Environment.ExitCode = 4; // NOT_FOUND
return;
}
ForensicSnapshotManifest? manifest = null;
if (includeManifest)
{
manifest = await client.GetSnapshotManifestAsync(tenant, snapshotId, cancellationToken).ConfigureAwait(false);
}
if (emitJson)
{
var output = includeManifest
? new { snapshot, manifest }
: (object)snapshot;
var json = JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = 0;
return;
}
RenderSnapshotDetails(snapshot, manifest);
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to show forensic snapshot.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderSnapshotDetails(ForensicSnapshotDocument snapshot, ForensicSnapshotManifest? manifest)
{
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[bold]Forensic Snapshot Details[/]", "");
grid.AddRow("[grey]Snapshot ID:[/]", $"[cyan]{Markup.Escape(snapshot.SnapshotId)}[/]");
grid.AddRow("[grey]Case ID:[/]", Markup.Escape(snapshot.CaseId));
grid.AddRow("[grey]Tenant:[/]", Markup.Escape(snapshot.Tenant));
grid.AddRow("[grey]Status:[/]", GetStatusMarkup(snapshot.Status));
if (!string.IsNullOrWhiteSpace(snapshot.Description))
{
grid.AddRow("[grey]Description:[/]", Markup.Escape(snapshot.Description));
}
grid.AddRow("[grey]Created At:[/]", snapshot.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
if (snapshot.CompletedAt.HasValue)
{
grid.AddRow("[grey]Completed At:[/]", snapshot.CompletedAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (snapshot.ExpiresAt.HasValue)
{
grid.AddRow("[grey]Expires At:[/]", snapshot.ExpiresAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(snapshot.CreatedBy))
{
grid.AddRow("[grey]Created By:[/]", Markup.Escape(snapshot.CreatedBy));
}
if (snapshot.ArtifactCount.HasValue)
{
grid.AddRow("[grey]Artifact Count:[/]", snapshot.ArtifactCount.Value.ToString(CultureInfo.InvariantCulture));
}
if (snapshot.SizeBytes.HasValue)
{
grid.AddRow("[grey]Size:[/]", FormatSize(snapshot.SizeBytes));
}
if (snapshot.Tags.Count > 0)
{
grid.AddRow("[grey]Tags:[/]", string.Join(", ", snapshot.Tags.Select(t => $"[blue]{Markup.Escape(t)}[/]")));
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Snapshot[/]")
};
AnsiConsole.Write(panel);
// Manifest details
var snapshotManifest = manifest ?? snapshot.Manifest;
if (snapshotManifest is not null)
{
RenderManifestDetails(snapshotManifest, manifest is not null);
}
}
static void RenderManifestDetails(ForensicSnapshotManifest manifest, bool includeArtifacts)
{
AnsiConsole.MarkupLine("");
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[bold]Manifest[/]", "");
grid.AddRow("[grey]Manifest ID:[/]", Markup.Escape(manifest.ManifestId));
grid.AddRow("[grey]Version:[/]", Markup.Escape(manifest.Version));
grid.AddRow("[grey]Digest:[/]", $"[yellow]{manifest.DigestAlgorithm}:{Markup.Escape(manifest.Digest)}[/]");
if (manifest.Signature is { } sig)
{
grid.AddRow("[grey]Signed:[/]", "[green]YES[/]");
grid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sig.Algorithm));
if (!string.IsNullOrWhiteSpace(sig.KeyId))
{
grid.AddRow("[grey]Key ID:[/]", Markup.Escape(sig.KeyId));
}
if (sig.SignedAt.HasValue)
{
grid.AddRow("[grey]Signed At:[/]", sig.SignedAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
}
else
{
grid.AddRow("[grey]Signed:[/]", "[grey]NO[/]");
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded
};
AnsiConsole.Write(panel);
// Artifacts table
if (includeArtifacts && manifest.Artifacts.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Artifacts:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Type");
table.AddColumn("Path");
table.AddColumn("Digest");
table.AddColumn("Size");
foreach (var artifact in manifest.Artifacts)
{
var path = artifact.Path.Length > 30 ? "..." + artifact.Path[^27..] : artifact.Path;
var digest = artifact.Digest.Length > 16 ? artifact.Digest[..16] + "..." : artifact.Digest;
table.AddRow(
Markup.Escape(artifact.Type),
Markup.Escape(path),
$"[grey]{artifact.DigestAlgorithm}:[/]{Markup.Escape(digest)}",
FormatSize(artifact.SizeBytes));
}
AnsiConsole.Write(table);
}
// Chain of custody
if (manifest.Metadata?.ChainOfCustody is { Count: > 0 } chain)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Chain of Custody:[/]");
var table = new Table()
.Border(TableBorder.Simple);
table.AddColumn("Timestamp");
table.AddColumn("Action");
table.AddColumn("Actor");
table.AddColumn("Notes");
foreach (var entry in chain)
{
table.AddRow(
entry.Timestamp.ToString("u", CultureInfo.InvariantCulture),
Markup.Escape(entry.Action),
Markup.Escape(entry.Actor),
Markup.Escape(entry.Notes ?? "-"));
}
AnsiConsole.Write(table);
}
}
}
private static string GetStatusMarkup(string status)
{
return status?.ToLowerInvariant() switch
{
"ready" => "[green]ready[/]",
"pending" => "[yellow]pending[/]",
"creating" => "[blue]creating[/]",
"failed" => "[red]failed[/]",
"expired" => "[grey]expired[/]",
"archived" => "[grey]archived[/]",
_ => Markup.Escape(status ?? "(unknown)")
};
}
private static string FormatSize(long? bytes)
{
if (!bytes.HasValue || bytes.Value == 0)
{
return "-";
}
var size = bytes.Value;
string[] suffixes = ["B", "KB", "MB", "GB", "TB"];
var index = 0;
var value = (double)size;
while (value >= 1024 && index < suffixes.Length - 1)
{
value /= 1024;
index++;
}
return $"{value:F1} {suffixes[index]}";
}
// CLI-FORENSICS-54-001: Handle forensic verify command
public static async Task HandleForensicVerifyAsync(
IServiceProvider services,
string bundlePath,
bool emitJson,
string? trustRootPath,
bool verifyChecksums,
bool verifySignatures,
bool verifyChain,
bool strictTimeline,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var verifier = scope.ServiceProvider.GetRequiredService<IForensicVerifier>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.verify", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "forensic verify");
activity?.SetTag("stellaops.cli.bundle_path", bundlePath);
using var duration = CliMetrics.MeasureCommandDuration("forensic verify");
try
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
throw new InvalidOperationException("Bundle path must be provided.");
}
var options = new ForensicVerificationOptions
{
VerifyChecksums = verifyChecksums,
VerifySignatures = verifySignatures,
VerifyChainOfCustody = verifyChain,
StrictTimeline = strictTimeline,
TrustRootPath = trustRootPath
};
var result = await verifier.VerifyBundleAsync(bundlePath, options, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = result.IsValid ? 0 : 12; // Exit code 12 for forensic verification errors
return;
}
RenderVerificationResult(result, verbose);
Environment.ExitCode = result.IsValid ? 0 : 12;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify forensic bundle.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderVerificationResult(ForensicVerificationResult result, bool verbose)
{
var statusIcon = result.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"\n[bold]Forensic Bundle Verification: {statusIcon}[/]");
AnsiConsole.MarkupLine($"[grey]Bundle:[/] {Markup.Escape(result.BundlePath)}");
AnsiConsole.MarkupLine($"[grey]Verified At:[/] {result.VerifiedAt:u}");
AnsiConsole.MarkupLine("");
// Manifest verification
if (result.ManifestVerification is { } manifest)
{
var manifestStatus = manifest.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Manifest Verification:[/] {manifestStatus}");
AnsiConsole.MarkupLine($" [grey]ID:[/] {Markup.Escape(manifest.ManifestId)}");
AnsiConsole.MarkupLine($" [grey]Version:[/] {Markup.Escape(manifest.Version)}");
AnsiConsole.MarkupLine($" [grey]Digest ({manifest.DigestAlgorithm}):[/] {Markup.Escape(manifest.Digest)}");
if (!manifest.IsValid)
{
AnsiConsole.MarkupLine($" [grey]Computed:[/] [red]{Markup.Escape(manifest.ComputedDigest)}[/]");
}
AnsiConsole.MarkupLine($" [grey]Artifacts:[/] {manifest.ArtifactCount}");
AnsiConsole.MarkupLine("");
}
// Checksum verification
if (result.ChecksumVerification is { } checksums)
{
var checksumStatus = checksums.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Checksum Verification:[/] {checksumStatus}");
AnsiConsole.MarkupLine($" [grey]Total:[/] {checksums.TotalArtifacts}");
AnsiConsole.MarkupLine($" [grey]Verified:[/] [green]{checksums.VerifiedArtifacts}[/]");
if (checksums.FailedArtifacts.Count > 0)
{
AnsiConsole.MarkupLine($" [grey]Failed:[/] [red]{checksums.FailedArtifacts.Count}[/]");
if (verbose)
{
foreach (var failure in checksums.FailedArtifacts)
{
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(failure.ArtifactId)}:[/] {Markup.Escape(failure.Reason)}");
}
}
}
AnsiConsole.MarkupLine("");
}
// Signature verification
if (result.SignatureVerification is { } signatures)
{
var sigStatus = signatures.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Signature Verification:[/] {sigStatus}");
AnsiConsole.MarkupLine($" [grey]Signatures:[/] {signatures.SignatureCount}");
AnsiConsole.MarkupLine($" [grey]Verified:[/] [green]{signatures.VerifiedSignatures}[/]");
if (verbose && signatures.Signatures.Count > 0)
{
foreach (var sig in signatures.Signatures)
{
var sigIcon = sig.IsTrusted ? "[green]TRUSTED[/]" :
sig.IsValid ? "[yellow]VALID (UNTRUSTED)[/]" :
"[red]INVALID[/]";
AnsiConsole.MarkupLine($" - Key: {Markup.Escape(sig.KeyId)} {sigIcon}");
if (!string.IsNullOrWhiteSpace(sig.Reason))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(sig.Reason)}[/]");
}
}
}
AnsiConsole.MarkupLine("");
}
// Chain of custody verification
if (result.ChainOfCustodyVerification is { } chain)
{
var chainStatus = chain.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Chain of Custody Verification:[/] {chainStatus}");
AnsiConsole.MarkupLine($" [grey]Entries:[/] {chain.EntryCount}");
AnsiConsole.MarkupLine($" [grey]Timeline:[/] {(chain.TimelineValid ? "[green]VALID[/]" : "[red]INVALID[/]")}");
AnsiConsole.MarkupLine($" [grey]Signatures:[/] {(chain.SignaturesValid ? "[green]VALID[/]" : "[red]INVALID[/]")}");
if (chain.Gaps.Count > 0)
{
AnsiConsole.MarkupLine($" [grey]Gaps:[/] [yellow]{chain.Gaps.Count}[/]");
if (verbose)
{
foreach (var gap in chain.Gaps)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(gap.Description)}[/]");
}
}
}
if (verbose && chain.Entries.Count > 0)
{
AnsiConsole.MarkupLine("");
var table = new Table()
.Border(TableBorder.Simple);
table.AddColumn("#");
table.AddColumn("Timestamp");
table.AddColumn("Action");
table.AddColumn("Actor");
table.AddColumn("Sig");
foreach (var entry in chain.Entries)
{
var sigStatus = entry.SignatureValid switch
{
true => "[green]OK[/]",
false => "[red]BAD[/]",
null => "[grey]-[/]"
};
table.AddRow(
entry.Index.ToString(CultureInfo.InvariantCulture),
entry.Timestamp.ToString("u", CultureInfo.InvariantCulture),
Markup.Escape(entry.Action),
Markup.Escape(entry.Actor),
sigStatus);
}
AnsiConsole.Write(table);
}
AnsiConsole.MarkupLine("");
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]- [{Markup.Escape(error.Code)}][/] {Markup.Escape(error.Message)}");
if (!string.IsNullOrWhiteSpace(error.Detail))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(error.Detail)}[/]");
}
}
AnsiConsole.MarkupLine("");
}
// Warnings
if (result.Warnings.Count > 0)
{
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Summary
AnsiConsole.MarkupLine(result.IsValid
? "[bold green]All verification checks passed.[/]"
: "[bold red]Verification failed. See errors above.[/]");
}
}
// CLI-FORENSICS-54-002: Handle forensic attest show command
public static async Task HandleForensicAttestShowAsync(
IServiceProvider services,
string artifactPath,
bool emitJson,
string? trustRootPath,
bool verify,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var reader = scope.ServiceProvider.GetRequiredService<IAttestationReader>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("forensic-attest");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.forensic.attest.show", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "forensic attest show");
activity?.SetTag("stellaops.cli.artifact_path", artifactPath);
using var duration = CliMetrics.MeasureCommandDuration("forensic attest show");
try
{
if (string.IsNullOrWhiteSpace(artifactPath))
{
throw new InvalidOperationException("Artifact path must be provided.");
}
var options = new AttestationShowOptions
{
VerifySignatures = verify,
TrustRootPath = trustRootPath
};
var result = await reader.ReadAttestationAsync(artifactPath, options, cancellationToken)
.ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = (result.VerificationResult?.IsValid ?? true) ? 0 : 12;
return;
}
RenderAttestationResult(result, verbose);
Environment.ExitCode = (result.VerificationResult?.IsValid ?? true) ? 0 : 12;
}
catch (FileNotFoundException ex)
{
logger.LogError("Attestation file not found: {Path}", ex.FileName);
AnsiConsole.MarkupLine($"[red]Attestation file not found:[/] {Markup.Escape(ex.FileName ?? artifactPath)}");
Environment.ExitCode = 4;
}
catch (InvalidDataException ex)
{
logger.LogError(ex, "Invalid attestation format.");
AnsiConsole.MarkupLine($"[red]Invalid attestation format:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to read attestation.");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderAttestationResult(AttestationShowResult result, bool verbose)
{
AnsiConsole.MarkupLine($"\n[bold]Attestation Details[/]");
AnsiConsole.MarkupLine($"[grey]File:[/] {Markup.Escape(result.FilePath)}");
AnsiConsole.MarkupLine("");
// Basic info
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Payload Type:[/]", Markup.Escape(result.PayloadType));
grid.AddRow("[grey]Statement Type:[/]", Markup.Escape(result.StatementType));
grid.AddRow("[grey]Predicate Type:[/]", $"[cyan]{Markup.Escape(result.PredicateType)}[/]");
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Statement[/]")
};
AnsiConsole.Write(panel);
AnsiConsole.MarkupLine("");
// Subjects
if (result.Subjects.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Subjects:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Name");
table.AddColumn("Algorithm");
table.AddColumn("Digest");
foreach (var subject in result.Subjects)
{
var digest = subject.DigestValue.Length > 24
? subject.DigestValue[..24] + "..."
: subject.DigestValue;
table.AddRow(
Markup.Escape(subject.Name),
Markup.Escape(subject.DigestAlgorithm),
$"[grey]{Markup.Escape(digest)}[/]");
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("");
}
// Signatures
if (result.Signatures.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Signatures:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Key ID");
table.AddColumn("Algorithm");
table.AddColumn("Valid");
table.AddColumn("Trusted");
foreach (var sig in result.Signatures)
{
var keyId = sig.KeyId.Length > 20 ? sig.KeyId[..20] + "..." : sig.KeyId;
var validStatus = sig.IsValid switch
{
true => "[green]YES[/]",
false => "[red]NO[/]",
null => "[grey]-[/]"
};
var trustedStatus = sig.IsTrusted switch
{
true => "[green]YES[/]",
false => "[red]NO[/]",
null => "[grey]-[/]"
};
table.AddRow(
Markup.Escape(keyId),
Markup.Escape(sig.Algorithm),
validStatus,
trustedStatus);
}
AnsiConsole.Write(table);
if (verbose)
{
foreach (var sig in result.Signatures.Where(s => !string.IsNullOrWhiteSpace(s.Reason)))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(sig.KeyId)}:[/] {Markup.Escape(sig.Reason!)}");
}
}
AnsiConsole.MarkupLine("");
}
// Predicate summary
if (result.PredicateSummary is { } pred)
{
AnsiConsole.MarkupLine("[bold]Predicate Summary:[/]");
var predGrid = new Grid();
predGrid.AddColumn();
predGrid.AddColumn();
if (!string.IsNullOrWhiteSpace(pred.BuildType))
{
predGrid.AddRow("[grey]Build Type:[/]", Markup.Escape(pred.BuildType));
}
if (!string.IsNullOrWhiteSpace(pred.Builder))
{
predGrid.AddRow("[grey]Builder:[/]", Markup.Escape(pred.Builder));
}
if (pred.Timestamp.HasValue)
{
predGrid.AddRow("[grey]Timestamp:[/]", pred.Timestamp.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(pred.InvocationId))
{
predGrid.AddRow("[grey]Invocation ID:[/]", Markup.Escape(pred.InvocationId));
}
var predPanel = new Panel(predGrid)
{
Border = BoxBorder.Rounded
};
AnsiConsole.Write(predPanel);
// Materials
if (verbose && pred.Materials is { Count: > 0 })
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Materials:[/]");
foreach (var mat in pred.Materials)
{
var uri = mat.Uri.Length > 60 ? "..." + mat.Uri[^57..] : mat.Uri;
AnsiConsole.MarkupLine($" [grey]- {Markup.Escape(uri)}[/]");
if (mat.Digest is { Count: > 0 })
{
foreach (var (algo, digest) in mat.Digest)
{
var d = digest.Length > 16 ? digest[..16] + "..." : digest;
AnsiConsole.MarkupLine($" [grey]{algo}:[/] {d}");
}
}
}
}
// Metadata
if (verbose && pred.Metadata is { Count: > 0 })
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Metadata:[/]");
foreach (var (key, value) in pred.Metadata)
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}");
}
}
AnsiConsole.MarkupLine("");
}
// Verification result
if (result.VerificationResult is { } vr)
{
var vrStatus = vr.IsValid ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Verification:[/] {vrStatus}");
AnsiConsole.MarkupLine($" [grey]Signatures:[/] {vr.SignatureCount}");
AnsiConsole.MarkupLine($" [grey]Valid:[/] [green]{vr.ValidSignatures}[/]");
AnsiConsole.MarkupLine($" [grey]Trusted:[/] [green]{vr.TrustedSignatures}[/]");
if (vr.Errors.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
foreach (var error in vr.Errors)
{
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
}
}
}
}
}
#endregion
#region Promotion (CLI-PROMO-70-001)
// CLI-PROMO-70-001: Handle promotion assemble command
public static async Task HandlePromotionAssembleAsync(
IServiceProvider services,
string image,
string? sbomPath,
string? vexPath,
string fromEnvironment,
string toEnvironment,
string? actor,
string? pipeline,
string? ticket,
string? notes,
bool skipRekor,
string? outputPath,
bool emitJson,
string? tenant,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var assembler = scope.ServiceProvider.GetRequiredService<IPromotionAssembler>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("promotion-assemble");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.promotion.assemble", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "promotion assemble");
activity?.SetTag("stellaops.cli.image", image);
using var duration = CliMetrics.MeasureCommandDuration("promotion assemble");
try
{
if (string.IsNullOrWhiteSpace(image))
{
throw new InvalidOperationException("Image reference must be provided.");
}
var request = new PromotionAssembleRequest
{
Tenant = tenant ?? string.Empty,
Image = image,
SbomPath = sbomPath,
VexPath = vexPath,
FromEnvironment = fromEnvironment,
ToEnvironment = toEnvironment,
Actor = actor,
Pipeline = pipeline,
Ticket = ticket,
Notes = notes,
SkipRekor = skipRekor,
OutputPath = outputPath
};
var result = await assembler.AssembleAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = result.Success ? 0 : 1;
return;
}
RenderPromotionResult(result, verbose);
Environment.ExitCode = result.Success ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to assemble promotion attestation.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderPromotionResult(PromotionAssembleResult result, bool verbose)
{
var statusIcon = result.Success ? "[green]SUCCESS[/]" : "[red]FAILED[/]";
AnsiConsole.MarkupLine($"\n[bold]Promotion Attestation Assembly: {statusIcon}[/]");
AnsiConsole.MarkupLine("");
// Basic info
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Image Digest:[/]", $"sha256:{Markup.Escape(result.ImageDigest)}");
if (result.Predicate is { } pred)
{
grid.AddRow("[grey]From:[/]", Markup.Escape(pred.Promotion.From));
grid.AddRow("[grey]To:[/]", Markup.Escape(pred.Promotion.To));
grid.AddRow("[grey]Actor:[/]", Markup.Escape(pred.Promotion.Actor ?? "(not specified)"));
grid.AddRow("[grey]Timestamp:[/]", pred.Promotion.Timestamp.ToString("u", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(pred.Promotion.Pipeline))
{
grid.AddRow("[grey]Pipeline:[/]", Markup.Escape(pred.Promotion.Pipeline));
}
if (!string.IsNullOrWhiteSpace(pred.Promotion.Ticket))
{
grid.AddRow("[grey]Ticket:[/]", Markup.Escape(pred.Promotion.Ticket));
}
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Promotion Details[/]")
};
AnsiConsole.Write(panel);
AnsiConsole.MarkupLine("");
// Materials
if (result.Materials.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Materials:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Role");
table.AddColumn("Format");
table.AddColumn("Digest");
foreach (var mat in result.Materials)
{
var digest = mat.Digest.Length > 16 ? mat.Digest[..16] + "..." : mat.Digest;
table.AddRow(
Markup.Escape(mat.Role),
Markup.Escape(mat.Format ?? "unknown"),
$"[grey]{Markup.Escape(digest)}[/]");
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("");
}
// Rekor
if (result.RekorEntry is { } rekor)
{
AnsiConsole.MarkupLine("[bold]Rekor Entry:[/]");
AnsiConsole.MarkupLine($" [grey]UUID:[/] {Markup.Escape(rekor.Uuid)}");
AnsiConsole.MarkupLine($" [grey]Log Index:[/] {rekor.LogIndex}");
AnsiConsole.MarkupLine("");
}
// Output path
if (!string.IsNullOrWhiteSpace(result.OutputPath))
{
AnsiConsole.MarkupLine($"[bold]Output:[/] {Markup.Escape(result.OutputPath)}");
AnsiConsole.MarkupLine("");
}
// Warnings
if (result.Warnings.Count > 0)
{
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Summary
if (result.Success)
{
AnsiConsole.MarkupLine("[green]Promotion attestation assembled successfully.[/]");
AnsiConsole.MarkupLine("[grey]Use 'stella promotion attest' to sign and submit to Signer.[/]");
}
else
{
AnsiConsole.MarkupLine("[red]Promotion attestation assembly failed. See errors above.[/]");
}
}
}
// CLI-PROMO-70-002: Handle promotion attest command
public static async Task HandlePromotionAttestAsync(
IServiceProvider services,
string predicatePath,
string? keyId,
bool useKeyless,
bool uploadToRekor,
string? outputPath,
bool emitJson,
string? tenant,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var assembler = scope.ServiceProvider.GetRequiredService<IPromotionAssembler>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("promotion-attest");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.promotion.attest", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "promotion attest");
using var duration = CliMetrics.MeasureCommandDuration("promotion attest");
try
{
if (string.IsNullOrWhiteSpace(predicatePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Predicate file path is required.");
Environment.ExitCode = 1;
return;
}
var request = new PromotionAttestRequest
{
Tenant = tenant ?? string.Empty,
PredicatePath = predicatePath,
KeyId = keyId,
UseKeyless = useKeyless,
OutputPath = outputPath,
UploadToRekor = uploadToRekor
};
var result = await assembler.AttestAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = result.Success ? 0 : 1;
return;
}
RenderAttestResult(result, verbose);
Environment.ExitCode = result.Success ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to attest promotion.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderAttestResult(PromotionAttestResult result, bool verbose)
{
var statusIcon = result.Success ? "[green]SUCCESS[/]" : "[red]FAILED[/]";
AnsiConsole.MarkupLine($"\n[bold]Promotion Attestation: {statusIcon}[/]");
AnsiConsole.MarkupLine("");
if (result.Success)
{
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
if (!string.IsNullOrWhiteSpace(result.SignerKeyId))
{
grid.AddRow("[grey]Key ID:[/]", Markup.Escape(result.SignerKeyId));
}
if (result.SignedAt.HasValue)
{
grid.AddRow("[grey]Signed At:[/]", result.SignedAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(result.AuditId))
{
grid.AddRow("[grey]Audit ID:[/]", Markup.Escape(result.AuditId));
}
if (result.RekorEntry is { } rekor)
{
grid.AddRow("[grey]Rekor UUID:[/]", Markup.Escape(rekor.Uuid));
grid.AddRow("[grey]Rekor Index:[/]", rekor.LogIndex.ToString());
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Attestation Details[/]")
};
AnsiConsole.Write(panel);
AnsiConsole.MarkupLine("");
if (!string.IsNullOrWhiteSpace(result.BundlePath))
{
AnsiConsole.MarkupLine($"[bold]Bundle:[/] {Markup.Escape(result.BundlePath)}");
AnsiConsole.MarkupLine("");
}
AnsiConsole.MarkupLine("[green]Attestation signed successfully.[/]");
AnsiConsole.MarkupLine("[grey]Use 'stella promotion verify' to verify the bundle.[/]");
}
// Warnings
if (result.Warnings.Count > 0)
{
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
}
AnsiConsole.MarkupLine("");
}
}
}
// CLI-PROMO-70-002: Handle promotion verify command
public static async Task HandlePromotionVerifyAsync(
IServiceProvider services,
string bundlePath,
string? sbomPath,
string? vexPath,
string? trustRootPath,
string? checkpointPath,
bool skipSignature,
bool skipRekor,
bool emitJson,
string? tenant,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var assembler = scope.ServiceProvider.GetRequiredService<IPromotionAssembler>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("promotion-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.promotion.verify", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "promotion verify");
using var duration = CliMetrics.MeasureCommandDuration("promotion verify");
try
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
AnsiConsole.MarkupLine("[red]Error:[/] Bundle file path is required.");
Environment.ExitCode = 1;
return;
}
var request = new PromotionVerifyRequest
{
Tenant = tenant ?? string.Empty,
BundlePath = bundlePath,
SbomPath = sbomPath,
VexPath = vexPath,
TrustRootPath = trustRootPath,
CheckpointPath = checkpointPath,
SkipSignatureVerification = skipSignature,
SkipRekorVerification = skipRekor
};
var result = await assembler.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = result.Verified ? 0 : 1;
return;
}
RenderVerifyResult(result, verbose);
Environment.ExitCode = result.Verified ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify promotion attestation.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderVerifyResult(PromotionVerifyResult result, bool verbose)
{
var statusIcon = result.Verified ? "[green]VERIFIED[/]" : "[red]FAILED[/]";
AnsiConsole.MarkupLine($"\n[bold]Promotion Verification: {statusIcon}[/]");
AnsiConsole.MarkupLine("");
// Signature verification
if (result.SignatureVerification is { } sigV)
{
var sigStatus = sigV.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Signature:[/] {sigStatus}");
if (verbose)
{
var sigGrid = new Grid();
sigGrid.AddColumn();
sigGrid.AddColumn();
if (!string.IsNullOrWhiteSpace(sigV.KeyId))
{
sigGrid.AddRow("[grey]Key ID:[/]", Markup.Escape(sigV.KeyId));
}
if (!string.IsNullOrWhiteSpace(sigV.Algorithm))
{
sigGrid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sigV.Algorithm));
}
if (!string.IsNullOrWhiteSpace(sigV.CertSubject))
{
sigGrid.AddRow("[grey]Subject:[/]", Markup.Escape(sigV.CertSubject));
}
if (!string.IsNullOrWhiteSpace(sigV.CertIssuer))
{
sigGrid.AddRow("[grey]Issuer:[/]", Markup.Escape(sigV.CertIssuer));
}
if (sigV.ValidFrom.HasValue)
{
sigGrid.AddRow("[grey]Valid From:[/]", sigV.ValidFrom.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (sigV.ValidTo.HasValue)
{
sigGrid.AddRow("[grey]Valid To:[/]", sigV.ValidTo.Value.ToString("u", CultureInfo.InvariantCulture));
}
AnsiConsole.Write(sigGrid);
}
if (!sigV.Verified && !string.IsNullOrWhiteSpace(sigV.Error))
{
AnsiConsole.MarkupLine($" [red]{Markup.Escape(sigV.Error)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Material verification
if (result.MaterialVerification is { } matV)
{
var matStatus = matV.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Materials:[/] {matStatus}");
if (matV.Materials.Count > 0)
{
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Role");
table.AddColumn("Status");
table.AddColumn("Digest");
foreach (var mat in matV.Materials)
{
var status = mat.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
var digest = mat.ExpectedDigest.Length > 16 ? mat.ExpectedDigest[..16] + "..." : mat.ExpectedDigest;
table.AddRow(
Markup.Escape(mat.Role),
status,
$"[grey]{Markup.Escape(digest)}[/]");
}
AnsiConsole.Write(table);
}
AnsiConsole.MarkupLine("");
}
// Rekor verification
if (result.RekorVerification is { } rekorV)
{
var rekorStatus = rekorV.Verified ? "[green]PASS[/]" : "[red]FAIL[/]";
AnsiConsole.MarkupLine($"[bold]Rekor:[/] {rekorStatus}");
if (verbose)
{
var rekorGrid = new Grid();
rekorGrid.AddColumn();
rekorGrid.AddColumn();
if (!string.IsNullOrWhiteSpace(rekorV.Uuid))
{
rekorGrid.AddRow("[grey]UUID:[/]", Markup.Escape(rekorV.Uuid));
}
if (rekorV.LogIndex.HasValue)
{
rekorGrid.AddRow("[grey]Log Index:[/]", rekorV.LogIndex.Value.ToString());
}
rekorGrid.AddRow("[grey]Inclusion Proof:[/]", rekorV.InclusionProofVerified ? "[green]PASS[/]" : "[red]FAIL[/]");
rekorGrid.AddRow("[grey]Checkpoint:[/]", rekorV.CheckpointVerified ? "[green]PASS[/]" : "[red]FAIL[/]");
AnsiConsole.Write(rekorGrid);
}
if (!rekorV.Verified && !string.IsNullOrWhiteSpace(rekorV.Error))
{
AnsiConsole.MarkupLine($" [red]{Markup.Escape(rekorV.Error)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Predicate summary
if (verbose && result.Predicate is { } pred)
{
AnsiConsole.MarkupLine("[bold]Predicate Summary:[/]");
var predGrid = new Grid();
predGrid.AddColumn();
predGrid.AddColumn();
predGrid.AddRow("[grey]Type:[/]", Markup.Escape(pred.Type));
predGrid.AddRow("[grey]From:[/]", Markup.Escape(pred.Promotion.From));
predGrid.AddRow("[grey]To:[/]", Markup.Escape(pred.Promotion.To));
predGrid.AddRow("[grey]Actor:[/]", Markup.Escape(pred.Promotion.Actor ?? "(not specified)"));
predGrid.AddRow("[grey]Timestamp:[/]", pred.Promotion.Timestamp.ToString("u", CultureInfo.InvariantCulture));
AnsiConsole.Write(predGrid);
AnsiConsole.MarkupLine("");
}
// Warnings
if (result.Warnings.Count > 0)
{
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Summary
if (result.Verified)
{
AnsiConsole.MarkupLine("[green]Promotion attestation verified successfully.[/]");
}
else
{
AnsiConsole.MarkupLine("[red]Promotion attestation verification failed. See details above.[/]");
}
}
}
// CLI-DETER-70-003: Handle detscore run command
public static async Task HandleDetscoreRunAsync(
IServiceProvider services,
string[] images,
string scanner,
string? policyBundle,
string? feedsBundle,
int runs,
DateTimeOffset? fixedClock,
int rngSeed,
int maxConcurrency,
string memoryLimit,
string cpuSet,
string platform,
double imageThreshold,
double overallThreshold,
string? outputDir,
string? release,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var harness = scope.ServiceProvider.GetRequiredService<IDeterminismHarness>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("detscore-run");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.detscore.run", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "detscore run");
activity?.SetTag("stellaops.cli.images", images.Length);
activity?.SetTag("stellaops.cli.runs", runs);
using var duration = CliMetrics.MeasureCommandDuration("detscore run");
try
{
if (images.Length == 0)
{
var error = new CliError(
CliErrorCodes.DeterminismNoImages,
"At least one image must be provided for determinism testing.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = error.ExitCode;
return;
}
if (string.IsNullOrWhiteSpace(scanner))
{
var error = new CliError(
CliErrorCodes.DeterminismScannerMissing,
"Scanner image reference must be provided.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = error.ExitCode;
return;
}
var request = new DeterminismRunRequest
{
Images = images,
Scanner = scanner,
PolicyBundle = policyBundle,
FeedsBundle = feedsBundle,
Runs = runs,
FixedClock = fixedClock,
RngSeed = rngSeed,
MaxConcurrency = maxConcurrency,
MemoryLimit = memoryLimit,
CpuSet = cpuSet,
Platform = platform,
ImageThreshold = imageThreshold,
OverallThreshold = overallThreshold,
OutputDir = outputDir,
Release = release
};
if (!emitJson)
{
AnsiConsole.MarkupLine("[bold]Starting Determinism Harness[/]");
AnsiConsole.MarkupLine($" [grey]Images:[/] {images.Length}");
AnsiConsole.MarkupLine($" [grey]Scanner:[/] {Markup.Escape(scanner)}");
AnsiConsole.MarkupLine($" [grey]Runs per image:[/] {runs}");
AnsiConsole.MarkupLine($" [grey]Thresholds:[/] image >= {imageThreshold:P0}, overall >= {overallThreshold:P0}");
AnsiConsole.MarkupLine("");
}
var result = await harness.RunAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
Environment.ExitCode = result.PassedThreshold ? 0 : 13;
return;
}
RenderDetscoreResult(result, verbose);
if (!result.PassedThreshold)
{
var error = new CliError(
CliErrorCodes.DeterminismThresholdFailed,
$"Determinism score {result.Manifest?.OverallScore:P0} below threshold {overallThreshold:P0}");
Environment.ExitCode = error.ExitCode;
}
else
{
Environment.ExitCode = 0;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to run determinism harness.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
var error = new CliError(CliErrorCodes.DeterminismRunFailed, ex.Message);
Environment.ExitCode = error.ExitCode;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderDetscoreResult(DeterminismRunResult result, bool verbose)
{
var manifest = result.Manifest;
if (manifest == null)
{
AnsiConsole.MarkupLine("[red]No determinism manifest generated.[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
return;
}
var statusIcon = result.PassedThreshold ? "[green]PASSED[/]" : "[red]FAILED[/]";
AnsiConsole.MarkupLine($"\n[bold]Determinism Score: {statusIcon}[/]");
AnsiConsole.MarkupLine("");
// Summary info
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Release:[/]", Markup.Escape(manifest.Release));
grid.AddRow("[grey]Platform:[/]", Markup.Escape(manifest.Platform));
grid.AddRow("[grey]Overall Score:[/]", GetScoreMarkup(manifest.OverallScore, manifest.Thresholds.OverallMin));
grid.AddRow("[grey]Generated:[/]", manifest.GeneratedAt.ToString("u", CultureInfo.InvariantCulture));
grid.AddRow("[grey]Duration:[/]", $"{result.DurationMs / 1000.0:F1}s");
if (!string.IsNullOrWhiteSpace(manifest.ScannerSha))
{
var sha = manifest.ScannerSha.Length > 16 ? manifest.ScannerSha[..16] + "..." : manifest.ScannerSha;
grid.AddRow("[grey]Scanner SHA:[/]", $"[grey]{Markup.Escape(sha)}[/]");
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Determinism Summary[/]")
};
AnsiConsole.Write(panel);
AnsiConsole.MarkupLine("");
// Per-image results
AnsiConsole.MarkupLine("[bold]Per-Image Results:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Image");
table.AddColumn("Runs");
table.AddColumn("Identical");
table.AddColumn("Score");
table.AddColumn("Status");
foreach (var img in manifest.Images)
{
var digest = img.Digest.Length > 24 ? img.Digest[..24] + "..." : img.Digest;
var status = img.Score >= manifest.Thresholds.ImageMin
? "[green]PASS[/]"
: "[red]FAIL[/]";
var scoreColor = img.Score >= manifest.Thresholds.ImageMin ? "green" : "red";
table.AddRow(
$"[grey]{Markup.Escape(digest)}[/]",
img.Runs.ToString(),
img.Identical.ToString(),
$"[{scoreColor}]{img.Score:P0}[/{scoreColor}]",
status);
if (verbose && img.NonDeterministic.Count > 0)
{
table.AddRow(
"",
"[yellow]Non-det:[/]",
$"[yellow]{string.Join(", ", img.NonDeterministic)}[/]",
"",
"");
}
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("");
// Execution details
if (verbose && manifest.Execution is { } exec)
{
AnsiConsole.MarkupLine("[bold]Execution Parameters:[/]");
var execGrid = new Grid();
execGrid.AddColumn();
execGrid.AddColumn();
if (exec.FixedClock.HasValue)
{
execGrid.AddRow("[grey]Fixed Clock:[/]", exec.FixedClock.Value.ToString("u", CultureInfo.InvariantCulture));
}
execGrid.AddRow("[grey]RNG Seed:[/]", exec.RngSeed.ToString());
execGrid.AddRow("[grey]Max Concurrency:[/]", exec.MaxConcurrency.ToString());
execGrid.AddRow("[grey]Memory Limit:[/]", exec.MemoryLimit);
execGrid.AddRow("[grey]CPU Set:[/]", exec.CpuSet);
execGrid.AddRow("[grey]Network Mode:[/]", exec.NetworkMode);
AnsiConsole.Write(execGrid);
AnsiConsole.MarkupLine("");
}
// Output path
if (!string.IsNullOrWhiteSpace(result.OutputPath))
{
AnsiConsole.MarkupLine($"[bold]Output:[/] {Markup.Escape(result.OutputPath)}");
AnsiConsole.MarkupLine("");
}
// Warnings
if (result.Warnings.Count > 0)
{
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.MarkupLine("[bold red]Errors:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]- {Markup.Escape(error)}[/]");
}
AnsiConsole.MarkupLine("");
}
// Summary
if (result.PassedThreshold)
{
AnsiConsole.MarkupLine($"[green]Determinism score {manifest.OverallScore:P0} meets threshold {manifest.Thresholds.OverallMin:P0}[/]");
}
else
{
AnsiConsole.MarkupLine($"[red]Determinism score {manifest.OverallScore:P0} below threshold {manifest.Thresholds.OverallMin:P0}[/]");
if (result.FailedImages.Count > 0)
{
AnsiConsole.MarkupLine($"[red]Failed images: {string.Join(", ", result.FailedImages.Select(i => i.Length > 16 ? i[..16] + "..." : i))}[/]");
}
}
}
static string GetScoreMarkup(double score, double threshold)
{
var color = score >= threshold ? "green" : "red";
return $"[{color}]{score:P0}[/{color}]";
}
}
// CLI-DETER-70-004: Handle detscore report command
public static async Task HandleDetscoreReportAsync(
IServiceProvider services,
string[] manifestPaths,
string format,
string? outputPath,
bool includeDetails,
string? title,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var harness = scope.ServiceProvider.GetRequiredService<IDeterminismHarness>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("detscore-report");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.detscore.report", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "detscore report");
activity?.SetTag("stellaops.cli.manifests", manifestPaths.Length);
activity?.SetTag("stellaops.cli.format", format);
using var duration = CliMetrics.MeasureCommandDuration("detscore report");
try
{
if (manifestPaths.Length == 0)
{
var error = new CliError(
CliErrorCodes.DeterminismManifestInvalid,
"At least one determinism.json manifest path must be provided.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = error.ExitCode;
return;
}
// Verify all files exist
var missingFiles = manifestPaths.Where(p => !File.Exists(p)).ToList();
if (missingFiles.Count > 0)
{
var error = new CliError(
CliErrorCodes.DeterminismManifestInvalid,
$"Manifest files not found: {string.Join(", ", missingFiles)}");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = error.ExitCode;
return;
}
var request = new DeterminismReportRequest
{
ManifestPaths = manifestPaths,
Format = format,
OutputPath = outputPath,
IncludeDetails = includeDetails,
Title = title
};
if (format != "json" && string.IsNullOrEmpty(outputPath))
{
AnsiConsole.MarkupLine("[bold]Generating Determinism Report[/]");
AnsiConsole.MarkupLine($" [grey]Manifests:[/] {manifestPaths.Length}");
AnsiConsole.MarkupLine($" [grey]Format:[/] {format}");
AnsiConsole.MarkupLine("");
}
var result = await harness.GenerateReportAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 13;
return;
}
// If output path specified, file was already written by harness
if (!string.IsNullOrWhiteSpace(result.OutputPath))
{
if (format != "json")
{
AnsiConsole.MarkupLine($"[green]Report written to:[/] {Markup.Escape(result.OutputPath)}");
RenderDetscoreReportSummary(result.Report!, verbose);
}
else
{
// For JSON format to file, also output JSON to stdout for piping
var json = JsonSerializer.Serialize(result.Report, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
}
else
{
// No output path - content was written to stdout by harness for markdown/csv
// For JSON, we output the structured result
if (format == "json")
{
var json = JsonSerializer.Serialize(result.Report, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
}
// Show warnings
if (result.Warnings.Count > 0 && format != "json")
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold yellow]Warnings:[/]");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(warning)}[/]");
}
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate determinism report.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 13;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
static void RenderDetscoreReportSummary(DeterminismReport report, bool verbose)
{
AnsiConsole.MarkupLine("");
var summary = report.Summary;
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Total Releases:[/]", summary.TotalReleases.ToString());
grid.AddRow("[grey]Total Images:[/]", summary.TotalImages.ToString());
grid.AddRow("[grey]Average Score:[/]", $"{summary.AverageScore:P1}");
grid.AddRow("[grey]Score Range:[/]", $"{summary.MinScore:P1} - {summary.MaxScore:P1}");
grid.AddRow("[grey]Passed:[/]", $"[green]{summary.PassedCount}[/]");
grid.AddRow("[grey]Failed:[/]", summary.FailedCount > 0 ? $"[red]{summary.FailedCount}[/]" : "0");
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader($"[bold]{Markup.Escape(report.Title)}[/]")
};
AnsiConsole.Write(panel);
if (summary.NonDeterministicArtifacts.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold yellow]Non-Deterministic Artifacts:[/]");
foreach (var artifact in summary.NonDeterministicArtifacts.Take(10))
{
AnsiConsole.MarkupLine($" [yellow]- {Markup.Escape(artifact)}[/]");
}
if (summary.NonDeterministicArtifacts.Count > 10)
{
AnsiConsole.MarkupLine($" [grey]... and {summary.NonDeterministicArtifacts.Count - 10} more[/]");
}
}
if (verbose && report.Releases.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Releases:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Release");
table.AddColumn("Platform");
table.AddColumn("Score");
table.AddColumn("Images");
table.AddColumn("Status");
foreach (var release in report.Releases)
{
var status = release.Passed ? "[green]PASS[/]" : "[red]FAIL[/]";
var scoreColor = release.Passed ? "green" : "red";
table.AddRow(
Markup.Escape(release.Release),
Markup.Escape(release.Platform),
$"[{scoreColor}]{release.OverallScore:P1}[/{scoreColor}]",
release.ImageCount.ToString(),
status);
}
AnsiConsole.Write(table);
}
}
}
// CLI-OBS-51-001: Handle obs top command
public static async Task HandleObsTopAsync(
IServiceProvider services,
string[] serviceNames,
string? tenant,
int refreshInterval,
bool includeQueues,
int maxAlerts,
string outputFormat,
bool offline,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-top");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.top", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "obs top");
activity?.SetTag("stellaops.cli.refresh", refreshInterval);
using var duration = CliMetrics.MeasureCommandDuration("obs top");
try
{
// Offline mode check
if (offline)
{
var error = new CliError(
CliErrorCodes.ObsOfflineViolation,
"Offline mode specified but obs top requires network access to fetch metrics.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = 5; // Per CLI guide: offline violation = 5
return;
}
var request = new ObsTopRequest
{
Services = serviceNames,
Tenant = tenant,
IncludeQueues = includeQueues,
RefreshInterval = refreshInterval,
MaxAlerts = maxAlerts
};
// Single fetch or streaming mode
if (refreshInterval <= 0)
{
await FetchAndRenderObsTop(client, request, outputFormat, verbose, cancellationToken).ConfigureAwait(false);
}
else
{
// Streaming mode with live updates
await StreamObsTop(client, request, outputFormat, verbose, refreshInterval, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch observability data.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 14;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static async Task FetchAndRenderObsTop(
IObservabilityClient client,
ObsTopRequest request,
string outputFormat,
bool verbose,
CancellationToken cancellationToken)
{
var result = await client.GetHealthSummaryAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 14;
return;
}
var summary = result.Summary!;
switch (outputFormat)
{
case "json":
var json = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
break;
case "ndjson":
// Output each service as a separate NDJSON line
foreach (var service in summary.Services)
{
var line = JsonSerializer.Serialize(service);
Console.WriteLine(line);
}
break;
default: // table
RenderObsTopTable(summary, verbose);
break;
}
Environment.ExitCode = 0;
}
private static async Task StreamObsTop(
IObservabilityClient client,
ObsTopRequest request,
string outputFormat,
bool verbose,
int intervalSeconds,
CancellationToken cancellationToken)
{
var isFirstRender = true;
while (!cancellationToken.IsCancellationRequested)
{
var result = await client.GetHealthSummaryAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken).ConfigureAwait(false);
continue;
}
var summary = result.Summary!;
if (outputFormat == "json" || outputFormat == "ndjson")
{
// For JSON streaming, output each fetch as a line
var json = JsonSerializer.Serialize(summary);
Console.WriteLine(json);
}
else
{
// Clear screen and render table
if (!isFirstRender)
{
AnsiConsole.Clear();
}
isFirstRender = false;
RenderObsTopTable(summary, verbose);
AnsiConsole.MarkupLine($"\n[grey]Refreshing every {intervalSeconds}s. Press Ctrl+C to stop.[/]");
}
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken).ConfigureAwait(false);
}
Environment.ExitCode = 0;
}
private static void RenderObsTopTable(PlatformHealthSummary summary, bool verbose)
{
// Overall status header
var statusColor = summary.OverallStatus switch
{
"healthy" => "green",
"degraded" => "yellow",
"unhealthy" => "red",
_ => "grey"
};
AnsiConsole.MarkupLine($"[bold]Platform Health: [{statusColor}]{summary.OverallStatus.ToUpperInvariant()}[/{statusColor}][/]");
AnsiConsole.MarkupLine($"[grey]Updated: {summary.Timestamp.ToString("u", CultureInfo.InvariantCulture)}[/]");
AnsiConsole.MarkupLine($"[grey]Error Budget: {summary.GlobalErrorBudget:P1} remaining[/]");
AnsiConsole.MarkupLine("");
// Services table
if (summary.Services.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Services:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Service");
table.AddColumn("Status");
table.AddColumn("Availability");
table.AddColumn("SLO Target");
table.AddColumn("Error Budget");
table.AddColumn("P95 Latency");
table.AddColumn("Burn Rate");
foreach (var service in summary.Services)
{
var svcStatusColor = service.Status switch
{
"healthy" => "green",
"degraded" => "yellow",
"unhealthy" => "red",
_ => "grey"
};
var availColor = service.Availability >= service.SloTarget ? "green" : "red";
var budgetColor = service.ErrorBudgetRemaining >= 0.5 ? "green" :
service.ErrorBudgetRemaining >= 0.2 ? "yellow" : "red";
var latency = service.Latency is not null
? $"{service.Latency.P95Ms:F0}ms"
: "-";
var latencyColor = service.Latency is { Breaching: true } ? "red" : "green";
var burnRate = service.BurnRate is not null
? $"{service.BurnRate.Current:F1}x"
: "-";
var burnColor = service.BurnRate?.AlertLevel switch
{
"critical" => "red",
"warning" => "yellow",
_ => "green"
};
table.AddRow(
Markup.Escape(service.Service),
$"[{svcStatusColor}]{service.Status.ToUpperInvariant()}[/{svcStatusColor}]",
$"[{availColor}]{service.Availability:P2}[/{availColor}]",
$"{service.SloTarget:P2}",
$"[{budgetColor}]{service.ErrorBudgetRemaining:P1}[/{budgetColor}]",
$"[{latencyColor}]{latency}[/{latencyColor}]",
$"[{burnColor}]{burnRate}[/{burnColor}]");
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("");
}
// Active alerts
if (summary.ActiveAlerts.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Active Alerts:[/]");
var alertTable = new Table()
.Border(TableBorder.Rounded);
alertTable.AddColumn("Severity");
alertTable.AddColumn("Service");
alertTable.AddColumn("Type");
alertTable.AddColumn("Message");
alertTable.AddColumn("Started");
foreach (var alert in summary.ActiveAlerts)
{
var severityColor = alert.Severity == "critical" ? "red" : "yellow";
var age = DateTimeOffset.UtcNow - alert.StartedAt;
var ageStr = age.TotalHours >= 1
? $"{age.TotalHours:F0}h ago"
: $"{age.TotalMinutes:F0}m ago";
alertTable.AddRow(
$"[{severityColor}]{alert.Severity.ToUpperInvariant()}[/{severityColor}]",
Markup.Escape(alert.Service),
Markup.Escape(alert.Type),
Markup.Escape(alert.Message.Length > 50 ? alert.Message[..47] + "..." : alert.Message),
ageStr);
}
AnsiConsole.Write(alertTable);
AnsiConsole.MarkupLine("");
}
// Queue health (verbose mode or if alerting)
if (verbose)
{
var allQueues = summary.Services
.SelectMany(s => s.Queues.Select(q => (Service: s.Service, Queue: q)))
.ToList();
if (allQueues.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Queue Health:[/]");
var queueTable = new Table()
.Border(TableBorder.Rounded);
queueTable.AddColumn("Service");
queueTable.AddColumn("Queue");
queueTable.AddColumn("Depth");
queueTable.AddColumn("Oldest");
queueTable.AddColumn("Throughput");
queueTable.AddColumn("Success");
foreach (var (svc, queue) in allQueues)
{
var depthColor = queue.Alerting ? "red" :
queue.Depth > queue.DepthThreshold * 0.8 ? "yellow" : "green";
queueTable.AddRow(
Markup.Escape(svc),
Markup.Escape(queue.Name),
$"[{depthColor}]{queue.Depth}[/{depthColor}]",
queue.OldestMessageAge.TotalMinutes > 0 ? $"{queue.OldestMessageAge.TotalMinutes:F0}m" : "-",
$"{queue.Throughput:F1}/s",
$"{queue.SuccessRate:P1}");
}
AnsiConsole.Write(queueTable);
}
}
}
// CLI-OBS-52-001: Handle obs trace command
public static async Task HandleObsTraceAsync(
IServiceProvider services,
string traceId,
string? tenant,
bool includeEvidence,
string outputFormat,
bool offline,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-trace");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.trace", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "obs trace");
activity?.SetTag("stellaops.cli.traceId", traceId);
using var duration = CliMetrics.MeasureCommandDuration("obs trace");
try
{
if (offline)
{
var error = new CliError(
CliErrorCodes.ObsOfflineViolation,
"Offline mode specified but obs trace requires network access.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = 5;
return;
}
if (string.IsNullOrWhiteSpace(traceId))
{
AnsiConsole.MarkupLine("[red]Error:[/] Trace ID is required.");
Environment.ExitCode = 1;
return;
}
// Echo trace ID on stderr for scripting
if (verbose)
{
Console.Error.WriteLine($"trace_id={traceId}");
}
var request = new ObsTraceRequest
{
TraceId = traceId,
Tenant = tenant,
IncludeEvidence = includeEvidence
};
var result = await client.GetTraceAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = result.Errors.Any(e => e.Contains("not found")) ? 4 : 14;
return;
}
var trace = result.Trace!;
if (outputFormat == "json")
{
var json = JsonSerializer.Serialize(trace, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
RenderTrace(trace, verbose);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch trace.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 14;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderTrace(DistributedTrace trace, bool verbose)
{
var statusColor = trace.Status == "error" ? "red" : "green";
AnsiConsole.MarkupLine($"[bold]Trace: {Markup.Escape(trace.TraceId)}[/]");
AnsiConsole.MarkupLine($"[grey]Status:[/] [{statusColor}]{trace.Status.ToUpperInvariant()}[/{statusColor}]");
AnsiConsole.MarkupLine($"[grey]Duration:[/] {trace.Duration.TotalMilliseconds:F0}ms");
AnsiConsole.MarkupLine($"[grey]Started:[/] {trace.StartTime.ToString("u", CultureInfo.InvariantCulture)}");
AnsiConsole.MarkupLine($"[grey]Services:[/] {string.Join(", ", trace.Services)}");
AnsiConsole.MarkupLine("");
// Spans table
if (trace.Spans.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Spans:[/]");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Service");
table.AddColumn("Operation");
table.AddColumn("Duration");
table.AddColumn("Status");
foreach (var span in trace.Spans.OrderBy(s => s.StartTime))
{
var spanStatusColor = span.Status == "error" ? "red" : "green";
table.AddRow(
Markup.Escape(span.ServiceName),
Markup.Escape(span.OperationName.Length > 40 ? span.OperationName[..37] + "..." : span.OperationName),
$"{span.Duration.TotalMilliseconds:F0}ms",
$"[{spanStatusColor}]{span.Status}[/{spanStatusColor}]");
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("");
}
// Evidence links
if (trace.EvidenceLinks.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Evidence Links:[/]");
var evidenceTable = new Table()
.Border(TableBorder.Rounded);
evidenceTable.AddColumn("Type");
evidenceTable.AddColumn("URI");
evidenceTable.AddColumn("Timestamp");
foreach (var link in trace.EvidenceLinks)
{
var uri = link.Uri.Length > 50 ? link.Uri[..47] + "..." : link.Uri;
evidenceTable.AddRow(
Markup.Escape(link.Type),
$"[link]{Markup.Escape(uri)}[/]",
link.Timestamp.ToString("u", CultureInfo.InvariantCulture));
}
AnsiConsole.Write(evidenceTable);
}
}
// CLI-OBS-52-001: Handle obs logs command
public static async Task HandleObsLogsAsync(
IServiceProvider services,
DateTimeOffset from,
DateTimeOffset to,
string? tenant,
string[] serviceNames,
string[] levels,
string? query,
int pageSize,
string? pageToken,
string outputFormat,
bool offline,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-logs");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.logs", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "obs logs");
using var duration = CliMetrics.MeasureCommandDuration("obs logs");
try
{
if (offline)
{
var error = new CliError(
CliErrorCodes.ObsOfflineViolation,
"Offline mode specified but obs logs requires network access.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
Environment.ExitCode = 5;
return;
}
// Validate time window (max 24h as guardrail)
var timeSpan = to - from;
if (timeSpan > TimeSpan.FromHours(24))
{
AnsiConsole.MarkupLine("[yellow]Warning:[/] Time window exceeds 24 hours. Results may be truncated.");
}
// Clamp page size
pageSize = Math.Clamp(pageSize, 1, 500);
var request = new ObsLogsRequest
{
From = from,
To = to,
Tenant = tenant,
Services = serviceNames,
Levels = levels,
Query = query,
PageSize = pageSize,
PageToken = pageToken
};
var result = await client.GetLogsAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 14;
return;
}
switch (outputFormat)
{
case "json":
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
break;
case "ndjson":
foreach (var log in result.Logs)
{
var line = JsonSerializer.Serialize(log);
Console.WriteLine(line);
}
break;
default: // table
RenderLogs(result, verbose);
break;
}
// Show pagination token if available
if (!string.IsNullOrWhiteSpace(result.NextPageToken) && outputFormat == "table")
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Next page: --page-token {Markup.Escape(result.NextPageToken)}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch logs.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 14;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderLogs(ObsLogsResult result, bool verbose)
{
if (result.Logs.Count == 0)
{
AnsiConsole.MarkupLine("[grey]No logs found for the specified time window.[/]");
return;
}
if (result.TotalCount.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Showing {result.Logs.Count} of {result.TotalCount} logs[/]");
}
else
{
AnsiConsole.MarkupLine($"[grey]Showing {result.Logs.Count} logs[/]");
}
AnsiConsole.MarkupLine("");
var table = new Table()
.Border(TableBorder.Rounded);
table.AddColumn("Timestamp");
table.AddColumn("Level");
table.AddColumn("Service");
table.AddColumn("Message");
if (verbose)
{
table.AddColumn("Trace ID");
}
foreach (var log in result.Logs)
{
var levelColor = log.Level switch
{
"error" => "red",
"warn" => "yellow",
"debug" => "grey",
_ => "white"
};
var message = log.Message.Length > 60 ? log.Message[..57] + "..." : log.Message;
if (verbose)
{
var traceId = log.TraceId ?? "-";
traceId = traceId.Length > 16 ? traceId[..16] + "..." : traceId;
table.AddRow(
log.Timestamp.ToString("HH:mm:ss.fff"),
$"[{levelColor}]{log.Level.ToUpperInvariant()}[/{levelColor}]",
Markup.Escape(log.Service),
Markup.Escape(message),
$"[grey]{Markup.Escape(traceId)}[/]");
}
else
{
table.AddRow(
log.Timestamp.ToString("HH:mm:ss.fff"),
$"[{levelColor}]{log.Level.ToUpperInvariant()}[/{levelColor}]",
Markup.Escape(log.Service),
Markup.Escape(message));
}
}
AnsiConsole.Write(table);
}
// CLI-OBS-55-001: Handle obs incident-mode enable command
public static async Task HandleObsIncidentModeEnableAsync(
IServiceProvider services,
string? tenant,
int ttlMinutes,
int retentionDays,
string? reason,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-incident-mode");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.incident-mode.enable", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "obs incident-mode enable");
using var duration = CliMetrics.MeasureCommandDuration("obs incident-mode enable");
try
{
var request = new IncidentModeEnableRequest
{
Tenant = tenant,
TtlMinutes = ttlMinutes,
RetentionExtensionDays = retentionDays,
Reason = reason
};
if (!emitJson)
{
AnsiConsole.MarkupLine("[bold yellow]Enabling incident mode...[/]");
}
var result = await client.EnableIncidentModeAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 14;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
RenderIncidentModeState(result.State!, "ENABLED", verbose);
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
{
AnsiConsole.MarkupLine($"\n[grey]Audit event: {Markup.Escape(result.AuditEventId)}[/]");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to enable incident mode.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 14;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
// CLI-OBS-55-001: Handle obs incident-mode disable command
public static async Task HandleObsIncidentModeDisableAsync(
IServiceProvider services,
string? tenant,
string? reason,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-incident-mode");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.incident-mode.disable", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "obs incident-mode disable");
using var duration = CliMetrics.MeasureCommandDuration("obs incident-mode disable");
try
{
var request = new IncidentModeDisableRequest
{
Tenant = tenant,
Reason = reason
};
if (!emitJson)
{
AnsiConsole.MarkupLine("[bold]Disabling incident mode...[/]");
}
var result = await client.DisableIncidentModeAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 14;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine("[green]Incident mode disabled.[/]");
if (result.PreviousState is { } prev)
{
AnsiConsole.MarkupLine($"[grey]Was enabled since: {prev.SetAt?.ToString("u", CultureInfo.InvariantCulture) ?? "unknown"}[/]");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
{
AnsiConsole.MarkupLine($"[grey]Audit event: {Markup.Escape(result.AuditEventId)}[/]");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to disable incident mode.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 14;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
// CLI-OBS-55-001: Handle obs incident-mode status command
public static async Task HandleObsIncidentModeStatusAsync(
IServiceProvider services,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IObservabilityClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("obs-incident-mode");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.obs.incident-mode.status", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "obs incident-mode status");
using var duration = CliMetrics.MeasureCommandDuration("obs incident-mode status");
try
{
var result = await client.GetIncidentModeStatusAsync(tenant, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 14;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result.State, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
var state = result.State!;
var statusLabel = state.Enabled ? "ENABLED" : "DISABLED";
RenderIncidentModeState(state, statusLabel, verbose);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get incident mode status.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 14;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderIncidentModeState(IncidentModeState state, string statusLabel, bool verbose)
{
var statusColor = state.Enabled ? "yellow" : "green";
AnsiConsole.MarkupLine($"[bold]Incident Mode: [{statusColor}]{statusLabel}[/{statusColor}][/]");
AnsiConsole.MarkupLine("");
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
if (!string.IsNullOrWhiteSpace(state.Tenant))
{
grid.AddRow("[grey]Tenant:[/]", Markup.Escape(state.Tenant));
}
if (state.SetAt.HasValue)
{
grid.AddRow("[grey]Enabled at:[/]", state.SetAt.Value.ToString("u", CultureInfo.InvariantCulture));
}
if (state.ExpiresAt.HasValue)
{
var remaining = state.ExpiresAt.Value - DateTimeOffset.UtcNow;
var expiresStr = remaining.TotalMinutes > 0
? $"{state.ExpiresAt.Value:u} ({remaining.TotalMinutes:F0}m remaining)"
: $"{state.ExpiresAt.Value:u} (expired)";
grid.AddRow("[grey]Expires at:[/]", expiresStr);
}
if (state.Enabled)
{
grid.AddRow("[grey]Retention extension:[/]", $"{state.RetentionExtensionDays} days");
}
if (!string.IsNullOrWhiteSpace(state.Actor) && verbose)
{
grid.AddRow("[grey]Actor:[/]", Markup.Escape(state.Actor));
}
if (!string.IsNullOrWhiteSpace(state.Source) && verbose)
{
grid.AddRow("[grey]Source:[/]", Markup.Escape(state.Source));
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Incident Mode Status[/]")
};
AnsiConsole.Write(panel);
if (state.Enabled)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[yellow]Effects while enabled:[/]");
AnsiConsole.MarkupLine(" - Extended retention for forensic bundles");
AnsiConsole.MarkupLine(" - Debug artefacts captured in evidence locker");
AnsiConsole.MarkupLine(" - 100% sampling rate for telemetry");
AnsiConsole.MarkupLine(" - `incident=true` tag on all logs/metrics/traces");
}
}
#endregion
#region Pack Commands (CLI-PACKS-42-001)
public static async Task HandlePackPlanAsync(
IServiceProvider services,
string packId,
string? version,
string? inputsPath,
bool dryRun,
string? outputPath,
string? tenant,
bool emitJson,
bool offline,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-plan");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.plan", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "pack plan");
activity?.SetTag("stellaops.cli.pack_id", packId);
using var duration = CliMetrics.MeasureCommandDuration("pack plan");
try
{
if (offline)
{
AnsiConsole.MarkupLine("[red]Error:[/] Pack plan requires network access.");
Environment.ExitCode = 15;
return;
}
if (string.IsNullOrWhiteSpace(packId))
{
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required.");
Environment.ExitCode = 15;
return;
}
// Load inputs if provided
Dictionary<string, object>? inputs = null;
if (!string.IsNullOrWhiteSpace(inputsPath))
{
var inputsFullPath = Path.GetFullPath(inputsPath);
if (!File.Exists(inputsFullPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Inputs file not found: {Markup.Escape(inputsFullPath)}");
Environment.ExitCode = 15;
return;
}
var inputsJson = await File.ReadAllTextAsync(inputsFullPath, cancellationToken).ConfigureAwait(false);
inputs = JsonSerializer.Deserialize<Dictionary<string, object>>(inputsJson);
}
var request = new PackPlanRequest
{
PackId = packId,
Version = version,
Inputs = inputs,
Tenant = tenant,
DryRun = dryRun,
ValidateOnly = dryRun
};
var result = await client.PlanAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
foreach (var ve in result.ValidationErrors)
{
var pathInfo = !string.IsNullOrWhiteSpace(ve.Path) ? $" at {ve.Path}" : "";
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ve.Code)}:[/] {Markup.Escape(ve.Message)}{pathInfo}");
}
Environment.ExitCode = 15;
return;
}
// Write output if path specified
if (!string.IsNullOrWhiteSpace(outputPath))
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(Path.GetFullPath(outputPath), json, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Plan written to {Path}.", Path.GetFullPath(outputPath));
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
RenderPackPlan(result, verbose);
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to plan pack.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 15;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePackRunAsync(
IServiceProvider services,
string packId,
string? version,
string? inputsPath,
string? planId,
bool wait,
int timeoutMinutes,
string[] labels,
string? outputPath,
string? tenant,
bool emitJson,
bool offline,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-run");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.run", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "pack run");
activity?.SetTag("stellaops.cli.pack_id", packId);
using var duration = CliMetrics.MeasureCommandDuration("pack run");
try
{
if (offline)
{
AnsiConsole.MarkupLine("[red]Error:[/] Pack run requires network access.");
Environment.ExitCode = 15;
return;
}
if (string.IsNullOrWhiteSpace(packId))
{
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required.");
Environment.ExitCode = 15;
return;
}
// Load inputs if provided
Dictionary<string, object>? inputs = null;
if (!string.IsNullOrWhiteSpace(inputsPath))
{
var inputsFullPath = Path.GetFullPath(inputsPath);
if (!File.Exists(inputsFullPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Inputs file not found: {Markup.Escape(inputsFullPath)}");
Environment.ExitCode = 15;
return;
}
var inputsJson = await File.ReadAllTextAsync(inputsFullPath, cancellationToken).ConfigureAwait(false);
inputs = JsonSerializer.Deserialize<Dictionary<string, object>>(inputsJson);
}
// Parse labels
var labelsDict = new Dictionary<string, string>();
foreach (var label in labels)
{
var parts = label.Split('=', 2);
if (parts.Length == 2)
{
labelsDict[parts[0]] = parts[1];
}
}
var request = new PackRunRequest
{
PackId = packId,
Version = version,
PlanId = planId,
Inputs = inputs,
Tenant = tenant,
Labels = labelsDict.Count > 0 ? labelsDict : null,
WaitForCompletion = wait,
TimeoutMinutes = timeoutMinutes
};
var result = await client.RunAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 15;
return;
}
// Write output if path specified
if (!string.IsNullOrWhiteSpace(outputPath))
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(Path.GetFullPath(outputPath), json, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Run result written to {Path}.", Path.GetFullPath(outputPath));
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
RenderPackRunStatus(result.Run!, verbose);
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
// Set exit code based on run status
var exitCode = result.Run?.Status switch
{
"succeeded" => 0,
"failed" => 15,
"waiting_approval" => 0, // Pending approval is not an error
_ => 0
};
Environment.ExitCode = exitCode;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to run pack.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 15;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePackPushAsync(
IServiceProvider services,
string path,
string? name,
string? version,
bool sign,
string? keyId,
bool force,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-push");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.push", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "pack push");
using var duration = CliMetrics.MeasureCommandDuration("pack push");
try
{
if (string.IsNullOrWhiteSpace(path))
{
AnsiConsole.MarkupLine("[red]Error:[/] Pack path is required.");
Environment.ExitCode = 15;
return;
}
var request = new PackPushRequest
{
PackPath = path,
Name = name,
Version = version,
Tenant = tenant,
Sign = sign,
KeyId = keyId,
Force = force
};
var result = await client.PushAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 15;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine("[green]Pack pushed successfully.[/]");
if (result.Pack is not null)
{
AnsiConsole.MarkupLine($" Pack: {Markup.Escape(result.Pack.Id)}");
AnsiConsole.MarkupLine($" Version: {Markup.Escape(result.Pack.Version)}");
}
if (!string.IsNullOrWhiteSpace(result.Digest))
{
AnsiConsole.MarkupLine($" Digest: {Markup.Escape(result.Digest)}");
}
if (!string.IsNullOrWhiteSpace(result.RekorLogId))
{
AnsiConsole.MarkupLine($" Rekor Log ID: {Markup.Escape(result.RekorLogId)}");
}
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to push pack.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 15;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePackPullAsync(
IServiceProvider services,
string packId,
string? version,
string? outputPath,
bool noVerify,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-pull");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.pull", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "pack pull");
activity?.SetTag("stellaops.cli.pack_id", packId);
using var duration = CliMetrics.MeasureCommandDuration("pack pull");
try
{
if (string.IsNullOrWhiteSpace(packId))
{
AnsiConsole.MarkupLine("[red]Error:[/] Pack ID is required.");
Environment.ExitCode = 15;
return;
}
var request = new PackPullRequest
{
PackId = packId,
Version = version,
OutputPath = outputPath,
Tenant = tenant,
Verify = !noVerify
};
var result = await client.PullAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
Environment.ExitCode = 15;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine("[green]Pack pulled successfully.[/]");
AnsiConsole.MarkupLine($" Output: {Markup.Escape(result.OutputPath ?? "unknown")}");
if (result.Verified)
{
AnsiConsole.MarkupLine(" [green]Signature verified[/]");
}
else if (!noVerify)
{
AnsiConsole.MarkupLine(" [yellow]Signature not verified[/]");
}
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to pull pack.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 15;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandlePackVerifyAsync(
IServiceProvider services,
string? path,
string? packId,
string? version,
string? digest,
bool noRekor,
bool noExpiry,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IPackClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("pack-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.pack.verify", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "pack verify");
using var duration = CliMetrics.MeasureCommandDuration("pack verify");
try
{
if (string.IsNullOrWhiteSpace(path) && string.IsNullOrWhiteSpace(packId))
{
AnsiConsole.MarkupLine("[red]Error:[/] Either --path or --pack-id is required.");
Environment.ExitCode = 15;
return;
}
var request = new PackVerifyRequest
{
PackPath = path,
PackId = packId,
Version = version,
Digest = digest,
Tenant = tenant,
CheckRekor = !noRekor,
CheckExpiry = !noExpiry
};
var result = await client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
foreach (var ve in result.ValidationErrors)
{
var pathInfo = !string.IsNullOrWhiteSpace(ve.Path) ? $" at {ve.Path}" : "";
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ve.Code)}:[/] {Markup.Escape(ve.Message)}{pathInfo}");
}
Environment.ExitCode = 15;
return;
}
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);
}
else
{
RenderPackVerifyResult(result, verbose);
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
var allValid = result.SignatureValid && result.DigestMatch && result.SchemaValid
&& (result.RekorVerified ?? true) && (result.CertificateValid ?? true);
Environment.ExitCode = allValid ? 0 : 15;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify pack.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 15;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderPackPlan(PackPlanResult result, bool verbose)
{
AnsiConsole.MarkupLine("[bold]Pack Execution Plan[/]");
AnsiConsole.MarkupLine("");
if (!string.IsNullOrWhiteSpace(result.PlanHash))
{
AnsiConsole.MarkupLine($"[grey]Plan Hash:[/] {Markup.Escape(result.PlanHash)}");
}
if (!string.IsNullOrWhiteSpace(result.PlanId))
{
AnsiConsole.MarkupLine($"[grey]Plan ID:[/] {Markup.Escape(result.PlanId)}");
}
if (result.EstimatedDuration.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Estimated Duration:[/] {result.EstimatedDuration.Value.TotalMinutes:F1} minutes");
}
AnsiConsole.MarkupLine("");
if (result.Steps.Count > 0)
{
var table = new Table();
table.AddColumn("Step");
table.AddColumn("Action");
table.AddColumn("Depends On");
if (verbose)
{
table.AddColumn("Condition");
}
foreach (var step in result.Steps)
{
var dependsOn = step.DependsOn.Count > 0 ? string.Join(", ", step.DependsOn) : "-";
var row = new List<string>
{
Markup.Escape(step.Name),
Markup.Escape(step.Action),
Markup.Escape(dependsOn)
};
if (verbose)
{
row.Add(Markup.Escape(step.Condition ?? "-"));
}
table.AddRow(row.ToArray());
}
AnsiConsole.Write(table);
}
if (result.RequiresApproval)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[yellow]This plan requires approval before execution.[/]");
if (result.ApprovalGates.Count > 0)
{
AnsiConsole.MarkupLine($" Approval gates: {string.Join(", ", result.ApprovalGates)}");
}
}
}
private static void RenderPackRunStatus(PackRunStatus status, bool verbose)
{
var statusColor = status.Status switch
{
"succeeded" => "green",
"failed" => "red",
"running" => "blue",
"waiting_approval" => "yellow",
"cancelled" => "grey",
_ => "white"
};
AnsiConsole.MarkupLine($"[bold]Pack Run: [{statusColor}]{status.Status.ToUpperInvariant()}[/{statusColor}][/]");
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Run ID:[/] {Markup.Escape(status.RunId)}");
AnsiConsole.MarkupLine($"[grey]Pack:[/] {Markup.Escape(status.PackId)} v{Markup.Escape(status.Version)}");
if (status.StartedAt.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Started:[/] {status.StartedAt.Value:u}");
}
if (status.Duration.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Duration:[/] {status.Duration.Value.TotalSeconds:F1}s");
}
if (!string.IsNullOrWhiteSpace(status.Actor) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Actor:[/] {Markup.Escape(status.Actor)}");
}
if (!string.IsNullOrWhiteSpace(status.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(status.AuditEventId)}");
}
if (!string.IsNullOrWhiteSpace(status.Error))
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(status.Error)}");
}
if (status.StepStatuses.Count > 0 && verbose)
{
AnsiConsole.MarkupLine("");
var table = new Table();
table.AddColumn("Step");
table.AddColumn("Status");
table.AddColumn("Duration");
foreach (var step in status.StepStatuses)
{
var stepColor = step.Status switch
{
"succeeded" => "green",
"failed" => "red",
"running" => "blue",
"skipped" => "grey",
_ => "white"
};
var durationStr = step.Duration.HasValue ? $"{step.Duration.Value.TotalSeconds:F1}s" : "-";
table.AddRow(
Markup.Escape(step.Name),
$"[{stepColor}]{Markup.Escape(step.Status)}[/{stepColor}]",
durationStr);
}
AnsiConsole.Write(table);
}
if (status.Artifacts.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Artifacts:[/]");
foreach (var artifact in status.Artifacts)
{
AnsiConsole.MarkupLine($" - {Markup.Escape(artifact.Name)} ({Markup.Escape(artifact.Type)})");
}
}
}
private static void RenderPackVerifyResult(PackVerifyResult result, bool verbose)
{
AnsiConsole.MarkupLine("[bold]Pack Verification Result[/]");
AnsiConsole.MarkupLine("");
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Signature:[/]", result.SignatureValid ? "[green]Valid[/]" : "[red]Invalid[/]");
grid.AddRow("[grey]Digest Match:[/]", result.DigestMatch ? "[green]Yes[/]" : "[red]No[/]");
grid.AddRow("[grey]Schema:[/]", result.SchemaValid ? "[green]Valid[/]" : "[red]Invalid[/]");
if (result.RekorVerified.HasValue)
{
grid.AddRow("[grey]Rekor Verified:[/]", result.RekorVerified.Value ? "[green]Yes[/]" : "[red]No[/]");
}
if (result.CertificateValid.HasValue)
{
grid.AddRow("[grey]Certificate:[/]", result.CertificateValid.Value ? "[green]Valid[/]" : "[red]Invalid[/]");
}
if (result.CertificateExpiry.HasValue && verbose)
{
var remaining = result.CertificateExpiry.Value - DateTimeOffset.UtcNow;
var expiryColor = remaining.TotalDays > 30 ? "green" : remaining.TotalDays > 7 ? "yellow" : "red";
grid.AddRow("[grey]Cert Expires:[/]", $"[{expiryColor}]{result.CertificateExpiry.Value:u}[/{expiryColor}]");
}
var panel = new Panel(grid)
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Verification Summary[/]")
};
AnsiConsole.Write(panel);
if (result.Pack is not null && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Pack:[/] {Markup.Escape(result.Pack.Id)} v{Markup.Escape(result.Pack.Version)}");
if (!string.IsNullOrWhiteSpace(result.Pack.Author))
{
AnsiConsole.MarkupLine($"[grey]Author:[/] {Markup.Escape(result.Pack.Author)}");
}
}
}
// CLI-PACKS-43-001: Advanced pack features handlers
internal static async Task<int> HandlePackRunsListAsync(
IServiceProvider services,
string? packId,
string? status,
string? actor,
DateTimeOffset? since,
DateTimeOffset? until,
int pageSize,
string? pageToken,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackRunListRequest
{
PackId = packId,
Status = status,
Actor = actor,
Since = since,
Until = until,
PageSize = pageSize,
PageToken = pageToken,
Tenant = tenant
};
var result = await client.ListRunsAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return 0;
}
if (result.Runs.Count == 0)
{
AnsiConsole.MarkupLine("[grey]No pack runs found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Run ID");
table.AddColumn("Pack");
table.AddColumn("Status");
table.AddColumn("Started");
table.AddColumn("Duration");
if (verbose)
{
table.AddColumn("Actor");
}
foreach (var run in result.Runs)
{
var statusColor = GetPackRunStatusColor(run.Status);
var duration = run.Duration.HasValue ? $"{run.Duration.Value.TotalSeconds:F0}s" : "-";
var row = new List<string>
{
Markup.Escape(run.RunId),
$"{Markup.Escape(run.PackId)}:{Markup.Escape(run.Version)}",
statusColor,
run.StartedAt?.ToString("u") ?? "-",
duration
};
if (verbose)
{
row.Add(Markup.Escape(run.Actor ?? "-"));
}
table.AddRow(row.ToArray());
}
AnsiConsole.Write(table);
if (result.TotalCount.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Showing {result.Runs.Count} of {result.TotalCount} runs[/]");
}
if (!string.IsNullOrWhiteSpace(result.NextPageToken))
{
AnsiConsole.MarkupLine($"[grey]More results available. Use --page-token {Markup.Escape(result.NextPageToken)}[/]");
}
return 0;
}
internal static async Task<int> HandlePackRunsShowAsync(
IServiceProvider services,
string runId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var result = await client.GetRunStatusAsync(runId, tenant, cancellationToken);
if (result is null)
{
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(new { error = "Run not found" }, JsonOutputOptions));
}
else
{
AnsiConsole.MarkupLine($"[red]Run '{Markup.Escape(runId)}' not found.[/]");
}
return 15;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return 0;
}
RenderPackRunStatus(result, verbose);
return 0;
}
internal static async Task<int> HandlePackRunsCancelAsync(
IServiceProvider services,
string runId,
string? reason,
bool force,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackCancelRequest
{
RunId = runId,
Tenant = tenant,
Reason = reason,
Force = force
};
var result = await client.CancelRunAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to cancel run:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
AnsiConsole.MarkupLine($"[green]Run '{Markup.Escape(runId)}' cancelled successfully.[/]");
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
return 0;
}
internal static async Task<int> HandlePackRunsPauseAsync(
IServiceProvider services,
string runId,
string? reason,
string? stepId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackApprovalPauseRequest
{
RunId = runId,
Tenant = tenant,
Reason = reason,
StepId = stepId
};
var result = await client.PauseForApprovalAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to pause run:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
AnsiConsole.MarkupLine($"[yellow]Run '{Markup.Escape(runId)}' paused for approval.[/]");
if (result.Run is not null)
{
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetPackRunStatusColor(result.Run.Status)}");
if (!string.IsNullOrWhiteSpace(result.Run.CurrentStep))
{
AnsiConsole.MarkupLine($"[grey]Paused at step:[/] {Markup.Escape(result.Run.CurrentStep)}");
}
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Resume with:[/] stella pack runs resume {Markup.Escape(runId)}");
return 0;
}
internal static async Task<int> HandlePackRunsResumeAsync(
IServiceProvider services,
string runId,
bool approve,
string? reason,
string? stepId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackApprovalResumeRequest
{
RunId = runId,
Tenant = tenant,
Approved = approve,
Reason = reason,
StepId = stepId
};
var result = await client.ResumeWithApprovalAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to resume run:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
if (approve)
{
AnsiConsole.MarkupLine($"[green]Run '{Markup.Escape(runId)}' approved and resumed.[/]");
}
else
{
AnsiConsole.MarkupLine($"[yellow]Run '{Markup.Escape(runId)}' rejected.[/]");
}
if (result.Run is not null)
{
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetPackRunStatusColor(result.Run.Status)}");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
return 0;
}
internal static async Task<int> HandlePackRunsLogsAsync(
IServiceProvider services,
string runId,
string? stepId,
int? tail,
DateTimeOffset? since,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackLogsRequest
{
RunId = runId,
Tenant = tenant,
StepId = stepId,
Tail = tail,
Since = since
};
var result = await client.GetLogsAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to get logs:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
if (result.Logs.Count == 0)
{
AnsiConsole.MarkupLine("[grey]No logs found.[/]");
return 0;
}
foreach (var log in result.Logs)
{
var levelColor = log.Level.ToLowerInvariant() switch
{
"error" => "red",
"warn" => "yellow",
"debug" => "grey",
_ => "white"
};
var stepInfo = verbose && !string.IsNullOrWhiteSpace(log.StepId) ? $"[{Markup.Escape(log.StepId)}] " : "";
var timestamp = verbose ? $"[grey]{log.Timestamp:HH:mm:ss}[/] " : "";
AnsiConsole.MarkupLine($"{timestamp}{stepInfo}[{levelColor}]{Markup.Escape(log.Message)}[/{levelColor}]");
}
return 0;
}
internal static async Task<int> HandlePackSecretsInjectAsync(
IServiceProvider services,
string runId,
string secretRef,
string provider,
string? envVar,
string? path,
string? stepId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackSecretInjectRequest
{
RunId = runId,
Tenant = tenant,
SecretRef = secretRef,
SecretProvider = provider,
TargetEnvVar = envVar,
TargetPath = path,
StepId = stepId
};
var result = await client.InjectSecretAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to inject secret:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
AnsiConsole.MarkupLine($"[green]Secret injected successfully.[/]");
AnsiConsole.MarkupLine($"[grey]Secret Ref:[/] {Markup.Escape(secretRef)}");
AnsiConsole.MarkupLine($"[grey]Provider:[/] {Markup.Escape(provider)}");
if (!string.IsNullOrWhiteSpace(envVar))
{
AnsiConsole.MarkupLine($"[grey]Environment Variable:[/] {Markup.Escape(envVar)}");
}
if (!string.IsNullOrWhiteSpace(path))
{
AnsiConsole.MarkupLine($"[grey]File Path:[/] {Markup.Escape(path)}");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
return 0;
}
internal static async Task<int> HandlePackCacheListAsync(
IServiceProvider services,
string? cacheDir,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackCacheRequest
{
Action = "list",
CacheDir = cacheDir
};
var result = await client.ManageCacheAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to list cache:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
if (result.Entries.Count == 0)
{
AnsiConsole.MarkupLine("[grey]Cache is empty.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Pack");
table.AddColumn("Version");
table.AddColumn("Size");
table.AddColumn("Cached At");
if (verbose)
{
table.AddColumn("Digest");
table.AddColumn("Verified");
}
foreach (var entry in result.Entries)
{
var row = new List<string>
{
Markup.Escape(entry.PackId),
Markup.Escape(entry.Version),
FormatBytes(entry.Size),
entry.CachedAt.ToString("u")
};
if (verbose)
{
row.Add(Markup.Escape(entry.Digest.Length > 12 ? entry.Digest[..12] + "..." : entry.Digest));
row.Add(entry.Verified ? "[green]Yes[/]" : "[yellow]No[/]");
}
table.AddRow(row.ToArray());
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine($"[grey]Total cache size:[/] {FormatBytes(result.TotalSize)}");
return 0;
}
internal static async Task<int> HandlePackCacheAddAsync(
IServiceProvider services,
string packId,
string? version,
string? cacheDir,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackCacheRequest
{
Action = "add",
PackId = packId,
Version = version,
CacheDir = cacheDir
};
var result = await client.ManageCacheAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to add to cache:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
AnsiConsole.MarkupLine($"[green]Pack added to cache.[/]");
AnsiConsole.MarkupLine($"[grey]Pack:[/] {Markup.Escape(packId)}");
if (!string.IsNullOrWhiteSpace(version))
{
AnsiConsole.MarkupLine($"[grey]Version:[/] {Markup.Escape(version)}");
}
return 0;
}
internal static async Task<int> HandlePackCachePruneAsync(
IServiceProvider services,
int? maxAgeDays,
long? maxSizeMb,
bool dryRun,
string? cacheDir,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IPackClient>();
var request = new PackCacheRequest
{
Action = dryRun ? "prune_preview" : "prune",
CacheDir = cacheDir,
MaxAge = maxAgeDays.HasValue ? TimeSpan.FromDays(maxAgeDays.Value) : null,
MaxSize = maxSizeMb.HasValue ? maxSizeMb.Value * 1024 * 1024 : null
};
var result = await client.ManageCacheAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 15;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to prune cache:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 15;
}
if (dryRun)
{
AnsiConsole.MarkupLine("[yellow]DRY RUN - No changes made[/]");
}
if (result.PrunedCount == 0)
{
AnsiConsole.MarkupLine("[grey]No packs to prune.[/]");
}
else
{
AnsiConsole.MarkupLine($"[green]{(dryRun ? "Would prune" : "Pruned")} {result.PrunedCount} pack(s).[/]");
AnsiConsole.MarkupLine($"[grey]Space {(dryRun ? "to be " : "")}freed:[/] {FormatBytes(result.PrunedSize)}");
}
AnsiConsole.MarkupLine($"[grey]Cache size {(dryRun ? "after prune" : "now")}:[/] {FormatBytes(result.TotalSize)}");
return 0;
}
private static string GetPackRunStatusColor(string status)
{
return status.ToLowerInvariant() switch
{
"pending" => "[yellow]pending[/]",
"running" => "[blue]running[/]",
"succeeded" => "[green]succeeded[/]",
"failed" => "[red]failed[/]",
"cancelled" => "[grey]cancelled[/]",
"waiting_approval" => "[yellow]waiting_approval[/]",
_ => Markup.Escape(status)
};
}
#endregion
#region Exceptions Commands (CLI-EXC-25-001)
/// <summary>
/// Handles 'stella exceptions list' command.
/// </summary>
internal static async Task<int> HandleExceptionsListAsync(
IServiceProvider services,
string? tenant,
string? vuln,
string? scopeType,
string? scopeValue,
string[]? status,
string? owner,
string? effectType,
DateTimeOffset? expiringBefore,
bool includeExpired,
int pageSize,
string? pageToken,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
var request = new ExceptionListRequest
{
Tenant = tenant,
Vuln = vuln,
ScopeType = scopeType,
ScopeValue = scopeValue,
Statuses = status?.Length > 0 ? status : null,
Owner = owner,
EffectType = effectType,
ExpiringBefore = expiringBefore,
IncludeExpired = includeExpired,
PageSize = pageSize,
PageToken = pageToken
};
try
{
var response = await client.ListAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
return 0;
}
if (response.Exceptions.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No exceptions found matching criteria.[/]");
return 0;
}
RenderExceptionList(response, verbose);
if (!string.IsNullOrWhiteSpace(response.NextPageToken))
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Next page token: {Markup.Escape(response.NextPageToken)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error listing exceptions: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella exceptions show' command.
/// </summary>
internal static async Task<int> HandleExceptionsShowAsync(
IServiceProvider services,
string exceptionId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
try
{
var exception = await client.GetAsync(exceptionId, tenant, cancellationToken);
if (exception is null)
{
var error = new CliError(CliErrorCodes.ExcNotFound, $"Exception '{exceptionId}' not found.");
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(exception, JsonOutputOptions));
return 0;
}
RenderExceptionDetail(exception, verbose);
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error fetching exception: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella exceptions create' command.
/// </summary>
internal static async Task<int> HandleExceptionsCreateAsync(
IServiceProvider services,
string tenant,
string vuln,
string scopeType,
string scopeValue,
string effectId,
string justification,
string owner,
DateTimeOffset? expiration,
string[]? evidence,
string? policyBinding,
bool stage,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
var evidenceRefs = evidence?.Select(e =>
{
// Format: type:uri[:digest]
var parts = e.Split(':', 3);
return new ExceptionEvidenceRef
{
Type = parts.Length > 0 ? parts[0] : "ticket",
Uri = parts.Length > 1 ? parts[1] : e,
Digest = parts.Length > 2 ? parts[2] : null
};
}).ToList();
var request = new ExceptionCreateRequest
{
Tenant = tenant,
Vuln = vuln,
Scope = new ExceptionScope
{
Type = scopeType,
Value = scopeValue
},
EffectId = effectId,
Justification = justification,
Owner = owner,
Expiration = expiration,
EvidenceRefs = evidenceRefs,
PolicyBinding = policyBinding,
Stage = stage
};
try
{
var result = await client.CreateAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 16;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to create exception:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 16;
}
AnsiConsole.MarkupLine("[green]Exception created successfully.[/]");
if (result.Exception is not null)
{
AnsiConsole.MarkupLine($"[grey]ID:[/] {Markup.Escape(result.Exception.Id)}");
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetStatusColor(result.Exception.Status)}");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error creating exception: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella exceptions promote' command.
/// </summary>
internal static async Task<int> HandleExceptionsPromoteAsync(
IServiceProvider services,
string exceptionId,
string? tenant,
string targetStatus,
string? comment,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
var request = new ExceptionPromoteRequest
{
ExceptionId = exceptionId,
Tenant = tenant,
TargetStatus = targetStatus,
Comment = comment
};
try
{
var result = await client.PromoteAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 16;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to promote exception:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 16;
}
AnsiConsole.MarkupLine("[green]Exception promoted successfully.[/]");
if (result.Exception is not null)
{
AnsiConsole.MarkupLine($"[grey]ID:[/] {Markup.Escape(result.Exception.Id)}");
AnsiConsole.MarkupLine($"[grey]New Status:[/] {GetStatusColor(result.Exception.Status)}");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error promoting exception: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella exceptions revoke' command.
/// </summary>
internal static async Task<int> HandleExceptionsRevokeAsync(
IServiceProvider services,
string exceptionId,
string? tenant,
string? reason,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
var request = new ExceptionRevokeRequest
{
ExceptionId = exceptionId,
Tenant = tenant,
Reason = reason
};
try
{
var result = await client.RevokeAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 16;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to revoke exception:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 16;
}
AnsiConsole.MarkupLine("[green]Exception revoked successfully.[/]");
if (result.Exception is not null)
{
AnsiConsole.MarkupLine($"[grey]ID:[/] {Markup.Escape(result.Exception.Id)}");
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetStatusColor(result.Exception.Status)}");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error revoking exception: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella exceptions import' command.
/// </summary>
internal static async Task<int> HandleExceptionsImportAsync(
IServiceProvider services,
string tenant,
string file,
bool stage,
string? source,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
if (!File.Exists(file))
{
var error = new CliError(CliErrorCodes.ExcImportFailed, $"File not found: {file}");
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
var request = new ExceptionImportRequest
{
Tenant = tenant,
Stage = stage,
Source = source
};
try
{
await using var stream = File.OpenRead(file);
var result = await client.ImportAsync(request, stream, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 16;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Import failed:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]Line {error.Line}: {Markup.Escape(error.Message)}[/]");
if (!string.IsNullOrWhiteSpace(error.Field))
{
AnsiConsole.MarkupLine($" [grey]Field: {Markup.Escape(error.Field)}[/]");
}
}
return 16;
}
AnsiConsole.MarkupLine("[green]Import completed successfully.[/]");
AnsiConsole.MarkupLine($"[grey]Imported:[/] {result.Imported}");
AnsiConsole.MarkupLine($"[grey]Skipped:[/] {result.Skipped}");
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error importing exceptions: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella exceptions export' command.
/// </summary>
internal static async Task<int> HandleExceptionsExportAsync(
IServiceProvider services,
string? tenant,
string[]? status,
string format,
string output,
bool includeManifest,
bool signed,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IExceptionClient>();
var request = new ExceptionExportRequest
{
Tenant = tenant,
Statuses = status?.Length > 0 ? status : null,
Format = format,
IncludeManifest = includeManifest,
Signed = signed
};
try
{
var (content, manifest) = await client.ExportAsync(request, cancellationToken);
// Write content to output file
await using (var fileStream = File.Create(output))
{
await content.CopyToAsync(fileStream, cancellationToken);
}
if (json && manifest is not null)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(manifest, JsonOutputOptions));
return 0;
}
AnsiConsole.MarkupLine("[green]Export completed successfully.[/]");
AnsiConsole.MarkupLine($"[grey]Output:[/] {Markup.Escape(output)}");
if (manifest is not null)
{
AnsiConsole.MarkupLine($"[grey]Count:[/] {manifest.Count}");
AnsiConsole.MarkupLine($"[grey]SHA256:[/] {Markup.Escape(manifest.Sha256)}");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Generated:[/] {manifest.GeneratedAt:u}");
if (!string.IsNullOrWhiteSpace(manifest.SignatureUri))
{
AnsiConsole.MarkupLine($"[grey]Signature:[/] {Markup.Escape(manifest.SignatureUri)}");
}
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error exporting exceptions: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
private static void RenderExceptionList(ExceptionListResponse response, bool verbose)
{
var table = new Table();
table.Border = TableBorder.Rounded;
table.AddColumn("ID");
table.AddColumn("Vuln");
table.AddColumn("Scope");
table.AddColumn("Effect");
table.AddColumn("Status");
table.AddColumn("Owner");
table.AddColumn("Expires");
foreach (var exc in response.Exceptions)
{
var scopeText = $"{exc.Scope.Type}:{Truncate(exc.Scope.Value, 30)}";
var effectText = exc.Effect?.EffectType ?? exc.EffectId;
var expiresText = exc.Expiration?.ToString("yyyy-MM-dd") ?? "-";
table.AddRow(
Markup.Escape(Truncate(exc.Id, 12)),
Markup.Escape(exc.Vuln),
Markup.Escape(scopeText),
Markup.Escape(effectText),
GetStatusColor(exc.Status),
Markup.Escape(Truncate(exc.Owner, 20)),
expiresText
);
}
AnsiConsole.Write(table);
if (response.TotalCount.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Total: {response.TotalCount} exceptions[/]");
}
}
private static void RenderExceptionDetail(ExceptionInstance exc, bool verbose)
{
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[grey]ID:[/]", Markup.Escape(exc.Id))
.AddRow("[grey]Tenant:[/]", Markup.Escape(exc.Tenant))
.AddRow("[grey]Vulnerability:[/]", Markup.Escape(exc.Vuln))
.AddRow("[grey]Status:[/]", GetStatusColor(exc.Status))
.AddRow("[grey]Owner:[/]", Markup.Escape(exc.Owner))
.AddRow("[grey]Effect:[/]", Markup.Escape(exc.Effect?.EffectType ?? exc.EffectId))
.AddRow("[grey]Created:[/]", exc.CreatedAt.ToString("u"))
.AddRow("[grey]Updated:[/]", exc.UpdatedAt.ToString("u"))
.AddRow("[grey]Expires:[/]", exc.Expiration?.ToString("u") ?? "[grey]Never[/]"))
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Exception Details[/]")
};
AnsiConsole.Write(panel);
// Scope
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Scope[/]");
AnsiConsole.MarkupLine($" [grey]Type:[/] {Markup.Escape(exc.Scope.Type)}");
AnsiConsole.MarkupLine($" [grey]Value:[/] {Markup.Escape(exc.Scope.Value)}");
if (exc.Scope.RuleNames?.Count > 0)
{
AnsiConsole.MarkupLine($" [grey]Rules:[/] {string.Join(", ", exc.Scope.RuleNames.Select(Markup.Escape))}");
}
if (exc.Scope.Severities?.Count > 0)
{
AnsiConsole.MarkupLine($" [grey]Severities:[/] {string.Join(", ", exc.Scope.Severities.Select(Markup.Escape))}");
}
// Justification
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Justification[/]");
AnsiConsole.MarkupLine($" {Markup.Escape(exc.Justification)}");
// Evidence
if (exc.EvidenceRefs.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Evidence[/]");
foreach (var ev in exc.EvidenceRefs)
{
AnsiConsole.MarkupLine($" • [{Markup.Escape(ev.Type)}] {Markup.Escape(ev.Uri)}");
if (!string.IsNullOrWhiteSpace(ev.Digest) && verbose)
{
AnsiConsole.MarkupLine($" [grey]Digest: {Markup.Escape(ev.Digest)}[/]");
}
}
}
// Approval info
if (!string.IsNullOrWhiteSpace(exc.ApprovedBy) && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Approval[/]");
AnsiConsole.MarkupLine($" [grey]Approved By:[/] {Markup.Escape(exc.ApprovedBy)}");
if (exc.ApprovedAt.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Approved At:[/] {exc.ApprovedAt.Value:u}");
}
}
// Effect details
if (exc.Effect is not null && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Effect Details[/]");
AnsiConsole.MarkupLine($" [grey]ID:[/] {Markup.Escape(exc.Effect.Id)}");
AnsiConsole.MarkupLine($" [grey]Type:[/] {Markup.Escape(exc.Effect.EffectType)}");
if (!string.IsNullOrWhiteSpace(exc.Effect.Name))
{
AnsiConsole.MarkupLine($" [grey]Name:[/] {Markup.Escape(exc.Effect.Name)}");
}
if (!string.IsNullOrWhiteSpace(exc.Effect.DowngradeSeverity))
{
AnsiConsole.MarkupLine($" [grey]Downgrade To:[/] {Markup.Escape(exc.Effect.DowngradeSeverity)}");
}
if (exc.Effect.MaxDurationDays.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Max Duration:[/] {exc.Effect.MaxDurationDays} days");
}
}
// Metadata
if (exc.Metadata?.Count > 0 && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Metadata[/]");
foreach (var kvp in exc.Metadata)
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(kvp.Key)}:[/] {Markup.Escape(kvp.Value)}");
}
}
}
private static string GetStatusColor(string status)
{
return status.ToLowerInvariant() switch
{
"draft" => "[grey]draft[/]",
"staged" => "[blue]staged[/]",
"active" => "[green]active[/]",
"expired" => "[yellow]expired[/]",
"revoked" => "[red]revoked[/]",
_ => Markup.Escape(status)
};
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value;
return value[..(maxLength - 3)] + "...";
}
#endregion
#region Orchestrator Commands (CLI-ORCH-32-001)
/// <summary>
/// Handles 'stella orch sources list' command.
/// </summary>
internal static async Task<int> HandleOrchSourcesListAsync(
IServiceProvider services,
string? tenant,
string? type,
string? status,
bool? enabled,
string? host,
string? tag,
int pageSize,
string? pageToken,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new SourceListRequest
{
Tenant = tenant,
Type = type,
Status = status,
Enabled = enabled,
Host = host,
Tag = tag,
PageSize = pageSize,
PageToken = pageToken
};
try
{
var response = await client.ListSourcesAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
return 0;
}
if (response.Sources.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No sources found matching criteria.[/]");
return 0;
}
RenderSourceList(response, verbose);
if (!string.IsNullOrWhiteSpace(response.NextPageToken))
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Next page token: {Markup.Escape(response.NextPageToken)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error listing sources: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch sources show' command.
/// </summary>
internal static async Task<int> HandleOrchSourcesShowAsync(
IServiceProvider services,
string sourceId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
try
{
var source = await client.GetSourceAsync(sourceId, tenant, cancellationToken);
if (source is null)
{
var error = new CliError(CliErrorCodes.OrchSourceNotFound, $"Source '{sourceId}' not found.");
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(source, JsonOutputOptions));
return 0;
}
RenderSourceDetail(source, verbose);
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error fetching source: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
private static void RenderSourceList(SourceListResponse response, bool verbose)
{
var table = new Table();
table.Border = TableBorder.Rounded;
table.AddColumn("ID");
table.AddColumn("Name");
table.AddColumn("Type");
table.AddColumn("Host");
table.AddColumn("Status");
table.AddColumn("Last Run");
if (verbose)
{
table.AddColumn("Priority");
table.AddColumn("Success Rate");
}
foreach (var src in response.Sources)
{
var lastRunText = src.LastRun?.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-";
var statusMarkup = GetSourceStatusColor(src.Status);
if (verbose)
{
var successRate = src.Metrics is not null && src.Metrics.TotalRuns > 0
? $"{(double)src.Metrics.SuccessfulRuns / src.Metrics.TotalRuns * 100:F1}%"
: "-";
table.AddRow(
Markup.Escape(TruncateText(src.Id, 12)),
Markup.Escape(TruncateText(src.Name, 20)),
Markup.Escape(src.Type),
Markup.Escape(TruncateText(src.Host, 25)),
statusMarkup,
lastRunText,
src.Priority.ToString(),
successRate
);
}
else
{
table.AddRow(
Markup.Escape(TruncateText(src.Id, 12)),
Markup.Escape(TruncateText(src.Name, 20)),
Markup.Escape(src.Type),
Markup.Escape(TruncateText(src.Host, 25)),
statusMarkup,
lastRunText
);
}
}
AnsiConsole.Write(table);
if (response.TotalCount.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Total: {response.TotalCount} sources[/]");
}
}
private static void RenderSourceDetail(OrchestratorSource src, bool verbose)
{
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[grey]ID:[/]", Markup.Escape(src.Id))
.AddRow("[grey]Name:[/]", Markup.Escape(src.Name))
.AddRow("[grey]Tenant:[/]", Markup.Escape(src.Tenant))
.AddRow("[grey]Type:[/]", Markup.Escape(src.Type))
.AddRow("[grey]Host:[/]", Markup.Escape(src.Host))
.AddRow("[grey]Status:[/]", GetSourceStatusColor(src.Status))
.AddRow("[grey]Enabled:[/]", src.Enabled ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[grey]Priority:[/]", src.Priority.ToString())
.AddRow("[grey]Created:[/]", src.CreatedAt.ToString("u"))
.AddRow("[grey]Updated:[/]", src.UpdatedAt.ToString("u")))
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold]Source Details[/]")
};
AnsiConsole.Write(panel);
// Pause info
if (!string.IsNullOrWhiteSpace(src.PausedBy) || src.PausedAt.HasValue)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold yellow]Pause Info[/]");
if (src.PausedAt.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Paused At:[/] {src.PausedAt.Value:u}");
}
if (!string.IsNullOrWhiteSpace(src.PausedBy))
{
AnsiConsole.MarkupLine($" [grey]Paused By:[/] {Markup.Escape(src.PausedBy)}");
}
if (!string.IsNullOrWhiteSpace(src.PauseReason))
{
AnsiConsole.MarkupLine($" [grey]Reason:[/] {Markup.Escape(src.PauseReason)}");
}
}
// Schedule
if (src.Schedule is not null)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Schedule[/]");
if (!string.IsNullOrWhiteSpace(src.Schedule.Cron))
{
AnsiConsole.MarkupLine($" [grey]Cron:[/] {Markup.Escape(src.Schedule.Cron)}");
}
if (src.Schedule.IntervalMinutes.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Interval:[/] {src.Schedule.IntervalMinutes} minutes");
}
if (src.Schedule.NextRunAt.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Next Run:[/] {src.Schedule.NextRunAt.Value:u}");
}
AnsiConsole.MarkupLine($" [grey]Timezone:[/] {Markup.Escape(src.Schedule.Timezone)}");
}
// Rate limit
if (src.RateLimit is not null)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Rate Limit[/]");
AnsiConsole.MarkupLine($" [grey]Max/min:[/] {src.RateLimit.MaxRequestsPerMinute}");
if (src.RateLimit.MaxRequestsPerHour.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Max/hour:[/] {src.RateLimit.MaxRequestsPerHour}");
}
AnsiConsole.MarkupLine($" [grey]Burst:[/] {src.RateLimit.BurstSize}");
if (src.RateLimit.CurrentTokens.HasValue && verbose)
{
AnsiConsole.MarkupLine($" [grey]Current Tokens:[/] {src.RateLimit.CurrentTokens:F2}");
}
if (src.RateLimit.ThrottledUntil.HasValue)
{
var remaining = src.RateLimit.ThrottledUntil.Value - DateTimeOffset.UtcNow;
var throttleColor = remaining.TotalMinutes > 5 ? "red" : "yellow";
AnsiConsole.MarkupLine($" [grey]Throttled Until:[/] [{throttleColor}]{src.RateLimit.ThrottledUntil.Value:u}[/{throttleColor}]");
}
}
// Last run
if (src.LastRun is not null && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Last Run[/]");
if (!string.IsNullOrWhiteSpace(src.LastRun.RunId))
{
AnsiConsole.MarkupLine($" [grey]Run ID:[/] {Markup.Escape(src.LastRun.RunId)}");
}
if (src.LastRun.StartedAt.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Started:[/] {src.LastRun.StartedAt.Value:u}");
}
if (src.LastRun.CompletedAt.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Completed:[/] {src.LastRun.CompletedAt.Value:u}");
}
if (!string.IsNullOrWhiteSpace(src.LastRun.Status))
{
var runStatusColor = src.LastRun.Status.ToLowerInvariant() switch
{
"succeeded" or "success" => "green",
"failed" or "error" => "red",
"running" => "blue",
_ => "grey"
};
AnsiConsole.MarkupLine($" [grey]Status:[/] [{runStatusColor}]{Markup.Escape(src.LastRun.Status)}[/{runStatusColor}]");
}
if (src.LastRun.ItemsProcessed.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Items Processed:[/] {src.LastRun.ItemsProcessed}");
}
if (src.LastRun.ItemsFailed.HasValue && src.LastRun.ItemsFailed > 0)
{
AnsiConsole.MarkupLine($" [grey]Items Failed:[/] [red]{src.LastRun.ItemsFailed}[/]");
}
if (src.LastRun.DurationMs.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Duration:[/] {src.LastRun.DurationMs}ms");
}
if (!string.IsNullOrWhiteSpace(src.LastRun.ErrorMessage))
{
AnsiConsole.MarkupLine($" [grey]Error:[/] [red]{Markup.Escape(src.LastRun.ErrorMessage)}[/]");
}
}
// Metrics
if (src.Metrics is not null && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Metrics[/]");
AnsiConsole.MarkupLine($" [grey]Total Runs:[/] {src.Metrics.TotalRuns}");
AnsiConsole.MarkupLine($" [grey]Successful:[/] [green]{src.Metrics.SuccessfulRuns}[/]");
AnsiConsole.MarkupLine($" [grey]Failed:[/] [red]{src.Metrics.FailedRuns}[/]");
if (src.Metrics.TotalRuns > 0)
{
var successRate = (double)src.Metrics.SuccessfulRuns / src.Metrics.TotalRuns * 100;
var rateColor = successRate >= 95 ? "green" : successRate >= 80 ? "yellow" : "red";
AnsiConsole.MarkupLine($" [grey]Success Rate:[/] [{rateColor}]{successRate:F1}%[/{rateColor}]");
}
if (src.Metrics.AverageDurationMs.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Avg Duration:[/] {src.Metrics.AverageDurationMs:F0}ms");
}
if (src.Metrics.UptimePercent.HasValue)
{
var uptimeColor = src.Metrics.UptimePercent >= 99 ? "green" : src.Metrics.UptimePercent >= 95 ? "yellow" : "red";
AnsiConsole.MarkupLine($" [grey]Uptime:[/] [{uptimeColor}]{src.Metrics.UptimePercent:F2}%[/{uptimeColor}]");
}
}
// Tags
if (src.Tags.Count > 0)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Tags[/]");
AnsiConsole.MarkupLine($" {string.Join(", ", src.Tags.Select(t => $"[blue]{Markup.Escape(t)}[/]"))}");
}
// Metadata
if (src.Metadata?.Count > 0 && verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]Metadata[/]");
foreach (var kvp in src.Metadata)
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(kvp.Key)}:[/] {Markup.Escape(kvp.Value)}");
}
}
}
private static string GetSourceStatusColor(string status)
{
return status.ToLowerInvariant() switch
{
"active" => "[green]active[/]",
"paused" => "[yellow]paused[/]",
"disabled" => "[grey]disabled[/]",
"throttled" => "[orange1]throttled[/]",
"error" => "[red]error[/]",
_ => Markup.Escape(status)
};
}
private static string TruncateText(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value;
return value[..(maxLength - 3)] + "...";
}
/// <summary>
/// Handles 'stella orch sources test' command.
/// CLI-ORCH-33-001
/// </summary>
internal static async Task<int> HandleOrchSourcesTestAsync(
IServiceProvider services,
string sourceId,
string? tenant,
int timeout,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new SourceTestRequest
{
SourceId = sourceId,
Tenant = tenant,
TimeoutSeconds = timeout
};
try
{
AnsiConsole.MarkupLine($"[grey]Testing source '{Markup.Escape(sourceId)}'...[/]");
var result = await client.TestSourceAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success && result.Reachable ? 0 : 17;
}
if (!result.Success || !result.Reachable)
{
AnsiConsole.MarkupLine($"[red]Test failed for source '{Markup.Escape(sourceId)}'[/]");
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(result.ErrorMessage)}[/]");
}
return 17;
}
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[grey]Source ID:[/]", Markup.Escape(result.SourceId))
.AddRow("[grey]Reachable:[/]", "[green]Yes[/]")
.AddRow("[grey]Latency:[/]", result.LatencyMs.HasValue ? $"{result.LatencyMs}ms" : "-")
.AddRow("[grey]Status Code:[/]", result.StatusCode.HasValue ? result.StatusCode.ToString()! : "-")
.AddRow("[grey]Tested At:[/]", result.TestedAt.ToString("u")))
{
Border = BoxBorder.Rounded,
Header = new PanelHeader("[bold green]Connection Test Passed[/]")
};
AnsiConsole.Write(panel);
if (verbose)
{
if (result.TlsValid.HasValue)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold]TLS Information[/]");
AnsiConsole.MarkupLine($" [grey]Valid:[/] {(result.TlsValid.Value ? "[green]Yes[/]" : "[red]No[/]")}");
if (result.TlsExpiry.HasValue)
{
var remaining = result.TlsExpiry.Value - DateTimeOffset.UtcNow;
var expiryColor = remaining.TotalDays > 30 ? "green" : remaining.TotalDays > 7 ? "yellow" : "red";
AnsiConsole.MarkupLine($" [grey]Expires:[/] [{expiryColor}]{result.TlsExpiry.Value:u}[/{expiryColor}] ({remaining.TotalDays:F0} days)");
}
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error testing source: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch sources pause' command.
/// CLI-ORCH-33-001
/// </summary>
internal static async Task<int> HandleOrchSourcesPauseAsync(
IServiceProvider services,
string sourceId,
string? tenant,
string? reason,
int? durationMinutes,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new SourcePauseRequest
{
SourceId = sourceId,
Tenant = tenant,
Reason = reason,
DurationMinutes = durationMinutes
};
try
{
var result = await client.PauseSourceAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 17;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to pause source:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 17;
}
AnsiConsole.MarkupLine($"[green]Source '{Markup.Escape(sourceId)}' paused successfully.[/]");
if (result.Source is not null)
{
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetSourceStatusColor(result.Source.Status)}");
if (result.Source.PausedAt.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Paused At:[/] {result.Source.PausedAt.Value:u}");
}
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
if (durationMinutes.HasValue)
{
var resumeAt = DateTimeOffset.UtcNow.AddMinutes(durationMinutes.Value);
AnsiConsole.MarkupLine($"[grey]Auto-resume at:[/] {resumeAt:u}");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error pausing source: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch sources resume' command.
/// CLI-ORCH-33-001
/// </summary>
internal static async Task<int> HandleOrchSourcesResumeAsync(
IServiceProvider services,
string sourceId,
string? tenant,
string? reason,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new SourceResumeRequest
{
SourceId = sourceId,
Tenant = tenant,
Reason = reason
};
try
{
var result = await client.ResumeSourceAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 17;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to resume source:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 17;
}
AnsiConsole.MarkupLine($"[green]Source '{Markup.Escape(sourceId)}' resumed successfully.[/]");
if (result.Source is not null)
{
AnsiConsole.MarkupLine($"[grey]Status:[/] {GetSourceStatusColor(result.Source.Status)}");
if (result.Source.Schedule?.NextRunAt.HasValue == true)
{
AnsiConsole.MarkupLine($"[grey]Next Run:[/] {result.Source.Schedule.NextRunAt.Value:u}");
}
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error resuming source: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
// CLI-ORCH-34-001: Backfill handlers
/// <summary>
/// Handles 'stella orch backfill start' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchBackfillStartAsync(
IServiceProvider services,
string sourceId,
string? tenant,
DateTimeOffset from,
DateTimeOffset to,
bool dryRun,
int priority,
int concurrency,
int batchSize,
bool resume,
string? filter,
bool force,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new BackfillRequest
{
SourceId = sourceId,
Tenant = tenant,
From = from,
To = to,
DryRun = dryRun,
Priority = priority,
Concurrency = concurrency,
BatchSize = batchSize,
Resume = resume,
Filter = filter,
Force = force
};
try
{
var result = await client.StartBackfillAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 17;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to start backfill:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 17;
}
if (dryRun)
{
AnsiConsole.MarkupLine("[yellow]DRY RUN - No changes will be made[/]");
AnsiConsole.MarkupLine("");
}
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[grey]Backfill ID:[/]", Markup.Escape(result.BackfillId ?? "-"))
.AddRow("[grey]Source:[/]", Markup.Escape(sourceId))
.AddRow("[grey]Status:[/]", GetBackfillStatusColor(result.Status))
.AddRow("[grey]Period:[/]", $"{from:u} → {to:u}")
.AddRow("[grey]Estimated Items:[/]", result.EstimatedItems?.ToString("N0") ?? "Unknown")
.AddRow("[grey]Estimated Duration:[/]", FormatDuration(result.EstimatedDurationMs)))
{
Header = new PanelHeader(dryRun ? "[yellow]Backfill Preview[/]" : "[green]Backfill Started[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(panel);
if (verbose)
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Priority:[/] {priority}");
AnsiConsole.MarkupLine($"[grey]Concurrency:[/] {concurrency}");
AnsiConsole.MarkupLine($"[grey]Batch Size:[/] {batchSize}");
if (!string.IsNullOrWhiteSpace(filter))
{
AnsiConsole.MarkupLine($"[grey]Filter:[/] {Markup.Escape(filter)}");
}
if (force)
{
AnsiConsole.MarkupLine("[yellow]Force mode: Existing data will be overwritten[/]");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
if (!dryRun && !string.IsNullOrWhiteSpace(result.BackfillId))
{
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[grey]Monitor progress with:[/] stella orch backfill status {Markup.Escape(result.BackfillId)}");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error starting backfill: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch backfill status' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchBackfillStatusAsync(
IServiceProvider services,
string backfillId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
try
{
var result = await client.GetBackfillAsync(backfillId, tenant, cancellationToken);
if (result is null)
{
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(new { error = "Backfill not found" }, JsonOutputOptions));
}
else
{
AnsiConsole.MarkupLine($"[red]Backfill '{Markup.Escape(backfillId)}' not found.[/]");
}
return 17;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return 0;
}
var progressPercent = result.EstimatedItems.HasValue && result.EstimatedItems > 0
? (double)result.ProcessedItems / result.EstimatedItems.Value * 100
: 0;
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[grey]Backfill ID:[/]", Markup.Escape(result.BackfillId ?? backfillId))
.AddRow("[grey]Source:[/]", Markup.Escape(result.SourceId))
.AddRow("[grey]Status:[/]", GetBackfillStatusColor(result.Status))
.AddRow("[grey]Period:[/]", $"{result.From:u} → {result.To:u}")
.AddRow("[grey]Progress:[/]", $"{result.ProcessedItems:N0} / {(result.EstimatedItems?.ToString("N0") ?? "?")} ({progressPercent:F1}%)")
.AddRow("[grey]Failed:[/]", result.FailedItems > 0 ? $"[red]{result.FailedItems:N0}[/]" : "0")
.AddRow("[grey]Skipped:[/]", result.SkippedItems > 0 ? $"[yellow]{result.SkippedItems:N0}[/]" : "0"))
{
Header = new PanelHeader("[blue]Backfill Status[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(panel);
if (verbose)
{
AnsiConsole.MarkupLine("");
if (result.StartedAt.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Started:[/] {result.StartedAt.Value:u}");
}
if (result.CompletedAt.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Completed:[/] {result.CompletedAt.Value:u}");
}
if (result.ActualDurationMs.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Duration:[/] {FormatDuration(result.ActualDurationMs)}");
}
else if (result.StartedAt.HasValue)
{
var elapsed = DateTimeOffset.UtcNow - result.StartedAt.Value;
AnsiConsole.MarkupLine($"[grey]Elapsed:[/] {FormatDuration((long)elapsed.TotalMilliseconds)}");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
}
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error getting backfill status: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch backfill list' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchBackfillListAsync(
IServiceProvider services,
string? sourceId,
string? status,
string? tenant,
int pageSize,
string? pageToken,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new BackfillListRequest
{
SourceId = sourceId,
Status = status,
Tenant = tenant,
PageSize = pageSize,
PageToken = pageToken
};
try
{
var result = await client.ListBackfillsAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return 0;
}
if (result.Backfills.Count == 0)
{
AnsiConsole.MarkupLine("[grey]No backfill operations found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("ID");
table.AddColumn("Source");
table.AddColumn("Status");
table.AddColumn("Period");
table.AddColumn("Progress");
table.AddColumn("Started");
foreach (var backfill in result.Backfills)
{
var progressStr = backfill.EstimatedItems.HasValue && backfill.EstimatedItems > 0
? $"{(double)backfill.ProcessedItems / backfill.EstimatedItems.Value * 100:F0}%"
: $"{backfill.ProcessedItems:N0}";
table.AddRow(
Markup.Escape(backfill.BackfillId ?? "-"),
Markup.Escape(backfill.SourceId),
GetBackfillStatusColor(backfill.Status),
$"{backfill.From:yyyy-MM-dd} → {backfill.To:yyyy-MM-dd}",
progressStr,
backfill.StartedAt?.ToString("u") ?? "-"
);
}
AnsiConsole.Write(table);
if (result.TotalCount.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Showing {result.Backfills.Count} of {result.TotalCount} backfills[/]");
}
if (!string.IsNullOrWhiteSpace(result.NextPageToken))
{
AnsiConsole.MarkupLine($"[grey]More results available. Use --page-token {Markup.Escape(result.NextPageToken)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error listing backfills: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch backfill cancel' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchBackfillCancelAsync(
IServiceProvider services,
string backfillId,
string? tenant,
string? reason,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new BackfillCancelRequest
{
BackfillId = backfillId,
Tenant = tenant,
Reason = reason
};
try
{
var result = await client.CancelBackfillAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 17;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to cancel backfill:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 17;
}
AnsiConsole.MarkupLine($"[green]Backfill '{Markup.Escape(backfillId)}' cancelled successfully.[/]");
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error cancelling backfill: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
// CLI-ORCH-34-001: Quota handlers
/// <summary>
/// Handles 'stella orch quotas get' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchQuotasGetAsync(
IServiceProvider services,
string? tenant,
string? sourceId,
string? resourceType,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new QuotaGetRequest
{
Tenant = tenant,
SourceId = sourceId,
ResourceType = resourceType
};
try
{
var result = await client.GetQuotasAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return 0;
}
if (result.Quotas.Count == 0)
{
AnsiConsole.MarkupLine("[grey]No quotas configured.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Tenant");
table.AddColumn("Resource");
table.AddColumn("Used");
table.AddColumn("Limit");
table.AddColumn("Remaining");
table.AddColumn("Period");
table.AddColumn("Status");
foreach (var quota in result.Quotas)
{
var usagePercent = quota.Limit > 0 ? (double)quota.Used / quota.Limit * 100 : 0;
var statusColor = quota.IsExceeded ? "[red]EXCEEDED[/]"
: quota.IsWarning ? "[yellow]WARNING[/]"
: "[green]OK[/]";
var usedFormatted = FormatQuotaValue(quota.Used, quota.ResourceType);
var limitFormatted = FormatQuotaValue(quota.Limit, quota.ResourceType);
var remainingFormatted = FormatQuotaValue(quota.Remaining, quota.ResourceType);
table.AddRow(
Markup.Escape(quota.Tenant),
Markup.Escape(quota.ResourceType),
$"{usedFormatted} ({usagePercent:F1}%)",
limitFormatted,
remainingFormatted,
Markup.Escape(quota.Period),
statusColor
);
}
AnsiConsole.Write(table);
if (verbose)
{
AnsiConsole.MarkupLine("");
foreach (var quota in result.Quotas.Where(q => q.IsWarning || q.IsExceeded))
{
if (quota.IsExceeded)
{
AnsiConsole.MarkupLine($"[red]EXCEEDED:[/] {Markup.Escape(quota.ResourceType)} - Resets at {quota.ResetAt:u}");
}
else if (quota.IsWarning)
{
AnsiConsole.MarkupLine($"[yellow]WARNING:[/] {Markup.Escape(quota.ResourceType)} at {(double)quota.Used / quota.Limit * 100:F1}% of limit");
}
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error getting quotas: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch quotas set' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchQuotasSetAsync(
IServiceProvider services,
string tenant,
string? sourceId,
string resourceType,
long limit,
string period,
double warningThreshold,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new QuotaSetRequest
{
Tenant = tenant,
SourceId = sourceId,
ResourceType = resourceType,
Limit = limit,
Period = period,
WarningThreshold = warningThreshold
};
try
{
var result = await client.SetQuotaAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 17;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to set quota:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 17;
}
AnsiConsole.MarkupLine($"[green]Quota set successfully.[/]");
if (result.Quota is not null)
{
AnsiConsole.MarkupLine($"[grey]Tenant:[/] {Markup.Escape(result.Quota.Tenant)}");
AnsiConsole.MarkupLine($"[grey]Resource:[/] {Markup.Escape(result.Quota.ResourceType)}");
AnsiConsole.MarkupLine($"[grey]Limit:[/] {FormatQuotaValue(result.Quota.Limit, result.Quota.ResourceType)}");
AnsiConsole.MarkupLine($"[grey]Period:[/] {Markup.Escape(result.Quota.Period)}");
AnsiConsole.MarkupLine($"[grey]Warning Threshold:[/] {result.Quota.WarningThreshold * 100:F0}%");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error setting quota: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
/// <summary>
/// Handles 'stella orch quotas reset' command.
/// CLI-ORCH-34-001
/// </summary>
internal static async Task<int> HandleOrchQuotasResetAsync(
IServiceProvider services,
string tenant,
string? sourceId,
string resourceType,
string? reason,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<IOrchestratorClient>();
var request = new QuotaResetRequest
{
Tenant = tenant,
SourceId = sourceId,
ResourceType = resourceType,
Reason = reason
};
try
{
var result = await client.ResetQuotaAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return result.Success ? 0 : 17;
}
if (!result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to reset quota:[/]");
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
return 17;
}
AnsiConsole.MarkupLine($"[green]Quota usage reset successfully.[/]");
if (result.Quota is not null)
{
AnsiConsole.MarkupLine($"[grey]Tenant:[/] {Markup.Escape(result.Quota.Tenant)}");
AnsiConsole.MarkupLine($"[grey]Resource:[/] {Markup.Escape(result.Quota.ResourceType)}");
AnsiConsole.MarkupLine($"[grey]New Usage:[/] {FormatQuotaValue(result.Quota.Used, result.Quota.ResourceType)}");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId) && verbose)
{
AnsiConsole.MarkupLine($"[grey]Audit Event:[/] {Markup.Escape(result.AuditEventId)}");
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error resetting quota: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
private static string GetBackfillStatusColor(string status)
{
return status.ToLowerInvariant() switch
{
"pending" => "[yellow]pending[/]",
"running" => "[blue]running[/]",
"completed" => "[green]completed[/]",
"failed" => "[red]failed[/]",
"cancelled" => "[grey]cancelled[/]",
"dry_run" => "[cyan]dry_run[/]",
_ => Markup.Escape(status)
};
}
private static string FormatDuration(long? milliseconds)
{
if (!milliseconds.HasValue)
return "Unknown";
var ts = TimeSpan.FromMilliseconds(milliseconds.Value);
if (ts.TotalDays >= 1)
return $"{ts.TotalDays:F1} days";
if (ts.TotalHours >= 1)
return $"{ts.TotalHours:F1} hours";
if (ts.TotalMinutes >= 1)
return $"{ts.TotalMinutes:F1} minutes";
return $"{ts.TotalSeconds:F1} seconds";
}
private static string FormatQuotaValue(long value, string resourceType)
{
return resourceType.ToLowerInvariant() switch
{
"data_ingested_bytes" or "storage_bytes" => FormatBytes(value),
_ => value.ToString("N0")
};
}
private static string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:F1} {sizes[order]}";
}
#endregion
#region CLI-PARITY-41-001: SBOM Commands
internal static async Task<int> HandleSbomListAsync(
IServiceProvider services,
string? tenant,
string? imageRef,
string? digest,
string? format,
DateTimeOffset? createdAfter,
DateTimeOffset? createdBefore,
bool? hasVulnerabilities,
int limit,
int? offset,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<ISbomClient>();
var request = new SbomListRequest
{
Tenant = tenant,
ImageRef = imageRef,
Digest = digest,
Format = format,
CreatedAfter = createdAfter,
CreatedBefore = createdBefore,
HasVulnerabilities = hasVulnerabilities,
Limit = limit,
Offset = offset,
Cursor = cursor
};
try
{
var response = await client.ListAsync(request, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
return 0;
}
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No SBOMs found matching the criteria.[/]");
return 0;
}
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("SBOM ID")
.AddColumn("Image")
.AddColumn("Format")
.AddColumn("Components")
.AddColumn("Vulns")
.AddColumn("Det. Score")
.AddColumn("Created");
foreach (var sbom in response.Items)
{
var detScore = sbom.DeterminismScore.HasValue
? $"{sbom.DeterminismScore.Value:P0}"
: "-";
var detColor = sbom.DeterminismScore switch
{
>= 0.95 => "green",
>= 0.80 => "yellow",
_ => "red"
};
table.AddRow(
Markup.Escape(sbom.SbomId),
Markup.Escape(sbom.ImageRef ?? "-"),
Markup.Escape(sbom.Format),
sbom.ComponentCount.ToString(),
GetVulnCountMarkup(sbom.VulnerabilityCount),
$"[{detColor}]{detScore}[/]",
sbom.CreatedAt.ToString("yyyy-MM-dd HH:mm"));
}
AnsiConsole.Write(table);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Total: {response.Total} | Showing: {response.Items.Count} | Has more: {response.HasMore}[/]");
if (!string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine($"[grey]Next cursor: {Markup.Escape(response.NextCursor)}[/]");
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error listing SBOMs: {Markup.Escape(error.Message)}[/]");
return 18;
}
}
internal static async Task<int> HandleSbomShowAsync(
IServiceProvider services,
string sbomId,
string? tenant,
bool includeComponents,
bool includeVulnerabilities,
bool includeLicenses,
bool explain,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<ISbomClient>();
try
{
var sbom = await client.GetAsync(
sbomId,
tenant,
includeComponents,
includeVulnerabilities,
includeLicenses,
explain,
cancellationToken);
if (sbom is null)
{
AnsiConsole.MarkupLine($"[red]SBOM '{Markup.Escape(sbomId)}' not found.[/]");
return 18;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(sbom, JsonOutputOptions));
return 0;
}
// Header panel
var headerGrid = new Grid()
.AddColumn()
.AddColumn();
headerGrid.AddRow("[grey]SBOM ID:[/]", Markup.Escape(sbom.SbomId));
if (!string.IsNullOrWhiteSpace(sbom.ImageRef))
headerGrid.AddRow("[grey]Image:[/]", Markup.Escape(sbom.ImageRef));
if (!string.IsNullOrWhiteSpace(sbom.Digest))
headerGrid.AddRow("[grey]Digest:[/]", Markup.Escape(sbom.Digest));
headerGrid.AddRow("[grey]Format:[/]", $"{Markup.Escape(sbom.Format)} {Markup.Escape(sbom.FormatVersion ?? "")}");
headerGrid.AddRow("[grey]Created:[/]", sbom.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") + " UTC");
headerGrid.AddRow("[grey]Components:[/]", sbom.ComponentCount.ToString());
headerGrid.AddRow("[grey]Vulnerabilities:[/]", GetVulnCountMarkup(sbom.VulnerabilityCount));
headerGrid.AddRow("[grey]Licenses:[/]", sbom.LicensesDetected.ToString());
if (sbom.DeterminismScore.HasValue)
{
var detColor = sbom.DeterminismScore.Value switch
{
>= 0.95 => "green",
>= 0.80 => "yellow",
_ => "red"
};
headerGrid.AddRow("[grey]Determinism:[/]", $"[{detColor}]{sbom.DeterminismScore.Value:P1}[/]");
}
AnsiConsole.Write(new Panel(headerGrid).Header("SBOM Details").Border(BoxBorder.Rounded));
// Attestation info
if (sbom.Attestation is not null)
{
var attGrid = new Grid()
.AddColumn()
.AddColumn();
attGrid.AddRow("[grey]Signed:[/]", sbom.Attestation.Signed ? "[green]Yes[/]" : "[yellow]No[/]");
if (!string.IsNullOrWhiteSpace(sbom.Attestation.SignatureAlgorithm))
attGrid.AddRow("[grey]Algorithm:[/]", Markup.Escape(sbom.Attestation.SignatureAlgorithm));
if (sbom.Attestation.SignedAt.HasValue)
attGrid.AddRow("[grey]Signed At:[/]", sbom.Attestation.SignedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") + " UTC");
if (sbom.Attestation.RekorLogIndex.HasValue)
attGrid.AddRow("[grey]Rekor Index:[/]", sbom.Attestation.RekorLogIndex.Value.ToString());
if (!string.IsNullOrWhiteSpace(sbom.Attestation.CertificateSubject))
attGrid.AddRow("[grey]Cert Subject:[/]", Markup.Escape(sbom.Attestation.CertificateSubject));
AnsiConsole.Write(new Panel(attGrid).Header("Attestation").Border(BoxBorder.Rounded));
}
// Components table
if (includeComponents && sbom.Components is { Count: > 0 })
{
var compTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("Name")
.AddColumn("Version")
.AddColumn("Type")
.AddColumn("Licenses");
foreach (var comp in sbom.Components.Take(verbose ? 100 : 25))
{
compTable.AddRow(
Markup.Escape(comp.Name),
Markup.Escape(comp.Version ?? "-"),
Markup.Escape(comp.Type ?? "-"),
Markup.Escape(string.Join(", ", comp.Licenses ?? [])));
}
if (sbom.Components.Count > (verbose ? 100 : 25))
{
compTable.AddRow($"[grey]... and {sbom.Components.Count - (verbose ? 100 : 25)} more[/]", "", "", "");
}
AnsiConsole.Write(new Panel(compTable).Header($"Components ({sbom.Components.Count})").Border(BoxBorder.Rounded));
}
// Vulnerabilities table
if (includeVulnerabilities && sbom.Vulnerabilities is { Count: > 0 })
{
var vulnTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("CVE")
.AddColumn("Severity")
.AddColumn("Score")
.AddColumn("Component")
.AddColumn("VEX Status");
foreach (var vuln in sbom.Vulnerabilities.Take(verbose ? 50 : 20))
{
vulnTable.AddRow(
Markup.Escape(vuln.VulnerabilityId),
GetSeverityMarkup(vuln.Severity),
vuln.Score?.ToString("F1") ?? "-",
Markup.Escape(vuln.AffectedComponent ?? "-"),
GetVexStatusMarkup(vuln.VexStatus));
}
if (sbom.Vulnerabilities.Count > (verbose ? 50 : 20))
{
vulnTable.AddRow($"[grey]... and {sbom.Vulnerabilities.Count - (verbose ? 50 : 20)} more[/]", "", "", "", "");
}
AnsiConsole.Write(new Panel(vulnTable).Header($"Vulnerabilities ({sbom.Vulnerabilities.Count})").Border(BoxBorder.Rounded));
}
// Licenses table
if (includeLicenses && sbom.Licenses is { Count: > 0 })
{
var licTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("License")
.AddColumn("ID")
.AddColumn("Components");
foreach (var lic in sbom.Licenses.OrderByDescending(l => l.ComponentCount).Take(20))
{
licTable.AddRow(
Markup.Escape(lic.Name),
Markup.Escape(lic.Id ?? "-"),
lic.ComponentCount.ToString());
}
AnsiConsole.Write(new Panel(licTable).Header($"Licenses ({sbom.Licenses.Count} unique)").Border(BoxBorder.Rounded));
}
// Explain panel
if (explain && sbom.Explain is not null)
{
if (sbom.Explain.DeterminismFactors is { Count: > 0 })
{
var factorTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("Factor")
.AddColumn("Impact")
.AddColumn("Score")
.AddColumn("Details");
foreach (var factor in sbom.Explain.DeterminismFactors)
{
var impactColor = factor.Impact.ToLowerInvariant() switch
{
"positive" => "green",
"negative" => "red",
_ => "yellow"
};
factorTable.AddRow(
Markup.Escape(factor.Factor),
$"[{impactColor}]{Markup.Escape(factor.Impact)}[/]",
$"{factor.Score:F2}",
Markup.Escape(factor.Details ?? "-"));
}
AnsiConsole.Write(new Panel(factorTable).Header("Determinism Factors").Border(BoxBorder.Rounded));
}
if (sbom.Explain.CompositionPath is { Count: > 0 })
{
var pathTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("Step")
.AddColumn("Operation")
.AddColumn("Input")
.AddColumn("Digest");
foreach (var step in sbom.Explain.CompositionPath)
{
pathTable.AddRow(
step.Step.ToString(),
Markup.Escape(step.Operation),
Markup.Escape(step.Input ?? "-"),
Markup.Escape(step.Digest?[..16] ?? "-"));
}
AnsiConsole.Write(new Panel(pathTable).Header("Composition Path").Border(BoxBorder.Rounded));
}
if (sbom.Explain.Warnings is { Count: > 0 })
{
AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
foreach (var warning in sbom.Explain.Warnings)
{
AnsiConsole.MarkupLine($" [yellow]• {Markup.Escape(warning)}[/]");
}
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error getting SBOM: {Markup.Escape(error.Message)}[/]");
return 18;
}
}
internal static async Task<int> HandleSbomCompareAsync(
IServiceProvider services,
string baseSbomId,
string targetSbomId,
string? tenant,
bool includeUnchanged,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<ISbomClient>();
var request = new SbomCompareRequest
{
Tenant = tenant,
BaseSbomId = baseSbomId,
TargetSbomId = targetSbomId,
IncludeUnchanged = includeUnchanged
};
try
{
var result = await client.CompareAsync(request, cancellationToken);
if (result is null)
{
AnsiConsole.MarkupLine("[red]Failed to compare SBOMs. One or both SBOMs may not exist.[/]");
return 18;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
return 0;
}
// Summary panel
var summaryGrid = new Grid()
.AddColumn()
.AddColumn()
.AddColumn();
summaryGrid.AddRow("[grey]Base SBOM:[/]", Markup.Escape(result.BaseSbomId), "");
summaryGrid.AddRow("[grey]Target SBOM:[/]", Markup.Escape(result.TargetSbomId), "");
summaryGrid.AddRow("", "", "");
summaryGrid.AddRow("[grey]Components:[/]",
$"[green]+{result.Summary.ComponentsAdded}[/]",
$"[red]-{result.Summary.ComponentsRemoved}[/]");
summaryGrid.AddRow("[grey]Modified:[/]", $"[yellow]~{result.Summary.ComponentsModified}[/]",
includeUnchanged ? $"[grey]={result.Summary.ComponentsUnchanged}[/]" : "");
summaryGrid.AddRow("[grey]Vulnerabilities:[/]",
$"[red]+{result.Summary.VulnerabilitiesAdded}[/]",
$"[green]-{result.Summary.VulnerabilitiesRemoved}[/]");
summaryGrid.AddRow("[grey]Licenses:[/]",
$"[blue]+{result.Summary.LicensesAdded}[/]",
$"[blue]-{result.Summary.LicensesRemoved}[/]");
AnsiConsole.Write(new Panel(summaryGrid).Header("Comparison Summary").Border(BoxBorder.Rounded));
// Component changes
if (result.ComponentChanges is { Count: > 0 })
{
var compTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("Change")
.AddColumn("Component")
.AddColumn("Base Version")
.AddColumn("Target Version");
foreach (var change in result.ComponentChanges.Take(verbose ? 100 : 30))
{
var changeColor = change.ChangeType.ToLowerInvariant() switch
{
"added" => "green",
"removed" => "red",
"modified" => "yellow",
_ => "grey"
};
compTable.AddRow(
$"[{changeColor}]{Markup.Escape(change.ChangeType)}[/]",
Markup.Escape(change.ComponentName),
Markup.Escape(change.BaseVersion ?? "-"),
Markup.Escape(change.TargetVersion ?? "-"));
}
if (result.ComponentChanges.Count > (verbose ? 100 : 30))
{
compTable.AddRow($"[grey]... and {result.ComponentChanges.Count - (verbose ? 100 : 30)} more[/]", "", "", "");
}
AnsiConsole.Write(new Panel(compTable).Header($"Component Changes ({result.ComponentChanges.Count})").Border(BoxBorder.Rounded));
}
// Vulnerability changes
if (result.VulnerabilityChanges is { Count: > 0 })
{
var vulnTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("Change")
.AddColumn("CVE")
.AddColumn("Severity")
.AddColumn("Component")
.AddColumn("Reason");
foreach (var change in result.VulnerabilityChanges.Take(verbose ? 50 : 20))
{
var changeColor = change.ChangeType.ToLowerInvariant() switch
{
"added" => "red",
"removed" => "green",
_ => "yellow"
};
vulnTable.AddRow(
$"[{changeColor}]{Markup.Escape(change.ChangeType)}[/]",
Markup.Escape(change.VulnerabilityId),
GetSeverityMarkup(change.Severity),
Markup.Escape(change.AffectedComponent ?? "-"),
Markup.Escape(change.Reason ?? "-"));
}
if (result.VulnerabilityChanges.Count > (verbose ? 50 : 20))
{
vulnTable.AddRow($"[grey]... and {result.VulnerabilityChanges.Count - (verbose ? 50 : 20)} more[/]", "", "", "", "");
}
AnsiConsole.Write(new Panel(vulnTable).Header($"Vulnerability Changes ({result.VulnerabilityChanges.Count})").Border(BoxBorder.Rounded));
}
// License changes
if (result.LicenseChanges is { Count: > 0 })
{
var licTable = new Table()
.Border(TableBorder.Simple)
.AddColumn("Change")
.AddColumn("License")
.AddColumn("Components");
foreach (var change in result.LicenseChanges)
{
var changeColor = change.ChangeType.ToLowerInvariant() switch
{
"added" => "blue",
"removed" => "grey",
_ => "yellow"
};
licTable.AddRow(
$"[{changeColor}]{Markup.Escape(change.ChangeType)}[/]",
Markup.Escape(change.LicenseName),
change.ComponentCount.ToString());
}
AnsiConsole.Write(new Panel(licTable).Header($"License Changes ({result.LicenseChanges.Count})").Border(BoxBorder.Rounded));
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error comparing SBOMs: {Markup.Escape(error.Message)}[/]");
return 18;
}
}
internal static async Task<int> HandleSbomExportAsync(
IServiceProvider services,
string sbomId,
string? tenant,
string format,
string? formatVersion,
string? output,
bool signed,
bool includeVex,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<ISbomClient>();
var request = new SbomExportRequest
{
Tenant = tenant,
SbomId = sbomId,
Format = format,
FormatVersion = formatVersion,
Signed = signed,
IncludeVex = includeVex
};
try
{
var (contentStream, result) = await client.ExportAsync(request, cancellationToken);
if (result is null || !result.Success)
{
AnsiConsole.MarkupLine("[red]Failed to export SBOM:[/]");
if (result?.Errors is not null)
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($" [red]• {Markup.Escape(error)}[/]");
}
}
return 18;
}
if (!string.IsNullOrWhiteSpace(output))
{
// Write to file
await using var fileStream = File.Create(output);
await contentStream.CopyToAsync(fileStream, cancellationToken);
AnsiConsole.MarkupLine($"[green]SBOM exported successfully to: {Markup.Escape(output)}[/]");
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Format:[/] {Markup.Escape(result.Format)}");
if (result.Signed)
{
AnsiConsole.MarkupLine($"[grey]Signed:[/] [green]Yes[/]");
if (!string.IsNullOrWhiteSpace(result.SignatureKeyId))
AnsiConsole.MarkupLine($"[grey]Key ID:[/] {Markup.Escape(result.SignatureKeyId)}");
}
if (!string.IsNullOrWhiteSpace(result.Digest))
AnsiConsole.MarkupLine($"[grey]Digest:[/] {Markup.Escape(result.DigestAlgorithm ?? "sha256")}:{Markup.Escape(result.Digest)}");
}
}
else
{
// Write to stdout
using var reader = new StreamReader(contentStream);
var content = await reader.ReadToEndAsync(cancellationToken);
Console.Write(content);
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error exporting SBOM: {Markup.Escape(error.Message)}[/]");
return 18;
}
}
internal static async Task<int> HandleSbomParityMatrixAsync(
IServiceProvider services,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
var client = services.GetRequiredService<ISbomClient>();
try
{
var response = await client.GetParityMatrixAsync(tenant, cancellationToken);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOutputOptions));
return 0;
}
// Summary panel
var summaryGrid = new Grid()
.AddColumn()
.AddColumn();
summaryGrid.AddRow("[grey]Total Commands:[/]", response.Summary.TotalCommands.ToString());
summaryGrid.AddRow("[grey]Full Parity:[/]", $"[green]{response.Summary.FullParity}[/]");
summaryGrid.AddRow("[grey]Partial Parity:[/]", $"[yellow]{response.Summary.PartialParity}[/]");
summaryGrid.AddRow("[grey]No Parity:[/]", $"[red]{response.Summary.NoParity}[/]");
summaryGrid.AddRow("[grey]Deterministic:[/]", response.Summary.DeterministicCommands.ToString());
summaryGrid.AddRow("[grey]--explain Support:[/]", response.Summary.ExplainEnabledCommands.ToString());
summaryGrid.AddRow("[grey]Offline Capable:[/]", response.Summary.OfflineCapableCommands.ToString());
if (!string.IsNullOrWhiteSpace(response.CliVersion))
summaryGrid.AddRow("[grey]CLI Version:[/]", Markup.Escape(response.CliVersion));
summaryGrid.AddRow("[grey]Generated:[/]", response.GeneratedAt.ToString("yyyy-MM-dd HH:mm:ss") + " UTC");
AnsiConsole.Write(new Panel(summaryGrid).Header("CLI Parity Matrix Summary").Border(BoxBorder.Rounded));
// Commands table
if (response.Entries.Count > 0)
{
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("Command Group")
.AddColumn("Command")
.AddColumn("CLI Support")
.AddColumn("Det.")
.AddColumn("Explain")
.AddColumn("Offline");
foreach (var entry in response.Entries.OrderBy(e => e.CommandGroup).ThenBy(e => e.Command))
{
var supportColor = entry.CliSupport.ToLowerInvariant() switch
{
"full" => "green",
"partial" => "yellow",
"none" => "red",
_ => "grey"
};
table.AddRow(
Markup.Escape(entry.CommandGroup),
Markup.Escape(entry.Command),
$"[{supportColor}]{Markup.Escape(entry.CliSupport)}[/]",
entry.Deterministic ? "[green]✓[/]" : "[grey]-[/]",
entry.ExplainSupport ? "[green]✓[/]" : "[grey]-[/]",
entry.OfflineSupport ? "[green]✓[/]" : "[grey]-[/]");
}
AnsiConsole.Write(table);
if (verbose)
{
// Show notes for entries with notes
var entriesWithNotes = response.Entries.Where(e => !string.IsNullOrWhiteSpace(e.Notes)).ToList();
if (entriesWithNotes.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Notes:[/]");
foreach (var entry in entriesWithNotes)
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(entry.CommandGroup)} {Markup.Escape(entry.Command)}:[/] {Markup.Escape(entry.Notes!)}");
}
}
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromException(ex);
AnsiConsole.MarkupLine($"[red]Error getting parity matrix: {Markup.Escape(error.Message)}[/]");
return 18;
}
}
private static string GetVulnCountMarkup(int count)
{
return count switch
{
0 => "[green]0[/]",
<= 5 => $"[yellow]{count}[/]",
_ => $"[red]{count}[/]"
};
}
private static string GetSeverityMarkup(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
return "[grey]-[/]";
return severity.ToLowerInvariant() switch
{
"critical" => "[red]CRITICAL[/]",
"high" => "[red]HIGH[/]",
"medium" => "[yellow]MEDIUM[/]",
"low" => "[blue]LOW[/]",
"none" or "informational" => "[grey]NONE[/]",
_ => Markup.Escape(severity)
};
}
private static string GetVexStatusMarkup(string? status)
{
if (string.IsNullOrWhiteSpace(status))
return "[grey]-[/]";
return status.ToLowerInvariant() switch
{
"affected" => "[red]affected[/]",
"not_affected" => "[green]not_affected[/]",
"fixed" => "[green]fixed[/]",
"under_investigation" => "[yellow]investigating[/]",
_ => Markup.Escape(status)
};
}
#endregion
#region Export Handlers (CLI-EXPORT-35-037)
internal static async Task<int> HandleExportProfilesListAsync(
IServiceProvider services,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var response = await client.ListProfilesAsync(cursor, limit, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Profiles.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No export profiles found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Profile ID");
table.AddColumn("Name");
table.AddColumn("Adapter");
table.AddColumn("Format");
table.AddColumn("Signing");
table.AddColumn("Created");
table.AddColumn("Updated");
foreach (var profile in response.Profiles)
{
table.AddRow(
Markup.Escape(profile.ProfileId),
Markup.Escape(profile.Name),
Markup.Escape(profile.Adapter),
Markup.Escape(profile.OutputFormat),
profile.SigningEnabled ? "[green]Yes[/]" : "[grey]No[/]",
profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture),
profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
}
AnsiConsole.Write(table);
return 0;
}
internal static async Task<int> HandleExportProfileShowAsync(
IServiceProvider services,
string profileId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var profile = await client.GetProfileAsync(profileId, cancellationToken).ConfigureAwait(false);
if (profile is null)
{
AnsiConsole.MarkupLine($"[red]Profile not found:[/] {Markup.Escape(profileId)}");
return 1;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(profile, JsonOptions));
return 0;
}
var profileTable = new Table { Border = TableBorder.Rounded };
profileTable.AddColumn("Field");
profileTable.AddColumn("Value");
profileTable.AddRow("Profile ID", Markup.Escape(profile.ProfileId));
profileTable.AddRow("Name", Markup.Escape(profile.Name));
profileTable.AddRow("Description", string.IsNullOrWhiteSpace(profile.Description) ? "[grey]-[/]" : Markup.Escape(profile.Description));
profileTable.AddRow("Adapter", Markup.Escape(profile.Adapter));
profileTable.AddRow("Format", Markup.Escape(profile.OutputFormat));
profileTable.AddRow("Signing", profile.SigningEnabled ? "[green]Enabled[/]" : "[grey]Disabled[/]");
profileTable.AddRow("Created", profile.CreatedAt.ToString("u", CultureInfo.InvariantCulture));
profileTable.AddRow("Updated", profile.UpdatedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
if (profile.Selectors is { Count: > 0 })
{
var selectorTable = new Table { Title = new TableTitle("Selectors") };
selectorTable.AddColumn("Key");
selectorTable.AddColumn("Value");
foreach (var selector in profile.Selectors)
{
selectorTable.AddRow(Markup.Escape(selector.Key), Markup.Escape(selector.Value));
}
AnsiConsole.Write(profileTable);
AnsiConsole.WriteLine();
AnsiConsole.Write(selectorTable);
}
else
{
AnsiConsole.Write(profileTable);
}
return 0;
}
internal static async Task<int> HandleExportRunsListAsync(
IServiceProvider services,
string? profileId,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var response = await client.ListRunsAsync(profileId, cursor, limit, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Runs.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No export runs found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Run ID");
table.AddColumn("Profile");
table.AddColumn("Status");
table.AddColumn("Progress");
table.AddColumn("Started");
table.AddColumn("Completed");
table.AddColumn("Bundle");
foreach (var run in response.Runs)
{
table.AddRow(
Markup.Escape(run.RunId),
Markup.Escape(run.ProfileId),
Markup.Escape(run.Status),
run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]",
run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]",
string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
}
AnsiConsole.Write(table);
if (response.HasMore && !string.IsNullOrWhiteSpace(response.ContinuationToken))
{
AnsiConsole.MarkupLine($"[yellow]More available. Use --cursor {Markup.Escape(response.ContinuationToken)}[/]");
}
return 0;
}
internal static async Task<int> HandleExportRunShowAsync(
IServiceProvider services,
string runId,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var run = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
AnsiConsole.MarkupLine($"[red]Run not found:[/] {Markup.Escape(runId)}");
return 1;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(run, JsonOptions));
return 0;
}
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Field");
table.AddColumn("Value");
table.AddRow("Run ID", Markup.Escape(run.RunId));
table.AddRow("Profile ID", Markup.Escape(run.ProfileId));
table.AddRow("Status", Markup.Escape(run.Status));
table.AddRow("Progress", run.Progress.HasValue ? $"{run.Progress.Value}%" : "[grey]-[/]");
table.AddRow("Started", run.StartedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
table.AddRow("Completed", run.CompletedAt?.ToString("u", CultureInfo.InvariantCulture) ?? "[grey]-[/]");
table.AddRow("Bundle Hash", string.IsNullOrWhiteSpace(run.BundleHash) ? "[grey]-[/]" : Markup.Escape(run.BundleHash));
table.AddRow("Bundle URL", string.IsNullOrWhiteSpace(run.BundleUrl) ? "[grey]-[/]" : Markup.Escape(run.BundleUrl));
table.AddRow("Error Code", string.IsNullOrWhiteSpace(run.ErrorCode) ? "[grey]-[/]" : Markup.Escape(run.ErrorCode));
table.AddRow("Error Message", string.IsNullOrWhiteSpace(run.ErrorMessage) ? "[grey]-[/]" : Markup.Escape(run.ErrorMessage));
AnsiConsole.Write(table);
return 0;
}
internal static async Task<int> HandleExportRunDownloadAsync(
IServiceProvider services,
string runId,
string outputPath,
bool overwrite,
string? verifyHash,
string runType,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
if (File.Exists(outputPath) && !overwrite)
{
AnsiConsole.MarkupLine($"[red]Output file already exists:[/] {Markup.Escape(outputPath)} (use --overwrite to replace)");
return 1;
}
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath)) ?? ".");
Stream? stream = null;
if (string.Equals(runType, "attestation", StringComparison.OrdinalIgnoreCase))
{
stream = await client.DownloadAttestationExportAsync(runId, cancellationToken).ConfigureAwait(false);
}
else
{
stream = await client.DownloadEvidenceExportAsync(runId, cancellationToken).ConfigureAwait(false);
}
if (stream is null)
{
AnsiConsole.MarkupLine($"[red]Export bundle not available for run:[/] {Markup.Escape(runId)}");
return 1;
}
await using (stream)
await using (var fileStream = File.Create(outputPath))
{
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(verifyHash))
{
await using var file = File.OpenRead(outputPath);
var hash = await SHA256.HashDataAsync(file, cancellationToken).ConfigureAwait(false);
var hashString = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(hashString, verifyHash.Trim(), StringComparison.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[red]Hash verification failed.[/] expected={Markup.Escape(verifyHash)}, actual={hashString}");
return 1;
}
}
AnsiConsole.MarkupLine($"[green]Bundle written to[/] {Markup.Escape(outputPath)}");
return 0;
}
internal static async Task<int> HandleExportStartEvidenceAsync(
IServiceProvider services,
string profileId,
string[]? selectors,
string? callbackUrl,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var selectorMap = ParseSelectorMap(selectors);
var request = new CreateEvidenceExportRequest(profileId, selectorMap, callbackUrl);
var response = await client.CreateEvidenceExportAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
AnsiConsole.MarkupLine($"[green]Export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
return 0;
}
internal static async Task<int> HandleExportStartAttestationAsync(
IServiceProvider services,
string profileId,
string[]? selectors,
bool includeTransparencyLog,
string? callbackUrl,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<IExportCenterClient>();
var selectorMap = ParseSelectorMap(selectors);
var request = new CreateAttestationExportRequest(profileId, selectorMap, includeTransparencyLog, callbackUrl);
var response = await client.CreateAttestationExportAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
AnsiConsole.MarkupLine($"[green]Attestation export started.[/] runId={Markup.Escape(response.RunId)} status={Markup.Escape(response.Status)}");
return 0;
}
private static IReadOnlyDictionary<string, string>? ParseSelectorMap(string[]? selectors)
{
if (selectors is null || selectors.Length == 0)
{
return null;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var selector in selectors)
{
if (string.IsNullOrWhiteSpace(selector))
{
continue;
}
var parts = selector.Split('=', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
{
AnsiConsole.MarkupLine($"[yellow]Ignoring selector with invalid format (expected key=value):[/] {Markup.Escape(selector)}");
continue;
}
result[parts[0]] = parts[1];
}
return result.Count == 0 ? null : result;
}
#endregion
#region Notify Handlers (CLI-PARITY-41-002)
internal static async Task<int> HandleNotifySimulateAsync(
IServiceProvider services,
string? tenant,
string? eventsFile,
string? rulesFile,
bool enabledOnly,
int? lookbackMinutes,
int? maxEvents,
string? eventKind,
bool includeNonMatches,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
var eventsPayload = LoadJsonElement(eventsFile);
var rulesPayload = LoadJsonElement(rulesFile);
var request = new NotifySimulationRequest
{
TenantId = tenant,
Events = eventsPayload,
Rules = rulesPayload,
EnabledRulesOnly = enabledOnly,
HistoricalLookbackMinutes = lookbackMinutes,
MaxEvents = maxEvents,
EventKindFilter = eventKind,
IncludeNonMatches = includeNonMatches
};
var result = await client.SimulateAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return 0;
}
AnsiConsole.MarkupLine(result.SimulationId is null
? "[yellow]Simulation completed.[/]"
: $"[green]Simulation {Markup.Escape(result.SimulationId)} completed.[/]");
var table = new Table();
table.AddColumn("Total Events");
table.AddColumn("Total Rules");
table.AddColumn("Matched Events");
table.AddColumn("Actions");
table.AddColumn("Duration (ms)");
table.AddRow(
(result.TotalEvents ?? 0).ToString(CultureInfo.InvariantCulture),
(result.TotalRules ?? 0).ToString(CultureInfo.InvariantCulture),
(result.MatchedEvents ?? 0).ToString(CultureInfo.InvariantCulture),
(result.TotalActionsTriggered ?? 0).ToString(CultureInfo.InvariantCulture),
result.DurationMs?.ToString("0.00", CultureInfo.InvariantCulture) ?? "-");
AnsiConsole.Write(table);
return 0;
}
internal static async Task<int> HandleNotifyAckAsync(
IServiceProvider services,
string? tenant,
string? incidentId,
string? token,
string? acknowledgedBy,
string? comment,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
if (string.IsNullOrWhiteSpace(token) && string.IsNullOrWhiteSpace(incidentId))
{
AnsiConsole.MarkupLine("[red]Either --token or --incident-id is required.[/]");
return 1;
}
var request = new NotifyAckRequest
{
TenantId = tenant,
IncidentId = incidentId,
Token = token,
AcknowledgedBy = acknowledgedBy,
Comment = comment
};
var result = await client.AckAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return 0;
}
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Acknowledge failed:[/] {Markup.Escape(result.Error ?? "unknown error")}");
return 1;
}
AnsiConsole.MarkupLine($"[green]Acknowledged.[/] incidentId={Markup.Escape(result.IncidentId ?? incidentId ?? "n/a")}");
return 0;
}
private static JsonElement? LoadJsonElement(string? filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return null;
}
try
{
var content = File.ReadAllText(filePath);
using var doc = JsonDocument.Parse(content);
return doc.RootElement.Clone();
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]Failed to load JSON from {Markup.Escape(filePath)}:[/] {Markup.Escape(ex.Message)}");
return null;
}
}
internal static async Task<int> HandleNotifyChannelsListAsync(
IServiceProvider services,
string? tenant,
string? channelType,
bool? enabled,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var request = new NotifyChannelListRequest
{
Tenant = tenant,
Type = channelType,
Enabled = enabled,
Limit = limit,
Cursor = cursor
};
var response = await client.ListChannelsAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No notification channels found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Channel ID");
table.AddColumn("Name");
table.AddColumn("Type");
table.AddColumn("Enabled");
table.AddColumn("Deliveries");
table.AddColumn("Failure Rate");
table.AddColumn("Updated");
foreach (var channel in response.Items)
{
var enabledMarkup = channel.Enabled ? "[green]Yes[/]" : "[grey]No[/]";
var failureRateMarkup = GetFailureRateMarkup(channel.FailureRate);
table.AddRow(
Markup.Escape(channel.ChannelId),
Markup.Escape(channel.DisplayName ?? channel.Name),
GetChannelTypeMarkup(channel.Type),
enabledMarkup,
channel.DeliveryCount.ToString(),
failureRateMarkup,
channel.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
}
AnsiConsole.Write(table);
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
}
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifyChannelsShowAsync(
IServiceProvider services,
string channelId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var channel = await client.GetChannelAsync(channelId, tenant, cancellationToken).ConfigureAwait(false);
if (channel == null)
{
AnsiConsole.MarkupLine($"[red]Channel not found: {Markup.Escape(channelId)}[/]");
return CliError.FromHttpStatus(404).ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(channel, JsonOptions));
return 0;
}
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Channel ID[/]", Markup.Escape(channel.ChannelId))
.AddRow("[bold]Name[/]", Markup.Escape(channel.DisplayName ?? channel.Name))
.AddRow("[bold]Type[/]", GetChannelTypeMarkup(channel.Type))
.AddRow("[bold]Enabled[/]", channel.Enabled ? "[green]Yes[/]" : "[grey]No[/]")
.AddRow("[bold]Tenant[/]", Markup.Escape(channel.TenantId))
.AddRow("[bold]Description[/]", Markup.Escape(channel.Description ?? "-"))
.AddRow("[bold]Created[/]", channel.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Updated[/]", channel.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Created By[/]", Markup.Escape(channel.CreatedBy ?? "-"))
.AddRow("[bold]Updated By[/]", Markup.Escape(channel.UpdatedBy ?? "-")));
panel.Header = new PanelHeader("Channel Details");
AnsiConsole.Write(panel);
// Configuration
if (channel.Config != null)
{
var configGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Secret Ref[/]", Markup.Escape(channel.Config.SecretRef))
.AddRow("[bold]Target[/]", Markup.Escape(channel.Config.Target ?? "-"))
.AddRow("[bold]Endpoint[/]", Markup.Escape(channel.Config.Endpoint ?? "-"));
if (channel.Config.Limits != null)
{
configGrid.AddRow("[bold]Concurrency[/]", channel.Config.Limits.Concurrency?.ToString() ?? "-");
configGrid.AddRow("[bold]Requests/Min[/]", channel.Config.Limits.RequestsPerMinute?.ToString() ?? "-");
configGrid.AddRow("[bold]Timeout (s)[/]", channel.Config.Limits.TimeoutSeconds?.ToString() ?? "-");
configGrid.AddRow("[bold]Max Batch Size[/]", channel.Config.Limits.MaxBatchSize?.ToString() ?? "-");
}
var configPanel = new Panel(configGrid) { Header = new PanelHeader("Configuration") };
AnsiConsole.Write(configPanel);
}
// Statistics
if (channel.Stats != null)
{
var statsGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Total Deliveries[/]", channel.Stats.TotalDeliveries.ToString())
.AddRow("[bold]Successful[/]", $"[green]{channel.Stats.SuccessfulDeliveries}[/]")
.AddRow("[bold]Failed[/]", channel.Stats.FailedDeliveries > 0 ? $"[red]{channel.Stats.FailedDeliveries}[/]" : "0")
.AddRow("[bold]Throttled[/]", channel.Stats.ThrottledDeliveries > 0 ? $"[yellow]{channel.Stats.ThrottledDeliveries}[/]" : "0")
.AddRow("[bold]Last Delivery[/]", channel.Stats.LastDeliveryAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
.AddRow("[bold]Avg Latency[/]", channel.Stats.AvgLatencyMs.HasValue ? $"{channel.Stats.AvgLatencyMs:F1} ms" : "-");
var statsPanel = new Panel(statsGrid) { Header = new PanelHeader("Statistics") };
AnsiConsole.Write(statsPanel);
}
// Health
if (channel.Health != null)
{
var healthMarkup = channel.Health.Status.ToLowerInvariant() switch
{
"healthy" => "[green]HEALTHY[/]",
"degraded" => "[yellow]DEGRADED[/]",
"unhealthy" => "[red]UNHEALTHY[/]",
_ => Markup.Escape(channel.Health.Status)
};
var healthGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Status[/]", healthMarkup)
.AddRow("[bold]Last Check[/]", channel.Health.LastCheckAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
.AddRow("[bold]Consecutive Failures[/]", channel.Health.ConsecutiveFailures > 0 ? $"[red]{channel.Health.ConsecutiveFailures}[/]" : "0");
if (!string.IsNullOrWhiteSpace(channel.Health.ErrorMessage))
{
healthGrid.AddRow("[bold]Error[/]", $"[red]{Markup.Escape(channel.Health.ErrorMessage)}[/]");
}
var healthPanel = new Panel(healthGrid) { Header = new PanelHeader("Health") };
AnsiConsole.Write(healthPanel);
}
// Labels
if (channel.Labels is { Count: > 0 })
{
var labelsTable = new Table();
labelsTable.AddColumn("Key");
labelsTable.AddColumn("Value");
foreach (var label in channel.Labels)
{
labelsTable.AddRow(Markup.Escape(label.Key), Markup.Escape(label.Value));
}
AnsiConsole.Write(new Panel(labelsTable) { Header = new PanelHeader("Labels") });
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifyChannelsTestAsync(
IServiceProvider services,
string channelId,
string? tenant,
string? message,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var request = new NotifyChannelTestRequest
{
ChannelId = channelId,
Tenant = tenant,
Message = message
};
var result = await client.TestChannelAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (result.Success)
{
AnsiConsole.MarkupLine($"[green]Test successful for channel {Markup.Escape(channelId)}[/]");
if (result.LatencyMs.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Latency: {result.LatencyMs} ms[/]");
}
if (!string.IsNullOrWhiteSpace(result.DeliveryId))
{
AnsiConsole.MarkupLine($"[grey]Delivery ID: {Markup.Escape(result.DeliveryId)}[/]");
}
return 0;
}
else
{
AnsiConsole.MarkupLine($"[red]Test failed for channel {Markup.Escape(channelId)}[/]");
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(result.ErrorMessage)}[/]");
}
if (result.ResponseCode.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Response code: {result.ResponseCode}[/]");
}
return 1;
}
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifyRulesListAsync(
IServiceProvider services,
string? tenant,
bool? enabled,
string? eventType,
string? channelId,
int? limit,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var request = new NotifyRuleListRequest
{
Tenant = tenant,
Enabled = enabled,
EventType = eventType,
ChannelId = channelId,
Limit = limit
};
var response = await client.ListRulesAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No notification rules found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Rule ID");
table.AddColumn("Name");
table.AddColumn("Enabled");
table.AddColumn("Priority");
table.AddColumn("Event Types");
table.AddColumn("Channels");
table.AddColumn("Matches");
foreach (var rule in response.Items)
{
var enabledMarkup = rule.Enabled ? "[green]Yes[/]" : "[grey]No[/]";
var eventTypes = rule.EventTypes.Count > 0 ? string.Join(", ", rule.EventTypes.Take(3)) : "-";
if (rule.EventTypes.Count > 3)
{
eventTypes += $" (+{rule.EventTypes.Count - 3})";
}
var channelCount = rule.ChannelIds.Count.ToString();
table.AddRow(
Markup.Escape(rule.RuleId),
Markup.Escape(rule.Name),
enabledMarkup,
rule.Priority.ToString(),
Markup.Escape(eventTypes),
channelCount,
rule.MatchCount.ToString());
}
AnsiConsole.Write(table);
if (response.HasMore)
{
AnsiConsole.MarkupLine("[grey]More results available.[/]");
}
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifyDeliveriesListAsync(
IServiceProvider services,
string? tenant,
string? status,
string? eventType,
string? channelId,
DateTimeOffset? since,
DateTimeOffset? until,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var request = new NotifyDeliveryListRequest
{
Tenant = tenant,
Status = status,
EventType = eventType,
ChannelId = channelId,
Since = since,
Until = until,
Limit = limit,
Cursor = cursor
};
var response = await client.ListDeliveriesAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No notification deliveries found.[/]");
return 0;
}
var table = new Table();
table.AddColumn("Delivery ID");
table.AddColumn("Channel");
table.AddColumn("Type");
table.AddColumn("Event");
table.AddColumn("Status");
table.AddColumn("Attempts");
table.AddColumn("Latency");
table.AddColumn("Created");
foreach (var delivery in response.Items)
{
var statusMarkup = GetDeliveryStatusMarkup(delivery.Status);
var latency = delivery.LatencyMs.HasValue ? $"{delivery.LatencyMs} ms" : "-";
table.AddRow(
Markup.Escape(delivery.DeliveryId),
Markup.Escape(delivery.ChannelName ?? delivery.ChannelId),
GetChannelTypeMarkup(delivery.ChannelType),
Markup.Escape(delivery.EventType),
statusMarkup,
delivery.AttemptCount.ToString(),
latency,
delivery.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
}
AnsiConsole.Write(table);
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
}
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifyDeliveriesShowAsync(
IServiceProvider services,
string deliveryId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var delivery = await client.GetDeliveryAsync(deliveryId, tenant, cancellationToken).ConfigureAwait(false);
if (delivery == null)
{
AnsiConsole.MarkupLine($"[red]Delivery not found: {Markup.Escape(deliveryId)}[/]");
return CliError.FromHttpStatus(404).ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(delivery, JsonOptions));
return 0;
}
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Delivery ID[/]", Markup.Escape(delivery.DeliveryId))
.AddRow("[bold]Channel[/]", Markup.Escape(delivery.ChannelName ?? delivery.ChannelId))
.AddRow("[bold]Channel Type[/]", GetChannelTypeMarkup(delivery.ChannelType))
.AddRow("[bold]Event Type[/]", Markup.Escape(delivery.EventType))
.AddRow("[bold]Status[/]", GetDeliveryStatusMarkup(delivery.Status))
.AddRow("[bold]Subject[/]", Markup.Escape(delivery.Subject ?? "-"))
.AddRow("[bold]Tenant[/]", Markup.Escape(delivery.TenantId))
.AddRow("[bold]Rule ID[/]", Markup.Escape(delivery.RuleId ?? "-"))
.AddRow("[bold]Event ID[/]", Markup.Escape(delivery.EventId ?? "-"))
.AddRow("[bold]Created[/]", delivery.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Sent[/]", delivery.SentAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
.AddRow("[bold]Failed[/]", delivery.FailedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")
.AddRow("[bold]Idempotency Key[/]", Markup.Escape(delivery.IdempotencyKey ?? "-")));
panel.Header = new PanelHeader("Delivery Details");
AnsiConsole.Write(panel);
if (!string.IsNullOrWhiteSpace(delivery.ErrorMessage))
{
AnsiConsole.Write(new Panel($"[red]{Markup.Escape(delivery.ErrorMessage)}[/]") { Header = new PanelHeader("Error") });
}
// Attempts
if (delivery.Attempts is { Count: > 0 })
{
var attemptsTable = new Table();
attemptsTable.AddColumn("#");
attemptsTable.AddColumn("Status");
attemptsTable.AddColumn("Attempted");
attemptsTable.AddColumn("Latency");
attemptsTable.AddColumn("Response");
attemptsTable.AddColumn("Error");
foreach (var attempt in delivery.Attempts)
{
var attemptStatusMarkup = GetDeliveryStatusMarkup(attempt.Status);
var latency = attempt.LatencyMs.HasValue ? $"{attempt.LatencyMs} ms" : "-";
var responseCode = attempt.ResponseCode?.ToString() ?? "-";
var errorMsg = attempt.ErrorMessage ?? "-";
if (errorMsg.Length > 50)
{
errorMsg = errorMsg[..47] + "...";
}
attemptsTable.AddRow(
attempt.AttemptNumber.ToString(),
attemptStatusMarkup,
attempt.AttemptedAt.ToString("yyyy-MM-dd HH:mm:ss"),
latency,
responseCode,
Markup.Escape(errorMsg));
}
AnsiConsole.Write(new Panel(attemptsTable) { Header = new PanelHeader($"Attempts ({delivery.AttemptCount})") });
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifyDeliveriesRetryAsync(
IServiceProvider services,
string deliveryId,
string? tenant,
string? idempotencyKey,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
var request = new NotifyRetryRequest
{
DeliveryId = deliveryId,
Tenant = tenant,
IdempotencyKey = idempotencyKey
};
var result = await client.RetryDeliveryAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (result.Success)
{
AnsiConsole.MarkupLine($"[green]Retry queued for delivery {Markup.Escape(deliveryId)}[/]");
if (!string.IsNullOrWhiteSpace(result.NewStatus))
{
AnsiConsole.MarkupLine($"[grey]New status: {GetDeliveryStatusMarkup(result.NewStatus)}[/]");
}
if (!string.IsNullOrWhiteSpace(result.AuditEventId))
{
AnsiConsole.MarkupLine($"[grey]Audit event: {Markup.Escape(result.AuditEventId)}[/]");
}
return 0;
}
else
{
AnsiConsole.MarkupLine($"[red]Retry failed for delivery {Markup.Escape(deliveryId)}[/]");
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
}
return 1;
}
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleNotifySendAsync(
IServiceProvider services,
string eventType,
string body,
string? tenant,
string? channelId,
string? subject,
string? severity,
string[]? metadata,
string? idempotencyKey,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<INotifyClient>();
try
{
// Parse metadata key=value pairs
Dictionary<string, string>? metadataDict = null;
if (metadata is { Length: > 0 })
{
metadataDict = new Dictionary<string, string>();
foreach (var item in metadata)
{
var parts = item.Split('=', 2);
if (parts.Length == 2)
{
metadataDict[parts[0]] = parts[1];
}
}
}
var request = new NotifySendRequest
{
EventType = eventType,
Body = body,
Tenant = tenant,
ChannelId = channelId,
Subject = subject,
Severity = severity,
Metadata = metadataDict,
IdempotencyKey = idempotencyKey
};
var result = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 1;
}
if (result.Success)
{
AnsiConsole.MarkupLine($"[green]Notification sent successfully[/]");
if (!string.IsNullOrWhiteSpace(result.EventId))
{
AnsiConsole.MarkupLine($"[grey]Event ID: {Markup.Escape(result.EventId)}[/]");
}
AnsiConsole.MarkupLine($"[grey]Channels matched: {result.ChannelsMatched}[/]");
if (result.DeliveryIds is { Count: > 0 })
{
AnsiConsole.MarkupLine($"[grey]Deliveries: {string.Join(", ", result.DeliveryIds.Take(5))}[/]");
if (result.DeliveryIds.Count > 5)
{
AnsiConsole.MarkupLine($"[grey] (+{result.DeliveryIds.Count - 5} more)[/]");
}
}
if (!string.IsNullOrWhiteSpace(result.IdempotencyKey))
{
AnsiConsole.MarkupLine($"[grey]Idempotency key: {Markup.Escape(result.IdempotencyKey)}[/]");
}
return 0;
}
else
{
AnsiConsole.MarkupLine("[red]Failed to send notification[/]");
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
}
return 1;
}
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
private static string GetChannelTypeMarkup(string type)
{
return type.ToLowerInvariant() switch
{
"slack" => "[bold purple]Slack[/]",
"teams" => "[bold blue]Teams[/]",
"email" => "[bold cyan]Email[/]",
"webhook" => "[bold yellow]Webhook[/]",
"pagerduty" => "[bold green]PagerDuty[/]",
"opsgenie" => "[bold orange1]OpsGenie[/]",
"cli" => "[bold grey]CLI[/]",
"inappinbox" or "inapp" => "[bold grey]InApp[/]",
_ => Markup.Escape(type)
};
}
private static string GetDeliveryStatusMarkup(string status)
{
return status.ToLowerInvariant() switch
{
"pending" => "[yellow]Pending[/]",
"sent" => "[green]Sent[/]",
"failed" => "[red]Failed[/]",
"throttled" => "[orange1]Throttled[/]",
"digested" => "[blue]Digested[/]",
"dropped" => "[grey]Dropped[/]",
_ => Markup.Escape(status)
};
}
private static string GetFailureRateMarkup(double? rate)
{
if (!rate.HasValue)
return "[grey]-[/]";
return rate.Value switch
{
0 => "[green]0%[/]",
< 0.1 => $"[green]{rate.Value:P1}[/]",
< 0.25 => $"[yellow]{rate.Value:P1}[/]",
_ => $"[red]{rate.Value:P1}[/]"
};
}
#endregion
#region Sbomer Handlers (CLI-SBOM-60-001)
internal static async Task<int> HandleSbomerLayerListAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? imageRef,
string? digest,
int? limit,
string? cursor,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerLayerListRequest
{
Tenant = tenant,
ScanId = scanId,
ImageRef = imageRef,
Digest = digest,
Limit = limit,
Cursor = cursor
};
var response = await client.ListLayersAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
return 0;
}
if (response.Items.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No layer fragments found.[/]");
return 0;
}
if (!string.IsNullOrWhiteSpace(response.ImageRef))
{
AnsiConsole.MarkupLine($"[grey]Image: {Markup.Escape(response.ImageRef)}[/]");
}
if (!string.IsNullOrWhiteSpace(response.ScanId))
{
AnsiConsole.MarkupLine($"[grey]Scan ID: {Markup.Escape(response.ScanId)}[/]");
}
var table = new Table();
table.AddColumn("Layer Digest");
table.AddColumn("Fragment SHA256");
table.AddColumn("Components");
table.AddColumn("Format");
table.AddColumn("DSSE");
table.AddColumn("Created");
foreach (var fragment in response.Items)
{
var layerDigestShort = fragment.LayerDigest.Length > 20
? fragment.LayerDigest[..20] + "..."
: fragment.LayerDigest;
var fragmentHashShort = fragment.FragmentSha256.Length > 16
? fragment.FragmentSha256[..16] + "..."
: fragment.FragmentSha256;
var dsseStatus = fragment.SignatureValid switch
{
true => "[green]Valid[/]",
false => "[red]Invalid[/]",
null => string.IsNullOrWhiteSpace(fragment.DsseEnvelopeSha256) ? "[grey]-[/]" : "[yellow]?[/]"
};
table.AddRow(
Markup.Escape(layerDigestShort),
Markup.Escape(fragmentHashShort),
fragment.ComponentCount.ToString(),
Markup.Escape(fragment.Format),
dsseStatus,
fragment.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"));
}
AnsiConsole.Write(table);
if (response.HasMore && !string.IsNullOrWhiteSpace(response.NextCursor))
{
AnsiConsole.MarkupLine($"[grey]More results available. Use --cursor {Markup.Escape(response.NextCursor)}[/]");
}
AnsiConsole.MarkupLine($"[grey]Total: {response.Total}[/]");
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerLayerShowAsync(
IServiceProvider services,
string layerDigest,
string? tenant,
string? scanId,
bool includeComponents,
bool includeDsse,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerLayerShowRequest
{
LayerDigest = layerDigest,
Tenant = tenant,
ScanId = scanId,
IncludeComponents = includeComponents,
IncludeDsse = includeDsse
};
var detail = await client.GetLayerAsync(request, cancellationToken).ConfigureAwait(false);
if (detail == null)
{
AnsiConsole.MarkupLine($"[red]Layer fragment not found: {Markup.Escape(layerDigest)}[/]");
return CliError.FromHttpStatus(404).ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(detail, JsonOptions));
return 0;
}
var fragment = detail.Fragment;
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Layer Digest[/]", Markup.Escape(fragment.LayerDigest))
.AddRow("[bold]Fragment SHA256[/]", Markup.Escape(fragment.FragmentSha256))
.AddRow("[bold]Components[/]", fragment.ComponentCount.ToString())
.AddRow("[bold]Format[/]", Markup.Escape(fragment.Format))
.AddRow("[bold]Created[/]", fragment.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Signature Algorithm[/]", Markup.Escape(fragment.SignatureAlgorithm ?? "-"))
.AddRow("[bold]Signature Valid[/]", fragment.SignatureValid switch
{
true => "[green]Yes[/]",
false => "[red]No[/]",
null => "[grey]-[/]"
}));
panel.Header = new PanelHeader("Layer Fragment");
AnsiConsole.Write(panel);
// DSSE Envelope
if (includeDsse && detail.DsseEnvelope != null)
{
var dsseGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Payload Type[/]", Markup.Escape(detail.DsseEnvelope.PayloadType))
.AddRow("[bold]Payload SHA256[/]", Markup.Escape(detail.DsseEnvelope.PayloadSha256))
.AddRow("[bold]Envelope SHA256[/]", Markup.Escape(detail.DsseEnvelope.EnvelopeSha256));
var dssePanel = new Panel(dsseGrid) { Header = new PanelHeader("DSSE Envelope") };
AnsiConsole.Write(dssePanel);
if (detail.DsseEnvelope.Signatures.Count > 0)
{
var sigTable = new Table();
sigTable.AddColumn("Key ID");
sigTable.AddColumn("Algorithm");
sigTable.AddColumn("Valid");
sigTable.AddColumn("Certificate Subject");
sigTable.AddColumn("Expiry");
foreach (var sig in detail.DsseEnvelope.Signatures)
{
var validMarkup = sig.Valid switch
{
true => "[green]Yes[/]",
false => "[red]No[/]",
null => "[grey]-[/]"
};
sigTable.AddRow(
Markup.Escape(sig.KeyId ?? "-"),
Markup.Escape(sig.Algorithm),
validMarkup,
Markup.Escape(sig.CertificateSubject ?? "-"),
sig.CertificateExpiry?.ToString("yyyy-MM-dd") ?? "-");
}
AnsiConsole.Write(new Panel(sigTable) { Header = new PanelHeader("Signatures") });
}
}
// Merkle Proof
if (detail.MerkleProof != null)
{
var merkleGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Leaf Hash[/]", Markup.Escape(detail.MerkleProof.LeafHash))
.AddRow("[bold]Root Hash[/]", Markup.Escape(detail.MerkleProof.RootHash))
.AddRow("[bold]Leaf Index[/]", detail.MerkleProof.LeafIndex.ToString())
.AddRow("[bold]Tree Size[/]", detail.MerkleProof.TreeSize.ToString())
.AddRow("[bold]Valid[/]", detail.MerkleProof.Valid switch
{
true => "[green]Yes[/]",
false => "[red]No[/]",
null => "[grey]-[/]"
});
AnsiConsole.Write(new Panel(merkleGrid) { Header = new PanelHeader("Merkle Proof") });
}
// Components
if (includeComponents && fragment.Components is { Count: > 0 })
{
var compTable = new Table();
compTable.AddColumn("PURL / Name");
compTable.AddColumn("Version");
compTable.AddColumn("Type");
foreach (var comp in fragment.Components.Take(50))
{
compTable.AddRow(
Markup.Escape(comp.Purl ?? comp.Name),
Markup.Escape(comp.Version ?? "-"),
Markup.Escape(comp.Type ?? "-"));
}
AnsiConsole.Write(new Panel(compTable) { Header = new PanelHeader($"Components ({fragment.Components.Count})") });
if (fragment.Components.Count > 50)
{
AnsiConsole.MarkupLine($"[grey] (+{fragment.Components.Count - 50} more components)[/]");
}
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerLayerVerifyAsync(
IServiceProvider services,
string layerDigest,
string? tenant,
string? scanId,
string? verifiersPath,
bool offline,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerLayerVerifyRequest
{
LayerDigest = layerDigest,
Tenant = tenant,
ScanId = scanId,
VerifiersPath = verifiersPath,
Offline = offline
};
var result = await client.VerifyLayerAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Valid ? 0 : 20;
}
if (result.Valid)
{
AnsiConsole.MarkupLine($"[green]Layer fragment verification PASSED[/]");
AnsiConsole.MarkupLine($"[grey]Layer: {Markup.Escape(result.LayerDigest)}[/]");
AnsiConsole.MarkupLine($"[grey]DSSE Valid: {(result.DsseValid ? "[green]Yes[/]" : "[red]No[/]")}[/]");
AnsiConsole.MarkupLine($"[grey]Content Hash Match: {(result.ContentHashMatch ? "[green]Yes[/]" : "[red]No[/]")}[/]");
if (result.MerkleProofValid.HasValue)
{
AnsiConsole.MarkupLine($"[grey]Merkle Proof Valid: {(result.MerkleProofValid.Value ? "[green]Yes[/]" : "[red]No[/]")}[/]");
}
if (!string.IsNullOrWhiteSpace(result.SignatureAlgorithm))
{
AnsiConsole.MarkupLine($"[grey]Signature Algorithm: {Markup.Escape(result.SignatureAlgorithm)}[/]");
}
}
else
{
AnsiConsole.MarkupLine($"[red]Layer fragment verification FAILED[/]");
AnsiConsole.MarkupLine($"[grey]Layer: {Markup.Escape(result.LayerDigest)}[/]");
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
}
}
if (result.Warnings is { Count: > 0 })
{
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
return result.Valid ? 0 : 20;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerComposeAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? imageRef,
string? digest,
string? outputPath,
string? format,
bool verifyFragments,
string? verifiersPath,
bool offline,
bool emitManifest,
bool emitMerkle,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerComposeRequest
{
Tenant = tenant,
ScanId = scanId,
ImageRef = imageRef,
Digest = digest,
OutputPath = outputPath,
Format = format,
VerifyFragments = verifyFragments,
VerifiersPath = verifiersPath,
Offline = offline,
EmitCompositionManifest = emitManifest,
EmitMerkleDiagnostics = emitMerkle
};
var result = await client.ComposeAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Success ? 0 : 20;
}
if (result.Success)
{
AnsiConsole.MarkupLine("[green]SBOM composition successful[/]");
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
.AddRow("[bold]Fragments[/]", result.FragmentCount.ToString())
.AddRow("[bold]Total Components[/]", result.TotalComponents.ToString())
.AddRow("[bold]Deterministic[/]", result.Deterministic ? "[green]Yes[/]" : "[yellow]No[/]");
if (!string.IsNullOrWhiteSpace(result.ComposedSha256))
grid.AddRow("[bold]Composed SHA256[/]", Markup.Escape(result.ComposedSha256));
if (!string.IsNullOrWhiteSpace(result.MerkleRoot))
grid.AddRow("[bold]Merkle Root[/]", Markup.Escape(result.MerkleRoot));
if (!string.IsNullOrWhiteSpace(result.OutputPath))
grid.AddRow("[bold]Output[/]", Markup.Escape(result.OutputPath));
if (!string.IsNullOrWhiteSpace(result.CompositionManifestPath))
grid.AddRow("[bold]Manifest[/]", Markup.Escape(result.CompositionManifestPath));
if (!string.IsNullOrWhiteSpace(result.MerkleDiagnosticsPath))
grid.AddRow("[bold]Merkle Diagnostics[/]", Markup.Escape(result.MerkleDiagnosticsPath));
if (result.Duration.HasValue)
grid.AddRow("[bold]Duration[/]", $"{result.Duration.Value.TotalSeconds:F2}s");
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Composition Result") });
if (result.FragmentVerifications is { Count: > 0 })
{
var failedVerifications = result.FragmentVerifications.Where(v => !v.Valid).ToList();
if (failedVerifications.Count > 0)
{
AnsiConsole.MarkupLine($"[yellow]{failedVerifications.Count} fragment(s) failed verification[/]");
}
}
}
else
{
AnsiConsole.MarkupLine("[red]SBOM composition failed[/]");
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
}
}
if (result.Warnings is { Count: > 0 })
{
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
return result.Success ? 0 : 20;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerCompositionShowAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? compositionPath,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerCompositionShowRequest
{
Tenant = tenant,
ScanId = scanId,
CompositionPath = compositionPath
};
var manifest = await client.GetCompositionManifestAsync(request, cancellationToken).ConfigureAwait(false);
if (manifest == null)
{
AnsiConsole.MarkupLine("[red]Composition manifest not found[/]");
return CliError.FromHttpStatus(404).ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(manifest, JsonOptions));
return 0;
}
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Version[/]", Markup.Escape(manifest.Version))
.AddRow("[bold]Scan ID[/]", Markup.Escape(manifest.ScanId))
.AddRow("[bold]Image[/]", Markup.Escape(manifest.ImageRef ?? "-"))
.AddRow("[bold]Digest[/]", Markup.Escape(manifest.Digest ?? "-"))
.AddRow("[bold]Merkle Root[/]", Markup.Escape(manifest.MerkleRoot))
.AddRow("[bold]Composed SHA256[/]", Markup.Escape(manifest.ComposedSha256))
.AddRow("[bold]Created[/]", manifest.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"))
.AddRow("[bold]Fragments[/]", manifest.Fragments.Count.ToString()));
panel.Header = new PanelHeader("Composition Manifest");
AnsiConsole.Write(panel);
// Fragments table
if (manifest.Fragments.Count > 0)
{
var fragTable = new Table();
fragTable.AddColumn("#");
fragTable.AddColumn("Layer Digest");
fragTable.AddColumn("Fragment SHA256");
fragTable.AddColumn("Components");
fragTable.AddColumn("DSSE");
foreach (var frag in manifest.Fragments.OrderBy(f => f.Order))
{
var layerShort = frag.LayerDigest.Length > 20 ? frag.LayerDigest[..20] + "..." : frag.LayerDigest;
var fragShort = frag.FragmentSha256.Length > 16 ? frag.FragmentSha256[..16] + "..." : frag.FragmentSha256;
var dsseMarkup = string.IsNullOrWhiteSpace(frag.DsseEnvelopeSha256) ? "[grey]-[/]" : "[green]Yes[/]";
fragTable.AddRow(
frag.Order.ToString(),
Markup.Escape(layerShort),
Markup.Escape(fragShort),
frag.ComponentCount.ToString(),
dsseMarkup);
}
AnsiConsole.Write(new Panel(fragTable) { Header = new PanelHeader("Fragments (Canonical Order)") });
}
// Properties
if (manifest.Properties is { Count: > 0 })
{
var propTable = new Table();
propTable.AddColumn("Property");
propTable.AddColumn("Value");
foreach (var prop in manifest.Properties)
{
propTable.AddRow(Markup.Escape(prop.Key), Markup.Escape(prop.Value));
}
AnsiConsole.Write(new Panel(propTable) { Header = new PanelHeader("Properties") });
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerCompositionVerifyAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? compositionPath,
string? sbomPath,
string? verifiersPath,
bool offline,
bool recompose,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerCompositionVerifyRequest
{
Tenant = tenant,
ScanId = scanId,
CompositionPath = compositionPath,
SbomPath = sbomPath,
VerifiersPath = verifiersPath,
Offline = offline,
Recompose = recompose
};
var result = await client.VerifyCompositionAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Valid ? 0 : 20;
}
if (result.Valid)
{
AnsiConsole.MarkupLine("[green]Composition verification PASSED[/]");
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
.AddRow("[bold]Merkle Root Match[/]", result.MerkleRootMatch ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]Composed Hash Match[/]", result.ComposedHashMatch ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]All Fragments Valid[/]", result.AllFragmentsValid ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]Fragment Count[/]", result.FragmentCount.ToString())
.AddRow("[bold]Deterministic[/]", result.Deterministic ? "[green]Yes[/]" : "[yellow]No[/]");
if (!string.IsNullOrWhiteSpace(result.RecomposedHash))
grid.AddRow("[bold]Recomposed Hash[/]", Markup.Escape(result.RecomposedHash));
if (!string.IsNullOrWhiteSpace(result.ExpectedHash))
grid.AddRow("[bold]Expected Hash[/]", Markup.Escape(result.ExpectedHash));
AnsiConsole.Write(new Panel(grid) { Header = new PanelHeader("Verification Result") });
}
else
{
AnsiConsole.MarkupLine("[red]Composition verification FAILED[/]");
AnsiConsole.MarkupLine($"[grey]Scan ID: {Markup.Escape(result.ScanId)}[/]");
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red] - {Markup.Escape(error)}[/]");
}
}
}
if (result.Warnings is { Count: > 0 })
{
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
return result.Valid ? 0 : 20;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerCompositionMerkleAsync(
IServiceProvider services,
string scanId,
string? tenant,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var diagnostics = await client.GetMerkleDiagnosticsAsync(scanId, tenant, cancellationToken).ConfigureAwait(false);
if (diagnostics == null)
{
AnsiConsole.MarkupLine("[red]Merkle diagnostics not found[/]");
return CliError.FromHttpStatus(404).ExitCode;
}
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(diagnostics, JsonOptions));
return 0;
}
var panel = new Panel(new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Scan ID[/]", Markup.Escape(diagnostics.ScanId))
.AddRow("[bold]Root Hash[/]", Markup.Escape(diagnostics.RootHash))
.AddRow("[bold]Tree Size[/]", diagnostics.TreeSize.ToString())
.AddRow("[bold]Valid[/]", diagnostics.Valid ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]Created[/]", diagnostics.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")));
panel.Header = new PanelHeader("Merkle Tree Diagnostics");
AnsiConsole.Write(panel);
// Leaves
if (diagnostics.Leaves.Count > 0)
{
var leafTable = new Table();
leafTable.AddColumn("Index");
leafTable.AddColumn("Layer Digest");
leafTable.AddColumn("Leaf Hash");
leafTable.AddColumn("Fragment SHA256");
foreach (var leaf in diagnostics.Leaves.OrderBy(l => l.Index))
{
var layerShort = leaf.LayerDigest.Length > 20 ? leaf.LayerDigest[..20] + "..." : leaf.LayerDigest;
var leafHashShort = leaf.Hash.Length > 16 ? leaf.Hash[..16] + "..." : leaf.Hash;
var fragHashShort = leaf.FragmentSha256.Length > 16 ? leaf.FragmentSha256[..16] + "..." : leaf.FragmentSha256;
leafTable.AddRow(
leaf.Index.ToString(),
Markup.Escape(layerShort),
Markup.Escape(leafHashShort),
Markup.Escape(fragHashShort));
}
AnsiConsole.Write(new Panel(leafTable) { Header = new PanelHeader("Merkle Leaves") });
}
// Intermediate nodes (verbose only)
if (verbose && diagnostics.IntermediateNodes is { Count: > 0 })
{
var nodeTable = new Table();
nodeTable.AddColumn("Level");
nodeTable.AddColumn("Index");
nodeTable.AddColumn("Hash");
nodeTable.AddColumn("Left Child");
nodeTable.AddColumn("Right Child");
foreach (var node in diagnostics.IntermediateNodes.OrderBy(n => n.Level).ThenBy(n => n.Index))
{
var hashShort = node.Hash.Length > 16 ? node.Hash[..16] + "..." : node.Hash;
var leftShort = (node.LeftChild?.Length > 12 ? node.LeftChild[..12] + "..." : node.LeftChild) ?? "-";
var rightShort = (node.RightChild?.Length > 12 ? node.RightChild[..12] + "..." : node.RightChild) ?? "-";
nodeTable.AddRow(
node.Level.ToString(),
node.Index.ToString(),
Markup.Escape(hashShort),
Markup.Escape(leftShort),
Markup.Escape(rightShort));
}
AnsiConsole.Write(new Panel(nodeTable) { Header = new PanelHeader("Intermediate Nodes") });
}
return 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
// CLI-SBOM-60-002: Drift detection handlers
internal static async Task<int> HandleSbomerDriftAnalyzeAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? baselineScanId,
string? sbomPath,
string? baselinePath,
string? compositionPath,
bool explain,
bool offline,
string? offlineKitPath,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var options = services.GetRequiredService<StellaOpsCliOptions>();
if (!offline && !OfflineModeGuard.IsNetworkAllowed(options, "sbomer drift analyze"))
{
return CliError.FromCode(CliError.OfflineMode).ExitCode;
}
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerDriftRequest
{
Tenant = tenant,
ScanId = scanId,
BaselineScanId = baselineScanId,
SbomPath = sbomPath,
BaselinePath = baselinePath,
CompositionPath = compositionPath,
Explain = explain,
Offline = offline,
OfflineKitPath = offlineKitPath
};
var result = await client.AnalyzeDriftAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.HasDrift && !result.Deterministic ? 1 : 0;
}
// Summary panel
var statusColor = result.HasDrift ? (result.Deterministic ? "yellow" : "red") : "green";
var statusText = result.HasDrift ? (result.Deterministic ? "Drift Detected (Deterministic)" : "Drift Detected (Non-Deterministic)") : "No Drift";
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Status[/]", $"[{statusColor}]{Markup.Escape(statusText)}[/]")
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
.AddRow("[bold]Baseline Scan ID[/]", Markup.Escape(result.BaselineScanId ?? "N/A"))
.AddRow("[bold]Current Hash[/]", Markup.Escape(result.CurrentHash ?? "N/A"))
.AddRow("[bold]Baseline Hash[/]", Markup.Escape(result.BaselineHash ?? "N/A"));
var panel = new Panel(grid) { Header = new PanelHeader("Drift Analysis") };
AnsiConsole.Write(panel);
// Summary statistics
if (result.Summary != null)
{
var summaryGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Components Added[/]", result.Summary.ComponentsAdded.ToString())
.AddRow("[bold]Components Removed[/]", result.Summary.ComponentsRemoved.ToString())
.AddRow("[bold]Components Modified[/]", result.Summary.ComponentsModified.ToString())
.AddRow("[bold]Array Ordering Drifts[/]", result.Summary.ArrayOrderingDrifts.ToString())
.AddRow("[bold]Timestamp Drifts[/]", result.Summary.TimestampDrifts.ToString())
.AddRow("[bold]Key Ordering Drifts[/]", result.Summary.KeyOrderingDrifts.ToString())
.AddRow("[bold]Whitespace Drifts[/]", result.Summary.WhitespaceDrifts.ToString())
.AddRow("[bold]Total Drifts[/]", $"[bold]{result.Summary.TotalDrifts}[/]");
AnsiConsole.Write(new Panel(summaryGrid) { Header = new PanelHeader("Summary") });
}
// Drift details table
if (result.Details is { Count: > 0 })
{
var table = new Table();
table.AddColumn("Path");
table.AddColumn("Type");
table.AddColumn("Severity");
table.AddColumn("Breaks Determinism");
table.AddColumn("Description");
foreach (var detail in result.Details.Take(50)) // Limit to 50 for display
{
var severityColor = detail.Severity switch
{
"error" => "red",
"warning" => "yellow",
_ => "dim"
};
var pathShort = detail.Path.Length > 40 ? "..." + detail.Path[^37..] : detail.Path;
var descShort = detail.Description.Length > 50 ? detail.Description[..47] + "..." : detail.Description;
table.AddRow(
Markup.Escape(pathShort),
Markup.Escape(detail.Type),
$"[{severityColor}]{Markup.Escape(detail.Severity)}[/]",
detail.BreaksDeterminism ? "[red]Yes[/]" : "[green]No[/]",
Markup.Escape(descShort));
}
if (result.Details.Count > 50)
{
AnsiConsole.MarkupLine($"[dim]Showing 50 of {result.Details.Count} drifts. Use --json for full list.[/]");
}
AnsiConsole.Write(new Panel(table) { Header = new PanelHeader("Drift Details") });
}
// Explanations (when --explain is used)
if (explain && result.Explanations is { Count: > 0 })
{
AnsiConsole.MarkupLine("\n[bold blue]Drift Explanations[/]");
AnsiConsole.WriteLine();
foreach (var explanation in result.Explanations)
{
var expGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Path[/]", Markup.Escape(explanation.Path))
.AddRow("[bold]Reason[/]", Markup.Escape(explanation.Reason));
if (!string.IsNullOrWhiteSpace(explanation.ExpectedBehavior))
expGrid.AddRow("[bold]Expected[/]", Markup.Escape(explanation.ExpectedBehavior));
if (!string.IsNullOrWhiteSpace(explanation.ActualBehavior))
expGrid.AddRow("[bold]Actual[/]", Markup.Escape(explanation.ActualBehavior));
if (!string.IsNullOrWhiteSpace(explanation.RootCause))
expGrid.AddRow("[bold]Root Cause[/]", Markup.Escape(explanation.RootCause));
if (!string.IsNullOrWhiteSpace(explanation.Remediation))
expGrid.AddRow("[bold cyan]Remediation[/]", $"[cyan]{Markup.Escape(explanation.Remediation)}[/]");
if (!string.IsNullOrWhiteSpace(explanation.DocumentationUrl))
expGrid.AddRow("[bold]Documentation[/]", $"[link]{Markup.Escape(explanation.DocumentationUrl)}[/]");
if (explanation.AffectedLayers is { Count: > 0 })
{
var layersList = string.Join(", ", explanation.AffectedLayers.Select(l => l.Length > 20 ? l[..17] + "..." : l));
expGrid.AddRow("[bold]Affected Layers[/]", Markup.Escape(layersList));
}
AnsiConsole.Write(new Panel(expGrid) { Header = new PanelHeader($"Explanation: {(explanation.Path.Length > 30 ? "..." + explanation.Path[^27..] : explanation.Path)}") });
AnsiConsole.WriteLine();
}
}
// Errors and warnings
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
if (verbose && result.Warnings is { Count: > 0 })
{
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
// Return non-zero if non-deterministic drift detected
return result.HasDrift && !result.Deterministic ? 1 : 0;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
internal static async Task<int> HandleSbomerDriftVerifyAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? sbomPath,
string? compositionPath,
string? verifiersPath,
string? offlineKitPath,
bool recomposeLocally,
bool validateFragments,
bool checkMerkle,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
SetVerbosity(services, verbose);
var client = services.GetRequiredService<ISbomerClient>();
try
{
var request = new SbomerDriftVerifyRequest
{
Tenant = tenant,
ScanId = scanId,
SbomPath = sbomPath,
CompositionPath = compositionPath,
VerifiersPath = verifiersPath,
OfflineKitPath = offlineKitPath,
RecomposeLocally = recomposeLocally,
ValidateFragments = validateFragments,
CheckMerkleProofs = checkMerkle
};
var result = await client.VerifyDriftAsync(request, cancellationToken).ConfigureAwait(false);
if (json)
{
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, JsonOptions));
return result.Valid ? 0 : 1;
}
// Main status panel
var statusColor = result.Valid ? "green" : "red";
var statusText = result.Valid ? "Verified" : "Verification Failed";
var grid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Status[/]", $"[{statusColor}]{Markup.Escape(statusText)}[/]")
.AddRow("[bold]Scan ID[/]", Markup.Escape(result.ScanId))
.AddRow("[bold]Deterministic[/]", result.Deterministic ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]Composition Valid[/]", result.CompositionValid ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]Fragments Valid[/]", result.FragmentsValid ? "[green]Yes[/]" : "[red]No[/]")
.AddRow("[bold]Merkle Proofs Valid[/]", result.MerkleProofsValid ? "[green]Yes[/]" : "[red]No[/]");
if (result.RecomposedHashMatch.HasValue)
{
grid.AddRow("[bold]Recomposed Hash Match[/]", result.RecomposedHashMatch.Value ? "[green]Yes[/]" : "[red]No[/]");
}
if (!string.IsNullOrWhiteSpace(result.CurrentHash))
{
var hashShort = result.CurrentHash.Length > 32 ? result.CurrentHash[..32] + "..." : result.CurrentHash;
grid.AddRow("[bold]Current Hash[/]", Markup.Escape(hashShort));
}
if (!string.IsNullOrWhiteSpace(result.RecomposedHash))
{
var hashShort = result.RecomposedHash.Length > 32 ? result.RecomposedHash[..32] + "..." : result.RecomposedHash;
grid.AddRow("[bold]Recomposed Hash[/]", Markup.Escape(hashShort));
}
var panel = new Panel(grid) { Header = new PanelHeader("Drift Verification") };
AnsiConsole.Write(panel);
// Offline kit info
if (result.OfflineKitInfo != null)
{
var kitGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Path[/]", Markup.Escape(result.OfflineKitInfo.Path))
.AddRow("[bold]Version[/]", Markup.Escape(result.OfflineKitInfo.Version ?? "N/A"))
.AddRow("[bold]Created[/]", result.OfflineKitInfo.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "N/A")
.AddRow("[bold]Fragment Count[/]", result.OfflineKitInfo.FragmentCount.ToString())
.AddRow("[bold]Verifiers Present[/]", result.OfflineKitInfo.VerifiersPresent ? "[green]Yes[/]" : "[yellow]No[/]")
.AddRow("[bold]Composition Manifest[/]", result.OfflineKitInfo.CompositionManifestPresent ? "[green]Yes[/]" : "[yellow]No[/]");
AnsiConsole.Write(new Panel(kitGrid) { Header = new PanelHeader("Offline Kit") });
}
// Fragment verifications (verbose mode)
if (verbose && result.FragmentVerifications is { Count: > 0 })
{
var fragTable = new Table();
fragTable.AddColumn("Layer Digest");
fragTable.AddColumn("Valid");
fragTable.AddColumn("DSSE");
fragTable.AddColumn("Content Hash");
fragTable.AddColumn("Merkle");
fragTable.AddColumn("Algorithm");
foreach (var frag in result.FragmentVerifications)
{
var layerShort = frag.LayerDigest.Length > 20 ? frag.LayerDigest[..17] + "..." : frag.LayerDigest;
fragTable.AddRow(
Markup.Escape(layerShort),
frag.Valid ? "[green]Yes[/]" : "[red]No[/]",
frag.DsseValid ? "[green]Yes[/]" : "[red]No[/]",
frag.ContentHashMatch ? "[green]Yes[/]" : "[red]No[/]",
frag.MerkleProofValid.HasValue ? (frag.MerkleProofValid.Value ? "[green]Yes[/]" : "[red]No[/]") : "[dim]N/A[/]",
Markup.Escape(frag.SignatureAlgorithm ?? "N/A"));
}
AnsiConsole.Write(new Panel(fragTable) { Header = new PanelHeader("Fragment Verifications") });
}
// Drift result if present
if (result.DriftResult != null && result.DriftResult.HasDrift)
{
var driftColor = result.DriftResult.Deterministic ? "yellow" : "red";
var driftStatus = result.DriftResult.Deterministic ? "Deterministic Drift" : "Non-Deterministic Drift";
var driftGrid = new Grid()
.AddColumn()
.AddColumn()
.AddRow("[bold]Status[/]", $"[{driftColor}]{Markup.Escape(driftStatus)}[/]");
if (result.DriftResult.Summary != null)
{
driftGrid.AddRow("[bold]Total Drifts[/]", result.DriftResult.Summary.TotalDrifts.ToString());
driftGrid.AddRow("[bold]Components Changed[/]", $"+{result.DriftResult.Summary.ComponentsAdded}/-{result.DriftResult.Summary.ComponentsRemoved}/~{result.DriftResult.Summary.ComponentsModified}");
}
AnsiConsole.Write(new Panel(driftGrid) { Header = new PanelHeader("Drift Summary") });
}
// Errors and warnings
if (result.Errors is { Count: > 0 })
{
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(error)}[/]");
}
}
if (verbose && result.Warnings is { Count: > 0 })
{
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning: {Markup.Escape(warning)}[/]");
}
}
return result.Valid ? 0 : 1;
}
catch (HttpRequestException ex)
{
var error = CliError.FromHttpStatus((int)(ex.StatusCode ?? System.Net.HttpStatusCode.InternalServerError), ex.Message);
AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Code)}: {Markup.Escape(error.Message)}[/]");
return error.ExitCode;
}
}
#endregion
#region Risk Commands (CLI-RISK-66-001 through CLI-RISK-68-001)
// CLI-RISK-66-001: Risk profile list
public static async Task HandleRiskProfileListAsync(
IServiceProvider services,
string? tenant,
bool includeDisabled,
string? category,
int? limit,
int? offset,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-profile-list");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.profile.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "risk profile list");
using var duration = CliMetrics.MeasureCommandDuration("risk profile list");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Listing risk profiles: includeDisabled={IncludeDisabled}, category={Category}",
includeDisabled, category);
var request = new RiskProfileListRequest
{
IncludeDisabled = includeDisabled,
Category = category,
Limit = limit,
Offset = offset,
Tenant = effectiveTenant
};
var response = await client.ListRiskProfilesAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
RenderRiskProfileListTable(response);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list risk profiles.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderRiskProfileListTable(RiskProfileListResponse response)
{
var table = new Table();
table.AddColumn(new TableColumn("[bold]Profile ID[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Name[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Category[/]").Centered());
table.AddColumn(new TableColumn("[bold]Version[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Rules[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Enabled[/]").Centered());
table.AddColumn(new TableColumn("[bold]Built-In[/]").Centered());
foreach (var profile in response.Profiles)
{
var enabledStatus = profile.Enabled ? "[green]Yes[/]" : "[grey]No[/]";
var builtInStatus = profile.BuiltIn ? "[cyan]Yes[/]" : "-";
table.AddRow(
Markup.Escape(profile.ProfileId),
Markup.Escape(profile.Name),
Markup.Escape(profile.Category),
profile.Version.ToString(),
profile.RuleCount.ToString(),
enabledStatus,
builtInStatus);
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Total:[/] {response.Total} | [grey]Showing:[/] {response.Profiles.Count} (offset {response.Offset})");
}
// CLI-RISK-66-002: Risk simulate
public static async Task HandleRiskSimulateAsync(
IServiceProvider services,
string? tenant,
string? profileId,
string? sbomId,
string? sbomPath,
string? assetId,
bool diffMode,
string? baselineProfileId,
bool emitJson,
bool emitCsv,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-simulate");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.simulate", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "risk simulate");
using var duration = CliMetrics.MeasureCommandDuration("risk simulate");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Validate input: at least one of sbomId, sbomPath, or assetId required
if (string.IsNullOrWhiteSpace(sbomId) && string.IsNullOrWhiteSpace(sbomPath) && string.IsNullOrWhiteSpace(assetId))
{
AnsiConsole.MarkupLine("[red]Error:[/] At least one of --sbom-id, --sbom-path, or --asset-id is required.");
Environment.ExitCode = 4;
return;
}
logger.LogDebug("Simulating risk: profileId={ProfileId}, sbomId={SbomId}, sbomPath={SbomPath}, assetId={AssetId}, diffMode={DiffMode}",
profileId, sbomId, sbomPath, assetId, diffMode);
var request = new RiskSimulateRequest
{
ProfileId = profileId,
SbomId = sbomId,
SbomPath = sbomPath,
AssetId = assetId,
DiffMode = diffMode,
BaselineProfileId = baselineProfileId,
Tenant = effectiveTenant
};
var result = await client.SimulateRiskAsync(request, cancellationToken).ConfigureAwait(false);
string output;
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
output = JsonSerializer.Serialize(result, jsonOptions);
}
else if (emitCsv)
{
output = RenderRiskSimulateCsv(result);
}
else
{
RenderRiskSimulateTable(result);
output = string.Empty;
}
if (!string.IsNullOrWhiteSpace(outputPath) && !string.IsNullOrEmpty(output))
{
await File.WriteAllTextAsync(outputPath, output, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"Output written to [cyan]{Markup.Escape(outputPath)}[/]");
}
else if (!string.IsNullOrEmpty(output))
{
AnsiConsole.WriteLine(output);
}
Environment.ExitCode = result.Success ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to simulate risk.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderRiskSimulateTable(RiskSimulateResult result)
{
// Header panel
var gradeColor = GetRiskGradeColor(result.Grade);
var headerPanel = new Panel(new Markup($"[bold]{Markup.Escape(result.ProfileName)}[/] (ID: {Markup.Escape(result.ProfileId)})"))
{
Header = new PanelHeader("[bold]Risk Simulation[/]"),
Border = BoxBorder.Rounded
};
AnsiConsole.Write(headerPanel);
AnsiConsole.WriteLine();
// Score summary
AnsiConsole.MarkupLine($"[bold]Overall Score:[/] [{gradeColor}]{result.OverallScore:F1}[/] ([{gradeColor}]{Markup.Escape(result.Grade)}[/])");
AnsiConsole.MarkupLine($"[bold]Simulated At:[/] {result.SimulatedAt:yyyy-MM-dd HH:mm:ss}");
AnsiConsole.WriteLine();
// Findings summary
var findingsGrid = new Grid();
findingsGrid.AddColumn();
findingsGrid.AddColumn();
findingsGrid.AddColumn();
findingsGrid.AddColumn();
findingsGrid.AddColumn();
findingsGrid.AddColumn();
findingsGrid.AddRow(
"[bold]Critical[/]",
"[bold]High[/]",
"[bold]Medium[/]",
"[bold]Low[/]",
"[bold]Info[/]",
"[bold]Total[/]");
findingsGrid.AddRow(
$"[red]{result.Findings.Critical}[/]",
$"[#FFA500]{result.Findings.High}[/]",
$"[yellow]{result.Findings.Medium}[/]",
$"[blue]{result.Findings.Low}[/]",
$"[grey]{result.Findings.Info}[/]",
$"[bold]{result.Findings.Total}[/]");
AnsiConsole.Write(new Panel(findingsGrid) { Header = new PanelHeader("[bold]Findings Summary[/]"), Border = BoxBorder.Rounded });
AnsiConsole.WriteLine();
// Diff information if available
if (result.Diff is not null)
{
var diffColor = result.Diff.Improved ? "green" : "red";
var diffSymbol = result.Diff.Improved ? "-" : "+";
AnsiConsole.MarkupLine($"[bold]Diff Mode:[/] [{diffColor}]{diffSymbol}{Math.Abs(result.Diff.Delta):F1}[/] (Baseline: {result.Diff.BaselineScore:F1} -> Candidate: {result.Diff.CandidateScore:F1})");
AnsiConsole.MarkupLine($"[grey]Findings Added:[/] {result.Diff.FindingsAdded} | [grey]Findings Removed:[/] {result.Diff.FindingsRemoved}");
AnsiConsole.WriteLine();
}
// Component scores
if (result.ComponentScores.Count > 0)
{
var componentTable = new Table();
componentTable.AddColumn(new TableColumn("[bold]Component[/]").LeftAligned());
componentTable.AddColumn(new TableColumn("[bold]Score[/]").RightAligned());
componentTable.AddColumn(new TableColumn("[bold]Grade[/]").Centered());
componentTable.AddColumn(new TableColumn("[bold]Findings[/]").RightAligned());
foreach (var component in result.ComponentScores)
{
var componentGradeColor = GetRiskGradeColor(component.Grade);
componentTable.AddRow(
Markup.Escape(component.ComponentName),
$"{component.Score:F1}",
$"[{componentGradeColor}]{Markup.Escape(component.Grade)}[/]",
component.FindingCount.ToString());
}
AnsiConsole.Write(componentTable);
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.WriteLine();
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
}
}
private static string RenderRiskSimulateCsv(RiskSimulateResult result)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("ComponentId,ComponentName,Score,Grade,FindingCount");
foreach (var component in result.ComponentScores)
{
sb.AppendLine($"\"{component.ComponentId}\",\"{component.ComponentName}\",{component.Score:F2},{component.Grade},{component.FindingCount}");
}
return sb.ToString();
}
private static string GetRiskGradeColor(string grade) => grade.ToUpperInvariant() switch
{
"A" or "A+" => "green",
"B" or "B+" => "cyan",
"C" or "C+" => "yellow",
"D" or "D+" => "#FFA500",
"F" => "red",
_ => "grey"
};
// CLI-RISK-67-001: Risk results
public static async Task HandleRiskResultsAsync(
IServiceProvider services,
string? tenant,
string? assetId,
string? sbomId,
string? profileId,
string? minSeverity,
double? maxScore,
bool includeExplain,
int? limit,
int? offset,
bool emitJson,
bool emitCsv,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-results");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.results", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "risk results");
using var duration = CliMetrics.MeasureCommandDuration("risk results");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Getting risk results: assetId={AssetId}, sbomId={SbomId}, profileId={ProfileId}, minSeverity={MinSeverity}",
assetId, sbomId, profileId, minSeverity);
var request = new RiskResultsRequest
{
AssetId = assetId,
SbomId = sbomId,
ProfileId = profileId,
MinSeverity = minSeverity,
MaxScore = maxScore,
IncludeExplain = includeExplain,
Limit = limit,
Offset = offset,
Tenant = effectiveTenant
};
var response = await client.GetRiskResultsAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, jsonOptions);
AnsiConsole.WriteLine(json);
}
else if (emitCsv)
{
RenderRiskResultsCsv(response);
}
else
{
RenderRiskResultsTable(response, includeExplain);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get risk results.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderRiskResultsTable(RiskResultsResponse response, bool includeExplain)
{
// Summary panel
var summaryGrid = new Grid();
summaryGrid.AddColumn();
summaryGrid.AddColumn();
summaryGrid.AddColumn();
summaryGrid.AddColumn();
summaryGrid.AddRow(
$"[bold]Average Score:[/] {response.Summary.AverageScore:F1}",
$"[bold]Min:[/] {response.Summary.MinScore:F1}",
$"[bold]Max:[/] {response.Summary.MaxScore:F1}",
$"[bold]Assets:[/] {response.Summary.AssetCount}");
AnsiConsole.Write(new Panel(summaryGrid) { Header = new PanelHeader("[bold]Summary[/]"), Border = BoxBorder.Rounded });
AnsiConsole.WriteLine();
// Results table
var table = new Table();
table.AddColumn(new TableColumn("[bold]Result ID[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Asset[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Profile[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Score[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Grade[/]").Centered());
table.AddColumn(new TableColumn("[bold]Severity[/]").Centered());
table.AddColumn(new TableColumn("[bold]Findings[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Evaluated[/]").RightAligned());
foreach (var result in response.Results)
{
var gradeColor = GetRiskGradeColor(result.Grade);
var severityColor = GetSeverityColor(result.Severity);
table.AddRow(
Markup.Escape(TruncateId(result.ResultId)),
Markup.Escape(result.AssetName ?? result.AssetId),
Markup.Escape(result.ProfileName ?? result.ProfileId),
$"{result.Score:F1}",
$"[{gradeColor}]{Markup.Escape(result.Grade)}[/]",
$"[{severityColor}]{Markup.Escape(result.Severity.ToUpperInvariant())}[/]",
result.FindingCount.ToString(),
result.EvaluatedAt.ToString("yyyy-MM-dd"));
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Total:[/] {response.Total} | [grey]Showing:[/] {response.Results.Count} (offset {response.Offset})");
// Explanations if requested
if (includeExplain)
{
foreach (var result in response.Results.Where(r => r.Explain != null))
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[bold]Explanation for {Markup.Escape(TruncateId(result.ResultId))}:[/]");
if (result.Explain!.Factors.Count > 0)
{
var factorTable = new Table();
factorTable.AddColumn("Factor");
factorTable.AddColumn("Weight");
factorTable.AddColumn("Contribution");
foreach (var factor in result.Explain.Factors)
{
factorTable.AddRow(
Markup.Escape(factor.Name),
$"{factor.Weight:F2}",
$"{factor.Contribution:F2}");
}
AnsiConsole.Write(factorTable);
}
if (result.Explain.Recommendations.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Recommendations:[/]");
foreach (var rec in result.Explain.Recommendations)
{
AnsiConsole.MarkupLine($" - {Markup.Escape(rec)}");
}
}
}
}
}
private static void RenderRiskResultsCsv(RiskResultsResponse response)
{
Console.WriteLine("ResultId,AssetId,AssetName,ProfileId,ProfileName,Score,Grade,Severity,FindingCount,EvaluatedAt");
foreach (var result in response.Results)
{
Console.WriteLine($"\"{result.ResultId}\",\"{result.AssetId}\",\"{result.AssetName ?? ""}\",\"{result.ProfileId}\",\"{result.ProfileName ?? ""}\",{result.Score:F2},{result.Grade},{result.Severity},{result.FindingCount},{result.EvaluatedAt:O}");
}
}
private static string TruncateId(string id) => id.Length > 12 ? id[..12] + "..." : id;
// CLI-RISK-68-001: Risk bundle verify
public static async Task HandleRiskBundleVerifyAsync(
IServiceProvider services,
string? tenant,
string bundlePath,
string? signaturePath,
bool checkRekor,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("risk-bundle-verify");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.risk.bundle.verify", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "risk bundle verify");
using var duration = CliMetrics.MeasureCommandDuration("risk bundle verify");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Validate bundle path exists
if (!File.Exists(bundlePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle file not found: {Markup.Escape(bundlePath)}");
Environment.ExitCode = 4;
return;
}
// Validate signature path if provided
if (!string.IsNullOrWhiteSpace(signaturePath) && !File.Exists(signaturePath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Signature file not found: {Markup.Escape(signaturePath)}");
Environment.ExitCode = 4;
return;
}
logger.LogDebug("Verifying risk bundle: bundlePath={BundlePath}, signaturePath={SignaturePath}, checkRekor={CheckRekor}",
bundlePath, signaturePath, checkRekor);
var request = new RiskBundleVerifyRequest
{
BundlePath = bundlePath,
SignaturePath = signaturePath,
CheckRekor = checkRekor,
Tenant = effectiveTenant
};
var result = await client.VerifyRiskBundleAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
RenderRiskBundleVerifyResult(result, verbose);
}
Environment.ExitCode = result.Valid ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify risk bundle.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderRiskBundleVerifyResult(RiskBundleVerifyResult result, bool verbose)
{
// Validation status
var validStatus = result.Valid ? "[green]VALID[/]" : "[red]INVALID[/]";
AnsiConsole.MarkupLine($"[bold]Bundle Status:[/] {validStatus}");
AnsiConsole.WriteLine();
// Bundle info
var infoGrid = new Grid();
infoGrid.AddColumn();
infoGrid.AddColumn();
infoGrid.AddRow("[bold]Bundle ID:[/]", Markup.Escape(result.BundleId));
infoGrid.AddRow("[bold]Version:[/]", Markup.Escape(result.BundleVersion));
infoGrid.AddRow("[bold]Profiles:[/]", result.ProfileCount.ToString());
infoGrid.AddRow("[bold]Rules:[/]", result.RuleCount.ToString());
AnsiConsole.Write(new Panel(infoGrid) { Header = new PanelHeader("[bold]Bundle Information[/]"), Border = BoxBorder.Rounded });
AnsiConsole.WriteLine();
// Signature info
if (result.SignatureValid.HasValue)
{
var sigStatus = result.SignatureValid.Value ? "[green]Valid[/]" : "[red]Invalid[/]";
AnsiConsole.MarkupLine($"[bold]Signature:[/] {sigStatus}");
if (result.SignedBy is not null)
{
AnsiConsole.MarkupLine($" [grey]Signed By:[/] {Markup.Escape(result.SignedBy)}");
}
if (result.SignedAt.HasValue)
{
AnsiConsole.MarkupLine($" [grey]Signed At:[/] {result.SignedAt:yyyy-MM-dd HH:mm:ss}");
}
}
// Rekor info
if (result.RekorVerified.HasValue)
{
var rekorStatus = result.RekorVerified.Value ? "[green]Verified[/]" : "[yellow]Not Found[/]";
AnsiConsole.MarkupLine($"[bold]Rekor Transparency:[/] {rekorStatus}");
if (result.RekorLogIndex is not null)
{
AnsiConsole.MarkupLine($" [grey]Log Index:[/] {Markup.Escape(result.RekorLogIndex)}");
}
}
// Errors
if (result.Errors.Count > 0)
{
AnsiConsole.WriteLine();
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
}
// Warnings (verbose only)
if (verbose && result.Warnings.Count > 0)
{
AnsiConsole.WriteLine();
foreach (var warning in result.Warnings)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(warning)}");
}
}
}
#endregion
#region Reachability Commands (CLI-SIG-26-001)
// CLI-SIG-26-001: Reachability upload-callgraph
public static async Task HandleReachabilityUploadCallGraphAsync(
IServiceProvider services,
string? tenant,
string callGraphPath,
string? scanId,
string? assetId,
string? format,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("reachability-upload");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.reachability.upload-callgraph", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "reachability upload-callgraph");
using var duration = CliMetrics.MeasureCommandDuration("reachability upload-callgraph");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Validate call graph path exists
if (!File.Exists(callGraphPath))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Call graph file not found: {Markup.Escape(callGraphPath)}");
Environment.ExitCode = 4;
return;
}
// Validate at least one of scanId or assetId
if (string.IsNullOrWhiteSpace(scanId) && string.IsNullOrWhiteSpace(assetId))
{
AnsiConsole.MarkupLine("[red]Error:[/] At least one of --scan-id or --asset-id is required.");
Environment.ExitCode = 4;
return;
}
logger.LogDebug("Uploading call graph: path={Path}, scanId={ScanId}, assetId={AssetId}, format={Format}",
callGraphPath, scanId, assetId, format);
var request = new ReachabilityUploadCallGraphRequest
{
ScanId = scanId,
AssetId = assetId,
CallGraphPath = callGraphPath,
Format = format,
Tenant = effectiveTenant
};
await using var fileStream = File.OpenRead(callGraphPath);
var result = await AnsiConsole.Progress()
.AutoClear(false)
.Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new SpinnerColumn())
.StartAsync(async ctx =>
{
var task = ctx.AddTask("[cyan]Uploading call graph...[/]", maxValue: 100);
task.IsIndeterminate = true;
var uploadResult = await client.UploadCallGraphAsync(request, fileStream, cancellationToken).ConfigureAwait(false);
task.Value = 100;
task.StopTask();
return uploadResult;
}).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
RenderReachabilityUploadResult(result);
}
Environment.ExitCode = result.Success ? 0 : 1;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to upload call graph.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderReachabilityUploadResult(ReachabilityUploadCallGraphResult result)
{
var statusColor = result.Success ? "green" : "red";
AnsiConsole.MarkupLine($"[bold]Upload Status:[/] [{statusColor}]{(result.Success ? "SUCCESS" : "FAILED")}[/]");
AnsiConsole.WriteLine();
var infoGrid = new Grid();
infoGrid.AddColumn();
infoGrid.AddColumn();
infoGrid.AddRow("[bold]Call Graph ID:[/]", Markup.Escape(result.CallGraphId));
infoGrid.AddRow("[bold]Entries Processed:[/]", result.EntriesProcessed.ToString());
infoGrid.AddRow("[bold]Errors:[/]", result.ErrorsCount.ToString());
infoGrid.AddRow("[bold]Uploaded At:[/]", result.UploadedAt.ToString("yyyy-MM-dd HH:mm:ss"));
AnsiConsole.Write(new Panel(infoGrid) { Header = new PanelHeader("[bold]Upload Details[/]"), Border = BoxBorder.Rounded });
if (result.Errors.Count > 0)
{
AnsiConsole.WriteLine();
foreach (var error in result.Errors)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error)}");
}
}
}
// CLI-SIG-26-001: Reachability list
public static async Task HandleReachabilityListAsync(
IServiceProvider services,
string? tenant,
string? scanId,
string? assetId,
string? status,
int? limit,
int? offset,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("reachability-list");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.reachability.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "reachability list");
using var duration = CliMetrics.MeasureCommandDuration("reachability list");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Listing reachability analyses: scanId={ScanId}, assetId={AssetId}, status={Status}",
scanId, assetId, status);
var request = new ReachabilityListRequest
{
ScanId = scanId,
AssetId = assetId,
Status = status,
Limit = limit,
Offset = offset,
Tenant = effectiveTenant
};
var response = await client.ListReachabilityAnalysesAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
RenderReachabilityListTable(response);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list reachability analyses.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderReachabilityListTable(ReachabilityListResponse response)
{
var table = new Table();
table.AddColumn(new TableColumn("[bold]Analysis ID[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Asset[/]").LeftAligned());
table.AddColumn(new TableColumn("[bold]Status[/]").Centered());
table.AddColumn(new TableColumn("[bold]Reachable[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Unreachable[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Unknown[/]").RightAligned());
table.AddColumn(new TableColumn("[bold]Created[/]").RightAligned());
foreach (var analysis in response.Analyses)
{
var statusColor = GetReachabilityStatusColor(analysis.Status);
table.AddRow(
Markup.Escape(TruncateId(analysis.AnalysisId)),
Markup.Escape(analysis.AssetName ?? analysis.AssetId ?? analysis.ScanId ?? "-"),
$"[{statusColor}]{Markup.Escape(analysis.Status)}[/]",
analysis.ReachableCount.ToString(),
analysis.UnreachableCount.ToString(),
analysis.UnknownCount.ToString(),
analysis.CreatedAt.ToString("yyyy-MM-dd HH:mm"));
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Total:[/] {response.Total} | [grey]Showing:[/] {response.Analyses.Count} (offset {response.Offset})");
}
private static string GetReachabilityStatusColor(string status) => status.ToLowerInvariant() switch
{
"completed" => "green",
"processing" => "cyan",
"pending" => "yellow",
"failed" => "red",
_ => "grey"
};
// CLI-SIG-26-001: Reachability explain
public static async Task HandleReachabilityExplainAsync(
IServiceProvider services,
string? tenant,
string analysisId,
string? vulnerabilityId,
string? packagePurl,
bool includeCallPaths,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("reachability-explain");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.reachability.explain", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "reachability explain");
using var duration = CliMetrics.MeasureCommandDuration("reachability explain");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
// Validate at least one of vulnerabilityId or packagePurl
if (string.IsNullOrWhiteSpace(vulnerabilityId) && string.IsNullOrWhiteSpace(packagePurl))
{
AnsiConsole.MarkupLine("[red]Error:[/] At least one of --vuln-id or --purl is required.");
Environment.ExitCode = 4;
return;
}
logger.LogDebug("Explaining reachability: analysisId={AnalysisId}, vulnId={VulnId}, purl={Purl}",
analysisId, vulnerabilityId, packagePurl);
var request = new ReachabilityExplainRequest
{
AnalysisId = analysisId,
VulnerabilityId = vulnerabilityId,
PackagePurl = packagePurl,
IncludeCallPaths = includeCallPaths,
Tenant = effectiveTenant
};
var result = await client.ExplainReachabilityAsync(request, cancellationToken).ConfigureAwait(false);
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
RenderReachabilityExplainResult(result, includeCallPaths);
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to explain reachability.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static void RenderReachabilityExplainResult(ReachabilityExplainResult result, bool includeCallPaths)
{
// State header
var stateColor = GetReachabilityStateColor(result.ReachabilityState);
AnsiConsole.MarkupLine($"[bold]Reachability State:[/] [{stateColor}]{Markup.Escape(result.ReachabilityState.ToUpperInvariant())}[/]");
if (result.ReachabilityScore.HasValue)
{
AnsiConsole.MarkupLine($"[bold]Reachability Score:[/] {result.ReachabilityScore:F2}");
}
AnsiConsole.MarkupLine($"[bold]Confidence:[/] {Markup.Escape(result.Confidence)}");
AnsiConsole.WriteLine();
// Target info
var infoGrid = new Grid();
infoGrid.AddColumn();
infoGrid.AddColumn();
infoGrid.AddRow("[bold]Analysis ID:[/]", Markup.Escape(result.AnalysisId));
if (!string.IsNullOrWhiteSpace(result.VulnerabilityId))
{
infoGrid.AddRow("[bold]Vulnerability:[/]", Markup.Escape(result.VulnerabilityId));
}
if (!string.IsNullOrWhiteSpace(result.PackagePurl))
{
infoGrid.AddRow("[bold]Package:[/]", Markup.Escape(result.PackagePurl));
}
AnsiConsole.Write(new Panel(infoGrid) { Border = BoxBorder.Rounded });
AnsiConsole.WriteLine();
// Reasoning
if (!string.IsNullOrWhiteSpace(result.Reasoning))
{
AnsiConsole.MarkupLine("[bold]Reasoning:[/]");
AnsiConsole.MarkupLine($" {Markup.Escape(result.Reasoning)}");
AnsiConsole.WriteLine();
}
// Affected functions
if (result.AffectedFunctions.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Affected Functions:[/]");
foreach (var func in result.AffectedFunctions)
{
var location = !string.IsNullOrWhiteSpace(func.FilePath) && func.LineNumber.HasValue
? $"{func.FilePath}:{func.LineNumber}"
: func.FilePath ?? "";
AnsiConsole.MarkupLine($" - [cyan]{Markup.Escape(func.Name)}[/]");
if (!string.IsNullOrWhiteSpace(func.ClassName))
{
AnsiConsole.MarkupLine($" Class: {Markup.Escape(func.ClassName)}");
}
if (!string.IsNullOrWhiteSpace(location))
{
AnsiConsole.MarkupLine($" Location: [grey]{Markup.Escape(location)}[/]");
}
}
AnsiConsole.WriteLine();
}
// Call paths
if (includeCallPaths && result.CallPaths.Count > 0)
{
AnsiConsole.MarkupLine($"[bold]Call Paths ({result.CallPaths.Count}):[/]");
AnsiConsole.WriteLine();
foreach (var path in result.CallPaths)
{
AnsiConsole.MarkupLine($"[bold]Path {Markup.Escape(path.PathId)}[/] (depth {path.Depth}):");
// Entry point
AnsiConsole.MarkupLine($" [green]Entry:[/] {Markup.Escape(path.EntryPoint.Name)}");
// Intermediate frames
foreach (var frame in path.Frames)
{
AnsiConsole.MarkupLine($" -> {Markup.Escape(frame.Name)}");
}
// Vulnerable function
AnsiConsole.MarkupLine($" [red]Vulnerable:[/] {Markup.Escape(path.VulnerableFunction.Name)}");
AnsiConsole.WriteLine();
}
}
}
private static string GetReachabilityStateColor(string state) => state.ToLowerInvariant() switch
{
"reachable" => "red",
"unreachable" => "green",
"unknown" or "indeterminate" => "yellow",
_ => "grey"
};
#endregion
#region API Spec Commands (CLI-SDK-63-001)
public static async Task HandleApiSpecListAsync(
IServiceProvider services,
string? tenant,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("api-spec-list");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.api.spec.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "api spec list");
using var duration = CliMetrics.MeasureCommandDuration("api spec list");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Listing API specs for tenant: {Tenant}", effectiveTenant ?? "(default)");
var result = await client.ListApiSpecsAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
Environment.ExitCode = 1;
return;
}
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
// Render aggregate spec
if (result.Aggregate is not null)
{
AnsiConsole.MarkupLine("[bold]Aggregate API Specification:[/]");
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[bold]Version:[/]", Markup.Escape(result.Aggregate.Version));
if (!string.IsNullOrWhiteSpace(result.Aggregate.OpenApiVersion))
{
grid.AddRow("[bold]OpenAPI:[/]", Markup.Escape(result.Aggregate.OpenApiVersion));
}
if (!string.IsNullOrWhiteSpace(result.Aggregate.ETag))
{
grid.AddRow("[bold]ETag:[/]", Markup.Escape(result.Aggregate.ETag));
}
if (!string.IsNullOrWhiteSpace(result.Aggregate.Sha256))
{
grid.AddRow("[bold]SHA-256:[/]", Markup.Escape(result.Aggregate.Sha256));
}
if (result.Aggregate.LastModified.HasValue)
{
grid.AddRow("[bold]Last Modified:[/]", result.Aggregate.LastModified.Value.ToString("yyyy-MM-dd HH:mm:ss UTC"));
}
AnsiConsole.Write(new Panel(grid) { Border = BoxBorder.Rounded });
AnsiConsole.WriteLine();
}
// Render service specs
if (result.Specs.Count > 0)
{
AnsiConsole.MarkupLine("[bold]Service API Specifications:[/]");
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Service");
table.AddColumn("Version");
table.AddColumn("OpenAPI");
table.AddColumn("Formats");
table.AddColumn("ETag");
foreach (var spec in result.Specs.OrderBy(s => s.Service))
{
var formats = spec.Formats.Count > 0 ? string.Join(", ", spec.Formats) : "-";
table.AddRow(
Markup.Escape(spec.Service),
Markup.Escape(spec.Version),
Markup.Escape(spec.OpenApiVersion ?? "-"),
Markup.Escape(formats),
Markup.Escape(spec.ETag ?? "-")
);
}
AnsiConsole.Write(table);
}
else
{
AnsiConsole.MarkupLine("[yellow]No service specifications found.[/]");
}
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list API specs.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleApiSpecDownloadAsync(
IServiceProvider services,
string? tenant,
string outputPath,
string? service,
string format,
bool overwrite,
string? etag,
string? checksum,
string checksumAlgorithm,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("api-spec-download");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.api.spec.download", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "api spec download");
using var duration = CliMetrics.MeasureCommandDuration("api spec download");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Downloading API spec: service={Service}, format={Format}, output={Output}",
service ?? "aggregate", format, outputPath);
var request = new ApiSpecDownloadRequest
{
Tenant = effectiveTenant,
OutputPath = outputPath,
Service = service,
Format = format,
Overwrite = overwrite,
ExpectedETag = etag,
ExpectedChecksum = checksum,
ChecksumAlgorithm = checksumAlgorithm
};
var result = await client.DownloadApiSpecAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
{
AnsiConsole.MarkupLine($"[red]Error Code:[/] {Markup.Escape(result.ErrorCode)}");
}
}
Environment.ExitCode = 1;
return;
}
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
var statusText = result.FromCache ? "[yellow]Using cached file[/]" : "[green]Downloaded[/]";
AnsiConsole.MarkupLine($"{statusText}");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[bold]Path:[/]", Markup.Escape(result.Path ?? "-"));
grid.AddRow("[bold]Size:[/]", $"{result.SizeBytes:N0} bytes");
if (!string.IsNullOrWhiteSpace(result.ApiVersion))
{
grid.AddRow("[bold]API Version:[/]", Markup.Escape(result.ApiVersion));
}
if (!string.IsNullOrWhiteSpace(result.ETag))
{
grid.AddRow("[bold]ETag:[/]", Markup.Escape(result.ETag));
}
if (!string.IsNullOrWhiteSpace(result.Checksum))
{
grid.AddRow($"[bold]{result.ChecksumAlgorithm?.ToUpperInvariant() ?? "Checksum"}:[/]", Markup.Escape(result.Checksum));
}
if (result.ChecksumVerified.HasValue)
{
var verifyStatus = result.ChecksumVerified.Value ? "[green]Verified[/]" : "[red]Mismatch[/]";
grid.AddRow("[bold]Checksum Verified:[/]", verifyStatus);
}
AnsiConsole.Write(new Panel(grid) { Border = BoxBorder.Rounded, Header = new PanelHeader("API Specification") });
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download API spec.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
#endregion
#region SDK Commands (CLI-SDK-64-001)
public static async Task HandleSdkUpdateAsync(
IServiceProvider services,
string? tenant,
string? language,
bool checkOnly,
bool showChangelog,
bool showDeprecations,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("sdk-update");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.sdk.update", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "sdk update");
using var duration = CliMetrics.MeasureCommandDuration("sdk update");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Checking SDK updates: language={Language}, checkOnly={CheckOnly}", language ?? "all", checkOnly);
var request = new SdkUpdateRequest
{
Tenant = effectiveTenant,
Language = language,
CheckOnly = checkOnly,
IncludeChangelog = showChangelog,
IncludeDeprecations = showDeprecations
};
var result = await client.CheckSdkUpdatesAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
Environment.ExitCode = 1;
return;
}
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
// Show update summary
var updatesAvailable = result.Updates.Where(u => u.UpdateAvailable).ToList();
if (updatesAvailable.Count > 0)
{
AnsiConsole.MarkupLine($"[bold cyan]SDK Updates Available ({updatesAvailable.Count}):[/]");
AnsiConsole.WriteLine();
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("SDK");
table.AddColumn("Current");
table.AddColumn("Latest");
table.AddColumn("Package");
table.AddColumn("Released");
foreach (var update in updatesAvailable)
{
var current = update.InstalledVersion ?? "[grey]Not installed[/]";
var released = update.ReleaseDate?.ToString("yyyy-MM-dd") ?? "-";
table.AddRow(
Markup.Escape(update.DisplayName ?? update.Language),
update.InstalledVersion is not null ? Markup.Escape(update.InstalledVersion) : "[grey]Not installed[/]",
$"[green]{Markup.Escape(update.LatestVersion)}[/]",
Markup.Escape(update.PackageName),
released
);
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
// Show changelog if requested
if (showChangelog)
{
foreach (var update in updatesAvailable.Where(u => u.Changelog?.Count > 0))
{
AnsiConsole.MarkupLine($"[bold]Changelog for {Markup.Escape(update.DisplayName ?? update.Language)}:[/]");
foreach (var entry in update.Changelog!.Take(5))
{
var typeIcon = entry.Type?.ToLowerInvariant() switch
{
"feature" => "[green]+[/]",
"fix" => "[yellow]~[/]",
"breaking" => "[red]![/]",
"deprecation" => "[yellow]?[/]",
_ => " "
};
var breakingMark = entry.IsBreaking ? " [red](BREAKING)[/]" : "";
AnsiConsole.MarkupLine($" {typeIcon} [{(entry.IsBreaking ? "red" : "white")}]{Markup.Escape(entry.Version)}[/]: {Markup.Escape(entry.Description)}{breakingMark}");
}
AnsiConsole.WriteLine();
}
}
}
else
{
AnsiConsole.MarkupLine("[green]All SDKs are up to date.[/]");
}
// Show deprecations if requested
if (showDeprecations && result.Deprecations.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[bold yellow]Deprecation Notices ({result.Deprecations.Count}):[/]");
foreach (var deprecation in result.Deprecations)
{
var severityColor = deprecation.Severity.ToLowerInvariant() switch
{
"critical" => "red",
"warning" => "yellow",
_ => "grey"
};
AnsiConsole.MarkupLine($" [{severityColor}][{deprecation.Severity.ToUpperInvariant()}][/] {Markup.Escape(deprecation.Language)}: {Markup.Escape(deprecation.Feature)}");
AnsiConsole.MarkupLine($" {Markup.Escape(deprecation.Message)}");
if (!string.IsNullOrWhiteSpace(deprecation.Replacement))
{
AnsiConsole.MarkupLine($" [green]Replacement:[/] {Markup.Escape(deprecation.Replacement)}");
}
if (!string.IsNullOrWhiteSpace(deprecation.RemovedInVersion))
{
AnsiConsole.MarkupLine($" [yellow]Removed in:[/] {Markup.Escape(deprecation.RemovedInVersion)}");
}
}
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Checked at: {result.CheckedAt:yyyy-MM-dd HH:mm:ss UTC}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to check SDK updates.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleSdkListAsync(
IServiceProvider services,
string? tenant,
string? language,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("sdk-list");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.sdk.list", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "sdk list");
using var duration = CliMetrics.MeasureCommandDuration("sdk list");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Listing installed SDKs: language={Language}", language ?? "all");
var result = await client.ListInstalledSdksAsync(language, effectiveTenant, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
Environment.ExitCode = 1;
return;
}
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
if (result.Sdks.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No SDKs found.[/]");
}
else
{
AnsiConsole.MarkupLine($"[bold]Installed SDKs ({result.Sdks.Count}):[/]");
AnsiConsole.WriteLine();
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Language");
table.AddColumn("Package");
table.AddColumn("Installed");
table.AddColumn("Latest");
table.AddColumn("API Support");
table.AddColumn("Status");
foreach (var sdk in result.Sdks.OrderBy(s => s.Language))
{
var apiSupport = sdk.MinApiVersion is not null && sdk.MaxApiVersion is not null
? $"{sdk.MinApiVersion} - {sdk.MaxApiVersion}"
: "-";
var status = sdk.UpdateAvailable
? "[yellow]Update available[/]"
: "[green]Up to date[/]";
table.AddRow(
Markup.Escape(sdk.DisplayName ?? sdk.Language),
Markup.Escape(sdk.PackageName),
Markup.Escape(sdk.InstalledVersion ?? "-"),
Markup.Escape(sdk.LatestVersion),
Markup.Escape(apiSupport),
status
);
}
AnsiConsole.Write(table);
}
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list SDKs.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
#endregion
#region Mirror Commands (CLI-AIRGAP-56-001)
/// <summary>
/// Handler for 'stella mirror create' command.
/// Creates an air-gap mirror bundle for offline distribution.
/// </summary>
public static async Task HandleMirrorCreateAsync(
IServiceProvider services,
string domainId,
string outputDirectory,
string? format,
string? tenant,
string? displayName,
string? targetRepository,
IReadOnlyList<string>? providers,
bool includeSignatures,
bool includeAttestations,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("mirror-create");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.mirror.create", System.Diagnostics.ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "mirror create");
activity?.SetTag("stellaops.cli.mirror.domain", domainId);
using var duration = CliMetrics.MeasureCommandDuration("mirror create");
try
{
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
}
logger.LogDebug("Creating mirror bundle: domain={DomainId}, output={OutputDir}, format={Format}",
domainId, outputDirectory, format ?? "all");
// Validate domain ID
var validDomains = new[] { "vex-advisories", "vulnerability-feeds", "policy-packs", "scanner-bundles", "offline-kit" };
if (!validDomains.Contains(domainId, StringComparer.OrdinalIgnoreCase))
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Domain '{Markup.Escape(domainId)}' is not a standard domain. Standard domains: {string.Join(", ", validDomains)}");
}
// Ensure output directory exists
var resolvedOutput = Path.GetFullPath(outputDirectory);
if (!Directory.Exists(resolvedOutput))
{
Directory.CreateDirectory(resolvedOutput);
logger.LogDebug("Created output directory: {OutputDir}", resolvedOutput);
}
// Generate bundle timestamp
var generatedAt = DateTimeOffset.UtcNow;
var bundleId = $"{domainId}-{generatedAt:yyyyMMddHHmmss}";
// Create the request model
var request = new MirrorCreateRequest
{
DomainId = domainId,
DisplayName = displayName ?? $"{domainId} Mirror Bundle",
TargetRepository = targetRepository,
Format = format,
Providers = providers,
OutputDirectory = resolvedOutput,
IncludeSignatures = includeSignatures,
IncludeAttestations = includeAttestations,
Tenant = effectiveTenant
};
// Build exports list based on domain
var exports = new List<MirrorBundleExport>();
long totalSize = 0;
// For now, create a placeholder export entry
// In production this would call backend APIs to get actual exports
var exportId = Guid.NewGuid().ToString();
var placeholderContent = JsonSerializer.Serialize(new
{
schemaVersion = 1,
domain = domainId,
generatedAt = generatedAt,
tenant = effectiveTenant,
format,
providers
}, new JsonSerializerOptions { WriteIndented = true });
var placeholderBytes = System.Text.Encoding.UTF8.GetBytes(placeholderContent);
var placeholderDigest = ComputeMirrorSha256Digest(placeholderBytes);
// Write placeholder export file
var exportFileName = $"{domainId}-export-{generatedAt:yyyyMMdd}.json";
var exportPath = Path.Combine(resolvedOutput, exportFileName);
await File.WriteAllBytesAsync(exportPath, placeholderBytes, cancellationToken).ConfigureAwait(false);
exports.Add(new MirrorBundleExport
{
Key = $"{domainId}-{format ?? "all"}",
Format = format ?? "json",
ExportId = exportId,
CreatedAt = generatedAt,
ArtifactSizeBytes = placeholderBytes.Length,
ArtifactDigest = placeholderDigest,
SourceProviders = providers?.ToList()
});
totalSize += placeholderBytes.Length;
// Create the bundle manifest
var bundle = new MirrorBundle
{
SchemaVersion = 1,
GeneratedAt = generatedAt,
DomainId = domainId,
DisplayName = request.DisplayName,
TargetRepository = targetRepository,
Exports = exports
};
// Write bundle manifest
var manifestFileName = $"{bundleId}-manifest.json";
var manifestPath = Path.Combine(resolvedOutput, manifestFileName);
var manifestJson = JsonSerializer.Serialize(bundle, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken).ConfigureAwait(false);
// Write SHA256SUMS file for verification
var checksumPath = Path.Combine(resolvedOutput, "SHA256SUMS");
var checksumLines = new List<string>
{
$"{ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson))} {manifestFileName}",
$"{placeholderDigest} {exportFileName}"
};
await File.WriteAllLinesAsync(checksumPath, checksumLines, cancellationToken).ConfigureAwait(false);
// Build result
var result = new MirrorCreateResult
{
ManifestPath = manifestPath,
BundlePath = null, // Archive creation would go here
SignaturePath = null, // Signature would be created here if includeSignatures
ExportCount = exports.Count,
TotalSizeBytes = totalSize,
BundleDigest = ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson)),
GeneratedAt = generatedAt,
DomainId = domainId,
Exports = verbose ? exports : null
};
if (emitJson)
{
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var json = JsonSerializer.Serialize(result, jsonOptions);
AnsiConsole.WriteLine(json);
}
else
{
AnsiConsole.MarkupLine($"[green]Mirror bundle created successfully![/]");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Domain:[/]", Markup.Escape(domainId));
grid.AddRow("[grey]Display Name:[/]", Markup.Escape(request.DisplayName ?? "-"));
grid.AddRow("[grey]Generated At:[/]", generatedAt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"));
grid.AddRow("[grey]Exports:[/]", exports.Count.ToString());
grid.AddRow("[grey]Total Size:[/]", FormatBytes(totalSize));
grid.AddRow("[grey]Manifest:[/]", Markup.Escape(manifestPath));
grid.AddRow("[grey]Checksums:[/]", Markup.Escape(checksumPath));
if (!string.IsNullOrWhiteSpace(targetRepository))
grid.AddRow("[grey]Target Repository:[/]", Markup.Escape(targetRepository));
AnsiConsole.Write(grid);
if (verbose && exports.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Exports:[/]");
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Key");
table.AddColumn("Format");
table.AddColumn("Size");
table.AddColumn("Digest");
foreach (var export in exports)
{
table.AddRow(
Markup.Escape(export.Key),
Markup.Escape(export.Format),
FormatBytes(export.ArtifactSizeBytes ?? 0),
Markup.Escape(TruncateMirrorDigest(export.ArtifactDigest))
);
}
AnsiConsole.Write(table);
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Next steps:[/]");
AnsiConsole.MarkupLine($" 1. Transfer the bundle directory to the air-gapped environment");
AnsiConsole.MarkupLine($" 2. Verify checksums: [cyan]cd {Markup.Escape(resolvedOutput)} && sha256sum -c SHA256SUMS[/]");
AnsiConsole.MarkupLine($" 3. Import the bundle: [cyan]stella airgap import --bundle {Markup.Escape(manifestPath)}[/]");
}
Environment.ExitCode = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
Environment.ExitCode = 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create mirror bundle.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
Environment.ExitCode = 1;
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
private static string ComputeMirrorSha256Digest(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private static string TruncateMirrorDigest(string digest)
{
if (string.IsNullOrEmpty(digest)) return "-";
if (digest.Length <= 20) return digest;
return digest[..20] + "...";
}
#endregion
#region AirGap Commands (CLI-AIRGAP-57-001)
/// <summary>
/// Handler for 'stella airgap import' command.
/// Imports an air-gap mirror bundle into the local data store.
/// </summary>
public static async Task<int> HandleAirgapImportAsync(
IServiceProvider services,
string bundlePath,
string? tenant,
bool globalScope,
bool dryRun,
bool force,
bool verifyOnly,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
// Exit codes: 0 success, 1 general error, 2 verification failed, 3 scope conflict, 4 input error
const int ExitSuccess = 0;
const int ExitGeneralError = 1;
const int ExitVerificationFailed = 2;
const int ExitScopeConflict = 3;
const int ExitInputError = 4;
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("airgap-import");
using var activity = CliActivitySource.Instance.StartActivity("cli.airgap.import", System.Diagnostics.ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "airgap import");
using var duration = CliMetrics.MeasureCommandDuration("airgap import");
try
{
// Validate input path
var resolvedPath = Path.GetFullPath(bundlePath);
string manifestPath;
if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifestPath = resolvedPath;
}
else if (Directory.Exists(resolvedPath))
{
// Look for manifest file in directory
var manifestCandidates = Directory.GetFiles(resolvedPath, "*-manifest.json")
.Concat(Directory.GetFiles(resolvedPath, "manifest.json"))
.ToArray();
if (manifestCandidates.Length == 0)
{
AnsiConsole.MarkupLine("[red]Error:[/] No manifest file found in bundle directory.");
return ExitInputError;
}
manifestPath = manifestCandidates.OrderByDescending(File.GetLastWriteTimeUtc).First();
}
else
{
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle path not found: {Markup.Escape(resolvedPath)}");
return ExitInputError;
}
var bundleDir = Path.GetDirectoryName(manifestPath)!;
activity?.SetTag("stellaops.cli.airgap.bundle_dir", bundleDir);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Manifest: {Markup.Escape(manifestPath)}[/]");
AnsiConsole.MarkupLine($"[grey]Bundle directory: {Markup.Escape(bundleDir)}[/]");
}
// Read and parse manifest
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<MirrorBundle>(manifestJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (manifest is null)
{
AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse bundle manifest.");
return ExitInputError;
}
activity?.SetTag("stellaops.cli.airgap.domain", manifest.DomainId);
activity?.SetTag("stellaops.cli.airgap.export_count", manifest.Exports?.Count ?? 0);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Domain: {Markup.Escape(manifest.DomainId)}[/]");
AnsiConsole.MarkupLine($"[grey]Generated: {manifest.GeneratedAt:yyyy-MM-dd HH:mm:ss}[/]");
AnsiConsole.MarkupLine($"[grey]Exports: {manifest.Exports?.Count ?? 0}[/]");
}
// Validate scope options
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
if (globalScope && !string.IsNullOrWhiteSpace(effectiveTenant))
{
AnsiConsole.MarkupLine("[red]Error:[/] Cannot specify both --global and --tenant. Choose one scope.");
return ExitScopeConflict;
}
var scopeDescription = globalScope ? "global" : (!string.IsNullOrWhiteSpace(effectiveTenant) ? $"tenant:{effectiveTenant}" : "default");
activity?.SetTag("stellaops.cli.airgap.scope", scopeDescription);
// Verify checksums
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
var verificationResults = new List<(string File, string Expected, string Actual, bool Valid)>();
var allValid = true;
if (File.Exists(checksumPath))
{
var checksumLines = await File.ReadAllLinesAsync(checksumPath, cancellationToken).ConfigureAwait(false);
foreach (var line in checksumLines.Where(l => !string.IsNullOrWhiteSpace(l)))
{
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var expectedDigest = parts[0].Trim();
var fileName = parts[1].Trim().TrimStart('*');
var filePath = Path.Combine(bundleDir, fileName);
if (!File.Exists(filePath))
{
verificationResults.Add((fileName, expectedDigest, "(file missing)", false));
allValid = false;
continue;
}
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
var actualDigest = ComputeMirrorSha256Digest(fileBytes);
var isValid = string.Equals(expectedDigest, actualDigest, StringComparison.OrdinalIgnoreCase) ||
string.Equals($"sha256:{expectedDigest}", actualDigest, StringComparison.OrdinalIgnoreCase);
verificationResults.Add((fileName, expectedDigest, actualDigest, isValid));
if (!isValid) allValid = false;
}
}
else
{
AnsiConsole.MarkupLine("[yellow]Warning:[/] No SHA256SUMS file found. Skipping checksum verification.");
}
// Build diff preview
var importPreview = new List<(string Key, string Format, string Action, string Details)>();
foreach (var export in manifest.Exports ?? Enumerable.Empty<MirrorBundleExport>())
{
var action = dryRun ? "would import" : "importing";
var details = $"{FormatBytes(export.ArtifactSizeBytes ?? 0)}, {export.Format}";
importPreview.Add((export.Key, export.Format, action, details));
}
// Build result
var result = new
{
manifestPath,
bundleDirectory = bundleDir,
domain = manifest.DomainId,
displayName = manifest.DisplayName,
generatedAt = manifest.GeneratedAt,
targetScope = scopeDescription,
exportCount = manifest.Exports?.Count ?? 0,
dryRun,
verifyOnly,
checksumVerification = new
{
checksumFileFound = File.Exists(checksumPath),
allValid,
results = verificationResults.Select(r => new
{
file = r.File,
expected = TruncateMirrorDigest(r.Expected),
actual = TruncateMirrorDigest(r.Actual),
valid = r.Valid
}).ToList()
},
imports = importPreview.Select(i => new
{
key = i.Key,
format = i.Format,
action = i.Action,
details = i.Details
}).ToList(),
status = !allValid ? "VERIFICATION_FAILED" : (verifyOnly ? "VERIFIED" : (dryRun ? "DRY_RUN" : "IMPORTED")),
auditLogEntry = new
{
timestamp = DateTimeOffset.UtcNow.ToString("o"),
action = verifyOnly ? "AIRGAP_VERIFY" : (dryRun ? "AIRGAP_IMPORT_PREVIEW" : "AIRGAP_IMPORT"),
domain = manifest.DomainId,
scope = scopeDescription,
force,
manifestDigest = ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson))
}
};
// Output results
if (emitJson)
{
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
AnsiConsole.WriteLine(json);
}
else
{
if (!allValid)
{
AnsiConsole.MarkupLine("[red]Bundle verification failed![/]");
AnsiConsole.WriteLine();
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("File");
table.AddColumn("Status");
table.AddColumn("Details");
foreach (var (file, expected, actual, valid) in verificationResults)
{
var validationStatus = valid ? "[green]VALID[/]" : "[red]INVALID[/]";
var details = valid ? "" : $"Expected: {TruncateMirrorDigest(expected)}, Got: {TruncateMirrorDigest(actual)}";
table.AddRow(Markup.Escape(file), validationStatus, Markup.Escape(details));
}
AnsiConsole.Write(table);
CliMetrics.RecordOfflineKitImport("verification_failed");
return ExitVerificationFailed;
}
var action = verifyOnly ? "Verified" : (dryRun ? "Previewing import of" : "Imported");
AnsiConsole.MarkupLine($"[green]{action} bundle:[/] {Markup.Escape(manifest.DomainId)}");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Domain:[/]", Markup.Escape(manifest.DomainId));
grid.AddRow("[grey]Display Name:[/]", Markup.Escape(manifest.DisplayName ?? "-"));
grid.AddRow("[grey]Generated At:[/]", manifest.GeneratedAt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"));
grid.AddRow("[grey]Scope:[/]", Markup.Escape(scopeDescription));
grid.AddRow("[grey]Exports:[/]", (manifest.Exports?.Count ?? 0).ToString());
if (verificationResults.Count > 0)
grid.AddRow("[grey]Checksum Verification:[/]", allValid ? "[green]PASSED[/]" : "[red]FAILED[/]");
grid.AddRow("[grey]Mode:[/]", verifyOnly ? "Verify Only" : (dryRun ? "Dry Run" : "Live Import"));
AnsiConsole.Write(grid);
if (importPreview.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Exports:[/]");
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Key");
table.AddColumn("Format");
table.AddColumn("Action");
table.AddColumn("Details");
foreach (var (key, format, act, details) in importPreview)
{
table.AddRow(Markup.Escape(key), Markup.Escape(format), Markup.Escape(act), Markup.Escape(details));
}
AnsiConsole.Write(table);
}
if (dryRun)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[grey]Dry run - no changes were made. Remove --dry-run to perform the import.[/]");
}
else if (!verifyOnly)
{
// CLI-AIRGAP-56-001: Use MirrorBundleImportService for real import
var importService = scope.ServiceProvider.GetService<IMirrorBundleImportService>();
if (importService is not null)
{
var importRequest = new MirrorImportRequest
{
BundlePath = bundlePath,
TenantId = effectiveTenant ?? (globalScope ? "global" : "default"),
TrustRootsPath = null, // Use bundled trust roots
DryRun = false,
Force = force
};
var importResult = await importService.ImportAsync(importRequest, cancellationToken).ConfigureAwait(false);
if (!importResult.Success)
{
AnsiConsole.MarkupLine($"[red]Import failed:[/] {Markup.Escape(importResult.Error ?? "Unknown error")}");
CliMetrics.RecordOfflineKitImport("import_failed");
return ExitGeneralError;
}
// Show DSSE verification status if applicable
if (importResult.DsseVerification is not null)
{
var dsseStatus = importResult.DsseVerification.IsValid ? "[green]VERIFIED[/]" : "[yellow]NOT VERIFIED[/]";
AnsiConsole.MarkupLine($"[grey]DSSE Signature:[/] {dsseStatus}");
if (!string.IsNullOrEmpty(importResult.DsseVerification.KeyId))
{
AnsiConsole.MarkupLine($"[grey] Key ID:[/] {Markup.Escape(TruncateMirrorDigest(importResult.DsseVerification.KeyId))}");
}
}
// Show imported paths in verbose mode
if (verbose && importResult.ImportedPaths.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Imported files:[/]");
foreach (var path in importResult.ImportedPaths.Take(10))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(Path.GetFileName(path))}[/]");
}
if (importResult.ImportedPaths.Count > 10)
{
AnsiConsole.MarkupLine($" [grey]... and {importResult.ImportedPaths.Count - 10} more files[/]");
}
}
logger.LogInformation("Air-gap bundle imported: domain={Domain}, exports={Exports}, scope={Scope}, files={FileCount}",
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription, importResult.ImportedPaths.Count);
}
else
{
// Fallback: log success without actual import
logger.LogInformation("Air-gap bundle imported (catalog-only): domain={Domain}, exports={Exports}, scope={Scope}",
manifest.DomainId, manifest.Exports?.Count ?? 0, scopeDescription);
}
}
}
var status = verifyOnly ? "verified" : (dryRun ? "dry_run" : "imported");
CliMetrics.RecordOfflineKitImport(status);
return ExitSuccess;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
return 130;
}
catch (JsonException ex)
{
logger.LogError(ex, "Failed to parse bundle manifest.");
AnsiConsole.MarkupLine($"[red]Error parsing manifest:[/] {Markup.Escape(ex.Message)}");
return ExitInputError;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to import air-gap bundle.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
return ExitGeneralError;
}
}
/// <summary>
/// Handles the 'stella airgap seal' command (CLI-AIRGAP-57-002).
/// Seals the environment for air-gapped operation by:
/// - Optionally verifying all imported bundles
/// - Creating a sealed mode marker file
/// - Disabling remote connectivity settings
/// - Recording the seal event in audit log
/// </summary>
public static async Task<int> HandleAirgapSealAsync(
IServiceProvider services,
string? configDir,
bool verify,
bool force,
bool dryRun,
bool emitJson,
string? reason,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitGeneralError = 1;
const int ExitVerificationFailed = 22;
const int ExitAlreadySealed = 23;
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("airgap-seal");
using var activity = CliActivitySource.Instance.StartActivity("cli.airgap.seal", System.Diagnostics.ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "airgap seal");
using var duration = CliMetrics.MeasureCommandDuration("airgap seal");
try
{
// Determine config directory
var configPath = configDir ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".stellaops");
if (!Directory.Exists(configPath))
{
Directory.CreateDirectory(configPath);
}
var sealMarkerPath = Path.Combine(configPath, "sealed.json");
var bundlesPath = Path.Combine(configPath, "bundles");
var auditLogPath = Path.Combine(configPath, "audit", "seal-events.ndjson");
// Check if already sealed
var isAlreadySealed = File.Exists(sealMarkerPath);
if (isAlreadySealed && !force)
{
if (emitJson)
{
var errorResult = new
{
success = false,
error = "Environment is already sealed. Use --force to reseal.",
sealMarkerPath,
existingSealedAt = File.Exists(sealMarkerPath)
? File.GetLastWriteTimeUtc(sealMarkerPath).ToString("o")
: null
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(errorResult, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
else
{
AnsiConsole.MarkupLine("[yellow]Environment is already sealed.[/]");
AnsiConsole.MarkupLine($"[grey]Seal marker:[/] {Markup.Escape(sealMarkerPath)}");
AnsiConsole.MarkupLine("[grey]Use --force to reseal the environment.[/]");
}
return ExitAlreadySealed;
}
// Verify bundles if requested
var verificationResults = new List<(string BundleId, bool Valid, string Details)>();
var verificationWarnings = new List<string>();
if (verify && Directory.Exists(bundlesPath))
{
var bundleDirs = Directory.GetDirectories(bundlesPath);
foreach (var bundleDir in bundleDirs)
{
cancellationToken.ThrowIfCancellationRequested();
var manifestPath = Path.Combine(bundleDir, "manifest.json");
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
var bundleId = Path.GetFileName(bundleDir);
if (!File.Exists(manifestPath))
{
verificationResults.Add((bundleId, false, "Missing manifest.json"));
verificationWarnings.Add($"Bundle '{bundleId}' has no manifest.json");
continue;
}
if (!File.Exists(checksumPath))
{
verificationResults.Add((bundleId, true, "No checksums (unverified)"));
verificationWarnings.Add($"Bundle '{bundleId}' has no SHA256SUMS file");
continue;
}
// Verify checksums
var checksumLines = await File.ReadAllLinesAsync(checksumPath, cancellationToken);
var allValid = true;
foreach (var line in checksumLines.Where(l => !string.IsNullOrWhiteSpace(l)))
{
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var expectedDigest = parts[0];
var fileName = parts[1].TrimStart('*');
var filePath = Path.Combine(bundleDir, fileName);
if (!File.Exists(filePath))
{
allValid = false;
verificationWarnings.Add($"Bundle '{bundleId}': Missing file '{fileName}'");
continue;
}
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
var actualDigest = ComputeMirrorSha256Digest(fileBytes);
if (!string.Equals(expectedDigest, actualDigest, StringComparison.OrdinalIgnoreCase) &&
!string.Equals($"sha256:{expectedDigest}", actualDigest, StringComparison.OrdinalIgnoreCase))
{
allValid = false;
verificationWarnings.Add($"Bundle '{bundleId}': Checksum mismatch for '{fileName}'");
}
}
verificationResults.Add((bundleId, allValid, allValid ? "All checksums valid" : "Checksum failures"));
}
}
// Check for verification failures
var hasFailures = verificationResults.Any(r => !r.Valid);
if (hasFailures && !force)
{
if (emitJson)
{
var errorResult = new
{
success = false,
error = "Bundle verification failed. Use --force to seal anyway.",
verificationResults = verificationResults.Select(r => new
{
bundleId = r.BundleId,
valid = r.Valid,
details = r.Details
}).ToList(),
warnings = verificationWarnings
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(errorResult, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
else
{
AnsiConsole.MarkupLine("[red]Bundle verification failed![/]");
foreach (var warning in verificationWarnings)
{
AnsiConsole.MarkupLine($" [yellow]![/] {Markup.Escape(warning)}");
}
AnsiConsole.MarkupLine("[grey]Use --force to seal the environment anyway.[/]");
}
CliMetrics.RecordOfflineKitImport("seal_verification_failed");
return ExitVerificationFailed;
}
// Build seal record
var sealRecord = new
{
schemaVersion = "1.0",
sealedAt = DateTimeOffset.UtcNow.ToString("o"),
sealedBy = Environment.UserName,
hostname = Environment.MachineName,
reason = reason ?? "Manual seal via stella airgap seal",
verification = new
{
performed = verify,
bundlesChecked = verificationResults.Count,
allValid = !hasFailures,
warnings = verificationWarnings
},
configuration = new
{
telemetryMode = "local",
networkMode = "offline",
updateMode = "disabled"
}
};
// Build audit log entry
var auditEntry = new
{
timestamp = DateTimeOffset.UtcNow.ToString("o"),
action = dryRun ? "AIRGAP_SEAL_PREVIEW" : "AIRGAP_SEAL",
actor = Environment.UserName,
hostname = Environment.MachineName,
reason = reason ?? "Manual seal",
force,
previouslySealed = isAlreadySealed,
verificationPerformed = verify,
bundlesVerified = verificationResults.Count,
warnings = verificationWarnings.Count
};
// Dry run output
if (dryRun)
{
if (emitJson)
{
var previewResult = new
{
dryRun = true,
wouldCreate = new
{
sealMarker = sealMarkerPath,
auditLog = auditLogPath
},
sealRecord,
auditEntry,
verificationResults = verificationResults.Select(r => new
{
bundleId = r.BundleId,
valid = r.Valid,
details = r.Details
}).ToList()
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(previewResult, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
else
{
AnsiConsole.MarkupLine("[bold]Dry run: Seal operation preview[/]");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Would create seal marker:[/]", Markup.Escape(sealMarkerPath));
grid.AddRow("[grey]Would create audit entry:[/]", Markup.Escape(auditLogPath));
grid.AddRow("[grey]Sealed by:[/]", Markup.Escape(sealRecord.sealedBy));
grid.AddRow("[grey]Hostname:[/]", Markup.Escape(sealRecord.hostname));
grid.AddRow("[grey]Reason:[/]", Markup.Escape(sealRecord.reason));
grid.AddRow("[grey]Telemetry mode:[/]", sealRecord.configuration.telemetryMode);
grid.AddRow("[grey]Network mode:[/]", sealRecord.configuration.networkMode);
AnsiConsole.Write(grid);
if (verificationResults.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Verification Results:[/]");
var table = new Table { Border = TableBorder.Rounded };
table.AddColumn("Bundle");
table.AddColumn("Status");
table.AddColumn("Details");
foreach (var (bundleId, valid, details) in verificationResults)
{
var verifyStatus = valid ? "[green]VALID[/]" : "[red]INVALID[/]";
table.AddRow(Markup.Escape(bundleId), verifyStatus, Markup.Escape(details));
}
AnsiConsole.Write(table);
}
}
CliMetrics.RecordOfflineKitImport("seal_dry_run");
return ExitSuccess;
}
// Actually seal the environment
// 1. Create audit log directory if needed
var auditDir = Path.GetDirectoryName(auditLogPath);
if (!string.IsNullOrEmpty(auditDir) && !Directory.Exists(auditDir))
{
Directory.CreateDirectory(auditDir);
}
// 2. Write audit log entry (append)
var auditJson = JsonSerializer.Serialize(auditEntry, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await File.AppendAllTextAsync(auditLogPath, auditJson + Environment.NewLine, cancellationToken);
// 3. Write seal marker
var sealJson = JsonSerializer.Serialize(sealRecord, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await File.WriteAllTextAsync(sealMarkerPath, sealJson, cancellationToken);
// 4. Enable sealed mode in CliMetrics
CliMetrics.IsSealedMode = true;
// Output results
if (emitJson)
{
var successResult = new
{
success = true,
sealMarkerPath,
auditLogPath,
sealRecord,
verificationResults = verificationResults.Select(r => new
{
bundleId = r.BundleId,
valid = r.Valid,
details = r.Details
}).ToList()
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(successResult, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
else
{
AnsiConsole.MarkupLine("[green]Environment sealed successfully![/]");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Seal marker:[/]", Markup.Escape(sealMarkerPath));
grid.AddRow("[grey]Audit log:[/]", Markup.Escape(auditLogPath));
grid.AddRow("[grey]Sealed at:[/]", sealRecord.sealedAt);
grid.AddRow("[grey]Sealed by:[/]", Markup.Escape(sealRecord.sealedBy));
grid.AddRow("[grey]Reason:[/]", Markup.Escape(sealRecord.reason));
AnsiConsole.Write(grid);
if (verificationResults.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[grey]Bundles verified:[/] {verificationResults.Count}");
if (verificationWarnings.Count > 0)
{
AnsiConsole.MarkupLine($"[yellow]Warnings:[/] {verificationWarnings.Count}");
}
}
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim]The CLI will now operate in air-gapped mode.[/]");
AnsiConsole.MarkupLine("[dim]Remote connectivity has been disabled.[/]");
}
CliMetrics.RecordOfflineKitImport("sealed");
return ExitSuccess;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
return 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to seal environment.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
return ExitGeneralError;
}
}
/// <summary>
/// Handle 'stella airgap export-evidence' command (CLI-AIRGAP-58-001).
/// Exports portable evidence packages for audit and compliance.
/// </summary>
public static async Task<int> HandleAirgapExportEvidenceAsync(
IServiceProvider services,
string outputPath,
string[] includeTypes,
DateTimeOffset? fromDate,
DateTimeOffset? toDate,
string? tenant,
string? subject,
bool compress,
bool emitJson,
bool verify,
bool verbose,
CancellationToken cancellationToken)
{
const int ExitSuccess = 0;
const int ExitInputError = 1;
const int ExitGeneralError = 2;
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("StellaOps.Cli.AirgapExportEvidence");
using var durationScope = CliMetrics.MeasureCommandDuration("airgap export-evidence");
try
{
// Determine which evidence types to include
var effectiveTypes = includeTypes.Length == 0
? new[] { "all" }
: includeTypes.Select(t => t.ToLowerInvariant()).ToArray();
var includeAll = effectiveTypes.Contains("all");
var includeAttestations = includeAll || effectiveTypes.Contains("attestations");
var includeSboms = includeAll || effectiveTypes.Contains("sboms");
var includeScans = includeAll || effectiveTypes.Contains("scans");
var includeVex = includeAll || effectiveTypes.Contains("vex");
// Determine config directory
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".stellaops");
// Prepare output directory
var guidPart = Guid.NewGuid().ToString("N")[..8];
var packageId = $"evidence-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}-{guidPart}";
var packageDir = Path.Combine(outputPath, packageId);
if (Directory.Exists(packageDir))
{
AnsiConsole.MarkupLine($"[red]Error:[/] Output directory already exists: {Markup.Escape(packageDir)}");
return ExitInputError;
}
Directory.CreateDirectory(packageDir);
// Evidence collection tracking
var evidenceFiles = new List<(string Type, string RelativePath, string Digest, long Size)>();
var verificationResults = new List<(string File, bool Valid, string Details)>();
var warnings = new List<string>();
// Create subdirectories for each evidence type
if (includeAttestations)
{
var attestDir = Path.Combine(packageDir, "attestations");
Directory.CreateDirectory(attestDir);
// Look for attestation files in config directory
var attestSourceDir = Path.Combine(configDir, "attestations");
if (Directory.Exists(attestSourceDir))
{
foreach (var file in Directory.GetFiles(attestSourceDir, "*.json"))
{
try
{
var fileName = Path.GetFileName(file);
var content = await File.ReadAllBytesAsync(file, cancellationToken);
// Filter by date if specified
if (fromDate.HasValue || toDate.HasValue)
{
var fileInfo = new FileInfo(file);
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
continue;
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
continue;
}
// Filter by subject if specified
if (!string.IsNullOrEmpty(subject))
{
var json = Encoding.UTF8.GetString(content);
if (!json.Contains(subject, StringComparison.OrdinalIgnoreCase))
continue;
}
var destPath = Path.Combine(attestDir, fileName);
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
var digest = ComputeMirrorSha256Digest(content);
evidenceFiles.Add(("attestations", $"attestations/{fileName}", digest, content.Length));
// Verify signature if requested
if (verify)
{
// Basic DSSE structure validation
try
{
var envelope = JsonSerializer.Deserialize<JsonElement>(content);
var hasPayload = envelope.TryGetProperty("payload", out var payloadElem);
var hasSignatures = envelope.TryGetProperty("signatures", out var sigs) &&
sigs.ValueKind == JsonValueKind.Array &&
sigs.GetArrayLength() > 0;
verificationResults.Add((fileName, hasPayload && hasSignatures,
hasPayload && hasSignatures ? "Valid DSSE structure" : "Invalid DSSE structure"));
}
catch
{
verificationResults.Add((fileName, false, "Failed to parse as JSON"));
}
}
}
catch (Exception ex)
{
warnings.Add($"Failed to export attestation {Path.GetFileName(file)}: {ex.Message}");
}
}
}
}
if (includeSboms)
{
var sbomDir = Path.Combine(packageDir, "sboms");
Directory.CreateDirectory(sbomDir);
// Look for SBOM files
var sbomSourceDir = Path.Combine(configDir, "sboms");
if (Directory.Exists(sbomSourceDir))
{
foreach (var file in Directory.GetFiles(sbomSourceDir, "*.json")
.Concat(Directory.GetFiles(sbomSourceDir, "*.spdx"))
.Concat(Directory.GetFiles(sbomSourceDir, "*.cdx.json")))
{
try
{
var fileName = Path.GetFileName(file);
var content = await File.ReadAllBytesAsync(file, cancellationToken);
// Date filter
if (fromDate.HasValue || toDate.HasValue)
{
var fileInfo = new FileInfo(file);
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
continue;
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
continue;
}
var destPath = Path.Combine(sbomDir, fileName);
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
var digest = ComputeMirrorSha256Digest(content);
evidenceFiles.Add(("sboms", $"sboms/{fileName}", digest, content.Length));
}
catch (Exception ex)
{
warnings.Add($"Failed to export SBOM {Path.GetFileName(file)}: {ex.Message}");
}
}
}
}
if (includeScans)
{
var scanDir = Path.Combine(packageDir, "scans");
Directory.CreateDirectory(scanDir);
// Look for scan result files
var scanSourceDir = Path.Combine(configDir, "scans");
if (Directory.Exists(scanSourceDir))
{
foreach (var file in Directory.GetFiles(scanSourceDir, "*.json"))
{
try
{
var fileName = Path.GetFileName(file);
var content = await File.ReadAllBytesAsync(file, cancellationToken);
// Date filter
if (fromDate.HasValue || toDate.HasValue)
{
var fileInfo = new FileInfo(file);
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
continue;
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
continue;
}
var destPath = Path.Combine(scanDir, fileName);
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
var digest = ComputeMirrorSha256Digest(content);
evidenceFiles.Add(("scans", $"scans/{fileName}", digest, content.Length));
}
catch (Exception ex)
{
warnings.Add($"Failed to export scan {Path.GetFileName(file)}: {ex.Message}");
}
}
}
}
if (includeVex)
{
var vexDir = Path.Combine(packageDir, "vex");
Directory.CreateDirectory(vexDir);
// Look for VEX files
var vexSourceDir = Path.Combine(configDir, "vex");
if (Directory.Exists(vexSourceDir))
{
foreach (var file in Directory.GetFiles(vexSourceDir, "*.json"))
{
try
{
var fileName = Path.GetFileName(file);
var content = await File.ReadAllBytesAsync(file, cancellationToken);
// Date filter
if (fromDate.HasValue || toDate.HasValue)
{
var fileInfo = new FileInfo(file);
if (fromDate.HasValue && fileInfo.CreationTimeUtc < fromDate.Value)
continue;
if (toDate.HasValue && fileInfo.CreationTimeUtc > toDate.Value)
continue;
}
var destPath = Path.Combine(vexDir, fileName);
await File.WriteAllBytesAsync(destPath, content, cancellationToken);
var digest = ComputeMirrorSha256Digest(content);
evidenceFiles.Add(("vex", $"vex/{fileName}", digest, content.Length));
}
catch (Exception ex)
{
warnings.Add($"Failed to export VEX {Path.GetFileName(file)}: {ex.Message}");
}
}
}
}
// Create manifest
var manifest = new
{
schemaVersion = "1.0",
packageId,
createdAt = DateTimeOffset.UtcNow.ToString("o"),
createdBy = Environment.UserName,
hostname = Environment.MachineName,
filters = new
{
fromDate = fromDate?.ToString("o"),
toDate = toDate?.ToString("o"),
tenant,
subject,
types = effectiveTypes
},
evidence = evidenceFiles.Select(f => new
{
type = f.Type,
path = f.RelativePath,
digest = f.Digest,
size = f.Size
}).ToList(),
verification = verify ? new
{
performed = true,
results = verificationResults.Select(r => new
{
file = r.File,
valid = r.Valid,
details = r.Details
}).ToList()
} : null,
warnings
};
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await File.WriteAllTextAsync(Path.Combine(packageDir, "manifest.json"), manifestJson, cancellationToken);
// Create SHA256SUMS file
var checksumLines = evidenceFiles
.Select(f => $"{f.Digest} {f.RelativePath}")
.ToList();
checksumLines.Insert(0, $"# Evidence package checksum manifest");
checksumLines.Insert(1, $"# Generated: {DateTimeOffset.UtcNow:o}");
await File.WriteAllLinesAsync(Path.Combine(packageDir, "SHA256SUMS"), checksumLines, cancellationToken);
// Compress if requested
string finalOutput = packageDir;
if (compress)
{
var archivePath = packageDir + ".tar.gz";
try
{
// Use tar command for compression (cross-platform approach)
var tarProcess = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "tar",
Arguments = $"-czf \"{archivePath}\" -C \"{outputPath}\" \"{packageId}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
tarProcess.Start();
await tarProcess.WaitForExitAsync(cancellationToken);
if (tarProcess.ExitCode == 0)
{
// Remove uncompressed directory
Directory.Delete(packageDir, recursive: true);
finalOutput = archivePath;
}
else
{
warnings.Add("Failed to compress package; uncompressed directory retained.");
}
}
catch (Exception ex)
{
warnings.Add($"Compression failed: {ex.Message}; uncompressed directory retained.");
}
}
// Output results
if (emitJson)
{
var result = new
{
success = true,
packageId,
outputPath = finalOutput,
compressed = compress && finalOutput.EndsWith(".tar.gz"),
evidenceCount = evidenceFiles.Count,
totalSize = evidenceFiles.Sum(f => f.Size),
types = evidenceFiles.GroupBy(f => f.Type)
.ToDictionary(g => g.Key, g => g.Count()),
verification = verify ? new
{
performed = true,
passed = verificationResults.Count(r => r.Valid),
failed = verificationResults.Count(r => !r.Valid),
results = verificationResults.Select(r => new
{
file = r.File,
valid = r.Valid,
details = r.Details
}).ToList()
} : null,
warnings
};
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
else
{
AnsiConsole.MarkupLine("[green]Evidence package created successfully![/]");
AnsiConsole.WriteLine();
var grid = new Grid();
grid.AddColumn();
grid.AddColumn();
grid.AddRow("[grey]Package ID:[/]", Markup.Escape(packageId));
grid.AddRow("[grey]Output:[/]", Markup.Escape(finalOutput));
grid.AddRow("[grey]Files:[/]", evidenceFiles.Count.ToString());
grid.AddRow("[grey]Total size:[/]", FormatBytes(evidenceFiles.Sum(f => f.Size)));
AnsiConsole.Write(grid);
AnsiConsole.WriteLine();
// Show evidence type breakdown
AnsiConsole.MarkupLine("[bold]Evidence by type:[/]");
var typeTable = new Table { Border = TableBorder.Rounded };
typeTable.AddColumn("Type");
typeTable.AddColumn("Count");
typeTable.AddColumn("Size");
foreach (var group in evidenceFiles.GroupBy(f => f.Type))
{
typeTable.AddRow(
Markup.Escape(group.Key),
group.Count().ToString(),
FormatBytes(group.Sum(f => f.Size)));
}
AnsiConsole.Write(typeTable);
// Show verification results if requested
if (verify && verificationResults.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Verification Results:[/]");
var verifyTable = new Table { Border = TableBorder.Rounded };
verifyTable.AddColumn("File");
verifyTable.AddColumn("Status");
verifyTable.AddColumn("Details");
foreach (var (file, valid, details) in verificationResults)
{
var status = valid ? "[green]VALID[/]" : "[red]INVALID[/]";
verifyTable.AddRow(Markup.Escape(file), status, Markup.Escape(details));
}
AnsiConsole.Write(verifyTable);
}
// Show warnings
if (warnings.Count > 0)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[yellow]Warnings:[/]");
foreach (var warning in warnings)
{
AnsiConsole.MarkupLine($" [yellow]![/] {Markup.Escape(warning)}");
}
}
}
CliMetrics.RecordOfflineKitImport("evidence_exported");
return ExitSuccess;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogWarning("Operation cancelled by user.");
return 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export evidence package.");
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
return ExitGeneralError;
}
}
#endregion
#region DevPortal Commands
/// <summary>
/// Handler for 'stella devportal verify' command (DVOFF-64-002).
/// Verifies integrity of a DevPortal/evidence bundle before import.
/// Exit codes: 0 success, 2 checksum mismatch, 3 signature failure, 4 TSA missing, 5 unexpected.
/// </summary>
public static async Task<int> HandleDevPortalVerifyAsync(
IServiceProvider services,
string bundlePath,
bool offline,
bool emitJson,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<DevPortalBundleVerifier>();
var verifier = new DevPortalBundleVerifier(logger);
using var activity = CliActivitySource.Instance.StartActivity("cli.devportal.verify", System.Diagnostics.ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "devportal verify");
activity?.SetTag("stellaops.cli.devportal.offline", offline);
using var duration = CliMetrics.MeasureCommandDuration("devportal verify");
try
{
var resolvedPath = Path.GetFullPath(bundlePath);
if (verbose)
{
AnsiConsole.MarkupLine($"[grey]Verifying bundle: {Markup.Escape(resolvedPath)}[/]");
if (offline)
{
AnsiConsole.MarkupLine("[grey]Mode: offline (TSA verification skipped)[/]");
}
}
var result = await verifier.VerifyBundleAsync(resolvedPath, offline, cancellationToken)
.ConfigureAwait(false);
activity?.SetTag("stellaops.cli.devportal.status", result.Status);
activity?.SetTag("stellaops.cli.devportal.exit_code", (int)result.ExitCode);
if (emitJson)
{
Console.WriteLine(result.ToJson());
}
else
{
if (result.ExitCode == DevPortalVerifyExitCode.Success)
{
AnsiConsole.MarkupLine("[green]Bundle verification successful.[/]");
AnsiConsole.MarkupLine($" Bundle ID: {Markup.Escape(result.BundleId ?? "unknown")}");
AnsiConsole.MarkupLine($" Root Hash: {Markup.Escape(result.RootHash ?? "unknown")}");
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O") ?? "unknown"}");
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
}
else
{
AnsiConsole.MarkupLine($"[red]Bundle verification failed:[/] {Markup.Escape(result.ErrorMessage ?? "Unknown error")}");
if (!string.IsNullOrEmpty(result.ErrorDetail))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(result.ErrorDetail)}[/]");
}
}
}
return (int)result.ExitCode;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (!emitJson)
{
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
}
return 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to verify bundle");
if (emitJson)
{
var errorResult = DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.Unexpected,
ex.Message);
Console.WriteLine(errorResult.ToJson());
}
else
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
}
return (int)DevPortalVerifyExitCode.Unexpected;
}
}
#endregion
}