save progress

This commit is contained in:
StellaOps Bot
2026-01-03 15:27:15 +02:00
parent d486d41a48
commit bc4dd4f377
70 changed files with 8531 additions and 653 deletions

View File

@@ -0,0 +1,331 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using Microsoft.AspNetCore.Mvc;
using StellaOps.BinaryIndex.Persistence.Repositories;
namespace StellaOps.BinaryIndex.WebService.Controllers;
/// <summary>
/// API endpoints for patch coverage visualization (Patch Map Explorer).
/// </summary>
/// <remarks>
/// Provides aggregated patch coverage data for heatmap visualization,
/// function-level drill-down, and affected image listing.
/// </remarks>
[ApiController]
[Route("api/v1/stats/patch-coverage")]
[Produces("application/json")]
public sealed class PatchCoverageController : ControllerBase
{
private readonly IDeltaSignatureRepository _repository;
private readonly ILogger<PatchCoverageController> _logger;
public PatchCoverageController(
IDeltaSignatureRepository repository,
ILogger<PatchCoverageController> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Get aggregated patch coverage by CVE for heatmap visualization.
/// </summary>
/// <remarks>
/// Returns summary statistics of vulnerable/patched/unknown counts per CVE,
/// with coverage percentage for heatmap coloring.
///
/// Sample request:
///
/// GET /api/v1/stats/patch-coverage?package=openssl&amp;limit=50
///
/// Sample response:
///
/// {
/// "entries": [
/// {
/// "cveId": "CVE-2023-0286",
/// "packageName": "openssl",
/// "vulnerableCount": 15,
/// "patchedCount": 85,
/// "unknownCount": 0,
/// "symbolCount": 3,
/// "coveragePercent": 85.0,
/// "lastUpdatedAt": "2024-01-15T10:30:00Z"
/// }
/// ],
/// "totalCount": 127,
/// "offset": 0,
/// "limit": 50
/// }
/// </remarks>
/// <param name="cve">Optional CVE IDs to filter (comma-separated).</param>
/// <param name="package">Optional package name filter.</param>
/// <param name="limit">Maximum CVEs to return (1-500, default 100).</param>
/// <param name="offset">Pagination offset (default 0).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Paginated list of patch coverage entries.</returns>
/// <response code="200">Returns the coverage data.</response>
/// <response code="400">Invalid parameters.</response>
[HttpGet]
[ProducesResponseType<PatchCoverageResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PatchCoverageResult>> GetPatchCoverageAsync(
[FromQuery] string? cve = null,
[FromQuery] string? package = null,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0,
CancellationToken ct = default)
{
// Validate parameters
if (limit < 1 || limit > 500)
{
return BadRequest(CreateProblem(
"Limit must be between 1 and 500.",
"InvalidLimit",
StatusCodes.Status400BadRequest));
}
if (offset < 0)
{
return BadRequest(CreateProblem(
"Offset must be non-negative.",
"InvalidOffset",
StatusCodes.Status400BadRequest));
}
// Parse CVE filter
IReadOnlyList<string>? cveFilter = null;
if (!string.IsNullOrWhiteSpace(cve))
{
cveFilter = cve.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
_logger.LogInformation(
"GetPatchCoverage: cve={CveFilter}, package={Package}, limit={Limit}, offset={Offset}",
cve, package, limit, offset);
try
{
var result = await _repository.GetPatchCoverageAsync(
cveFilter,
package,
limit,
offset,
ct);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get patch coverage");
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "CoverageError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Get detailed function-level patch coverage for a specific CVE.
/// </summary>
/// <remarks>
/// Returns breakdown of vulnerable/patched counts per function/symbol,
/// with summary statistics and delta pair indicators.
///
/// Sample request:
///
/// GET /api/v1/stats/patch-coverage/CVE-2023-0286/details
///
/// Sample response:
///
/// {
/// "cveId": "CVE-2023-0286",
/// "packageName": "openssl",
/// "functions": [
/// {
/// "symbolName": "X509_VERIFY_PARAM_set1_policies",
/// "soname": "libssl.so.3",
/// "vulnerableCount": 5,
/// "patchedCount": 95,
/// "unknownCount": 0,
/// "hasDelta": true
/// }
/// ],
/// "summary": {
/// "totalImages": 100,
/// "vulnerableImages": 5,
/// "patchedImages": 95,
/// "unknownImages": 0,
/// "overallCoverage": 95.0,
/// "symbolCount": 3,
/// "deltaPairCount": 3
/// }
/// }
/// </remarks>
/// <param name="cveId">CVE identifier (e.g., CVE-2023-0286).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Detailed coverage breakdown.</returns>
/// <response code="200">Returns the detailed coverage.</response>
/// <response code="404">CVE not found in index.</response>
[HttpGet("{cveId}/details")]
[ProducesResponseType<PatchCoverageDetails>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PatchCoverageDetails>> GetPatchCoverageDetailsAsync(
[FromRoute] string cveId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return BadRequest(CreateProblem(
"CVE ID is required.",
"MissingCveId",
StatusCodes.Status400BadRequest));
}
_logger.LogInformation("GetPatchCoverageDetails: cveId={CveId}", cveId);
try
{
var result = await _repository.GetPatchCoverageDetailsAsync(cveId, ct);
if (result is null)
{
return NotFound(CreateProblem(
$"No coverage data found for CVE {cveId}.",
"CveNotFound",
StatusCodes.Status404NotFound));
}
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get patch coverage details for {CveId}", cveId);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "DetailsError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Get paginated list of matching images for a CVE.
/// </summary>
/// <remarks>
/// Returns images/binaries matching the specified CVE, with optional
/// filtering by symbol name and match state.
///
/// Sample request:
///
/// GET /api/v1/stats/patch-coverage/CVE-2023-0286/matches?state=vulnerable&amp;limit=20
///
/// Sample response:
///
/// {
/// "matches": [
/// {
/// "matchId": "550e8400-e29b-41d4-a716-446655440000",
/// "binaryKey": "sha256:abc123...",
/// "binarySha256": "abc123...",
/// "symbolName": "X509_VERIFY_PARAM_set1_policies",
/// "matchState": "vulnerable",
/// "confidence": 0.95,
/// "scanId": "550e8400-e29b-41d4-a716-446655440001",
/// "scannedAt": "2024-01-15T10:30:00Z"
/// }
/// ],
/// "totalCount": 15,
/// "offset": 0,
/// "limit": 20
/// }
/// </remarks>
/// <param name="cveId">CVE identifier.</param>
/// <param name="symbol">Optional symbol name filter.</param>
/// <param name="state">Optional state filter (vulnerable, patched, unknown).</param>
/// <param name="limit">Maximum matches to return (1-200, default 50).</param>
/// <param name="offset">Pagination offset (default 0).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Paginated list of matching images.</returns>
/// <response code="200">Returns the matching images.</response>
/// <response code="400">Invalid parameters.</response>
[HttpGet("{cveId}/matches")]
[ProducesResponseType<PatchMatchPage>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PatchMatchPage>> GetMatchingImagesAsync(
[FromRoute] string cveId,
[FromQuery] string? symbol = null,
[FromQuery] string? state = null,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return BadRequest(CreateProblem(
"CVE ID is required.",
"MissingCveId",
StatusCodes.Status400BadRequest));
}
if (limit < 1 || limit > 200)
{
return BadRequest(CreateProblem(
"Limit must be between 1 and 200.",
"InvalidLimit",
StatusCodes.Status400BadRequest));
}
if (offset < 0)
{
return BadRequest(CreateProblem(
"Offset must be non-negative.",
"InvalidOffset",
StatusCodes.Status400BadRequest));
}
// Validate state if provided
if (!string.IsNullOrWhiteSpace(state))
{
var validStates = new[] { "vulnerable", "patched", "unknown" };
if (!validStates.Contains(state.ToLowerInvariant()))
{
return BadRequest(CreateProblem(
"State must be one of: vulnerable, patched, unknown.",
"InvalidState",
StatusCodes.Status400BadRequest));
}
}
_logger.LogInformation(
"GetMatchingImages: cveId={CveId}, symbol={Symbol}, state={State}, limit={Limit}, offset={Offset}",
cveId, symbol, state, limit, offset);
try
{
var result = await _repository.GetMatchingImagesAsync(
cveId,
symbol,
state,
limit,
offset,
ct);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get matching images for {CveId}", cveId);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "MatchesError", StatusCodes.Status500InternalServerError));
}
}
private static ProblemDetails CreateProblem(string detail, string type, int statusCode)
{
return new ProblemDetails
{
Title = "Patch Coverage Error",
Detail = detail,
Type = $"https://stellaops.dev/errors/{type}",
Status = statusCode
};
}
}

View File

@@ -19,6 +19,7 @@
<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.Persistence/StellaOps.BinaryIndex.Persistence.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.VexBridge/StellaOps.BinaryIndex.VexBridge.csproj" />
</ItemGroup>