This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -0,0 +1,260 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Plugin.Security;
/// <summary>
/// Verifies plugin signatures using Cosign.
/// </summary>
/// <remarks>
/// Requires Cosign to be installed and available in PATH.
/// Signature files are expected to be adjacent to the assembly with .sig extension.
/// </remarks>
public sealed class CosignPluginVerifier : IPluginSignatureVerifier
{
private readonly CosignVerifierOptions _options;
private readonly ILogger<CosignPluginVerifier>? _logger;
public CosignPluginVerifier(CosignVerifierOptions options, ILogger<CosignPluginVerifier>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
}
public async Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(assemblyPath))
{
throw new ArgumentException("Assembly path cannot be null or empty.", nameof(assemblyPath));
}
if (!File.Exists(assemblyPath))
{
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: $"Assembly file not found: {assemblyPath}",
CertificateChain: null);
}
var signaturePath = GetSignaturePath(assemblyPath);
if (!File.Exists(signaturePath))
{
if (_options.AllowUnsigned)
{
_logger?.LogWarning("Plugin '{Assembly}' is unsigned but unsigned plugins are allowed.", Path.GetFileName(assemblyPath));
return new SignatureVerificationResult(
IsValid: true,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: null,
CertificateChain: null);
}
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: $"Signature file not found: {signaturePath}",
CertificateChain: null);
}
return await VerifyWithCosignAsync(assemblyPath, signaturePath, cancellationToken).ConfigureAwait(false);
}
private async Task<SignatureVerificationResult> VerifyWithCosignAsync(
string assemblyPath,
string signaturePath,
CancellationToken cancellationToken)
{
var cosignPath = _options.CosignPath ?? "cosign";
var arguments = BuildCosignArguments(assemblyPath, signaturePath);
_logger?.LogDebug("Verifying plugin signature: {CosignPath} {Arguments}", cosignPath, arguments);
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = cosignPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var output = await outputTask.ConfigureAwait(false);
var error = await errorTask.ConfigureAwait(false);
if (process.ExitCode == 0)
{
var (signerIdentity, timestamp) = ParseCosignOutput(output);
_logger?.LogInformation("Plugin '{Assembly}' signature verified. Signer: {Signer}", Path.GetFileName(assemblyPath), signerIdentity ?? "unknown");
return new SignatureVerificationResult(
IsValid: true,
SignerIdentity: signerIdentity,
SignatureTimestamp: timestamp,
FailureReason: null,
CertificateChain: output);
}
_logger?.LogError("Plugin '{Assembly}' signature verification failed: {Error}", Path.GetFileName(assemblyPath), error);
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: string.IsNullOrWhiteSpace(error) ? "Signature verification failed" : error.Trim(),
CertificateChain: null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger?.LogError(ex, "Failed to execute Cosign for plugin '{Assembly}'.", Path.GetFileName(assemblyPath));
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: $"Cosign execution failed: {ex.Message}",
CertificateChain: null);
}
}
private string BuildCosignArguments(string assemblyPath, string signaturePath)
{
var args = $"verify-blob --signature \"{signaturePath}\"";
if (!string.IsNullOrWhiteSpace(_options.PublicKeyPath))
{
args += $" --key \"{_options.PublicKeyPath}\"";
}
if (!string.IsNullOrWhiteSpace(_options.CertificatePath))
{
args += $" --certificate \"{_options.CertificatePath}\"";
}
if (!string.IsNullOrWhiteSpace(_options.CertificateIdentity))
{
args += $" --certificate-identity \"{_options.CertificateIdentity}\"";
}
if (!string.IsNullOrWhiteSpace(_options.CertificateOidcIssuer))
{
args += $" --certificate-oidc-issuer \"{_options.CertificateOidcIssuer}\"";
}
if (_options.UseRekorTransparencyLog)
{
args += " --rekor-url https://rekor.sigstore.dev";
}
else
{
args += " --insecure-ignore-tlog";
}
args += $" \"{assemblyPath}\"";
return args;
}
private static string GetSignaturePath(string assemblyPath)
{
return assemblyPath + ".sig";
}
private static (string? SignerIdentity, DateTimeOffset? Timestamp) ParseCosignOutput(string output)
{
string? signerIdentity = null;
DateTimeOffset? timestamp = null;
try
{
if (output.Contains("\""))
{
using var doc = JsonDocument.Parse(output);
if (doc.RootElement.TryGetProperty("optional", out var optional))
{
if (optional.TryGetProperty("Subject", out var subject))
{
signerIdentity = subject.GetString();
}
}
}
}
catch
{
// Output may not be JSON; extract identity from text
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Contains("Subject:"))
{
signerIdentity = line.Split(':', 2)[1].Trim();
}
}
}
return (signerIdentity, timestamp);
}
}
/// <summary>
/// Configuration options for Cosign-based plugin verification.
/// </summary>
public sealed class CosignVerifierOptions
{
/// <summary>
/// Path to the Cosign executable. Defaults to "cosign" (must be in PATH).
/// </summary>
public string? CosignPath { get; set; }
/// <summary>
/// Path to the public key file for verification.
/// </summary>
public string? PublicKeyPath { get; set; }
/// <summary>
/// Path to the certificate file for verification.
/// </summary>
public string? CertificatePath { get; set; }
/// <summary>
/// Expected certificate identity (email or URI).
/// </summary>
public string? CertificateIdentity { get; set; }
/// <summary>
/// Expected OIDC issuer for the certificate.
/// </summary>
public string? CertificateOidcIssuer { get; set; }
/// <summary>
/// Whether to verify against the Rekor transparency log. Defaults to true.
/// </summary>
public bool UseRekorTransparencyLog { get; set; } = true;
/// <summary>
/// Whether to allow loading unsigned plugins. Defaults to false.
/// Should only be true in development/testing scenarios.
/// </summary>
public bool AllowUnsigned { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Plugin.Security;
/// <summary>
/// Provides plugin signature verification to ensure integrity and authenticity.
/// </summary>
public interface IPluginSignatureVerifier
{
/// <summary>
/// Verifies the signature of a plugin assembly file.
/// </summary>
/// <param name="assemblyPath">The full path to the plugin assembly.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating verification status.</returns>
Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default);
}
/// <summary>
/// Represents the result of a signature verification operation.
/// </summary>
/// <param name="IsValid">True if the signature is valid.</param>
/// <param name="SignerIdentity">The identity of the signer, if available.</param>
/// <param name="SignatureTimestamp">When the artifact was signed, if available.</param>
/// <param name="FailureReason">Description of verification failure, if applicable.</param>
/// <param name="CertificateChain">Certificate chain information, if available.</param>
public sealed record SignatureVerificationResult(
bool IsValid,
string? SignerIdentity,
System.DateTimeOffset? SignatureTimestamp,
string? FailureReason,
string? CertificateChain);

View File

@@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Plugin.Security;
/// <summary>
/// A no-op plugin verifier that always returns valid.
/// Use only in development/testing scenarios where signature verification is not required.
/// </summary>
public sealed class NullPluginVerifier : IPluginSignatureVerifier
{
/// <summary>
/// Gets the singleton instance.
/// </summary>
public static NullPluginVerifier Instance { get; } = new();
private NullPluginVerifier() { }
/// <inheritdoc />
public Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SignatureVerificationResult(
IsValid: true,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: null,
CertificateChain: null));
}
}