353 lines
13 KiB
C#
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;
|
|
}
|
|
}
|