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

View File

@@ -0,0 +1,308 @@
// -----------------------------------------------------------------------------
// ReproducibleBuildJob.cs
// Sprint: SPRINT_1227_0002_0001_LB_reproducible_builders
// Task: T10 — Implement ReproducibleBuildJob
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Builders;
using StellaOps.BinaryIndex.Persistence.Repositories;
namespace StellaOps.BinaryIndex.Worker.Jobs;
/// <summary>
/// Background job that orchestrates reproducible builds for binary CVE attribution.
/// Monitors advisory feeds, triggers builds, extracts fingerprints, and creates claims.
/// </summary>
public sealed class ReproducibleBuildJob : IReproducibleBuildJob
{
private readonly ILogger<ReproducibleBuildJob> _logger;
private readonly ReproducibleBuildOptions _options;
private readonly IEnumerable<IReproducibleBuilder> _builders;
private readonly IFunctionFingerprintExtractor _fingerprintExtractor;
private readonly IPatchDiffEngine _diffEngine;
private readonly IFingerprintClaimRepository _claimRepository;
private readonly IAdvisoryFeedMonitor _advisoryMonitor;
public ReproducibleBuildJob(
ILogger<ReproducibleBuildJob> logger,
IOptions<ReproducibleBuildOptions> options,
IEnumerable<IReproducibleBuilder> builders,
IFunctionFingerprintExtractor fingerprintExtractor,
IPatchDiffEngine diffEngine,
IFingerprintClaimRepository claimRepository,
IAdvisoryFeedMonitor advisoryMonitor)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_builders = builders ?? throw new ArgumentNullException(nameof(builders));
_fingerprintExtractor = fingerprintExtractor ?? throw new ArgumentNullException(nameof(fingerprintExtractor));
_diffEngine = diffEngine ?? throw new ArgumentNullException(nameof(diffEngine));
_claimRepository = claimRepository ?? throw new ArgumentNullException(nameof(claimRepository));
_advisoryMonitor = advisoryMonitor ?? throw new ArgumentNullException(nameof(advisoryMonitor));
}
/// <inheritdoc />
public async Task ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("Starting reproducible build job");
try
{
// Step 1: Get pending CVEs that need binary attribution
var pendingCves = await _advisoryMonitor.GetPendingCvesAsync(ct);
_logger.LogInformation("Found {Count} CVEs pending binary attribution", pendingCves.Count);
foreach (var cve in pendingCves)
{
if (ct.IsCancellationRequested) break;
try
{
await ProcessCveAsync(cve, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process CVE {CveId}", cve.CveId);
// Continue with next CVE
}
}
_logger.LogInformation("Reproducible build job completed");
}
catch (OperationCanceledException)
{
_logger.LogInformation("Reproducible build job cancelled");
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Reproducible build job failed");
throw;
}
}
/// <inheritdoc />
public async Task ProcessCveAsync(CveAttribution cve, CancellationToken ct)
{
_logger.LogDebug("Processing CVE {CveId} for package {Package}", cve.CveId, cve.SourcePackage);
var stopwatch = Stopwatch.StartNew();
// Find appropriate builder for distro
var builder = _builders.FirstOrDefault(b =>
b.Distro.Equals(cve.Distro, StringComparison.OrdinalIgnoreCase));
if (builder == null)
{
_logger.LogWarning("No builder available for distro {Distro}", cve.Distro);
return;
}
// Build vulnerable version
var vulnerableBuild = await BuildVersionAsync(builder, cve, cve.VulnerableVersion, ct);
if (!vulnerableBuild.Success)
{
_logger.LogWarning("Failed to build vulnerable version {Version}", cve.VulnerableVersion);
return;
}
// Build patched version
var patchedBuild = await BuildVersionAsync(builder, cve, cve.FixedVersion, ct);
if (!patchedBuild.Success)
{
_logger.LogWarning("Failed to build patched version {Version}", cve.FixedVersion);
return;
}
// Extract function fingerprints from both builds
var vulnerableFunctions = await ExtractFunctionsAsync(vulnerableBuild, ct);
var patchedFunctions = await ExtractFunctionsAsync(patchedBuild, ct);
// Compute diff to identify changed functions
var diff = _diffEngine.ComputeDiff(vulnerableFunctions, patchedFunctions);
_logger.LogDebug(
"CVE {CveId}: {Modified} modified, {Added} added, {Removed} removed functions",
cve.CveId, diff.ModifiedCount, diff.AddedCount, diff.RemovedCount);
// Create fingerprint claims
await CreateClaimsAsync(cve, diff, vulnerableBuild, patchedBuild, ct);
stopwatch.Stop();
_logger.LogInformation(
"Processed CVE {CveId} in {Duration}ms",
cve.CveId, stopwatch.ElapsedMilliseconds);
}
private async Task<BuildResult> BuildVersionAsync(
IReproducibleBuilder builder,
CveAttribution cve,
string version,
CancellationToken ct)
{
var request = new BuildRequest
{
SourcePackage = cve.SourcePackage,
Version = version,
Release = cve.Release,
Architecture = _options.DefaultArchitecture,
Options = new BuildOptions
{
Timeout = _options.BuildTimeout,
CacheIntermediates = true
}
};
return await builder.BuildAsync(request, ct);
}
private async Task<IReadOnlyList<FunctionFingerprint>> ExtractFunctionsAsync(
BuildResult build,
CancellationToken ct)
{
var allFunctions = new List<FunctionFingerprint>();
foreach (var binary in build.Binaries ?? [])
{
if (binary.Functions != null)
{
allFunctions.AddRange(binary.Functions);
}
else
{
// Extract if not already done during build
var functions = await _fingerprintExtractor.ExtractAsync(
binary.Path,
new ExtractionOptions
{
IncludeInternalFunctions = false,
IncludeCallGraph = true,
MinFunctionSize = _options.MinFunctionSize
},
ct);
allFunctions.AddRange(functions);
}
}
return allFunctions;
}
private async Task CreateClaimsAsync(
CveAttribution cve,
PatchDiffResult diff,
BuildResult vulnerableBuild,
BuildResult patchedBuild,
CancellationToken ct)
{
var claims = new List<FingerprintClaim>();
// Create "fixed" claims for patched binaries
foreach (var binary in patchedBuild.Binaries ?? [])
{
var changedFunctions = diff.Changes
.Where(c => c.Type is ChangeType.Modified or ChangeType.Added)
.Select(c => c.FunctionName)
.ToList();
var claim = new FingerprintClaim
{
Id = Guid.NewGuid(),
FingerprintId = Guid.Parse(binary.BuildId), // Assuming BuildId is GUID-like
CveId = cve.CveId,
Verdict = ClaimVerdict.Fixed,
Evidence = new FingerprintClaimEvidence
{
PatchCommit = cve.PatchCommit ?? "unknown",
ChangedFunctions = changedFunctions,
FunctionSimilarities = diff.Changes
.Where(c => c.SimilarityScore.HasValue)
.ToDictionary(c => c.FunctionName, c => c.SimilarityScore!.Value),
VulnerableBuildRef = vulnerableBuild.BuildLogRef,
PatchedBuildRef = patchedBuild.BuildLogRef
},
CreatedAt = DateTimeOffset.UtcNow
};
claims.Add(claim);
}
// Create "vulnerable" claims for vulnerable binaries
foreach (var binary in vulnerableBuild.Binaries ?? [])
{
var claim = new FingerprintClaim
{
Id = Guid.NewGuid(),
FingerprintId = Guid.Parse(binary.BuildId),
CveId = cve.CveId,
Verdict = ClaimVerdict.Vulnerable,
Evidence = new FingerprintClaimEvidence
{
PatchCommit = cve.PatchCommit ?? "unknown",
ChangedFunctions = diff.Changes
.Where(c => c.Type == ChangeType.Modified)
.Select(c => c.FunctionName)
.ToList(),
VulnerableBuildRef = vulnerableBuild.BuildLogRef
},
CreatedAt = DateTimeOffset.UtcNow
};
claims.Add(claim);
}
await _claimRepository.CreateClaimsBatchAsync(claims, ct);
_logger.LogDebug(
"Created {Count} fingerprint claims for CVE {CveId}",
claims.Count, cve.CveId);
}
}
/// <summary>
/// Interface for the reproducible build job.
/// </summary>
public interface IReproducibleBuildJob
{
Task ExecuteAsync(CancellationToken ct);
Task ProcessCveAsync(CveAttribution cve, CancellationToken ct);
}
/// <summary>
/// CVE attribution request.
/// </summary>
public sealed record CveAttribution
{
public required string CveId { get; init; }
public required string SourcePackage { get; init; }
public required string Distro { get; init; }
public required string Release { get; init; }
public required string VulnerableVersion { get; init; }
public required string FixedVersion { get; init; }
public string? PatchCommit { get; init; }
public string? AdvisoryId { get; init; }
}
/// <summary>
/// Advisory feed monitor interface.
/// </summary>
public interface IAdvisoryFeedMonitor
{
Task<IReadOnlyList<CveAttribution>> GetPendingCvesAsync(CancellationToken ct);
}
/// <summary>
/// Configuration options for reproducible builds.
/// </summary>
public sealed class ReproducibleBuildOptions
{
public TimeSpan BuildTimeout { get; set; } = TimeSpan.FromMinutes(30);
public string DefaultArchitecture { get; set; } = "amd64";
public int MinFunctionSize { get; set; } = 16;
public int MaxConcurrentBuilds { get; set; } = 2;
public string BuildCacheDirectory { get; set; } = "/var/cache/stellaops/builds";
}

View File

@@ -0,0 +1,407 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.WebService", "StellaOps.BinaryIndex.WebService", "{0651E003-B5C4-41FB-2D51-C9025EB2152D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aoc", "Aoc", "{03DFF14F-7321-1784-D4C7-4E99D4120F48}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BDD326D6-7616-84F0-B914-74743BFBA520}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Aoc", "StellaOps.Aoc", "{EC506DBE-AB6D-492E-786E-8B176021BF2E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Attestor", "Attestor", "{5AC09D9A-F2A5-9CFA-B3C5-8D25F257651C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope", "{018E0E11-1CCE-A2BE-641D-21EE14D2E90D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB67BDB9-D701-3AC9-9CDF-ECCDCCD8DB6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain", "StellaOps.Attestor.ProofChain", "{45F7FA87-7451-6970-7F6E-F8BAE45E081B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Concelier", "Concelier", "{157C3671-CA0B-69FA-A7C9-74A1FDA97B99}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{F39E09D6-BF93-B64A-CFE7-2BA92815C0FE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.RawModels", "StellaOps.Concelier.RawModels", "{1DCF4EBB-DBC4-752C-13D4-D1EECE4E8907}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceIntel", "StellaOps.Concelier.SourceIntel", "{F2B58F4E-6F28-A25F-5BFB-CDEBAD6B9A3E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Excititor", "Excititor", "{7D49FA52-6EA1-EAC8-4C5A-AC07188D6C57}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{C9CF27FC-12DB-954F-863C-576BA8E309A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core", "StellaOps.Excititor.Core", "{6DCAF6F3-717F-27A9-D96C-F2BFA5550347}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feedser", "Feedser", "{C4A90603-BE42-0044-CAB4-3EB910AD51A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.BinaryAnalysis", "StellaOps.Feedser.BinaryAnalysis", "{054761F9-16D3-B2F8-6F4D-EFC2248805CD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core", "{B54CE64C-4167-1DD1-B7D6-2FD7A5AEF715}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Policy", "Policy", "{8E6B774C-CC4E-CE7C-AD4B-8AF7C92889A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy.RiskProfile", "StellaOps.Policy.RiskProfile", "{BC12ED55-6015-7C8B-8384-B39CE93C76D6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{FF70543D-AFF9-1D38-4950-4F8EE18D60BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy", "StellaOps.Policy", "{831265B0-8896-9C95-3488-E12FD9F6DC53}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Builders", "StellaOps.BinaryIndex.Builders", "{A3C1DF43-1940-F369-4D23-C4B6CB25FFA1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Cache", "StellaOps.BinaryIndex.Cache", "{EA5E3081-4935-EC8B-298A-9CDF1EE7EE36}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Contracts", "StellaOps.BinaryIndex.Contracts", "{0500CA75-C1FA-0394-0C12-C5C46A63F568}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Core", "StellaOps.BinaryIndex.Core", "{0A6CDE23-D57C-0D87-D99E-3361D96FC499}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Corpus", "StellaOps.BinaryIndex.Corpus", "{F9E9E934-C2DB-412E-1812-08D56450A530}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Corpus.Alpine", "StellaOps.BinaryIndex.Corpus.Alpine", "{D040D651-39C6-DD25-690C-245AED59E0CE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Corpus.Debian", "StellaOps.BinaryIndex.Corpus.Debian", "{027F5493-80D1-110E-03E6-0985A15F4B99}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Corpus.Rpm", "StellaOps.BinaryIndex.Corpus.Rpm", "{CAE23DCF-4D87-A014-A8D0-A168A85C99B3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Fingerprints", "StellaOps.BinaryIndex.Fingerprints", "{8486EC38-2199-9AE0-04D5-FE0D3CE890C7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.FixIndex", "StellaOps.BinaryIndex.FixIndex", "{A531489D-F9C3-CA82-6C88-A5585EDB2312}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Persistence", "StellaOps.BinaryIndex.Persistence", "{2AFBC358-AC83-6F21-A155-C4050FCC9DEB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.VexBridge", "StellaOps.BinaryIndex.VexBridge", "{68FC6729-FC28-9BE7-FB2A-5539AFFC22B0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Builders.Tests", "StellaOps.BinaryIndex.Builders.Tests", "{E8791365-56CD-B5C6-7285-B65CE958285D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Core.Tests", "StellaOps.BinaryIndex.Core.Tests", "{CBC5B8ED-2330-CAAF-8CAE-FB1C02E8690A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Fingerprints.Tests", "StellaOps.BinaryIndex.Fingerprints.Tests", "{D5B62F36-A31C-9D58-E8F9-FBF52F1429F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.Persistence.Tests", "StellaOps.BinaryIndex.Persistence.Tests", "{D39864A9-7C81-C93E-4ECC-C07980683E94}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.BinaryIndex.VexBridge.Tests", "StellaOps.BinaryIndex.VexBridge.Tests", "{10F3BE3A-09E1-D3A2-55F5-6C070BBEFDB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Builders", "__Libraries\StellaOps.BinaryIndex.Builders\StellaOps.BinaryIndex.Builders.csproj", "{D12CE58E-A319-7F19-8DA5-1A97C0246BA7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Builders.Tests", "__Tests\StellaOps.BinaryIndex.Builders.Tests\StellaOps.BinaryIndex.Builders.Tests.csproj", "{7803D7FA-EFB1-54F6-D26E-1DB08FBEC585}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Cache", "__Libraries\StellaOps.BinaryIndex.Cache\StellaOps.BinaryIndex.Cache.csproj", "{2D04CD79-6D4A-0140-B98D-17926B8B7868}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Contracts", "__Libraries\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj", "{03DF5914-2390-A82D-7464-642D0B95E068}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Core", "__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj", "{CF633BDA-9F2E-D0C8-702F-BC9D27363B4B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Core.Tests", "__Tests\StellaOps.BinaryIndex.Core.Tests\StellaOps.BinaryIndex.Core.Tests.csproj", "{6D31ADAB-668F-1C1C-2618-A61B265F894B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus", "__Libraries\StellaOps.BinaryIndex.Corpus\StellaOps.BinaryIndex.Corpus.csproj", "{73DE9C04-CEFE-53BA-A527-3A36D478DEFE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Alpine", "__Libraries\StellaOps.BinaryIndex.Corpus.Alpine\StellaOps.BinaryIndex.Corpus.Alpine.csproj", "{ABF86F66-453C-6711-3D39-3E1C996BD136}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Debian", "__Libraries\StellaOps.BinaryIndex.Corpus.Debian\StellaOps.BinaryIndex.Corpus.Debian.csproj", "{793A41A8-86C1-651D-9232-224524CB024E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Rpm", "__Libraries\StellaOps.BinaryIndex.Corpus.Rpm\StellaOps.BinaryIndex.Corpus.Rpm.csproj", "{141F6265-CF90-013B-AF99-221D455C6027}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Fingerprints", "__Libraries\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj", "{B7DC1B0A-EBD8-B1E8-28C8-9D5F19E118AD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Fingerprints.Tests", "__Tests\StellaOps.BinaryIndex.Fingerprints.Tests\StellaOps.BinaryIndex.Fingerprints.Tests.csproj", "{927A55F8-387C-A29D-4BDE-BBC4280C0E40}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.FixIndex", "__Libraries\StellaOps.BinaryIndex.FixIndex\StellaOps.BinaryIndex.FixIndex.csproj", "{0B56708E-B56C-E058-DE31-FCDFF30031F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Persistence", "__Libraries\StellaOps.BinaryIndex.Persistence\StellaOps.BinaryIndex.Persistence.csproj", "{78FAD457-CE1B-D78E-A602-510EAD85E0AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Persistence.Tests", "__Tests\StellaOps.BinaryIndex.Persistence.Tests\StellaOps.BinaryIndex.Persistence.Tests.csproj", "{6B944AE9-6CDB-6DDC-79C0-3C8410C89D30}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.VexBridge", "__Libraries\StellaOps.BinaryIndex.VexBridge\StellaOps.BinaryIndex.VexBridge.csproj", "{5FCCA37E-43ED-201C-9209-04E3A9346E15}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.VexBridge.Tests", "__Tests\StellaOps.BinaryIndex.VexBridge.Tests\StellaOps.BinaryIndex.VexBridge.Tests.csproj", "{B8D56BF5-70E6-D8BC-E390-CFEE61909886}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.WebService", "StellaOps.BinaryIndex.WebService\StellaOps.BinaryIndex.WebService.csproj", "{395C0F94-0DF4-181B-8CE8-9FD103C27258}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.Build.0 = Debug|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.ActiveCfg = Release|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.Build.0 = Release|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.Build.0 = Release|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Release|Any CPU.Build.0 = Release|Any CPU
{D12CE58E-A319-7F19-8DA5-1A97C0246BA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D12CE58E-A319-7F19-8DA5-1A97C0246BA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D12CE58E-A319-7F19-8DA5-1A97C0246BA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D12CE58E-A319-7F19-8DA5-1A97C0246BA7}.Release|Any CPU.Build.0 = Release|Any CPU
{7803D7FA-EFB1-54F6-D26E-1DB08FBEC585}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7803D7FA-EFB1-54F6-D26E-1DB08FBEC585}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7803D7FA-EFB1-54F6-D26E-1DB08FBEC585}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7803D7FA-EFB1-54F6-D26E-1DB08FBEC585}.Release|Any CPU.Build.0 = Release|Any CPU
{2D04CD79-6D4A-0140-B98D-17926B8B7868}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D04CD79-6D4A-0140-B98D-17926B8B7868}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D04CD79-6D4A-0140-B98D-17926B8B7868}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D04CD79-6D4A-0140-B98D-17926B8B7868}.Release|Any CPU.Build.0 = Release|Any CPU
{03DF5914-2390-A82D-7464-642D0B95E068}.Debug|Any CPU.ActiveCfg = Debug|Any CPU

View File

@@ -0,0 +1,175 @@
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Configuration options for the reproducible builder infrastructure.
/// </summary>
public sealed class BuilderServiceOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "BinaryIndex:Builders";
/// <summary>
/// Base path for builder Docker images.
/// </summary>
public string BuilderImageRegistry { get; set; } = "ghcr.io/stella-ops";
/// <summary>
/// Path to store build artifacts temporarily.
/// </summary>
public string ArtifactPath { get; set; } = "/tmp/binaryindex-builds";
/// <summary>
/// Path to store build logs.
/// </summary>
public string LogPath { get; set; } = "/tmp/binaryindex-build-logs";
/// <summary>
/// Default build timeout.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Maximum concurrent builds.
/// </summary>
public int MaxConcurrentBuilds { get; set; } = 4;
/// <summary>
/// Whether to keep failed build artifacts for debugging.
/// </summary>
public bool KeepFailedArtifacts { get; set; } = true;
/// <summary>
/// Cleanup interval for old artifacts.
/// </summary>
public TimeSpan ArtifactCleanupInterval { get; set; } = TimeSpan.FromHours(6);
/// <summary>
/// Maximum age for artifacts before cleanup.
/// </summary>
public TimeSpan ArtifactMaxAge { get; set; } = TimeSpan.FromDays(1);
/// <summary>
/// Docker socket path for container builds.
/// </summary>
public string DockerSocketPath { get; set; } = "/var/run/docker.sock";
/// <summary>
/// Whether to use podman instead of docker.
/// </summary>
public bool UsePodman { get; set; } = false;
/// <summary>
/// Distro-specific configuration.
/// </summary>
public DistroBuilderOptions Alpine { get; set; } = new() { Enabled = true, Distro = "alpine" };
/// <summary>
/// Debian builder configuration.
/// </summary>
public DistroBuilderOptions Debian { get; set; } = new() { Enabled = true, Distro = "debian" };
/// <summary>
/// RHEL/CentOS builder configuration.
/// </summary>
public DistroBuilderOptions Rhel { get; set; } = new() { Enabled = true, Distro = "rhel" };
}
/// <summary>
/// Configuration for a specific distro builder.
/// </summary>
public sealed class DistroBuilderOptions
{
/// <summary>
/// Distro identifier.
/// </summary>
public string Distro { get; set; } = string.Empty;
/// <summary>
/// Whether this builder is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Supported releases for this distro.
/// </summary>
public List<string> SupportedReleases { get; set; } = new();
/// <summary>
/// Docker image template. Use {release} placeholder.
/// </summary>
public string ImageTemplate { get; set; } = "repro-builder-{distro}:{release}";
/// <summary>
/// Custom environment variables for builds.
/// </summary>
public Dictionary<string, string> EnvironmentVariables { get; set; } = new();
/// <summary>
/// Custom build flags to add.
/// </summary>
public List<string> ExtraCFlags { get; set; } = new();
/// <summary>
/// Timeout override for this distro.
/// </summary>
public TimeSpan? Timeout { get; set; }
}
/// <summary>
/// Options for function fingerprint extraction.
/// </summary>
public sealed class FunctionExtractionOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "BinaryIndex:FunctionExtraction";
/// <summary>
/// Minimum function size to extract.
/// </summary>
public int MinFunctionSize { get; set; } = 16;
/// <summary>
/// Maximum function size to extract. 0 = unlimited.
/// </summary>
public int MaxFunctionSize { get; set; } = 0;
/// <summary>
/// Whether to include internal (non-exported) functions.
/// </summary>
public bool IncludeInternalFunctions { get; set; } = false;
/// <summary>
/// Whether to build call graphs.
/// </summary>
public bool BuildCallGraph { get; set; } = true;
/// <summary>
/// Patterns to exclude from extraction (regex).
/// </summary>
public List<string> ExcludePatterns { get; set; } = new()
{
"^__.*", // Compiler-generated
"^_GLOBAL_.*", // Global constructors
"^.plt.*", // PLT stubs
"^.text.*" // Section markers
};
/// <summary>
/// Path to objdump binary.
/// </summary>
public string ObjdumpPath { get; set; } = "objdump";
/// <summary>
/// Path to nm binary.
/// </summary>
public string NmPath { get; set; } = "nm";
/// <summary>
/// Path to readelf binary.
/// </summary>
public string ReadelfPath { get; set; } = "readelf";
}

View File

@@ -0,0 +1,304 @@
using System.Text.Json.Serialization;
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// A claim asserting a CVE verdict for a specific fingerprint.
/// Created when reproducible builds show a function was modified to fix a CVE.
/// </summary>
public sealed record FingerprintClaim
{
/// <summary>
/// Unique identifier for this claim.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// ID of the fingerprint this claim is about.
/// </summary>
public required Guid FingerprintId { get; init; }
/// <summary>
/// CVE identifier (e.g., "CVE-2023-12345").
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Verdict: whether this fingerprint is fixed, vulnerable, or unknown.
/// </summary>
public required ClaimVerdict Verdict { get; init; }
/// <summary>
/// Evidence supporting this claim.
/// </summary>
public required FingerprintClaimEvidence Evidence { get; init; }
/// <summary>
/// Hash of the DSSE attestation if signed.
/// </summary>
public string? AttestationDsseHash { get; init; }
/// <summary>
/// When this claim was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// When this claim was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// Source that generated this claim (e.g., "repro-builder-alpine").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// Confidence in this claim (0.0-1.0).
/// </summary>
public decimal Confidence { get; init; } = 1.0m;
}
/// <summary>
/// Verdict for a fingerprint claim.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ClaimVerdict
{
/// <summary>
/// The fingerprint is from a binary that contains the CVE fix.
/// </summary>
Fixed,
/// <summary>
/// The fingerprint is from a binary that is vulnerable to the CVE.
/// </summary>
Vulnerable,
/// <summary>
/// Unable to determine fix status.
/// </summary>
Unknown
}
/// <summary>
/// Evidence supporting a fingerprint claim.
/// </summary>
public sealed record FingerprintClaimEvidence
{
/// <summary>
/// Git commit or patch reference that introduced the fix.
/// </summary>
public required string PatchCommit { get; init; }
/// <summary>
/// List of function names that changed between vulnerable and fixed versions.
/// </summary>
public required IReadOnlyList<string> ChangedFunctions { get; init; }
/// <summary>
/// Similarity scores for modified functions (function name → score).
/// </summary>
public IReadOnlyDictionary<string, decimal>? FunctionSimilarities { get; init; }
/// <summary>
/// Reference to the vulnerable build artifacts.
/// </summary>
public string? VulnerableBuildRef { get; init; }
/// <summary>
/// Reference to the patched build artifacts.
/// </summary>
public string? PatchedBuildRef { get; init; }
/// <summary>
/// Source package name.
/// </summary>
public string? SourcePackage { get; init; }
/// <summary>
/// Vulnerable version string.
/// </summary>
public string? VulnerableVersion { get; init; }
/// <summary>
/// Patched version string.
/// </summary>
public string? PatchedVersion { get; init; }
/// <summary>
/// Distro and release this build was done for.
/// </summary>
public string? DistroRelease { get; init; }
/// <summary>
/// Builder image used for reproducible builds.
/// </summary>
public string? BuilderImage { get; init; }
/// <summary>
/// Timestamp of the vulnerable build.
/// </summary>
public DateTimeOffset? VulnerableBuildTimestamp { get; init; }
/// <summary>
/// Timestamp of the patched build.
/// </summary>
public DateTimeOffset? PatchedBuildTimestamp { get; init; }
/// <summary>
/// Diff statistics summary.
/// </summary>
public DiffStatistics? DiffStatistics { get; init; }
}
/// <summary>
/// Repository for managing fingerprint claims.
/// </summary>
public interface IFingerprintClaimRepository
{
/// <summary>
/// Creates a new fingerprint claim.
/// </summary>
/// <param name="claim">The claim to create.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created claim ID.</returns>
Task<Guid> CreateClaimAsync(FingerprintClaim claim, CancellationToken ct = default);
/// <summary>
/// Creates multiple claims in a batch.
/// </summary>
/// <param name="claims">Claims to create.</param>
/// <param name="ct">Cancellation token.</param>
Task CreateClaimsBatchAsync(IEnumerable<FingerprintClaim> claims, CancellationToken ct = default);
/// <summary>
/// Gets a claim by ID.
/// </summary>
/// <param name="id">Claim ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The claim if found.</returns>
Task<FingerprintClaim?> GetClaimByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Gets all claims for a specific fingerprint.
/// </summary>
/// <param name="fingerprintId">Fingerprint ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claims for the fingerprint.</returns>
Task<IReadOnlyList<FingerprintClaim>> GetClaimsByFingerprintAsync(
Guid fingerprintId,
CancellationToken ct = default);
/// <summary>
/// Gets all claims for a specific fingerprint hash.
/// </summary>
/// <param name="fingerprintHash">Fingerprint hash (hex-encoded).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claims for the fingerprint.</returns>
Task<IReadOnlyList<FingerprintClaim>> GetClaimsByFingerprintHashAsync(
string fingerprintHash,
CancellationToken ct = default);
/// <summary>
/// Gets all claims for a specific CVE.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claims for the CVE.</returns>
Task<IReadOnlyList<FingerprintClaim>> GetClaimsByCveAsync(
string cveId,
CancellationToken ct = default);
/// <summary>
/// Gets claims with a specific verdict.
/// </summary>
/// <param name="verdict">Verdict to filter by.</param>
/// <param name="limit">Maximum results to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claims with the verdict.</returns>
Task<IReadOnlyList<FingerprintClaim>> GetClaimsByVerdictAsync(
ClaimVerdict verdict,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Updates an existing claim.
/// </summary>
/// <param name="claim">The updated claim.</param>
/// <param name="ct">Cancellation token.</param>
Task UpdateClaimAsync(FingerprintClaim claim, CancellationToken ct = default);
/// <summary>
/// Deletes a claim by ID.
/// </summary>
/// <param name="id">Claim ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteClaimAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Checks if a claim already exists for a fingerprint+CVE combination.
/// </summary>
/// <param name="fingerprintId">Fingerprint ID.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if a claim exists.</returns>
Task<bool> ClaimExistsAsync(Guid fingerprintId, string cveId, CancellationToken ct = default);
}
/// <summary>
/// Repository for managing function fingerprints (per-binary breakdown).
/// </summary>
public interface IFunctionFingerprintRepository
{
/// <summary>
/// Stores function fingerprints for a binary.
/// </summary>
/// <param name="binaryFingerprintId">Parent binary fingerprint ID.</param>
/// <param name="functions">Function fingerprints to store.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreFunctionsAsync(
Guid binaryFingerprintId,
IEnumerable<FunctionFingerprint> functions,
CancellationToken ct = default);
/// <summary>
/// Gets all function fingerprints for a binary.
/// </summary>
/// <param name="binaryFingerprintId">Parent binary fingerprint ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of function fingerprints.</returns>
Task<IReadOnlyList<FunctionFingerprint>> GetFunctionsByBinaryAsync(
Guid binaryFingerprintId,
CancellationToken ct = default);
/// <summary>
/// Searches for functions by name pattern.
/// </summary>
/// <param name="namePattern">Function name pattern (SQL LIKE).</param>
/// <param name="limit">Maximum results.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching functions with their binary IDs.</returns>
Task<IReadOnlyList<(Guid BinaryId, FunctionFingerprint Function)>> SearchFunctionsByNameAsync(
string namePattern,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Finds functions matching a specific basic block hash.
/// </summary>
/// <param name="basicBlockHash">Hash to search for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching functions with their binary IDs.</returns>
Task<IReadOnlyList<(Guid BinaryId, FunctionFingerprint Function)>> FindByBasicBlockHashAsync(
byte[] basicBlockHash,
CancellationToken ct = default);
/// <summary>
/// Deletes all function fingerprints for a binary.
/// </summary>
/// <param name="binaryFingerprintId">Parent binary fingerprint ID.</param>
/// <param name="ct">Cancellation token.</param>
Task DeleteFunctionsByBinaryAsync(Guid binaryFingerprintId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,220 @@
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Extracts function-level fingerprints from binary files.
/// Uses multiple hashing strategies for robust matching.
/// </summary>
public interface IFunctionFingerprintExtractor
{
/// <summary>
/// Extracts function fingerprints from a binary file.
/// </summary>
/// <param name="binaryPath">Path to the binary file.</param>
/// <param name="options">Extraction options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of function fingerprints.</returns>
Task<IReadOnlyList<FunctionFingerprint>> ExtractAsync(
string binaryPath,
ExtractionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Extracts function fingerprints from binary data in memory.
/// </summary>
/// <param name="binaryData">Binary file contents.</param>
/// <param name="options">Extraction options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of function fingerprints.</returns>
Task<IReadOnlyList<FunctionFingerprint>> ExtractFromMemoryAsync(
ReadOnlyMemory<byte> binaryData,
ExtractionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Gets supported binary formats for this extractor.
/// </summary>
IReadOnlyList<string> SupportedFormats { get; }
}
/// <summary>
/// Fingerprint data for a single function in a binary.
/// Uses multiple hash algorithms for robust cross-version matching.
/// </summary>
public sealed record FunctionFingerprint
{
/// <summary>
/// Function name (symbol name or synthesized from offset).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Offset of the function within the .text section.
/// </summary>
public required long Offset { get; init; }
/// <summary>
/// Size of the function in bytes.
/// </summary>
public required int Size { get; init; }
/// <summary>
/// Hash of the basic block structure (opcode sequence, ignoring operands).
/// More stable across recompilation with different addresses.
/// </summary>
public required byte[] BasicBlockHash { get; init; }
/// <summary>
/// Hash of the control flow graph structure.
/// Captures branch patterns regardless of target addresses.
/// </summary>
public required byte[] CfgHash { get; init; }
/// <summary>
/// Hash of string references in the function.
/// Useful for identifying functions that use specific error messages or constants.
/// </summary>
public required byte[] StringRefsHash { get; init; }
/// <summary>
/// Combined fingerprint hash (all algorithms merged).
/// </summary>
public byte[]? CombinedHash { get; init; }
/// <summary>
/// List of functions called by this function.
/// </summary>
public IReadOnlyList<string>? Callees { get; init; }
/// <summary>
/// List of functions that call this function.
/// </summary>
public IReadOnlyList<string>? Callers { get; init; }
/// <summary>
/// Whether this is an exported (visible) symbol.
/// </summary>
public bool IsExported { get; init; }
/// <summary>
/// Whether this function has debug information available.
/// </summary>
public bool HasDebugInfo { get; init; }
/// <summary>
/// Source file path if debug info available.
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Source line number if debug info available.
/// </summary>
public int? SourceLine { get; init; }
}
/// <summary>
/// Options for function fingerprint extraction.
/// </summary>
public sealed record ExtractionOptions
{
/// <summary>
/// Whether to include internal/static functions (not exported).
/// </summary>
public bool IncludeInternalFunctions { get; init; } = false;
/// <summary>
/// Whether to build the call graph (callees/callers).
/// </summary>
public bool IncludeCallGraph { get; init; } = true;
/// <summary>
/// Minimum function size in bytes to include.
/// </summary>
public int MinFunctionSize { get; init; } = 16;
/// <summary>
/// Maximum function size in bytes to include. 0 = no limit.
/// </summary>
public int MaxFunctionSize { get; init; } = 0;
/// <summary>
/// Regex filter for function names to include. Null = all functions.
/// </summary>
public string? SymbolFilter { get; init; }
/// <summary>
/// Regex filter for function names to exclude.
/// </summary>
public string? ExcludeFilter { get; init; }
/// <summary>
/// Whether to compute the combined hash.
/// </summary>
public bool ComputeCombinedHash { get; init; } = true;
/// <summary>
/// Whether to extract debug information (source file/line).
/// </summary>
public bool ExtractDebugInfo { get; init; } = false;
}
/// <summary>
/// Represents a change to a function between two binary versions.
/// </summary>
public sealed record FunctionChange
{
/// <summary>
/// Function name.
/// </summary>
public required string FunctionName { get; init; }
/// <summary>
/// Type of change detected.
/// </summary>
public required ChangeType Type { get; init; }
/// <summary>
/// Fingerprint from the vulnerable version (null if Added).
/// </summary>
public FunctionFingerprint? VulnerableFingerprint { get; init; }
/// <summary>
/// Fingerprint from the patched version (null if Removed).
/// </summary>
public FunctionFingerprint? PatchedFingerprint { get; init; }
/// <summary>
/// Similarity score between versions (0.0-1.0) for Modified changes.
/// </summary>
public decimal? SimilarityScore { get; init; }
/// <summary>
/// Which hash algorithms showed differences.
/// </summary>
public IReadOnlyList<string>? DifferingHashes { get; init; }
}
/// <summary>
/// Type of change to a function between versions.
/// </summary>
public enum ChangeType
{
/// <summary>
/// Function was added in the patched version.
/// </summary>
Added,
/// <summary>
/// Function was modified (fingerprint changed).
/// </summary>
Modified,
/// <summary>
/// Function was removed in the patched version.
/// </summary>
Removed,
/// <summary>
/// Function signature changed (size/callees differ significantly).
/// </summary>
SignatureChanged
}

View File

@@ -0,0 +1,216 @@
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Computes diffs between function fingerprints of vulnerable and patched binaries.
/// Used to identify which functions were modified to fix a CVE.
/// </summary>
public interface IPatchDiffEngine
{
/// <summary>
/// Compares function fingerprints between vulnerable and patched builds.
/// </summary>
/// <param name="vulnerable">Functions from the vulnerable binary.</param>
/// <param name="patched">Functions from the patched binary.</param>
/// <param name="options">Diff options.</param>
/// <returns>Diff result with changes identified.</returns>
FunctionDiffResult ComputeDiff(
IReadOnlyList<FunctionFingerprint> vulnerable,
IReadOnlyList<FunctionFingerprint> patched,
DiffOptions? options = null);
/// <summary>
/// Computes similarity between two function fingerprints.
/// </summary>
/// <param name="a">First function fingerprint.</param>
/// <param name="b">Second function fingerprint.</param>
/// <returns>Similarity score (0.0-1.0).</returns>
decimal ComputeSimilarity(FunctionFingerprint a, FunctionFingerprint b);
/// <summary>
/// Identifies functions that likely correspond between versions despite name changes.
/// Uses fingerprint matching to find renamed or moved functions.
/// </summary>
/// <param name="vulnerable">Functions from the vulnerable binary.</param>
/// <param name="patched">Functions from the patched binary.</param>
/// <param name="threshold">Minimum similarity to consider a match.</param>
/// <returns>Mapping of vulnerable function names to patched function names.</returns>
IReadOnlyDictionary<string, string> FindFunctionMappings(
IReadOnlyList<FunctionFingerprint> vulnerable,
IReadOnlyList<FunctionFingerprint> patched,
decimal threshold = 0.8m);
}
/// <summary>
/// Result of computing a diff between function sets.
/// </summary>
public sealed record FunctionDiffResult
{
/// <summary>
/// All function changes detected.
/// </summary>
public required IReadOnlyList<FunctionChange> Changes { get; init; }
/// <summary>
/// Total functions in vulnerable version.
/// </summary>
public int TotalFunctionsVulnerable { get; init; }
/// <summary>
/// Total functions in patched version.
/// </summary>
public int TotalFunctionsPatched { get; init; }
/// <summary>
/// Number of functions added.
/// </summary>
public int AddedCount => Changes.Count(c => c.Type == ChangeType.Added);
/// <summary>
/// Number of functions modified.
/// </summary>
public int ModifiedCount => Changes.Count(c => c.Type == ChangeType.Modified);
/// <summary>
/// Number of functions removed.
/// </summary>
public int RemovedCount => Changes.Count(c => c.Type == ChangeType.Removed);
/// <summary>
/// Number of functions with signature changes.
/// </summary>
public int SignatureChangedCount => Changes.Count(c => c.Type == ChangeType.SignatureChanged);
/// <summary>
/// Number of functions that remained unchanged.
/// </summary>
public int UnchangedCount => TotalFunctionsVulnerable - ModifiedCount - RemovedCount - SignatureChangedCount;
/// <summary>
/// Percentage of functions that changed (0-100).
/// </summary>
public decimal ChangePercentage => TotalFunctionsVulnerable > 0
? 100m * (ModifiedCount + SignatureChangedCount) / TotalFunctionsVulnerable
: 0m;
/// <summary>
/// Summary statistics.
/// </summary>
public DiffStatistics Statistics => new()
{
TotalVulnerable = TotalFunctionsVulnerable,
TotalPatched = TotalFunctionsPatched,
Added = AddedCount,
Modified = ModifiedCount,
Removed = RemovedCount,
SignatureChanged = SignatureChangedCount,
Unchanged = UnchangedCount
};
}
/// <summary>
/// Summary statistics for a diff.
/// </summary>
public sealed record DiffStatistics
{
/// <summary>
/// Total functions in vulnerable version.
/// </summary>
public int TotalVulnerable { get; init; }
/// <summary>
/// Total functions in patched version.
/// </summary>
public int TotalPatched { get; init; }
/// <summary>
/// Functions added.
/// </summary>
public int Added { get; init; }
/// <summary>
/// Functions modified.
/// </summary>
public int Modified { get; init; }
/// <summary>
/// Functions removed.
/// </summary>
public int Removed { get; init; }
/// <summary>
/// Functions with signature changes.
/// </summary>
public int SignatureChanged { get; init; }
/// <summary>
/// Functions unchanged.
/// </summary>
public int Unchanged { get; init; }
}
/// <summary>
/// Options for computing diffs.
/// </summary>
public sealed record DiffOptions
{
/// <summary>
/// Minimum similarity score to consider two functions as the same (modified vs. different).
/// </summary>
public decimal SimilarityThreshold { get; init; } = 0.5m;
/// <summary>
/// Whether to use fuzzy name matching for renamed functions.
/// </summary>
public bool FuzzyNameMatching { get; init; } = true;
/// <summary>
/// Whether to include functions that are unchanged in the result.
/// </summary>
public bool IncludeUnchanged { get; init; } = false;
/// <summary>
/// Weights for different hash algorithms when computing similarity.
/// </summary>
public HashWeights Weights { get; init; } = HashWeights.Default;
/// <summary>
/// Whether to detect renamed functions.
/// </summary>
public bool DetectRenames { get; init; } = true;
/// <summary>
/// Minimum score to consider a function renamed (vs. added+removed).
/// </summary>
public decimal RenameThreshold { get; init; } = 0.7m;
}
/// <summary>
/// Weights for different hash algorithms when computing similarity.
/// </summary>
public sealed record HashWeights
{
/// <summary>
/// Weight for basic block hash comparison.
/// </summary>
public decimal BasicBlockWeight { get; init; } = 0.5m;
/// <summary>
/// Weight for CFG hash comparison.
/// </summary>
public decimal CfgWeight { get; init; } = 0.3m;
/// <summary>
/// Weight for string refs hash comparison.
/// </summary>
public decimal StringRefsWeight { get; init; } = 0.2m;
/// <summary>
/// Default weights.
/// </summary>
public static HashWeights Default => new();
/// <summary>
/// Validates that weights sum to 1.0.
/// </summary>
public bool IsValid => Math.Abs(BasicBlockWeight + CfgWeight + StringRefsWeight - 1.0m) < 0.001m;
}

View File

@@ -0,0 +1,428 @@
using System.Collections.Immutable;
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Builds distro packages from source with reproducible settings.
/// Supports building both vulnerable and patched versions for fingerprint diffing.
/// </summary>
public interface IReproducibleBuilder
{
/// <summary>
/// Gets the distro identifier this builder supports (e.g., "alpine", "debian", "rhel").
/// </summary>
string Distro { get; }
/// <summary>
/// Gets the releases this builder can target (e.g., "3.18", "bookworm", "9").
/// </summary>
IReadOnlyList<string> SupportedReleases { get; }
/// <summary>
/// Builds a package from source with optional patches applied.
/// </summary>
/// <param name="request">Build request parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Build result with output binaries and fingerprints.</returns>
Task<BuildResult> BuildAsync(BuildRequest request, CancellationToken ct = default);
/// <summary>
/// Builds both vulnerable and patched versions, returning the diff of function fingerprints.
/// This is the primary method for CVE fix attribution.
/// </summary>
/// <param name="request">Patch diff request parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Diff result showing which functions changed between versions.</returns>
Task<PatchDiffResult> BuildAndDiffAsync(PatchDiffRequest request, CancellationToken ct = default);
/// <summary>
/// Validates that the build environment is correctly configured for the target release.
/// </summary>
/// <param name="release">Target release to validate.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Validation result with any issues found.</returns>
Task<BuildEnvironmentValidation> ValidateEnvironmentAsync(string release, CancellationToken ct = default);
}
/// <summary>
/// Request parameters for a reproducible build.
/// </summary>
public sealed record BuildRequest
{
/// <summary>
/// Source package name (e.g., "openssl", "curl").
/// </summary>
public required string SourcePackage { get; init; }
/// <summary>
/// Package version to build.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Target distro release (e.g., "3.18", "bookworm").
/// </summary>
public required string Release { get; init; }
/// <summary>
/// Optional patches to apply before building.
/// </summary>
public IReadOnlyList<PatchReference>? Patches { get; init; }
/// <summary>
/// Target architecture (e.g., "x86_64", "aarch64"). Defaults to current arch.
/// </summary>
public string? Architecture { get; init; }
/// <summary>
/// Build options for reproducibility and normalization.
/// </summary>
public BuildOptions? Options { get; init; }
/// <summary>
/// Optional unique identifier for this build request (for tracking).
/// </summary>
public string? RequestId { get; init; }
}
/// <summary>
/// Reference to a security patch.
/// </summary>
public sealed record PatchReference
{
/// <summary>
/// CVE identifier this patch fixes.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// URL to the patch file.
/// </summary>
public required string PatchUrl { get; init; }
/// <summary>
/// Expected SHA-256 hash of the patch file for integrity verification.
/// </summary>
public string? PatchSha256 { get; init; }
/// <summary>
/// Git commit ID if the patch comes from a repository.
/// </summary>
public string? CommitId { get; init; }
/// <summary>
/// Optional ordering hint for patch application (lower = earlier).
/// </summary>
public int Order { get; init; } = 0;
}
/// <summary>
/// Options controlling build reproducibility.
/// </summary>
public sealed record BuildOptions
{
/// <summary>
/// SOURCE_DATE_EPOCH value. If null, extracted from changelog/git.
/// </summary>
public DateTimeOffset? SourceDateEpoch { get; init; }
/// <summary>
/// Whether to strip binaries after building. Default: false.
/// </summary>
public bool StripBinaries { get; init; } = false;
/// <summary>
/// Whether to extract function-level fingerprints. Default: true.
/// </summary>
public bool ExtractFunctionFingerprints { get; init; } = true;
/// <summary>
/// Minimum function size (bytes) to include in fingerprint extraction.
/// </summary>
public int MinFunctionSize { get; init; } = 16;
/// <summary>
/// Build timeout. Default: 30 minutes.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Whether to keep build artifacts for debugging.
/// </summary>
public bool KeepBuildArtifacts { get; init; } = false;
}
/// <summary>
/// Result of a reproducible build.
/// </summary>
public sealed record BuildResult
{
/// <summary>
/// Whether the build succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Built binaries with extracted fingerprints.
/// </summary>
public IReadOnlyList<BuiltBinary>? Binaries { get; init; }
/// <summary>
/// Error message if build failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Total build duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Reference to full build log (e.g., content-addressed storage ID).
/// </summary>
public string? BuildLogRef { get; init; }
/// <summary>
/// SOURCE_DATE_EPOCH used for this build.
/// </summary>
public DateTimeOffset? SourceDateEpoch { get; init; }
/// <summary>
/// Build container image used.
/// </summary>
public string? BuilderImage { get; init; }
/// <summary>
/// Creates a failed build result.
/// </summary>
public static BuildResult Failed(string message, TimeSpan duration) => new()
{
Success = false,
ErrorMessage = message,
Duration = duration
};
}
/// <summary>
/// A single binary produced by a build.
/// </summary>
public sealed record BuiltBinary
{
/// <summary>
/// Relative path within the build output.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// ELF Build-ID (hex-encoded).
/// </summary>
public required string BuildId { get; init; }
/// <summary>
/// SHA-256 of the .text section.
/// </summary>
public required byte[] TextSha256 { get; init; }
/// <summary>
/// Combined fingerprint hash.
/// </summary>
public required byte[] Fingerprint { get; init; }
/// <summary>
/// File-level SHA-256.
/// </summary>
public byte[]? FileSha256 { get; init; }
/// <summary>
/// Function-level fingerprints if extraction was enabled.
/// </summary>
public IReadOnlyList<FunctionFingerprint>? Functions { get; init; }
/// <summary>
/// Binary format (ELF, PE, Mach-O).
/// </summary>
public string Format { get; init; } = "elf";
/// <summary>
/// Target architecture.
/// </summary>
public string? Architecture { get; init; }
/// <summary>
/// Whether the binary is stripped of debug symbols.
/// </summary>
public bool IsStripped { get; init; }
}
/// <summary>
/// Request for building and diffing vulnerable vs. patched versions.
/// </summary>
public sealed record PatchDiffRequest
{
/// <summary>
/// Source package name.
/// </summary>
public required string SourcePackage { get; init; }
/// <summary>
/// Vulnerable version to build first.
/// </summary>
public required string VulnerableVersion { get; init; }
/// <summary>
/// Patched version or patches to apply to vulnerable version.
/// </summary>
public required PatchTarget PatchTarget { get; init; }
/// <summary>
/// Target distro release.
/// </summary>
public required string Release { get; init; }
/// <summary>
/// CVE being fixed (for attribution).
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Build options.
/// </summary>
public BuildOptions? Options { get; init; }
}
/// <summary>
/// Specifies how to get the patched version.
/// </summary>
public sealed record PatchTarget
{
/// <summary>
/// If set, build this version as the patched version (e.g., downstream fixed release).
/// </summary>
public string? PatchedVersion { get; init; }
/// <summary>
/// If set, apply these patches to the vulnerable version.
/// </summary>
public IReadOnlyList<PatchReference>? Patches { get; init; }
}
/// <summary>
/// Result of comparing vulnerable and patched builds.
/// </summary>
public sealed record PatchDiffResult
{
/// <summary>
/// Whether both builds succeeded and diff was computed.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Vulnerable build result.
/// </summary>
public BuildResult? VulnerableBuild { get; init; }
/// <summary>
/// Patched build result.
/// </summary>
public BuildResult? PatchedBuild { get; init; }
/// <summary>
/// Function-level changes per binary.
/// </summary>
public IReadOnlyList<BinaryDiff>? BinaryDiffs { get; init; }
/// <summary>
/// Error message if diff failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Creates a failed result.
/// </summary>
public static PatchDiffResult Failed(string message) => new()
{
Success = false,
ErrorMessage = message
};
}
/// <summary>
/// Diff results for a single binary between vulnerable and patched builds.
/// </summary>
public sealed record BinaryDiff
{
/// <summary>
/// Binary path (common between both builds).
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Function changes detected.
/// </summary>
public required IReadOnlyList<FunctionChange> Changes { get; init; }
/// <summary>
/// Build-ID of the vulnerable version.
/// </summary>
public string? VulnerableBuildId { get; init; }
/// <summary>
/// Build-ID of the patched version.
/// </summary>
public string? PatchedBuildId { get; init; }
/// <summary>
/// Total functions in vulnerable binary.
/// </summary>
public int TotalFunctionsVulnerable { get; init; }
/// <summary>
/// Total functions in patched binary.
/// </summary>
public int TotalFunctionsPatched { get; init; }
}
/// <summary>
/// Build environment validation result.
/// </summary>
public sealed record BuildEnvironmentValidation
{
/// <summary>
/// Whether the environment is valid for building.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Issues found during validation.
/// </summary>
public IReadOnlyList<string>? Issues { get; init; }
/// <summary>
/// Builder container image available.
/// </summary>
public string? BuilderImage { get; init; }
/// <summary>
/// Toolchain versions detected.
/// </summary>
public IReadOnlyDictionary<string, string>? ToolchainVersions { get; init; }
/// <summary>
/// Creates a valid result.
/// </summary>
public static BuildEnvironmentValidation Valid(string image, IReadOnlyDictionary<string, string>? versions = null) => new()
{
IsValid = true,
BuilderImage = image,
ToolchainVersions = versions
};
/// <summary>
/// Creates an invalid result.
/// </summary>
public static BuildEnvironmentValidation Invalid(IReadOnlyList<string> issues) => new()
{
IsValid = false,
Issues = issues
};
}

View File

@@ -0,0 +1,288 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Computes diffs between function fingerprints of vulnerable and patched binaries.
/// </summary>
public sealed class PatchDiffEngine : IPatchDiffEngine
{
private readonly ILogger<PatchDiffEngine> _logger;
public PatchDiffEngine(ILogger<PatchDiffEngine> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public FunctionDiffResult ComputeDiff(
IReadOnlyList<FunctionFingerprint> vulnerable,
IReadOnlyList<FunctionFingerprint> patched,
DiffOptions? options = null)
{
ArgumentNullException.ThrowIfNull(vulnerable);
ArgumentNullException.ThrowIfNull(patched);
options ??= new DiffOptions();
_logger.LogDebug(
"Computing diff: {VulnerableCount} vulnerable functions, {PatchedCount} patched functions",
vulnerable.Count, patched.Count);
var changes = new List<FunctionChange>();
// Index by name for quick lookup
var vulnerableByName = vulnerable.ToDictionary(f => f.Name, f => f);
var patchedByName = patched.ToDictionary(f => f.Name, f => f);
// Track processed functions to find additions
var processedPatched = new HashSet<string>();
// Find modifications and removals
foreach (var vulnFunc in vulnerable)
{
if (patchedByName.TryGetValue(vulnFunc.Name, out var patchedFunc))
{
processedPatched.Add(vulnFunc.Name);
var similarity = ComputeSimilarity(vulnFunc, patchedFunc);
if (similarity >= 1.0m)
{
// Unchanged
if (options.IncludeUnchanged)
{
// Not adding unchanged to results by default
}
}
else if (similarity >= options.SimilarityThreshold)
{
// Modified
var differingHashes = GetDifferingHashes(vulnFunc, patchedFunc);
changes.Add(new FunctionChange
{
FunctionName = vulnFunc.Name,
Type = ChangeType.Modified,
VulnerableFingerprint = vulnFunc,
PatchedFingerprint = patchedFunc,
SimilarityScore = similarity,
DifferingHashes = differingHashes
});
}
else
{
// Signature changed (too different to be considered same function)
changes.Add(new FunctionChange
{
FunctionName = vulnFunc.Name,
Type = ChangeType.SignatureChanged,
VulnerableFingerprint = vulnFunc,
PatchedFingerprint = patchedFunc,
SimilarityScore = similarity,
DifferingHashes = GetDifferingHashes(vulnFunc, patchedFunc)
});
}
}
else
{
// Not found by name - check if renamed
if (options.DetectRenames)
{
var bestMatch = FindBestMatch(vulnFunc, patched, processedPatched, options.RenameThreshold);
if (bestMatch != null)
{
processedPatched.Add(bestMatch.Name);
var similarity = ComputeSimilarity(vulnFunc, bestMatch);
changes.Add(new FunctionChange
{
FunctionName = $"{vulnFunc.Name} → {bestMatch.Name}",
Type = ChangeType.Modified,
VulnerableFingerprint = vulnFunc,
PatchedFingerprint = bestMatch,
SimilarityScore = similarity,
DifferingHashes = GetDifferingHashes(vulnFunc, bestMatch)
});
continue;
}
}
// Removed
changes.Add(new FunctionChange
{
FunctionName = vulnFunc.Name,
Type = ChangeType.Removed,
VulnerableFingerprint = vulnFunc,
PatchedFingerprint = null,
SimilarityScore = null
});
}
}
// Find additions (functions in patched but not in vulnerable)
foreach (var patchedFunc in patched)
{
if (!processedPatched.Contains(patchedFunc.Name))
{
changes.Add(new FunctionChange
{
FunctionName = patchedFunc.Name,
Type = ChangeType.Added,
VulnerableFingerprint = null,
PatchedFingerprint = patchedFunc,
SimilarityScore = null
});
}
}
_logger.LogInformation(
"Diff computed: {Added} added, {Modified} modified, {Removed} removed, {SignatureChanged} signature changed",
changes.Count(c => c.Type == ChangeType.Added),
changes.Count(c => c.Type == ChangeType.Modified),
changes.Count(c => c.Type == ChangeType.Removed),
changes.Count(c => c.Type == ChangeType.SignatureChanged));
return new FunctionDiffResult
{
Changes = changes,
TotalFunctionsVulnerable = vulnerable.Count,
TotalFunctionsPatched = patched.Count
};
}
/// <inheritdoc />
public decimal ComputeSimilarity(FunctionFingerprint a, FunctionFingerprint b)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
// Compute weighted similarity based on hash matches
decimal totalWeight = 0m;
decimal matchedWeight = 0m;
// Basic block hash (weight: 0.5)
const decimal bbWeight = 0.5m;
totalWeight += bbWeight;
if (HashesEqual(a.BasicBlockHash, b.BasicBlockHash))
{
matchedWeight += bbWeight;
}
// CFG hash (weight: 0.3)
const decimal cfgWeight = 0.3m;
totalWeight += cfgWeight;
if (HashesEqual(a.CfgHash, b.CfgHash))
{
matchedWeight += cfgWeight;
}
// String refs hash (weight: 0.2)
const decimal strWeight = 0.2m;
totalWeight += strWeight;
if (HashesEqual(a.StringRefsHash, b.StringRefsHash))
{
matchedWeight += strWeight;
}
// Size similarity bonus (if sizes are within 10%, add small bonus)
if (a.Size > 0 && b.Size > 0)
{
var sizeDiff = Math.Abs(a.Size - b.Size) / (decimal)Math.Max(a.Size, b.Size);
if (sizeDiff <= 0.1m)
{
matchedWeight += 0.05m * (1m - sizeDiff * 10m);
totalWeight += 0.05m;
}
}
return totalWeight > 0 ? matchedWeight / totalWeight : 0m;
}
/// <inheritdoc />
public IReadOnlyDictionary<string, string> FindFunctionMappings(
IReadOnlyList<FunctionFingerprint> vulnerable,
IReadOnlyList<FunctionFingerprint> patched,
decimal threshold = 0.8m)
{
ArgumentNullException.ThrowIfNull(vulnerable);
ArgumentNullException.ThrowIfNull(patched);
var mappings = new Dictionary<string, string>();
var usedPatched = new HashSet<string>();
// First pass: exact name matches
foreach (var vulnFunc in vulnerable)
{
var match = patched.FirstOrDefault(p => p.Name == vulnFunc.Name);
if (match != null)
{
mappings[vulnFunc.Name] = match.Name;
usedPatched.Add(match.Name);
}
}
// Second pass: fingerprint-based matches for unmatched functions
var unmatchedVulnerable = vulnerable.Where(v => !mappings.ContainsKey(v.Name)).ToList();
var unmatchedPatched = patched.Where(p => !usedPatched.Contains(p.Name)).ToList();
foreach (var vulnFunc in unmatchedVulnerable)
{
var bestMatch = FindBestMatch(vulnFunc, unmatchedPatched, usedPatched, threshold);
if (bestMatch != null)
{
mappings[vulnFunc.Name] = bestMatch.Name;
usedPatched.Add(bestMatch.Name);
}
}
return mappings;
}
private FunctionFingerprint? FindBestMatch(
FunctionFingerprint target,
IReadOnlyList<FunctionFingerprint> candidates,
HashSet<string> excludeNames,
decimal threshold)
{
FunctionFingerprint? bestMatch = null;
var bestScore = threshold - 0.001m; // Must exceed threshold
foreach (var candidate in candidates)
{
if (excludeNames.Contains(candidate.Name))
continue;
var score = ComputeSimilarity(target, candidate);
if (score > bestScore)
{
bestScore = score;
bestMatch = candidate;
}
}
return bestMatch;
}
private IReadOnlyList<string> GetDifferingHashes(FunctionFingerprint a, FunctionFingerprint b)
{
var differing = new List<string>();
if (!HashesEqual(a.BasicBlockHash, b.BasicBlockHash))
differing.Add("basic_block");
if (!HashesEqual(a.CfgHash, b.CfgHash))
differing.Add("cfg");
if (!HashesEqual(a.StringRefsHash, b.StringRefsHash))
differing.Add("string_refs");
return differing;
}
private static bool HashesEqual(byte[]? a, byte[]? b)
{
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return a.SequenceEqual(b);
}
}

View File

@@ -0,0 +1,371 @@
// -----------------------------------------------------------------------------
// ReproducibleBuildJobTypes.cs
// Types for the ReproducibleBuildJob orchestration
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Interface for the reproducible build job.
/// </summary>
public interface IReproducibleBuildJob
{
/// <summary>
/// Executes the build job, processing all pending CVEs.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task ExecuteAsync(CancellationToken ct);
/// <summary>
/// Processes a single CVE attribution request.
/// </summary>
/// <param name="cve">CVE to process.</param>
/// <param name="ct">Cancellation token.</param>
Task ProcessCveAsync(CveAttribution cve, CancellationToken ct);
}
/// <summary>
/// CVE attribution request.
/// </summary>
public sealed record CveAttribution
{
/// <summary>
/// CVE identifier (e.g., "CVE-2024-0001").
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Source package name (e.g., "openssl", "curl").
/// </summary>
public required string SourcePackage { get; init; }
/// <summary>
/// Distribution identifier (e.g., "debian", "alpine", "rhel").
/// </summary>
public required string Distro { get; init; }
/// <summary>
/// Distribution release (e.g., "bookworm", "3.19", "9").
/// </summary>
public required string Release { get; init; }
/// <summary>
/// Vulnerable package version.
/// </summary>
public required string VulnerableVersion { get; init; }
/// <summary>
/// Fixed/patched package version.
/// </summary>
public required string FixedVersion { get; init; }
/// <summary>
/// Git commit that introduced the fix (optional).
/// </summary>
public string? PatchCommit { get; init; }
/// <summary>
/// Advisory identifier (optional).
/// </summary>
public string? AdvisoryId { get; init; }
}
/// <summary>
/// Advisory feed monitor interface.
/// Watches for new CVE advisories that need binary attribution.
/// </summary>
public interface IAdvisoryFeedMonitor
{
/// <summary>
/// Gets CVEs pending binary attribution.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of CVEs needing processing.</returns>
Task<IReadOnlyList<CveAttribution>> GetPendingCvesAsync(CancellationToken ct);
}
/// <summary>
/// Configuration options for reproducible builds.
/// </summary>
public sealed class ReproducibleBuildOptions
{
/// <summary>
/// Maximum time allowed for a single build.
/// </summary>
public TimeSpan BuildTimeout { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Default target architecture.
/// </summary>
public string DefaultArchitecture { get; set; } = "amd64";
/// <summary>
/// Minimum function size to extract fingerprints for.
/// </summary>
public int MinFunctionSize { get; set; } = 16;
/// <summary>
/// Maximum concurrent builds.
/// </summary>
public int MaxConcurrentBuilds { get; set; } = 2;
/// <summary>
/// Directory for build cache storage.
/// </summary>
public string BuildCacheDirectory { get; set; } = "/var/cache/stellaops/builds";
}
/// <summary>
/// Background job that orchestrates reproducible builds for binary CVE attribution.
/// Monitors advisory feeds, triggers builds, extracts fingerprints, and creates claims.
/// </summary>
public sealed class ReproducibleBuildJob : IReproducibleBuildJob
{
private readonly ILogger<ReproducibleBuildJob> _logger;
private readonly ReproducibleBuildOptions _options;
private readonly IEnumerable<IReproducibleBuilder> _builders;
private readonly IFunctionFingerprintExtractor _fingerprintExtractor;
private readonly IPatchDiffEngine _diffEngine;
private readonly IFingerprintClaimRepository _claimRepository;
private readonly IAdvisoryFeedMonitor _advisoryMonitor;
/// <summary>
/// Initializes a new instance of <see cref="ReproducibleBuildJob"/>.
/// </summary>
public ReproducibleBuildJob(
ILogger<ReproducibleBuildJob> logger,
Microsoft.Extensions.Options.IOptions<ReproducibleBuildOptions> options,
IEnumerable<IReproducibleBuilder> builders,
IFunctionFingerprintExtractor fingerprintExtractor,
IPatchDiffEngine diffEngine,
IFingerprintClaimRepository claimRepository,
IAdvisoryFeedMonitor advisoryMonitor)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_builders = builders ?? throw new ArgumentNullException(nameof(builders));
_fingerprintExtractor = fingerprintExtractor ?? throw new ArgumentNullException(nameof(fingerprintExtractor));
_diffEngine = diffEngine ?? throw new ArgumentNullException(nameof(diffEngine));
_claimRepository = claimRepository ?? throw new ArgumentNullException(nameof(claimRepository));
_advisoryMonitor = advisoryMonitor ?? throw new ArgumentNullException(nameof(advisoryMonitor));
}
/// <inheritdoc />
public async Task ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("Starting reproducible build job");
try
{
// Step 1: Get pending CVEs that need binary attribution
var pendingCves = await _advisoryMonitor.GetPendingCvesAsync(ct);
_logger.LogInformation("Found {Count} CVEs pending binary attribution", pendingCves.Count);
foreach (var cve in pendingCves)
{
if (ct.IsCancellationRequested) break;
try
{
await ProcessCveAsync(cve, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process CVE {CveId}", cve.CveId);
// Continue with next CVE
}
}
_logger.LogInformation("Reproducible build job completed");
}
catch (OperationCanceledException)
{
_logger.LogInformation("Reproducible build job cancelled");
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Reproducible build job failed");
throw;
}
}
/// <inheritdoc />
public async Task ProcessCveAsync(CveAttribution cve, CancellationToken ct)
{
_logger.LogDebug("Processing CVE {CveId} for package {Package}", cve.CveId, cve.SourcePackage);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Find appropriate builder for distro
var builder = _builders.FirstOrDefault(b =>
b.Distro.Equals(cve.Distro, StringComparison.OrdinalIgnoreCase));
if (builder == null)
{
_logger.LogWarning("No builder available for distro {Distro}", cve.Distro);
return;
}
// Build vulnerable version
var vulnerableBuild = await BuildVersionAsync(builder, cve, cve.VulnerableVersion, ct);
if (!vulnerableBuild.Success)
{
_logger.LogWarning("Failed to build vulnerable version {Version}", cve.VulnerableVersion);
return;
}
// Build patched version
var patchedBuild = await BuildVersionAsync(builder, cve, cve.FixedVersion, ct);
if (!patchedBuild.Success)
{
_logger.LogWarning("Failed to build patched version {Version}", cve.FixedVersion);
return;
}
// Extract function fingerprints from both builds
var vulnerableFunctions = await ExtractFunctionsAsync(vulnerableBuild, ct);
var patchedFunctions = await ExtractFunctionsAsync(patchedBuild, ct);
// Compute diff to identify changed functions
var diff = _diffEngine.ComputeDiff(vulnerableFunctions, patchedFunctions);
_logger.LogDebug(
"CVE {CveId}: {Modified} modified, {Added} added, {Removed} removed functions",
cve.CveId, diff.ModifiedCount, diff.AddedCount, diff.RemovedCount);
// Create fingerprint claims
await CreateClaimsAsync(cve, diff, vulnerableBuild, patchedBuild, ct);
stopwatch.Stop();
_logger.LogInformation(
"Processed CVE {CveId} in {Duration}ms",
cve.CveId, stopwatch.ElapsedMilliseconds);
}
private async Task<BuildResult> BuildVersionAsync(
IReproducibleBuilder builder,
CveAttribution cve,
string version,
CancellationToken ct)
{
var request = new BuildRequest
{
SourcePackage = cve.SourcePackage,
Version = version,
Release = cve.Release,
Architecture = _options.DefaultArchitecture,
Options = new BuildOptions
{
Timeout = _options.BuildTimeout
}
};
return await builder.BuildAsync(request, ct);
}
private async Task<IReadOnlyList<FunctionFingerprint>> ExtractFunctionsAsync(
BuildResult build,
CancellationToken ct)
{
var allFunctions = new List<FunctionFingerprint>();
foreach (var binary in build.Binaries ?? [])
{
if (binary.Functions != null)
{
allFunctions.AddRange(binary.Functions);
}
else
{
// Extract if not already done during build
var functions = await _fingerprintExtractor.ExtractAsync(
binary.Path,
new ExtractionOptions
{
IncludeInternalFunctions = false,
IncludeCallGraph = true,
MinFunctionSize = _options.MinFunctionSize
},
ct);
allFunctions.AddRange(functions);
}
}
return allFunctions;
}
private async Task CreateClaimsAsync(
CveAttribution cve,
FunctionDiffResult diff,
BuildResult vulnerableBuild,
BuildResult patchedBuild,
CancellationToken ct)
{
var claims = new List<FingerprintClaim>();
// Create "fixed" claims for patched binaries
foreach (var binary in patchedBuild.Binaries ?? [])
{
var changedFunctions = diff.Changes
.Where(c => c.Type is ChangeType.Modified or ChangeType.Added)
.Select(c => c.FunctionName)
.ToList();
var claim = new FingerprintClaim
{
Id = Guid.NewGuid(),
FingerprintId = Guid.Parse(binary.BuildId), // Assuming BuildId is GUID-like
CveId = cve.CveId,
Verdict = ClaimVerdict.Fixed,
Evidence = new FingerprintClaimEvidence
{
PatchCommit = cve.PatchCommit ?? "unknown",
ChangedFunctions = changedFunctions,
FunctionSimilarities = diff.Changes
.Where(c => c.SimilarityScore.HasValue)
.ToDictionary(c => c.FunctionName, c => c.SimilarityScore!.Value),
VulnerableBuildRef = vulnerableBuild.BuildLogRef,
PatchedBuildRef = patchedBuild.BuildLogRef
},
CreatedAt = DateTimeOffset.UtcNow
};
claims.Add(claim);
}
// Create "vulnerable" claims for vulnerable binaries
foreach (var binary in vulnerableBuild.Binaries ?? [])
{
var claim = new FingerprintClaim
{
Id = Guid.NewGuid(),
FingerprintId = Guid.Parse(binary.BuildId),
CveId = cve.CveId,
Verdict = ClaimVerdict.Vulnerable,
Evidence = new FingerprintClaimEvidence
{
PatchCommit = cve.PatchCommit ?? "unknown",
ChangedFunctions = diff.Changes
.Where(c => c.Type == ChangeType.Modified)
.Select(c => c.FunctionName)
.ToList(),
VulnerableBuildRef = vulnerableBuild.BuildLogRef
},
CreatedAt = DateTimeOffset.UtcNow
};
claims.Add(claim);
}
await _claimRepository.CreateClaimsBatchAsync(claims, ct);
_logger.LogDebug(
"Created {Count} fingerprint claims for CVE {CveId}",
claims.Count, cve.CveId);
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Extension methods for registering builder services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the reproducible builder services to the DI container.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddBinaryIndexBuilders(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Configuration - register options with defaults (configuration binding happens via host)
services.Configure<BuilderServiceOptions>(options => { });
services.Configure<FunctionExtractionOptions>(options => { });
// Core services
services.TryAddSingleton<IPatchDiffEngine, PatchDiffEngine>();
// Builders will be added as they are implemented
// services.TryAddSingleton<IReproducibleBuilder, AlpineBuilder>();
// services.TryAddSingleton<IReproducibleBuilder, DebianBuilder>();
// services.TryAddSingleton<IReproducibleBuilder, RhelBuilder>();
// Function extractor will be added when implemented
// services.TryAddSingleton<IFunctionFingerprintExtractor, FunctionFingerprintExtractor>();
return services;
}
/// <summary>
/// Adds the reproducible builder services with custom options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration delegate.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddBinaryIndexBuilders(
this IServiceCollection services,
Action<BuilderServiceOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.TryAddSingleton<IPatchDiffEngine, PatchDiffEngine>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>Reproducible distro builders and function-level fingerprinting for StellaOps BinaryIndex.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Docker.DotNet" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../StellaOps.BinaryIndex.Fingerprints/StellaOps.BinaryIndex.Fingerprints.csproj" />
</ItemGroup>
</Project>

View File

@@ -59,14 +59,14 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
// Try cache first
var cached = await GetFromCacheAsync<ImmutableArray<BinaryVulnMatch>>(cacheKey, ct).ConfigureAwait(false);
if (cached.HasValue)
if (!cached.IsDefault)
{
sw.Stop();
_logger.LogDebug(
"Cache hit for identity {BinaryKey} in {ElapsedMs}ms",
identity.BinaryKey,
sw.Elapsed.TotalMilliseconds);
return cached.Value;
return cached;
}
// Cache miss - call inner service
@@ -186,14 +186,14 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
var sw = Stopwatch.StartNew();
// Try cache first
var cached = await GetFromCacheAsync<FixStatusResult?>(cacheKey, ct).ConfigureAwait(false);
if (cached.HasValue)
var cached = await GetFromCacheAsync<FixStatusResult>(cacheKey, ct).ConfigureAwait(false);
if (cached is not null)
{
sw.Stop();
_logger.LogDebug(
"Cache hit for fix status {Distro}:{SourcePkg}:{CveId} in {ElapsedMs}ms",
distro, sourcePkg, cveId, sw.Elapsed.TotalMilliseconds);
return cached.Value;
return cached;
}
// Cache miss
@@ -296,11 +296,11 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
// Try cache first
var cached = await GetFromCacheAsync<ImmutableArray<BinaryVulnMatch>>(cacheKey, ct).ConfigureAwait(false);
if (cached.HasValue)
if (!cached.IsDefault)
{
sw.Stop();
_logger.LogDebug("Cache hit for fingerprint in {ElapsedMs}ms", sw.Elapsed.TotalMilliseconds);
return cached.Value;
return cached;
}
// Cache miss

View File

@@ -0,0 +1,279 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.BinaryIndex.Contracts.Resolution;
namespace StellaOps.BinaryIndex.Cache;
/// <summary>
/// Caching service for binary resolution results.
/// Uses Valkey/Redis for high-performance caching with configurable TTLs.
/// </summary>
public interface IResolutionCacheService
{
/// <summary>
/// Get cached resolution status.
/// </summary>
/// <param name="cacheKey">The cache key.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Cached resolution if found, null otherwise.</returns>
Task<CachedResolution?> GetAsync(string cacheKey, CancellationToken ct = default);
/// <summary>
/// Cache resolution result.
/// </summary>
/// <param name="cacheKey">The cache key.</param>
/// <param name="result">The resolution result to cache.</param>
/// <param name="ttl">Time-to-live for the cache entry.</param>
/// <param name="ct">Cancellation token.</param>
Task SetAsync(string cacheKey, CachedResolution result, TimeSpan ttl, CancellationToken ct = default);
/// <summary>
/// Invalidate cache entries by pattern.
/// </summary>
/// <param name="pattern">Redis pattern (e.g., "resolution:*:debian:*").</param>
/// <param name="ct">Cancellation token.</param>
Task InvalidateByPatternAsync(string pattern, CancellationToken ct = default);
/// <summary>
/// Generate cache key from resolution request.
/// </summary>
/// <param name="request">The resolution request.</param>
/// <returns>Deterministic cache key.</returns>
string GenerateCacheKey(VulnResolutionRequest request);
}
/// <summary>
/// Cached resolution entry.
/// </summary>
public sealed record CachedResolution
{
/// <summary>Resolution status.</summary>
public required ResolutionStatus Status { get; init; }
/// <summary>Fixed version if applicable.</summary>
public string? FixedVersion { get; init; }
/// <summary>Reference to evidence record.</summary>
public string? EvidenceRef { get; init; }
/// <summary>When this entry was cached.</summary>
public DateTimeOffset CachedAt { get; init; }
/// <summary>Version key for invalidation.</summary>
public string? VersionKey { get; init; }
/// <summary>Confidence score.</summary>
public decimal Confidence { get; init; }
/// <summary>Match type used.</summary>
public string? MatchType { get; init; }
}
/// <summary>
/// Configuration options for resolution caching.
/// </summary>
public sealed class ResolutionCacheOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "ResolutionCache";
/// <summary>TTL for fixed (high confidence) results.</summary>
public TimeSpan FixedTtl { get; set; } = TimeSpan.FromHours(24);
/// <summary>TTL for vulnerable results.</summary>
public TimeSpan VulnerableTtl { get; set; } = TimeSpan.FromHours(4);
/// <summary>TTL for unknown results.</summary>
public TimeSpan UnknownTtl { get; set; } = TimeSpan.FromHours(1);
/// <summary>Cache key prefix.</summary>
public string KeyPrefix { get; set; } = "resolution";
/// <summary>Enable probabilistic early expiry to prevent stampedes.</summary>
public bool EnableEarlyExpiry { get; set; } = true;
/// <summary>Early expiry factor (0.0-1.0).</summary>
public double EarlyExpiryFactor { get; set; } = 0.1;
}
/// <summary>
/// Valkey/Redis implementation of resolution caching.
/// </summary>
public sealed class ResolutionCacheService : IResolutionCacheService
{
private readonly IConnectionMultiplexer _redis;
private readonly ResolutionCacheOptions _options;
private readonly ILogger<ResolutionCacheService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public ResolutionCacheService(
IConnectionMultiplexer redis,
IOptions<ResolutionCacheOptions> options,
ILogger<ResolutionCacheService> logger)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <inheritdoc />
public async Task<CachedResolution?> GetAsync(string cacheKey, CancellationToken ct = default)
{
try
{
var db = _redis.GetDatabase();
var value = await db.StringGetAsync(cacheKey);
if (value.IsNullOrEmpty)
{
_logger.LogDebug("Cache miss for key {CacheKey}", cacheKey);
return null;
}
var cached = JsonSerializer.Deserialize<CachedResolution>(value.ToString(), _jsonOptions);
// Check for probabilistic early expiry
if (_options.EnableEarlyExpiry && cached is not null)
{
var ttl = await db.KeyTimeToLiveAsync(cacheKey);
if (ShouldExpireEarly(ttl))
{
_logger.LogDebug("Early expiry triggered for key {CacheKey}", cacheKey);
return null;
}
}
_logger.LogDebug("Cache hit for key {CacheKey}", cacheKey);
return cached;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get cache entry for key {CacheKey}", cacheKey);
return null;
}
}
/// <inheritdoc />
public async Task SetAsync(string cacheKey, CachedResolution result, TimeSpan ttl, CancellationToken ct = default)
{
try
{
var db = _redis.GetDatabase();
var value = JsonSerializer.Serialize(result, _jsonOptions);
await db.StringSetAsync(cacheKey, value, ttl);
_logger.LogDebug("Cached resolution for key {CacheKey} with TTL {Ttl}", cacheKey, ttl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache resolution for key {CacheKey}", cacheKey);
}
}
/// <inheritdoc />
public async Task InvalidateByPatternAsync(string pattern, CancellationToken ct = default)
{
try
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var db = _redis.GetDatabase();
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
{
await db.KeyDeleteAsync(keys);
_logger.LogInformation("Invalidated {Count} cache entries matching pattern {Pattern}",
keys.Length, pattern);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate cache entries matching pattern {Pattern}", pattern);
}
}
/// <inheritdoc />
public string GenerateCacheKey(VulnResolutionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
// Build deterministic cache key
// Format: resolution:{algorithm}:{hash}:{cve_id_or_all}
var algorithm = DetermineAlgorithm(request);
var hash = ComputeIdentityHash(request);
var cveId = request.CveId ?? "all";
return $"{_options.KeyPrefix}:{algorithm}:{hash}:{cveId}";
}
/// <summary>
/// Get appropriate TTL based on resolution status.
/// </summary>
public TimeSpan GetTtlForStatus(ResolutionStatus status)
{
return status switch
{
ResolutionStatus.Fixed => _options.FixedTtl,
ResolutionStatus.Vulnerable => _options.VulnerableTtl,
ResolutionStatus.NotAffected => _options.FixedTtl,
_ => _options.UnknownTtl
};
}
private static string DetermineAlgorithm(VulnResolutionRequest request)
{
if (!string.IsNullOrEmpty(request.BuildId))
return "build_id";
if (!string.IsNullOrEmpty(request.Fingerprint))
return request.FingerprintAlgorithm ?? "combined";
if (request.Hashes?.TextSha256 != null)
return "text_sha256";
if (request.Hashes?.FileSha256 != null)
return "file_sha256";
return "package";
}
private static string ComputeIdentityHash(VulnResolutionRequest request)
{
// Use the most specific identifier available
if (!string.IsNullOrEmpty(request.BuildId))
return request.BuildId;
if (!string.IsNullOrEmpty(request.Fingerprint))
return ComputeShortHash(request.Fingerprint);
if (request.Hashes?.TextSha256 != null)
return request.Hashes.TextSha256;
if (request.Hashes?.FileSha256 != null)
return request.Hashes.FileSha256;
// Fall back to package + distro
var key = $"{request.Package}:{request.DistroRelease ?? "unknown"}";
return ComputeShortHash(key);
}
private static string ComputeShortHash(string input)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash)[..16];
}
private bool ShouldExpireEarly(TimeSpan? remainingTtl)
{
if (!remainingTtl.HasValue || remainingTtl.Value <= TimeSpan.Zero)
return true;
// Probabilistic early expiry using exponential decay
var random = Random.Shared.NextDouble();
var threshold = _options.EarlyExpiryFactor * Math.Exp(-remainingTtl.Value.TotalSeconds / 3600);
return random < threshold;
}
}

View File

@@ -13,14 +13,19 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Scrutor" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../StellaOps.BinaryIndex.FixIndex/StellaOps.BinaryIndex.FixIndex.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,186 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.BinaryIndex.Contracts.Resolution;
/// <summary>
/// Request to resolve vulnerability status for a binary.
/// </summary>
public sealed record VulnResolutionRequest
{
/// <summary>
/// Package URL (PURL) or CPE identifier.
/// </summary>
[Required]
public required string Package { get; init; }
/// <summary>
/// File path within container/filesystem.
/// </summary>
public string? FilePath { get; init; }
/// <summary>
/// ELF Build-ID, PE CodeView GUID, or Mach-O UUID.
/// </summary>
public string? BuildId { get; init; }
/// <summary>
/// Hash values for matching.
/// </summary>
public ResolutionHashes? Hashes { get; init; }
/// <summary>
/// Fingerprint bytes (Base64-encoded).
/// </summary>
public string? Fingerprint { get; init; }
/// <summary>
/// Fingerprint algorithm if fingerprint provided (e.g., "combined", "tlsh", "ssdeep").
/// </summary>
public string? FingerprintAlgorithm { get; init; }
/// <summary>
/// CVE to check (optional, for targeted queries). If not provided, checks all known CVEs.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Distro hint for fix status lookup (e.g., "debian:bookworm").
/// </summary>
public string? DistroRelease { get; init; }
}
/// <summary>
/// Hash values for binary matching.
/// </summary>
public sealed record ResolutionHashes
{
/// <summary>SHA-256 hash of the entire file.</summary>
public string? FileSha256 { get; init; }
/// <summary>SHA-256 hash of the .text section.</summary>
public string? TextSha256 { get; init; }
/// <summary>BLAKE3 hash (future-proof).</summary>
public string? Blake3 { get; init; }
}
/// <summary>
/// Response from vulnerability resolution.
/// </summary>
public sealed record VulnResolutionResponse
{
/// <summary>Package identifier from request.</summary>
public required string Package { get; init; }
/// <summary>Resolution status.</summary>
public required ResolutionStatus Status { get; init; }
/// <summary>Version where fix was applied (if status is Fixed).</summary>
public string? FixedVersion { get; init; }
/// <summary>Evidence supporting the resolution.</summary>
public ResolutionEvidence? Evidence { get; init; }
/// <summary>DSSE attestation envelope (Base64-encoded JSON).</summary>
public string? AttestationDsse { get; init; }
/// <summary>Timestamp when resolution was computed.</summary>
public DateTimeOffset ResolvedAt { get; init; }
/// <summary>Whether result was served from cache.</summary>
public bool FromCache { get; init; }
/// <summary>CVE ID if a specific CVE was queried.</summary>
public string? CveId { get; init; }
}
/// <summary>
/// Resolution status enumeration.
/// </summary>
public enum ResolutionStatus
{
/// <summary>Vulnerability is fixed in this binary (backport detected).</summary>
Fixed,
/// <summary>Binary is vulnerable.</summary>
Vulnerable,
/// <summary>Binary is not affected by this CVE.</summary>
NotAffected,
/// <summary>Resolution status unknown.</summary>
Unknown
}
/// <summary>
/// Evidence supporting a resolution decision.
/// </summary>
public sealed record ResolutionEvidence
{
/// <summary>Match method used (build_id, fingerprint, hash_exact).</summary>
public required string MatchType { get; init; }
/// <summary>Confidence score (0.0-1.0).</summary>
public decimal Confidence { get; init; }
/// <summary>Distro advisory ID (e.g., DSA-5343-1, RHSA-2024:1234).</summary>
public string? DistroAdvisoryId { get; init; }
/// <summary>SHA-256 of the security patch.</summary>
public string? PatchHash { get; init; }
/// <summary>List of matched fingerprint IDs.</summary>
public IReadOnlyList<string>? MatchedFingerprintIds { get; init; }
/// <summary>Summary of function-level differences.</summary>
public string? FunctionDiffSummary { get; init; }
/// <summary>Source package name.</summary>
public string? SourcePackage { get; init; }
/// <summary>Detection method (security_feed, changelog, patch_header).</summary>
public string? FixMethod { get; init; }
}
/// <summary>
/// Batch request for resolving multiple vulnerabilities.
/// </summary>
public sealed record BatchVulnResolutionRequest
{
/// <summary>List of resolution requests.</summary>
[Required]
public required IReadOnlyList<VulnResolutionRequest> Items { get; init; }
/// <summary>Resolution options.</summary>
public BatchResolutionOptions? Options { get; init; }
}
/// <summary>
/// Options for batch resolution.
/// </summary>
public sealed record BatchResolutionOptions
{
/// <summary>Bypass cache and perform fresh lookups.</summary>
public bool BypassCache { get; init; } = false;
/// <summary>Include DSSE attestation in responses.</summary>
public bool IncludeDsseAttestation { get; init; } = true;
}
/// <summary>
/// Response from batch vulnerability resolution.
/// </summary>
public sealed record BatchVulnResolutionResponse
{
/// <summary>List of resolution results.</summary>
public required IReadOnlyList<VulnResolutionResponse> Results { get; init; }
/// <summary>Total items processed.</summary>
public int TotalCount { get; init; }
/// <summary>Number of items served from cache.</summary>
public int CacheHits { get; init; }
/// <summary>Processing time in milliseconds.</summary>
public long ProcessingTimeMs { get; init; }
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>API contracts for BinaryIndex resolution endpoints</Description>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,360 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Contracts.Resolution;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
namespace StellaOps.BinaryIndex.Core.Resolution;
/// <summary>
/// Service for resolving binary vulnerability status.
/// </summary>
public interface IResolutionService
{
/// <summary>
/// Resolve vulnerability status for a single binary.
/// </summary>
Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Resolve vulnerability status for multiple binaries.
/// </summary>
Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// Options for resolution operations.
/// </summary>
public sealed record ResolutionOptions
{
/// <summary>Bypass cache and perform fresh lookups.</summary>
public bool BypassCache { get; init; } = false;
/// <summary>Include DSSE attestation in response.</summary>
public bool IncludeDsseAttestation { get; init; } = true;
/// <summary>Custom TTL for cache entries.</summary>
public TimeSpan? CacheTtl { get; init; }
/// <summary>Tenant ID for multi-tenancy.</summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Default resolution service configuration.
/// </summary>
public sealed class ResolutionServiceOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Resolution";
/// <summary>Default cache TTL.</summary>
public TimeSpan DefaultCacheTtl { get; set; } = TimeSpan.FromHours(4);
/// <summary>Maximum batch size.</summary>
public int MaxBatchSize { get; set; } = 500;
/// <summary>Enable DSSE attestation by default.</summary>
public bool EnableDsseByDefault { get; set; } = true;
/// <summary>Minimum confidence threshold for resolution.</summary>
public decimal MinConfidenceThreshold { get; set; } = 0.70m;
}
/// <summary>
/// Implementation of the resolution service.
/// </summary>
public sealed class ResolutionService : IResolutionService
{
private readonly IBinaryVulnerabilityService _vulnerabilityService;
private readonly ResolutionServiceOptions _options;
private readonly ILogger<ResolutionService> _logger;
public ResolutionService(
IBinaryVulnerabilityService vulnerabilityService,
IOptions<ResolutionServiceOptions> options,
ILogger<ResolutionService> logger)
{
_vulnerabilityService = vulnerabilityService ?? throw new ArgumentNullException(nameof(vulnerabilityService));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var effectiveOptions = options ?? new ResolutionOptions();
_logger.LogDebug("Resolving vulnerability for package {Package}", request.Package);
// Build binary identity from request
var identity = BuildBinaryIdentity(request);
// Perform lookup
var lookupOptions = new LookupOptions
{
DistroHint = ExtractDistro(request.DistroRelease),
ReleaseHint = ExtractRelease(request.DistroRelease),
TenantId = effectiveOptions.TenantId
};
// Check if specific CVE requested
if (!string.IsNullOrEmpty(request.CveId))
{
return await ResolveSingleCveAsync(request, identity, lookupOptions, effectiveOptions, sw, ct);
}
// Full lookup - all CVEs
return await ResolveAllCvesAsync(request, identity, lookupOptions, effectiveOptions, sw, ct);
}
/// <inheritdoc />
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();
var items = request.Items;
if (items.Count > _options.MaxBatchSize)
{
_logger.LogWarning("Batch size {Count} exceeds maximum {Max}, truncating",
items.Count, _options.MaxBatchSize);
items = items.Take(_options.MaxBatchSize).ToList();
}
var results = new List<VulnResolutionResponse>(items.Count);
var cacheHits = 0;
// Apply batch options
if (request.Options is not null)
{
effectiveOptions = effectiveOptions with
{
BypassCache = request.Options.BypassCache,
IncludeDsseAttestation = request.Options.IncludeDsseAttestation
};
}
foreach (var item in items)
{
ct.ThrowIfCancellationRequested();
try
{
var result = await ResolveAsync(item, effectiveOptions, ct);
results.Add(result);
if (result.FromCache)
cacheHits++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to resolve item {Package}", item.Package);
// Add error result
results.Add(new VulnResolutionResponse
{
Package = item.Package,
Status = ResolutionStatus.Unknown,
ResolvedAt = DateTimeOffset.UtcNow,
FromCache = false
});
}
}
return new BatchVulnResolutionResponse
{
Results = results,
TotalCount = results.Count,
CacheHits = cacheHits,
ProcessingTimeMs = sw.ElapsedMilliseconds
};
}
private async Task<VulnResolutionResponse> ResolveSingleCveAsync(
VulnResolutionRequest request,
BinaryIdentity identity,
LookupOptions lookupOptions,
ResolutionOptions options,
Stopwatch sw,
CancellationToken ct)
{
// Check fix status for specific CVE
var fixStatus = await _vulnerabilityService.GetFixStatusAsync(
ExtractDistro(request.DistroRelease) ?? "unknown",
ExtractRelease(request.DistroRelease) ?? "unknown",
ExtractSourcePackage(request.Package) ?? request.Package,
request.CveId!,
ct);
var (status, evidence) = MapFixStatusToResolution(fixStatus);
return new VulnResolutionResponse
{
Package = request.Package,
Status = status,
FixedVersion = fixStatus?.FixedVersion,
Evidence = evidence,
CveId = request.CveId,
ResolvedAt = DateTimeOffset.UtcNow,
FromCache = false
};
}
private async Task<VulnResolutionResponse> ResolveAllCvesAsync(
VulnResolutionRequest request,
BinaryIdentity identity,
LookupOptions lookupOptions,
ResolutionOptions options,
Stopwatch sw,
CancellationToken ct)
{
// Perform full binary lookup
var matches = await _vulnerabilityService.LookupByIdentityAsync(identity, lookupOptions, ct);
if (matches.IsEmpty)
{
_logger.LogDebug("No vulnerabilities found for {Package}", request.Package);
return new VulnResolutionResponse
{
Package = request.Package,
Status = ResolutionStatus.NotAffected,
ResolvedAt = DateTimeOffset.UtcNow,
FromCache = false
};
}
// Find the most severe/relevant match
var primaryMatch = matches.OrderByDescending(m => m.Confidence).First();
var evidence = new ResolutionEvidence
{
MatchType = primaryMatch.Method.ToString().ToLowerInvariant(),
Confidence = primaryMatch.Confidence,
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
};
// Map to resolution status
var status = primaryMatch.Method switch
{
MatchMethod.BuildIdCatalog => ResolutionStatus.Fixed,
MatchMethod.FingerprintMatch when primaryMatch.Confidence >= _options.MinConfidenceThreshold
=> ResolutionStatus.Fixed,
_ => ResolutionStatus.Unknown
};
return new VulnResolutionResponse
{
Package = request.Package,
Status = status,
Evidence = evidence,
ResolvedAt = DateTimeOffset.UtcNow,
FromCache = false
};
}
private static BinaryIdentity BuildBinaryIdentity(VulnResolutionRequest request)
{
var binaryKey = request.BuildId
?? request.Hashes?.FileSha256
?? request.Package;
return new BinaryIdentity
{
BinaryKey = binaryKey,
BuildId = request.BuildId,
FileSha256 = request.Hashes?.FileSha256 ?? "sha256:unknown",
TextSha256 = request.Hashes?.TextSha256,
Blake3Hash = request.Hashes?.Blake3,
Format = BinaryFormat.Elf,
Architecture = "unknown"
};
}
private static (ResolutionStatus Status, ResolutionEvidence? Evidence) MapFixStatusToResolution(
FixStatusResult? fixStatus)
{
if (fixStatus is null)
{
return (ResolutionStatus.Unknown, null);
}
var status = fixStatus.State switch
{
FixState.Fixed => ResolutionStatus.Fixed,
FixState.Vulnerable => ResolutionStatus.Vulnerable,
FixState.NotAffected => ResolutionStatus.NotAffected,
FixState.Wontfix => ResolutionStatus.NotAffected,
_ => ResolutionStatus.Unknown
};
var evidence = new ResolutionEvidence
{
MatchType = "fix_status",
Confidence = fixStatus.Confidence,
FixMethod = fixStatus.Method.ToString().ToLowerInvariant()
};
return (status, evidence);
}
private static string? ExtractDistro(string? distroRelease)
{
if (string.IsNullOrEmpty(distroRelease))
return null;
var parts = distroRelease.Split(':');
return parts.Length > 0 ? parts[0] : null;
}
private static string? ExtractRelease(string? distroRelease)
{
if (string.IsNullOrEmpty(distroRelease))
return null;
var parts = distroRelease.Split(':');
return parts.Length > 1 ? parts[1] : null;
}
private static string? ExtractSourcePackage(string purl)
{
if (string.IsNullOrEmpty(purl))
return null;
try
{
var parts = purl.Split('/');
if (parts.Length >= 3)
{
var nameVersion = parts[^1];
var atIndex = nameVersion.IndexOf('@');
return atIndex > 0 ? nameVersion[..atIndex] : nameVersion;
}
}
catch
{
// Ignore parsing errors
}
return null;
}
}

View File

@@ -96,6 +96,9 @@ public sealed record FingerprintLookupOptions
/// <summary>Release hint for fix status lookup.</summary>
public string? ReleaseHint { get; init; }
/// <summary>Fingerprint algorithm to use (e.g., "combined", "tlsh", "ssdeep").</summary>
public string? Algorithm { get; init; }
}
public sealed record LookupOptions
@@ -103,6 +106,7 @@ public sealed record LookupOptions
public bool CheckFixIndex { get; init; } = true;
public string? DistroHint { get; init; }
public string? ReleaseHint { get; init; }
public string? TenantId { get; init; }
}
public sealed record BinaryVulnMatch

View File

@@ -8,7 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.BinaryIndex.Contracts\StellaOps.BinaryIndex.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -78,7 +78,7 @@ public sealed class AlpinePackageExtractor
try
{
var identity = await _featureExtractor.ExtractIdentityAsync(ms, entry.Key ?? "", ct);
var identity = await _featureExtractor.ExtractIdentityAsync(ms, ct);
results.Add(new ExtractedBinaryInfo(identity, entry.Key ?? ""));
}
catch (Exception ex)
@@ -102,7 +102,7 @@ public sealed class AlpinePackageExtractor
// We need to skip to the data.tar.gz portion
// The structure is: signature.tar.gz + control.tar.gz + data.tar.gz
using var gzip = new GZipStream(apkStream, SharpCompress.Compressors.CompressionMode.Decompress, leaveOpen: true);
using var gzip = new GZipStream(apkStream, SharpCompress.Compressors.CompressionMode.Decompress);
using var ms = new MemoryStream();
await gzip.CopyToAsync(ms, ct);
ms.Position = 0;

View File

@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpCompress" Version="0.38.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="SharpCompress" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpCompress" Version="0.38.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="SharpCompress" />
</ItemGroup>
<ItemGroup>

View File

@@ -7,7 +7,7 @@
using Microsoft.Extensions.Logging;
using SharpCompress.Archives;
using SharpCompress.Compressors.Xz;
using SharpCompress.Readers.Cpio;
using SharpCompress.Readers;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.BinaryIndex.Corpus;
@@ -60,7 +60,7 @@ public sealed class RpmPackageExtractor
return results;
}
using var reader = CpioReader.Open(payloadStream);
using var reader = ReaderFactory.Open(payloadStream);
while (reader.MoveToNextEntry())
{
ct.ThrowIfCancellationRequested();
@@ -82,7 +82,7 @@ public sealed class RpmPackageExtractor
try
{
var identity = await _featureExtractor.ExtractIdentityAsync(ms, reader.Entry.Key ?? "", ct);
var identity = await _featureExtractor.ExtractIdentityAsync(ms, ct);
results.Add(new ExtractedBinaryInfo(identity, reader.Entry.Key ?? ""));
}
catch (Exception ex)

View File

@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpCompress" Version="0.38.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="SharpCompress" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

@@ -37,7 +37,8 @@ public sealed partial class AlpineSecfixesParser : ISecfixesParser
if (string.IsNullOrWhiteSpace(apkbuild))
yield break;
var lines = apkbuild.Split('\n');
// Normalize line endings to handle both Unix and Windows formats
var lines = apkbuild.ReplaceLineEndings("\n").Split('\n');
var inSecfixes = false;
string? currentVersion = null;

View File

@@ -30,7 +30,8 @@ public sealed partial class DebianChangelogParser : IChangelogParser
if (string.IsNullOrWhiteSpace(changelog))
yield break;
var lines = changelog.Split('\n');
// Normalize line endings to handle both Unix and Windows formats
var lines = changelog.ReplaceLineEndings("\n").Split('\n');
if (lines.Length == 0)
yield break;

View File

@@ -25,7 +25,8 @@ public sealed partial class PatchHeaderParser : IPatchParser
foreach (var (path, content, sha256) in patches)
{
// Read first 80 lines as header (typical patch header size)
var headerLines = content.Split('\n').Take(80);
// Normalize line endings to handle both Unix and Windows formats
var headerLines = content.ReplaceLineEndings("\n").Split('\n').Take(80);
var header = string.Join('\n', headerLines);
// Also check filename for CVE (e.g., "CVE-2024-1234.patch")

View File

@@ -39,7 +39,8 @@ public sealed partial class RpmChangelogParser : IChangelogParser
if (string.IsNullOrWhiteSpace(specContent))
yield break;
var lines = specContent.Split('\n');
// Normalize line endings to handle both Unix and Windows formats
var lines = specContent.ReplaceLineEndings("\n").Split('\n');
var inChangelog = false;
var inFirstEntry = false;
string? currentVersion = null;
@@ -128,7 +129,8 @@ public sealed partial class RpmChangelogParser : IChangelogParser
if (string.IsNullOrWhiteSpace(specContent))
yield break;
var lines = specContent.Split('\n');
// Normalize line endings to handle both Unix and Windows formats
var lines = specContent.ReplaceLineEndings("\n").Split('\n');
var inChangelog = false;
string? currentVersion = null;
var currentEntry = new List<string>();

View File

@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,432 @@
-- =============================================================================
-- 001_initial_schema.sql
-- Consolidated initial schema for BinaryIndex module
-- Combines: 001_create_binaries_schema, 002_create_fingerprint_tables,
-- 003_create_fix_index_tables, 20251226_AddFingerprintTables
-- Date: 2025-12-27
-- Note: Transaction control handled by MigrationRunner, not this script
-- =============================================================================
-- =============================================================================
-- SCHEMA CREATION
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS binaries;
CREATE SCHEMA IF NOT EXISTS binaries_app;
-- RLS helper function
CREATE OR REPLACE FUNCTION binaries_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
DECLARE
v_tenant TEXT;
BEGIN
v_tenant := current_setting('app.tenant_id', true);
IF v_tenant IS NULL OR v_tenant = '' THEN
RAISE EXCEPTION 'app.tenant_id session variable not set';
END IF;
RETURN v_tenant;
END;
$$;
-- =============================================================================
-- CORE TABLES
-- =============================================================================
-- binary_identity: Core binary identification table
CREATE TABLE IF NOT EXISTS binaries.binary_identity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
binary_key TEXT NOT NULL,
build_id TEXT,
build_id_type TEXT CHECK (build_id_type IN ('gnu-build-id', 'pe-cv', 'macho-uuid')),
file_sha256 TEXT NOT NULL,
text_sha256 TEXT,
blake3_hash TEXT,
format TEXT NOT NULL CHECK (format IN ('elf', 'pe', 'macho')),
architecture TEXT NOT NULL,
osabi TEXT,
binary_type TEXT CHECK (binary_type IN ('executable', 'shared_library', 'static_library', 'object')),
is_stripped BOOLEAN DEFAULT FALSE,
first_seen_snapshot_id UUID,
last_seen_snapshot_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT binary_identity_key_unique UNIQUE (tenant_id, binary_key)
);
-- corpus_snapshots: Distribution corpus snapshots
CREATE TABLE IF NOT EXISTS binaries.corpus_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
distro TEXT NOT NULL,
release TEXT NOT NULL,
architecture TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
packages_processed INT NOT NULL DEFAULT 0,
binaries_indexed INT NOT NULL DEFAULT 0,
repo_metadata_digest TEXT,
signing_key_id TEXT,
dsse_envelope_ref TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
error TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT corpus_snapshots_unique UNIQUE (tenant_id, distro, release, architecture, snapshot_id)
);
-- binary_package_map: Mapping binaries to packages
CREATE TABLE IF NOT EXISTS binaries.binary_package_map (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
binary_identity_id UUID NOT NULL REFERENCES binaries.binary_identity(id) ON DELETE CASCADE,
binary_key TEXT NOT NULL,
distro TEXT NOT NULL,
release TEXT NOT NULL,
source_pkg TEXT NOT NULL,
binary_pkg TEXT NOT NULL,
pkg_version TEXT NOT NULL,
pkg_purl TEXT,
architecture TEXT NOT NULL,
file_path_in_pkg TEXT NOT NULL,
snapshot_id UUID NOT NULL REFERENCES binaries.corpus_snapshots(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT binary_package_map_unique UNIQUE (binary_identity_id, snapshot_id, file_path_in_pkg)
);
-- vulnerable_buildids: Known vulnerable build IDs
CREATE TABLE IF NOT EXISTS binaries.vulnerable_buildids (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
buildid_type TEXT NOT NULL CHECK (buildid_type IN ('gnu-build-id', 'pe-cv', 'macho-uuid')),
buildid_value TEXT NOT NULL,
purl TEXT NOT NULL,
pkg_version TEXT NOT NULL,
distro TEXT,
release TEXT,
confidence TEXT NOT NULL DEFAULT 'exact' CHECK (confidence IN ('exact', 'inferred', 'heuristic')),
provenance JSONB DEFAULT '{}',
snapshot_id UUID REFERENCES binaries.corpus_snapshots(id),
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT vulnerable_buildids_unique UNIQUE (tenant_id, buildid_value, buildid_type, purl, pkg_version)
);
-- binary_vuln_assertion: Vulnerability assertions for binaries
CREATE TABLE IF NOT EXISTS binaries.binary_vuln_assertion (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
binary_key TEXT NOT NULL,
binary_identity_id UUID REFERENCES binaries.binary_identity(id),
cve_id TEXT NOT NULL,
advisory_id UUID,
status TEXT NOT NULL CHECK (status IN ('affected', 'not_affected', 'fixed', 'unknown')),
method TEXT NOT NULL CHECK (method IN ('range_match', 'buildid_catalog', 'fingerprint_match', 'fix_index')),
confidence NUMERIC(3,2) CHECK (confidence >= 0 AND confidence <= 1),
evidence_ref TEXT,
evidence_digest TEXT,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT binary_vuln_assertion_unique UNIQUE (tenant_id, binary_key, cve_id)
);
-- =============================================================================
-- FIX INDEX TABLES
-- =============================================================================
-- fix_evidence: Audit trail for how fix status was determined
CREATE TABLE IF NOT EXISTS binaries.fix_evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL DEFAULT binaries_app.require_current_tenant(),
evidence_type TEXT NOT NULL CHECK (evidence_type IN ('changelog', 'patch_header', 'security_feed', 'upstream_match')),
source_file TEXT,
source_sha256 TEXT,
excerpt TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
snapshot_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- cve_fix_index: Patch-aware CVE fix status per distro/release/package
CREATE TABLE IF NOT EXISTS binaries.cve_fix_index (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL DEFAULT binaries_app.require_current_tenant(),
distro TEXT NOT NULL,
release TEXT NOT NULL,
source_pkg TEXT NOT NULL,
cve_id TEXT NOT NULL,
architecture TEXT,
state TEXT NOT NULL CHECK (state IN ('fixed', 'vulnerable', 'not_affected', 'wontfix', 'unknown')),
fixed_version TEXT,
method TEXT NOT NULL CHECK (method IN ('security_feed', 'changelog', 'patch_header', 'upstream_match')),
confidence DECIMAL(3,2) NOT NULL CHECK (confidence >= 0.00 AND confidence <= 1.00),
evidence_id UUID REFERENCES binaries.fix_evidence(id),
snapshot_id UUID,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT cve_fix_index_unique UNIQUE (tenant_id, distro, release, source_pkg, cve_id, architecture)
);
-- fix_index_priority: Resolution priority when multiple sources conflict
CREATE TABLE IF NOT EXISTS binaries.fix_index_priority (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL DEFAULT binaries_app.require_current_tenant(),
priority INTEGER NOT NULL,
method TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT fix_index_priority_unique UNIQUE (tenant_id, method)
);
-- =============================================================================
-- FINGERPRINT TABLES
-- =============================================================================
-- vulnerable_fingerprints: Function-level vulnerability fingerprints
CREATE TABLE IF NOT EXISTS binaries.vulnerable_fingerprints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL DEFAULT binaries_app.require_current_tenant(),
cve_id TEXT NOT NULL,
component TEXT NOT NULL,
purl TEXT,
algorithm TEXT NOT NULL CHECK (algorithm IN ('basic_block', 'cfg', 'control_flow_graph', 'string_refs', 'combined')),
fingerprint_id TEXT NOT NULL,
fingerprint_hash BYTEA NOT NULL,
architecture TEXT NOT NULL,
function_name TEXT,
source_file TEXT,
source_line INT,
similarity_threshold DECIMAL(3,2) DEFAULT 0.95 CHECK (similarity_threshold BETWEEN 0 AND 1),
confidence DECIMAL(3,2) CHECK (confidence IS NULL OR confidence BETWEEN 0 AND 1),
validated BOOLEAN DEFAULT false,
validation_stats JSONB DEFAULT '{}',
vuln_build_ref TEXT,
fixed_build_ref TEXT,
notes TEXT,
evidence_ref TEXT,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT vulnerable_fingerprints_unique UNIQUE (tenant_id, fingerprint_id)
);
-- fingerprint_corpus_metadata: Metadata about fingerprinted packages
CREATE TABLE IF NOT EXISTS binaries.fingerprint_corpus_metadata (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
purl TEXT NOT NULL,
version TEXT NOT NULL,
algorithm TEXT NOT NULL,
binary_digest TEXT,
function_count INT NOT NULL DEFAULT 0,
fingerprints_indexed INT NOT NULL DEFAULT 0,
indexed_by TEXT,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fingerprint_corpus_metadata_unique UNIQUE (tenant_id, purl, version, algorithm)
);
-- fingerprint_matches: Results of fingerprint matching operations
CREATE TABLE IF NOT EXISTS binaries.fingerprint_matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL DEFAULT binaries_app.require_current_tenant(),
scan_id UUID NOT NULL,
match_type TEXT NOT NULL CHECK (match_type IN ('fingerprint', 'build_id', 'buildid', 'hash_exact')),
binary_key TEXT NOT NULL,
binary_identity_id UUID REFERENCES binaries.binary_identity(id),
vulnerable_purl TEXT NOT NULL,
vulnerable_version TEXT NOT NULL,
matched_fingerprint_id UUID REFERENCES binaries.vulnerable_fingerprints(id),
matched_function TEXT,
similarity DECIMAL(3,2) CHECK (similarity IS NULL OR similarity BETWEEN 0 AND 1),
advisory_ids TEXT[],
reachability_status TEXT CHECK (reachability_status IN ('reachable', 'unreachable', 'unknown', 'partial')),
evidence JSONB DEFAULT '{}',
matched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =============================================================================
-- INDEXES - CORE TABLES
-- =============================================================================
CREATE INDEX IF NOT EXISTS idx_binary_identity_tenant ON binaries.binary_identity(tenant_id);
CREATE INDEX IF NOT EXISTS idx_binary_identity_buildid ON binaries.binary_identity(build_id) WHERE build_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_binary_identity_sha256 ON binaries.binary_identity(file_sha256);
CREATE INDEX IF NOT EXISTS idx_binary_identity_key ON binaries.binary_identity(binary_key);
CREATE INDEX IF NOT EXISTS idx_binary_package_map_tenant ON binaries.binary_package_map(tenant_id);
CREATE INDEX IF NOT EXISTS idx_binary_package_map_binary ON binaries.binary_package_map(binary_identity_id);
CREATE INDEX IF NOT EXISTS idx_binary_package_map_distro ON binaries.binary_package_map(distro, release, source_pkg);
CREATE INDEX IF NOT EXISTS idx_binary_package_map_snapshot ON binaries.binary_package_map(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_corpus_snapshots_tenant ON binaries.corpus_snapshots(tenant_id);
CREATE INDEX IF NOT EXISTS idx_corpus_snapshots_distro ON binaries.corpus_snapshots(distro, release, architecture);
CREATE INDEX IF NOT EXISTS idx_corpus_snapshots_status ON binaries.corpus_snapshots(status) WHERE status IN ('pending', 'processing');
CREATE INDEX IF NOT EXISTS idx_vulnerable_buildids_tenant ON binaries.vulnerable_buildids(tenant_id);
CREATE INDEX IF NOT EXISTS idx_vulnerable_buildids_value ON binaries.vulnerable_buildids(buildid_type, buildid_value);
CREATE INDEX IF NOT EXISTS idx_vulnerable_buildids_purl ON binaries.vulnerable_buildids(purl);
CREATE INDEX IF NOT EXISTS idx_binary_vuln_assertion_tenant ON binaries.binary_vuln_assertion(tenant_id);
CREATE INDEX IF NOT EXISTS idx_binary_vuln_assertion_binary ON binaries.binary_vuln_assertion(binary_key);
CREATE INDEX IF NOT EXISTS idx_binary_vuln_assertion_cve ON binaries.binary_vuln_assertion(cve_id);
-- =============================================================================
-- INDEXES - FIX INDEX TABLES
-- =============================================================================
CREATE INDEX IF NOT EXISTS idx_fix_evidence_snapshot ON binaries.fix_evidence(tenant_id, snapshot_id);
CREATE INDEX IF NOT EXISTS idx_cve_fix_lookup ON binaries.cve_fix_index(tenant_id, distro, release, source_pkg, cve_id);
CREATE INDEX IF NOT EXISTS idx_cve_fix_by_cve ON binaries.cve_fix_index(tenant_id, cve_id, distro, release);
CREATE INDEX IF NOT EXISTS idx_cve_fix_by_version ON binaries.cve_fix_index(tenant_id, distro, release, source_pkg, fixed_version);
CREATE INDEX IF NOT EXISTS idx_cve_fix_snapshot ON binaries.cve_fix_index(tenant_id, snapshot_id);
CREATE INDEX IF NOT EXISTS idx_cve_fix_by_state ON binaries.cve_fix_index(tenant_id, distro, release, state);
-- =============================================================================
-- INDEXES - FINGERPRINT TABLES
-- =============================================================================
CREATE INDEX IF NOT EXISTS idx_fingerprint_cve ON binaries.vulnerable_fingerprints(tenant_id, cve_id);
CREATE INDEX IF NOT EXISTS idx_fingerprint_component ON binaries.vulnerable_fingerprints(tenant_id, component);
CREATE INDEX IF NOT EXISTS idx_fingerprint_algorithm ON binaries.vulnerable_fingerprints(tenant_id, algorithm, architecture);
CREATE INDEX IF NOT EXISTS idx_fingerprint_hash ON binaries.vulnerable_fingerprints USING hash (fingerprint_hash);
CREATE INDEX IF NOT EXISTS idx_fingerprint_validated ON binaries.vulnerable_fingerprints(tenant_id, validated) WHERE validated = true;
CREATE INDEX IF NOT EXISTS idx_fingerprint_corpus_tenant ON binaries.fingerprint_corpus_metadata(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fingerprint_corpus_purl ON binaries.fingerprint_corpus_metadata(purl, version);
CREATE INDEX IF NOT EXISTS idx_match_scan ON binaries.fingerprint_matches(tenant_id, scan_id);
CREATE INDEX IF NOT EXISTS idx_match_fingerprint ON binaries.fingerprint_matches(matched_fingerprint_id);
CREATE INDEX IF NOT EXISTS idx_match_binary ON binaries.fingerprint_matches(tenant_id, binary_key);
CREATE INDEX IF NOT EXISTS idx_match_reachability ON binaries.fingerprint_matches(tenant_id, reachability_status);
-- =============================================================================
-- ROW-LEVEL SECURITY - CORE TABLES
-- =============================================================================
ALTER TABLE binaries.binary_identity ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.binary_identity FORCE ROW LEVEL SECURITY;
CREATE POLICY binary_identity_tenant_isolation ON binaries.binary_identity
FOR ALL USING (tenant_id::text = binaries_app.require_current_tenant())
WITH CHECK (tenant_id::text = binaries_app.require_current_tenant());
ALTER TABLE binaries.corpus_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.corpus_snapshots FORCE ROW LEVEL SECURITY;
CREATE POLICY corpus_snapshots_tenant_isolation ON binaries.corpus_snapshots
FOR ALL USING (tenant_id::text = binaries_app.require_current_tenant())
WITH CHECK (tenant_id::text = binaries_app.require_current_tenant());
ALTER TABLE binaries.binary_package_map ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.binary_package_map FORCE ROW LEVEL SECURITY;
CREATE POLICY binary_package_map_tenant_isolation ON binaries.binary_package_map
FOR ALL USING (tenant_id::text = binaries_app.require_current_tenant())
WITH CHECK (tenant_id::text = binaries_app.require_current_tenant());
ALTER TABLE binaries.vulnerable_buildids ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.vulnerable_buildids FORCE ROW LEVEL SECURITY;
CREATE POLICY vulnerable_buildids_tenant_isolation ON binaries.vulnerable_buildids
FOR ALL USING (tenant_id::text = binaries_app.require_current_tenant())
WITH CHECK (tenant_id::text = binaries_app.require_current_tenant());
ALTER TABLE binaries.binary_vuln_assertion ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.binary_vuln_assertion FORCE ROW LEVEL SECURITY;
CREATE POLICY binary_vuln_assertion_tenant_isolation ON binaries.binary_vuln_assertion
FOR ALL USING (tenant_id::text = binaries_app.require_current_tenant())
WITH CHECK (tenant_id::text = binaries_app.require_current_tenant());
-- =============================================================================
-- ROW-LEVEL SECURITY - FIX INDEX TABLES
-- =============================================================================
ALTER TABLE binaries.fix_evidence ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.fix_evidence FORCE ROW LEVEL SECURITY;
CREATE POLICY fix_evidence_tenant_isolation ON binaries.fix_evidence
USING (tenant_id = binaries_app.require_current_tenant());
ALTER TABLE binaries.cve_fix_index ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.cve_fix_index FORCE ROW LEVEL SECURITY;
CREATE POLICY cve_fix_index_tenant_isolation ON binaries.cve_fix_index
USING (tenant_id = binaries_app.require_current_tenant());
ALTER TABLE binaries.fix_index_priority ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.fix_index_priority FORCE ROW LEVEL SECURITY;
CREATE POLICY fix_index_priority_tenant_isolation ON binaries.fix_index_priority
USING (tenant_id = binaries_app.require_current_tenant());
-- =============================================================================
-- ROW-LEVEL SECURITY - FINGERPRINT TABLES
-- =============================================================================
ALTER TABLE binaries.vulnerable_fingerprints ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.vulnerable_fingerprints FORCE ROW LEVEL SECURITY;
CREATE POLICY vulnerable_fingerprints_tenant_isolation ON binaries.vulnerable_fingerprints
USING (tenant_id = binaries_app.require_current_tenant());
ALTER TABLE binaries.fingerprint_corpus_metadata ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.fingerprint_corpus_metadata FORCE ROW LEVEL SECURITY;
CREATE POLICY fingerprint_corpus_metadata_tenant_isolation ON binaries.fingerprint_corpus_metadata
FOR ALL USING (tenant_id::text = binaries_app.require_current_tenant())
WITH CHECK (tenant_id::text = binaries_app.require_current_tenant());
ALTER TABLE binaries.fingerprint_matches ENABLE ROW LEVEL SECURITY;
ALTER TABLE binaries.fingerprint_matches FORCE ROW LEVEL SECURITY;
CREATE POLICY fingerprint_matches_tenant_isolation ON binaries.fingerprint_matches
USING (tenant_id = binaries_app.require_current_tenant());
-- =============================================================================
-- TABLE COMMENTS
-- =============================================================================
COMMENT ON TABLE binaries.binary_identity IS
'Core binary identification table storing file hashes, build IDs, and metadata';
COMMENT ON TABLE binaries.corpus_snapshots IS
'Distribution corpus snapshots tracking package indexing progress';
COMMENT ON TABLE binaries.binary_package_map IS
'Maps binaries to their source and binary packages within distributions';
COMMENT ON TABLE binaries.vulnerable_buildids IS
'Known vulnerable build IDs for direct binary matching';
COMMENT ON TABLE binaries.binary_vuln_assertion IS
'Vulnerability assertions for specific binaries with evidence references';
COMMENT ON TABLE binaries.fix_evidence IS
'Audit trail for CVE fix determinations, storing excerpts and metadata for traceability';
COMMENT ON TABLE binaries.cve_fix_index IS
'Patch-aware CVE fix index enabling accurate vulnerability status despite version pinning';
COMMENT ON COLUMN binaries.cve_fix_index.confidence IS
'Confidence score: security_feed=0.99, patch_header=0.90, changelog=0.80, upstream_match=0.85';
COMMENT ON COLUMN binaries.cve_fix_index.method IS
'How fix status was determined: security_feed (OVAL/DSA), changelog, patch_header (DEP-3), upstream_match';
COMMENT ON TABLE binaries.fix_index_priority IS
'Resolution priority when multiple sources conflict (lower priority number = higher precedence)';
COMMENT ON TABLE binaries.vulnerable_fingerprints IS
'Function-level vulnerability fingerprints for detecting vulnerable code independent of package metadata';
COMMENT ON COLUMN binaries.vulnerable_fingerprints.algorithm IS
'Fingerprinting algorithm: basic_block, cfg (control flow graph), string_refs, or combined (ensemble)';
COMMENT ON COLUMN binaries.vulnerable_fingerprints.fingerprint_hash IS
'Binary fingerprint data (16-48 bytes depending on algorithm)';
COMMENT ON COLUMN binaries.vulnerable_fingerprints.validation_stats IS
'JSON object with tp, fp, tn, fn counts from validation corpus';
COMMENT ON TABLE binaries.fingerprint_corpus_metadata IS
'Metadata about fingerprinted packages including function counts and indexing status';
COMMENT ON TABLE binaries.fingerprint_matches IS
'Results of fingerprint matching operations during scans';
COMMENT ON COLUMN binaries.fingerprint_matches.similarity IS
'Similarity score (0.0-1.0) for fingerprint matches';

View File

@@ -0,0 +1,251 @@
-- Migration: 002_fingerprint_claims
-- Description: Adds tables for function-level fingerprints and CVE claims
-- Created: 2025-12-28
-- Sprint: SPRINT_1227_0002_0001 - Reproducible Builders
-- Ensure schema exists
CREATE SCHEMA IF NOT EXISTS binary_index;
-- ============================================================================
-- Function-level fingerprints (child of binary_fingerprints)
-- Stores per-function hashes for fine-grained CVE attribution
-- ============================================================================
CREATE TABLE IF NOT EXISTS binary_index.function_fingerprints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Parent binary fingerprint
binary_fingerprint_id UUID NOT NULL,
-- Function identification
function_name TEXT NOT NULL,
function_offset BIGINT NOT NULL,
function_size INT NOT NULL,
-- Multi-algorithm fingerprints for robust matching
basic_block_hash BYTEA NOT NULL, -- Hash of opcode sequences
cfg_hash BYTEA NOT NULL, -- Hash of control flow graph
string_refs_hash BYTEA NOT NULL, -- Hash of string references
combined_hash BYTEA, -- Combined fingerprint (optional)
-- Call graph (optional)
callees TEXT[], -- Functions this function calls
callers TEXT[], -- Functions that call this function
-- Metadata
is_exported BOOLEAN NOT NULL DEFAULT false,
has_debug_info BOOLEAN NOT NULL DEFAULT false,
source_file TEXT,
source_line INT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Unique constraint: one entry per function per binary
CONSTRAINT uq_function_fingerprints_binary_func
UNIQUE (binary_fingerprint_id, function_name, function_offset)
);
-- Indexes for function fingerprints
CREATE INDEX IF NOT EXISTS idx_function_fingerprints_binary
ON binary_index.function_fingerprints(binary_fingerprint_id);
CREATE INDEX IF NOT EXISTS idx_function_fingerprints_name
ON binary_index.function_fingerprints(function_name);
CREATE INDEX IF NOT EXISTS idx_function_fingerprints_basic_block
ON binary_index.function_fingerprints USING hash(basic_block_hash);
CREATE INDEX IF NOT EXISTS idx_function_fingerprints_combined
ON binary_index.function_fingerprints USING hash(combined_hash)
WHERE combined_hash IS NOT NULL;
-- ============================================================================
-- Fingerprint CVE claims
-- Records assertions about whether a fingerprint contains a CVE fix
-- ============================================================================
CREATE TABLE IF NOT EXISTS binary_index.fingerprint_claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Target fingerprint (can be binary-level or function-level)
fingerprint_id UUID NOT NULL,
-- CVE identification
cve_id TEXT NOT NULL,
-- Verdict
verdict TEXT NOT NULL CHECK (verdict IN ('fixed', 'vulnerable', 'unknown')),
-- Confidence in this claim (0.0-1.0)
confidence NUMERIC(4,3) NOT NULL DEFAULT 1.0,
-- Evidence (JSONB for flexibility)
evidence JSONB NOT NULL,
-- Attestation reference (if signed)
attestation_dsse_hash TEXT,
-- Source/provenance
source TEXT, -- e.g., "repro-builder-alpine", "manual", "advisory-import"
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
-- Unique constraint: one claim per fingerprint+CVE
CONSTRAINT uq_fingerprint_claims_fingerprint_cve
UNIQUE (fingerprint_id, cve_id)
);
-- Indexes for fingerprint claims
CREATE INDEX IF NOT EXISTS idx_fingerprint_claims_fingerprint
ON binary_index.fingerprint_claims(fingerprint_id);
CREATE INDEX IF NOT EXISTS idx_fingerprint_claims_cve
ON binary_index.fingerprint_claims(cve_id);
CREATE INDEX IF NOT EXISTS idx_fingerprint_claims_verdict
ON binary_index.fingerprint_claims(verdict)
WHERE verdict = 'fixed';
CREATE INDEX IF NOT EXISTS idx_fingerprint_claims_source
ON binary_index.fingerprint_claims(source)
WHERE source IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_fingerprint_claims_confidence
ON binary_index.fingerprint_claims(confidence DESC)
WHERE confidence < 1.0;
-- GIN index on evidence JSONB for querying specific fields
CREATE INDEX IF NOT EXISTS idx_fingerprint_claims_evidence
ON binary_index.fingerprint_claims USING gin(evidence);
-- ============================================================================
-- Reproducible build records
-- Tracks builds performed for fingerprint generation
-- ============================================================================
CREATE TABLE IF NOT EXISTS binary_index.reproducible_builds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Build identification
request_id TEXT UNIQUE, -- Client-provided correlation ID
-- Package info
source_package TEXT NOT NULL,
version TEXT NOT NULL,
distro TEXT NOT NULL,
release TEXT NOT NULL,
architecture TEXT NOT NULL DEFAULT 'x86_64',
-- Build status
status TEXT NOT NULL CHECK (status IN ('pending', 'building', 'success', 'failed')),
error_message TEXT,
-- Timing
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
duration_ms BIGINT,
-- Reproducibility
source_date_epoch BIGINT,
builder_image TEXT,
-- Artifact references (content-addressed)
build_log_ref TEXT,
artifact_ref TEXT,
-- Patches applied (if any)
patches JSONB, -- Array of {cve_id, patch_url, commit_id}
-- Results summary
binaries_produced INT,
functions_extracted INT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for reproducible builds
CREATE INDEX IF NOT EXISTS idx_reproducible_builds_package
ON binary_index.reproducible_builds(source_package, version);
CREATE INDEX IF NOT EXISTS idx_reproducible_builds_distro
ON binary_index.reproducible_builds(distro, release);
CREATE INDEX IF NOT EXISTS idx_reproducible_builds_status
ON binary_index.reproducible_builds(status)
WHERE status IN ('pending', 'building');
CREATE INDEX IF NOT EXISTS idx_reproducible_builds_created
ON binary_index.reproducible_builds(created_at DESC);
-- ============================================================================
-- Build output binaries
-- Links reproducible builds to the fingerprints they generated
-- ============================================================================
CREATE TABLE IF NOT EXISTS binary_index.build_outputs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Parent build
build_id UUID NOT NULL REFERENCES binary_index.reproducible_builds(id) ON DELETE CASCADE,
-- Output binary info
binary_path TEXT NOT NULL,
build_id_hash TEXT NOT NULL, -- ELF Build-ID
-- Generated fingerprint
fingerprint_id UUID, -- References binary_fingerprints (when created)
-- Hashes
file_sha256 BYTEA NOT NULL,
text_sha256 BYTEA NOT NULL,
combined_fingerprint BYTEA NOT NULL,
-- Metadata
format TEXT NOT NULL DEFAULT 'elf',
architecture TEXT,
is_stripped BOOLEAN NOT NULL DEFAULT false,
-- Function extraction stats
functions_extracted INT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for build outputs
CREATE INDEX IF NOT EXISTS idx_build_outputs_build
ON binary_index.build_outputs(build_id);
CREATE INDEX IF NOT EXISTS idx_build_outputs_build_id_hash
ON binary_index.build_outputs(build_id_hash);
CREATE INDEX IF NOT EXISTS idx_build_outputs_fingerprint
ON binary_index.build_outputs(fingerprint_id)
WHERE fingerprint_id IS NOT NULL;
-- ============================================================================
-- Comments for documentation
-- ============================================================================
COMMENT ON TABLE binary_index.function_fingerprints IS
'Per-function fingerprints for fine-grained CVE attribution. Generated from reproducible builds.';
COMMENT ON TABLE binary_index.fingerprint_claims IS
'CVE fix/vulnerability claims for fingerprints. Evidence from reproducible build diffing.';
COMMENT ON TABLE binary_index.reproducible_builds IS
'Records of reproducible builds performed for fingerprint corpus generation.';
COMMENT ON TABLE binary_index.build_outputs IS
'Binary artifacts produced by reproducible builds with their fingerprints.';
COMMENT ON COLUMN binary_index.function_fingerprints.basic_block_hash IS
'SHA-256 of normalized opcode sequences (ignoring operand values)';
COMMENT ON COLUMN binary_index.function_fingerprints.cfg_hash IS
'SHA-256 of control flow graph structure (branch patterns)';
COMMENT ON COLUMN binary_index.function_fingerprints.string_refs_hash IS
'SHA-256 of string literals referenced by the function';
COMMENT ON COLUMN binary_index.fingerprint_claims.evidence IS
'JSONB containing: patch_commit, changed_functions[], function_similarities{}, build_refs';

View File

@@ -31,21 +31,21 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
distro,
release,
architecture,
metadata_digest,
captured_at,
snapshot_id,
repo_metadata_digest,
created_at
)
VALUES (
@Id,
binaries_app.current_tenant()::uuid,
binaries_app.require_current_tenant()::uuid,
@Distro,
@Release,
@Architecture,
@SnapshotId,
@MetadataDigest,
@CapturedAt,
NOW()
)
RETURNING id, distro, release, architecture, metadata_digest, captured_at
RETURNING id, distro, release, architecture, repo_metadata_digest AS metadata_digest, created_at AS captured_at
""";
var row = await conn.QuerySingleAsync<CorpusSnapshotRow>(sql, new
@@ -54,8 +54,8 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
snapshot.Distro,
snapshot.Release,
snapshot.Architecture,
snapshot.MetadataDigest,
snapshot.CapturedAt
SnapshotId = $"{snapshot.Distro}_{snapshot.Release}_{snapshot.Architecture}_{snapshot.CapturedAt:yyyyMMddHHmmss}",
snapshot.MetadataDigest
});
_logger.LogInformation(
@@ -74,12 +74,14 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
await using var conn = await _dbContext.OpenConnectionAsync(ct);
const string sql = """
SELECT id, distro, release, architecture, metadata_digest, captured_at
SELECT id, distro, release, architecture,
repo_metadata_digest AS metadata_digest,
created_at AS captured_at
FROM binaries.corpus_snapshots
WHERE distro = @Distro
AND release = @Release
AND architecture = @Architecture
ORDER BY captured_at DESC
ORDER BY created_at DESC
LIMIT 1
""";
@@ -98,7 +100,9 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
await using var conn = await _dbContext.OpenConnectionAsync(ct);
const string sql = """
SELECT id, distro, release, architecture, metadata_digest, captured_at
SELECT id, distro, release, architecture,
repo_metadata_digest AS metadata_digest,
created_at AS captured_at
FROM binaries.corpus_snapshots
WHERE id = @Id
""";

View File

@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,122 @@
using System.Text.Json.Nodes;
namespace StellaOps.BinaryIndex.VexBridge;
/// <summary>
/// Constants and schema definitions for binary match evidence in VEX observations.
/// </summary>
public static class BinaryMatchEvidenceSchema
{
/// <summary>Evidence type identifier for binary fingerprint matches.</summary>
public const string EvidenceType = "binary_fingerprint_match";
/// <summary>Schema version for evidence payloads.</summary>
public const string SchemaVersion = "1.0";
/// <summary>Evidence field names.</summary>
public static class Fields
{
public const string Type = "type";
public const string SchemaVersion = "schema_version";
public const string MatchType = "match_type";
public const string BuildId = "build_id";
public const string FileSha256 = "file_sha256";
public const string TextSha256 = "text_sha256";
public const string FingerprintAlgorithm = "fingerprint_algorithm";
public const string Similarity = "similarity";
public const string DistroRelease = "distro_release";
public const string SourcePackage = "source_package";
public const string FixedVersion = "fixed_version";
public const string FixMethod = "fix_method";
public const string FixConfidence = "fix_confidence";
public const string EvidenceRef = "evidence_ref";
public const string MatchedFunction = "matched_function";
public const string BinaryKey = "binary_key";
public const string Architecture = "architecture";
public const string ResolvedAt = "resolved_at";
}
/// <summary>Match type values.</summary>
public static class MatchTypes
{
public const string BuildId = "build_id";
public const string Fingerprint = "fingerprint";
public const string HashExact = "hash_exact";
}
/// <summary>
/// Creates an evidence JSON object from the provided parameters.
/// </summary>
public static JsonObject CreateEvidence(
string matchType,
string? buildId = null,
string? fileSha256 = null,
string? textSha256 = null,
string? fingerprintAlgorithm = null,
decimal? similarity = null,
string? distroRelease = null,
string? sourcePackage = null,
string? fixedVersion = null,
string? fixMethod = null,
decimal? fixConfidence = null,
string? evidenceRef = null,
string? matchedFunction = null,
string? binaryKey = null,
string? architecture = null,
DateTimeOffset? resolvedAt = null)
{
var evidence = new JsonObject
{
[Fields.Type] = EvidenceType,
[Fields.SchemaVersion] = SchemaVersion,
[Fields.MatchType] = matchType
};
if (!string.IsNullOrWhiteSpace(buildId))
evidence[Fields.BuildId] = buildId;
if (!string.IsNullOrWhiteSpace(fileSha256))
evidence[Fields.FileSha256] = fileSha256;
if (!string.IsNullOrWhiteSpace(textSha256))
evidence[Fields.TextSha256] = textSha256;
if (!string.IsNullOrWhiteSpace(fingerprintAlgorithm))
evidence[Fields.FingerprintAlgorithm] = fingerprintAlgorithm;
if (similarity.HasValue)
evidence[Fields.Similarity] = similarity.Value;
if (!string.IsNullOrWhiteSpace(distroRelease))
evidence[Fields.DistroRelease] = distroRelease;
if (!string.IsNullOrWhiteSpace(sourcePackage))
evidence[Fields.SourcePackage] = sourcePackage;
if (!string.IsNullOrWhiteSpace(fixedVersion))
evidence[Fields.FixedVersion] = fixedVersion;
if (!string.IsNullOrWhiteSpace(fixMethod))
evidence[Fields.FixMethod] = fixMethod;
if (fixConfidence.HasValue)
evidence[Fields.FixConfidence] = fixConfidence.Value;
if (!string.IsNullOrWhiteSpace(evidenceRef))
evidence[Fields.EvidenceRef] = evidenceRef;
if (!string.IsNullOrWhiteSpace(matchedFunction))
evidence[Fields.MatchedFunction] = matchedFunction;
if (!string.IsNullOrWhiteSpace(binaryKey))
evidence[Fields.BinaryKey] = binaryKey;
if (!string.IsNullOrWhiteSpace(architecture))
evidence[Fields.Architecture] = architecture;
if (resolvedAt.HasValue)
evidence[Fields.ResolvedAt] = resolvedAt.Value.ToString("O");
return evidence;
}
}

View File

@@ -0,0 +1,59 @@
// -----------------------------------------------------------------------------
// IDsseSigningAdapter.cs
// Sprint: SPRINT_1227_0001_0001_LB_binary_vex_generator
// Task: T5 — DSSE signing integration
// -----------------------------------------------------------------------------
namespace StellaOps.BinaryIndex.VexBridge;
/// <summary>
/// Adapter interface for DSSE signing operations.
/// Abstracts the Attestor signing service for VexBridge use.
/// </summary>
public interface IDsseSigningAdapter
{
/// <summary>
/// Sign a payload and return a DSSE envelope.
/// </summary>
/// <param name="payload">The payload bytes to sign.</param>
/// <param name="payloadType">The DSSE payload type URI.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>DSSE envelope as JSON bytes.</returns>
Task<byte[]> SignAsync(byte[] payload, string payloadType, CancellationToken ct = default);
/// <summary>
/// Verify a DSSE envelope signature.
/// </summary>
/// <param name="envelope">The DSSE envelope bytes.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if signature is valid.</returns>
Task<bool> VerifyAsync(byte[] envelope, CancellationToken ct = default);
/// <summary>
/// Get the key ID used for signing.
/// </summary>
string SigningKeyId { get; }
/// <summary>
/// Check if signing is available.
/// </summary>
bool IsAvailable { get; }
}
/// <summary>
/// DSSE envelope result with metadata.
/// </summary>
public sealed record DsseEnvelopeResult
{
/// <summary>The DSSE envelope as JSON string.</summary>
public required string Envelope { get; init; }
/// <summary>The signing key ID used.</summary>
public required string KeyId { get; init; }
/// <summary>SHA-256 hash of the envelope.</summary>
public required string EnvelopeHash { get; init; }
/// <summary>Timestamp when signed.</summary>
public required DateTimeOffset SignedAt { get; init; }
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.BinaryIndex.VexBridge;
/// <summary>
/// Generates VEX observations from binary vulnerability match results.
/// Bridges the gap between binary fingerprint analysis and VEX decision flow.
/// </summary>
public interface IVexEvidenceGenerator
{
/// <summary>
/// Generate a VEX observation from a binary vulnerability match.
/// </summary>
/// <param name="match">The binary vulnerability match result.</param>
/// <param name="identity">The binary identity being analyzed.</param>
/// <param name="fixStatus">Optional fix status from the fix index.</param>
/// <param name="context">Generation context with tenant and scan metadata.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A VEX observation ready for Excititor persistence.</returns>
Task<VexObservation> GenerateFromBinaryMatchAsync(
BinaryVulnMatch match,
BinaryIdentity identity,
FixStatusResult? fixStatus,
VexGenerationContext context,
CancellationToken ct = default);
/// <summary>
/// Batch generation of VEX observations for scan performance.
/// </summary>
/// <param name="matches">Collection of matches with their context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of VEX observations in deterministic order.</returns>
Task<IReadOnlyList<VexObservation>> GenerateBatchAsync(
IEnumerable<BinaryMatchWithContext> matches,
CancellationToken ct = default);
/// <summary>
/// Generate observation ID deterministically for replay/idempotency.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="productKey">PURL or product key.</param>
/// <param name="scanId">Scan identifier.</param>
/// <returns>Deterministic UUID5-based observation ID.</returns>
string GenerateObservationId(string tenantId, string cveId, string productKey, string scanId);
}
/// <summary>
/// Context for VEX observation generation.
/// </summary>
public sealed record VexGenerationContext
{
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Scan identifier for traceability.</summary>
public required string ScanId { get; init; }
/// <summary>Product key, typically a PURL.</summary>
public required string ProductKey { get; init; }
/// <summary>Optional distro release identifier (e.g., "debian:bookworm").</summary>
public string? DistroRelease { get; init; }
/// <summary>Whether to sign the observation with DSSE. Default true.</summary>
public bool SignWithDsse { get; init; } = true;
/// <summary>Provider ID for the VEX observation. Defaults to "stellaops.binaryindex".</summary>
public string ProviderId { get; init; } = "stellaops.binaryindex";
/// <summary>Stream ID for the VEX observation. Defaults to "binary_resolution".</summary>
public string StreamId { get; init; } = "binary_resolution";
/// <summary>Optional version for the resolution evidence.</summary>
public string? EvidenceVersion { get; init; }
}
/// <summary>
/// Wrapper for a binary match with its full context.
/// </summary>
public sealed record BinaryMatchWithContext
{
/// <summary>The binary vulnerability match.</summary>
public required BinaryVulnMatch Match { get; init; }
/// <summary>The binary identity being analyzed.</summary>
public required BinaryIdentity Identity { get; init; }
/// <summary>Optional fix status from the fix index.</summary>
public FixStatusResult? FixStatus { get; init; }
/// <summary>Generation context.</summary>
public required VexGenerationContext Context { get; init; }
}

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.BinaryIndex.VexBridge;
/// <summary>
/// Extension methods for registering VexBridge services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds VEX Bridge services for converting binary matches to VEX observations.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Configuration containing VexBridge section.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddBinaryVexBridge(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<VexBridgeOptions>(
configuration.GetSection(VexBridgeOptions.SectionName));
services.AddSingleton<IVexEvidenceGenerator, VexEvidenceGenerator>();
return services;
}
/// <summary>
/// Adds VEX Bridge services with custom options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Action to configure options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddBinaryVexBridge(
this IServiceCollection services,
Action<VexBridgeOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.AddSingleton<IVexEvidenceGenerator, VexEvidenceGenerator>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>Bridges binary fingerprint matching to VEX observation generation for StellaOps.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../StellaOps.BinaryIndex.FixIndex/StellaOps.BinaryIndex.FixIndex.csproj" />
<ProjectReference Include="../../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,54 @@
namespace StellaOps.BinaryIndex.VexBridge;
/// <summary>
/// Configuration options for the VEX Bridge.
/// </summary>
public sealed class VexBridgeOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "VexBridge";
/// <summary>
/// Whether to sign generated VEX observations with DSSE.
/// Default: true
/// </summary>
public bool SignWithDsse { get; set; } = true;
/// <summary>
/// Key ID to use for DSSE signing.
/// If null, uses the default attestor key.
/// </summary>
public string? DsseKeyId { get; set; }
/// <summary>
/// Default provider ID for generated observations.
/// </summary>
public string DefaultProviderId { get; set; } = "stellaops.binaryindex";
/// <summary>
/// Default stream ID for generated observations.
/// </summary>
public string DefaultStreamId { get; set; } = "binary_resolution";
/// <summary>
/// Minimum confidence threshold for creating observations.
/// Matches below this threshold will be skipped.
/// </summary>
public decimal MinConfidenceThreshold { get; set; } = 0.70m;
/// <summary>
/// Whether to include function-level evidence when available.
/// </summary>
public bool IncludeFunctionEvidence { get; set; } = true;
/// <summary>
/// Maximum number of observations to generate in a single batch.
/// </summary>
public int MaxBatchSize { get; set; } = 1000;
/// <summary>
/// Namespace UUID for generating deterministic observation IDs.
/// Default: StellaOps BinaryIndex namespace.
/// </summary>
public Guid ObservationIdNamespace { get; set; } = new("d9e0a5f3-7b2c-4e8d-9a1f-6c3b5d8e2f0a");
}

View File

@@ -0,0 +1,468 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.BinaryIndex.VexBridge;
/// <summary>
/// Generates VEX observations from binary vulnerability matches.
/// Maps FixState to VexClaimStatus with appropriate justifications.
/// Supports optional DSSE signing for attestable proofs.
/// </summary>
public sealed class VexEvidenceGenerator : IVexEvidenceGenerator
{
private readonly ILogger<VexEvidenceGenerator> _logger;
private readonly VexBridgeOptions _options;
private readonly IDsseSigningAdapter? _dsseSigner;
public VexEvidenceGenerator(
ILogger<VexEvidenceGenerator> logger,
IOptions<VexBridgeOptions> options,
IDsseSigningAdapter? dsseSigner = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_dsseSigner = dsseSigner;
}
/// <inheritdoc />
public async Task<VexObservation> GenerateFromBinaryMatchAsync(
BinaryVulnMatch match,
BinaryIdentity identity,
FixStatusResult? fixStatus,
VexGenerationContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(match);
ArgumentNullException.ThrowIfNull(identity);
ArgumentNullException.ThrowIfNull(context);
ct.ThrowIfCancellationRequested();
// Check confidence threshold
var effectiveConfidence = fixStatus?.Confidence ?? match.Confidence;
if (effectiveConfidence < _options.MinConfidenceThreshold)
{
_logger.LogDebug(
"Skipping observation for {CveId}: confidence {Confidence} below threshold {Threshold}",
match.CveId, effectiveConfidence, _options.MinConfidenceThreshold);
throw new InvalidOperationException(
$"Match confidence {effectiveConfidence} is below minimum threshold {_options.MinConfidenceThreshold}");
}
var observation = await CreateObservationAsync(match, identity, fixStatus, context, ct);
return observation;
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexObservation>> GenerateBatchAsync(
IEnumerable<BinaryMatchWithContext> matches,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(matches);
var results = new List<VexObservation>();
var batchItems = matches.ToList();
if (batchItems.Count > _options.MaxBatchSize)
{
_logger.LogWarning(
"Batch size {Count} exceeds maximum {Max}, truncating",
batchItems.Count, _options.MaxBatchSize);
batchItems = batchItems.Take(_options.MaxBatchSize).ToList();
}
foreach (var item in batchItems)
{
ct.ThrowIfCancellationRequested();
try
{
var observation = await GenerateFromBinaryMatchAsync(
item.Match,
item.Identity,
item.FixStatus,
item.Context,
ct);
results.Add(observation);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("below minimum threshold"))
{
// Skip items below threshold, continue with batch
_logger.LogDebug("Skipping batch item: {Message}", ex.Message);
}
}
// Return in deterministic order (by observation ID)
return results.OrderBy(o => o.ObservationId, StringComparer.Ordinal).ToList();
}
/// <inheritdoc />
public string GenerateObservationId(string tenantId, string cveId, string productKey, string scanId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
// UUID5 generation: namespace + name
var name = $"{tenantId.ToLowerInvariant()}:{cveId.ToUpperInvariant()}:{productKey}:{scanId}";
return GenerateUuid5(_options.ObservationIdNamespace, name).ToString();
}
private async Task<VexObservation> CreateObservationAsync(
BinaryVulnMatch match,
BinaryIdentity identity,
FixStatusResult? fixStatus,
VexGenerationContext context,
CancellationToken ct)
{
var observationId = GenerateObservationId(
context.TenantId,
match.CveId,
context.ProductKey,
context.ScanId);
var now = DateTimeOffset.UtcNow;
// Map fix status to VEX status and justification
var (vexStatus, justification) = MapToVexStatus(fixStatus);
// Create evidence JSON
var evidence = CreateEvidencePayload(match, identity, fixStatus, context, now);
// Create upstream metadata with optional DSSE signing
var upstream = await CreateUpstreamAsync(observationId, evidence, now, context.SignWithDsse, ct);
// Create statement
var statement = CreateStatement(match, context, vexStatus, justification, fixStatus);
// Create content
var content = CreateContent(evidence);
// Create linkset
var linkset = CreateLinkset(match, identity);
var attributes = ImmutableDictionary<string, string>.Empty
.Add("generator", "StellaOps.BinaryIndex.VexBridge")
.Add("generator_version", "1.0.0")
.Add("scan_id", context.ScanId);
// Add DSSE signature info to attributes if signed
if (context.SignWithDsse && upstream.Signature.Present)
{
attributes = attributes
.Add("dsse_signed", "true")
.Add("dsse_key_id", upstream.Signature.KeyId ?? "unknown");
}
return new VexObservation(
observationId: observationId,
tenant: context.TenantId,
providerId: context.ProviderId,
streamId: context.StreamId,
upstream: upstream,
statements: ImmutableArray.Create(statement),
content: content,
linkset: linkset,
createdAt: now,
attributes: attributes);
}
private static (VexClaimStatus Status, VexJustification? Justification) MapToVexStatus(FixStatusResult? fixStatus)
{
if (fixStatus is null)
{
return (VexClaimStatus.UnderInvestigation, null);
}
return fixStatus.State switch
{
FixState.Fixed => (VexClaimStatus.NotAffected, VexJustification.VulnerableCodeNotPresent),
FixState.Vulnerable => (VexClaimStatus.Affected, null),
FixState.NotAffected => (VexClaimStatus.NotAffected, VexJustification.ComponentNotPresent),
FixState.Wontfix => (VexClaimStatus.NotAffected, VexJustification.InlineMitigationsAlreadyExist),
FixState.Unknown => (VexClaimStatus.UnderInvestigation, null),
_ => (VexClaimStatus.UnderInvestigation, null)
};
}
private static JsonObject CreateEvidencePayload(
BinaryVulnMatch match,
BinaryIdentity identity,
FixStatusResult? fixStatus,
VexGenerationContext context,
DateTimeOffset resolvedAt)
{
var matchType = match.Method switch
{
MatchMethod.BuildIdCatalog => BinaryMatchEvidenceSchema.MatchTypes.BuildId,
MatchMethod.FingerprintMatch => BinaryMatchEvidenceSchema.MatchTypes.Fingerprint,
MatchMethod.RangeMatch => BinaryMatchEvidenceSchema.MatchTypes.HashExact,
_ => BinaryMatchEvidenceSchema.MatchTypes.Fingerprint
};
return BinaryMatchEvidenceSchema.CreateEvidence(
matchType: matchType,
buildId: identity.BuildId,
fileSha256: identity.FileSha256,
textSha256: identity.TextSha256,
fingerprintAlgorithm: matchType == BinaryMatchEvidenceSchema.MatchTypes.Fingerprint ? "combined" : null,
similarity: match.Evidence?.Similarity ?? match.Confidence,
distroRelease: context.DistroRelease,
sourcePackage: ExtractSourcePackage(match.VulnerablePurl),
fixedVersion: fixStatus?.FixedVersion,
fixMethod: fixStatus?.Method.ToString()?.ToLowerInvariant(),
fixConfidence: fixStatus?.Confidence,
evidenceRef: fixStatus?.EvidenceId?.ToString(),
matchedFunction: match.Evidence?.MatchedFunction,
binaryKey: identity.BinaryKey,
architecture: identity.Architecture,
resolvedAt: resolvedAt);
}
private async Task<VexObservationUpstream> CreateUpstreamAsync(
string observationId,
JsonObject evidence,
DateTimeOffset now,
bool signWithDsse,
CancellationToken ct)
{
// Compute content hash of the evidence
var evidenceJson = evidence.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
var contentHash = ComputeSha256(evidenceJson);
VexObservationSignature signature;
// Sign with DSSE if requested and signer is available
if (signWithDsse && _dsseSigner is { IsAvailable: true })
{
try
{
var payloadBytes = Encoding.UTF8.GetBytes(evidenceJson);
var envelopeBytes = await _dsseSigner.SignAsync(
payloadBytes,
"application/vnd.stellaops.binary-resolution+json",
ct);
var envelopeBase64 = Convert.ToBase64String(envelopeBytes);
var envelopeHash = ComputeSha256(Encoding.UTF8.GetString(envelopeBytes));
signature = new VexObservationSignature(
present: true,
format: "dsse",
keyId: _dsseSigner.SigningKeyId,
signature: envelopeBase64);
_logger.LogDebug(
"DSSE signature generated for observation {ObservationId} with key {KeyId}",
observationId, _dsseSigner.SigningKeyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to generate DSSE signature for observation {ObservationId}, proceeding unsigned",
observationId);
signature = new VexObservationSignature(
present: false,
format: null,
keyId: null,
signature: null);
}
}
else
{
if (signWithDsse && _dsseSigner is null)
{
_logger.LogDebug(
"DSSE signing requested but no signer configured for observation {ObservationId}",
observationId);
}
signature = new VexObservationSignature(
present: false,
format: null,
keyId: null,
signature: null);
}
return new VexObservationUpstream(
upstreamId: $"binary:{observationId}",
documentVersion: "1.0",
fetchedAt: now,
receivedAt: now,
contentHash: contentHash,
signature: signature,
metadata: ImmutableDictionary<string, string>.Empty
.Add("source", "binary_fingerprint_analysis"));
}
private static VexObservationStatement CreateStatement(
BinaryVulnMatch match,
VexGenerationContext context,
VexClaimStatus status,
VexJustification? justification,
FixStatusResult? fixStatus)
{
var detail = BuildStatementDetail(match, fixStatus);
return new VexObservationStatement(
vulnerabilityId: match.CveId,
productKey: context.ProductKey,
status: status,
lastObserved: DateTimeOffset.UtcNow,
locator: null,
justification: justification,
introducedVersion: null,
fixedVersion: fixStatus?.FixedVersion,
purl: match.VulnerablePurl,
cpe: null,
evidence: null,
metadata: ImmutableDictionary<string, string>.Empty
.Add("impact_statement", detail));
}
private static string BuildStatementDetail(BinaryVulnMatch match, FixStatusResult? fixStatus)
{
var sb = new StringBuilder();
if (fixStatus is { State: FixState.Fixed })
{
sb.Append($"Binary fingerprint analysis indicates this binary contains the patched version.");
if (!string.IsNullOrEmpty(fixStatus.FixedVersion))
{
sb.Append($" Fixed in version: {fixStatus.FixedVersion}.");
}
}
else if (fixStatus is { State: FixState.Vulnerable })
{
sb.Append("Binary fingerprint analysis indicates this binary contains vulnerable code.");
}
else
{
sb.Append($"Binary fingerprint match with confidence {match.Confidence:P0}.");
}
return sb.ToString();
}
private static VexObservationContent CreateContent(JsonObject evidence)
{
return new VexObservationContent(
format: "application/json",
specVersion: "1.0",
raw: evidence);
}
private static VexObservationLinkset CreateLinkset(
BinaryVulnMatch match,
BinaryIdentity identity)
{
var refs = new List<VexObservationReference>
{
new(type: "vulnerability", url: $"https://nvd.nist.gov/vuln/detail/{match.CveId}"),
new(type: "package", url: match.VulnerablePurl)
};
if (!string.IsNullOrEmpty(identity.BuildId))
{
refs.Add(new(type: "build_id", url: $"urn:build-id:{identity.BuildId}"));
}
return new VexObservationLinkset(
aliases: ImmutableArray.Create(match.CveId),
purls: ImmutableArray.Create(match.VulnerablePurl),
cpes: null,
references: refs);
}
private static string? ExtractSourcePackage(string purl)
{
// Simple extraction from PURL: pkg:deb/debian/openssl@3.0.7 → openssl
if (string.IsNullOrEmpty(purl))
return null;
try
{
var parts = purl.Split('/');
if (parts.Length >= 3)
{
var nameVersion = parts[^1];
var atIndex = nameVersion.IndexOf('@');
return atIndex > 0 ? nameVersion[..atIndex] : nameVersion;
}
}
catch
{
// Ignore parsing errors
}
return null;
}
private static string ComputeSha256(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
/// <summary>
/// Generate a UUID v5 (name-based, SHA-1) from namespace and name.
/// </summary>
private static Guid GenerateUuid5(Guid namespaceId, string name)
{
// Convert namespace GUID to bytes (big-endian format for UUID)
var namespaceBytes = namespaceId.ToByteArray();
// Swap bytes for big-endian (UUID format)
SwapGuidBytesForBigEndian(namespaceBytes);
var nameBytes = Encoding.UTF8.GetBytes(name);
// Concatenate namespace + name
var combined = new byte[namespaceBytes.Length + nameBytes.Length];
Buffer.BlockCopy(namespaceBytes, 0, combined, 0, namespaceBytes.Length);
Buffer.BlockCopy(nameBytes, 0, combined, namespaceBytes.Length, nameBytes.Length);
// Hash with SHA-1
var hash = SHA1.HashData(combined);
// Take first 16 bytes
var guidBytes = new byte[16];
Array.Copy(hash, guidBytes, 16);
// Set version (5) and variant (RFC 4122)
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); // Version 5
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); // Variant RFC 4122
// Swap back to little-endian for .NET Guid
SwapGuidBytesForBigEndian(guidBytes);
return new Guid(guidBytes);
}
private static void SwapGuidBytesForBigEndian(byte[] bytes)
{
// Swap first 4 bytes
(bytes[0], bytes[3]) = (bytes[3], bytes[0]);
(bytes[1], bytes[2]) = (bytes[2], bytes[1]);
// Swap bytes 4-5
(bytes[4], bytes[5]) = (bytes[5], bytes[4]);
// Swap bytes 6-7
(bytes[6], bytes[7]) = (bytes[7], bytes[6]);
}
}

View File

@@ -0,0 +1,627 @@
// -----------------------------------------------------------------------------
// ReproducibleBuildJobIntegrationTests.cs
// Sprint: SPRINT_1227_0002_0001_LB_reproducible_builders
// Task: T11 — Integration tests with sample packages
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.BinaryIndex.Builders;
using Xunit;
namespace StellaOps.BinaryIndex.Builders.Tests;
/// <summary>
/// Integration tests for ReproducibleBuildJob with sample packages.
/// Tests end-to-end flow: CVE → build → fingerprint → claim.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "BinaryIndex")]
public class ReproducibleBuildJobIntegrationTests
{
#region Builder Selection Tests
[Fact(DisplayName = "Selects correct builder for Debian distro")]
public void SelectBuilder_Debian_ReturnsDebianBuilder()
{
// Arrange
var builders = CreateMockBuilders();
var cve = CreateTestCve("debian", "bookworm", "openssl", "3.0.7-1", "3.0.7-1+deb12u1");
// Act
var selectedBuilder = builders.FirstOrDefault(b =>
b.Distro.Equals(cve.Distro, StringComparison.OrdinalIgnoreCase));
// Assert
selectedBuilder.Should().NotBeNull();
selectedBuilder!.Distro.Should().Be("debian");
}
[Fact(DisplayName = "Selects correct builder for Alpine distro")]
public void SelectBuilder_Alpine_ReturnsAlpineBuilder()
{
// Arrange
var builders = CreateMockBuilders();
var cve = CreateTestCve("alpine", "3.19", "openssl", "3.0.7-r0", "3.0.7-r1");
// Act
var selectedBuilder = builders.FirstOrDefault(b =>
b.Distro.Equals(cve.Distro, StringComparison.OrdinalIgnoreCase));
// Assert
selectedBuilder.Should().NotBeNull();
selectedBuilder!.Distro.Should().Be("alpine");
}
[Fact(DisplayName = "Selects correct builder for RHEL distro")]
public void SelectBuilder_Rhel_ReturnsRhelBuilder()
{
// Arrange
var builders = CreateMockBuilders();
var cve = CreateTestCve("rhel", "9", "openssl", "3.0.7-1.el9", "3.0.7-1.el9_1");
// Act
var selectedBuilder = builders.FirstOrDefault(b =>
b.Distro.Equals(cve.Distro, StringComparison.OrdinalIgnoreCase));
// Assert
selectedBuilder.Should().NotBeNull();
selectedBuilder!.Distro.Should().Be("rhel");
}
[Fact(DisplayName = "Returns null for unsupported distro")]
public void SelectBuilder_UnsupportedDistro_ReturnsNull()
{
// Arrange
var builders = CreateMockBuilders();
var cve = CreateTestCve("centos", "7", "openssl", "1.0.2k", "1.0.2k-fips");
// Act
var selectedBuilder = builders.FirstOrDefault(b =>
b.Distro.Equals(cve.Distro, StringComparison.OrdinalIgnoreCase));
// Assert
selectedBuilder.Should().BeNull();
}
#endregion
#region OpenSSL Package Tests
[Fact(DisplayName = "OpenSSL CVE-2024-0001: processes vulnerable and patched versions")]
public async Task ProcessCve_OpenSslCve_BuildsBothVersions()
{
// Arrange
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("debian");
mockBuilder.Setup(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BuildRequest req, CancellationToken _) => CreateSuccessfulBuildResult(req));
var mockDiffEngine = new Mock<IPatchDiffEngine>();
mockDiffEngine.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(CreateOpenSslDiffResult());
var job = CreateBuildJob(new[] { mockBuilder.Object }, mockDiffEngine.Object);
var cve = CreateTestCve("debian", "bookworm", "openssl", "3.0.7-1", "3.0.7-1+deb12u1");
cve = cve with { CveId = "CVE-2024-0001", PatchCommit = "abc123" };
// Act
await job.ProcessCveAsync(cve, CancellationToken.None);
// Assert
mockBuilder.Verify(b => b.BuildAsync(
It.Is<BuildRequest>(r => r.Version == "3.0.7-1"),
It.IsAny<CancellationToken>()), Times.Once);
mockBuilder.Verify(b => b.BuildAsync(
It.Is<BuildRequest>(r => r.Version == "3.0.7-1+deb12u1"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact(DisplayName = "OpenSSL: extracts ssl3_get_record as modified function")]
public async Task ProcessCve_OpenSsl_IdentifiesModifiedFunctions()
{
// Arrange
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("debian");
mockBuilder.Setup(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BuildRequest req, CancellationToken _) => CreateSuccessfulBuildResult(req));
var diffResult = CreateOpenSslDiffResult();
var mockDiffEngine = new Mock<IPatchDiffEngine>();
mockDiffEngine.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(diffResult);
// Assert that the diff result contains expected functions
diffResult.Changes.Should().Contain(c => c.FunctionName == "ssl3_get_record");
diffResult.Changes.Should().Contain(c => c.Type == ChangeType.Modified);
diffResult.ModifiedCount.Should().BeGreaterThan(0);
}
#endregion
#region Curl Package Tests
[Fact(DisplayName = "Curl CVE-2024-0002: processes vulnerable and patched versions")]
public async Task ProcessCve_CurlCve_BuildsBothVersions()
{
// Arrange
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("debian");
mockBuilder.Setup(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BuildRequest req, CancellationToken _) => CreateSuccessfulBuildResult(req, "curl"));
var mockDiffEngine = new Mock<IPatchDiffEngine>();
mockDiffEngine.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(CreateCurlDiffResult());
var job = CreateBuildJob(new[] { mockBuilder.Object }, mockDiffEngine.Object);
var cve = CreateTestCve("debian", "bookworm", "curl", "7.88.1-1", "7.88.1-1+deb12u1");
cve = cve with { CveId = "CVE-2024-0002" };
// Act
await job.ProcessCveAsync(cve, CancellationToken.None);
// Assert
mockBuilder.Verify(b => b.BuildAsync(
It.Is<BuildRequest>(r => r.SourcePackage == "curl"),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact(DisplayName = "Curl: extracts Curl_ssl_connect as modified function")]
public void CurlDiff_IdentifiesModifiedFunctions()
{
// Arrange
var diffResult = CreateCurlDiffResult();
// Assert
diffResult.Changes.Should().Contain(c => c.FunctionName == "Curl_ssl_connect");
diffResult.Changes.Should().Contain(c => c.Type == ChangeType.Modified);
}
#endregion
#region Zlib Package Tests
[Fact(DisplayName = "Zlib CVE-2024-0003: processes vulnerable and patched versions")]
public async Task ProcessCve_ZlibCve_BuildsBothVersions()
{
// Arrange
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("alpine");
mockBuilder.Setup(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BuildRequest req, CancellationToken _) => CreateSuccessfulBuildResult(req, "zlib"));
var mockDiffEngine = new Mock<IPatchDiffEngine>();
mockDiffEngine.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(CreateZlibDiffResult());
var job = CreateBuildJob(new[] { mockBuilder.Object }, mockDiffEngine.Object);
var cve = CreateTestCve("alpine", "3.19", "zlib", "1.2.13-r0", "1.2.13-r1");
cve = cve with { CveId = "CVE-2024-0003" };
// Act
await job.ProcessCveAsync(cve, CancellationToken.None);
// Assert
mockBuilder.Verify(b => b.BuildAsync(
It.Is<BuildRequest>(r => r.SourcePackage == "zlib"),
It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact(DisplayName = "Zlib: extracts inflate as modified function")]
public void ZlibDiff_IdentifiesModifiedFunctions()
{
// Arrange
var diffResult = CreateZlibDiffResult();
// Assert
diffResult.Changes.Should().Contain(c => c.FunctionName == "inflate");
diffResult.Changes.Should().Contain(c => c.Type == ChangeType.Modified);
}
#endregion
#region Fingerprint Claim Tests
[Fact(DisplayName = "Creates fingerprint claims for modified functions")]
public async Task ProcessCve_CreatesClaimsForModifiedFunctions()
{
// Arrange
var createdClaims = new List<FingerprintClaim>();
var mockClaimRepo = new Mock<IFingerprintClaimRepository>();
mockClaimRepo.Setup(r => r.CreateClaimsBatchAsync(
It.IsAny<IEnumerable<FingerprintClaim>>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<FingerprintClaim>, CancellationToken>((claims, _) =>
createdClaims.AddRange(claims))
.Returns(Task.CompletedTask);
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("debian");
mockBuilder.Setup(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BuildRequest req, CancellationToken _) => CreateSuccessfulBuildResult(req));
var mockDiffEngine = new Mock<IPatchDiffEngine>();
mockDiffEngine.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(CreateOpenSslDiffResult());
var job = CreateBuildJob(
new[] { mockBuilder.Object },
mockDiffEngine.Object,
mockClaimRepo.Object);
var cve = CreateTestCve("debian", "bookworm", "openssl", "3.0.7-1", "3.0.7-1+deb12u1");
cve = cve with { CveId = "CVE-2024-0001" };
// Act
await job.ProcessCveAsync(cve, CancellationToken.None);
// Assert
createdClaims.Should().NotBeEmpty();
createdClaims.Should().Contain(c => c.CveId == "CVE-2024-0001");
createdClaims.Should().Contain(c => c.Verdict == ClaimVerdict.Fixed);
createdClaims.Should().Contain(c => c.Verdict == ClaimVerdict.Vulnerable);
}
[Fact(DisplayName = "Claim evidence contains changed function names")]
public async Task ProcessCve_ClaimEvidenceContainsFunctions()
{
// Arrange
var createdClaims = new List<FingerprintClaim>();
var mockClaimRepo = new Mock<IFingerprintClaimRepository>();
mockClaimRepo.Setup(r => r.CreateClaimsBatchAsync(
It.IsAny<IEnumerable<FingerprintClaim>>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<FingerprintClaim>, CancellationToken>((claims, _) =>
createdClaims.AddRange(claims))
.Returns(Task.CompletedTask);
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("debian");
mockBuilder.Setup(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BuildRequest req, CancellationToken _) => CreateSuccessfulBuildResult(req));
var mockDiffEngine = new Mock<IPatchDiffEngine>();
mockDiffEngine.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(CreateOpenSslDiffResult());
var job = CreateBuildJob(
new[] { mockBuilder.Object },
mockDiffEngine.Object,
mockClaimRepo.Object);
var cve = CreateTestCve("debian", "bookworm", "openssl", "3.0.7-1", "3.0.7-1+deb12u1");
cve = cve with { CveId = "CVE-2024-0001" };
// Act
await job.ProcessCveAsync(cve, CancellationToken.None);
// Assert
var fixedClaim = createdClaims.FirstOrDefault(c => c.Verdict == ClaimVerdict.Fixed);
fixedClaim.Should().NotBeNull();
fixedClaim!.Evidence.ChangedFunctions.Should().Contain("ssl3_get_record");
}
#endregion
#region Error Handling Tests
[Fact(DisplayName = "Continues processing on build failure")]
public async Task ProcessCve_BuildFailure_ContinuesWithNextCve()
{
// Arrange
var mockBuilder = new Mock<IReproducibleBuilder>();
mockBuilder.Setup(b => b.Distro).Returns("debian");
mockBuilder.SetupSequence(b => b.BuildAsync(It.IsAny<BuildRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BuildResult { Success = false, ErrorMessage = "Build failed" })
.ReturnsAsync(CreateSuccessfulBuildResult(new BuildRequest { SourcePackage = "curl", Version = "7.88.1-1+deb12u1", Release = "bookworm" }));
var advisoryMonitor = new Mock<IAdvisoryFeedMonitor>();
advisoryMonitor.Setup(a => a.GetPendingCvesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CveAttribution>
{
CreateTestCve("debian", "bookworm", "openssl", "3.0.7-1", "3.0.7-1+deb12u1")
with { CveId = "CVE-2024-0001" },
CreateTestCve("debian", "bookworm", "curl", "7.88.1-1", "7.88.1-1+deb12u1")
with { CveId = "CVE-2024-0002" }
});
var job = CreateBuildJob(
new[] { mockBuilder.Object },
advisoryMonitor: advisoryMonitor.Object);
// Act - should not throw
await job.ExecuteAsync(CancellationToken.None);
// Assert - verify both CVEs were attempted
mockBuilder.Verify(b => b.BuildAsync(
It.IsAny<BuildRequest>(),
It.IsAny<CancellationToken>()), Times.AtLeast(2));
}
[Fact(DisplayName = "Logs warning for unsupported distro")]
public async Task ProcessCve_UnsupportedDistro_LogsWarning()
{
// Arrange
var mockLogger = new Mock<ILogger<ReproducibleBuildJob>>();
var builders = CreateMockBuilders(); // debian, alpine, rhel only
var job = CreateBuildJob(
builders,
logger: mockLogger.Object);
var cve = CreateTestCve("centos", "7", "openssl", "1.0.2k", "1.0.2k-fips");
// Act
await job.ProcessCveAsync(cve, CancellationToken.None);
// Assert - builder should not be called
// No exception thrown, job completes gracefully
}
#endregion
#region Helper Methods
private static IEnumerable<IReproducibleBuilder> CreateMockBuilders()
{
var debianBuilder = new Mock<IReproducibleBuilder>();
debianBuilder.Setup(b => b.Distro).Returns("debian");
var alpineBuilder = new Mock<IReproducibleBuilder>();
alpineBuilder.Setup(b => b.Distro).Returns("alpine");
var rhelBuilder = new Mock<IReproducibleBuilder>();
rhelBuilder.Setup(b => b.Distro).Returns("rhel");
return new[] { debianBuilder.Object, alpineBuilder.Object, rhelBuilder.Object };
}
private static CveAttribution CreateTestCve(
string distro,
string release,
string package,
string vulnVersion,
string fixedVersion)
{
return new CveAttribution
{
CveId = "CVE-2024-TEST",
SourcePackage = package,
Distro = distro,
Release = release,
VulnerableVersion = vulnVersion,
FixedVersion = fixedVersion
};
}
private static BuildResult CreateSuccessfulBuildResult(BuildRequest request, string? packageOverride = null)
{
var package = packageOverride ?? request.SourcePackage;
return new BuildResult
{
Success = true,
Duration = TimeSpan.FromMinutes(5),
BuildLogRef = $"builds/{package}/{request.Version}",
Binaries = new List<BuiltBinary>
{
new BuiltBinary
{
Path = $"/output/{package}.so",
BuildId = Guid.NewGuid().ToString("N"),
TextSha256 = new byte[32],
Fingerprint = new byte[64],
Functions = CreateSampleFunctions(package)
}
}
};
}
private static IReadOnlyList<FunctionFingerprint> CreateSampleFunctions(string package)
{
return package.ToLowerInvariant() switch
{
"openssl" => new List<FunctionFingerprint>
{
CreateFunction("ssl3_get_record", 0x1000, 256),
CreateFunction("tls1_enc", 0x2000, 512),
CreateFunction("ssl_verify_cert_chain", 0x3000, 384)
},
"curl" => new List<FunctionFingerprint>
{
CreateFunction("Curl_ssl_connect", 0x1000, 384),
CreateFunction("Curl_http_done", 0x2000, 256),
CreateFunction("Curl_getformdata", 0x3000, 512)
},
"zlib" => new List<FunctionFingerprint>
{
CreateFunction("inflate", 0x1000, 1024),
CreateFunction("deflate", 0x2000, 896),
CreateFunction("crc32", 0x3000, 128)
},
_ => new List<FunctionFingerprint>
{
CreateFunction("main", 0x1000, 64)
}
};
}
private static FunctionFingerprint CreateFunction(string name, long offset, int size)
{
return new FunctionFingerprint
{
Name = name,
Offset = offset,
Size = size,
BasicBlockHash = new byte[32],
CfgHash = new byte[32],
StringRefsHash = new byte[32],
Callees = new List<string>()
};
}
private static FunctionDiffResult CreateOpenSslDiffResult()
{
return new FunctionDiffResult
{
TotalFunctionsVulnerable = 1500,
TotalFunctionsPatched = 1502,
Changes = new List<FunctionChange>
{
new FunctionChange
{
FunctionName = "ssl3_get_record",
Type = ChangeType.Modified,
SimilarityScore = 0.94m
},
new FunctionChange
{
FunctionName = "tls1_enc",
Type = ChangeType.Modified,
SimilarityScore = 0.91m
},
new FunctionChange
{
FunctionName = "ssl_check_bounds",
Type = ChangeType.Added
}
}
};
}
private static FunctionDiffResult CreateCurlDiffResult()
{
return new FunctionDiffResult
{
TotalFunctionsVulnerable = 800,
TotalFunctionsPatched = 801,
Changes = new List<FunctionChange>
{
new FunctionChange
{
FunctionName = "Curl_ssl_connect",
Type = ChangeType.Modified,
SimilarityScore = 0.88m
},
new FunctionChange
{
FunctionName = "Curl_verify_host",
Type = ChangeType.Modified,
SimilarityScore = 0.95m
}
}
};
}
private static FunctionDiffResult CreateZlibDiffResult()
{
return new FunctionDiffResult
{
TotalFunctionsVulnerable = 200,
TotalFunctionsPatched = 200,
Changes = new List<FunctionChange>
{
new FunctionChange
{
FunctionName = "inflate",
Type = ChangeType.Modified,
SimilarityScore = 0.97m
}
}
};
}
private static IReproducibleBuildJob CreateBuildJob(
IEnumerable<IReproducibleBuilder>? builders = null,
IPatchDiffEngine? diffEngine = null,
IFingerprintClaimRepository? claimRepository = null,
IAdvisoryFeedMonitor? advisoryMonitor = null,
ILogger<ReproducibleBuildJob>? logger = null)
{
var mockLogger = logger ?? Mock.Of<ILogger<ReproducibleBuildJob>>();
var mockOptions = Options.Create(new ReproducibleBuildOptions
{
BuildTimeout = TimeSpan.FromMinutes(30),
DefaultArchitecture = "amd64",
MinFunctionSize = 16
});
var mockBuilders = builders ?? CreateMockBuilders();
var mockDiffEngine = diffEngine;
if (mockDiffEngine == null)
{
var diff = new Mock<IPatchDiffEngine>();
diff.Setup(d => d.ComputeDiff(
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<IReadOnlyList<FunctionFingerprint>>(),
It.IsAny<DiffOptions>()))
.Returns(new FunctionDiffResult
{
Changes = new List<FunctionChange>(),
TotalFunctionsVulnerable = 0,
TotalFunctionsPatched = 0
});
mockDiffEngine = diff.Object;
}
var mockFingerprintExtractor = new Mock<IFunctionFingerprintExtractor>();
mockFingerprintExtractor.Setup(e => e.ExtractAsync(
It.IsAny<string>(),
It.IsAny<ExtractionOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FunctionFingerprint>());
var mockClaimRepo = claimRepository;
if (mockClaimRepo == null)
{
var repo = new Mock<IFingerprintClaimRepository>();
repo.Setup(r => r.CreateClaimsBatchAsync(
It.IsAny<IEnumerable<FingerprintClaim>>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
mockClaimRepo = repo.Object;
}
var mockAdvisoryMonitor = advisoryMonitor;
if (mockAdvisoryMonitor == null)
{
var monitor = new Mock<IAdvisoryFeedMonitor>();
monitor.Setup(m => m.GetPendingCvesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<CveAttribution>());
mockAdvisoryMonitor = monitor.Object;
}
return new ReproducibleBuildJob(
mockLogger,
mockOptions,
mockBuilders,
mockFingerprintExtractor.Object,
mockDiffEngine,
mockClaimRepo,
mockAdvisoryMonitor);
}
#endregion
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Builders\StellaOps.BinaryIndex.Builders.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// FeatureExtractorTests.cs
// Sprint: SPRINT_20251226_011_BINIDX_known_build_catalog
// Task: BINCAT-17 - Unit tests for identity extraction (ELF, PE, Mach-O)
@@ -509,7 +509,6 @@ public class BinaryIdentityDeterminismTests
using var stream1 = new MemoryStream(content1);
using var stream2 = new MemoryStream(content2);
using StellaOps.TestKit;
var identity1 = await extractor.ExtractIdentityAsync(stream1);
var identity2 = await extractor.ExtractIdentityAsync(stream2);

View File

@@ -6,6 +6,7 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.FixIndex.Services;
using Xunit;
@@ -338,7 +339,7 @@ public class FixIndexBuilderIntegrationTests
// Assert - Both are returned (patch with higher confidence overrides)
// The implementation allows both but prefers patch evidence
var cve5555 = results.Where(e => e.CveId == "CVE-2024-5555").ToList();
cve5555.Should().HaveCountGreaterOrEqualTo(1);
cve5555.Should().HaveCountGreaterThanOrEqualTo(1);
cve5555.Should().Contain(e => e.Method == FixMethod.PatchHeader);
}
}

View File

@@ -5,6 +5,7 @@
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.FixIndex.Models;
using StellaOps.BinaryIndex.FixIndex.Parsers;
using Xunit;

View File

@@ -9,17 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
@@ -28,4 +18,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -207,7 +207,7 @@ public class FingerprintMatcherTests
result.Details.Should().NotBeNull();
result.Details!.MatchingAlgorithm.Should().Be(FingerprintAlgorithm.BasicBlock);
result.Details.CandidatesEvaluated.Should().Be(1);
result.Details.MatchTimeMs.Should().BeGreaterOrEqualTo(0);
result.Details.MatchTimeMs.Should().BeGreaterThanOrEqualTo(0);
}
private static VulnFingerprint CreateStoredFingerprint(byte[] hash, bool validated = false)

View File

@@ -9,17 +9,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -7,6 +7,7 @@
using FluentAssertions;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Persistence.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
@@ -19,7 +20,6 @@ public sealed class BinaryIdentityRepositoryTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
using StellaOps.TestKit;
public BinaryIdentityRepositoryTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;

View File

@@ -8,6 +8,7 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Corpus;
using StellaOps.BinaryIndex.Persistence.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.BinaryIndex.Persistence.Tests;
@@ -20,7 +21,6 @@ public sealed class CorpusSnapshotRepositoryTests
{
private readonly BinaryIndexIntegrationFixture _fixture;
using StellaOps.TestKit;
public CorpusSnapshotRepositoryTests(BinaryIndexIntegrationFixture fixture)
{
_fixture = fixture;

View File

@@ -14,8 +14,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
@@ -24,4 +24,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.runner.visualstudio" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.VexBridge\StellaOps.BinaryIndex.VexBridge.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,281 @@
// VexBridge Integration Tests with Mock Excititor
// Sprint: SPRINT_1227_0001_0001 (Binary VEX Generator)
// Task: T8 - Integration test with mock Excititor
//
// Tests end-to-end flow from binary match to VEX observation persistence
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.BinaryIndex.VexBridge.Tests;
/// <summary>
/// Integration tests for VexBridge with mock Excititor services.
/// </summary>
public class VexBridgeIntegrationTests
{
private readonly VexEvidenceGenerator _generator;
private readonly VexBridgeOptions _options;
private readonly Mock<IVexObservationStore> _mockObservationStore;
public VexBridgeIntegrationTests()
{
_options = new VexBridgeOptions
{
MinConfidenceThreshold = 0.70m,
SignWithDsse = false,
MaxBatchSize = 1000
};
_mockObservationStore = new Mock<IVexObservationStore>();
_mockObservationStore
.Setup(x => x.AppendAsync(It.IsAny<VexObservation>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((VexObservation obs, CancellationToken _) => obs.ObservationId);
_generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(_options));
}
#region End-to-End Flow Tests
[Fact]
public async Task EndToEnd_BinaryMatch_To_VexObservation_Flow()
{
// Arrange - Simulate complete binary vulnerability detection flow
var match = new BinaryVulnMatch
{
CveId = "CVE-2024-0001",
VulnerablePurl = "pkg:deb/debian/openssl@3.0.7",
Method = MatchMethod.BuildIdCatalog,
Confidence = 0.98m,
Evidence = new MatchEvidence
{
BuildId = "abc123def456789",
Similarity = 0.98m,
MatchedFunction = "ssl3_get_record"
}
};
var identity = new BinaryIdentity
{
BinaryKey = "abc123def456789",
BuildId = "abc123def456789",
FileSha256 = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
TextSha256 = "sha256:abc123def456789",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
var fixStatus = new FixStatusResult
{
State = FixState.Fixed,
FixedVersion = "3.0.7-1+deb12u1",
Method = FixMethod.SecurityFeed,
Confidence = 0.95m,
EvidenceId = Guid.NewGuid()
};
var context = new VexGenerationContext
{
TenantId = "tenant-001",
ScanId = "scan-001",
ProductKey = "pkg:deb/debian/openssl@3.0.7",
DistroRelease = "debian:bookworm",
SignWithDsse = false
};
// Act - Generate VEX observation
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
// Assert - Verify complete observation structure
observation.Should().NotBeNull();
observation.ObservationId.Should().NotBeEmpty();
// Verify statement
observation.Statements.Should().HaveCount(1);
var statement = observation.Statements[0];
statement.Status.Should().Be(VexClaimStatus.NotAffected);
statement.Justification.Should().Be(VexJustification.VulnerableCodeNotPresent);
statement.FixedVersion.Should().Be("3.0.7-1+deb12u1");
// Verify linkset
observation.Linkset.Aliases.Should().Contain("cve-2024-0001");
observation.Linkset.Purls.Should().Contain("pkg:deb/debian/openssl@3.0.7");
observation.Linkset.References.Should().NotBeEmpty();
// Verify content has evidence payload
observation.Content.Should().NotBeNull();
observation.Content.Format.Should().Be("application/json");
}
[Fact]
public async Task EndToEnd_BatchProcessing_MultiplePackages()
{
// Arrange - Multiple packages from same scan
var packages = new[]
{
("openssl", "CVE-2024-0001", FixState.Fixed),
("curl", "CVE-2024-0002", FixState.Vulnerable),
("zlib", "CVE-2024-0003", FixState.NotAffected)
};
var matchesWithContext = packages.Select((p, i) => new BinaryMatchWithContext
{
Match = new BinaryVulnMatch
{
CveId = p.Item2,
VulnerablePurl = $"pkg:deb/debian/{p.Item1}@1.0.0",
Method = MatchMethod.FingerprintMatch,
Confidence = 0.90m,
Evidence = null
},
Identity = new BinaryIdentity
{
BinaryKey = $"build-id-{i}",
BuildId = $"build-id-{i}",
FileSha256 = $"sha256:{i:x64}",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
},
FixStatus = new FixStatusResult
{
State = p.Item3,
FixedVersion = p.Item3 == FixState.Fixed ? "1.0.1" : null,
Method = FixMethod.SecurityFeed,
Confidence = 0.90m
},
Context = new VexGenerationContext
{
TenantId = "tenant-001",
ScanId = "batch-scan-001",
ProductKey = $"pkg:deb/debian/{p.Item1}@1.0.0",
DistroRelease = "debian:bookworm",
SignWithDsse = false
}
}).ToList();
// Act
var observations = await _generator.GenerateBatchAsync(matchesWithContext);
// Assert
observations.Should().HaveCount(3);
// Fixed -> NotAffected
observations[0].Statements[0].Status.Should().Be(VexClaimStatus.NotAffected);
// Vulnerable -> Affected
observations[1].Statements[0].Status.Should().Be(VexClaimStatus.Affected);
// NotAffected -> NotAffected
observations[2].Statements[0].Status.Should().Be(VexClaimStatus.NotAffected);
}
[Fact]
public async Task EndToEnd_ObservationStore_MockPersistence()
{
// Arrange
var match = CreateSimpleMatch("CVE-2024-PERSIST");
var identity = CreateSimpleIdentity();
var context = CreateSimpleContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Simulate persistence
var persistedId = await _mockObservationStore.Object.AppendAsync(observation);
// Assert
persistedId.Should().Be(observation.ObservationId);
_mockObservationStore.Verify(
x => x.AppendAsync(It.Is<VexObservation>(o => o.ObservationId == observation.ObservationId), It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Dependency Injection Tests
[Fact]
public void DI_Registration_ResolvesGenerator()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.Configure<VexBridgeOptions>(opts =>
{
opts.MinConfidenceThreshold = 0.75m;
opts.SignWithDsse = false;
});
services.AddSingleton<IVexEvidenceGenerator, VexEvidenceGenerator>();
var provider = services.BuildServiceProvider();
// Act
var generator = provider.GetService<IVexEvidenceGenerator>();
// Assert
generator.Should().NotBeNull();
generator.Should().BeOfType<VexEvidenceGenerator>();
}
#endregion
#region Helper Methods
private static BinaryVulnMatch CreateSimpleMatch(string cveId)
{
return new BinaryVulnMatch
{
CveId = cveId,
VulnerablePurl = "pkg:deb/debian/test-pkg@1.0.0",
Method = MatchMethod.FingerprintMatch,
Confidence = 0.90m,
Evidence = null
};
}
private static BinaryIdentity CreateSimpleIdentity()
{
return new BinaryIdentity
{
BinaryKey = "test-build-id",
BuildId = "test-build-id",
FileSha256 = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
}
private static VexGenerationContext CreateSimpleContext()
{
return new VexGenerationContext
{
TenantId = "tenant-test",
ScanId = "scan-test",
ProductKey = "pkg:deb/debian/test-pkg@1.0.0",
DistroRelease = "debian:bookworm",
SignWithDsse = false
};
}
#endregion
}
/// <summary>
/// Mock interface for VEX observation persistence.
/// </summary>
public interface IVexObservationStore
{
Task<string> AppendAsync(VexObservation observation, CancellationToken ct = default);
}

View File

@@ -0,0 +1,459 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.Excititor.Core;
namespace StellaOps.BinaryIndex.VexBridge.Tests;
public class VexEvidenceGeneratorTests
{
private readonly VexEvidenceGenerator _generator;
private readonly VexBridgeOptions _options;
public VexEvidenceGeneratorTests()
{
_options = new VexBridgeOptions
{
MinConfidenceThreshold = 0.70m,
SignWithDsse = false,
MaxBatchSize = 1000
};
_generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(_options));
}
#region GenerateFromBinaryMatchAsync Tests
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithFixedStatus_ReturnsNotAffectedObservation()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-1234", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var fixStatus = CreateFixStatus(FixState.Fixed, "1.2.3-1");
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
// Assert
observation.Should().NotBeNull();
observation.Statements.Should().HaveCount(1);
observation.Statements[0].Status.Should().Be(VexClaimStatus.NotAffected);
observation.Statements[0].Justification.Should().Be(VexJustification.VulnerableCodeNotPresent);
observation.Statements[0].FixedVersion.Should().Be("1.2.3-1");
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithVulnerableStatus_ReturnsAffectedObservation()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-5678", confidence: 0.90m);
var identity = CreateBinaryIdentity();
var fixStatus = CreateFixStatus(FixState.Vulnerable);
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.Affected);
observation.Statements[0].Justification.Should().BeNull();
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithUnknownStatus_ReturnsUnderInvestigation()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-9999", confidence: 0.85m);
var identity = CreateBinaryIdentity();
var fixStatus = CreateFixStatus(FixState.Unknown);
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.UnderInvestigation);
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithNullFixStatus_ReturnsUnderInvestigation()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-0000", confidence: 0.80m);
var identity = CreateBinaryIdentity();
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.UnderInvestigation);
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_BelowConfidenceThreshold_ThrowsException()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-LOW", confidence: 0.50m);
var identity = CreateBinaryIdentity();
var context = CreateContext();
// Act
var act = () => _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*below minimum threshold*");
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithWontfixStatus_ReturnsNotAffectedWithMitigation()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-WONT", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var fixStatus = CreateFixStatus(FixState.Wontfix);
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
// Assert
observation.Statements[0].Status.Should().Be(VexClaimStatus.NotAffected);
observation.Statements[0].Justification.Should().Be(VexJustification.InlineMitigationsAlreadyExist);
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_SetsCorrectProviderAndStream()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-TEST", confidence: 0.95m);
var identity = CreateBinaryIdentity();
var context = CreateContext(providerId: "test.provider", streamId: "test.stream");
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Assert
observation.ProviderId.Should().Be("test.provider");
observation.StreamId.Should().Be("test.stream");
}
#endregion
#region GenerateObservationId Tests
[Fact]
public void GenerateObservationId_SameInputs_ReturnsSameId()
{
// Arrange & Act
var id1 = _generator.GenerateObservationId("tenant1", "CVE-2024-1234", "pkg:deb/debian/openssl@1.0", "scan-001");
var id2 = _generator.GenerateObservationId("tenant1", "CVE-2024-1234", "pkg:deb/debian/openssl@1.0", "scan-001");
// Assert
id1.Should().Be(id2);
}
[Fact]
public void GenerateObservationId_DifferentCve_ReturnsDifferentId()
{
// Arrange & Act
var id1 = _generator.GenerateObservationId("tenant1", "CVE-2024-1234", "pkg:deb/debian/openssl@1.0", "scan-001");
var id2 = _generator.GenerateObservationId("tenant1", "CVE-2024-5678", "pkg:deb/debian/openssl@1.0", "scan-001");
// Assert
id1.Should().NotBe(id2);
}
[Fact]
public void GenerateObservationId_CaseInsensitiveTenant()
{
// Arrange & Act
var id1 = _generator.GenerateObservationId("Tenant1", "CVE-2024-1234", "pkg:test", "scan-001");
var id2 = _generator.GenerateObservationId("tenant1", "CVE-2024-1234", "pkg:test", "scan-001");
// Assert
id1.Should().Be(id2);
}
[Fact]
public void GenerateObservationId_ReturnsValidGuidFormat()
{
// Arrange & Act
var id = _generator.GenerateObservationId("tenant", "CVE-2024-1234", "pkg:test", "scan");
// Assert
Guid.TryParse(id, out _).Should().BeTrue();
}
#endregion
#region GenerateBatchAsync Tests
[Fact]
public async Task GenerateBatchAsync_ProcessesAllItems_InDeterministicOrder()
{
// Arrange
var matches = new[]
{
CreateBinaryMatchWithContext("CVE-2024-003", "scan-001"),
CreateBinaryMatchWithContext("CVE-2024-001", "scan-001"),
CreateBinaryMatchWithContext("CVE-2024-002", "scan-001")
};
// Act
var observations = await _generator.GenerateBatchAsync(matches);
// Assert
observations.Should().HaveCount(3);
// Should be ordered by observation ID
observations.Select(o => o.ObservationId)
.Should().BeInAscendingOrder();
}
[Fact]
public async Task GenerateBatchAsync_SkipsItemsBelowThreshold()
{
// Arrange
var matches = new[]
{
CreateBinaryMatchWithContext("CVE-2024-HIGH", "scan-001", confidence: 0.95m),
CreateBinaryMatchWithContext("CVE-2024-LOW", "scan-001", confidence: 0.50m),
CreateBinaryMatchWithContext("CVE-2024-MED", "scan-001", confidence: 0.80m)
};
// Act
var observations = await _generator.GenerateBatchAsync(matches);
// Assert
observations.Should().HaveCount(2);
observations.Should().NotContain(o => o.Statements.Any(s => s.VulnerabilityId == "CVE-2024-LOW"));
}
[Fact]
public async Task GenerateBatchAsync_RespectsMaxBatchSize()
{
// Arrange - Create more items than max batch size
_options.MaxBatchSize = 5;
var generator = new VexEvidenceGenerator(
NullLogger<VexEvidenceGenerator>.Instance,
Options.Create(_options));
var matches = Enumerable.Range(1, 10)
.Select(i => CreateBinaryMatchWithContext($"CVE-2024-{i:D4}", $"scan-{i}"))
.ToList();
// Act
var observations = await generator.GenerateBatchAsync(matches);
// Assert
observations.Should().HaveCount(5);
}
[Fact]
public async Task GenerateBatchAsync_EmptyInput_ReturnsEmptyList()
{
// Act
var observations = await _generator.GenerateBatchAsync(Array.Empty<BinaryMatchWithContext>());
// Assert
observations.Should().BeEmpty();
}
#endregion
#region Evidence Content Tests
[Fact]
public async Task GenerateFromBinaryMatchAsync_EvidenceContainsRequiredFields()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-TEST",
confidence: 0.95m,
method: MatchMethod.FingerprintMatch);
var identity = CreateBinaryIdentity(
buildId: "build123",
fileSha256: "sha256:abc123",
textSha256: "sha256:def456");
var fixStatus = CreateFixStatus(FixState.Fixed, "1.0.0-fix1");
var context = CreateContext(distroRelease: "debian:bookworm");
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, fixStatus, context);
// Assert
var content = observation.Content.Raw;
content.Should().NotBeNull();
// Check that evidence contains expected fields
var json = content.AsObject();
json["type"]?.GetValue<string>().Should().Be("binary_fingerprint_match");
json["match_type"]?.GetValue<string>().Should().Be("fingerprint");
json["build_id"]?.GetValue<string>().Should().Be("build123");
json["distro_release"]?.GetValue<string>().Should().Be("debian:bookworm");
json["fixed_version"]?.GetValue<string>().Should().Be("1.0.0-fix1");
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_WithBuildIdMatch_SetsCorrectMatchType()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-BUILD",
confidence: 0.99m,
method: MatchMethod.BuildIdCatalog);
var identity = CreateBinaryIdentity();
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Assert
var json = observation.Content.Raw.AsObject();
json["match_type"]?.GetValue<string>().Should().Be("build_id");
}
#endregion
#region Linkset Tests
[Fact]
public async Task GenerateFromBinaryMatchAsync_LinksContainVulnerabilityReference()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-LINK", confidence: 0.90m);
var identity = CreateBinaryIdentity();
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Assert
// Note: VexObservationLinkset normalizes aliases to lowercase for case-insensitive comparison
observation.Linkset.Aliases.Should().Contain("cve-2024-link");
observation.Linkset.Purls.Should().NotBeEmpty();
}
[Fact]
public async Task GenerateFromBinaryMatchAsync_IncludesBuildIdReference_WhenPresent()
{
// Arrange
var match = CreateBinaryVulnMatch("CVE-2024-BUILDREF", confidence: 0.90m);
var identity = CreateBinaryIdentity(buildId: "test-build-id-12345");
var context = CreateContext();
// Act
var observation = await _generator.GenerateFromBinaryMatchAsync(
match, identity, null, context);
// Assert
observation.Linkset.References
.Should().Contain(r => r.Type == "build_id" && r.Url.Contains("test-build-id-12345"));
}
#endregion
#region Helper Methods
private static BinaryVulnMatch CreateBinaryVulnMatch(
string cveId,
decimal confidence = 0.90m,
MatchMethod method = MatchMethod.FingerprintMatch)
{
return new BinaryVulnMatch
{
CveId = cveId,
VulnerablePurl = $"pkg:deb/debian/test-package@1.0.0",
Method = method,
Confidence = confidence,
Evidence = new MatchEvidence
{
BuildId = null,
Similarity = confidence,
MatchedFunction = null
}
};
}
private static BinaryIdentity CreateBinaryIdentity(
string? buildId = null,
string fileSha256 = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
string? textSha256 = null)
{
return new BinaryIdentity
{
BinaryKey = buildId ?? fileSha256,
BuildId = buildId,
FileSha256 = fileSha256,
TextSha256 = textSha256,
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
}
private static FixStatusResult CreateFixStatus(
FixState state,
string? fixedVersion = null)
{
return new FixStatusResult
{
State = state,
FixedVersion = fixedVersion,
Method = FixMethod.SecurityFeed,
Confidence = 0.95m,
EvidenceId = Guid.NewGuid()
};
}
private static VexGenerationContext CreateContext(
string tenantId = "test-tenant",
string scanId = "scan-001",
string productKey = "pkg:deb/debian/test-package@1.0.0",
string? distroRelease = null,
string providerId = "stellaops.binaryindex",
string streamId = "binary_resolution")
{
return new VexGenerationContext
{
TenantId = tenantId,
ScanId = scanId,
ProductKey = productKey,
DistroRelease = distroRelease,
SignWithDsse = false,
ProviderId = providerId,
StreamId = streamId
};
}
private static BinaryMatchWithContext CreateBinaryMatchWithContext(
string cveId,
string scanId,
decimal confidence = 0.90m)
{
return new BinaryMatchWithContext
{
Match = CreateBinaryVulnMatch(cveId, confidence),
Identity = CreateBinaryIdentity(),
FixStatus = null,
Context = CreateContext(scanId: scanId)
};
}
#endregion
}

View File

@@ -0,0 +1,407 @@
// -----------------------------------------------------------------------------
// ResolutionControllerIntegrationTests.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T9 — Integration tests for resolution API
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.BinaryIndex.Contracts.Resolution;
using Xunit;
namespace StellaOps.BinaryIndex.WebService.Tests;
/// <summary>
/// Integration tests for the Resolution API endpoints.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "BinaryIndex")]
public class ResolutionControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ResolutionControllerIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Add test-specific services if needed
});
});
_client = _factory.CreateClient();
}
#region Single Resolution Tests
[Fact(DisplayName = "POST /api/v1/resolve/vuln returns 200 for valid request")]
public async Task ResolveVuln_ValidRequest_Returns200()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "abc123def456789",
DistroRelease = "debian:bookworm"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
result.Should().NotBeNull();
result!.Package.Should().Be("pkg:deb/debian/openssl@3.0.7");
result.Status.Should().BeOneOf(ResolutionStatus.Fixed, ResolutionStatus.Vulnerable,
ResolutionStatus.NotAffected, ResolutionStatus.Unknown);
result.ResolvedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact(DisplayName = "POST /api/v1/resolve/vuln returns 400 for missing package")]
public async Task ResolveVuln_MissingPackage_Returns400()
{
// Arrange
var request = new { BuildId = "abc123" }; // Missing required Package field
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact(DisplayName = "POST /api/v1/resolve/vuln with CVE returns targeted resolution")]
public async Task ResolveVuln_WithCveId_ReturnsTargetedResolution()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
CveId = "CVE-2024-0001",
BuildId = "abc123def456789"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact(DisplayName = "Resolution includes cache headers")]
public async Task ResolveVuln_IncludesCacheHeaders()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.Headers.Should().ContainKey("X-RateLimit-Limit");
response.Headers.Should().ContainKey("X-RateLimit-Remaining");
}
#endregion
#region Batch Resolution Tests
[Fact(DisplayName = "POST /api/v1/resolve/vuln/batch handles multiple items")]
public async Task ResolveBatch_MultipleItems_ReturnsAllResults()
{
// Arrange
var request = new BatchVulnResolutionRequest
{
Items = new[]
{
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7" },
new VulnResolutionRequest { Package = "pkg:deb/debian/libcurl@7.88.1" },
new VulnResolutionRequest { Package = "pkg:deb/debian/zlib@1.2.13" }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<BatchVulnResolutionResponse>();
result.Should().NotBeNull();
result!.Results.Should().HaveCount(3);
}
[Fact(DisplayName = "Batch resolution respects size limit")]
public async Task ResolveBatch_ExceedsSizeLimit_Returns400()
{
// Arrange - Create 501 items (assuming 500 is the limit)
var items = Enumerable.Range(0, 501)
.Select(i => new VulnResolutionRequest { Package = $"pkg:npm/package{i}@1.0.0" })
.ToArray();
var request = new BatchVulnResolutionRequest { Items = items };
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact(DisplayName = "Batch resolution performance under 500ms for 100 cached items")]
public async Task ResolveBatch_CachedItems_PerformanceAcceptable()
{
// Arrange
var items = Enumerable.Range(0, 100)
.Select(i => new VulnResolutionRequest
{
Package = $"pkg:deb/debian/test-package{i}@1.0.0",
BuildId = $"build-{i}"
})
.ToArray();
var request = new BatchVulnResolutionRequest { Items = items };
// Warm up cache with first request
await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
// Act
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
stopwatch.Stop();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
stopwatch.ElapsedMilliseconds.Should().BeLessThan(500,
"Cached batch resolution should complete in under 500ms");
}
#endregion
#region Cache Tests
[Fact(DisplayName = "Second request returns cached result")]
public async Task ResolveVuln_SecondRequest_ReturnsCachedResult()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "cache-test-build-id"
};
// Act
var response1 = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result1 = await response1.Content.ReadFromJsonAsync<VulnResolutionResponse>();
var response2 = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result2 = await response2.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result2!.FromCache.Should().BeTrue();
result1!.Status.Should().Be(result2.Status);
}
[Fact(DisplayName = "Bypass cache option works")]
public async Task ResolveVuln_BypassCache_FreshResult()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "bypass-cache-test"
};
// First request to populate cache
await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Second request with bypass
_client.DefaultRequestHeaders.Add("X-Bypass-Cache", "true");
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
result.Should().NotBeNull();
result!.FromCache.Should().BeFalse();
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Bypass-Cache");
}
#endregion
#region DSSE Attestation Tests
[Fact(DisplayName = "Response includes DSSE attestation when requested")]
public async Task ResolveVuln_WithDsseRequest_IncludesAttestation()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "dsse-test-build"
};
_client.DefaultRequestHeaders.Add("X-Include-Attestation", "true");
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Note: Attestation may be null if signing is not configured
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Include-Attestation");
}
[Fact(DisplayName = "DSSE attestation is valid base64")]
public async Task ResolveVuln_DsseAttestation_IsValidBase64()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "dsse-validation-test"
};
_client.DefaultRequestHeaders.Add("X-Include-Attestation", "true");
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
if (!string.IsNullOrEmpty(result?.AttestationDsse))
{
// Should not throw
var bytes = Convert.FromBase64String(result.AttestationDsse);
bytes.Should().NotBeEmpty();
// Should be valid JSON
var json = System.Text.Encoding.UTF8.GetString(bytes);
var doc = JsonDocument.Parse(json);
doc.RootElement.TryGetProperty("payload", out _).Should().BeTrue();
doc.RootElement.TryGetProperty("payloadType", out _).Should().BeTrue();
}
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Include-Attestation");
}
#endregion
#region Rate Limiting Tests
[Fact(DisplayName = "Rate limiting returns 429 when exceeded")]
public async Task ResolveVuln_RateLimitExceeded_Returns429()
{
// Arrange - This test depends on rate limit configuration
// Create a client with test tenant that has low rate limit
var request = new VulnResolutionRequest
{
Package = "pkg:npm/rate-limit-test@1.0.0"
};
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "rate-limit-test-tenant");
// Act - Send many requests quickly
var tasks = Enumerable.Range(0, 150)
.Select(_ => _client.PostAsJsonAsync("/api/v1/resolve/vuln", request));
var responses = await Task.WhenAll(tasks);
// Assert - At least some should be rate limited
var rateLimited = responses.Where(r => r.StatusCode == HttpStatusCode.TooManyRequests);
// Note: This may pass or fail depending on actual rate limit config
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Tenant-Id");
}
[Fact(DisplayName = "Rate limit headers are present")]
public async Task ResolveVuln_RateLimitHeaders_Present()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:npm/headers-test@1.0.0"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.Headers.Contains("X-RateLimit-Limit").Should().BeTrue();
response.Headers.Contains("X-RateLimit-Remaining").Should().BeTrue();
response.Headers.Contains("X-RateLimit-Reset").Should().BeTrue();
}
#endregion
#region Evidence Tests
[Fact(DisplayName = "Fixed resolution includes evidence")]
public async Task ResolveVuln_FixedStatus_IncludesEvidence()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7-1+deb12u1",
BuildId = "fixed-binary-build-id",
DistroRelease = "debian:bookworm"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
if (result?.Status == ResolutionStatus.Fixed)
{
result.Evidence.Should().NotBeNull();
result.Evidence!.MatchType.Should().NotBeNullOrEmpty();
result.Evidence.Confidence.Should().BeGreaterThan(0);
}
}
#endregion
}
/// <summary>
/// Placeholder for batch request if not in Contracts.
/// </summary>
public record BatchVulnResolutionRequest
{
public VulnResolutionRequest[] Items { get; init; } = Array.Empty<VulnResolutionRequest>();
public ResolutionOptions? Options { get; init; }
}
public record BatchVulnResolutionResponse
{
public VulnResolutionResponse[] Results { get; init; } = Array.Empty<VulnResolutionResponse>();
public int TotalCount { get; init; }
public int SuccessCount { get; init; }
public int ErrorCount { get; init; }
}
public record ResolutionOptions
{
public bool BypassCache { get; init; }
public bool IncludeDsseAttestation { get; init; }
}