Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,176 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.BinaryIndex.Contracts.Resolution;
using StellaOps.BinaryIndex.Core.Resolution;
namespace StellaOps.BinaryIndex.WebService.Controllers;
/// <summary>
/// API endpoints for binary vulnerability resolution.
/// </summary>
[ApiController]
[Route("api/v1/resolve")]
[Produces("application/json")]
public sealed class ResolutionController : ControllerBase
{
private readonly IResolutionService _resolutionService;
private readonly ILogger<ResolutionController> _logger;
public ResolutionController(
IResolutionService resolutionService,
ILogger<ResolutionController> logger)
{
_resolutionService = resolutionService ?? throw new ArgumentNullException(nameof(resolutionService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Resolve vulnerability status for a single binary.
/// </summary>
/// <remarks>
/// Accepts binary identity (Build-ID, hashes, fingerprint) and returns resolution status
/// with evidence. Results may be cached for performance.
///
/// Sample request:
///
/// POST /api/v1/resolve/vuln
/// {
/// "package": "pkg:deb/debian/openssl@3.0.7",
/// "build_id": "abc123def456789...",
/// "hashes": {
/// "file_sha256": "sha256:e3b0c44...",
/// "text_sha256": "sha256:abc123..."
/// },
/// "distro_release": "debian:bookworm"
/// }
/// </remarks>
/// <param name="request">Resolution request with binary identity.</param>
/// <param name="bypassCache">If true, skip cache and perform fresh lookup.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Resolution response with status and evidence.</returns>
/// <response code="200">Returns the resolution result.</response>
/// <response code="400">Invalid request parameters.</response>
/// <response code="404">Binary not found in index.</response>
[HttpPost("vuln")]
[ProducesResponseType<VulnResolutionResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VulnResolutionResponse>> ResolveVulnerabilityAsync(
[FromBody] VulnResolutionRequest request,
[FromQuery] bool bypassCache = false,
CancellationToken ct = default)
{
if (request is null)
{
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest"));
}
if (string.IsNullOrWhiteSpace(request.Package))
{
return BadRequest(CreateProblem("Package identifier is required.", "MissingPackage"));
}
_logger.LogInformation("Resolving vulnerability for package {Package}, CVE: {CveId}",
request.Package, request.CveId ?? "(all)");
try
{
var options = new ResolutionOptions
{
BypassCache = bypassCache,
IncludeDsseAttestation = true
};
var result = await _resolutionService.ResolveAsync(request, options, ct);
return Ok(result);
}
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"));
}
}
/// <summary>
/// Resolve vulnerability status for multiple binaries in batch.
/// </summary>
/// <remarks>
/// Processes multiple resolution requests in parallel for efficiency.
/// Maximum batch size is 500 items.
///
/// Sample request:
///
/// POST /api/v1/resolve/vuln/batch
/// {
/// "items": [
/// { "package": "pkg:deb/debian/openssl@3.0.7", "build_id": "..." },
/// { "package": "pkg:deb/debian/libcurl@7.88.1", "build_id": "..." }
/// ],
/// "options": {
/// "bypass_cache": false,
/// "include_dsse_attestation": true
/// }
/// }
/// </remarks>
/// <param name="request">Batch resolution request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Batch resolution response with all results.</returns>
/// <response code="200">Returns the batch resolution results.</response>
/// <response code="400">Invalid request parameters.</response>
[HttpPost("vuln/batch")]
[ProducesResponseType<BatchVulnResolutionResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BatchVulnResolutionResponse>> ResolveBatchAsync(
[FromBody] BatchVulnResolutionRequest request,
CancellationToken ct = default)
{
if (request is null)
{
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest"));
}
if (request.Items is null || request.Items.Count == 0)
{
return BadRequest(CreateProblem("At least one item is required.", "EmptyBatch"));
}
_logger.LogInformation("Processing batch resolution for {Count} items", request.Items.Count);
try
{
var options = new ResolutionOptions
{
BypassCache = request.Options?.BypassCache ?? false,
IncludeDsseAttestation = request.Options?.IncludeDsseAttestation ?? true
};
var result = await _resolutionService.ResolveBatchAsync(request, options, ct);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process batch resolution");
return StatusCode(500, CreateProblem("Internal server error during batch resolution.", "BatchResolutionError"));
}
}
/// <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)
{
return new ProblemDetails
{
Title = "Resolution Error",
Detail = detail,
Type = $"https://stellaops.dev/errors/{type}",
Status = 400
};
}
}

View File

@@ -0,0 +1,223 @@
// -----------------------------------------------------------------------------
// RateLimitingMiddleware.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T10 — Rate limiting for resolution API
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.WebService.Telemetry;
namespace StellaOps.BinaryIndex.WebService.Middleware;
/// <summary>
/// Rate limiting middleware for the resolution API.
/// Implements sliding window rate limiting per tenant.
/// </summary>
public sealed class RateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RateLimitingMiddleware> _logger;
private readonly RateLimitingOptions _options;
private readonly ResolutionTelemetry? _telemetry;
private readonly ConcurrentDictionary<string, SlidingWindowCounter> _counters = new();
public RateLimitingMiddleware(
RequestDelegate next,
ILogger<RateLimitingMiddleware> logger,
IOptions<RateLimitingOptions> options,
ResolutionTelemetry? telemetry = 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;
}
public async Task InvokeAsync(HttpContext context)
{
// Only apply to resolution endpoints
if (!context.Request.Path.StartsWithSegments("/api/v1/resolve"))
{
await _next(context);
return;
}
var tenantId = GetTenantId(context);
var clientIp = GetClientIp(context);
var rateLimitKey = $"{tenantId}:{clientIp}";
var counter = _counters.GetOrAdd(rateLimitKey, _ => new SlidingWindowCounter(_options.WindowSize));
if (!counter.TryIncrement(_options.MaxRequests))
{
_logger.LogWarning(
"Rate limit exceeded for tenant {TenantId} from {ClientIp}",
tenantId, clientIp);
_telemetry?.RecordRateLimited(tenantId);
context.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
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();
await context.Response.WriteAsJsonAsync(new
{
error = "rate_limit_exceeded",
message = $"Rate limit of {_options.MaxRequests} requests per {_options.WindowSize.TotalSeconds} seconds exceeded",
retry_after_seconds = _options.RetryAfterSeconds
});
return;
}
// Add rate limit headers
var remaining = Math.Max(0, _options.MaxRequests - counter.Count);
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();
await _next(context);
}
private static string GetTenantId(HttpContext context)
{
// Try to get tenant from header, claim, or default
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
{
return tenantHeader.ToString();
}
var claim = context.User?.FindFirst("tenant_id");
if (claim != null)
{
return claim.Value;
}
return "default";
}
private static string GetClientIp(HttpContext context)
{
// Check for forwarded headers first
if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwarded))
{
var ips = forwarded.ToString().Split(',');
if (ips.Length > 0)
{
return ips[0].Trim();
}
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
}
/// <summary>
/// Sliding window rate limit counter.
/// </summary>
internal sealed class SlidingWindowCounter
{
private readonly TimeSpan _windowSize;
private readonly object _lock = new();
private int _count;
private DateTimeOffset _windowStart;
public SlidingWindowCounter(TimeSpan windowSize)
{
_windowSize = windowSize;
_windowStart = DateTimeOffset.UtcNow;
_count = 0;
}
public int Count
{
get
{
lock (_lock)
{
ResetIfNeeded();
return _count;
}
}
}
public DateTimeOffset WindowReset => _windowStart + _windowSize;
public bool TryIncrement(int maxRequests)
{
lock (_lock)
{
ResetIfNeeded();
if (_count >= maxRequests)
{
return false;
}
_count++;
return true;
}
}
private void ResetIfNeeded()
{
var now = DateTimeOffset.UtcNow;
if (now >= _windowStart + _windowSize)
{
_windowStart = now;
_count = 0;
}
}
}
/// <summary>
/// Rate limiting configuration options.
/// </summary>
public sealed class RateLimitingOptions
{
/// <summary>Maximum requests per window.</summary>
public int MaxRequests { get; set; } = 100;
/// <summary>Sliding window size.</summary>
public TimeSpan WindowSize { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>Retry-After header value in seconds.</summary>
public int RetryAfterSeconds { get; set; } = 60;
/// <summary>Enable rate limiting.</summary>
public bool Enabled { get; set; } = true;
}
/// <summary>
/// Extension methods for rate limiting.
/// </summary>
public static class RateLimitingExtensions
{
public static IApplicationBuilder UseResolutionRateLimiting(this IApplicationBuilder app)
{
return app.UseMiddleware<RateLimitingMiddleware>();
}
public static IServiceCollection AddResolutionRateLimiting(
this IServiceCollection services,
Action<RateLimitingOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
else
{
services.Configure<RateLimitingOptions>(_ => { });
}
return services;
}
}

View File

@@ -0,0 +1,49 @@
using StellaOps.BinaryIndex.Cache;
using StellaOps.BinaryIndex.Core.Resolution;
using StellaOps.BinaryIndex.VexBridge;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Configure options
builder.Services.Configure<ResolutionServiceOptions>(
builder.Configuration.GetSection(ResolutionServiceOptions.SectionName));
builder.Services.Configure<ResolutionCacheOptions>(
builder.Configuration.GetSection(ResolutionCacheOptions.SectionName));
// Add Redis/Valkey connection
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(redisConnectionString));
// Add services
builder.Services.AddSingleton<IResolutionCacheService, ResolutionCacheService>();
builder.Services.AddScoped<IResolutionService, ResolutionService>();
// Add VexBridge
builder.Services.AddBinaryVexBridge(builder.Configuration);
// Add health checks
builder.Services.AddHealthChecks()
.AddRedis(redisConnectionString, name: "redis");
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.BinaryIndex.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:49948;http://localhost:49949"
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>BinaryIndex WebService - Resolution API for binary vulnerability lookup</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.Redis" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.Cache/StellaOps.BinaryIndex.Cache.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.Contracts/StellaOps.BinaryIndex.Contracts.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.VexBridge/StellaOps.BinaryIndex.VexBridge.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,218 @@
// -----------------------------------------------------------------------------
// ResolutionTelemetry.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T11 — Telemetry for resolution API
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.BinaryIndex.WebService.Telemetry;
/// <summary>
/// OpenTelemetry instrumentation for binary resolution API.
/// </summary>
public sealed class ResolutionTelemetry : IDisposable
{
public const string ServiceName = "StellaOps.BinaryIndex.Resolution";
public const string MeterName = "StellaOps.BinaryIndex.Resolution";
public const string ActivitySourceName = "StellaOps.BinaryIndex.Resolution";
private readonly Meter _meter;
// Counters
private readonly Counter<long> _requestsTotal;
private readonly Counter<long> _cacheHitsTotal;
private readonly Counter<long> _cacheMissesTotal;
private readonly Counter<long> _resolutionsTotal;
private readonly Counter<long> _errorsTotal;
private readonly Counter<long> _rateLimitedTotal;
// Histograms
private readonly Histogram<double> _requestDurationMs;
private readonly Histogram<double> _cacheLatencyMs;
private readonly Histogram<double> _fingerprintMatchDurationMs;
private readonly Histogram<int> _batchSize;
private readonly Histogram<double> _confidenceScore;
// Gauges
private readonly UpDownCounter<long> _requestsInProgress;
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
public ResolutionTelemetry(IMeterFactory? meterFactory = null)
{
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName);
_requestsTotal = _meter.CreateCounter<long>(
"binaryindex.resolution.requests.total",
unit: "{request}",
description: "Total resolution API requests");
_cacheHitsTotal = _meter.CreateCounter<long>(
"binaryindex.resolution.cache.hits.total",
unit: "{hit}",
description: "Total cache hits");
_cacheMissesTotal = _meter.CreateCounter<long>(
"binaryindex.resolution.cache.misses.total",
unit: "{miss}",
description: "Total cache misses");
_resolutionsTotal = _meter.CreateCounter<long>(
"binaryindex.resolution.resolutions.total",
unit: "{resolution}",
description: "Total successful resolutions");
_errorsTotal = _meter.CreateCounter<long>(
"binaryindex.resolution.errors.total",
unit: "{error}",
description: "Total resolution errors");
_rateLimitedTotal = _meter.CreateCounter<long>(
"binaryindex.resolution.rate_limited.total",
unit: "{request}",
description: "Total rate-limited requests");
_requestDurationMs = _meter.CreateHistogram<double>(
"binaryindex.resolution.request.duration.ms",
unit: "ms",
description: "Request duration in milliseconds");
_cacheLatencyMs = _meter.CreateHistogram<double>(
"binaryindex.resolution.cache.latency.ms",
unit: "ms",
description: "Cache lookup latency in milliseconds");
_fingerprintMatchDurationMs = _meter.CreateHistogram<double>(
"binaryindex.resolution.fingerprint_match.duration.ms",
unit: "ms",
description: "Fingerprint matching duration in milliseconds");
_batchSize = _meter.CreateHistogram<int>(
"binaryindex.resolution.batch.size",
unit: "{item}",
description: "Batch request size");
_confidenceScore = _meter.CreateHistogram<double>(
"binaryindex.resolution.confidence",
unit: "1",
description: "Resolution confidence score distribution");
_requestsInProgress = _meter.CreateUpDownCounter<long>(
"binaryindex.resolution.requests.in_progress",
unit: "{request}",
description: "Requests currently in progress");
}
public void RecordRequest(string method, string status, TimeSpan duration, bool cacheHit)
{
var tags = new TagList
{
{ ResolutionTelemetryTags.Method, method },
{ ResolutionTelemetryTags.Status, status },
{ ResolutionTelemetryTags.CacheHit, cacheHit.ToString().ToLowerInvariant() }
};
_requestsTotal.Add(1, tags);
_requestDurationMs.Record(duration.TotalMilliseconds, tags);
if (cacheHit)
_cacheHitsTotal.Add(1, tags);
else
_cacheMissesTotal.Add(1, tags);
}
public void RecordResolution(string matchType, string resolutionStatus, decimal confidence)
{
var tags = new TagList
{
{ ResolutionTelemetryTags.MatchType, matchType },
{ ResolutionTelemetryTags.ResolutionStatus, resolutionStatus }
};
_resolutionsTotal.Add(1, tags);
_confidenceScore.Record((double)confidence, tags);
}
public void RecordError(string errorCode, string method)
{
_errorsTotal.Add(1, new TagList
{
{ ResolutionTelemetryTags.ErrorCode, errorCode },
{ ResolutionTelemetryTags.Method, method }
});
}
public void RecordRateLimited(string tenantId)
{
_rateLimitedTotal.Add(1, new TagList
{
{ ResolutionTelemetryTags.TenantId, tenantId }
});
}
public void RecordBatchRequest(int size)
{
_batchSize.Record(size);
}
public void RecordCacheLatency(TimeSpan latency, bool hit)
{
_cacheLatencyMs.Record(latency.TotalMilliseconds, new TagList
{
{ ResolutionTelemetryTags.CacheHit, hit.ToString().ToLowerInvariant() }
});
}
public void RecordFingerprintMatchDuration(TimeSpan duration, string algorithm)
{
_fingerprintMatchDurationMs.Record(duration.TotalMilliseconds, new TagList
{
{ ResolutionTelemetryTags.Algorithm, algorithm }
});
}
public void IncrementInProgress() => _requestsInProgress.Add(1);
public void DecrementInProgress() => _requestsInProgress.Add(-1);
public static Activity? StartResolveActivity(string package, string? cveId)
{
var activity = ActivitySource.StartActivity("Resolution.Resolve");
activity?.SetTag("package", package);
if (cveId != null) activity?.SetTag("cve_id", cveId);
return activity;
}
public static Activity? StartBatchResolveActivity(int count)
{
var activity = ActivitySource.StartActivity("Resolution.ResolveBatch");
activity?.SetTag("batch_size", count);
return activity;
}
public void Dispose() => _meter.Dispose();
}
public static class ResolutionTelemetryTags
{
public const string Method = "method";
public const string Status = "status";
public const string CacheHit = "cache_hit";
public const string MatchType = "match_type";
public const string ResolutionStatus = "resolution_status";
public const string ErrorCode = "error_code";
public const string TenantId = "tenant_id";
public const string Algorithm = "algorithm";
}
public static class ResolutionTelemetryExtensions
{
public static IServiceCollection AddResolutionTelemetry(this IServiceCollection services)
{
services.TryAddSingleton<ResolutionTelemetry>();
return services;
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}

View File

@@ -0,0 +1,33 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Redis": "localhost:6379"
},
"Resolution": {
"DefaultCacheTtl": "04:00:00",
"MaxBatchSize": 500,
"EnableDsseByDefault": true,
"MinConfidenceThreshold": 0.70
},
"ResolutionCache": {
"FixedTtl": "24:00:00",
"VulnerableTtl": "04:00:00",
"UnknownTtl": "01:00:00",
"KeyPrefix": "resolution",
"EnableEarlyExpiry": true,
"EarlyExpiryFactor": 0.1
},
"VexBridge": {
"SignWithDsse": true,
"DefaultProviderId": "stellaops.binaryindex",
"DefaultStreamId": "binary_resolution",
"MinConfidenceThreshold": 0.70,
"MaxBatchSize": 1000
}
}