- Added `SchedulerWorkerOptions` class to encapsulate configuration for the scheduler worker. - Introduced `PlannerBackgroundService` to manage the planner loop, fetching and processing planning runs. - Created `PlannerExecutionService` to handle the execution logic for planning runs, including impact targeting and run persistence. - Developed `PlannerExecutionResult` and `PlannerExecutionStatus` to standardize execution outcomes. - Implemented validation logic within `SchedulerWorkerOptions` to ensure proper configuration. - Added documentation for the planner loop and impact targeting features. - Established health check endpoints and authentication mechanisms for the Signals service. - Created unit tests for the Signals API to ensure proper functionality and response handling. - Configured options for authority integration and fallback authentication methods.
4089 lines
149 KiB
C#
4089 lines
149 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;
|
|
|
|
namespace StellaOps.Cli.Commands;
|
|
|
|
internal static class CommandHandlers
|
|
{
|
|
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 outputPath = Path.GetFullPath(output);
|
|
var directory = Path.GetDirectoryName(outputPath);
|
|
if (!string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var jsonReport = JsonSerializer.Serialize(response, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
await File.WriteAllTextAsync(outputPath, jsonReport, cancellationToken).ConfigureAwait(false);
|
|
logger.LogInformation("Dry-run report written to {Path}.", outputPath);
|
|
}
|
|
|
|
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 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,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, 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,
|
|
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));
|
|
|
|
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);
|
|
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 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 async Task WriteSimulationOutputAsync(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 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 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 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|