277 lines
9.7 KiB
C#
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");
|
|
}
|