save progress
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.BinaryIndex.Contracts.Resolution;
|
||||
using StellaOps.BinaryIndex.Core.Resolution;
|
||||
|
||||
@@ -13,13 +14,16 @@ namespace StellaOps.BinaryIndex.WebService.Controllers;
|
||||
public sealed class ResolutionController : ControllerBase
|
||||
{
|
||||
private readonly IResolutionService _resolutionService;
|
||||
private readonly ResolutionServiceOptions _resolutionOptions;
|
||||
private readonly ILogger<ResolutionController> _logger;
|
||||
|
||||
public ResolutionController(
|
||||
IResolutionService resolutionService,
|
||||
IOptions<ResolutionServiceOptions> resolutionOptions,
|
||||
ILogger<ResolutionController> logger)
|
||||
{
|
||||
_resolutionService = resolutionService ?? throw new ArgumentNullException(nameof(resolutionService));
|
||||
_resolutionOptions = resolutionOptions?.Value ?? throw new ArgumentNullException(nameof(resolutionOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -54,6 +58,7 @@ public sealed class ResolutionController : ControllerBase
|
||||
[ProducesResponseType<VulnResolutionResponse>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<VulnResolutionResponse>> ResolveVulnerabilityAsync(
|
||||
[FromBody] VulnResolutionRequest request,
|
||||
[FromQuery] bool bypassCache = false,
|
||||
@@ -61,12 +66,12 @@ public sealed class ResolutionController : ControllerBase
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest"));
|
||||
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest", StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Package))
|
||||
{
|
||||
return BadRequest(CreateProblem("Package identifier is required.", "MissingPackage"));
|
||||
return BadRequest(CreateProblem("Package identifier is required.", "MissingPackage", StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Resolving vulnerability for package {Package}, CVE: {CveId}",
|
||||
@@ -77,7 +82,7 @@ public sealed class ResolutionController : ControllerBase
|
||||
var options = new ResolutionOptions
|
||||
{
|
||||
BypassCache = bypassCache,
|
||||
IncludeDsseAttestation = true
|
||||
IncludeDsseAttestation = _resolutionOptions.EnableDsseByDefault
|
||||
};
|
||||
|
||||
var result = await _resolutionService.ResolveAsync(request, options, ct);
|
||||
@@ -86,7 +91,8 @@ public sealed class ResolutionController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve vulnerability for package {Package}", request.Package);
|
||||
return StatusCode(500, CreateProblem("Internal server error during resolution.", "ResolutionError"));
|
||||
return StatusCode(StatusCodes.Status500InternalServerError,
|
||||
CreateProblem("Internal server error during resolution.", "ResolutionError", StatusCodes.Status500InternalServerError));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,18 +125,19 @@ public sealed class ResolutionController : ControllerBase
|
||||
[HttpPost("vuln/batch")]
|
||||
[ProducesResponseType<BatchVulnResolutionResponse>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<BatchVulnResolutionResponse>> ResolveBatchAsync(
|
||||
[FromBody] BatchVulnResolutionRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest"));
|
||||
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest", StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
{
|
||||
return BadRequest(CreateProblem("At least one item is required.", "EmptyBatch"));
|
||||
return BadRequest(CreateProblem("At least one item is required.", "EmptyBatch", StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Processing batch resolution for {Count} items", request.Items.Count);
|
||||
@@ -140,7 +147,7 @@ public sealed class ResolutionController : ControllerBase
|
||||
var options = new ResolutionOptions
|
||||
{
|
||||
BypassCache = request.Options?.BypassCache ?? false,
|
||||
IncludeDsseAttestation = request.Options?.IncludeDsseAttestation ?? true
|
||||
IncludeDsseAttestation = request.Options?.IncludeDsseAttestation ?? _resolutionOptions.EnableDsseByDefault
|
||||
};
|
||||
|
||||
var result = await _resolutionService.ResolveBatchAsync(request, options, ct);
|
||||
@@ -149,28 +156,19 @@ public sealed class ResolutionController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process batch resolution");
|
||||
return StatusCode(500, CreateProblem("Internal server error during batch resolution.", "BatchResolutionError"));
|
||||
return StatusCode(StatusCodes.Status500InternalServerError,
|
||||
CreateProblem("Internal server error during batch resolution.", "BatchResolutionError", StatusCodes.Status500InternalServerError));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoint.
|
||||
/// </summary>
|
||||
[HttpGet("health")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Health()
|
||||
{
|
||||
return Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string detail, string type)
|
||||
private static ProblemDetails CreateProblem(string detail, string type, int statusCode)
|
||||
{
|
||||
return new ProblemDetails
|
||||
{
|
||||
Title = "Resolution Error",
|
||||
Detail = detail,
|
||||
Type = $"https://stellaops.dev/errors/{type}",
|
||||
Status = 400
|
||||
Status = statusCode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RateLimitingMiddleware.cs
|
||||
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
|
||||
// Task: T10 — Rate limiting for resolution API
|
||||
// Task: T10 - Rate limiting for resolution API
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
@@ -23,22 +23,32 @@ public sealed class RateLimitingMiddleware
|
||||
private readonly ILogger<RateLimitingMiddleware> _logger;
|
||||
private readonly RateLimitingOptions _options;
|
||||
private readonly ResolutionTelemetry? _telemetry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, SlidingWindowCounter> _counters = new();
|
||||
private long _requestCounter;
|
||||
|
||||
public RateLimitingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RateLimitingMiddleware> logger,
|
||||
IOptions<RateLimitingOptions> options,
|
||||
ResolutionTelemetry? telemetry = null)
|
||||
ResolutionTelemetry? telemetry = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_telemetry = telemetry;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only apply to resolution endpoints
|
||||
if (!context.Request.Path.StartsWithSegments("/api/v1/resolve"))
|
||||
{
|
||||
@@ -46,13 +56,15 @@ public sealed class RateLimitingMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantId = GetTenantId(context);
|
||||
var clientIp = GetClientIp(context);
|
||||
var rateLimitKey = $"{tenantId}:{clientIp}";
|
||||
|
||||
var counter = _counters.GetOrAdd(rateLimitKey, _ => new SlidingWindowCounter(_options.WindowSize));
|
||||
var counter = _counters.GetOrAdd(rateLimitKey, _ => new SlidingWindowCounter(_options.WindowSize, now));
|
||||
CleanupStaleCounters(now);
|
||||
|
||||
if (!counter.TryIncrement(_options.MaxRequests))
|
||||
if (!counter.TryIncrement(_options.MaxRequests, now))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rate limit exceeded for tenant {TenantId} from {ClientIp}",
|
||||
@@ -64,8 +76,7 @@ public sealed class RateLimitingMiddleware
|
||||
context.Response.Headers["Retry-After"] = _options.RetryAfterSeconds.ToString();
|
||||
context.Response.Headers["X-RateLimit-Limit"] = _options.MaxRequests.ToString();
|
||||
context.Response.Headers["X-RateLimit-Remaining"] = "0";
|
||||
context.Response.Headers["X-RateLimit-Reset"] = DateTimeOffset.UtcNow
|
||||
.AddSeconds(_options.RetryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||
context.Response.Headers["X-RateLimit-Reset"] = counter.GetWindowReset(now).ToUnixTimeSeconds().ToString();
|
||||
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
@@ -78,14 +89,35 @@ public sealed class RateLimitingMiddleware
|
||||
}
|
||||
|
||||
// Add rate limit headers
|
||||
var remaining = Math.Max(0, _options.MaxRequests - counter.Count);
|
||||
var remaining = Math.Max(0, _options.MaxRequests - counter.GetCount(now));
|
||||
context.Response.Headers["X-RateLimit-Limit"] = _options.MaxRequests.ToString();
|
||||
context.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
|
||||
context.Response.Headers["X-RateLimit-Reset"] = counter.WindowReset.ToUnixTimeSeconds().ToString();
|
||||
context.Response.Headers["X-RateLimit-Reset"] = counter.GetWindowReset(now).ToUnixTimeSeconds().ToString();
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private void CleanupStaleCounters(DateTimeOffset now)
|
||||
{
|
||||
if (_options.CleanupEveryNRequests <= 0 || _options.EvictionAfter <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.Increment(ref _requestCounter) % _options.CleanupEveryNRequests != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in _counters)
|
||||
{
|
||||
if (entry.Value.ShouldEvict(now, _options.EvictionAfter))
|
||||
{
|
||||
_counters.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
// Try to get tenant from header, claim, or default
|
||||
@@ -128,33 +160,39 @@ internal sealed class SlidingWindowCounter
|
||||
private readonly object _lock = new();
|
||||
private int _count;
|
||||
private DateTimeOffset _windowStart;
|
||||
private DateTimeOffset _lastSeen;
|
||||
|
||||
public SlidingWindowCounter(TimeSpan windowSize)
|
||||
public SlidingWindowCounter(TimeSpan windowSize, DateTimeOffset now)
|
||||
{
|
||||
_windowSize = windowSize;
|
||||
_windowStart = DateTimeOffset.UtcNow;
|
||||
_windowStart = now;
|
||||
_lastSeen = now;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ResetIfNeeded();
|
||||
return _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset WindowReset => _windowStart + _windowSize;
|
||||
|
||||
public bool TryIncrement(int maxRequests)
|
||||
public int GetCount(DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ResetIfNeeded();
|
||||
ResetIfNeeded(now);
|
||||
return _count;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset GetWindowReset(DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ResetIfNeeded(now);
|
||||
return _windowStart + _windowSize;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryIncrement(int maxRequests, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ResetIfNeeded(now);
|
||||
|
||||
if (_count >= maxRequests)
|
||||
{
|
||||
@@ -166,9 +204,17 @@ internal sealed class SlidingWindowCounter
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetIfNeeded()
|
||||
public bool ShouldEvict(DateTimeOffset now, TimeSpan evictionAfter)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
lock (_lock)
|
||||
{
|
||||
return now - _lastSeen >= evictionAfter;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetIfNeeded(DateTimeOffset now)
|
||||
{
|
||||
_lastSeen = now;
|
||||
if (now >= _windowStart + _windowSize)
|
||||
{
|
||||
_windowStart = now;
|
||||
@@ -193,6 +239,12 @@ public sealed class RateLimitingOptions
|
||||
|
||||
/// <summary>Enable rate limiting.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Evict counters after this period of inactivity.</summary>
|
||||
public TimeSpan EvictionAfter { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>Run cleanup every N requests.</summary>
|
||||
public int CleanupEveryNRequests { get; set; } = 250;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.BinaryIndex.Cache;
|
||||
using StellaOps.BinaryIndex.Core.Resolution;
|
||||
using StellaOps.BinaryIndex.VexBridge;
|
||||
using StellaOps.BinaryIndex.WebService.Middleware;
|
||||
using StellaOps.BinaryIndex.WebService.Services;
|
||||
using StellaOps.BinaryIndex.WebService.Telemetry;
|
||||
using StackExchange.Redis;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -13,8 +19,10 @@ builder.Services.AddSwaggerGen();
|
||||
// Configure options
|
||||
builder.Services.Configure<ResolutionServiceOptions>(
|
||||
builder.Configuration.GetSection(ResolutionServiceOptions.SectionName));
|
||||
builder.Services.Configure<ResolutionCacheOptions>(
|
||||
builder.Configuration.GetSection(ResolutionCacheOptions.SectionName));
|
||||
builder.Services.AddSingleton<IValidateOptions<ResolutionCacheOptions>, ResolutionCacheOptionsValidator>();
|
||||
builder.Services.AddOptions<ResolutionCacheOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ResolutionCacheOptions.SectionName))
|
||||
.ValidateOnStart();
|
||||
|
||||
// Add Redis/Valkey connection
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
|
||||
@@ -22,12 +30,29 @@ builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(redisConnectionString));
|
||||
|
||||
// Add services
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
builder.Services.TryAddSingleton<IRandomSource, SystemRandomSource>();
|
||||
builder.Services.AddSingleton<IResolutionCacheService, ResolutionCacheService>();
|
||||
builder.Services.AddScoped<IResolutionService, ResolutionService>();
|
||||
builder.Services.AddScoped<ResolutionService>();
|
||||
builder.Services.AddScoped<IResolutionService>(sp =>
|
||||
new CachedResolutionService(
|
||||
sp.GetRequiredService<ResolutionService>(),
|
||||
sp.GetRequiredService<IResolutionCacheService>(),
|
||||
sp.GetRequiredService<IOptions<ResolutionCacheOptions>>(),
|
||||
sp.GetRequiredService<IOptions<ResolutionServiceOptions>>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetRequiredService<ILogger<CachedResolutionService>>()));
|
||||
|
||||
// Add VexBridge
|
||||
builder.Services.AddBinaryVexBridge(builder.Configuration);
|
||||
|
||||
// Add telemetry
|
||||
builder.Services.AddResolutionTelemetry();
|
||||
|
||||
// Add rate limiting
|
||||
builder.Services.AddResolutionRateLimiting(options =>
|
||||
builder.Configuration.GetSection("RateLimiting").Bind(options));
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddRedis(redisConnectionString, name: "redis");
|
||||
@@ -42,6 +67,7 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseResolutionRateLimiting();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.BinaryIndex.Cache;
|
||||
using StellaOps.BinaryIndex.Contracts.Resolution;
|
||||
using StellaOps.BinaryIndex.Core.Resolution;
|
||||
|
||||
namespace StellaOps.BinaryIndex.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adds cache behavior to the core resolution service.
|
||||
/// </summary>
|
||||
public sealed class CachedResolutionService : IResolutionService
|
||||
{
|
||||
private readonly IResolutionService _inner;
|
||||
private readonly IResolutionCacheService _cache;
|
||||
private readonly ResolutionCacheOptions _cacheOptions;
|
||||
private readonly ResolutionServiceOptions _serviceOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CachedResolutionService> _logger;
|
||||
|
||||
public CachedResolutionService(
|
||||
IResolutionService inner,
|
||||
IResolutionCacheService cache,
|
||||
IOptions<ResolutionCacheOptions> cacheOptions,
|
||||
IOptions<ResolutionServiceOptions> serviceOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CachedResolutionService> logger)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_cacheOptions = cacheOptions?.Value ?? throw new ArgumentNullException(nameof(cacheOptions));
|
||||
_serviceOptions = serviceOptions?.Value ?? throw new ArgumentNullException(nameof(serviceOptions));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VulnResolutionResponse> ResolveAsync(
|
||||
VulnResolutionRequest request,
|
||||
ResolutionOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var effectiveOptions = options ?? new ResolutionOptions();
|
||||
if (!effectiveOptions.BypassCache)
|
||||
{
|
||||
var cacheKey = _cache.GenerateCacheKey(request);
|
||||
var cached = await _cache.GetAsync(cacheKey, ct).ConfigureAwait(false);
|
||||
if (cached is not null)
|
||||
{
|
||||
return FromCached(request, cached);
|
||||
}
|
||||
}
|
||||
|
||||
var response = await _inner.ResolveAsync(request, effectiveOptions, ct).ConfigureAwait(false);
|
||||
if (!effectiveOptions.BypassCache)
|
||||
{
|
||||
var cacheKey = _cache.GenerateCacheKey(request);
|
||||
var ttl = effectiveOptions.CacheTtl ?? GetCacheTtl(response.Status);
|
||||
await _cache.SetAsync(cacheKey, ToCached(response), ttl, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response with { FromCache = false };
|
||||
}
|
||||
|
||||
public async Task<BatchVulnResolutionResponse> ResolveBatchAsync(
|
||||
BatchVulnResolutionRequest request,
|
||||
ResolutionOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var effectiveOptions = options ?? new ResolutionOptions();
|
||||
|
||||
if (request.Options is not null)
|
||||
{
|
||||
effectiveOptions = effectiveOptions with
|
||||
{
|
||||
BypassCache = request.Options.BypassCache,
|
||||
IncludeDsseAttestation = request.Options.IncludeDsseAttestation
|
||||
};
|
||||
}
|
||||
|
||||
var items = request.Items;
|
||||
if (items.Count > _serviceOptions.MaxBatchSize)
|
||||
{
|
||||
_logger.LogWarning("Batch size {Count} exceeds maximum {Max}, truncating", items.Count, _serviceOptions.MaxBatchSize);
|
||||
items = items.Take(_serviceOptions.MaxBatchSize).ToList();
|
||||
}
|
||||
|
||||
var results = new List<VulnResolutionResponse>(items.Count);
|
||||
var cacheHits = 0;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!effectiveOptions.BypassCache)
|
||||
{
|
||||
var cacheKey = _cache.GenerateCacheKey(item);
|
||||
var cached = await _cache.GetAsync(cacheKey, ct).ConfigureAwait(false);
|
||||
if (cached is not null)
|
||||
{
|
||||
results.Add(FromCached(item, cached));
|
||||
cacheHits++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = await _inner.ResolveAsync(item, effectiveOptions, ct).ConfigureAwait(false);
|
||||
results.Add(result with { FromCache = false });
|
||||
|
||||
var ttl = effectiveOptions.CacheTtl ?? GetCacheTtl(result.Status);
|
||||
await _cache.SetAsync(cacheKey, ToCached(result), ttl, ct).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var uncached = await _inner.ResolveAsync(item, effectiveOptions, ct).ConfigureAwait(false);
|
||||
results.Add(uncached with { FromCache = false });
|
||||
}
|
||||
|
||||
return new BatchVulnResolutionResponse
|
||||
{
|
||||
Results = results,
|
||||
TotalCount = results.Count,
|
||||
CacheHits = cacheHits,
|
||||
ProcessingTimeMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
private VulnResolutionResponse FromCached(VulnResolutionRequest request, CachedResolution cached)
|
||||
{
|
||||
var evidence = BuildEvidence(cached);
|
||||
|
||||
return new VulnResolutionResponse
|
||||
{
|
||||
Package = request.Package,
|
||||
Status = cached.Status,
|
||||
FixedVersion = cached.FixedVersion,
|
||||
Evidence = evidence,
|
||||
ResolvedAt = cached.CachedAt,
|
||||
FromCache = true,
|
||||
CveId = request.CveId,
|
||||
AttestationDsse = null
|
||||
};
|
||||
}
|
||||
|
||||
private CachedResolution ToCached(VulnResolutionResponse response)
|
||||
{
|
||||
return new CachedResolution
|
||||
{
|
||||
Status = response.Status,
|
||||
FixedVersion = response.FixedVersion,
|
||||
EvidenceRef = null,
|
||||
CachedAt = _timeProvider.GetUtcNow(),
|
||||
VersionKey = null,
|
||||
Confidence = response.Evidence?.Confidence ?? 0m,
|
||||
MatchType = response.Evidence?.MatchType ?? ResolutionMatchTypes.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static ResolutionEvidence? BuildEvidence(CachedResolution cached)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cached.MatchType) && cached.Confidence <= 0m)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ResolutionEvidence
|
||||
{
|
||||
MatchType = string.IsNullOrWhiteSpace(cached.MatchType)
|
||||
? ResolutionMatchTypes.Unknown
|
||||
: cached.MatchType,
|
||||
Confidence = cached.Confidence
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan GetCacheTtl(ResolutionStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
ResolutionStatus.Fixed => _cacheOptions.FixedTtl,
|
||||
ResolutionStatus.NotAffected => _cacheOptions.FixedTtl,
|
||||
ResolutionStatus.Vulnerable => _cacheOptions.VulnerableTtl,
|
||||
_ => _cacheOptions.UnknownTtl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>BinaryIndex WebService - Resolution API for binary vulnerability lookup</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0129-M | DONE | Maintainability audit for StellaOps.BinaryIndex.WebService. |
|
||||
| AUDIT-0129-T | DONE | Test coverage audit for StellaOps.BinaryIndex.WebService. |
|
||||
| AUDIT-0129-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0129-A | DONE | Cache wiring, rate limiting, telemetry, TimeProvider, controller fixes, and tests applied. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ResolutionTelemetry.cs
|
||||
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
|
||||
// Task: T11 — Telemetry for resolution API
|
||||
// Task: T11 - Telemetry for resolution API
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
Reference in New Issue
Block a user