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

This commit is contained in:
StellaOps Bot
2025-11-28 00:45:16 +02:00
parent 3b96b2e3ea
commit 1c6730a1d2
95 changed files with 14504 additions and 463 deletions

View File

@@ -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;
}
}