release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,96 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.PatchVerification.Services;
namespace StellaOps.Scanner.PatchVerification.DependencyInjection;
/// <summary>
/// Extension methods for registering patch verification services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds patch verification services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPatchVerification(this IServiceCollection services)
{
// Core orchestrator
services.TryAddScoped<IPatchVerificationOrchestrator, PatchVerificationOrchestrator>();
// Default to in-memory store if no other implementation registered
services.TryAddSingleton<IPatchSignatureStore, InMemoryPatchSignatureStore>();
// Ensure TimeProvider is registered
services.TryAddSingleton(TimeProvider.System);
return services;
}
/// <summary>
/// Adds patch verification with a custom signature store.
/// </summary>
/// <typeparam name="TStore">The signature store implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPatchVerification<TStore>(this IServiceCollection services)
where TStore : class, IPatchSignatureStore
{
// Register custom store
services.AddSingleton<IPatchSignatureStore, TStore>();
// Core orchestrator
services.TryAddScoped<IPatchVerificationOrchestrator, PatchVerificationOrchestrator>();
// Ensure TimeProvider is registered
services.TryAddSingleton(TimeProvider.System);
return services;
}
/// <summary>
/// Adds patch verification with configuration options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPatchVerification(
this IServiceCollection services,
Action<PatchVerificationServiceOptions> configure)
{
var options = new PatchVerificationServiceOptions();
configure(options);
if (options.UseInMemoryStore)
{
services.AddSingleton<IPatchSignatureStore, InMemoryPatchSignatureStore>();
}
services.TryAddScoped<IPatchVerificationOrchestrator, PatchVerificationOrchestrator>();
services.TryAddSingleton(TimeProvider.System);
return services;
}
}
/// <summary>
/// Options for configuring patch verification services.
/// </summary>
public sealed class PatchVerificationServiceOptions
{
/// <summary>
/// Use in-memory signature store (default: true for development).
/// </summary>
public bool UseInMemoryStore { get; set; } = true;
/// <summary>
/// Connection string for PostgreSQL store (when not using in-memory).
/// </summary>
public string? PostgresConnectionString { get; set; }
/// <summary>
/// Valkey/Redis connection string for caching.
/// </summary>
public string? CacheConnectionString { get; set; }
}

View File

@@ -0,0 +1,57 @@
using StellaOps.Scanner.PatchVerification.Models;
namespace StellaOps.Scanner.PatchVerification;
/// <summary>
/// Orchestrates patch verification during container scans.
/// Verifies that backported security patches are actually present in binaries
/// by comparing fingerprints against known-good patch signatures.
/// </summary>
public interface IPatchVerificationOrchestrator
{
/// <summary>
/// Verifies patches for vulnerabilities detected in a scan.
/// </summary>
/// <param name="context">Verification context with scan results and binary paths.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification results for each CVE/binary pair.</returns>
Task<PatchVerificationResult> VerifyAsync(
PatchVerificationContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a single CVE patch in a specific binary.
/// </summary>
/// <param name="cveId">CVE identifier to verify.</param>
/// <param name="binaryPath">Path to the binary to verify.</param>
/// <param name="artifactPurl">PURL of the containing artifact.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification evidence for the CVE/binary pair.</returns>
Task<PatchVerificationEvidence> VerifySingleAsync(
string cveId,
string binaryPath,
string artifactPurl,
PatchVerificationOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if patch verification data is available for a CVE.
/// </summary>
/// <param name="cveId">CVE identifier to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if patch fingerprints exist for the CVE.</returns>
Task<bool> HasPatchDataAsync(
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the list of CVEs for which patch data is available.
/// </summary>
/// <param name="cveIds">CVE identifiers to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Subset of input CVEs that have patch data available.</returns>
Task<IReadOnlyList<string>> GetCvesWithPatchDataAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Scanner.PatchVerification.Models;
/// <summary>
/// Reference to a DSSE envelope stored in the evidence system.
/// Provides traceability without embedding full envelope data.
/// </summary>
/// <param name="EnvelopeId">Unique identifier of the DSSE envelope.</param>
/// <param name="KeyId">Key ID used to sign the envelope (RFC7638 JWK thumbprint).</param>
/// <param name="Issuer">Issuer identifier (vendor, distro, community).</param>
/// <param name="SignedAt">When the envelope was signed.</param>
public sealed record DsseEnvelopeRef(
string EnvelopeId,
string KeyId,
string Issuer,
DateTimeOffset SignedAt)
{
/// <summary>
/// URI for retrieving the full envelope from storage.
/// </summary>
public string? StorageUri { get; init; }
/// <summary>
/// Digest of the envelope content for integrity verification.
/// Format: "sha256:..."
/// </summary>
public string? ContentDigest { get; init; }
}

View File

@@ -0,0 +1,79 @@
namespace StellaOps.Scanner.PatchVerification.Models;
/// <summary>
/// Context for patch verification containing scan results and binary locations.
/// </summary>
public sealed record PatchVerificationContext
{
/// <summary>
/// Unique scan identifier for correlation.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Container image digest being scanned (sha256:...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// PURL of the scanned artifact.
/// </summary>
public required string ArtifactPurl { get; init; }
/// <summary>
/// CVE IDs detected in the scan that should be verified.
/// Only these CVEs will have patch verification attempted.
/// </summary>
public required IReadOnlyList<string> CveIds { get; init; }
/// <summary>
/// Mapping of binary paths to their extracted filesystem locations.
/// Key: original path in container (e.g., /usr/lib/libssl.so.1.1)
/// Value: extracted path on disk (e.g., /tmp/scan-123/usr/lib/libssl.so.1.1)
/// </summary>
public required IReadOnlyDictionary<string, string> BinaryPaths { get; init; }
/// <summary>
/// Verification options.
/// </summary>
public PatchVerificationOptions Options { get; init; } = new();
/// <summary>
/// Correlation ID for distributed tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Operating system/distro information for targeted verification.
/// </summary>
public string? OsRelease { get; init; }
/// <summary>
/// Creates a context for a single CVE verification.
/// </summary>
public static PatchVerificationContext ForSingleCve(
string scanId,
string tenantId,
string imageDigest,
string artifactPurl,
string cveId,
IReadOnlyDictionary<string, string> binaryPaths,
PatchVerificationOptions? options = null)
{
return new PatchVerificationContext
{
ScanId = scanId,
TenantId = tenantId,
ImageDigest = imageDigest,
ArtifactPurl = artifactPurl,
CveIds = [cveId],
BinaryPaths = binaryPaths,
Options = options ?? new PatchVerificationOptions()
};
}
}

View File

@@ -0,0 +1,148 @@
using StellaOps.Feedser.BinaryAnalysis.Models;
namespace StellaOps.Scanner.PatchVerification.Models;
/// <summary>
/// Evidence of patch verification for a single CVE/binary pair.
/// Designed to feed into VEX trust score computation.
/// </summary>
public sealed record PatchVerificationEvidence
{
/// <summary>
/// Deterministic evidence ID (UUID5 from CVE + binary digest + scan ID).
/// Ensures reproducibility across verification runs.
/// </summary>
public required string EvidenceId { get; init; }
/// <summary>
/// CVE identifier being verified.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// PURL of the affected artifact.
/// </summary>
public required string ArtifactPurl { get; init; }
/// <summary>
/// Path to the verified binary within the container.
/// </summary>
public required string BinaryPath { get; init; }
/// <summary>
/// Verification status.
/// </summary>
public required PatchVerificationStatus Status { get; init; }
/// <summary>
/// Similarity score from fingerprint matching (0.0-1.0).
/// Higher values indicate closer match to expected patched state.
/// </summary>
public required double Similarity { get; init; }
/// <summary>
/// Confidence in the verification result (0.0-1.0).
/// Lower for stripped binaries, compiler variations, or partial matches.
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Fingerprint method used for verification.
/// </summary>
public required FingerprintMethod Method { get; init; }
/// <summary>
/// Expected fingerprint from patch database (patched state).
/// </summary>
public BinaryFingerprint? ExpectedFingerprint { get; init; }
/// <summary>
/// Actual fingerprint computed from the scanned binary.
/// </summary>
public BinaryFingerprint? ActualFingerprint { get; init; }
/// <summary>
/// DSSE attestation if available.
/// Presence increases trust score.
/// </summary>
public DsseEnvelopeRef? Attestation { get; init; }
/// <summary>
/// Issuer of the patch signature (vendor, distro, community).
/// </summary>
public string? IssuerId { get; init; }
/// <summary>
/// Human-readable reason for the status.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Verification timestamp.
/// </summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>
/// Version of the verifier engine for reproducibility tracking.
/// </summary>
public string? VerifierVersion { get; init; }
/// <summary>
/// Computes trust score contribution for VEX consensus.
/// Follows the trust algebra defined in the architecture.
/// </summary>
/// <returns>Trust score between 0.0 and 1.0.</returns>
public double ComputeTrustScore()
{
// Base score from status
var baseScore = Status switch
{
PatchVerificationStatus.Verified => 0.50,
PatchVerificationStatus.PartialMatch => 0.25,
PatchVerificationStatus.Inconclusive => 0.10,
PatchVerificationStatus.NotPatched => 0.00,
PatchVerificationStatus.NoPatchData => 0.00,
_ => 0.00
};
// Adjust by confidence
var adjusted = baseScore * Confidence;
// Bonus for DSSE attestation (+15%)
if (Attestation is not null)
{
adjusted += 0.15;
}
// Bonus for function-level match (+10% scaled by similarity)
if (Method is FingerprintMethod.CFGHash or FingerprintMethod.InstructionHash)
{
adjusted += 0.10 * Similarity;
}
// Section-level match bonus (+5% scaled by similarity)
if (Method == FingerprintMethod.SectionHash)
{
adjusted += 0.05 * Similarity;
}
return Math.Clamp(adjusted, 0.0, 1.0);
}
/// <summary>
/// Determines if this evidence supports marking the CVE as fixed in VEX.
/// </summary>
/// <param name="minConfidence">Minimum confidence threshold.</param>
/// <returns>True if evidence supports FIXED status.</returns>
public bool SupportsFixedStatus(double minConfidence = 0.70)
{
return Status == PatchVerificationStatus.Verified && Confidence >= minConfidence;
}
/// <summary>
/// Determines if this evidence is inconclusive and requires manual review.
/// </summary>
public bool RequiresManualReview => Status is
PatchVerificationStatus.Inconclusive or
PatchVerificationStatus.PartialMatch;
}

View File

@@ -0,0 +1,62 @@
using StellaOps.Feedser.BinaryAnalysis.Models;
namespace StellaOps.Scanner.PatchVerification.Models;
/// <summary>
/// Options for controlling patch verification behavior.
/// </summary>
public sealed record PatchVerificationOptions
{
/// <summary>
/// Minimum confidence threshold to report as Verified (default: 0.7).
/// Results below this threshold are marked as PartialMatch or Inconclusive.
/// </summary>
public double MinConfidenceThreshold { get; init; } = 0.70;
/// <summary>
/// Minimum similarity threshold for fingerprint match (default: 0.85).
/// Lower values allow more fuzzy matching but increase false positive risk.
/// </summary>
public double MinSimilarityThreshold { get; init; } = 0.85;
/// <summary>
/// Preferred fingerprint methods in order of preference.
/// More precise methods (CFGHash, InstructionHash) are preferred over fuzzy (TLSH).
/// </summary>
public IReadOnlyList<FingerprintMethod> PreferredMethods { get; init; } =
[
FingerprintMethod.CFGHash,
FingerprintMethod.InstructionHash,
FingerprintMethod.SectionHash,
FingerprintMethod.TLSH
];
/// <summary>
/// Whether to require DSSE attestation for high-confidence results.
/// When true, unattested matches receive lower trust scores.
/// </summary>
public bool RequireAttestation { get; init; } = false;
/// <summary>
/// Maximum age of patch signature data to consider valid (hours).
/// Signatures older than this are treated as stale and may trigger warnings.
/// </summary>
public int MaxPatchDataAgeHours { get; init; } = 168; // 7 days
/// <summary>
/// Whether to emit evidence for CVEs with no patch data available.
/// When true, NoPatchData evidence is included in results for completeness.
/// </summary>
public bool EmitNoPatchDataEvidence { get; init; } = true;
/// <summary>
/// Whether to continue verification on errors for individual binaries.
/// When true, failures are logged but don't stop the overall verification.
/// </summary>
public bool ContinueOnError { get; init; } = true;
/// <summary>
/// Maximum concurrent binary verifications for performance tuning.
/// </summary>
public int MaxConcurrency { get; init; } = 4;
}

View File

@@ -0,0 +1,110 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.PatchVerification.Models;
/// <summary>
/// Aggregated result of patch verification for a scan.
/// </summary>
public sealed record PatchVerificationResult
{
/// <summary>
/// Scan identifier this result belongs to.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// Individual verification evidence for each CVE/binary pair.
/// </summary>
public required IReadOnlyList<PatchVerificationEvidence> Evidence { get; init; }
/// <summary>
/// CVEs confirmed as patched (verification succeeded with high confidence).
/// </summary>
public required IReadOnlySet<string> PatchedCves { get; init; }
/// <summary>
/// CVEs confirmed as unpatched (verification failed - vulnerable code present).
/// </summary>
public required IReadOnlySet<string> UnpatchedCves { get; init; }
/// <summary>
/// CVEs with inconclusive verification (e.g., stripped binaries).
/// </summary>
public required IReadOnlySet<string> InconclusiveCves { get; init; }
/// <summary>
/// CVEs with no patch data available in the signature store.
/// </summary>
public required IReadOnlySet<string> NoPatchDataCves { get; init; }
/// <summary>
/// Overall verification timestamp.
/// </summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>
/// Verifier engine version for reproducibility.
/// </summary>
public required string VerifierVersion { get; init; }
/// <summary>
/// Total number of CVEs processed.
/// </summary>
public int TotalCvesProcessed =>
PatchedCves.Count + UnpatchedCves.Count + InconclusiveCves.Count + NoPatchDataCves.Count;
/// <summary>
/// Percentage of CVEs that were verified as patched.
/// </summary>
public double PatchedPercentage =>
TotalCvesProcessed > 0 ? (double)PatchedCves.Count / TotalCvesProcessed : 0.0;
/// <summary>
/// Gets evidence for a specific CVE.
/// </summary>
public IEnumerable<PatchVerificationEvidence> GetEvidenceForCve(string cveId) =>
Evidence.Where(e => e.CveId == cveId);
/// <summary>
/// Gets evidence for a specific binary path.
/// </summary>
public IEnumerable<PatchVerificationEvidence> GetEvidenceForBinary(string binaryPath) =>
Evidence.Where(e => e.BinaryPath == binaryPath);
/// <summary>
/// Gets the highest-confidence evidence for each CVE.
/// </summary>
public IReadOnlyDictionary<string, PatchVerificationEvidence> GetBestEvidencePerCve()
{
return Evidence
.GroupBy(e => e.CveId)
.Select(g => g.OrderByDescending(e => e.Confidence).First())
.ToImmutableDictionary(e => e.CveId);
}
/// <summary>
/// Creates an empty result for when no verification was performed.
/// </summary>
public static PatchVerificationResult Empty(string scanId, string verifierVersion) => new()
{
ScanId = scanId,
Evidence = [],
PatchedCves = ImmutableHashSet<string>.Empty,
UnpatchedCves = ImmutableHashSet<string>.Empty,
InconclusiveCves = ImmutableHashSet<string>.Empty,
NoPatchDataCves = ImmutableHashSet<string>.Empty,
VerifiedAt = DateTimeOffset.UtcNow,
VerifierVersion = verifierVersion
};
/// <summary>
/// Computes aggregate trust score across all evidence.
/// </summary>
public double ComputeAggregateTrustScore()
{
if (Evidence.Count == 0)
return 0.0;
return Evidence.Average(e => e.ComputeTrustScore());
}
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Scanner.PatchVerification.Models;
/// <summary>
/// Status of patch verification for a CVE/binary pair.
/// </summary>
public enum PatchVerificationStatus
{
/// <summary>
/// Patch verified - fingerprint matches expected patched state.
/// Binary-level evidence confirms the security fix is present.
/// </summary>
Verified,
/// <summary>
/// Partial match - some but not all expected changes detected.
/// May indicate incomplete patching or partial backport.
/// </summary>
PartialMatch,
/// <summary>
/// Inconclusive - unable to verify definitively.
/// Common causes: stripped binary, missing debug symbols, compiler variations.
/// </summary>
Inconclusive,
/// <summary>
/// Not patched - binary matches vulnerable state or lacks expected patch changes.
/// </summary>
NotPatched,
/// <summary>
/// No patch data available for this CVE in the patch signature store.
/// Verification cannot be performed without reference fingerprints.
/// </summary>
NoPatchData
}

View File

@@ -0,0 +1,441 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.Feedser.BinaryAnalysis;
using StellaOps.Feedser.BinaryAnalysis.Models;
using StellaOps.Scanner.PatchVerification.Models;
using StellaOps.Scanner.PatchVerification.Services;
namespace StellaOps.Scanner.PatchVerification;
/// <summary>
/// Orchestrates patch verification by coordinating fingerprint extraction and matching.
/// </summary>
public sealed class PatchVerificationOrchestrator : IPatchVerificationOrchestrator
{
private readonly IEnumerable<IBinaryFingerprinter> _fingerprinters;
private readonly IPatchSignatureStore _signatureStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PatchVerificationOrchestrator> _logger;
/// <summary>
/// Current verifier version for reproducibility tracking.
/// </summary>
public const string VerifierVersion = "1.0.0";
/// <summary>
/// Initializes a new instance of the orchestrator.
/// </summary>
public PatchVerificationOrchestrator(
IEnumerable<IBinaryFingerprinter> fingerprinters,
IPatchSignatureStore signatureStore,
TimeProvider timeProvider,
ILogger<PatchVerificationOrchestrator> logger)
{
_fingerprinters = fingerprinters ?? throw new ArgumentNullException(nameof(fingerprinters));
_signatureStore = signatureStore ?? throw new ArgumentNullException(nameof(signatureStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<PatchVerificationResult> VerifyAsync(
PatchVerificationContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
_logger.LogInformation(
"Starting patch verification for scan {ScanId} with {CveCount} CVEs and {BinaryCount} binaries",
context.ScanId,
context.CveIds.Count,
context.BinaryPaths.Count);
var evidenceList = new List<PatchVerificationEvidence>();
var patchedCves = new HashSet<string>();
var unpatchedCves = new HashSet<string>();
var inconclusiveCves = new HashSet<string>();
var noPatchDataCves = new HashSet<string>();
// Filter CVEs to those with patch data
var cvesWithData = await _signatureStore.FilterWithPatchDataAsync(context.CveIds, cancellationToken);
var cvesWithDataSet = cvesWithData.ToHashSet();
foreach (var cveId in context.CveIds)
{
cancellationToken.ThrowIfCancellationRequested();
if (!cvesWithDataSet.Contains(cveId))
{
noPatchDataCves.Add(cveId);
if (context.Options.EmitNoPatchDataEvidence)
{
evidenceList.Add(CreateNoPatchDataEvidence(cveId, context));
}
continue;
}
try
{
var cveEvidence = await VerifyCveAsync(cveId, context, cancellationToken);
evidenceList.AddRange(cveEvidence);
// Categorize CVE based on best evidence
var bestEvidence = cveEvidence
.OrderByDescending(e => e.Confidence)
.FirstOrDefault();
if (bestEvidence is not null)
{
switch (bestEvidence.Status)
{
case PatchVerificationStatus.Verified:
patchedCves.Add(cveId);
break;
case PatchVerificationStatus.NotPatched:
unpatchedCves.Add(cveId);
break;
case PatchVerificationStatus.Inconclusive:
case PatchVerificationStatus.PartialMatch:
inconclusiveCves.Add(cveId);
break;
}
}
}
catch (Exception ex) when (context.Options.ContinueOnError)
{
_logger.LogWarning(ex, "Failed to verify CVE {CveId}, continuing with other CVEs", cveId);
inconclusiveCves.Add(cveId);
}
}
var result = new PatchVerificationResult
{
ScanId = context.ScanId,
Evidence = evidenceList.ToImmutableArray(),
PatchedCves = patchedCves.ToImmutableHashSet(),
UnpatchedCves = unpatchedCves.ToImmutableHashSet(),
InconclusiveCves = inconclusiveCves.ToImmutableHashSet(),
NoPatchDataCves = noPatchDataCves.ToImmutableHashSet(),
VerifiedAt = _timeProvider.GetUtcNow(),
VerifierVersion = VerifierVersion
};
_logger.LogInformation(
"Patch verification complete for scan {ScanId}: {Patched} patched, {Unpatched} unpatched, {Inconclusive} inconclusive, {NoData} no data",
context.ScanId,
patchedCves.Count,
unpatchedCves.Count,
inconclusiveCves.Count,
noPatchDataCves.Count);
return result;
}
/// <inheritdoc />
public async Task<PatchVerificationEvidence> VerifySingleAsync(
string cveId,
string binaryPath,
string artifactPurl,
PatchVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(binaryPath);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactPurl);
options ??= new PatchVerificationOptions();
// Check for patch data
var signatures = await _signatureStore.GetByCveAsync(cveId, cancellationToken);
if (signatures.Count == 0)
{
return CreateNoPatchDataEvidenceSingle(cveId, binaryPath, artifactPurl);
}
// Find matching signature for binary
var matchingSignature = signatures.FirstOrDefault(s =>
binaryPath.EndsWith(s.BinaryPath, StringComparison.OrdinalIgnoreCase) ||
s.BinaryPath == "*");
if (matchingSignature is null)
{
return CreateNoPatchDataEvidenceSingle(cveId, binaryPath, artifactPurl);
}
// Get appropriate fingerprinter
var fingerprinter = GetFingerprinter(matchingSignature.PatchedFingerprint.Method, options);
if (fingerprinter is null)
{
_logger.LogWarning(
"No fingerprinter available for method {Method}",
matchingSignature.PatchedFingerprint.Method);
return CreateInconclusiveEvidence(
cveId, binaryPath, artifactPurl,
"No fingerprinter available for required method",
matchingSignature.PatchedFingerprint.Method);
}
try
{
// Match binary against patched fingerprint
var matchResult = await fingerprinter.MatchAsync(
binaryPath,
matchingSignature.PatchedFingerprint,
cancellationToken);
return CreateEvidenceFromMatch(
cveId, binaryPath, artifactPurl,
matchResult, matchingSignature, options);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to verify {CveId} in {Binary}", cveId, binaryPath);
return CreateInconclusiveEvidence(
cveId, binaryPath, artifactPurl,
$"Verification failed: {ex.Message}",
fingerprinter.Method);
}
}
/// <inheritdoc />
public async Task<bool> HasPatchDataAsync(
string cveId,
CancellationToken cancellationToken = default)
{
return await _signatureStore.ExistsAsync(cveId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetCvesWithPatchDataAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
return await _signatureStore.FilterWithPatchDataAsync(cveIds, cancellationToken);
}
private async Task<IReadOnlyList<PatchVerificationEvidence>> VerifyCveAsync(
string cveId,
PatchVerificationContext context,
CancellationToken cancellationToken)
{
var signatures = await _signatureStore.GetByCveAsync(cveId, cancellationToken);
var evidenceList = new List<PatchVerificationEvidence>();
foreach (var (containerPath, extractedPath) in context.BinaryPaths)
{
// Find signature matching this binary
var matchingSignature = signatures.FirstOrDefault(s =>
containerPath.EndsWith(s.BinaryPath, StringComparison.OrdinalIgnoreCase) ||
s.BinaryPath == "*");
if (matchingSignature is null)
{
continue; // No signature for this binary
}
var fingerprinter = GetFingerprinter(matchingSignature.PatchedFingerprint.Method, context.Options);
if (fingerprinter is null)
{
evidenceList.Add(CreateInconclusiveEvidence(
cveId, containerPath, context.ArtifactPurl,
"No fingerprinter available",
matchingSignature.PatchedFingerprint.Method));
continue;
}
try
{
var matchResult = await fingerprinter.MatchAsync(
extractedPath,
matchingSignature.PatchedFingerprint,
cancellationToken);
evidenceList.Add(CreateEvidenceFromMatch(
cveId, containerPath, context.ArtifactPurl,
matchResult, matchingSignature, context.Options,
context.ScanId));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to verify {CveId} in {Binary}", cveId, containerPath);
evidenceList.Add(CreateInconclusiveEvidence(
cveId, containerPath, context.ArtifactPurl,
$"Verification error: {ex.Message}",
fingerprinter.Method));
}
}
return evidenceList;
}
private IBinaryFingerprinter? GetFingerprinter(
FingerprintMethod method,
PatchVerificationOptions options)
{
// Try exact method match first
var fingerprinter = _fingerprinters.FirstOrDefault(f => f.Method == method);
if (fingerprinter is not null)
{
return fingerprinter;
}
// Fall back to preferred methods
foreach (var preferredMethod in options.PreferredMethods)
{
fingerprinter = _fingerprinters.FirstOrDefault(f => f.Method == preferredMethod);
if (fingerprinter is not null)
{
return fingerprinter;
}
}
return _fingerprinters.FirstOrDefault();
}
private PatchVerificationEvidence CreateEvidenceFromMatch(
string cveId,
string binaryPath,
string artifactPurl,
FingerprintMatchResult matchResult,
PatchSignatureEntry signature,
PatchVerificationOptions options,
string? scanId = null)
{
var status = DetermineStatus(matchResult, options);
var evidenceId = scanId is not null
? EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, scanId)
: EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, Guid.NewGuid().ToString("N"));
return new PatchVerificationEvidence
{
EvidenceId = evidenceId,
CveId = cveId,
ArtifactPurl = artifactPurl,
BinaryPath = binaryPath,
Status = status,
Similarity = matchResult.Similarity,
Confidence = matchResult.Confidence,
Method = matchResult.Method,
ExpectedFingerprint = signature.PatchedFingerprint,
ActualFingerprint = null, // Could be populated if needed
Attestation = signature.Attestation,
IssuerId = signature.IssuerId,
Reason = GetReasonForStatus(status, matchResult),
VerifiedAt = _timeProvider.GetUtcNow(),
VerifierVersion = VerifierVersion
};
}
private static PatchVerificationStatus DetermineStatus(
FingerprintMatchResult matchResult,
PatchVerificationOptions options)
{
if (!matchResult.IsMatch)
{
return PatchVerificationStatus.NotPatched;
}
if (matchResult.Confidence >= options.MinConfidenceThreshold &&
matchResult.Similarity >= options.MinSimilarityThreshold)
{
return PatchVerificationStatus.Verified;
}
if (matchResult.Similarity >= options.MinSimilarityThreshold * 0.7)
{
return PatchVerificationStatus.PartialMatch;
}
return PatchVerificationStatus.Inconclusive;
}
private static string GetReasonForStatus(
PatchVerificationStatus status,
FingerprintMatchResult matchResult)
{
return status switch
{
PatchVerificationStatus.Verified =>
$"Binary matches patched fingerprint (similarity: {matchResult.Similarity:P0}, confidence: {matchResult.Confidence:P0})",
PatchVerificationStatus.PartialMatch =>
$"Partial match detected (similarity: {matchResult.Similarity:P0}, confidence: {matchResult.Confidence:P0})",
PatchVerificationStatus.NotPatched =>
"Binary does not match patched fingerprint",
PatchVerificationStatus.Inconclusive =>
$"Match inconclusive (similarity: {matchResult.Similarity:P0}, confidence: {matchResult.Confidence:P0})",
_ => "Unknown status"
};
}
private PatchVerificationEvidence CreateNoPatchDataEvidence(
string cveId,
PatchVerificationContext context)
{
var binaryPath = context.BinaryPaths.Keys.FirstOrDefault() ?? "unknown";
return new PatchVerificationEvidence
{
EvidenceId = EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, context.ScanId),
CveId = cveId,
ArtifactPurl = context.ArtifactPurl,
BinaryPath = binaryPath,
Status = PatchVerificationStatus.NoPatchData,
Similarity = 0.0,
Confidence = 0.0,
Method = FingerprintMethod.TLSH, // Default
Reason = "No patch signature data available for this CVE",
VerifiedAt = _timeProvider.GetUtcNow(),
VerifierVersion = VerifierVersion
};
}
private PatchVerificationEvidence CreateNoPatchDataEvidenceSingle(
string cveId,
string binaryPath,
string artifactPurl)
{
return new PatchVerificationEvidence
{
EvidenceId = EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, Guid.NewGuid().ToString("N")),
CveId = cveId,
ArtifactPurl = artifactPurl,
BinaryPath = binaryPath,
Status = PatchVerificationStatus.NoPatchData,
Similarity = 0.0,
Confidence = 0.0,
Method = FingerprintMethod.TLSH,
Reason = "No patch signature data available for this CVE",
VerifiedAt = _timeProvider.GetUtcNow(),
VerifierVersion = VerifierVersion
};
}
private PatchVerificationEvidence CreateInconclusiveEvidence(
string cveId,
string binaryPath,
string artifactPurl,
string reason,
FingerprintMethod method)
{
return new PatchVerificationEvidence
{
EvidenceId = EvidenceIdGenerator.GenerateFromPath(cveId, binaryPath, Guid.NewGuid().ToString("N")),
CveId = cveId,
ArtifactPurl = artifactPurl,
BinaryPath = binaryPath,
Status = PatchVerificationStatus.Inconclusive,
Similarity = 0.0,
Confidence = 0.0,
Method = method,
Reason = reason,
VerifiedAt = _timeProvider.GetUtcNow(),
VerifierVersion = VerifierVersion
};
}
}

View File

@@ -0,0 +1,92 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.PatchVerification.Services;
/// <summary>
/// Generates deterministic evidence IDs for patch verification results.
/// Uses UUID5 (SHA-1 based) for reproducibility across verification runs.
/// </summary>
public static class EvidenceIdGenerator
{
/// <summary>
/// UUID5 namespace for patch verification evidence.
/// </summary>
private static readonly Guid PatchVerificationNamespace =
new("7d8f4a3c-2e1b-5c9d-8f6e-4a3b2c1d0e9f");
/// <summary>
/// Generates a deterministic evidence ID from verification inputs.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="binaryDigest">SHA256 digest of the binary.</param>
/// <param name="scanId">Scan identifier.</param>
/// <returns>Deterministic UUID5-based evidence ID.</returns>
public static string Generate(string cveId, string binaryDigest, string scanId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(binaryDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var input = $"{cveId}|{binaryDigest}|{scanId}";
var uuid = CreateUuid5(PatchVerificationNamespace, input);
return $"pv:{uuid:N}";
}
/// <summary>
/// Generates a deterministic evidence ID from binary path when digest unavailable.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="binaryPath">Path to the binary.</param>
/// <param name="scanId">Scan identifier.</param>
/// <returns>Deterministic UUID5-based evidence ID.</returns>
public static string GenerateFromPath(string cveId, string binaryPath, string scanId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(binaryPath);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
// Normalize path for cross-platform consistency
var normalizedPath = binaryPath.Replace('\\', '/');
var input = $"{cveId}|path:{normalizedPath}|{scanId}";
var uuid = CreateUuid5(PatchVerificationNamespace, input);
return $"pv:{uuid:N}";
}
/// <summary>
/// Creates a UUID5 (name-based, SHA-1) from namespace and name.
/// </summary>
private static Guid CreateUuid5(Guid namespaceId, string name)
{
var namespaceBytes = namespaceId.ToByteArray();
SwapByteOrder(namespaceBytes);
var nameBytes = Encoding.UTF8.GetBytes(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);
var hash = SHA1.HashData(combined);
// Set version (5) and variant bits
hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5
hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant RFC4122
var result = new byte[16];
Array.Copy(hash, 0, result, 0, 16);
SwapByteOrder(result);
return new Guid(result);
}
/// <summary>
/// Swaps byte order for UUID encoding (big-endian to little-endian).
/// </summary>
private static void SwapByteOrder(byte[] guid)
{
(guid[0], guid[3]) = (guid[3], guid[0]);
(guid[1], guid[2]) = (guid[2], guid[1]);
(guid[4], guid[5]) = (guid[5], guid[4]);
(guid[6], guid[7]) = (guid[7], guid[6]);
}
}

View File

@@ -0,0 +1,122 @@
using StellaOps.Feedser.BinaryAnalysis.Models;
using StellaOps.Scanner.PatchVerification.Models;
namespace StellaOps.Scanner.PatchVerification.Services;
/// <summary>
/// Store for known-good patch signatures used to verify backported patches.
/// </summary>
public interface IPatchSignatureStore
{
/// <summary>
/// Gets patch signatures for a specific CVE.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Patch signatures for the CVE, or empty if none available.</returns>
Task<IReadOnlyList<PatchSignatureEntry>> GetByCveAsync(
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets patch signatures for a CVE filtered by PURL pattern.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="purlPattern">PURL pattern to filter by (e.g., "pkg:rpm/openssl*").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Matching patch signatures.</returns>
Task<IReadOnlyList<PatchSignatureEntry>> GetByCveAndPurlAsync(
string cveId,
string purlPattern,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if patch data exists for a CVE.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if patch signatures exist.</returns>
Task<bool> ExistsAsync(
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Filters a list of CVEs to those with available patch data.
/// </summary>
/// <param name="cveIds">CVE identifiers to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>CVEs with patch data available.</returns>
Task<IReadOnlyList<string>> FilterWithPatchDataAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores a patch signature entry.
/// </summary>
/// <param name="entry">Patch signature entry to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task StoreAsync(
PatchSignatureEntry entry,
CancellationToken cancellationToken = default);
}
/// <summary>
/// A patch signature entry containing fingerprints for a patched binary.
/// </summary>
public sealed record PatchSignatureEntry
{
/// <summary>
/// Unique entry identifier.
/// </summary>
public required string EntryId { get; init; }
/// <summary>
/// CVE this patch fixes.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// PURL of the patched package.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Binary path within the package this signature applies to.
/// </summary>
public required string BinaryPath { get; init; }
/// <summary>
/// Fingerprint of the patched (fixed) binary.
/// </summary>
public required BinaryFingerprint PatchedFingerprint { get; init; }
/// <summary>
/// Optional fingerprint of the vulnerable binary (for comparison).
/// </summary>
public BinaryFingerprint? VulnerableFingerprint { get; init; }
/// <summary>
/// DSSE attestation for this signature.
/// </summary>
public DsseEnvelopeRef? Attestation { get; init; }
/// <summary>
/// Issuer of this signature (vendor, distro, community).
/// </summary>
public required string IssuerId { get; init; }
/// <summary>
/// When this signature was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When this signature expires (null = never).
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,119 @@
using System.Collections.Concurrent;
namespace StellaOps.Scanner.PatchVerification.Services;
/// <summary>
/// In-memory implementation of patch signature store for development and testing.
/// For production, use a persistent store backed by PostgreSQL or distributed cache.
/// </summary>
public sealed class InMemoryPatchSignatureStore : IPatchSignatureStore
{
private readonly ConcurrentDictionary<string, List<PatchSignatureEntry>> _entriesByCve = new();
/// <inheritdoc />
public Task<IReadOnlyList<PatchSignatureEntry>> GetByCveAsync(
string cveId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_entriesByCve.TryGetValue(cveId, out var entries))
{
return Task.FromResult<IReadOnlyList<PatchSignatureEntry>>(entries.ToList());
}
return Task.FromResult<IReadOnlyList<PatchSignatureEntry>>([]);
}
/// <inheritdoc />
public Task<IReadOnlyList<PatchSignatureEntry>> GetByCveAndPurlAsync(
string cveId,
string purlPattern,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_entriesByCve.TryGetValue(cveId, out var entries))
{
return Task.FromResult<IReadOnlyList<PatchSignatureEntry>>([]);
}
// Simple pattern matching (supports * wildcard at end)
var filtered = entries
.Where(e => MatchesPurlPattern(e.Purl, purlPattern))
.ToList();
return Task.FromResult<IReadOnlyList<PatchSignatureEntry>>(filtered);
}
/// <inheritdoc />
public Task<bool> ExistsAsync(
string cveId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_entriesByCve.ContainsKey(cveId));
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> FilterWithPatchDataAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var result = cveIds
.Where(cveId => _entriesByCve.ContainsKey(cveId))
.ToList();
return Task.FromResult<IReadOnlyList<string>>(result);
}
/// <inheritdoc />
public Task StoreAsync(
PatchSignatureEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
cancellationToken.ThrowIfCancellationRequested();
_entriesByCve.AddOrUpdate(
entry.CveId,
_ => [entry],
(_, existingList) =>
{
// Remove existing entry with same ID and add new one
var updated = existingList
.Where(e => e.EntryId != entry.EntryId)
.Append(entry)
.ToList();
return updated;
});
return Task.CompletedTask;
}
/// <summary>
/// Clears all stored entries. Useful for testing.
/// </summary>
public void Clear()
{
_entriesByCve.Clear();
}
/// <summary>
/// Gets total count of stored entries.
/// </summary>
public int Count => _entriesByCve.Values.Sum(v => v.Count);
private static bool MatchesPurlPattern(string purl, string pattern)
{
if (pattern.EndsWith('*'))
{
var prefix = pattern[..^1];
return purl.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
return purl.Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.PatchVerification</RootNamespace>
<AssemblyName>StellaOps.Scanner.PatchVerification</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Feedser/StellaOps.Feedser.BinaryAnalysis/StellaOps.Feedser.BinaryAnalysis.csproj" />
<ProjectReference Include="../../../Feedser/StellaOps.Feedser.Core/StellaOps.Feedser.Core.csproj" />
<ProjectReference Include="../../../VexLens/StellaOps.VexLens/StellaOps.VexLens.Core/StellaOps.VexLens.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>