save progress
This commit is contained in:
@@ -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&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&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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -436,6 +436,251 @@ public sealed class DeltaSignatureRepository : IDeltaSignatureRepository
|
||||
return rows.ToDictionary(r => r.State, r => r.Count);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Patch Coverage Aggregation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PatchCoverageResult> GetPatchCoverageAsync(
|
||||
IReadOnlyList<string>? cveFilter = null,
|
||||
string? packageFilter = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _dbContext.OpenConnectionAsync(ct);
|
||||
|
||||
var conditions = new List<string>();
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
if (cveFilter is { Count: > 0 })
|
||||
{
|
||||
conditions.Add("ds.cve_id = ANY(@CveIds)");
|
||||
parameters.Add("CveIds", cveFilter.ToArray());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(packageFilter))
|
||||
{
|
||||
conditions.Add("ds.package_name = @PackageName");
|
||||
parameters.Add("PackageName", packageFilter);
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0
|
||||
? "WHERE " + string.Join(" AND ", conditions)
|
||||
: string.Empty;
|
||||
|
||||
// Count total CVEs matching filter
|
||||
var countSql = $"""
|
||||
SELECT COUNT(DISTINCT ds.cve_id)
|
||||
FROM binaries.delta_signature ds
|
||||
{whereClause}
|
||||
""";
|
||||
|
||||
var countCommand = new CommandDefinition(countSql, parameters, cancellationToken: ct);
|
||||
var totalCount = await conn.ExecuteScalarAsync<int>(countCommand);
|
||||
|
||||
// Get aggregated coverage by CVE
|
||||
var sql = $"""
|
||||
WITH cve_stats AS (
|
||||
SELECT
|
||||
ds.cve_id,
|
||||
ds.package_name,
|
||||
COUNT(DISTINCT ds.symbol_name) as symbol_count,
|
||||
COUNT(*) FILTER (WHERE ds.signature_state = 'vulnerable') as vulnerable_count,
|
||||
COUNT(*) FILTER (WHERE ds.signature_state = 'patched') as patched_count,
|
||||
COUNT(*) FILTER (WHERE ds.signature_state NOT IN ('vulnerable', 'patched')) as unknown_count,
|
||||
MAX(ds.updated_at) as last_updated_at
|
||||
FROM binaries.delta_signature ds
|
||||
{whereClause}
|
||||
GROUP BY ds.cve_id, ds.package_name
|
||||
)
|
||||
SELECT
|
||||
cve_id as CveId,
|
||||
package_name as PackageName,
|
||||
vulnerable_count as VulnerableCount,
|
||||
patched_count as PatchedCount,
|
||||
unknown_count as UnknownCount,
|
||||
symbol_count as SymbolCount,
|
||||
CASE WHEN (vulnerable_count + patched_count + unknown_count) > 0
|
||||
THEN (patched_count * 100.0 / (vulnerable_count + patched_count + unknown_count))
|
||||
ELSE 0
|
||||
END as CoveragePercent,
|
||||
last_updated_at as LastUpdatedAt
|
||||
FROM cve_stats
|
||||
ORDER BY cve_id
|
||||
LIMIT @Limit OFFSET @Offset
|
||||
""";
|
||||
|
||||
parameters.Add("Limit", limit);
|
||||
parameters.Add("Offset", offset);
|
||||
|
||||
var command = new CommandDefinition(sql, parameters, cancellationToken: ct);
|
||||
var rows = await conn.QueryAsync<PatchCoverageEntry>(command);
|
||||
|
||||
_logger.LogDebug(
|
||||
"GetPatchCoverageAsync returned {Count} entries (total: {Total})",
|
||||
rows.Count(), totalCount);
|
||||
|
||||
return new PatchCoverageResult
|
||||
{
|
||||
Entries = rows.ToList(),
|
||||
TotalCount = totalCount,
|
||||
Offset = offset,
|
||||
Limit = limit
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PatchCoverageDetails?> GetPatchCoverageDetailsAsync(
|
||||
string cveId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _dbContext.OpenConnectionAsync(ct);
|
||||
|
||||
// Get function-level breakdown
|
||||
const string functionSql = """
|
||||
SELECT
|
||||
ds.symbol_name as SymbolName,
|
||||
ds.soname as Soname,
|
||||
COUNT(*) FILTER (WHERE ds.signature_state = 'vulnerable') as VulnerableCount,
|
||||
COUNT(*) FILTER (WHERE ds.signature_state = 'patched') as PatchedCount,
|
||||
COUNT(*) FILTER (WHERE ds.signature_state NOT IN ('vulnerable', 'patched')) as UnknownCount,
|
||||
(COUNT(*) FILTER (WHERE ds.signature_state = 'vulnerable') > 0
|
||||
AND COUNT(*) FILTER (WHERE ds.signature_state = 'patched') > 0) as HasDelta
|
||||
FROM binaries.delta_signature ds
|
||||
WHERE ds.cve_id = @CveId
|
||||
GROUP BY ds.symbol_name, ds.soname
|
||||
ORDER BY ds.symbol_name
|
||||
""";
|
||||
|
||||
var functionCommand = new CommandDefinition(
|
||||
functionSql,
|
||||
new { CveId = cveId },
|
||||
cancellationToken: ct);
|
||||
var functions = (await conn.QueryAsync<FunctionCoverageEntry>(functionCommand)).ToList();
|
||||
|
||||
if (functions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get package name
|
||||
const string packageSql = """
|
||||
SELECT DISTINCT package_name
|
||||
FROM binaries.delta_signature
|
||||
WHERE cve_id = @CveId
|
||||
LIMIT 1
|
||||
""";
|
||||
var packageName = await conn.ExecuteScalarAsync<string>(
|
||||
new CommandDefinition(packageSql, new { CveId = cveId }, cancellationToken: ct)) ?? "unknown";
|
||||
|
||||
// Compute summary
|
||||
var totalVulnerable = functions.Sum(f => f.VulnerableCount);
|
||||
var totalPatched = functions.Sum(f => f.PatchedCount);
|
||||
var totalUnknown = functions.Sum(f => f.UnknownCount);
|
||||
var totalImages = totalVulnerable + totalPatched + totalUnknown;
|
||||
var deltaPairCount = functions.Count(f => f.HasDelta);
|
||||
|
||||
var summary = new PatchCoverageSummary
|
||||
{
|
||||
TotalImages = totalImages,
|
||||
VulnerableImages = totalVulnerable,
|
||||
PatchedImages = totalPatched,
|
||||
UnknownImages = totalUnknown,
|
||||
OverallCoverage = totalImages > 0
|
||||
? (decimal)totalPatched * 100m / totalImages
|
||||
: 0m,
|
||||
SymbolCount = functions.Count,
|
||||
DeltaPairCount = deltaPairCount
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"GetPatchCoverageDetailsAsync for {CveId}: {SymbolCount} symbols, {Coverage:F1}% coverage",
|
||||
cveId, functions.Count, summary.OverallCoverage);
|
||||
|
||||
return new PatchCoverageDetails
|
||||
{
|
||||
CveId = cveId,
|
||||
PackageName = packageName,
|
||||
Functions = functions,
|
||||
Summary = summary
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PatchMatchPage> GetMatchingImagesAsync(
|
||||
string cveId,
|
||||
string? symbolName = null,
|
||||
string? matchState = null,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = await _dbContext.OpenConnectionAsync(ct);
|
||||
|
||||
var conditions = new List<string> { "m.cve_id = @CveId" };
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("CveId", cveId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(symbolName))
|
||||
{
|
||||
conditions.Add("m.symbol_name = @SymbolName");
|
||||
parameters.Add("SymbolName", symbolName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(matchState))
|
||||
{
|
||||
conditions.Add("m.matched_state = @MatchState");
|
||||
parameters.Add("MatchState", matchState);
|
||||
}
|
||||
|
||||
var whereClause = "WHERE " + string.Join(" AND ", conditions);
|
||||
|
||||
// Count total matches
|
||||
var countSql = $"""
|
||||
SELECT COUNT(*)
|
||||
FROM binaries.delta_sig_match m
|
||||
{whereClause}
|
||||
""";
|
||||
var countCommand = new CommandDefinition(countSql, parameters, cancellationToken: ct);
|
||||
var totalCount = await conn.ExecuteScalarAsync<int>(countCommand);
|
||||
|
||||
// Get paginated matches
|
||||
var sql = $"""
|
||||
SELECT
|
||||
m.id as MatchId,
|
||||
m.binary_key as BinaryKey,
|
||||
m.binary_sha256 as BinarySha256,
|
||||
m.symbol_name as SymbolName,
|
||||
m.matched_state as MatchState,
|
||||
m.confidence as Confidence,
|
||||
m.scan_id as ScanId,
|
||||
m.scanned_at as ScannedAt
|
||||
FROM binaries.delta_sig_match m
|
||||
{whereClause}
|
||||
ORDER BY m.scanned_at DESC
|
||||
LIMIT @Limit OFFSET @Offset
|
||||
""";
|
||||
|
||||
parameters.Add("Limit", limit);
|
||||
parameters.Add("Offset", offset);
|
||||
|
||||
var command = new CommandDefinition(sql, parameters, cancellationToken: ct);
|
||||
var rows = await conn.QueryAsync<PatchMatchEntry>(command);
|
||||
|
||||
_logger.LogDebug(
|
||||
"GetMatchingImagesAsync for {CveId}: {Count} matches (total: {Total})",
|
||||
cveId, rows.Count(), totalCount);
|
||||
|
||||
return new PatchMatchPage
|
||||
{
|
||||
Matches = rows.ToList(),
|
||||
TotalCount = totalCount,
|
||||
Offset = offset,
|
||||
Limit = limit
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal row type for Dapper mapping.
|
||||
/// </summary>
|
||||
|
||||
@@ -97,6 +97,221 @@ public interface IDeltaSignatureRepository
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, int>> GetCountsByStateAsync(
|
||||
CancellationToken ct = default);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Patch Coverage Aggregation (for Patch Map visualization)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated patch coverage statistics by CVE.
|
||||
/// Returns summary counts of vulnerable/patched/unknown states per CVE.
|
||||
/// </summary>
|
||||
/// <param name="cveFilter">Optional CVE IDs to filter.</param>
|
||||
/// <param name="packageFilter">Optional package name filter.</param>
|
||||
/// <param name="limit">Maximum number of CVEs to return (default 100).</param>
|
||||
/// <param name="offset">Offset for pagination.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<PatchCoverageResult> GetPatchCoverageAsync(
|
||||
IReadOnlyList<string>? cveFilter = null,
|
||||
string? packageFilter = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed patch coverage for a specific CVE with function-level breakdown.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<PatchCoverageDetails?> GetPatchCoverageDetailsAsync(
|
||||
string cveId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets paginated list of images/binaries matching a specific CVE and symbol.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="symbolName">Optional symbol name filter.</param>
|
||||
/// <param name="matchState">Optional state filter (vulnerable/patched/unknown).</param>
|
||||
/// <param name="limit">Maximum results.</param>
|
||||
/// <param name="offset">Pagination offset.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<PatchMatchPage> GetMatchingImagesAsync(
|
||||
string cveId,
|
||||
string? symbolName = null,
|
||||
string? matchState = null,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Patch Coverage DTOs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Result of patch coverage aggregation query.
|
||||
/// </summary>
|
||||
public sealed record PatchCoverageResult
|
||||
{
|
||||
/// <summary>Coverage entries by CVE.</summary>
|
||||
public required IReadOnlyList<PatchCoverageEntry> Entries { get; init; }
|
||||
|
||||
/// <summary>Total number of CVEs matching filter.</summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Offset used for pagination.</summary>
|
||||
public int Offset { get; init; }
|
||||
|
||||
/// <summary>Limit used for pagination.</summary>
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch coverage summary for a single CVE.
|
||||
/// </summary>
|
||||
public sealed record PatchCoverageEntry
|
||||
{
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Primary package name.</summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>Number of images with vulnerable state.</summary>
|
||||
public int VulnerableCount { get; init; }
|
||||
|
||||
/// <summary>Number of images with patched state.</summary>
|
||||
public int PatchedCount { get; init; }
|
||||
|
||||
/// <summary>Number of images with unknown state.</summary>
|
||||
public int UnknownCount { get; init; }
|
||||
|
||||
/// <summary>Total number of distinct symbols tracked.</summary>
|
||||
public int SymbolCount { get; init; }
|
||||
|
||||
/// <summary>Patch coverage percentage (0-100).</summary>
|
||||
public decimal CoveragePercent { get; init; }
|
||||
|
||||
/// <summary>When this CVE's signatures were last updated.</summary>
|
||||
public DateTimeOffset LastUpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed patch coverage for a CVE with function-level breakdown.
|
||||
/// </summary>
|
||||
public sealed record PatchCoverageDetails
|
||||
{
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Primary package name.</summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>Function-level breakdown.</summary>
|
||||
public required IReadOnlyList<FunctionCoverageEntry> Functions { get; init; }
|
||||
|
||||
/// <summary>Summary statistics.</summary>
|
||||
public required PatchCoverageSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage entry for a single function/symbol.
|
||||
/// </summary>
|
||||
public sealed record FunctionCoverageEntry
|
||||
{
|
||||
/// <summary>Symbol/function name.</summary>
|
||||
public required string SymbolName { get; init; }
|
||||
|
||||
/// <summary>Shared object name (soname).</summary>
|
||||
public string? Soname { get; init; }
|
||||
|
||||
/// <summary>Number of vulnerable matches.</summary>
|
||||
public int VulnerableCount { get; init; }
|
||||
|
||||
/// <summary>Number of patched matches.</summary>
|
||||
public int PatchedCount { get; init; }
|
||||
|
||||
/// <summary>Number of unknown matches.</summary>
|
||||
public int UnknownCount { get; init; }
|
||||
|
||||
/// <summary>Whether vulnerable and patched signatures exist.</summary>
|
||||
public bool HasDelta { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for patch coverage.
|
||||
/// </summary>
|
||||
public sealed record PatchCoverageSummary
|
||||
{
|
||||
/// <summary>Total images analyzed.</summary>
|
||||
public int TotalImages { get; init; }
|
||||
|
||||
/// <summary>Total vulnerable images.</summary>
|
||||
public int VulnerableImages { get; init; }
|
||||
|
||||
/// <summary>Total patched images.</summary>
|
||||
public int PatchedImages { get; init; }
|
||||
|
||||
/// <summary>Total unknown images.</summary>
|
||||
public int UnknownImages { get; init; }
|
||||
|
||||
/// <summary>Overall coverage percentage.</summary>
|
||||
public decimal OverallCoverage { get; init; }
|
||||
|
||||
/// <summary>Number of distinct symbols.</summary>
|
||||
public int SymbolCount { get; init; }
|
||||
|
||||
/// <summary>Number of symbols with delta pairs.</summary>
|
||||
public int DeltaPairCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of matching images.
|
||||
/// </summary>
|
||||
public sealed record PatchMatchPage
|
||||
{
|
||||
/// <summary>Matching image entries.</summary>
|
||||
public required IReadOnlyList<PatchMatchEntry> Matches { get; init; }
|
||||
|
||||
/// <summary>Total count matching filter.</summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Offset used.</summary>
|
||||
public int Offset { get; init; }
|
||||
|
||||
/// <summary>Limit used.</summary>
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single image match entry.
|
||||
/// </summary>
|
||||
public sealed record PatchMatchEntry
|
||||
{
|
||||
/// <summary>Match ID.</summary>
|
||||
public Guid MatchId { get; init; }
|
||||
|
||||
/// <summary>Binary key (image digest or path).</summary>
|
||||
public required string BinaryKey { get; init; }
|
||||
|
||||
/// <summary>Binary SHA-256 hash.</summary>
|
||||
public string? BinarySha256 { get; init; }
|
||||
|
||||
/// <summary>Matched symbol name.</summary>
|
||||
public required string SymbolName { get; init; }
|
||||
|
||||
/// <summary>Match state (vulnerable/patched/unknown).</summary>
|
||||
public required string MatchState { get; init; }
|
||||
|
||||
/// <summary>Match confidence (0-1).</summary>
|
||||
public decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>Scan ID that produced this match.</summary>
|
||||
public Guid? ScanId { get; init; }
|
||||
|
||||
/// <summary>When the match was recorded.</summary>
|
||||
public DateTimeOffset ScannedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user