Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Surfaces/SurfaceQueryService.cs
2026-02-01 21:37:40 +02:00

277 lines
9.7 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Implementation of the vulnerability surface query service.
/// Queries the database for pre-computed vulnerability surfaces.
/// </summary>
public sealed class SurfaceQueryService : ISurfaceQueryService
{
private readonly ISurfaceRepository _repository;
private readonly IMemoryCache _cache;
private readonly ILogger<SurfaceQueryService> _logger;
private readonly SurfaceQueryOptions _options;
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(15);
public SurfaceQueryService(
ISurfaceRepository repository,
IMemoryCache cache,
ILogger<SurfaceQueryService> 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();
}
/// <inheritdoc />
public async Task<SurfaceQueryResult> QueryAsync(
SurfaceQueryRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var cacheKey = $"surface:{request.QueryKey}";
// Check cache first
if (_options.EnableCaching && _cache.TryGetValue<SurfaceQueryResult>(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}");
}
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, SurfaceQueryResult>> QueryBulkAsync(
IEnumerable<SurfaceQueryRequest> requests,
CancellationToken cancellationToken = default)
{
var requestList = requests.ToList();
var results = new Dictionary<string, SurfaceQueryResult>(requestList.Count);
// Split into cached and uncached
var uncachedRequests = new List<SurfaceQueryRequest>();
foreach (var request in requestList)
{
var cacheKey = $"surface:{request.QueryKey}";
if (_options.EnableCaching && _cache.TryGetValue<SurfaceQueryResult>(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;
}
/// <inheritdoc />
public async Task<bool> 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<bool>(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);
}
}
/// <summary>
/// Options for surface query service.
/// </summary>
public sealed record SurfaceQueryOptions
{
/// <summary>
/// Whether to enable in-memory caching.
/// </summary>
public bool EnableCaching { get; init; } = true;
/// <summary>
/// Cache duration for surface results.
/// </summary>
public TimeSpan? CacheDuration { get; init; }
/// <summary>
/// Batch size for bulk queries.
/// </summary>
public int BulkQueryBatchSize { get; init; } = 10;
}
/// <summary>
/// Metrics for surface query service.
/// </summary>
internal static class SurfaceQueryMetrics
{
private static readonly string MeterName = "StellaOps.Scanner.Reachability.Surfaces";
public static readonly System.Diagnostics.Metrics.Counter<long> CacheHits =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.cache_hits",
description: "Number of surface query cache hits");
public static readonly System.Diagnostics.Metrics.Counter<long> CacheMisses =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.cache_misses",
description: "Number of surface query cache misses");
public static readonly System.Diagnostics.Metrics.Counter<long> SurfaceHits =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.surface_hits",
description: "Number of surfaces found");
public static readonly System.Diagnostics.Metrics.Counter<long> SurfaceMisses =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.surface_misses",
description: "Number of surfaces not found");
public static readonly System.Diagnostics.Metrics.Counter<long> QueryErrors =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.errors",
description: "Number of query errors");
public static readonly System.Diagnostics.Metrics.Histogram<long> QueryDurationMs =
new System.Diagnostics.Metrics.Meter(MeterName).CreateHistogram<long>(
"stellaops.surface_query.duration_ms",
unit: "ms",
description: "Surface query duration in milliseconds");
}