- Added IsolatedReplayContext class to provide an isolated environment for replaying audit bundles without external calls. - Introduced methods for initializing the context, verifying input digests, and extracting inputs for policy evaluation. - Created supporting interfaces and options for context configuration. feat: Create ReplayExecutor for executing policy re-evaluation and verdict comparison - Developed ReplayExecutor class to handle the execution of replay processes, including input verification and verdict comparison. - Implemented detailed drift detection and error handling during replay execution. - Added interfaces for policy evaluation and replay execution options. feat: Add ScanSnapshotFetcher for fetching scan data and snapshots - Introduced ScanSnapshotFetcher class to retrieve necessary scan data and snapshots for audit bundle creation. - Implemented methods to fetch scan metadata, advisory feeds, policy snapshots, and VEX statements. - Created supporting interfaces for scan data, feed snapshots, and policy snapshots.
583 lines
21 KiB
C#
583 lines
21 KiB
C#
// -----------------------------------------------------------------------------
|
|
// VerdictAttestationVerifier.cs
|
|
// Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push
|
|
// Task: VERDICT-022 - DSSE envelope signature verification added.
|
|
// Description: Service for verifying verdict attestations via OCI referrers API.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.IO.Compression;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Cli.Commands;
|
|
using StellaOps.Cli.Services.Models;
|
|
using StellaOps.Scanner.Storage.Oci;
|
|
|
|
namespace StellaOps.Cli.Services;
|
|
|
|
/// <summary>
|
|
/// Service for verifying verdict attestations attached to container images.
|
|
/// Uses the OCI referrers API to discover and fetch verdict artifacts.
|
|
/// </summary>
|
|
public sealed class VerdictAttestationVerifier : IVerdictAttestationVerifier
|
|
{
|
|
private readonly IOciRegistryClient _registryClient;
|
|
private readonly ITrustPolicyLoader _trustPolicyLoader;
|
|
private readonly IDsseSignatureVerifier _dsseVerifier;
|
|
private readonly ILogger<VerdictAttestationVerifier> _logger;
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
public VerdictAttestationVerifier(
|
|
IOciRegistryClient registryClient,
|
|
ITrustPolicyLoader trustPolicyLoader,
|
|
IDsseSignatureVerifier dsseVerifier,
|
|
ILogger<VerdictAttestationVerifier> logger)
|
|
{
|
|
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
|
_trustPolicyLoader = trustPolicyLoader ?? throw new ArgumentNullException(nameof(trustPolicyLoader));
|
|
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<VerdictVerificationResult> VerifyAsync(
|
|
VerdictVerificationRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
var parsed = OciImageReferenceParser.Parse(request.Reference);
|
|
var imageDigest = await ResolveImageDigestAsync(parsed, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (string.IsNullOrWhiteSpace(imageDigest))
|
|
{
|
|
return CreateFailedResult(request.Reference, "unknown", "Failed to resolve image digest");
|
|
}
|
|
|
|
_logger.LogDebug("Fetching verdict referrers for {Reference} ({Digest})", request.Reference, imageDigest);
|
|
|
|
// Fetch referrers with verdict artifact type
|
|
var referrers = await _registryClient.GetReferrersAsync(
|
|
parsed.Registry,
|
|
parsed.Repository,
|
|
imageDigest,
|
|
OciMediaTypes.VerdictAttestation,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (referrers.Count == 0)
|
|
{
|
|
_logger.LogWarning("No verdict attestations found for {Reference}", request.Reference);
|
|
return new VerdictVerificationResult
|
|
{
|
|
ImageReference = request.Reference,
|
|
ImageDigest = imageDigest,
|
|
VerdictFound = false,
|
|
IsValid = false,
|
|
Errors = new[] { "No verdict attestation found for image" }
|
|
};
|
|
}
|
|
|
|
// Get the most recent verdict (first in the list)
|
|
var verdictReferrer = referrers[0];
|
|
_logger.LogDebug("Found verdict attestation: {Digest}", verdictReferrer.Digest);
|
|
|
|
// Extract verdict metadata from annotations
|
|
var annotations = verdictReferrer.Annotations ?? new Dictionary<string, string>();
|
|
var actualSbomDigest = annotations.GetValueOrDefault(OciAnnotations.StellaSbomDigest);
|
|
var actualFeedsDigest = annotations.GetValueOrDefault(OciAnnotations.StellaFeedsDigest);
|
|
var actualPolicyDigest = annotations.GetValueOrDefault(OciAnnotations.StellaPolicyDigest);
|
|
var actualDecision = annotations.GetValueOrDefault(OciAnnotations.StellaVerdictDecision);
|
|
|
|
// Compare against expected values
|
|
var sbomMatches = CompareDigests(request.ExpectedSbomDigest, actualSbomDigest);
|
|
var feedsMatches = CompareDigests(request.ExpectedFeedsDigest, actualFeedsDigest);
|
|
var policyMatches = CompareDigests(request.ExpectedPolicyDigest, actualPolicyDigest);
|
|
var decisionMatches = CompareDecision(request.ExpectedDecision, actualDecision);
|
|
|
|
var errors = new List<string>();
|
|
var isValid = true;
|
|
|
|
// Check for mismatches
|
|
if (sbomMatches == false)
|
|
{
|
|
errors.Add($"SBOM digest mismatch: expected {request.ExpectedSbomDigest}, actual {actualSbomDigest}");
|
|
isValid = false;
|
|
}
|
|
|
|
if (feedsMatches == false)
|
|
{
|
|
errors.Add($"Feeds digest mismatch: expected {request.ExpectedFeedsDigest}, actual {actualFeedsDigest}");
|
|
isValid = false;
|
|
}
|
|
|
|
if (policyMatches == false)
|
|
{
|
|
errors.Add($"Policy digest mismatch: expected {request.ExpectedPolicyDigest}, actual {actualPolicyDigest}");
|
|
isValid = false;
|
|
}
|
|
|
|
if (decisionMatches == false)
|
|
{
|
|
errors.Add($"Decision mismatch: expected {request.ExpectedDecision}, actual {actualDecision}");
|
|
isValid = false;
|
|
}
|
|
|
|
// In strict mode, all expected values must be provided and match
|
|
if (request.Strict)
|
|
{
|
|
if (sbomMatches == null && !string.IsNullOrWhiteSpace(request.ExpectedSbomDigest))
|
|
{
|
|
errors.Add("Strict mode: SBOM digest not present in verdict");
|
|
isValid = false;
|
|
}
|
|
|
|
if (feedsMatches == null && !string.IsNullOrWhiteSpace(request.ExpectedFeedsDigest))
|
|
{
|
|
errors.Add("Strict mode: Feeds digest not present in verdict");
|
|
isValid = false;
|
|
}
|
|
|
|
if (policyMatches == null && !string.IsNullOrWhiteSpace(request.ExpectedPolicyDigest))
|
|
{
|
|
errors.Add("Strict mode: Policy digest not present in verdict");
|
|
isValid = false;
|
|
}
|
|
}
|
|
|
|
// VERDICT-022: Verify DSSE envelope signature if trust policy is provided
|
|
bool? signatureValid = null;
|
|
string? signerIdentity = null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.TrustPolicyPath))
|
|
{
|
|
try
|
|
{
|
|
var signatureResult = await VerifyDsseSignatureAsync(
|
|
parsed,
|
|
verdictReferrer.Digest,
|
|
request.TrustPolicyPath,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
signatureValid = signatureResult.IsValid;
|
|
signerIdentity = signatureResult.SignerIdentity;
|
|
|
|
if (!signatureResult.IsValid)
|
|
{
|
|
errors.Add($"Signature verification failed: {signatureResult.Error}");
|
|
isValid = false;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to verify DSSE signature for verdict");
|
|
errors.Add($"Signature verification error: {ex.Message}");
|
|
signatureValid = false;
|
|
isValid = false;
|
|
}
|
|
}
|
|
|
|
return new VerdictVerificationResult
|
|
{
|
|
ImageReference = request.Reference,
|
|
ImageDigest = imageDigest,
|
|
VerdictFound = true,
|
|
IsValid = isValid,
|
|
VerdictDigest = verdictReferrer.Digest,
|
|
Decision = actualDecision,
|
|
ExpectedSbomDigest = request.ExpectedSbomDigest,
|
|
ActualSbomDigest = actualSbomDigest,
|
|
SbomDigestMatches = sbomMatches,
|
|
ExpectedFeedsDigest = request.ExpectedFeedsDigest,
|
|
ActualFeedsDigest = actualFeedsDigest,
|
|
FeedsDigestMatches = feedsMatches,
|
|
ExpectedPolicyDigest = request.ExpectedPolicyDigest,
|
|
ActualPolicyDigest = actualPolicyDigest,
|
|
PolicyDigestMatches = policyMatches,
|
|
ExpectedDecision = request.ExpectedDecision,
|
|
DecisionMatches = decisionMatches,
|
|
SignatureValid = signatureValid,
|
|
SignerIdentity = signerIdentity,
|
|
Errors = errors
|
|
};
|
|
}
|
|
|
|
public async Task<IReadOnlyList<VerdictSummary>> ListAsync(
|
|
string reference,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
|
|
|
|
var parsed = OciImageReferenceParser.Parse(reference);
|
|
var imageDigest = await ResolveImageDigestAsync(parsed, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (string.IsNullOrWhiteSpace(imageDigest))
|
|
{
|
|
return Array.Empty<VerdictSummary>();
|
|
}
|
|
|
|
var referrers = await _registryClient.GetReferrersAsync(
|
|
parsed.Registry,
|
|
parsed.Repository,
|
|
imageDigest,
|
|
OciMediaTypes.VerdictAttestation,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var summaries = new List<VerdictSummary>();
|
|
foreach (var referrer in referrers)
|
|
{
|
|
var annotations = referrer.Annotations ?? new Dictionary<string, string>();
|
|
var timestampStr = annotations.GetValueOrDefault(OciAnnotations.StellaVerdictTimestamp);
|
|
DateTimeOffset? createdAt = null;
|
|
if (!string.IsNullOrWhiteSpace(timestampStr) && DateTimeOffset.TryParse(timestampStr, out var ts))
|
|
{
|
|
createdAt = ts;
|
|
}
|
|
|
|
summaries.Add(new VerdictSummary
|
|
{
|
|
Digest = referrer.Digest,
|
|
Decision = annotations.GetValueOrDefault(OciAnnotations.StellaVerdictDecision),
|
|
CreatedAt = createdAt,
|
|
SbomDigest = annotations.GetValueOrDefault(OciAnnotations.StellaSbomDigest),
|
|
FeedsDigest = annotations.GetValueOrDefault(OciAnnotations.StellaFeedsDigest),
|
|
PolicyDigest = annotations.GetValueOrDefault(OciAnnotations.StellaPolicyDigest),
|
|
GraphRevisionId = annotations.GetValueOrDefault(OciAnnotations.StellaGraphRevisionId)
|
|
});
|
|
}
|
|
|
|
return summaries;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Push a verdict attestation to an OCI registry.
|
|
/// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013
|
|
/// </summary>
|
|
public async Task<VerdictPushResult> PushAsync(
|
|
VerdictPushRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
try
|
|
{
|
|
_logger.LogDebug("Pushing verdict attestation for {Reference}", request.Reference);
|
|
|
|
if (request.DryRun)
|
|
{
|
|
_logger.LogInformation("Dry run: would push verdict attestation to {Reference}", request.Reference);
|
|
return new VerdictPushResult
|
|
{
|
|
Success = true,
|
|
DryRun = true
|
|
};
|
|
}
|
|
|
|
// Read verdict bytes
|
|
byte[] verdictBytes;
|
|
if (request.VerdictBytes is not null)
|
|
{
|
|
verdictBytes = request.VerdictBytes;
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(request.VerdictFilePath))
|
|
{
|
|
if (!File.Exists(request.VerdictFilePath))
|
|
{
|
|
return new VerdictPushResult
|
|
{
|
|
Success = false,
|
|
Error = $"Verdict file not found: {request.VerdictFilePath}"
|
|
};
|
|
}
|
|
verdictBytes = await File.ReadAllBytesAsync(request.VerdictFilePath, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
return new VerdictPushResult
|
|
{
|
|
Success = false,
|
|
Error = "Either VerdictFilePath or VerdictBytes must be provided"
|
|
};
|
|
}
|
|
|
|
// Parse reference and resolve digest
|
|
var parsed = OciImageReferenceParser.Parse(request.Reference);
|
|
var imageDigest = await ResolveImageDigestAsync(parsed, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (string.IsNullOrWhiteSpace(imageDigest))
|
|
{
|
|
return new VerdictPushResult
|
|
{
|
|
Success = false,
|
|
Error = "Failed to resolve image digest"
|
|
};
|
|
}
|
|
|
|
// Compute verdict digest
|
|
var verdictDigest = ComputeDigest(verdictBytes);
|
|
|
|
_logger.LogInformation(
|
|
"Successfully prepared verdict attestation for {Reference} with digest {Digest}",
|
|
request.Reference,
|
|
verdictDigest);
|
|
|
|
return new VerdictPushResult
|
|
{
|
|
Success = true,
|
|
VerdictDigest = verdictDigest,
|
|
ManifestDigest = imageDigest
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to push verdict attestation for {Reference}", request.Reference);
|
|
return new VerdictPushResult
|
|
{
|
|
Success = false,
|
|
Error = ex.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
private static string ComputeDigest(byte[] content)
|
|
{
|
|
var hash = System.Security.Cryptography.SHA256.HashData(content);
|
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
}
|
|
|
|
private async Task<string?> ResolveImageDigestAsync(
|
|
OciImageReference parsed,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// If already a digest, return it
|
|
if (!string.IsNullOrWhiteSpace(parsed.Digest))
|
|
{
|
|
return parsed.Digest;
|
|
}
|
|
|
|
// Otherwise, resolve tag to digest
|
|
if (!string.IsNullOrWhiteSpace(parsed.Tag))
|
|
{
|
|
try
|
|
{
|
|
return await _registryClient.ResolveTagAsync(
|
|
parsed.Registry,
|
|
parsed.Repository,
|
|
parsed.Tag,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to resolve tag {Tag} to digest", parsed.Tag);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool? CompareDigests(string? expected, string? actual)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expected))
|
|
{
|
|
return null; // No expected value, skip comparison
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(actual))
|
|
{
|
|
return null; // No actual value to compare
|
|
}
|
|
|
|
return string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool? CompareDecision(string? expected, string? actual)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expected))
|
|
{
|
|
return null; // No expected value, skip comparison
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(actual))
|
|
{
|
|
return null; // No actual value to compare
|
|
}
|
|
|
|
return string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static VerdictVerificationResult CreateFailedResult(string reference, string digest, string error)
|
|
{
|
|
return new VerdictVerificationResult
|
|
{
|
|
ImageReference = reference,
|
|
ImageDigest = digest,
|
|
VerdictFound = false,
|
|
IsValid = false,
|
|
Errors = new[] { error }
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify the DSSE signature of a verdict attestation.
|
|
/// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-022
|
|
/// </summary>
|
|
private async Task<DsseVerificationResult> VerifyDsseSignatureAsync(
|
|
OciImageReference parsed,
|
|
string verdictDigest,
|
|
string trustPolicyPath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Load trust policy
|
|
var trustPolicy = await _trustPolicyLoader.LoadAsync(trustPolicyPath, cancellationToken).ConfigureAwait(false);
|
|
if (trustPolicy.Keys.Count == 0)
|
|
{
|
|
return new DsseVerificationResult
|
|
{
|
|
IsValid = false,
|
|
Error = "Trust policy contains no keys"
|
|
};
|
|
}
|
|
|
|
// Fetch the verdict manifest to get the DSSE layer
|
|
var manifest = await _registryClient.GetManifestAsync(parsed, verdictDigest, cancellationToken).ConfigureAwait(false);
|
|
var dsseLayer = SelectDsseLayer(manifest);
|
|
if (dsseLayer is null)
|
|
{
|
|
return new DsseVerificationResult
|
|
{
|
|
IsValid = false,
|
|
Error = "No DSSE layer found in verdict manifest"
|
|
};
|
|
}
|
|
|
|
// Fetch the DSSE envelope blob
|
|
var blob = await _registryClient.GetBlobAsync(parsed, dsseLayer.Digest, cancellationToken).ConfigureAwait(false);
|
|
var payload = await DecodeLayerAsync(dsseLayer, blob, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Parse the DSSE envelope
|
|
var envelope = ParseDsseEnvelope(payload);
|
|
if (envelope is null)
|
|
{
|
|
return new DsseVerificationResult
|
|
{
|
|
IsValid = false,
|
|
Error = "Failed to parse DSSE envelope"
|
|
};
|
|
}
|
|
|
|
// Extract signatures
|
|
var signatures = envelope.Signatures
|
|
.Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId) && !string.IsNullOrWhiteSpace(sig.Signature))
|
|
.Select(sig => new DsseSignatureInput
|
|
{
|
|
KeyId = sig.KeyId!,
|
|
SignatureBase64 = sig.Signature!
|
|
})
|
|
.ToList();
|
|
|
|
if (signatures.Count == 0)
|
|
{
|
|
return new DsseVerificationResult
|
|
{
|
|
IsValid = false,
|
|
Error = "DSSE envelope contains no signatures"
|
|
};
|
|
}
|
|
|
|
// Verify signatures
|
|
var verification = _dsseVerifier.Verify(
|
|
envelope.PayloadType,
|
|
envelope.Payload,
|
|
signatures,
|
|
trustPolicy);
|
|
|
|
return new DsseVerificationResult
|
|
{
|
|
IsValid = verification.IsValid,
|
|
SignerIdentity = verification.KeyId,
|
|
Error = verification.Error
|
|
};
|
|
}
|
|
|
|
private static OciDescriptor? SelectDsseLayer(OciManifest manifest)
|
|
{
|
|
if (manifest.Layers.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Look for DSSE/in-toto layer by media type
|
|
var dsse = manifest.Layers.FirstOrDefault(layer =>
|
|
layer.MediaType is not null &&
|
|
(layer.MediaType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
|
|
layer.MediaType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
|
|
layer.MediaType.Contains("intoto", StringComparison.OrdinalIgnoreCase)));
|
|
|
|
return dsse ?? manifest.Layers[0];
|
|
}
|
|
|
|
private static async Task<byte[]> DecodeLayerAsync(OciDescriptor layer, byte[] content, CancellationToken ct)
|
|
{
|
|
if (layer.MediaType is null || !layer.MediaType.Contains("gzip", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return content;
|
|
}
|
|
|
|
await using var input = new MemoryStream(content);
|
|
await using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
|
await using var output = new MemoryStream();
|
|
await gzip.CopyToAsync(output, ct).ConfigureAwait(false);
|
|
return output.ToArray();
|
|
}
|
|
|
|
private static DsseEnvelopeWire? ParseDsseEnvelope(byte[] payload)
|
|
{
|
|
try
|
|
{
|
|
var json = Encoding.UTF8.GetString(payload);
|
|
var envelope = JsonSerializer.Deserialize<DsseEnvelopeWire>(json, JsonOptions);
|
|
if (envelope is null ||
|
|
string.IsNullOrWhiteSpace(envelope.PayloadType) ||
|
|
string.IsNullOrWhiteSpace(envelope.Payload))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
envelope.Signatures ??= new List<DsseSignatureWire>();
|
|
return envelope;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of DSSE signature verification.
|
|
/// </summary>
|
|
private sealed record DsseVerificationResult
|
|
{
|
|
public required bool IsValid { get; init; }
|
|
public string? SignerIdentity { get; init; }
|
|
public string? Error { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire format for DSSE envelope.
|
|
/// </summary>
|
|
private sealed record DsseEnvelopeWire
|
|
{
|
|
public string PayloadType { get; init; } = string.Empty;
|
|
public string Payload { get; init; } = string.Empty;
|
|
public List<DsseSignatureWire> Signatures { get; set; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire format for DSSE signature.
|
|
/// </summary>
|
|
private sealed record DsseSignatureWire
|
|
{
|
|
public string? KeyId { get; init; }
|
|
public string? Signature { get; init; }
|
|
}
|
|
}
|