Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs
master 240e8ff25d
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat(kms): Implement file-backed key management commands and handlers
- Added `kms export` and `kms import` commands to manage file-backed signing keys.
- Implemented `HandleKmsExportAsync` and `HandleKmsImportAsync` methods in CommandHandlers for exporting and importing key material.
- Introduced KmsPassphrasePrompt for secure passphrase input.
- Updated CLI architecture documentation to include new KMS commands.
- Enhanced unit tests for KMS export and import functionalities.
- Updated project references to include StellaOps.Cryptography.Kms library.
- Marked KMS interface implementation and CLI support tasks as DONE in the task board.
2025-10-30 14:41:48 +02:00

5826 lines
216 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.Serialization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Prompts;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
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 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 path = Path.GetFullPath(file);
await client.UploadScanResultsAsync(path, 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 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(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 });
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 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 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(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(uri, cancellationToken).ConfigureAwait(false);
}
return await LoadIngestInputFromFileAsync(input, cancellationToken).ConfigureAwait(false);
}
private static async Task<IngestInputPayload> LoadIngestInputFromHttpAsync(Uri uri, CancellationToken cancellationToken)
{
using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
using var httpClient = new HttpClient(handler);
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 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;
}
}
}