Add channel test providers for Email, Slack, Teams, and Webhook

- Implemented EmailChannelTestProvider to generate email preview payloads.
- Implemented SlackChannelTestProvider to create Slack message previews.
- Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews.
- Implemented WebhookChannelTestProvider to create webhook payloads.
- Added INotifyChannelTestProvider interface for channel-specific preview generation.
- Created ChannelTestPreviewContracts for request and response models.
- Developed NotifyChannelTestService to handle test send requests and generate previews.
- Added rate limit policies for test sends and delivery history.
- Implemented unit tests for service registration and binding.
- Updated project files to include necessary dependencies and configurations.
This commit is contained in:
master
2025-10-19 23:29:34 +03:00
parent a811f7ac47
commit a07f46231b
239 changed files with 17245 additions and 3155 deletions

View File

@@ -358,6 +358,48 @@ internal static class CommandFactory
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
});
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
{
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
};
var backfillForceOption = new Option<bool>("--force")
{
Description = "Reprocess documents even if statements already exist."
};
var backfillBatchSizeOption = new Option<int>("--batch-size")
{
Description = "Number of raw documents to fetch per batch (default 100)."
};
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
{
Description = "Optional maximum number of raw documents to process."
};
backfill.Add(backfillRetrievedSinceOption);
backfill.Add(backfillForceOption);
backfill.Add(backfillBatchSizeOption);
backfill.Add(backfillMaxDocumentsOption);
backfill.SetAction((parseResult, _) =>
{
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
var force = parseResult.GetValue(backfillForceOption);
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
if (batchSize <= 0)
{
batchSize = 100;
}
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
services,
retrievedSince,
force,
batchSize,
maxDocuments,
verbose,
cancellationToken);
});
var verify = new Command("verify", "Verify Excititor exports or attestations.");
var exportIdOption = new Option<string?>("--export-id")
{
@@ -408,6 +450,7 @@ internal static class CommandFactory
excititor.Add(resume);
excititor.Add(list);
excititor.Add(export);
excititor.Add(backfill);
excititor.Add(verify);
excititor.Add(reconcile);
return excititor;

View File

@@ -25,103 +25,103 @@ 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);
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>();
@@ -138,128 +138,128 @@ internal static class CommandHandlers
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 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;
}
}
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 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,
@@ -271,16 +271,16 @@ internal static class CommandHandlers
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);
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);
@@ -330,16 +330,16 @@ internal static class CommandHandlers
{
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
{
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;
}
}
@@ -723,6 +723,62 @@ internal static class CommandHandlers
}
}
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,
@@ -2208,7 +2264,7 @@ internal static class CommandHandlers
{
["policyVerdict"] = decision.PolicyVerdict,
["signed"] = decision.Signed,
["hasSbom"] = decision.HasSbom
["hasSbomReferrers"] = decision.HasSbomReferrers
};
if (decision.Reasons.Count > 0)
@@ -2218,11 +2274,26 @@ internal static class CommandHandlers
if (decision.Rekor is not null)
{
map["rekor"] = new Dictionary<string, object?>(StringComparer.Ordinal)
var rekorMap = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid))
{
["uuid"] = decision.Rekor.Uuid,
["url"] = decision.Rekor.Url
};
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)
@@ -2240,7 +2311,8 @@ internal static class CommandHandlers
if (AnsiConsole.Profile.Capabilities.Interactive)
{
var table = new Table().Border(TableBorder.Rounded).AddColumns("Image", "Verdict", "Signed", "SBOM", "Reasons", "Attestation");
var table = new Table().Border(TableBorder.Rounded)
.AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation");
foreach (var image in orderedImages)
{
@@ -2250,9 +2322,11 @@ internal static class CommandHandlers
image,
decision.PolicyVerdict,
FormatBoolean(decision.Signed),
FormatBoolean(decision.HasSbom),
FormatBoolean(decision.HasSbomReferrers),
FormatQuietedDisplay(decision.AdditionalProperties),
FormatConfidenceDisplay(decision.AdditionalProperties),
decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!);
FormatAttestation(decision.Rekor));
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
@@ -2264,7 +2338,7 @@ internal static class CommandHandlers
}
else
{
table.AddRow(image, "<missing>", "-", "-", "-", "-");
table.AddRow(image, "<missing>", "-", "-", "-", "-", "-", "-");
}
}
@@ -2278,12 +2352,14 @@ internal static class CommandHandlers
{
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} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}",
image,
decision.PolicyVerdict,
FormatBoolean(decision.Signed),
FormatBoolean(decision.HasSbom),
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!,
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;
@@ -2346,6 +2422,144 @@ internal static class CommandHandlers
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 readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
private static string FormatAdditionalValue(object? value)
{
return value switch
@@ -2359,8 +2573,6 @@ internal static class CommandHandlers
};
}
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)
{
@@ -2397,29 +2609,29 @@ internal static class CommandHandlers
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;
{
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;
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
@@ -19,9 +19,9 @@ using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services;
namespace StellaOps.Cli.Services;
internal sealed class BackendOperationsClient : IBackendOperationsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
@@ -48,34 +48,34 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
{
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
outputPath = ResolveArtifactPath(outputPath, channel);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
if (!overwrite && File.Exists(outputPath))
{
var existing = new FileInfo(outputPath);
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
return new ScannerArtifactResult(outputPath, existing.Length, true);
}
var attempt = 0;
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
while (true)
{
attempt++;
try
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
outputPath = ResolveArtifactPath(outputPath, channel);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
if (!overwrite && File.Exists(outputPath))
{
var existing = new FileInfo(outputPath);
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
return new ScannerArtifactResult(outputPath, existing.Length, true);
}
var attempt = 0;
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
while (true)
{
attempt++;
try
{
using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
@@ -83,55 +83,55 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (attempt < maxAttempts)
{
var backoffSeconds = Math.Pow(2, attempt);
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
{
var tempFile = outputPath + ".tmp";
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var fileStream = File.Create(tempFile))
{
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
if (verbose)
{
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
}
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
File.Move(tempFile, outputPath);
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
var downloaded = new FileInfo(outputPath);
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
}
}
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (attempt < maxAttempts)
{
var backoffSeconds = Math.Pow(2, attempt);
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
{
var tempFile = outputPath + ".tmp";
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var fileStream = File.Create(tempFile))
{
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
if (verbose)
{
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
}
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
File.Move(tempFile, outputPath);
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
var downloaded = new FileInfo(outputPath);
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
}
public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
@@ -194,46 +194,46 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
}
}
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(jobKind))
{
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
}
var requestBody = new JobTriggerRequest
{
Trigger = "cli",
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
};
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(jobKind))
{
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
}
var requestBody = new JobTriggerRequest
{
Trigger = "cli",
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
};
var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Accepted)
{
JobRunResponse? run = null;
if (response.Content.Headers.ContentLength is > 0)
{
try
{
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
}
}
var location = response.Headers.Location?.ToString();
return new JobTriggerResult(true, "Accepted", location, run);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Accepted)
{
JobRunResponse? run = null;
if (response.Content.Headers.ContentLength is > 0)
{
try
{
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
}
}
var location = response.Headers.Location?.ToString();
return new JobTriggerResult(true, "Accepted", location, run);
}
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
return new JobTriggerResult(false, failureMessage, null, null);
}
@@ -443,19 +443,24 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
var reasons = ExtractReasons(decision.Reasons);
var metadata = ExtractExtensionMetadata(decision.ExtensionData);
var hasSbom = decision.HasSbomReferrers ?? decision.HasSbomLegacy;
RuntimePolicyRekorReference? rekor = null;
if (decision.Rekor is not null &&
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url)))
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) ||
!string.IsNullOrWhiteSpace(decision.Rekor.Url) ||
decision.Rekor.Verified.HasValue))
{
rekor = new RuntimePolicyRekorReference(
NormalizeOptionalString(decision.Rekor.Uuid),
NormalizeOptionalString(decision.Rekor.Url));
NormalizeOptionalString(decision.Rekor.Url),
decision.Rekor.Verified);
}
decisions[image] = new RuntimePolicyImageDecision(
verdict,
decision.Signed,
decision.HasSbom,
hasSbom,
reasons,
rekor,
metadata);
@@ -624,15 +629,15 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
{
throw new InvalidOperationException($"Invalid request URI '{relativeUri}'.");
}
if (requestUri.IsAbsoluteUri)
{
// Nothing to normalize.
}
else
{
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
}
if (requestUri.IsAbsoluteUri)
{
// Nothing to normalize.
}
else
{
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
}
return new HttpRequestMessage(method, requestUri);
@@ -820,76 +825,76 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
{
if (_httpClient.BaseAddress is null)
{
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
}
private string ResolveArtifactPath(string outputPath, string channel)
{
if (!string.IsNullOrWhiteSpace(outputPath))
{
return Path.GetFullPath(outputPath);
}
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(_options.ScannerCacheDirectory);
Directory.CreateDirectory(directory);
var fileName = $"stellaops-scanner-{channel}.tar.gz";
return Path.Combine(directory, fileName);
}
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
var builder = new StringBuilder();
builder.Append("Backend request failed with status ");
builder.Append(statusCode);
builder.Append(' ');
builder.Append(response.ReasonPhrase ?? "Unknown");
if (response.Content.Headers.ContentLength is > 0)
{
try
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (problem is not null)
{
if (!string.IsNullOrWhiteSpace(problem.Title))
{
builder.AppendLine().Append(problem.Title);
}
if (!string.IsNullOrWhiteSpace(problem.Detail))
{
builder.AppendLine().Append(problem.Detail);
}
}
}
catch (JsonException)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
builder.AppendLine().Append(raw);
}
}
}
return builder.ToString();
}
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
{
if (headers.TryGetValues(name, out var values))
{
return values.FirstOrDefault();
}
return null;
}
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
}
private string ResolveArtifactPath(string outputPath, string channel)
{
if (!string.IsNullOrWhiteSpace(outputPath))
{
return Path.GetFullPath(outputPath);
}
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(_options.ScannerCacheDirectory);
Directory.CreateDirectory(directory);
var fileName = $"stellaops-scanner-{channel}.tar.gz";
return Path.Combine(directory, fileName);
}
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
var builder = new StringBuilder();
builder.Append("Backend request failed with status ");
builder.Append(statusCode);
builder.Append(' ');
builder.Append(response.ReasonPhrase ?? "Unknown");
if (response.Content.Headers.ContentLength is > 0)
{
try
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (problem is not null)
{
if (!string.IsNullOrWhiteSpace(problem.Title))
{
builder.AppendLine().Append(problem.Title);
}
if (!string.IsNullOrWhiteSpace(problem.Detail))
{
builder.AppendLine().Append(problem.Detail);
}
}
}
catch (JsonException)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
builder.AppendLine().Append(raw);
}
}
}
return builder.ToString();
}
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
{
if (headers.TryGetValues(name, out var values))
{
return values.FirstOrDefault();
}
return null;
}
private static string? NormalizeExpectedDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
@@ -909,23 +914,23 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
await using (var stream = File.OpenRead(filePath))
{
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(expectedDigest))
{
var normalized = NormalizeDigest(expectedDigest);
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
{
File.Delete(filePath);
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
}
}
else
{
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
}
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(expectedDigest))
{
var normalized = NormalizeDigest(expectedDigest);
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
{
File.Delete(filePath);
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
}
}
else
{
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
}
return digestHex;
}
@@ -945,71 +950,71 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
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)
{
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
{
if (!string.IsNullOrWhiteSpace(signatureHeader))
{
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
}
return;
}
if (string.IsNullOrWhiteSpace(signatureHeader))
{
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
}
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
if (!File.Exists(publicKeyPath))
{
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
}
var signatureBytes = Convert.FromBase64String(signatureHeader);
var digestBytes = Convert.FromHexString(digestHex);
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
using var rsa = RSA.Create();
rsa.ImportFromPem(pem);
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
if (!valid)
{
throw new InvalidOperationException("Scanner signature validation failed.");
}
if (verbose)
{
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
}
}
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
{
if (!string.IsNullOrWhiteSpace(signatureHeader))
{
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
}
return;
}
if (string.IsNullOrWhiteSpace(signatureHeader))
{
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
}
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
if (!File.Exists(publicKeyPath))
{
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
}
var signatureBytes = Convert.FromBase64String(signatureHeader);
var digestBytes = Convert.FromHexString(digestHex);
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
using var rsa = RSA.Create();
rsa.ImportFromPem(pem);
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
if (!valid)
{
throw new InvalidOperationException("Scanner signature validation failed.");
}
if (verbose)
{
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
}
}
private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response)
{
var metadata = new
{
channel,
digest = $"sha256:{digestHex}",
signature = signatureHeader,
downloadedAt = DateTimeOffset.UtcNow,
source = response.RequestMessage?.RequestUri?.ToString(),
sizeBytes = new FileInfo(outputPath).Length,
headers = new
{
etag = response.Headers.ETag?.Tag,
lastModified = response.Content.Headers.LastModified,
contentType = response.Content.Headers.ContentType?.ToString()
}
};
var metadataPath = outputPath + ".metadata.json";
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
{
WriteIndented = true
});
digest = $"sha256:{digestHex}",
signature = signatureHeader,
downloadedAt = DateTimeOffset.UtcNow,
source = response.RequestMessage?.RequestUri?.ToString(),
sizeBytes = new FileInfo(outputPath).Length,
headers = new
{
etag = response.Headers.ETag?.Tag,
lastModified = response.Content.Headers.LastModified,
contentType = response.Content.Headers.ContentType?.ToString()
}
};
var metadataPath = outputPath + ".metadata.json";
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(metadataPath, json);
}

View File

@@ -17,9 +17,9 @@ internal sealed record RuntimePolicyEvaluationResult(
internal sealed record RuntimePolicyImageDecision(
string PolicyVerdict,
bool? Signed,
bool? HasSbom,
bool? HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IReadOnlyDictionary<string, object?> AdditionalProperties);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);

View File

@@ -42,8 +42,12 @@ internal sealed class RuntimePolicyEvaluationImageDocument
[JsonPropertyName("signed")]
public bool? Signed { get; set; }
[JsonPropertyName("hasSbomReferrers")]
public bool? HasSbomReferrers { get; set; }
// Legacy field kept for pre-contract-sync services.
[JsonPropertyName("hasSbom")]
public bool? HasSbom { get; set; }
public bool? HasSbomLegacy { get; set; }
[JsonPropertyName("reasons")]
public List<string>? Reasons { get; set; }
@@ -62,4 +66,7 @@ internal sealed class RuntimePolicyRekorDocument
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("verified")]
public bool? Verified { get; set; }
}

View File

@@ -20,5 +20,5 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|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.|
|CLI-RUNTIME-13-008 Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|**DONE (2025-10-19)** CLI runtime table/JSON now align with SCANNER-RUNTIME-12-302 (SBOM referrers, quieted provenance, confidence, verified Rekor); docs/09 updated with joint sign-off note.|
|CLI-RUNTIME-13-009 Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|**DONE (2025-10-19)** Spectre console harness + regression tests cover table and `--json` output paths for `runtime policy test`, using stubbed backend and integrated into `dotnet test` suite.|