Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Services/ReplayVerificationService.cs
2026-02-01 21:37:40 +02:00

353 lines
13 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Implementation of <see cref="IReplayVerificationService"/>.
/// Verifies replay hashes and detects drift in security evaluations.
/// </summary>
internal sealed class ReplayVerificationService : IReplayVerificationService
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.ReplayVerification");
private readonly IReplayHashService _hashService;
private readonly ILogger<ReplayVerificationService> _logger;
private readonly IClock _clock;
// In-memory cache of replay hash inputs for demonstration
// In production, would be stored in database
private readonly ConcurrentDictionary<string, ReplayHashInputs> _inputsCache = new();
public ReplayVerificationService(
IReplayHashService hashService,
ILogger<ReplayVerificationService> logger,
IClock clock)
{
_hashService = hashService ?? throw new ArgumentNullException(nameof(hashService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
/// <inheritdoc />
public async Task<ReplayVerificationResult> 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<ReplayFieldDrift>.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
};
}
}
/// <inheritdoc />
public async Task<ReplayDriftAnalysis> 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<ReplayFieldDrift>.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
};
}
/// <summary>
/// Stores inputs for a replay hash (for later verification).
/// </summary>
public void StoreInputs(string replayHash, ReplayHashInputs inputs)
{
_inputsCache[replayHash] = inputs;
}
private async Task<ReplayHashInputs?> 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<ReplayFieldDrift> ComputeDrifts(
ReplayHashInputs expected,
ReplayHashInputs actual)
{
var drifts = new List<ReplayFieldDrift>();
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<ReplayFieldDrift> 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<string>();
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<string> GetCurrentFeedsDigestAsync(string tenantId, CancellationToken ct)
{
// In real implementation, would query feeds service
return Task.FromResult($"sha256:feeds-snapshot-{_clock.UtcNow:yyyyMMddHH}");
}
private Task<string> GetCurrentPolicyVersionAsync(string tenantId, CancellationToken ct)
{
// In real implementation, would query policy service
return Task.FromResult("v1.0.0");
}
private Task<string> 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;
}
}