up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Caching;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Request for runtime policy evaluation over linkset/SBOM data.
|
||||
/// </summary>
|
||||
internal sealed record RuntimeEvaluationRequest(
|
||||
string PackId,
|
||||
int Version,
|
||||
string TenantId,
|
||||
string SubjectPurl,
|
||||
string AdvisoryId,
|
||||
PolicyEvaluationSeverity Severity,
|
||||
PolicyEvaluationAdvisory Advisory,
|
||||
PolicyEvaluationVexEvidence Vex,
|
||||
PolicyEvaluationSbom Sbom,
|
||||
PolicyEvaluationExceptions Exceptions,
|
||||
PolicyEvaluationReachability Reachability,
|
||||
DateTimeOffset? EvaluationTimestamp = null,
|
||||
bool BypassCache = false);
|
||||
|
||||
/// <summary>
|
||||
/// Response from runtime policy evaluation.
|
||||
/// </summary>
|
||||
internal sealed record RuntimeEvaluationResponse(
|
||||
string PackId,
|
||||
int Version,
|
||||
string PolicyDigest,
|
||||
string Status,
|
||||
string? Severity,
|
||||
string? RuleName,
|
||||
int? Priority,
|
||||
ImmutableDictionary<string, string> Annotations,
|
||||
ImmutableArray<string> Warnings,
|
||||
PolicyExceptionApplication? AppliedException,
|
||||
string CorrelationId,
|
||||
bool Cached,
|
||||
CacheSource CacheSource,
|
||||
long EvaluationDurationMs);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime evaluator executing compiled policy plans over advisory/VEX linksets and SBOM asset metadata
|
||||
/// with deterministic caching (Redis) and fallback path.
|
||||
/// </summary>
|
||||
internal sealed class PolicyRuntimeEvaluationService
|
||||
{
|
||||
private readonly IPolicyPackRepository _repository;
|
||||
private readonly IPolicyEvaluationCache _cache;
|
||||
private readonly PolicyEvaluator _evaluator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyRuntimeEvaluationService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions ContextSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public PolicyRuntimeEvaluationService(
|
||||
IPolicyPackRepository repository,
|
||||
IPolicyEvaluationCache cache,
|
||||
PolicyEvaluator evaluator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyRuntimeEvaluationService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a policy against the provided context with deterministic caching.
|
||||
/// </summary>
|
||||
public async Task<RuntimeEvaluationResponse> EvaluateAsync(
|
||||
RuntimeEvaluationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var startTimestamp = _timeProvider.GetTimestamp();
|
||||
var evaluationTimestamp = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
|
||||
|
||||
// Load the compiled policy bundle
|
||||
var bundle = await _repository.GetBundleAsync(request.PackId, request.Version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Policy bundle not found for pack '{request.PackId}' version {request.Version}.");
|
||||
}
|
||||
|
||||
// Compute deterministic cache key
|
||||
var subjectDigest = ComputeSubjectDigest(request.TenantId, request.SubjectPurl, request.AdvisoryId);
|
||||
var contextDigest = ComputeContextDigest(request);
|
||||
var cacheKey = PolicyEvaluationCacheKey.Create(bundle.Digest, subjectDigest, contextDigest);
|
||||
|
||||
// Try cache lookup unless bypassed
|
||||
if (!request.BypassCache)
|
||||
{
|
||||
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cacheResult.CacheHit && cacheResult.Entry is not null)
|
||||
{
|
||||
var duration = GetElapsedMilliseconds(startTimestamp);
|
||||
_logger.LogDebug(
|
||||
"Cache hit for evaluation {PackId}@{Version} subject {Subject} from {Source}",
|
||||
request.PackId, request.Version, request.SubjectPurl, cacheResult.Source);
|
||||
|
||||
return CreateResponseFromCache(
|
||||
request, bundle.Digest, cacheResult.Entry, cacheResult.Source, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - perform evaluation
|
||||
var document = DeserializeCompiledPolicy(bundle.Payload);
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to deserialize compiled policy for pack '{request.PackId}' version {request.Version}.");
|
||||
}
|
||||
|
||||
var context = new PolicyEvaluationContext(
|
||||
request.Severity,
|
||||
new PolicyEvaluationEnvironment(ImmutableDictionary<string, string>.Empty),
|
||||
request.Advisory,
|
||||
request.Vex,
|
||||
request.Sbom,
|
||||
request.Exceptions,
|
||||
request.Reachability,
|
||||
evaluationTimestamp);
|
||||
|
||||
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
var result = _evaluator.Evaluate(evalRequest);
|
||||
|
||||
var correlationId = ComputeCorrelationId(bundle.Digest, subjectDigest, contextDigest);
|
||||
var expiresAt = evaluationTimestamp.AddMinutes(30);
|
||||
|
||||
// Store in cache
|
||||
var cacheEntry = new PolicyEvaluationCacheEntry(
|
||||
result.Status,
|
||||
result.Severity,
|
||||
result.RuleName,
|
||||
result.Priority,
|
||||
result.Annotations,
|
||||
result.Warnings,
|
||||
result.AppliedException?.ExceptionId,
|
||||
correlationId,
|
||||
evaluationTimestamp,
|
||||
expiresAt);
|
||||
|
||||
await _cache.SetAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var evalDuration = GetElapsedMilliseconds(startTimestamp);
|
||||
_logger.LogDebug(
|
||||
"Evaluated {PackId}@{Version} subject {Subject} in {Duration}ms - {Status}",
|
||||
request.PackId, request.Version, request.SubjectPurl, evalDuration, result.Status);
|
||||
|
||||
return new RuntimeEvaluationResponse(
|
||||
request.PackId,
|
||||
request.Version,
|
||||
bundle.Digest,
|
||||
result.Status,
|
||||
result.Severity,
|
||||
result.RuleName,
|
||||
result.Priority,
|
||||
result.Annotations,
|
||||
result.Warnings,
|
||||
result.AppliedException,
|
||||
correlationId,
|
||||
Cached: false,
|
||||
CacheSource: CacheSource.None,
|
||||
EvaluationDurationMs: evalDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates multiple subjects in batch with caching.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RuntimeEvaluationResponse>> EvaluateBatchAsync(
|
||||
IReadOnlyList<RuntimeEvaluationRequest> requests,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
return Array.Empty<RuntimeEvaluationResponse>();
|
||||
}
|
||||
|
||||
var results = new List<RuntimeEvaluationResponse>(requests.Count);
|
||||
|
||||
// Group by pack/version for bundle loading efficiency
|
||||
var groups = requests.GroupBy(r => (r.PackId, r.Version));
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var (packId, version) = group.Key;
|
||||
var bundle = await _repository.GetBundleAsync(packId, version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
foreach (var request in group)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Policy bundle not found for pack '{PackId}' version {Version}, skipping evaluation",
|
||||
packId, version);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = DeserializeCompiledPolicy(bundle.Payload);
|
||||
if (document is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to deserialize policy bundle for pack '{PackId}' version {Version}",
|
||||
packId, version);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build cache keys for batch lookup
|
||||
var cacheKeys = new List<(RuntimeEvaluationRequest Request, PolicyEvaluationCacheKey Key)>();
|
||||
foreach (var request in group)
|
||||
{
|
||||
var subjectDigest = ComputeSubjectDigest(request.TenantId, request.SubjectPurl, request.AdvisoryId);
|
||||
var contextDigest = ComputeContextDigest(request);
|
||||
var key = PolicyEvaluationCacheKey.Create(bundle.Digest, subjectDigest, contextDigest);
|
||||
cacheKeys.Add((request, key));
|
||||
}
|
||||
|
||||
// Batch cache lookup
|
||||
var keyList = cacheKeys.Select(k => k.Key).ToList();
|
||||
var cacheResults = await _cache.GetBatchAsync(keyList, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var toEvaluate = new List<(RuntimeEvaluationRequest Request, PolicyEvaluationCacheKey Key)>();
|
||||
|
||||
// Process cache hits
|
||||
foreach (var (request, key) in cacheKeys)
|
||||
{
|
||||
if (!request.BypassCache && cacheResults.Found.TryGetValue(key, out var entry))
|
||||
{
|
||||
var response = CreateResponseFromCache(request, bundle.Digest, entry, CacheSource.InMemory, 0);
|
||||
results.Add(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
toEvaluate.Add((request, key));
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate cache misses
|
||||
var entriesToCache = new Dictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry>();
|
||||
|
||||
foreach (var (request, key) in toEvaluate)
|
||||
{
|
||||
var startTimestamp = _timeProvider.GetTimestamp();
|
||||
var evaluationTimestamp = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
|
||||
|
||||
var context = new PolicyEvaluationContext(
|
||||
request.Severity,
|
||||
new PolicyEvaluationEnvironment(ImmutableDictionary<string, string>.Empty),
|
||||
request.Advisory,
|
||||
request.Vex,
|
||||
request.Sbom,
|
||||
request.Exceptions,
|
||||
request.Reachability,
|
||||
evaluationTimestamp);
|
||||
|
||||
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
var result = _evaluator.Evaluate(evalRequest);
|
||||
|
||||
var correlationId = ComputeCorrelationId(bundle.Digest, key.SubjectDigest, key.ContextDigest);
|
||||
var expiresAt = evaluationTimestamp.AddMinutes(30);
|
||||
var duration = GetElapsedMilliseconds(startTimestamp);
|
||||
|
||||
var cacheEntry = new PolicyEvaluationCacheEntry(
|
||||
result.Status,
|
||||
result.Severity,
|
||||
result.RuleName,
|
||||
result.Priority,
|
||||
result.Annotations,
|
||||
result.Warnings,
|
||||
result.AppliedException?.ExceptionId,
|
||||
correlationId,
|
||||
evaluationTimestamp,
|
||||
expiresAt);
|
||||
|
||||
entriesToCache[key] = cacheEntry;
|
||||
|
||||
results.Add(new RuntimeEvaluationResponse(
|
||||
request.PackId,
|
||||
request.Version,
|
||||
bundle.Digest,
|
||||
result.Status,
|
||||
result.Severity,
|
||||
result.RuleName,
|
||||
result.Priority,
|
||||
result.Annotations,
|
||||
result.Warnings,
|
||||
result.AppliedException,
|
||||
correlationId,
|
||||
Cached: false,
|
||||
CacheSource: CacheSource.None,
|
||||
EvaluationDurationMs: duration));
|
||||
}
|
||||
|
||||
// Batch store cache entries
|
||||
if (entriesToCache.Count > 0)
|
||||
{
|
||||
await _cache.SetBatchAsync(entriesToCache, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static RuntimeEvaluationResponse CreateResponseFromCache(
|
||||
RuntimeEvaluationRequest request,
|
||||
string policyDigest,
|
||||
PolicyEvaluationCacheEntry entry,
|
||||
CacheSource source,
|
||||
long durationMs)
|
||||
{
|
||||
PolicyExceptionApplication? appliedException = null;
|
||||
if (entry.ExceptionId is not null)
|
||||
{
|
||||
// Reconstruct minimal exception application from cache
|
||||
appliedException = new PolicyExceptionApplication(
|
||||
entry.ExceptionId,
|
||||
EffectId: "cached",
|
||||
EffectType: PolicyExceptionEffectType.Suppress,
|
||||
OriginalStatus: entry.Status,
|
||||
OriginalSeverity: entry.Severity,
|
||||
AppliedStatus: entry.Status,
|
||||
AppliedSeverity: entry.Severity,
|
||||
Metadata: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
return new RuntimeEvaluationResponse(
|
||||
request.PackId,
|
||||
request.Version,
|
||||
policyDigest,
|
||||
entry.Status,
|
||||
entry.Severity,
|
||||
entry.RuleName,
|
||||
entry.Priority,
|
||||
entry.Annotations,
|
||||
entry.Warnings,
|
||||
appliedException,
|
||||
entry.CorrelationId,
|
||||
Cached: true,
|
||||
CacheSource: source,
|
||||
EvaluationDurationMs: durationMs);
|
||||
}
|
||||
|
||||
private static string ComputeSubjectDigest(string tenantId, string subjectPurl, string advisoryId)
|
||||
{
|
||||
var input = $"{tenantId}|{subjectPurl}|{advisoryId}";
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string ComputeContextDigest(RuntimeEvaluationRequest request)
|
||||
{
|
||||
// Create deterministic context representation
|
||||
var contextData = new
|
||||
{
|
||||
severity = request.Severity.Normalized,
|
||||
severityScore = request.Severity.Score,
|
||||
advisorySource = request.Advisory.Source,
|
||||
vexCount = request.Vex.Statements.Length,
|
||||
vexStatements = request.Vex.Statements.Select(s => $"{s.Status}:{s.Justification}").OrderBy(s => s).ToArray(),
|
||||
sbomTags = request.Sbom.Tags.OrderBy(t => t).ToArray(),
|
||||
exceptionCount = request.Exceptions.Instances.Length,
|
||||
reachability = request.Reachability.State,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(contextData, ContextSerializerOptions);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(json), hash);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string ComputeCorrelationId(string policyDigest, string subjectDigest, string contextDigest)
|
||||
{
|
||||
var input = $"{policyDigest}|{subjectDigest}|{contextDigest}";
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static PolicyIrDocument? DeserializeCompiledPolicy(ImmutableArray<byte> payload)
|
||||
{
|
||||
if (payload.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(payload.AsSpan());
|
||||
return JsonSerializer.Deserialize<PolicyIrDocument>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private long GetElapsedMilliseconds(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
|
||||
return (long)elapsed.TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user