Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
master e950474a77
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
up
2025-11-27 15:16:31 +02:00

8008 lines
294 KiB
C#

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.Cli.Configuration;
using StellaOps.Cli.Prompts;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Kms;
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;
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
};
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)
{
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.");
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}");
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))
{
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
plan.Confidence.ToString("F1", CultureInfo.InvariantCulture),
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 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,
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);
}
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);
var input = new PolicySimulationInput(
baseVersion,
candidateVersion,
sbomSet,
environment,
explain);
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);
}
var outputFormat = DeterminePolicySimulationFormat(format, outputPath);
var payload = BuildPolicySimulationPayload(normalizedPolicyId, baseVersion, candidateVersion, sbomSet, environment, result);
if (!string.IsNullOrWhiteSpace(outputPath))
{
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);
}
}
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,
_ => throw new ArgumentException("Invalid format. Use 'table' or 'json'.")
};
}
if (!string.IsNullOrWhiteSpace(outputPath) || 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;
}
logger.LogInformation(
"Policy diff summary — Added: {Added}, Removed: {Removed}, Unchanged: {Unchanged}.",
result.Diff.Added,
result.Diff.Removed,
result.Diff.Unchanged);
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);
}
}
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;
}
private static Task WriteSimulationOutputAsync(string outputPath, object payload, CancellationToken cancellationToken)
=> WriteJsonPayloadAsync(outputPath, payload, cancellationToken);
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
}
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);
}
}
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 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);
#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);
}
if (!string.IsNullOrEmpty(outputPath))
{
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
await File.WriteAllTextAsync(outputPath, reportJson).ConfigureAwait(false);
AnsiConsole.MarkupLine("Validation report written to [cyan]{0}[/]", Markup.Escape(outputPath));
}
}
Environment.ExitCode = result.IsValid ? 0 : (strict ? 1 : 0);
if (!result.IsValid && !strict)
{
Environment.ExitCode = 1;
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine("[red]Error:[/] {0}", Markup.Escape(ex.Message));
Environment.ExitCode = 1;
}
await Task.CompletedTask.ConfigureAwait(false);
}
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);
#endregion
}