// ----------------------------------------------------------------------------- // ReplayVerificationService.cs // Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-033) // Task: Replay verification endpoint // Description: Implementation of replay hash verification with drift detection. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; namespace StellaOps.SbomService.Services; /// /// Implementation of . /// Verifies replay hashes and detects drift in security evaluations. /// internal sealed class ReplayVerificationService : IReplayVerificationService { private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.ReplayVerification"); private readonly IReplayHashService _hashService; private readonly ILogger _logger; private readonly IClock _clock; // In-memory cache of replay hash inputs for demonstration // In production, would be stored in database private readonly ConcurrentDictionary _inputsCache = new(); public ReplayVerificationService( IReplayHashService hashService, ILogger logger, IClock clock) { _hashService = hashService ?? throw new ArgumentNullException(nameof(hashService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); } /// public async Task VerifyAsync( ReplayVerificationRequest request, CancellationToken ct = default) { using var activity = ActivitySource.StartActivity("VerifyReplayHash"); activity?.SetTag("replay_hash", TruncateHash(request.ReplayHash)); activity?.SetTag("tenant_id", request.TenantId); _logger.LogInformation( "Verifying replay hash {ReplayHash} for tenant {TenantId}", TruncateHash(request.ReplayHash), request.TenantId); try { // Try to lookup original inputs ReplayHashInputs? expectedInputs = null; if (_inputsCache.TryGetValue(request.ReplayHash, out var cached)) { expectedInputs = cached; } // Build verification inputs var verificationInputs = await BuildVerificationInputsAsync( request, expectedInputs, ct); if (verificationInputs is null) { return new ReplayVerificationResult { IsMatch = false, ExpectedHash = request.ReplayHash, ComputedHash = string.Empty, Status = ReplayVerificationStatus.InputsNotFound, VerifiedAt = _clock.UtcNow, Error = "Unable to determine verification inputs. Provide explicit inputs or ensure hash is stored." }; } // Compute verification hash var computedHash = _hashService.ComputeHash(verificationInputs); // Compare var isMatch = string.Equals(computedHash, request.ReplayHash, StringComparison.OrdinalIgnoreCase); // Compute drifts if not matching var drifts = ImmutableArray.Empty; if (!isMatch && expectedInputs is not null) { drifts = ComputeDrifts(expectedInputs, verificationInputs); } var status = isMatch ? ReplayVerificationStatus.Match : ReplayVerificationStatus.Drift; _logger.LogInformation( "Replay verification {Status}: expected {Expected}, computed {Computed}", status, TruncateHash(request.ReplayHash), TruncateHash(computedHash)); return new ReplayVerificationResult { IsMatch = isMatch, ExpectedHash = request.ReplayHash, ComputedHash = computedHash, Status = status, ExpectedInputs = expectedInputs, ComputedInputs = verificationInputs, Drifts = drifts, VerifiedAt = _clock.UtcNow, Message = isMatch ? "Replay hash verified successfully" : $"Drift detected in {drifts.Length} field(s)" }; } catch (Exception ex) { _logger.LogError(ex, "Failed to verify replay hash {ReplayHash}", request.ReplayHash); return new ReplayVerificationResult { IsMatch = false, ExpectedHash = request.ReplayHash, ComputedHash = string.Empty, Status = ReplayVerificationStatus.Error, VerifiedAt = _clock.UtcNow, Error = ex.Message }; } } /// public async Task CompareDriftAsync( string hashA, string hashB, string tenantId, CancellationToken ct = default) { using var activity = ActivitySource.StartActivity("CompareDrift"); activity?.SetTag("hash_a", TruncateHash(hashA)); activity?.SetTag("hash_b", TruncateHash(hashB)); _logger.LogInformation( "Comparing drift between {HashA} and {HashB}", TruncateHash(hashA), TruncateHash(hashB)); // Lookup inputs for both hashes _inputsCache.TryGetValue(hashA, out var inputsA); _inputsCache.TryGetValue(hashB, out var inputsB); var isIdentical = string.Equals(hashA, hashB, StringComparison.OrdinalIgnoreCase); var drifts = ImmutableArray.Empty; var driftSummary = "identical"; if (!isIdentical && inputsA is not null && inputsB is not null) { drifts = ComputeDrifts(inputsA, inputsB); driftSummary = SummarizeDrifts(drifts); } else if (!isIdentical) { driftSummary = "unable to compare - inputs not found"; } await Task.CompletedTask; return new ReplayDriftAnalysis { HashA = hashA, HashB = hashB, IsIdentical = isIdentical, InputsA = inputsA, InputsB = inputsB, Drifts = drifts, DriftSummary = driftSummary, AnalyzedAt = _clock.UtcNow }; } /// /// Stores inputs for a replay hash (for later verification). /// public void StoreInputs(string replayHash, ReplayHashInputs inputs) { _inputsCache[replayHash] = inputs; } private async Task BuildVerificationInputsAsync( ReplayVerificationRequest request, ReplayHashInputs? expectedInputs, CancellationToken ct) { // If we have explicit inputs in request, use them if (!string.IsNullOrEmpty(request.SbomDigest)) { // Get current or specified values for other fields var feedsDigest = request.FeedsSnapshotDigest ?? expectedInputs?.FeedsSnapshotDigest ?? await GetCurrentFeedsDigestAsync(request.TenantId, ct); var policyVersion = request.PolicyVersion ?? expectedInputs?.PolicyVersion ?? await GetCurrentPolicyVersionAsync(request.TenantId, ct); var vexDigest = request.VexVerdictsDigest ?? expectedInputs?.VexVerdictsDigest ?? await GetCurrentVexDigestAsync(request.SbomDigest, request.TenantId, ct); var timestamp = request.Timestamp ?? (request.FreezeTime ? expectedInputs?.Timestamp : null) ?? _clock.UtcNow; return new ReplayHashInputs { SbomDigest = request.SbomDigest, FeedsSnapshotDigest = feedsDigest, PolicyVersion = policyVersion, VexVerdictsDigest = vexDigest, Timestamp = timestamp }; } // If we have expected inputs and should use them if (expectedInputs is not null) { if (request.FreezeTime) { return expectedInputs; } // Re-compute with current time but same other inputs return expectedInputs with { Timestamp = _clock.UtcNow }; } // Cannot determine inputs return null; } private static ImmutableArray ComputeDrifts( ReplayHashInputs expected, ReplayHashInputs actual) { var drifts = new List(); if (!string.Equals(expected.SbomDigest, actual.SbomDigest, StringComparison.OrdinalIgnoreCase)) { drifts.Add(new ReplayFieldDrift { FieldName = "SbomDigest", ExpectedValue = expected.SbomDigest, ActualValue = actual.SbomDigest, Severity = "critical", Description = "SBOM content has changed - represents a different artifact or version" }); } if (!string.Equals(expected.FeedsSnapshotDigest, actual.FeedsSnapshotDigest, StringComparison.OrdinalIgnoreCase)) { drifts.Add(new ReplayFieldDrift { FieldName = "FeedsSnapshotDigest", ExpectedValue = expected.FeedsSnapshotDigest, ActualValue = actual.FeedsSnapshotDigest, Severity = "warning", Description = "Vulnerability feeds have been updated since original evaluation" }); } if (!string.Equals(expected.PolicyVersion, actual.PolicyVersion, StringComparison.OrdinalIgnoreCase)) { drifts.Add(new ReplayFieldDrift { FieldName = "PolicyVersion", ExpectedValue = expected.PolicyVersion, ActualValue = actual.PolicyVersion, Severity = "warning", Description = "Policy rules have been modified since original evaluation" }); } if (!string.Equals(expected.VexVerdictsDigest, actual.VexVerdictsDigest, StringComparison.OrdinalIgnoreCase)) { drifts.Add(new ReplayFieldDrift { FieldName = "VexVerdictsDigest", ExpectedValue = expected.VexVerdictsDigest, ActualValue = actual.VexVerdictsDigest, Severity = "warning", Description = "VEX verdicts have changed (new statements or consensus updates)" }); } if (expected.Timestamp != actual.Timestamp) { drifts.Add(new ReplayFieldDrift { FieldName = "Timestamp", ExpectedValue = expected.Timestamp.ToString("O", CultureInfo.InvariantCulture), ActualValue = actual.Timestamp.ToString("O", CultureInfo.InvariantCulture), Severity = "info", Description = "Evaluation timestamp differs (expected when not freezing time)" }); } return drifts.ToImmutableArray(); } private static string SummarizeDrifts(ImmutableArray drifts) { if (drifts.IsEmpty) { return "no drift detected"; } var criticalCount = drifts.Count(d => d.Severity == "critical"); var warningCount = drifts.Count(d => d.Severity == "warning"); var infoCount = drifts.Count(d => d.Severity == "info"); var parts = new List(); if (criticalCount > 0) parts.Add($"{criticalCount} critical"); if (warningCount > 0) parts.Add($"{warningCount} warning"); if (infoCount > 0) parts.Add($"{infoCount} info"); return string.Join(", ", parts); } private Task GetCurrentFeedsDigestAsync(string tenantId, CancellationToken ct) { // In real implementation, would query feeds service return Task.FromResult($"sha256:feeds-snapshot-{_clock.UtcNow:yyyyMMddHH}"); } private Task GetCurrentPolicyVersionAsync(string tenantId, CancellationToken ct) { // In real implementation, would query policy service return Task.FromResult("v1.0.0"); } private Task GetCurrentVexDigestAsync(string sbomDigest, string tenantId, CancellationToken ct) { // In real implementation, would query VexLens return Task.FromResult($"sha256:vex-{sbomDigest[..16]}-current"); } private static string TruncateHash(string hash) { if (string.IsNullOrEmpty(hash)) return hash; return hash.Length > 16 ? $"{hash[..16]}..." : hash; } }