Files
git.stella-ops.org/src/VexLens/StellaOps.VexLens/Mapping/ProductIdentityMatcher.cs
StellaOps Bot 5e514532df Implement VEX document verification system with issuer management and signature verification
- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation.
- Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments.
- Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats.
- Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats.
- Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction.
- Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
2025-12-06 13:41:22 +02:00

260 lines
8.5 KiB
C#

using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Mapping;
/// <summary>
/// Utility for matching and comparing product identities across different identifier types.
/// </summary>
public static class ProductIdentityMatcher
{
/// <summary>
/// Checks if two products are equivalent based on their identifiers.
/// </summary>
public static ProductMatchResult Match(NormalizedProduct product1, NormalizedProduct product2)
{
var matches = new List<ProductMatchEvidence>();
// Check PURL match
if (!string.IsNullOrEmpty(product1.Purl) && !string.IsNullOrEmpty(product2.Purl))
{
if (PurlParser.IsSamePackage(product1.Purl, product2.Purl))
{
var versionMatch = CheckVersionMatch(
PurlParser.Parse(product1.Purl).PackageUrl?.Version,
PurlParser.Parse(product2.Purl).PackageUrl?.Version);
matches.Add(new ProductMatchEvidence(
MatchType: ProductMatchType.Purl,
Confidence: versionMatch ? MatchConfidence.Exact : MatchConfidence.PackageOnly,
Evidence: $"PURL match: {product1.Purl} ≈ {product2.Purl}"));
}
}
// Check CPE match
if (!string.IsNullOrEmpty(product1.Cpe) && !string.IsNullOrEmpty(product2.Cpe))
{
if (CpeParser.IsSameProduct(product1.Cpe, product2.Cpe))
{
var cpe1 = CpeParser.Parse(product1.Cpe).Cpe;
var cpe2 = CpeParser.Parse(product2.Cpe).Cpe;
var versionMatch = cpe1?.Version == cpe2?.Version && cpe1?.Version != "*";
matches.Add(new ProductMatchEvidence(
MatchType: ProductMatchType.Cpe,
Confidence: versionMatch ? MatchConfidence.Exact : MatchConfidence.PackageOnly,
Evidence: $"CPE match: {product1.Cpe} ≈ {product2.Cpe}"));
}
}
// Check key match
if (!string.IsNullOrEmpty(product1.Key) && !string.IsNullOrEmpty(product2.Key))
{
if (string.Equals(product1.Key, product2.Key, StringComparison.OrdinalIgnoreCase))
{
matches.Add(new ProductMatchEvidence(
MatchType: ProductMatchType.Key,
Confidence: MatchConfidence.Exact,
Evidence: $"Key match: {product1.Key}"));
}
}
// Check name + version match
if (!string.IsNullOrEmpty(product1.Name) && !string.IsNullOrEmpty(product2.Name))
{
if (string.Equals(product1.Name, product2.Name, StringComparison.OrdinalIgnoreCase))
{
var versionMatch = CheckVersionMatch(product1.Version, product2.Version);
matches.Add(new ProductMatchEvidence(
MatchType: ProductMatchType.NameVersion,
Confidence: versionMatch ? MatchConfidence.Exact : MatchConfidence.PackageOnly,
Evidence: $"Name match: {product1.Name}" + (versionMatch ? $" @ {product1.Version}" : "")));
}
}
// Check hash match
if (product1.Hashes != null && product2.Hashes != null)
{
foreach (var (alg, hash1) in product1.Hashes)
{
if (product2.Hashes.TryGetValue(alg, out var hash2))
{
if (string.Equals(hash1, hash2, StringComparison.OrdinalIgnoreCase))
{
matches.Add(new ProductMatchEvidence(
MatchType: ProductMatchType.Hash,
Confidence: MatchConfidence.Exact,
Evidence: $"Hash match ({alg}): {hash1}"));
}
}
}
}
// Determine overall match result
var overallConfidence = matches.Count > 0
? matches.Max(m => m.Confidence)
: MatchConfidence.None;
return new ProductMatchResult(
IsMatch: matches.Count > 0,
OverallConfidence: overallConfidence,
Evidence: matches);
}
/// <summary>
/// Finds matching products in a collection.
/// </summary>
public static IReadOnlyList<ProductMatchResult> FindMatches(
NormalizedProduct target,
IEnumerable<NormalizedProduct> candidates,
MatchConfidence minimumConfidence = MatchConfidence.PackageOnly)
{
var results = new List<ProductMatchResult>();
foreach (var candidate in candidates)
{
var matchResult = Match(target, candidate);
if (matchResult.IsMatch && matchResult.OverallConfidence >= minimumConfidence)
{
results.Add(matchResult with { MatchedProduct = candidate });
}
}
return results.OrderByDescending(r => r.OverallConfidence).ToList();
}
/// <summary>
/// Computes a similarity score between two products (0.0 to 1.0).
/// </summary>
public static double ComputeSimilarity(NormalizedProduct product1, NormalizedProduct product2)
{
var matchResult = Match(product1, product2);
if (!matchResult.IsMatch)
{
return 0.0;
}
return matchResult.OverallConfidence switch
{
MatchConfidence.Exact => 1.0,
MatchConfidence.PackageOnly => 0.8,
MatchConfidence.Fuzzy => 0.5,
MatchConfidence.Partial => 0.3,
_ => 0.0
};
}
/// <summary>
/// Detects the identifier type from a string.
/// </summary>
public static ProductIdentifierType? DetectIdentifierType(string? identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
{
return null;
}
if (identifier.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return PurlParser.IsValid(identifier) ? ProductIdentifierType.Purl : null;
}
if (identifier.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
{
return CpeParser.IsValid(identifier) ? ProductIdentifierType.Cpe : null;
}
if (identifier.StartsWith("swid:", StringComparison.OrdinalIgnoreCase))
{
return ProductIdentifierType.Swid;
}
// Could be a bom-ref or vendor product ID
return ProductIdentifierType.Custom;
}
/// <summary>
/// Extracts all identifiers from a product.
/// </summary>
public static IReadOnlyList<(ProductIdentifierType Type, string Value)> ExtractIdentifiers(NormalizedProduct product)
{
var identifiers = new List<(ProductIdentifierType, string)>();
if (!string.IsNullOrWhiteSpace(product.Purl))
{
identifiers.Add((ProductIdentifierType.Purl, product.Purl));
}
if (!string.IsNullOrWhiteSpace(product.Cpe))
{
identifiers.Add((ProductIdentifierType.Cpe, product.Cpe));
}
if (!string.IsNullOrWhiteSpace(product.Key))
{
var keyType = DetectIdentifierType(product.Key);
if (keyType.HasValue && keyType.Value != ProductIdentifierType.Purl && keyType.Value != ProductIdentifierType.Cpe)
{
identifiers.Add((keyType.Value, product.Key));
}
else if (keyType == null)
{
identifiers.Add((ProductIdentifierType.Custom, product.Key));
}
}
return identifiers;
}
private static bool CheckVersionMatch(string? version1, string? version2)
{
if (string.IsNullOrEmpty(version1) || string.IsNullOrEmpty(version2))
{
return false;
}
return string.Equals(version1, version2, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Result of a product match operation.
/// </summary>
public sealed record ProductMatchResult(
bool IsMatch,
MatchConfidence OverallConfidence,
IReadOnlyList<ProductMatchEvidence> Evidence,
NormalizedProduct? MatchedProduct = null);
/// <summary>
/// Evidence supporting a product match.
/// </summary>
public sealed record ProductMatchEvidence(
ProductMatchType MatchType,
MatchConfidence Confidence,
string Evidence);
/// <summary>
/// Type of product match.
/// </summary>
public enum ProductMatchType
{
Purl,
Cpe,
Key,
NameVersion,
Hash
}
/// <summary>
/// Confidence level of a match.
/// </summary>
public enum MatchConfidence
{
None = 0,
Partial = 1,
Fuzzy = 2,
PackageOnly = 3,
Exact = 4
}