Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SurfaceQueryService.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-002, REACH-003, REACH-007)
|
||||
// Description: Implementation of vulnerability surface query service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user