save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -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
};
}
}

View File

@@ -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>

View File

@@ -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");

View File

@@ -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
};
}
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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;