Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- 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.
5826 lines
216 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|