feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
@@ -25,6 +25,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRuntimeCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
|
||||
@@ -335,11 +336,16 @@ internal static class CommandFactory
|
||||
{
|
||||
Description = "Optional provider identifier when requesting targeted exports."
|
||||
};
|
||||
var exportOutputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Optional path to download the export artifact."
|
||||
};
|
||||
export.Add(formatOption);
|
||||
export.Add(exportDeltaOption);
|
||||
export.Add(exportScopeOption);
|
||||
export.Add(exportSinceOption);
|
||||
export.Add(exportProviderOption);
|
||||
export.Add(exportOutputOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "openvex";
|
||||
@@ -347,8 +353,9 @@ internal static class CommandFactory
|
||||
var scope = parseResult.GetValue(exportScopeOption);
|
||||
var since = parseResult.GetValue(exportSinceOption);
|
||||
var provider = parseResult.GetValue(exportProviderOption);
|
||||
var output = parseResult.GetValue(exportOutputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, verbose, cancellationToken);
|
||||
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var verify = new Command("verify", "Verify Excititor exports or attestations.");
|
||||
@@ -406,6 +413,70 @@ internal static class CommandFactory
|
||||
return excititor;
|
||||
}
|
||||
|
||||
private static Command BuildRuntimeCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var runtime = new Command("runtime", "Interact with runtime admission policy APIs.");
|
||||
var policy = new Command("policy", "Runtime policy operations.");
|
||||
|
||||
var test = new Command("test", "Evaluate runtime policy decisions for image digests.");
|
||||
var namespaceOption = new Option<string?>("--namespace", new[] { "--ns" })
|
||||
{
|
||||
Description = "Namespace or logical scope for the evaluation."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string[]>("--image", new[] { "-i", "--images" })
|
||||
{
|
||||
Description = "Image digests to evaluate (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
|
||||
var fileOption = new Option<string?>("--file", new[] { "-f" })
|
||||
{
|
||||
Description = "Path to a file containing image digests (one per line)."
|
||||
};
|
||||
|
||||
var labelOption = new Option<string[]>("--label", new[] { "-l", "--labels" })
|
||||
{
|
||||
Description = "Pod labels in key=value format (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit the raw JSON response."
|
||||
};
|
||||
|
||||
test.Add(namespaceOption);
|
||||
test.Add(imageOption);
|
||||
test.Add(fileOption);
|
||||
test.Add(labelOption);
|
||||
test.Add(jsonOption);
|
||||
|
||||
test.SetAction((parseResult, _) =>
|
||||
{
|
||||
var nsValue = parseResult.GetValue(namespaceOption);
|
||||
var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>();
|
||||
var file = parseResult.GetValue(fileOption);
|
||||
var labels = parseResult.GetValue(labelOption) ?? Array.Empty<string>();
|
||||
var outputJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleRuntimePolicyTestAsync(
|
||||
services,
|
||||
nsValue,
|
||||
images,
|
||||
file,
|
||||
labels,
|
||||
outputJson,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
policy.Add(test);
|
||||
runtime.Add(policy);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -8,6 +9,7 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -512,49 +514,213 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
public static Task HandleExcititorExportAsync(
|
||||
public static async Task HandleExcititorExportAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
bool delta,
|
||||
string? scope,
|
||||
DateTimeOffset? since,
|
||||
string? provider,
|
||||
string? outputPath,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
|
||||
["delta"] = delta
|
||||
};
|
||||
|
||||
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))
|
||||
{
|
||||
payload["scope"] = scope.Trim();
|
||||
activity?.SetTag("stellaops.cli.scope", scope);
|
||||
}
|
||||
if (since.HasValue)
|
||||
{
|
||||
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
activity?.SetTag("stellaops.cli.since", since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
payload["provider"] = provider.Trim();
|
||||
activity?.SetTag("stellaops.cli.provider", provider);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.output", outputPath);
|
||||
}
|
||||
using var duration = CliMetrics.MeasureCommandDuration("excititor export");
|
||||
|
||||
return ExecuteExcititorCommandAsync(
|
||||
services,
|
||||
commandName: "excititor export",
|
||||
verbose,
|
||||
new Dictionary<string, object?>
|
||||
try
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["format"] = payload["format"],
|
||||
["delta"] = delta,
|
||||
["scope"] = scope,
|
||||
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
["provider"] = provider
|
||||
},
|
||||
client => client.ExecuteExcititorOperationAsync("export", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||
cancellationToken);
|
||||
["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 HandleExcititorVerifyAsync(
|
||||
@@ -646,6 +812,106 @@ internal static class CommandHandlers
|
||||
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,
|
||||
@@ -1485,6 +1751,617 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
["hasSbom"] = decision.HasSbom
|
||||
};
|
||||
|
||||
if (decision.Reasons.Count > 0)
|
||||
{
|
||||
map["reasons"] = decision.Reasons;
|
||||
}
|
||||
|
||||
if (decision.Rekor is not null)
|
||||
{
|
||||
map["rekor"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["uuid"] = decision.Rekor.Uuid,
|
||||
["url"] = decision.Rekor.Url
|
||||
};
|
||||
}
|
||||
|
||||
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", "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.HasSbom),
|
||||
decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
|
||||
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!);
|
||||
|
||||
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} sbom={Sbom} attestation={Attestation} reasons={Reasons}",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
FormatBoolean(decision.Signed),
|
||||
FormatBoolean(decision.HasSbom),
|
||||
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!,
|
||||
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 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 readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
|
||||
{
|
||||
if (providers is null || providers.Count == 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@@ -25,6 +26,8 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
@@ -266,6 +269,208 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return new ExcititorOperationResult(false, failure, null, null);
|
||||
}
|
||||
|
||||
public async Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(exportId))
|
||||
{
|
||||
throw new ArgumentException("Export id must be provided.", nameof(exportId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(destinationPath))
|
||||
{
|
||||
throw new ArgumentException("Destination path must be provided.", nameof(destinationPath));
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(destinationPath);
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var normalizedAlgorithm = string.IsNullOrWhiteSpace(expectedDigestAlgorithm)
|
||||
? null
|
||||
: expectedDigestAlgorithm.Trim();
|
||||
var normalizedDigest = NormalizeExpectedDigest(expectedDigest);
|
||||
|
||||
if (File.Exists(fullPath)
|
||||
&& string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(normalizedDigest))
|
||||
{
|
||||
var existingDigest = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
if (string.Equals(existingDigest, normalizedDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var info = new FileInfo(fullPath);
|
||||
_logger.LogDebug("Export {ExportId} already present at {Path}; digest matches.", exportId, fullPath);
|
||||
return new ExcititorExportDownloadResult(fullPath, info.Length, true);
|
||||
}
|
||||
}
|
||||
|
||||
var encodedId = Uri.EscapeDataString(exportId);
|
||||
using var request = CreateRequest(HttpMethod.Get, $"excititor/export/{encodedId}/download");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tempPath = fullPath + ".tmp";
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
|
||||
using (var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (var fileStream = File.Create(tempPath))
|
||||
{
|
||||
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedAlgorithm) && !string.IsNullOrWhiteSpace(normalizedDigest))
|
||||
{
|
||||
if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var computed = await ComputeSha256Async(tempPath, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.Equals(computed, normalizedDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
throw new InvalidOperationException($"Export digest mismatch. Expected sha256:{normalizedDigest}, computed sha256:{computed}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Export digest verification skipped. Unsupported algorithm {Algorithm}.", normalizedAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
File.Delete(fullPath);
|
||||
}
|
||||
|
||||
File.Move(tempPath, fullPath);
|
||||
|
||||
var downloaded = new FileInfo(fullPath);
|
||||
return new ExcititorExportDownloadResult(fullPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var images = NormalizeImages(request.Images);
|
||||
if (images.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one image digest must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
var payload = new RuntimePolicyEvaluationRequestDocument
|
||||
{
|
||||
Namespace = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim(),
|
||||
Images = images
|
||||
};
|
||||
|
||||
if (request.Labels.Count > 0)
|
||||
{
|
||||
payload.Labels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var label in request.Labels)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(label.Key))
|
||||
{
|
||||
payload.Labels[label.Key] = label.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var message = CreateRequest(HttpMethod.Post, "api/scanner/policy/runtime");
|
||||
await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
message.Content = JsonContent.Create(payload, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
RuntimePolicyEvaluationResponseDocument? document;
|
||||
try
|
||||
{
|
||||
document = await response.Content.ReadFromJsonAsync<RuntimePolicyEvaluationResponseDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse runtime policy response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException("Runtime policy response was empty.");
|
||||
}
|
||||
|
||||
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
|
||||
if (document.Results is not null)
|
||||
{
|
||||
foreach (var kvp in document.Results)
|
||||
{
|
||||
var image = kvp.Key;
|
||||
var decision = kvp.Value;
|
||||
if (string.IsNullOrWhiteSpace(image) || decision is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var verdict = string.IsNullOrWhiteSpace(decision.PolicyVerdict)
|
||||
? "unknown"
|
||||
: decision.PolicyVerdict!.Trim();
|
||||
|
||||
var reasons = ExtractReasons(decision.Reasons);
|
||||
var metadata = ExtractExtensionMetadata(decision.ExtensionData);
|
||||
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
if (decision.Rekor is not null &&
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url)))
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
NormalizeOptionalString(decision.Rekor.Uuid),
|
||||
NormalizeOptionalString(decision.Rekor.Url));
|
||||
}
|
||||
|
||||
decisions[image] = new RuntimePolicyImageDecision(
|
||||
verdict,
|
||||
decision.Signed,
|
||||
decision.HasSbom,
|
||||
reasons,
|
||||
rekor,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
var decisionsView = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions);
|
||||
|
||||
return new RuntimePolicyEvaluationResult(
|
||||
document.TtlSeconds ?? 0,
|
||||
document.ExpiresAtUtc?.ToUniversalTime(),
|
||||
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
|
||||
decisionsView);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -324,7 +529,96 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
private static List<string> NormalizeImages(IReadOnlyList<string> images)
|
||||
{
|
||||
var normalized = new List<string>();
|
||||
if (images is null)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in images)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = entry.Trim();
|
||||
if (seen.Add(trimmed))
|
||||
{
|
||||
normalized.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractReasons(List<string>? reasons)
|
||||
{
|
||||
if (reasons is null || reasons.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var list = new List<string>();
|
||||
foreach (var reason in reasons)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
list.Add(reason.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<string>() : list;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> ExtractExtensionMetadata(Dictionary<string, JsonElement>? extensionData)
|
||||
{
|
||||
if (extensionData is null || extensionData.Count == 0)
|
||||
{
|
||||
return EmptyMetadata;
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in extensionData)
|
||||
{
|
||||
var value = ConvertJsonElementToObject(kvp.Value);
|
||||
if (value is not null)
|
||||
{
|
||||
metadata[kvp.Key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.Count == 0)
|
||||
{
|
||||
return EmptyMetadata;
|
||||
}
|
||||
|
||||
return new ReadOnlyDictionary<string, object?>(metadata);
|
||||
}
|
||||
|
||||
private static object? ConvertJsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Number when element.TryGetInt64(out var integer) => integer,
|
||||
JsonValueKind.Number when element.TryGetDouble(out var @double) => @double,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
{
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
@@ -596,12 +890,25 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
string digestHex;
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
private static string? NormalizeExpectedDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = digest.Trim();
|
||||
return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmed[7..]
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
string digestHex;
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -619,18 +926,25 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest[7..];
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest[7..];
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -17,5 +17,9 @@ internal interface IBackendOperationsClient
|
||||
|
||||
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
|
||||
|
||||
Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
|
||||
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorExportDownloadResult(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
bool FromCache);
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Decisions);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbom,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url);
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationRequestDocument
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Namespace { get; set; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<string> Images { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationResponseDocument
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int? TtlSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("expiresAtUtc")]
|
||||
public DateTimeOffset? ExpiresAtUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
{
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public string? PolicyVerdict { get; set; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbom { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public RuntimePolicyRekorDocument? Rekor { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyRekorDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
@@ -15,8 +15,10 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
||||
|Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** – docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.|
|
||||
|Surface password policy diagnostics in CLI output|DevEx/CLI, Security Guild|AUTHSEC-CRYPTO-02-004|**DONE (2025-10-15)** – CLI startup runs the Authority plug-in analyzer, logs weakened password policy warnings with manifest paths, added unit tests (`dotnet test src/StellaOps.Cli.Tests`) and updated docs/09 with remediation guidance.|
|
||||
|EXCITITOR-CLI-01-001 – Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|DONE (2025-10-18) – Introduced `excititor` verbs (init/pull/resume/list-providers/export/verify/reconcile) with token-auth backend calls, provenance-friendly logging, and regression coverage.|
|
||||
|EXCITITOR-CLI-01-002 – Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|TODO – Display export metadata (sha256, size, Rekor link), support optional artifact download path, and handle cache hits gracefully.|
|
||||
|EXCITITOR-CLI-01-003 – CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|TODO – Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|
||||
|CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|TODO – Add `runtime policy test` and related verbs to query `/policy/runtime`, display verdicts/TTL/reasons, and support batch inputs.|
|
||||
|EXCITITOR-CLI-01-002 – Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|DONE (2025-10-19) – CLI export prints digest/size/Rekor metadata, `--output` downloads with SHA-256 verification + cache reuse, and unit coverage validated via `dotnet test src/StellaOps.Cli.Tests`.|
|
||||
|EXCITITOR-CLI-01-003 – CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|**DOING (2025-10-19)** – Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|
||||
|CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|**DONE (2025-10-19)** – Added `runtime policy test` command (stdin/file support, JSON output), backend client method + typed models, verdict table output, docs/tests updated (`dotnet test src/StellaOps.Cli.Tests`).|
|
||||
|CLI-OFFLINE-13-006 – Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO – Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.|
|
||||
|CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|
|
||||
|CLI-RUNTIME-13-008 – Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|TODO – Once `/api/v1/scanner/policy/runtime` exits TODO, verify CLI output against final schema (field names, metadata) and update formatter/tests if the contract moves. Capture joint review notes in docs/09 and link Scanner task sign-off.|
|
||||
|CLI-RUNTIME-13-009 – Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|TODO – Build Spectre test harness exercising `runtime policy test` against a stubbed backend to lock output shape (table + `--json`) and guard regressions. Integrate into `dotnet test` suite.|
|
||||
|
||||
Reference in New Issue
Block a user