- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints. - Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication. - Developed ConcelierExporterClient for managing Trivy DB settings and export operations. - Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering. - Implemented styles and HTML structure for Trivy DB settings page. - Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
1070 lines
42 KiB
C#
1070 lines
42 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Globalization;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.Client;
|
|
using StellaOps.Cli.Commands;
|
|
using StellaOps.Cli.Configuration;
|
|
using StellaOps.Cli.Services;
|
|
using StellaOps.Cli.Services.Models;
|
|
using StellaOps.Cli.Telemetry;
|
|
using StellaOps.Cli.Tests.Testing;
|
|
using StellaOps.Cryptography;
|
|
using Spectre.Console;
|
|
using Spectre.Console.Testing;
|
|
|
|
namespace StellaOps.Cli.Tests.Commands;
|
|
|
|
public sealed class CommandHandlersTests
|
|
{
|
|
[Fact]
|
|
public async Task HandleExportJobAsync_SetsExitCodeZeroOnSuccess()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", "/jobs/export:json/1", null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExportJobAsync(
|
|
provider,
|
|
format: "json",
|
|
delta: false,
|
|
publishFull: null,
|
|
publishDelta: null,
|
|
includeFull: null,
|
|
includeDelta: null,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("export:json", backend.LastJobKind);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleMergeJobAsync_SetsExitCodeOnFailure()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(false, "Job already running", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleMergeJobAsync(provider, verbose: false, CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
Assert.Equal("merge:reconcile", backend.LastJobKind);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleScannerRunAsync_AutomaticallyUploadsResults()
|
|
{
|
|
using var tempDir = new TempDirectory();
|
|
var resultsFile = Path.Combine(tempDir.Path, "results", "scan.json");
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "Accepted", null, null));
|
|
var metadataFile = Path.Combine(tempDir.Path, "results", "scan-run.json");
|
|
var executor = new StubExecutor(new ScannerExecutionResult(0, resultsFile, metadataFile));
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results")
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend, executor, new StubInstaller(), options);
|
|
|
|
Directory.CreateDirectory(Path.Combine(tempDir.Path, "target"));
|
|
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
await CommandHandlers.HandleScannerRunAsync(
|
|
provider,
|
|
runner: "docker",
|
|
entry: "scanner-image",
|
|
targetDirectory: Path.Combine(tempDir.Path, "target"),
|
|
arguments: Array.Empty<string>(),
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(resultsFile, backend.LastUploadPath);
|
|
Assert.True(File.Exists(metadataFile));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
ClientSecret = "secret",
|
|
Scope = "concelier.jobs.trigger",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal(1, tokenClient.ClientCredentialRequests);
|
|
Assert.NotNull(tokenClient.CachedEntry);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthLoginAsync_FailsWhenPasswordMissing()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
Username = "user",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
|
|
|
await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthStatusAsync_ReportsMissingToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
|
|
|
await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorInitAsync_CallsBackend()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "accepted", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExcititorInitAsync(
|
|
provider,
|
|
new[] { "redhat" },
|
|
resume: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("init", backend.LastExcititorRoute);
|
|
Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod);
|
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload);
|
|
Assert.Equal(true, payload["resume"]);
|
|
var providers = Assert.IsAssignableFrom<IEnumerable<string>>(payload["providers"]!);
|
|
Assert.Contains("redhat", providers, StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorListProvidersAsync_WritesOutput()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
|
{
|
|
ProviderSummaries = new[]
|
|
{
|
|
new ExcititorProviderSummary("redhat", "distro", "Red Hat", "vendor", true, DateTimeOffset.UtcNow)
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
await CommandHandlers.HandleExcititorListProvidersAsync(provider, includeDisabled: false, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorVerifyAsync_FailsWithoutArguments()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExcititorVerifyAsync(provider, null, null, null, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorVerifyAsync_AttachesAttestationFile()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempFile = new TempFile("attestation.json", Encoding.UTF8.GetBytes("{\"ok\":true}"));
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
await CommandHandlers.HandleExcititorVerifyAsync(
|
|
provider,
|
|
exportId: "export-123",
|
|
digest: "sha256:abc",
|
|
attestationPath: tempFile.Path,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Equal("verify", backend.LastExcititorRoute);
|
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload);
|
|
Assert.Equal("export-123", payload["exportId"]);
|
|
Assert.Equal("sha256:abc", payload["digest"]);
|
|
var attestation = Assert.IsAssignableFrom<IDictionary<string, object?>>(payload["attestation"]!);
|
|
Assert.Equal(Path.GetFileName(tempFile.Path), attestation["fileName"]);
|
|
Assert.NotNull(attestation["base64"]);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleExcititorExportAsync_DownloadsWhenOutputProvided()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
const string manifestJson = """
|
|
{
|
|
"exportId": "exports/20251019T101530Z/abcdef1234567890",
|
|
"format": "openvex",
|
|
"createdAt": "2025-10-19T10:15:30Z",
|
|
"artifact": { "algorithm": "sha256", "digest": "abcdef1234567890" },
|
|
"fromCache": false,
|
|
"sizeBytes": 2048,
|
|
"attestation": {
|
|
"rekor": {
|
|
"location": "https://rekor.example/api/v1/log/entries/123",
|
|
"logIndex": "123"
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
backend.ExcititorResult = new ExcititorOperationResult(true, "ok", null, JsonDocument.Parse(manifestJson).RootElement.Clone());
|
|
var provider = BuildServiceProvider(backend);
|
|
var outputPath = Path.Combine(tempDir.Path, "export.json");
|
|
|
|
await CommandHandlers.HandleExcititorExportAsync(
|
|
provider,
|
|
format: "openvex",
|
|
delta: false,
|
|
scope: null,
|
|
since: null,
|
|
provider: null,
|
|
outputPath: outputPath,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Single(backend.ExportDownloads);
|
|
var request = backend.ExportDownloads[0];
|
|
Assert.Equal("exports/20251019T101530Z/abcdef1234567890", request.ExportId);
|
|
Assert.Equal(Path.GetFullPath(outputPath), request.DestinationPath);
|
|
Assert.Equal("sha256", request.Algorithm);
|
|
Assert.Equal("abcdef1234567890", request.Digest);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("default")]
|
|
[InlineData("libsodium")]
|
|
public async Task HandleAuthRevokeVerifyAsync_VerifiesBundlesUsingProviderRegistry(string? providerHint)
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var artifacts = await WriteRevocationArtifactsAsync(tempDir, providerHint);
|
|
|
|
await CommandHandlers.HandleAuthRevokeVerifyAsync(
|
|
artifacts.BundlePath,
|
|
artifacts.SignaturePath,
|
|
artifacts.KeyPath,
|
|
verbose: true,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthStatusAsync_ReportsCachedToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
|
"token",
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(30),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
|
|
|
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(1, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
|
CreateUnsignedJwt(
|
|
("sub", "cli-user"),
|
|
("aud", "concelier"),
|
|
("iss", "https://authority.example"),
|
|
("iat", 1_700_000_000),
|
|
("nbf", 1_700_000_000)),
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(30),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleAuthLogoutAsync_ClearsToken()
|
|
{
|
|
var original = Environment.ExitCode;
|
|
using var tempDir = new TempDirectory();
|
|
|
|
try
|
|
{
|
|
var options = new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
|
Authority = new StellaOpsCliAuthorityOptions
|
|
{
|
|
Url = "https://authority.example",
|
|
ClientId = "cli",
|
|
TokenCacheDirectory = tempDir.Path
|
|
}
|
|
};
|
|
|
|
var tokenClient = new StubTokenClient();
|
|
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
|
"token",
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(5),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
|
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
|
|
|
await CommandHandlers.HandleAuthLogoutAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
|
|
|
Assert.Null(tokenClient.CachedEntry);
|
|
Assert.Equal(1, tokenClient.ClearRequests);
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRuntimePolicyTestAsync_WritesInteractiveTable()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalConsole = AnsiConsole.Console;
|
|
|
|
var console = new TestConsole();
|
|
console.Width(120);
|
|
console.Interactive();
|
|
console.EmitAnsiSequences();
|
|
|
|
AnsiConsole.Console = console;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
|
|
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal)
|
|
{
|
|
["sha256:aaa"] = new RuntimePolicyImageDecision(
|
|
"allow",
|
|
true,
|
|
true,
|
|
Array.AsReadOnly(new[] { "trusted baseline" }),
|
|
new RuntimePolicyRekorReference("uuid-allow", "https://rekor.example/entries/uuid-allow", true),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "baseline",
|
|
["quieted"] = false,
|
|
["confidence"] = 0.97,
|
|
["confidenceBand"] = "high"
|
|
})),
|
|
["sha256:bbb"] = new RuntimePolicyImageDecision(
|
|
"block",
|
|
false,
|
|
false,
|
|
Array.AsReadOnly(new[] { "missing attestation" }),
|
|
new RuntimePolicyRekorReference("uuid-block", "https://rekor.example/entries/uuid-block", false),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "policy",
|
|
["quieted"] = false,
|
|
["confidence"] = 0.12,
|
|
["confidenceBand"] = "low"
|
|
})),
|
|
["sha256:ccc"] = new RuntimePolicyImageDecision(
|
|
"audit",
|
|
true,
|
|
false,
|
|
Array.AsReadOnly(new[] { "pending sbom sync" }),
|
|
new RuntimePolicyRekorReference(null, null, null),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "mirror",
|
|
["quieted"] = true,
|
|
["quietedBy"] = "allow-temporary",
|
|
["confidence"] = 0.42,
|
|
["confidenceBand"] = "medium"
|
|
}))
|
|
};
|
|
|
|
backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult(
|
|
300,
|
|
DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture),
|
|
"rev-42",
|
|
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleRuntimePolicyTestAsync(
|
|
provider,
|
|
namespaceValue: "prod",
|
|
imageArguments: new[] { "sha256:aaa", "sha256:bbb" },
|
|
filePath: null,
|
|
labelArguments: new[] { "app=frontend" },
|
|
outputJson: false,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
var output = console.Output;
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.Contains("Image", output, StringComparison.Ordinal);
|
|
Assert.Contains("Verdict", output, StringComparison.Ordinal);
|
|
Assert.Contains("SBOM Ref", output, StringComparison.Ordinal);
|
|
Assert.Contains("Quieted", output, StringComparison.Ordinal);
|
|
Assert.Contains("Confidence", output, StringComparison.Ordinal);
|
|
Assert.Contains("sha256:aaa", output, StringComparison.Ordinal);
|
|
Assert.Contains("uuid-allow", output, StringComparison.Ordinal);
|
|
Assert.Contains("(verified)", output, StringComparison.Ordinal);
|
|
Assert.Contains("0.97 (high)", output, StringComparison.Ordinal);
|
|
Assert.Contains("sha256:bbb", output, StringComparison.Ordinal);
|
|
Assert.Contains("uuid-block", output, StringComparison.Ordinal);
|
|
Assert.Contains("(unverified)", output, StringComparison.Ordinal);
|
|
Assert.Contains("sha256:ccc", output, StringComparison.Ordinal);
|
|
Assert.Contains("yes", output, StringComparison.Ordinal);
|
|
Assert.Contains("allow-temporary", output, StringComparison.Ordinal);
|
|
Assert.True(
|
|
output.IndexOf("sha256:aaa", StringComparison.Ordinal) <
|
|
output.IndexOf("sha256:ccc", StringComparison.Ordinal));
|
|
}
|
|
finally
|
|
{
|
|
Environment.ExitCode = originalExit;
|
|
AnsiConsole.Console = originalConsole;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandleRuntimePolicyTestAsync_WritesDeterministicJson()
|
|
{
|
|
var originalExit = Environment.ExitCode;
|
|
var originalOut = Console.Out;
|
|
|
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
|
|
|
var decisions = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal)
|
|
{
|
|
["sha256:json-a"] = new RuntimePolicyImageDecision(
|
|
"allow",
|
|
true,
|
|
true,
|
|
Array.AsReadOnly(new[] { "baseline allow" }),
|
|
new RuntimePolicyRekorReference("uuid-json-allow", "https://rekor.example/entries/uuid-json-allow", true),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "baseline",
|
|
["confidence"] = 0.66
|
|
})),
|
|
["sha256:json-b"] = new RuntimePolicyImageDecision(
|
|
"audit",
|
|
true,
|
|
false,
|
|
Array.AsReadOnly(Array.Empty<string>()),
|
|
new RuntimePolicyRekorReference(null, null, null),
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["source"] = "mirror",
|
|
["quieted"] = true,
|
|
["quietedBy"] = "risk-accepted"
|
|
}))
|
|
};
|
|
|
|
backend.RuntimePolicyResult = new RuntimePolicyEvaluationResult(
|
|
600,
|
|
DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture),
|
|
"rev-json-7",
|
|
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions));
|
|
|
|
var provider = BuildServiceProvider(backend);
|
|
|
|
using var writer = new StringWriter();
|
|
Console.SetOut(writer);
|
|
|
|
try
|
|
{
|
|
await CommandHandlers.HandleRuntimePolicyTestAsync(
|
|
provider,
|
|
namespaceValue: "staging",
|
|
imageArguments: new[] { "sha256:json-a", "sha256:json-b" },
|
|
filePath: null,
|
|
labelArguments: Array.Empty<string>(),
|
|
outputJson: true,
|
|
verbose: false,
|
|
cancellationToken: CancellationToken.None);
|
|
|
|
var output = writer.ToString().Trim();
|
|
|
|
Assert.Equal(0, Environment.ExitCode);
|
|
Assert.False(string.IsNullOrWhiteSpace(output));
|
|
|
|
using var document = JsonDocument.Parse(output);
|
|
var root = document.RootElement;
|
|
|
|
Assert.Equal(600, root.GetProperty("ttlSeconds").GetInt32());
|
|
Assert.Equal("rev-json-7", root.GetProperty("policyRevision").GetString());
|
|
var expiresAt = root.GetProperty("expiresAtUtc").GetString();
|
|
Assert.NotNull(expiresAt);
|
|
Assert.Equal(
|
|
DateTimeOffset.Parse("2025-10-20T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
|
DateTimeOffset.Parse(expiresAt!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal));
|
|
|
|
var results = root.GetProperty("results");
|
|
var keys = results.EnumerateObject().Select(p => p.Name).ToArray();
|
|
Assert.Equal(new[] { "sha256:json-a", "sha256:json-b" }, keys);
|
|
|
|
var first = results.GetProperty("sha256:json-a");
|
|
Assert.Equal("allow", first.GetProperty("policyVerdict").GetString());
|
|
Assert.True(first.GetProperty("signed").GetBoolean());
|
|
Assert.True(first.GetProperty("hasSbomReferrers").GetBoolean());
|
|
var rekor = first.GetProperty("rekor");
|
|
Assert.Equal("uuid-json-allow", rekor.GetProperty("uuid").GetString());
|
|
Assert.True(rekor.GetProperty("verified").GetBoolean());
|
|
Assert.Equal("baseline", first.GetProperty("source").GetString());
|
|
Assert.Equal(0.66, first.GetProperty("confidence").GetDouble(), 3);
|
|
|
|
var second = results.GetProperty("sha256:json-b");
|
|
Assert.Equal("audit", second.GetProperty("policyVerdict").GetString());
|
|
Assert.True(second.GetProperty("signed").GetBoolean());
|
|
Assert.False(second.GetProperty("hasSbomReferrers").GetBoolean());
|
|
Assert.Equal("mirror", second.GetProperty("source").GetString());
|
|
Assert.True(second.GetProperty("quieted").GetBoolean());
|
|
Assert.Equal("risk-accepted", second.GetProperty("quietedBy").GetString());
|
|
Assert.False(second.TryGetProperty("rekor", out _));
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Environment.ExitCode = originalExit;
|
|
}
|
|
}
|
|
|
|
private static async Task<RevocationArtifactPaths> WriteRevocationArtifactsAsync(TempDirectory temp, string? providerHint)
|
|
{
|
|
var (bundleBytes, signature, keyPem) = await BuildRevocationArtifactsAsync(providerHint);
|
|
|
|
var bundlePath = Path.Combine(temp.Path, "revocation-bundle.json");
|
|
var signaturePath = Path.Combine(temp.Path, "revocation-bundle.json.jws");
|
|
var keyPath = Path.Combine(temp.Path, "revocation-key.pem");
|
|
|
|
await File.WriteAllBytesAsync(bundlePath, bundleBytes);
|
|
await File.WriteAllTextAsync(signaturePath, signature);
|
|
await File.WriteAllTextAsync(keyPath, keyPem);
|
|
|
|
return new RevocationArtifactPaths(bundlePath, signaturePath, keyPath);
|
|
}
|
|
|
|
private static async Task<(byte[] Bundle, string Signature, string KeyPem)> BuildRevocationArtifactsAsync(string? providerHint)
|
|
{
|
|
var bundleBytes = Encoding.UTF8.GetBytes("{\"revocations\":[]}");
|
|
|
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
|
|
|
var signingKey = new CryptoSigningKey(
|
|
new CryptoKeyReference("revocation-test"),
|
|
SignatureAlgorithms.Es256,
|
|
privateParameters: in parameters,
|
|
createdAt: DateTimeOffset.UtcNow);
|
|
|
|
var provider = new DefaultCryptoProvider();
|
|
provider.UpsertSigningKey(signingKey);
|
|
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
|
|
|
var header = new Dictionary<string, object>
|
|
{
|
|
["alg"] = SignatureAlgorithms.Es256,
|
|
["kid"] = signingKey.Reference.KeyId,
|
|
["typ"] = "application/vnd.stellaops.revocation-bundle+jws",
|
|
["b64"] = false,
|
|
["crit"] = new[] { "b64" }
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(providerHint))
|
|
{
|
|
header["provider"] = providerHint;
|
|
}
|
|
|
|
var serializerOptions = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = null,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
var headerJson = JsonSerializer.Serialize(header, serializerOptions);
|
|
var encodedHeader = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
|
|
|
|
var signingInput = new byte[encodedHeader.Length + 1 + bundleBytes.Length];
|
|
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
|
|
Buffer.BlockCopy(headerBytes, 0, signingInput, 0, headerBytes.Length);
|
|
signingInput[headerBytes.Length] = (byte)'.';
|
|
Buffer.BlockCopy(bundleBytes, 0, signingInput, headerBytes.Length + 1, bundleBytes.Length);
|
|
|
|
var signatureBytes = await signer.SignAsync(signingInput);
|
|
var encodedSignature = Base64UrlEncoder.Encode(signatureBytes);
|
|
var jws = string.Concat(encodedHeader, "..", encodedSignature);
|
|
|
|
var publicKeyBytes = ecdsa.ExportSubjectPublicKeyInfo();
|
|
var keyPem = new string(PemEncoding.Write("PUBLIC KEY", publicKeyBytes));
|
|
|
|
return (bundleBytes, jws, keyPem);
|
|
}
|
|
|
|
private sealed record RevocationArtifactPaths(string BundlePath, string SignaturePath, string KeyPath);
|
|
|
|
private static IServiceProvider BuildServiceProvider(
|
|
IBackendOperationsClient backend,
|
|
IScannerExecutor? executor = null,
|
|
IScannerInstaller? installer = null,
|
|
StellaOpsCliOptions? options = null,
|
|
IStellaOpsTokenClient? tokenClient = null)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(backend);
|
|
services.AddSingleton<ILoggerFactory>(_ => LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)));
|
|
services.AddSingleton(new VerbosityState());
|
|
var resolvedOptions = options ?? new StellaOpsCliOptions
|
|
{
|
|
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
|
|
};
|
|
services.AddSingleton(resolvedOptions);
|
|
|
|
var resolvedExecutor = executor ?? CreateDefaultExecutor();
|
|
services.AddSingleton<IScannerExecutor>(resolvedExecutor);
|
|
services.AddSingleton<IScannerInstaller>(installer ?? new StubInstaller());
|
|
|
|
if (tokenClient is not null)
|
|
{
|
|
services.AddSingleton(tokenClient);
|
|
}
|
|
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
private static IScannerExecutor CreateDefaultExecutor()
|
|
{
|
|
var tempResultsFile = Path.GetTempFileName();
|
|
var tempMetadataFile = Path.Combine(
|
|
Path.GetDirectoryName(tempResultsFile)!,
|
|
$"{Path.GetFileNameWithoutExtension(tempResultsFile)}-run.json");
|
|
return new StubExecutor(new ScannerExecutionResult(0, tempResultsFile, tempMetadataFile));
|
|
}
|
|
|
|
private sealed class StubBackendClient : IBackendOperationsClient
|
|
{
|
|
private readonly JobTriggerResult _jobResult;
|
|
private static readonly RuntimePolicyEvaluationResult DefaultRuntimePolicyResult =
|
|
new RuntimePolicyEvaluationResult(
|
|
0,
|
|
null,
|
|
null,
|
|
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(
|
|
new Dictionary<string, RuntimePolicyImageDecision>()));
|
|
|
|
public StubBackendClient(JobTriggerResult result)
|
|
{
|
|
_jobResult = result;
|
|
}
|
|
|
|
public string? LastJobKind { get; private set; }
|
|
public string? LastUploadPath { get; private set; }
|
|
public string? LastExcititorRoute { get; private set; }
|
|
public HttpMethod? LastExcititorMethod { get; private set; }
|
|
public object? LastExcititorPayload { get; private set; }
|
|
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
|
|
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
|
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
|
public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult;
|
|
|
|
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
|
=> throw new NotImplementedException();
|
|
|
|
public Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
|
{
|
|
LastUploadPath = filePath;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
|
{
|
|
LastJobKind = jobKind;
|
|
return Task.FromResult(_jobResult);
|
|
}
|
|
|
|
public Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken)
|
|
{
|
|
LastExcititorRoute = route;
|
|
LastExcititorMethod = method;
|
|
LastExcititorPayload = payload;
|
|
return Task.FromResult(ExcititorResult ?? new ExcititorOperationResult(true, "ok", null, null));
|
|
}
|
|
|
|
public Task<ExcititorExportDownloadResult> DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken)
|
|
{
|
|
var fullPath = Path.GetFullPath(destinationPath);
|
|
var directory = Path.GetDirectoryName(fullPath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
File.WriteAllText(fullPath, "{}");
|
|
var info = new FileInfo(fullPath);
|
|
ExportDownloads.Add((exportId, fullPath, expectedDigestAlgorithm, expectedDigest));
|
|
return Task.FromResult(new ExcititorExportDownloadResult(fullPath, info.Length, false));
|
|
}
|
|
|
|
public Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
|
=> Task.FromResult(ProviderSummaries);
|
|
|
|
public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(RuntimePolicyResult);
|
|
|
|
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
|
|
=> throw new NotSupportedException();
|
|
}
|
|
|
|
private sealed class StubExecutor : IScannerExecutor
|
|
{
|
|
private readonly ScannerExecutionResult _result;
|
|
|
|
public StubExecutor(ScannerExecutionResult result)
|
|
{
|
|
_result = result;
|
|
}
|
|
|
|
public Task<ScannerExecutionResult> RunAsync(string runner, string entry, string targetDirectory, string resultsDirectory, IReadOnlyList<string> arguments, bool verbose, CancellationToken cancellationToken)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_result.ResultsPath)!);
|
|
if (!File.Exists(_result.ResultsPath))
|
|
{
|
|
File.WriteAllText(_result.ResultsPath, "{}");
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_result.RunMetadataPath)!);
|
|
if (!File.Exists(_result.RunMetadataPath))
|
|
{
|
|
File.WriteAllText(_result.RunMetadataPath, "{}");
|
|
}
|
|
|
|
return Task.FromResult(_result);
|
|
}
|
|
}
|
|
|
|
private sealed class StubInstaller : IScannerInstaller
|
|
{
|
|
public Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class StubTokenClient : IStellaOpsTokenClient
|
|
{
|
|
private readonly StellaOpsTokenResult _token;
|
|
|
|
public StubTokenClient()
|
|
{
|
|
_token = new StellaOpsTokenResult(
|
|
"token-123",
|
|
"Bearer",
|
|
DateTimeOffset.UtcNow.AddMinutes(30),
|
|
new[] { StellaOpsScopes.ConcelierJobsTrigger });
|
|
}
|
|
|
|
public int ClientCredentialRequests { get; private set; }
|
|
public int PasswordRequests { get; private set; }
|
|
public int ClearRequests { get; private set; }
|
|
public StellaOpsTokenCacheEntry? CachedEntry { get; set; }
|
|
|
|
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
|
|
{
|
|
CachedEntry = entry;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
|
{
|
|
ClearRequests++;
|
|
CachedEntry = null;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new JsonWebKeySet("{\"keys\":[]}"));
|
|
|
|
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
|
|
=> ValueTask.FromResult(CachedEntry);
|
|
|
|
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
|
|
{
|
|
ClientCredentialRequests++;
|
|
return Task.FromResult(_token);
|
|
}
|
|
|
|
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
|
|
{
|
|
PasswordRequests++;
|
|
return Task.FromResult(_token);
|
|
}
|
|
}
|
|
|
|
private static string CreateUnsignedJwt(params (string Key, object Value)[] claims)
|
|
{
|
|
var headerJson = "{\"alg\":\"none\",\"typ\":\"JWT\"}";
|
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
foreach (var claim in claims)
|
|
{
|
|
payload[claim.Key] = claim.Value;
|
|
}
|
|
|
|
var payloadJson = JsonSerializer.Serialize(payload);
|
|
return $"{Base64UrlEncode(headerJson)}.{Base64UrlEncode(payloadJson)}.";
|
|
}
|
|
|
|
private static string Base64UrlEncode(string value)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(value);
|
|
return Convert.ToBase64String(bytes)
|
|
.TrimEnd('=')
|
|
.Replace('+', '-')
|
|
.Replace('/', '_');
|
|
}
|
|
}
|