227 lines
6.8 KiB
C#
227 lines
6.8 KiB
C#
// -----------------------------------------------------------------------------
|
|
// CachingVexObservationProvider.cs
|
|
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
|
// Description: Caching wrapper for VEX observation provider with batch prefetch.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace StellaOps.Scanner.Gate;
|
|
|
|
/// <summary>
|
|
/// Caching wrapper for <see cref="IVexObservationProvider"/> that supports batch prefetch.
|
|
/// Implements short TTL bounded cache for gate throughput optimization.
|
|
/// </summary>
|
|
public sealed class CachingVexObservationProvider : IVexObservationBatchProvider, IDisposable
|
|
{
|
|
private readonly IVexObservationQuery _query;
|
|
private readonly string _tenantId;
|
|
private readonly MemoryCache _cache;
|
|
private readonly TimeSpan _cacheTtl;
|
|
private readonly ILogger<CachingVexObservationProvider> _logger;
|
|
private readonly SemaphoreSlim _prefetchLock = new(1, 1);
|
|
|
|
/// <summary>
|
|
/// Default cache size limit (number of entries).
|
|
/// </summary>
|
|
public const int DefaultCacheSizeLimit = 10_000;
|
|
|
|
/// <summary>
|
|
/// Default cache TTL.
|
|
/// </summary>
|
|
public static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromMinutes(5);
|
|
|
|
public CachingVexObservationProvider(
|
|
IVexObservationQuery query,
|
|
string tenantId,
|
|
ILogger<CachingVexObservationProvider> logger,
|
|
TimeSpan? cacheTtl = null,
|
|
int? cacheSizeLimit = null)
|
|
{
|
|
_query = query;
|
|
_tenantId = tenantId;
|
|
_logger = logger;
|
|
_cacheTtl = cacheTtl ?? DefaultCacheTtl;
|
|
|
|
_cache = new MemoryCache(new MemoryCacheOptions
|
|
{
|
|
SizeLimit = cacheSizeLimit ?? DefaultCacheSizeLimit,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<VexObservationResult?> GetVexStatusAsync(
|
|
string vulnerabilityId,
|
|
string purl,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var cacheKey = BuildCacheKey(vulnerabilityId, purl);
|
|
|
|
if (_cache.TryGetValue(cacheKey, out VexObservationResult? cached))
|
|
{
|
|
_logger.LogTrace("VEX cache hit: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
|
|
return cached;
|
|
}
|
|
|
|
_logger.LogTrace("VEX cache miss: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
|
|
|
|
var queryResult = await _query.GetEffectiveStatusAsync(
|
|
_tenantId,
|
|
vulnerabilityId,
|
|
purl,
|
|
cancellationToken);
|
|
|
|
if (queryResult is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var result = MapToObservationResult(queryResult);
|
|
CacheResult(cacheKey, result);
|
|
return result;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
|
|
string vulnerabilityId,
|
|
string purl,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var statements = await _query.GetStatementsAsync(
|
|
_tenantId,
|
|
vulnerabilityId,
|
|
purl,
|
|
cancellationToken);
|
|
|
|
return statements
|
|
.Select(s => new VexStatementInfo
|
|
{
|
|
StatementId = s.StatementId,
|
|
IssuerId = s.IssuerId,
|
|
Status = s.Status,
|
|
Timestamp = s.Timestamp,
|
|
TrustWeight = s.TrustWeight,
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task PrefetchAsync(
|
|
IReadOnlyList<VexLookupKey> keys,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (keys.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Deduplicate and find keys not in cache
|
|
var uncachedKeys = keys
|
|
.DistinctBy(k => BuildCacheKey(k.VulnerabilityId, k.Purl))
|
|
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.Purl), out _))
|
|
.Select(k => new VexQueryKey(k.VulnerabilityId, k.Purl))
|
|
.ToList();
|
|
|
|
if (uncachedKeys.Count == 0)
|
|
{
|
|
_logger.LogDebug("Prefetch: all {Count} keys already cached", keys.Count);
|
|
return;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Prefetch: fetching {UncachedCount} of {TotalCount} keys",
|
|
uncachedKeys.Count,
|
|
keys.Count);
|
|
|
|
await _prefetchLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
// Double-check after acquiring lock
|
|
uncachedKeys = uncachedKeys
|
|
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.ProductId), out _))
|
|
.ToList();
|
|
|
|
if (uncachedKeys.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var batchResults = await _query.BatchLookupAsync(
|
|
_tenantId,
|
|
uncachedKeys,
|
|
cancellationToken);
|
|
|
|
foreach (var (key, result) in batchResults)
|
|
{
|
|
var cacheKey = BuildCacheKey(key.VulnerabilityId, key.ProductId);
|
|
var observationResult = MapToObservationResult(result);
|
|
CacheResult(cacheKey, observationResult);
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Prefetch: cached {ResultCount} results",
|
|
batchResults.Count);
|
|
}
|
|
finally
|
|
{
|
|
_prefetchLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets cache statistics.
|
|
/// </summary>
|
|
public CacheStatistics GetStatistics() => new()
|
|
{
|
|
CurrentEntryCount = _cache.Count,
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
_cache.Dispose();
|
|
_prefetchLock.Dispose();
|
|
}
|
|
|
|
private static string BuildCacheKey(string vulnerabilityId, string productId) =>
|
|
string.Format(
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
"vex:{0}:{1}",
|
|
vulnerabilityId.ToUpperInvariant(),
|
|
productId.ToLowerInvariant());
|
|
|
|
private static VexObservationResult MapToObservationResult(VexObservationQueryResult queryResult) =>
|
|
new()
|
|
{
|
|
Status = queryResult.Status,
|
|
Justification = queryResult.Justification,
|
|
Confidence = queryResult.Confidence,
|
|
BackportHints = queryResult.BackportHints,
|
|
};
|
|
|
|
private void CacheResult(string cacheKey, VexObservationResult result)
|
|
{
|
|
var options = new MemoryCacheEntryOptions
|
|
{
|
|
Size = 1,
|
|
SlidingExpiration = _cacheTtl,
|
|
AbsoluteExpirationRelativeToNow = _cacheTtl * 2,
|
|
};
|
|
|
|
_cache.Set(cacheKey, result, options);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cache statistics for monitoring.
|
|
/// </summary>
|
|
public sealed record CacheStatistics
|
|
{
|
|
/// <summary>
|
|
/// Current number of entries in cache.
|
|
/// </summary>
|
|
public int CurrentEntryCount { get; init; }
|
|
}
|