Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
344
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Sign.cs
Normal file
344
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Sign.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - CLI handlers for keyless signing
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Signer.Infrastructure.Sigstore;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Handle keyless signing via Sigstore (Fulcio + Rekor).
|
||||
/// </summary>
|
||||
public static async Task<int> HandleSignKeylessAsync(
|
||||
IServiceProvider services,
|
||||
string input,
|
||||
string? output,
|
||||
string? identityToken,
|
||||
bool useRekor,
|
||||
string? fulcioUrl,
|
||||
string? rekorUrl,
|
||||
string? oidcIssuer,
|
||||
string bundleFormat,
|
||||
string? caBundle,
|
||||
bool insecure,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(input))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Input file not found: {input}");
|
||||
return CliExitCodes.InputFileNotFound;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve output path
|
||||
var outputPath = output ?? $"{input}.sigstore";
|
||||
|
||||
// Get or detect identity token
|
||||
var token = identityToken ?? await DetectAmbientIdentityTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] No identity token provided and ambient detection failed.");
|
||||
AnsiConsole.MarkupLine("[dim]Provide --identity-token or run in a CI environment with OIDC support.[/]");
|
||||
return CliExitCodes.MissingRequiredOption;
|
||||
}
|
||||
|
||||
// Read artifact
|
||||
var artifactBytes = await File.ReadAllBytesAsync(input, cancellationToken);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Input:[/] {input} ({artifactBytes.Length} bytes)");
|
||||
AnsiConsole.MarkupLine($"[dim]Output:[/] {outputPath}");
|
||||
AnsiConsole.MarkupLine($"[dim]Rekor:[/] {(useRekor ? "enabled" : "disabled")}");
|
||||
if (fulcioUrl != null) AnsiConsole.MarkupLine($"[dim]Fulcio URL:[/] {fulcioUrl}");
|
||||
if (rekorUrl != null) AnsiConsole.MarkupLine($"[dim]Rekor URL:[/] {rekorUrl}");
|
||||
}
|
||||
|
||||
// Get signing service (with option overrides)
|
||||
var sigstoreService = services.GetService<ISigstoreSigningService>();
|
||||
if (sigstoreService is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Sigstore signing service not configured.");
|
||||
AnsiConsole.MarkupLine("[dim]Ensure Sigstore is enabled in configuration.[/]");
|
||||
return CliExitCodes.ServiceNotConfigured;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[blue]Signing artifact with Sigstore keyless signing...[/]");
|
||||
|
||||
var result = await sigstoreService.SignKeylessAsync(
|
||||
artifactBytes,
|
||||
token,
|
||||
cancellationToken);
|
||||
|
||||
// Write bundle based on format
|
||||
var bundle = CreateSignatureBundle(result, bundleFormat);
|
||||
await File.WriteAllTextAsync(outputPath, bundle, cancellationToken);
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] Signature bundle written to: [cyan]{outputPath}[/]");
|
||||
AnsiConsole.MarkupLine($"[dim]Subject:[/] {result.Certificate.Subject}");
|
||||
AnsiConsole.MarkupLine($"[dim]Issuer:[/] {result.Certificate.Issuer}");
|
||||
AnsiConsole.MarkupLine($"[dim]Certificate expires:[/] {result.Certificate.ExpiresAtUtc:u}");
|
||||
|
||||
if (result.RekorEntry != null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Rekor log index:[/] {result.RekorEntry.LogIndex}");
|
||||
AnsiConsole.MarkupLine($"[dim]Rekor UUID:[/] {result.RekorEntry.Uuid}");
|
||||
}
|
||||
|
||||
return CliExitCodes.Success;
|
||||
}
|
||||
catch (SigstoreException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Sigstore error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return CliExitCodes.SigningFailed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return CliExitCodes.UnexpectedError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle keyless signature verification.
|
||||
/// </summary>
|
||||
public static async Task<int> HandleVerifyKeylessAsync(
|
||||
IServiceProvider services,
|
||||
string input,
|
||||
string? bundlePath,
|
||||
string? certificatePath,
|
||||
string? signaturePath,
|
||||
string? rekorUuid,
|
||||
string? rekorUrl,
|
||||
string? expectedIssuer,
|
||||
string? expectedSubject,
|
||||
string? caBundle,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(input))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Input file not found: {input}");
|
||||
return CliExitCodes.InputFileNotFound;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve bundle or certificate+signature paths
|
||||
var resolvedBundlePath = bundlePath ?? $"{input}.sigstore";
|
||||
string certificate;
|
||||
byte[] signature;
|
||||
|
||||
if (File.Exists(resolvedBundlePath))
|
||||
{
|
||||
// Parse bundle
|
||||
var bundleJson = await File.ReadAllTextAsync(resolvedBundlePath, cancellationToken);
|
||||
var bundle = JsonDocument.Parse(bundleJson);
|
||||
|
||||
certificate = bundle.RootElement.GetProperty("certificate").GetString() ?? string.Empty;
|
||||
var sigBase64 = bundle.RootElement.GetProperty("signature").GetString() ?? string.Empty;
|
||||
signature = Convert.FromBase64String(sigBase64);
|
||||
|
||||
if (bundle.RootElement.TryGetProperty("rekorEntry", out var rekorEntry))
|
||||
{
|
||||
rekorUuid ??= rekorEntry.GetProperty("uuid").GetString();
|
||||
}
|
||||
}
|
||||
else if (certificatePath != null && signaturePath != null)
|
||||
{
|
||||
certificate = await File.ReadAllTextAsync(certificatePath, cancellationToken);
|
||||
signature = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] No bundle found and --certificate/--signature not provided.");
|
||||
return CliExitCodes.MissingRequiredOption;
|
||||
}
|
||||
|
||||
var artifactBytes = await File.ReadAllBytesAsync(input, cancellationToken);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Input:[/] {input} ({artifactBytes.Length} bytes)");
|
||||
AnsiConsole.MarkupLine($"[dim]Certificate:[/] {(certificatePath ?? resolvedBundlePath)}");
|
||||
if (rekorUuid != null) AnsiConsole.MarkupLine($"[dim]Rekor UUID:[/] {rekorUuid}");
|
||||
}
|
||||
|
||||
var sigstoreService = services.GetService<ISigstoreSigningService>();
|
||||
if (sigstoreService is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Sigstore signing service not configured.");
|
||||
return CliExitCodes.ServiceNotConfigured;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[blue]Verifying keyless signature...[/]");
|
||||
|
||||
var isValid = await sigstoreService.VerifyKeylessAsync(
|
||||
artifactBytes,
|
||||
signature,
|
||||
certificate,
|
||||
rekorUuid,
|
||||
cancellationToken);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]✓[/] Signature verification [green]PASSED[/]");
|
||||
|
||||
// Additional policy checks
|
||||
if (expectedIssuer != null || expectedSubject != null)
|
||||
{
|
||||
var cert = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificate);
|
||||
var (subject, issuer) = ExtractCertificateIdentity(cert);
|
||||
|
||||
if (expectedIssuer != null && !string.Equals(issuer, expectedIssuer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]⚠[/] Issuer mismatch: expected '{expectedIssuer}', got '{issuer}'");
|
||||
return CliExitCodes.PolicyViolation;
|
||||
}
|
||||
|
||||
if (expectedSubject != null && !subject.Contains(expectedSubject, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]⚠[/] Subject mismatch: expected '{expectedSubject}', got '{subject}'");
|
||||
return CliExitCodes.PolicyViolation;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[dim]Subject:[/] {subject}");
|
||||
AnsiConsole.MarkupLine($"[dim]Issuer:[/] {issuer}");
|
||||
}
|
||||
|
||||
return CliExitCodes.Success;
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]✗[/] Signature verification [red]FAILED[/]");
|
||||
return CliExitCodes.VerificationFailed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return CliExitCodes.UnexpectedError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to detect ambient identity token from CI environment.
|
||||
/// </summary>
|
||||
private static Task<string?> DetectAmbientIdentityTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check common CI environment variables for OIDC tokens
|
||||
|
||||
// Gitea Actions
|
||||
var giteaToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
|
||||
if (!string.IsNullOrEmpty(giteaToken))
|
||||
{
|
||||
return Task.FromResult<string?>(giteaToken);
|
||||
}
|
||||
|
||||
// GitHub Actions
|
||||
var githubToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
|
||||
if (!string.IsNullOrEmpty(githubToken))
|
||||
{
|
||||
return Task.FromResult<string?>(githubToken);
|
||||
}
|
||||
|
||||
// GitLab CI
|
||||
var gitlabToken = Environment.GetEnvironmentVariable("CI_JOB_JWT_V2")
|
||||
?? Environment.GetEnvironmentVariable("CI_JOB_JWT");
|
||||
if (!string.IsNullOrEmpty(gitlabToken))
|
||||
{
|
||||
return Task.FromResult<string?>(gitlabToken);
|
||||
}
|
||||
|
||||
// Kubernetes service account token
|
||||
var k8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||
if (File.Exists(k8sTokenPath))
|
||||
{
|
||||
var k8sToken = File.ReadAllText(k8sTokenPath);
|
||||
return Task.FromResult<string?>(k8sToken);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates signature bundle in specified format.
|
||||
/// </summary>
|
||||
private static string CreateSignatureBundle(SigstoreSigningResult result, string format)
|
||||
{
|
||||
var bundle = new
|
||||
{
|
||||
mediaType = "application/vnd.dev.sigstore.bundle+json;version=0.2",
|
||||
certificate = result.Certificate.Certificate,
|
||||
certificateChain = result.Certificate.CertificateChain,
|
||||
signature = result.Signature,
|
||||
publicKey = result.PublicKey,
|
||||
algorithm = result.Algorithm,
|
||||
sct = result.Certificate.SignedCertificateTimestamp,
|
||||
rekorEntry = result.RekorEntry is not null ? new
|
||||
{
|
||||
uuid = result.RekorEntry.Uuid,
|
||||
logIndex = result.RekorEntry.LogIndex,
|
||||
integratedTime = result.RekorEntry.IntegratedTime,
|
||||
logId = result.RekorEntry.LogId,
|
||||
signedEntryTimestamp = result.RekorEntry.SignedEntryTimestamp
|
||||
} : null,
|
||||
signedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
subject = result.Certificate.Subject,
|
||||
issuer = result.Certificate.Issuer
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts OIDC identity from Fulcio certificate.
|
||||
/// </summary>
|
||||
private static (string Subject, string Issuer) ExtractCertificateIdentity(
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
|
||||
{
|
||||
var issuer = "unknown";
|
||||
var subject = cert.Subject;
|
||||
|
||||
foreach (var ext in cert.Extensions)
|
||||
{
|
||||
// Fulcio OIDC issuer extension
|
||||
if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.1")
|
||||
{
|
||||
issuer = System.Text.Encoding.UTF8.GetString(ext.RawData).Trim('\0');
|
||||
}
|
||||
// Fulcio OIDC subject extension
|
||||
else if (ext.Oid?.Value == "1.3.6.1.4.1.57264.1.7")
|
||||
{
|
||||
subject = System.Text.Encoding.UTF8.GetString(ext.RawData).Trim('\0');
|
||||
}
|
||||
}
|
||||
|
||||
return (subject, issuer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user