// ----------------------------------------------------------------------------- // DeltaEvidenceComposer.cs // Sprint: SPRINT_20260118_026_Scanner_delta_scanning_engine // Task: TASK-026-05 - Delta Evidence Composer // Description: Implementation of delta scan evidence composition // ----------------------------------------------------------------------------- using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; namespace StellaOps.Scanner.Delta.Evidence; /// /// Composes signed, DSSE-wrapped delta evidence for policy gates. /// Produces deterministic, canonical JSON for consistent hashing. /// public sealed class DeltaEvidenceComposer : IDeltaEvidenceComposer { private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // Ensure deterministic key ordering PropertyNameCaseInsensitive = false }; private readonly IEvidenceSigningService? _signingService; private readonly IRekorSubmissionService? _rekorService; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private static readonly string ScannerVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute()?.InformationalVersion ?? "1.0.0"; public DeltaEvidenceComposer( ILogger logger, TimeProvider? timeProvider = null, IEvidenceSigningService? signingService = null, IRekorSubmissionService? rekorService = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; _signingService = signingService; _rekorService = rekorService; } public async Task ComposeAsync( DeltaScanResult scanResult, EvidenceCompositionOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(scanResult); options ??= new EvidenceCompositionOptions(); var composedAt = _timeProvider.GetUtcNow(); var scanId = options.ScanId ?? Guid.NewGuid().ToString("N"); // Create the in-toto statement var statement = CreateStatement(scanResult, scanId, composedAt); // Serialize to canonical JSON var statementJson = SerializeCanonical(statement); var payloadBytes = Encoding.UTF8.GetBytes(statementJson); var payloadBase64 = Convert.ToBase64String(payloadBytes); var payloadHash = ComputeHash(payloadBytes); // Sign if requested var signatures = new List(); if (options.Sign && _signingService != null) { var signature = await _signingService.SignAsync( payloadBytes, options.SigningKeyId, cancellationToken).ConfigureAwait(false); signatures.Add(new DsseSignature { KeyId = signature.KeyId, Sig = signature.SignatureBase64 }); _logger.LogDebug("Signed delta scan evidence with key {KeyId}", signature.KeyId); } else if (options.Sign) { _logger.LogWarning("Signing requested but no signing service available"); } var envelope = new DeltaScanDsseEnvelope { PayloadType = "application/vnd.in-toto+json", Payload = payloadBase64, Signatures = signatures }; // Compute idempotency key var idempotencyKey = ComputeIdempotencyKey( scanResult.OldManifestDigest, scanResult.NewManifestDigest); // Submit to Rekor if requested RekorEntryInfo? rekorEntry = null; if (options.SubmitToRekor && _rekorService != null) { try { rekorEntry = await _rekorService.SubmitAsync( envelope, idempotencyKey, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Submitted delta scan evidence to Rekor, logIndex={LogIndex}", rekorEntry.LogIndex); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to submit to Rekor, continuing without transparency log"); } } return new DeltaScanEvidence { Envelope = envelope, Statement = statement, PayloadHash = payloadHash, RekorEntry = rekorEntry, IdempotencyKey = idempotencyKey, ComposedAt = composedAt }; } public InTotoStatement CreateStatement(DeltaScanResult scanResult) { return CreateStatement(scanResult, Guid.NewGuid().ToString("N"), _timeProvider.GetUtcNow()); } private InTotoStatement CreateStatement( DeltaScanResult scanResult, string scanId, DateTimeOffset scannedAt) { var predicate = CreatePredicate(scanResult, scanId, scannedAt); // Create subjects for both old and new images var subjects = new List { new() { Name = scanResult.NewImage, Digest = new Dictionary { ["sha256"] = ExtractDigestValue(scanResult.NewManifestDigest) } } }; // Add old image as second subject if (!string.IsNullOrWhiteSpace(scanResult.OldManifestDigest)) { subjects.Add(new InTotoSubject { Name = scanResult.OldImage, Digest = new Dictionary { ["sha256"] = ExtractDigestValue(scanResult.OldManifestDigest) } }); } return new InTotoStatement { Subject = subjects, PredicateType = DeltaScanPredicate.PredicateType, Predicate = predicate }; } private static DeltaScanPredicate CreatePredicate( DeltaScanResult scanResult, string scanId, DateTimeOffset scannedAt) { // Calculate layer reuse ratio var totalLayers = scanResult.AddedLayers.Count + scanResult.RemovedLayers.Count + scanResult.UnchangedLayers.Count; var reuseRatio = totalLayers > 0 ? (double)scanResult.UnchangedLayers.Count / totalLayers : 0.0; return new DeltaScanPredicate { ScanId = scanId, ScannedAt = scannedAt, Scanner = new ScannerInfo { Name = "StellaOps.Scanner.Delta", Version = ScannerVersion, SbomTool = "syft", SbomToolVersion = "0.100.0" }, OldImage = new ImageSubject { Reference = scanResult.OldImage, ManifestDigest = scanResult.OldManifestDigest, LayerCount = scanResult.UnchangedLayers.Count + scanResult.RemovedLayers.Count }, NewImage = new ImageSubject { Reference = scanResult.NewImage, ManifestDigest = scanResult.NewManifestDigest, LayerCount = scanResult.UnchangedLayers.Count + scanResult.AddedLayers.Count }, LayerChanges = new LayerChangesInfo { Added = scanResult.AddedLayers.Count, Removed = scanResult.RemovedLayers.Count, Unchanged = scanResult.UnchangedLayers.Count, ReuseRatio = Math.Round(reuseRatio, 4), AddedDiffIds = scanResult.AddedLayers.Select(l => l.DiffId).ToList(), RemovedDiffIds = scanResult.RemovedLayers.Select(l => l.DiffId).ToList() }, ComponentChanges = new ComponentChangesInfo { Added = scanResult.AddedComponentCount, Removed = 0, // Would need to track this in DeltaScanResult VersionChanged = 0, OtherModified = 0, Unchanged = scanResult.CachedComponentCount, TotalComponents = scanResult.AddedComponentCount + scanResult.CachedComponentCount, IsBreaking = false, // Would need to determine from SBOM diff CachedComponentCount = scanResult.CachedComponentCount, ScannedComponentCount = scanResult.AddedComponentCount }, Metrics = new ScanMetrics { TotalDurationMs = (long)scanResult.ScanDuration.TotalMilliseconds, AddedLayersScanDurationMs = (long)scanResult.AddedLayersScanDuration.TotalMilliseconds, UsedCache = scanResult.UsedCache, CacheHitRatio = scanResult.CachedComponentCount > 0 ? (double)scanResult.CachedComponentCount / (scanResult.AddedComponentCount + scanResult.CachedComponentCount) : null }, SbomFormat = scanResult.SbomFormat, SbomDigest = !string.IsNullOrWhiteSpace(scanResult.CompositeSbom) ? "sha256:" + ComputeHash(Encoding.UTF8.GetBytes(scanResult.CompositeSbom)) : null }; } public string ComputePredicateHash(DeltaScanPredicate predicate) { var json = SerializeCanonical(predicate); return ComputeHash(Encoding.UTF8.GetBytes(json)); } private static string SerializeCanonical(T obj) { // For truly canonical JSON, we need sorted keys // System.Text.Json doesn't natively support this, but for our use case // the predictable property order from the record definitions is sufficient return JsonSerializer.Serialize(obj, CanonicalJsonOptions); } private static string ComputeHash(byte[] data) { var hashBytes = SHA256.HashData(data); return Convert.ToHexStringLower(hashBytes); } private static string ComputeIdempotencyKey(string oldDigest, string newDigest) { var combined = $"{oldDigest}:{newDigest}"; return ComputeHash(Encoding.UTF8.GetBytes(combined)); } private static string ExtractDigestValue(string digest) { // Extract the hex value from "sha256:abc123..." if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) { return digest[7..]; } return digest; } } /// /// Interface for evidence signing service. /// Implementations may use various signing backends (local key, KMS, HSM, etc.). /// public interface IEvidenceSigningService { /// /// Signs the payload and returns the signature. /// /// Payload bytes to sign. /// Optional key ID (null for default). /// Cancellation token. /// Signature result. Task SignAsync( byte[] payload, string? keyId, CancellationToken cancellationToken = default); } /// /// Result of signing operation. /// public sealed record SignatureResult { /// /// Key ID used for signing. /// public string? KeyId { get; init; } /// /// Base64-encoded signature. /// public required string SignatureBase64 { get; init; } /// /// Signature algorithm used. /// public string? Algorithm { get; init; } } /// /// Interface for Rekor transparency log submission. /// public interface IRekorSubmissionService { /// /// Submits an envelope to Rekor. /// /// DSSE envelope to submit. /// Idempotency key for deduplication. /// Cancellation token. /// Rekor entry information. Task SubmitAsync( DeltaScanDsseEnvelope envelope, string idempotencyKey, CancellationToken cancellationToken = default); }