// ----------------------------------------------------------------------------- // SurfaceQueryService.cs // Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-002, REACH-003, REACH-007) // Description: Implementation of vulnerability surface query service. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Scanner.Reachability.Surfaces; /// /// Implementation of the vulnerability surface query service. /// Queries the database for pre-computed vulnerability surfaces. /// public sealed class SurfaceQueryService : ISurfaceQueryService { private readonly ISurfaceRepository _repository; private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly SurfaceQueryOptions _options; private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(15); public SurfaceQueryService( ISurfaceRepository repository, IMemoryCache cache, ILogger logger, SurfaceQueryOptions? options = null) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options ?? new SurfaceQueryOptions(); } /// public async Task QueryAsync( SurfaceQueryRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var cacheKey = $"surface:{request.QueryKey}"; // Check cache first if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out var cached)) { SurfaceQueryMetrics.CacheHits.Add(1); return cached!; } SurfaceQueryMetrics.CacheMisses.Add(1); var sw = Stopwatch.StartNew(); try { // Query repository var surface = await _repository.GetSurfaceAsync( request.CveId, request.Ecosystem, request.PackageName, request.Version, cancellationToken); SurfaceQueryResult result; if (surface is not null) { // Surface found - get triggers var triggers = await _repository.GetTriggersAsync( surface.Id, request.MaxTriggers, cancellationToken); var sinks = await _repository.GetSinksAsync(surface.Id, cancellationToken); result = SurfaceQueryResult.Found( surface.Id, triggers, sinks, surface.ComputedAt); SurfaceQueryMetrics.SurfaceHits.Add(1); _logger.LogDebug( "Surface found for {CveId}/{PackageName}: {TriggerCount} triggers, {SinkCount} sinks", request.CveId, request.PackageName, triggers.Count, sinks.Count); } else { // Surface not found - apply fallback cascade result = ApplyFallbackCascade(request); SurfaceQueryMetrics.SurfaceMisses.Add(1); } sw.Stop(); SurfaceQueryMetrics.QueryDurationMs.Record(sw.ElapsedMilliseconds); // Cache result if (_options.EnableCaching) { var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _options.CacheDuration ?? DefaultCacheDuration }; _cache.Set(cacheKey, result, cacheOptions); } return result; } catch (Exception ex) { sw.Stop(); SurfaceQueryMetrics.QueryErrors.Add(1); _logger.LogWarning(ex, "Failed to query surface for {CveId}/{PackageName}", request.CveId, request.PackageName); return SurfaceQueryResult.FallbackToPackageApi($"Query failed: {ex.Message}"); } } /// public async Task> QueryBulkAsync( IEnumerable requests, CancellationToken cancellationToken = default) { var requestList = requests.ToList(); var results = new Dictionary(requestList.Count); // Split into cached and uncached var uncachedRequests = new List(); foreach (var request in requestList) { var cacheKey = $"surface:{request.QueryKey}"; if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out var cached)) { results[request.QueryKey] = cached!; SurfaceQueryMetrics.CacheHits.Add(1); } else { uncachedRequests.Add(request); SurfaceQueryMetrics.CacheMisses.Add(1); } } // Query remaining in parallel batches if (uncachedRequests.Count > 0) { var batchSize = _options.BulkQueryBatchSize; var batches = uncachedRequests .Select((r, i) => new { Request = r, Index = i }) .GroupBy(x => x.Index / batchSize) .Select(g => g.Select(x => x.Request).ToList()); foreach (var batch in batches) { var tasks = batch.Select(r => QueryAsync(r, cancellationToken)); var batchResults = await Task.WhenAll(tasks); for (var i = 0; i < batch.Count; i++) { results[batch[i].QueryKey] = batchResults[i]; } } } return results; } /// public async Task ExistsAsync( string cveId, string ecosystem, string packageName, string version, CancellationToken cancellationToken = default) { var cacheKey = $"surface_exists:{cveId}|{ecosystem}|{packageName}|{version}"; if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out var exists)) { return exists; } var result = await _repository.ExistsAsync(cveId, ecosystem, packageName, version, cancellationToken); if (_options.EnableCaching) { _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5)); } return result; } private SurfaceQueryResult ApplyFallbackCascade(SurfaceQueryRequest request) { _logger.LogDebug( "No surface for {CveId}/{PackageName} v{Version}, applying fallback cascade", request.CveId, request.PackageName, request.Version); // Fallback cascade: // 1. If we have package API info, use that // 2. Otherwise, fall back to "all methods" mode // For now, return FallbackAll - in future we can add PackageApi lookup return SurfaceQueryResult.NotFound(request.CveId, request.PackageName); } } /// /// Options for surface query service. /// public sealed record SurfaceQueryOptions { /// /// Whether to enable in-memory caching. /// public bool EnableCaching { get; init; } = true; /// /// Cache duration for surface results. /// public TimeSpan? CacheDuration { get; init; } /// /// Batch size for bulk queries. /// public int BulkQueryBatchSize { get; init; } = 10; } /// /// Metrics for surface query service. /// internal static class SurfaceQueryMetrics { private static readonly string MeterName = "StellaOps.Scanner.Reachability.Surfaces"; public static readonly System.Diagnostics.Metrics.Counter CacheHits = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.surface_query.cache_hits", description: "Number of surface query cache hits"); public static readonly System.Diagnostics.Metrics.Counter CacheMisses = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.surface_query.cache_misses", description: "Number of surface query cache misses"); public static readonly System.Diagnostics.Metrics.Counter SurfaceHits = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.surface_query.surface_hits", description: "Number of surfaces found"); public static readonly System.Diagnostics.Metrics.Counter SurfaceMisses = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.surface_query.surface_misses", description: "Number of surfaces not found"); public static readonly System.Diagnostics.Metrics.Counter QueryErrors = new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter( "stellaops.surface_query.errors", description: "Number of query errors"); public static readonly System.Diagnostics.Metrics.Histogram QueryDurationMs = new System.Diagnostics.Metrics.Meter(MeterName).CreateHistogram( "stellaops.surface_query.duration_ms", unit: "ms", description: "Surface query duration in milliseconds"); }