up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -0,0 +1,270 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// Service for joining reachability facts with policy evaluation inputs.
/// Provides efficient batch lookups with caching and metrics.
/// </summary>
public sealed class ReachabilityFactsJoiningService
{
private readonly IReachabilityFactsStore _store;
private readonly IReachabilityFactsOverlayCache _cache;
private readonly ILogger<ReachabilityFactsJoiningService> _logger;
private readonly TimeProvider _timeProvider;
public ReachabilityFactsJoiningService(
IReachabilityFactsStore store,
IReachabilityFactsOverlayCache cache,
ILogger<ReachabilityFactsJoiningService> logger,
TimeProvider timeProvider)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Gets reachability facts for a batch of component-advisory pairs.
/// Uses cache-first strategy with store fallback.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="items">List of component-advisory pairs.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Batch result with facts and cache statistics.</returns>
public async Task<ReachabilityFactsBatch> GetFactsBatchAsync(
string tenantId,
IReadOnlyList<ReachabilityFactsRequest> items,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"reachability_facts.batch_lookup",
ActivityKind.Internal);
activity?.SetTag("tenant", tenantId);
activity?.SetTag("batch_size", items.Count);
var keys = items
.Select(i => new ReachabilityFactKey(tenantId, i.ComponentPurl, i.AdvisoryId))
.Distinct()
.ToList();
// Try cache first
var cacheResult = await _cache.GetBatchAsync(keys, cancellationToken).ConfigureAwait(false);
ReachabilityFactsTelemetry.RecordCacheHits(cacheResult.CacheHits);
ReachabilityFactsTelemetry.RecordCacheMisses(cacheResult.CacheMisses);
activity?.SetTag("cache_hits", cacheResult.CacheHits);
activity?.SetTag("cache_misses", cacheResult.CacheMisses);
if (cacheResult.NotFound.Count == 0)
{
// All items found in cache
return cacheResult;
}
// Fetch missing items from store
var storeResults = await _store.GetBatchAsync(cacheResult.NotFound, cancellationToken)
.ConfigureAwait(false);
activity?.SetTag("store_hits", storeResults.Count);
// Populate cache with store results
if (storeResults.Count > 0)
{
await _cache.SetBatchAsync(storeResults, cancellationToken).ConfigureAwait(false);
}
// Merge results
var allFound = new Dictionary<ReachabilityFactKey, ReachabilityFact>(cacheResult.Found);
foreach (var (key, fact) in storeResults)
{
allFound[key] = fact;
}
var stillNotFound = cacheResult.NotFound
.Where(k => !storeResults.ContainsKey(k))
.ToList();
_logger.LogDebug(
"Reachability facts lookup: {Total} requested, {CacheHits} cache hits, {StoreFetched} from store, {NotFound} not found",
keys.Count,
cacheResult.CacheHits,
storeResults.Count,
stillNotFound.Count);
return new ReachabilityFactsBatch
{
Found = allFound,
NotFound = stillNotFound,
CacheHits = cacheResult.CacheHits,
CacheMisses = cacheResult.CacheMisses,
};
}
/// <summary>
/// Gets a single reachability fact.
/// </summary>
public async Task<ReachabilityFact?> GetFactAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
// Try cache first
var (cached, cacheHit) = await _cache.GetAsync(key, cancellationToken).ConfigureAwait(false);
if (cacheHit)
{
ReachabilityFactsTelemetry.RecordCacheHits(1);
return cached;
}
ReachabilityFactsTelemetry.RecordCacheMisses(1);
// Fall back to store
var fact = await _store.GetAsync(tenantId, componentPurl, advisoryId, cancellationToken)
.ConfigureAwait(false);
if (fact != null)
{
await _cache.SetAsync(key, fact, cancellationToken).ConfigureAwait(false);
}
return fact;
}
/// <summary>
/// Enriches signal context with reachability facts.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="componentPurl">Component PURL.</param>
/// <param name="advisoryId">Advisory ID.</param>
/// <param name="signals">Signal context to enrich.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if reachability fact was found and applied.</returns>
public async Task<bool> EnrichSignalsAsync(
string tenantId,
string componentPurl,
string advisoryId,
IDictionary<string, object?> signals,
CancellationToken cancellationToken = default)
{
var fact = await GetFactAsync(tenantId, componentPurl, advisoryId, cancellationToken)
.ConfigureAwait(false);
if (fact == null)
{
// Set default unknown state
signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["state"] = "unknown",
["confidence"] = 0m,
["score"] = 0m,
["has_runtime_evidence"] = false,
};
return false;
}
signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["state"] = fact.State.ToString().ToLowerInvariant(),
["confidence"] = fact.Confidence,
["score"] = fact.Score,
["has_runtime_evidence"] = fact.HasRuntimeEvidence,
["source"] = fact.Source,
["method"] = fact.Method.ToString().ToLowerInvariant(),
};
ReachabilityFactsTelemetry.RecordFactApplied(fact.State.ToString().ToLowerInvariant());
return true;
}
/// <summary>
/// Saves a new reachability fact and updates the cache.
/// </summary>
public async Task SaveFactAsync(
ReachabilityFact fact,
CancellationToken cancellationToken = default)
{
await _store.SaveAsync(fact, cancellationToken).ConfigureAwait(false);
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
await _cache.SetAsync(key, fact, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Saved reachability fact: {TenantId}/{ComponentPurl}/{AdvisoryId} = {State} ({Confidence:P0})",
fact.TenantId,
fact.ComponentPurl,
fact.AdvisoryId,
fact.State,
fact.Confidence);
}
/// <summary>
/// Invalidates cache entries when reachability facts are updated externally.
/// </summary>
public Task InvalidateCacheAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
return _cache.InvalidateAsync(key, cancellationToken);
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public ReachabilityFactsCacheStats GetCacheStats() => _cache.GetStats();
}
/// <summary>
/// Request item for batch reachability facts lookup.
/// </summary>
public sealed record ReachabilityFactsRequest(string ComponentPurl, string AdvisoryId);
/// <summary>
/// Telemetry for reachability facts operations.
/// Delegates to PolicyEngineTelemetry for centralized metrics.
/// </summary>
public static class ReachabilityFactsTelemetry
{
/// <summary>
/// Records cache hits.
/// </summary>
public static void RecordCacheHits(int count)
{
PolicyEngineTelemetry.RecordReachabilityCacheHits(count);
}
/// <summary>
/// Records cache misses.
/// </summary>
public static void RecordCacheMisses(int count)
{
PolicyEngineTelemetry.RecordReachabilityCacheMisses(count);
}
/// <summary>
/// Records a reachability fact being applied.
/// </summary>
public static void RecordFactApplied(string state)
{
PolicyEngineTelemetry.RecordReachabilityApplied(state);
}
/// <summary>
/// Gets the current cache hit ratio from stats.
/// </summary>
public static double GetCacheHitRatio(ReachabilityFactsCacheStats stats)
{
return stats.HitRatio;
}
}

View File

@@ -0,0 +1,258 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// Represents a reachability fact for a component-vulnerability pair.
/// </summary>
public sealed record ReachabilityFact
{
/// <summary>
/// Unique identifier for this reachability fact.
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Component PURL this fact applies to.
/// </summary>
[JsonPropertyName("component_purl")]
public required string ComponentPurl { get; init; }
/// <summary>
/// Vulnerability/advisory identifier (CVE, GHSA, etc.).
/// </summary>
[JsonPropertyName("advisory_id")]
public required string AdvisoryId { get; init; }
/// <summary>
/// Reachability state (reachable, unreachable, unknown, under_investigation).
/// </summary>
[JsonPropertyName("state")]
public required ReachabilityState State { get; init; }
/// <summary>
/// Confidence score (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidence")]
public required decimal Confidence { get; init; }
/// <summary>
/// Reachability score (0.0 to 1.0, higher = more reachable).
/// </summary>
[JsonPropertyName("score")]
public decimal Score { get; init; }
/// <summary>
/// Whether this fact has runtime evidence backing it.
/// </summary>
[JsonPropertyName("has_runtime_evidence")]
public bool HasRuntimeEvidence { get; init; }
/// <summary>
/// Source of the reachability analysis.
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Analysis method used (static, dynamic, hybrid).
/// </summary>
[JsonPropertyName("method")]
public required AnalysisMethod Method { get; init; }
/// <summary>
/// Reference to the call graph or evidence artifact.
/// </summary>
[JsonPropertyName("evidence_ref")]
public string? EvidenceRef { get; init; }
/// <summary>
/// Content hash of the analysis evidence.
/// </summary>
[JsonPropertyName("evidence_hash")]
public string? EvidenceHash { get; init; }
/// <summary>
/// Timestamp when this fact was computed.
/// </summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Timestamp when this fact expires and should be recomputed.
/// </summary>
[JsonPropertyName("expires_at")]
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, object?>? Metadata { get; init; }
}
/// <summary>
/// Reachability state enumeration aligned with VEX status semantics.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ReachabilityState>))]
public enum ReachabilityState
{
/// <summary>
/// The vulnerable code path is reachable from application entry points.
/// </summary>
[JsonPropertyName("reachable")]
Reachable,
/// <summary>
/// The vulnerable code path is not reachable from application entry points.
/// </summary>
[JsonPropertyName("unreachable")]
Unreachable,
/// <summary>
/// Reachability status is unknown or could not be determined.
/// </summary>
[JsonPropertyName("unknown")]
Unknown,
/// <summary>
/// Reachability is under investigation and may change.
/// </summary>
[JsonPropertyName("under_investigation")]
UnderInvestigation,
}
/// <summary>
/// Analysis method enumeration.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AnalysisMethod>))]
public enum AnalysisMethod
{
/// <summary>
/// Static analysis (call graph, data flow).
/// </summary>
[JsonPropertyName("static")]
Static,
/// <summary>
/// Dynamic analysis (runtime profiling, instrumentation).
/// </summary>
[JsonPropertyName("dynamic")]
Dynamic,
/// <summary>
/// Hybrid approach combining static and dynamic analysis.
/// </summary>
[JsonPropertyName("hybrid")]
Hybrid,
/// <summary>
/// Manual assessment or expert judgment.
/// </summary>
[JsonPropertyName("manual")]
Manual,
}
/// <summary>
/// Query parameters for fetching reachability facts.
/// </summary>
public sealed record ReachabilityFactsQuery
{
/// <summary>
/// Tenant identifier (required).
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Component PURLs to query (optional filter).
/// </summary>
public IReadOnlyList<string>? ComponentPurls { get; init; }
/// <summary>
/// Advisory IDs to query (optional filter).
/// </summary>
public IReadOnlyList<string>? AdvisoryIds { get; init; }
/// <summary>
/// Filter by reachability states (optional).
/// </summary>
public IReadOnlyList<ReachabilityState>? States { get; init; }
/// <summary>
/// Minimum confidence threshold (optional).
/// </summary>
public decimal? MinConfidence { get; init; }
/// <summary>
/// Include expired facts (default: false).
/// </summary>
public bool IncludeExpired { get; init; }
/// <summary>
/// Maximum number of results.
/// </summary>
public int Limit { get; init; } = 1000;
/// <summary>
/// Skip count for pagination.
/// </summary>
public int Skip { get; init; }
}
/// <summary>
/// Composite key for caching reachability facts.
/// </summary>
public readonly record struct ReachabilityFactKey(string TenantId, string ComponentPurl, string AdvisoryId)
{
/// <summary>
/// Creates a cache key string from this composite key.
/// </summary>
public string ToCacheKey() => $"rf:{TenantId}:{ComponentPurl}:{AdvisoryId}";
/// <summary>
/// Parses a cache key back into a composite key.
/// </summary>
public static ReachabilityFactKey? FromCacheKey(string key)
{
var parts = key.Split(':', 4);
if (parts.Length < 4 || parts[0] != "rf")
{
return null;
}
return new ReachabilityFactKey(parts[1], parts[2], parts[3]);
}
}
/// <summary>
/// Batch lookup result for reachability facts.
/// </summary>
public sealed record ReachabilityFactsBatch
{
/// <summary>
/// Facts that were found.
/// </summary>
public required IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> Found { get; init; }
/// <summary>
/// Keys that were not found.
/// </summary>
public required IReadOnlyList<ReachabilityFactKey> NotFound { get; init; }
/// <summary>
/// Number of cache hits.
/// </summary>
public int CacheHits { get; init; }
/// <summary>
/// Number of cache misses that required store lookup.
/// </summary>
public int CacheMisses { get; init; }
}

View File

@@ -0,0 +1,333 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// Interface for the reachability facts overlay cache.
/// Provides fast in-memory/Redis caching layer above the persistent store.
/// </summary>
public interface IReachabilityFactsOverlayCache
{
/// <summary>
/// Gets a reachability fact from the cache.
/// </summary>
Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(
ReachabilityFactKey key,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple reachability facts from the cache.
/// </summary>
Task<ReachabilityFactsBatch> GetBatchAsync(
IReadOnlyList<ReachabilityFactKey> keys,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets a reachability fact in the cache.
/// </summary>
Task SetAsync(
ReachabilityFactKey key,
ReachabilityFact fact,
CancellationToken cancellationToken = default);
/// <summary>
/// Sets multiple reachability facts in the cache.
/// </summary>
Task SetBatchAsync(
IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates a cache entry.
/// </summary>
Task InvalidateAsync(
ReachabilityFactKey key,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all cache entries for a tenant.
/// </summary>
Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics.
/// </summary>
ReachabilityFactsCacheStats GetStats();
}
/// <summary>
/// Cache statistics.
/// </summary>
public sealed record ReachabilityFactsCacheStats
{
public long TotalRequests { get; init; }
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public double HitRatio => TotalRequests > 0 ? (double)CacheHits / TotalRequests : 0;
public long ItemCount { get; init; }
public long EvictionCount { get; init; }
}
/// <summary>
/// In-memory implementation of the reachability facts overlay cache.
/// Uses a time-based eviction strategy with configurable TTL.
/// </summary>
public sealed class InMemoryReachabilityFactsOverlayCache : IReachabilityFactsOverlayCache
{
private readonly ConcurrentDictionary<string, CacheEntry> _cache;
private readonly TimeProvider _timeProvider;
private readonly ILogger<InMemoryReachabilityFactsOverlayCache> _logger;
private readonly TimeSpan _defaultTtl;
private readonly int _maxItems;
private long _totalRequests;
private long _cacheHits;
private long _cacheMisses;
private long _evictionCount;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public InMemoryReachabilityFactsOverlayCache(
ILogger<InMemoryReachabilityFactsOverlayCache> logger,
TimeProvider timeProvider,
IOptions<PolicyEngineOptions> options)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_cache = new ConcurrentDictionary<string, CacheEntry>(StringComparer.Ordinal);
var cacheOptions = options?.Value.ReachabilityCache ?? new ReachabilityFactsCacheOptions();
_defaultTtl = TimeSpan.FromMinutes(cacheOptions.DefaultTtlMinutes);
_maxItems = cacheOptions.MaxItems;
}
public Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(
ReachabilityFactKey key,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _totalRequests);
var cacheKey = key.ToCacheKey();
var now = _timeProvider.GetUtcNow();
if (_cache.TryGetValue(cacheKey, out var entry) && entry.ExpiresAt > now)
{
Interlocked.Increment(ref _cacheHits);
return Task.FromResult<(ReachabilityFact?, bool)>((entry.Fact, true));
}
Interlocked.Increment(ref _cacheMisses);
// Remove expired entry if present
if (entry != null)
{
_cache.TryRemove(cacheKey, out _);
}
return Task.FromResult<(ReachabilityFact?, bool)>((null, false));
}
public async Task<ReachabilityFactsBatch> GetBatchAsync(
IReadOnlyList<ReachabilityFactKey> keys,
CancellationToken cancellationToken = default)
{
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
var notFound = new List<ReachabilityFactKey>();
var cacheHits = 0;
var cacheMisses = 0;
foreach (var key in keys)
{
var (fact, hit) = await GetAsync(key, cancellationToken).ConfigureAwait(false);
if (fact != null)
{
found[key] = fact;
cacheHits++;
}
else
{
notFound.Add(key);
cacheMisses++;
}
}
return new ReachabilityFactsBatch
{
Found = found,
NotFound = notFound,
CacheHits = cacheHits,
CacheMisses = cacheMisses,
};
}
public Task SetAsync(
ReachabilityFactKey key,
ReachabilityFact fact,
CancellationToken cancellationToken = default)
{
EnsureCapacity();
var cacheKey = key.ToCacheKey();
var now = _timeProvider.GetUtcNow();
var ttl = fact.ExpiresAt.HasValue && fact.ExpiresAt.Value > now
? fact.ExpiresAt.Value - now
: _defaultTtl;
var entry = new CacheEntry(fact, now.Add(ttl));
_cache[cacheKey] = entry;
return Task.CompletedTask;
}
public Task SetBatchAsync(
IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts,
CancellationToken cancellationToken = default)
{
EnsureCapacity(facts.Count);
var now = _timeProvider.GetUtcNow();
foreach (var (key, fact) in facts)
{
var cacheKey = key.ToCacheKey();
var ttl = fact.ExpiresAt.HasValue && fact.ExpiresAt.Value > now
? fact.ExpiresAt.Value - now
: _defaultTtl;
var entry = new CacheEntry(fact, now.Add(ttl));
_cache[cacheKey] = entry;
}
return Task.CompletedTask;
}
public Task InvalidateAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
{
var cacheKey = key.ToCacheKey();
_cache.TryRemove(cacheKey, out _);
return Task.CompletedTask;
}
public Task InvalidateTenantAsync(string tenantId, CancellationToken cancellationToken = default)
{
var prefix = $"rf:{tenantId}:";
var keysToRemove = _cache.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList();
foreach (var key in keysToRemove)
{
_cache.TryRemove(key, out _);
}
_logger.LogDebug("Invalidated {Count} cache entries for tenant {TenantId}", keysToRemove.Count, tenantId);
return Task.CompletedTask;
}
public ReachabilityFactsCacheStats GetStats()
{
return new ReachabilityFactsCacheStats
{
TotalRequests = Interlocked.Read(ref _totalRequests),
CacheHits = Interlocked.Read(ref _cacheHits),
CacheMisses = Interlocked.Read(ref _cacheMisses),
ItemCount = _cache.Count,
EvictionCount = Interlocked.Read(ref _evictionCount),
};
}
private void EnsureCapacity(int additionalItems = 1)
{
if (_cache.Count + additionalItems <= _maxItems)
{
return;
}
var now = _timeProvider.GetUtcNow();
var itemsToRemove = _cache.Count + additionalItems - _maxItems + (_maxItems / 10); // Remove 10% extra
// First, remove expired items
var expiredKeys = _cache
.Where(kvp => kvp.Value.ExpiresAt <= now)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredKeys)
{
if (_cache.TryRemove(key, out _))
{
Interlocked.Increment(ref _evictionCount);
itemsToRemove--;
}
}
if (itemsToRemove <= 0)
{
return;
}
// Then, remove oldest items by expiration time
var oldestKeys = _cache
.OrderBy(kvp => kvp.Value.ExpiresAt)
.Take(itemsToRemove)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in oldestKeys)
{
if (_cache.TryRemove(key, out _))
{
Interlocked.Increment(ref _evictionCount);
}
}
_logger.LogDebug(
"Evicted {EvictedCount} cache entries (expired: {ExpiredCount}, oldest: {OldestCount})",
expiredKeys.Count + oldestKeys.Count,
expiredKeys.Count,
oldestKeys.Count);
}
private sealed record CacheEntry(ReachabilityFact Fact, DateTimeOffset ExpiresAt);
}
/// <summary>
/// Configuration options for the reachability facts cache.
/// </summary>
public sealed class ReachabilityFactsCacheOptions
{
/// <summary>
/// Default TTL for cache entries in minutes.
/// </summary>
public int DefaultTtlMinutes { get; set; } = 15;
/// <summary>
/// Maximum number of items in the cache.
/// </summary>
public int MaxItems { get; set; } = 100000;
/// <summary>
/// Whether to enable Redis as a distributed cache layer.
/// </summary>
public bool EnableRedis { get; set; }
/// <summary>
/// Redis connection string.
/// </summary>
public string? RedisConnectionString { get; set; }
/// <summary>
/// Redis key prefix for reachability facts.
/// </summary>
public string RedisKeyPrefix { get; set; } = "stellaops:rf:";
}

View File

@@ -0,0 +1,213 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// Store interface for reachability facts persistence.
/// </summary>
public interface IReachabilityFactsStore
{
/// <summary>
/// Gets a single reachability fact by key.
/// </summary>
Task<ReachabilityFact?> GetAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple reachability facts by keys.
/// </summary>
Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(
IReadOnlyList<ReachabilityFactKey> keys,
CancellationToken cancellationToken = default);
/// <summary>
/// Queries reachability facts with filtering.
/// </summary>
Task<IReadOnlyList<ReachabilityFact>> QueryAsync(
ReachabilityFactsQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Saves or updates a reachability fact.
/// </summary>
Task SaveAsync(
ReachabilityFact fact,
CancellationToken cancellationToken = default);
/// <summary>
/// Saves multiple reachability facts.
/// </summary>
Task SaveBatchAsync(
IReadOnlyList<ReachabilityFact> facts,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a reachability fact.
/// </summary>
Task DeleteAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the count of facts for a tenant.
/// </summary>
Task<long> CountAsync(
string tenantId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of the reachability facts store for development and testing.
/// </summary>
public sealed class InMemoryReachabilityFactsStore : IReachabilityFactsStore
{
private readonly ConcurrentDictionary<ReachabilityFactKey, ReachabilityFact> _facts = new();
private readonly TimeProvider _timeProvider;
public InMemoryReachabilityFactsStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<ReachabilityFact?> GetAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
_facts.TryGetValue(key, out var fact);
return Task.FromResult(fact);
}
public Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(
IReadOnlyList<ReachabilityFactKey> keys,
CancellationToken cancellationToken = default)
{
var result = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
foreach (var key in keys)
{
if (_facts.TryGetValue(key, out var fact))
{
result[key] = fact;
}
}
return Task.FromResult<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>>(result);
}
public Task<IReadOnlyList<ReachabilityFact>> QueryAsync(
ReachabilityFactsQuery query,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var results = _facts.Values
.Where(f => f.TenantId == query.TenantId)
.Where(f => query.ComponentPurls == null || query.ComponentPurls.Contains(f.ComponentPurl))
.Where(f => query.AdvisoryIds == null || query.AdvisoryIds.Contains(f.AdvisoryId))
.Where(f => query.States == null || query.States.Contains(f.State))
.Where(f => !query.MinConfidence.HasValue || f.Confidence >= query.MinConfidence.Value)
.Where(f => query.IncludeExpired || !f.ExpiresAt.HasValue || f.ExpiresAt > now)
.OrderByDescending(f => f.ComputedAt)
.Skip(query.Skip)
.Take(query.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<ReachabilityFact>>(results);
}
public Task SaveAsync(ReachabilityFact fact, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
_facts[key] = fact;
return Task.CompletedTask;
}
public Task SaveBatchAsync(IReadOnlyList<ReachabilityFact> facts, CancellationToken cancellationToken = default)
{
foreach (var fact in facts)
{
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
_facts[key] = fact;
}
return Task.CompletedTask;
}
public Task DeleteAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
_facts.TryRemove(key, out _);
return Task.CompletedTask;
}
public Task<long> CountAsync(string tenantId, CancellationToken cancellationToken = default)
{
var count = _facts.Values.Count(f => f.TenantId == tenantId);
return Task.FromResult((long)count);
}
}
/// <summary>
/// Index definitions for MongoDB reachability_facts collection.
/// </summary>
public static class ReachabilityFactsIndexes
{
/// <summary>
/// Primary compound index for efficient lookups.
/// </summary>
public const string PrimaryIndex = "tenant_component_advisory";
/// <summary>
/// Index for querying by tenant and state.
/// </summary>
public const string TenantStateIndex = "tenant_state_computed";
/// <summary>
/// Index for TTL expiration.
/// </summary>
public const string ExpirationIndex = "expires_at_ttl";
/// <summary>
/// Gets the index definitions for creating MongoDB indexes.
/// </summary>
public static IReadOnlyList<ReachabilityIndexDefinition> GetIndexDefinitions()
{
return new[]
{
new ReachabilityIndexDefinition(
PrimaryIndex,
new[] { "tenant_id", "component_purl", "advisory_id" },
Unique: true),
new ReachabilityIndexDefinition(
TenantStateIndex,
new[] { "tenant_id", "state", "computed_at" },
Unique: false),
new ReachabilityIndexDefinition(
ExpirationIndex,
new[] { "expires_at" },
Unique: false,
ExpireAfterSeconds: 0),
};
}
}
/// <summary>
/// Index definition for MongoDB collection.
/// </summary>
public sealed record ReachabilityIndexDefinition(
string Name,
IReadOnlyList<string> Fields,
bool Unique,
int? ExpireAfterSeconds = null);