Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
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,
|
||||
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,
|
||||
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"),
|
||||
ActualValue = actual.Timestamp.ToString("O"),
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user