release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user