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

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

View File

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