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.
This commit is contained in:
StellaOps Bot
2025-12-06 13:41:22 +02:00
parent 2141196496
commit 5e514532df
112 changed files with 24861 additions and 211 deletions

View File

@@ -0,0 +1,331 @@
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.VexLens.Mapping;
/// <summary>
/// Parser for Common Platform Enumeration (CPE) identifiers.
/// Supports both CPE 2.2 (URI binding) and CPE 2.3 (formatted string binding).
/// </summary>
public static partial class CpeParser
{
// CPE 2.3 formatted string: cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other
[GeneratedRegex(
@"^cpe:2\.3:([aho\*\-]):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*)$",
RegexOptions.Compiled)]
private static partial Regex Cpe23Regex();
// CPE 2.2 URI: cpe:/part:vendor:product:version:update:edition:language
[GeneratedRegex(
@"^cpe:/([aho]):([^:]*):([^:]*):([^:]*)?:?([^:]*)?:?([^:]*)?:?([^:]*)?$",
RegexOptions.Compiled)]
private static partial Regex Cpe22Regex();
private const string Wildcard = "*";
private const string Na = "-";
/// <summary>
/// Parses a CPE string (2.2 or 2.3 format) into its components.
/// </summary>
public static CpeParseResult Parse(string? cpe)
{
if (string.IsNullOrWhiteSpace(cpe))
{
return CpeParseResult.Failed("CPE cannot be null or empty");
}
cpe = cpe.Trim();
// Try CPE 2.3 first
var match23 = Cpe23Regex().Match(cpe);
if (match23.Success)
{
return ParseCpe23(match23, cpe);
}
// Try CPE 2.2
var match22 = Cpe22Regex().Match(cpe);
if (match22.Success)
{
return ParseCpe22(match22, cpe);
}
return CpeParseResult.Failed("Invalid CPE format");
}
/// <summary>
/// Validates if a string is a valid CPE.
/// </summary>
public static bool IsValid(string? cpe)
{
if (string.IsNullOrWhiteSpace(cpe))
{
return false;
}
return Cpe23Regex().IsMatch(cpe) || Cpe22Regex().IsMatch(cpe);
}
/// <summary>
/// Converts a CPE to 2.3 formatted string format.
/// </summary>
public static string? ToCpe23(string? cpe)
{
var result = Parse(cpe);
if (!result.Success || result.Cpe == null)
{
return null;
}
return BuildCpe23(result.Cpe);
}
/// <summary>
/// Converts a CPE to 2.2 URI format.
/// </summary>
public static string? ToCpe22(string? cpe)
{
var result = Parse(cpe);
if (!result.Success || result.Cpe == null)
{
return null;
}
return BuildCpe22(result.Cpe);
}
/// <summary>
/// Checks if two CPEs match (with wildcard support).
/// </summary>
public static bool Matches(string? cpe1, string? cpe2)
{
var result1 = Parse(cpe1);
var result2 = Parse(cpe2);
if (!result1.Success || !result2.Success)
{
return false;
}
var c1 = result1.Cpe!;
var c2 = result2.Cpe!;
return MatchComponent(c1.Part, c2.Part) &&
MatchComponent(c1.Vendor, c2.Vendor) &&
MatchComponent(c1.Product, c2.Product) &&
MatchComponent(c1.Version, c2.Version) &&
MatchComponent(c1.Update, c2.Update) &&
MatchComponent(c1.Edition, c2.Edition) &&
MatchComponent(c1.Language, c2.Language) &&
MatchComponent(c1.SwEdition, c2.SwEdition) &&
MatchComponent(c1.TargetSw, c2.TargetSw) &&
MatchComponent(c1.TargetHw, c2.TargetHw) &&
MatchComponent(c1.Other, c2.Other);
}
/// <summary>
/// Checks if two CPEs refer to the same product (ignoring version).
/// </summary>
public static bool IsSameProduct(string? cpe1, string? cpe2)
{
var result1 = Parse(cpe1);
var result2 = Parse(cpe2);
if (!result1.Success || !result2.Success)
{
return false;
}
var c1 = result1.Cpe!;
var c2 = result2.Cpe!;
return string.Equals(c1.Part, c2.Part, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c1.Vendor, c2.Vendor, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c1.Product, c2.Product, StringComparison.OrdinalIgnoreCase);
}
private static CpeParseResult ParseCpe23(Match match, string raw)
{
var cpe = new CommonPlatformEnumeration(
CpeVersion: "2.3",
Part: NormalizeComponent(match.Groups[1].Value),
Vendor: NormalizeComponent(match.Groups[2].Value),
Product: NormalizeComponent(match.Groups[3].Value),
Version: NormalizeComponent(match.Groups[4].Value),
Update: NormalizeComponent(match.Groups[5].Value),
Edition: NormalizeComponent(match.Groups[6].Value),
Language: NormalizeComponent(match.Groups[7].Value),
SwEdition: NormalizeComponent(match.Groups[8].Value),
TargetSw: NormalizeComponent(match.Groups[9].Value),
TargetHw: NormalizeComponent(match.Groups[10].Value),
Other: NormalizeComponent(match.Groups[11].Value),
Raw: raw);
return CpeParseResult.Successful(cpe);
}
private static CpeParseResult ParseCpe22(Match match, string raw)
{
var cpe = new CommonPlatformEnumeration(
CpeVersion: "2.2",
Part: NormalizeComponent(match.Groups[1].Value),
Vendor: NormalizeComponent(match.Groups[2].Value),
Product: NormalizeComponent(match.Groups[3].Value),
Version: NormalizeComponent(match.Groups[4].Success ? match.Groups[4].Value : Wildcard),
Update: NormalizeComponent(match.Groups[5].Success ? match.Groups[5].Value : Wildcard),
Edition: NormalizeComponent(match.Groups[6].Success ? match.Groups[6].Value : Wildcard),
Language: NormalizeComponent(match.Groups[7].Success ? match.Groups[7].Value : Wildcard),
SwEdition: Wildcard,
TargetSw: Wildcard,
TargetHw: Wildcard,
Other: Wildcard,
Raw: raw);
return CpeParseResult.Successful(cpe);
}
private static string NormalizeComponent(string component)
{
if (string.IsNullOrEmpty(component))
{
return Wildcard;
}
// Decode percent-encoded characters
var decoded = Uri.UnescapeDataString(component);
// Replace escaped characters
decoded = decoded
.Replace("\\:", ":")
.Replace("\\;", ";")
.Replace("\\@", "@");
return decoded.ToLowerInvariant();
}
private static bool MatchComponent(string c1, string c2)
{
// Wildcard matches everything
if (c1 == Wildcard || c2 == Wildcard)
{
return true;
}
// NA only matches NA
if (c1 == Na || c2 == Na)
{
return c1 == Na && c2 == Na;
}
return string.Equals(c1, c2, StringComparison.OrdinalIgnoreCase);
}
private static string BuildCpe23(CommonPlatformEnumeration cpe)
{
var sb = new StringBuilder();
sb.Append("cpe:2.3:");
sb.Append(EscapeComponent(cpe.Part));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Vendor));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Product));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Version));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Update));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Edition));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Language));
sb.Append(':');
sb.Append(EscapeComponent(cpe.SwEdition));
sb.Append(':');
sb.Append(EscapeComponent(cpe.TargetSw));
sb.Append(':');
sb.Append(EscapeComponent(cpe.TargetHw));
sb.Append(':');
sb.Append(EscapeComponent(cpe.Other));
return sb.ToString();
}
private static string BuildCpe22(CommonPlatformEnumeration cpe)
{
var sb = new StringBuilder();
sb.Append("cpe:/");
sb.Append(cpe.Part);
sb.Append(':');
sb.Append(EscapeComponent22(cpe.Vendor));
sb.Append(':');
sb.Append(EscapeComponent22(cpe.Product));
if (cpe.Version != Wildcard)
{
sb.Append(':');
sb.Append(EscapeComponent22(cpe.Version));
}
if (cpe.Update != Wildcard)
{
sb.Append(':');
sb.Append(EscapeComponent22(cpe.Update));
}
if (cpe.Edition != Wildcard)
{
sb.Append(':');
sb.Append(EscapeComponent22(cpe.Edition));
}
if (cpe.Language != Wildcard)
{
sb.Append(':');
sb.Append(EscapeComponent22(cpe.Language));
}
return sb.ToString();
}
private static string EscapeComponent(string component)
{
if (component == Wildcard || component == Na)
{
return component;
}
return component
.Replace(":", "\\:")
.Replace(";", "\\;")
.Replace("@", "\\@");
}
private static string EscapeComponent22(string component)
{
if (component == Wildcard)
{
return "";
}
if (component == Na)
{
return "-";
}
return Uri.EscapeDataString(component);
}
}
/// <summary>
/// Result of CPE parsing.
/// </summary>
public sealed record CpeParseResult(
bool Success,
CommonPlatformEnumeration? Cpe,
string? ErrorMessage)
{
public static CpeParseResult Successful(CommonPlatformEnumeration cpe) =>
new(true, cpe, null);
public static CpeParseResult Failed(string error) =>
new(false, null, error);
}

View File

@@ -0,0 +1,169 @@
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Mapping;
/// <summary>
/// Interface for product identity mapping services.
/// Maps product references from various sources to canonical identifiers.
/// </summary>
public interface IProductMapper
{
/// <summary>
/// Maps a normalized product to a canonical identity.
/// </summary>
Task<ProductMappingResult> MapAsync(
NormalizedProduct product,
ProductMappingContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Batch maps multiple products to canonical identities.
/// </summary>
Task<IReadOnlyList<ProductMappingResult>> MapBatchAsync(
IEnumerable<NormalizedProduct> products,
ProductMappingContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves product aliases (e.g., maps one PURL to equivalent PURLs).
/// </summary>
Task<ProductAliasResult> ResolveAliasesAsync(
string identifier,
ProductIdentifierType identifierType,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Context for product mapping operations.
/// </summary>
public sealed record ProductMappingContext(
string? TenantId,
bool ResolveAliases,
bool ValidateIdentifiers,
IReadOnlyDictionary<string, object?>? Options);
/// <summary>
/// Result of a product mapping operation.
/// </summary>
public sealed record ProductMappingResult(
NormalizedProduct OriginalProduct,
CanonicalProduct? CanonicalProduct,
bool Success,
ProductMappingConfidence Confidence,
IReadOnlyList<string>? Warnings,
IReadOnlyList<ProductMappingError>? Errors);
/// <summary>
/// A canonicalized product identity with validated identifiers.
/// </summary>
public sealed record CanonicalProduct(
string CanonicalKey,
string? Name,
string? Version,
PackageUrl? Purl,
CommonPlatformEnumeration? Cpe,
IReadOnlyList<ProductAlias>? Aliases,
ProductVendorInfo? Vendor,
IReadOnlyDictionary<string, string>? Hashes);
/// <summary>
/// Parsed Package URL (PURL) components.
/// </summary>
public sealed record PackageUrl(
string Type,
string? Namespace,
string Name,
string? Version,
IReadOnlyDictionary<string, string>? Qualifiers,
string? Subpath,
string Raw);
/// <summary>
/// Parsed Common Platform Enumeration (CPE) components.
/// </summary>
public sealed record CommonPlatformEnumeration(
string CpeVersion,
string Part,
string Vendor,
string Product,
string Version,
string Update,
string Edition,
string Language,
string SwEdition,
string TargetSw,
string TargetHw,
string Other,
string Raw);
/// <summary>
/// Product alias linking different identifier systems.
/// </summary>
public sealed record ProductAlias(
ProductIdentifierType Type,
string Value,
ProductAliasSource Source);
/// <summary>
/// Source of a product alias mapping.
/// </summary>
public enum ProductAliasSource
{
VexDocument,
SbomDocument,
VendorMapping,
CommunityMapping,
NvdMapping,
Inferred
}
/// <summary>
/// Vendor information for a product.
/// </summary>
public sealed record ProductVendorInfo(
string VendorId,
string? Name,
string? Uri);
/// <summary>
/// Type of product identifier.
/// </summary>
public enum ProductIdentifierType
{
Purl,
Cpe,
Swid,
BomRef,
VendorProductId,
Custom
}
/// <summary>
/// Confidence level in product mapping.
/// </summary>
public enum ProductMappingConfidence
{
Exact,
High,
Medium,
Low,
Unknown
}
/// <summary>
/// Error during product mapping.
/// </summary>
public sealed record ProductMappingError(
string Code,
string Message,
string? Field);
/// <summary>
/// Result of product alias resolution.
/// </summary>
public sealed record ProductAliasResult(
string OriginalIdentifier,
ProductIdentifierType OriginalType,
IReadOnlyList<ProductAlias> Aliases,
bool Success,
IReadOnlyList<string>? Warnings);

View File

@@ -0,0 +1,259 @@
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
}

View File

@@ -0,0 +1,301 @@
using System.Text;
using StellaOps.VexLens.Models;
namespace StellaOps.VexLens.Mapping;
/// <summary>
/// Default implementation of <see cref="IProductMapper"/>.
/// Maps normalized products to canonical identities using PURL and CPE parsing.
/// </summary>
public sealed class ProductMapper : IProductMapper
{
private readonly IProductAliasResolver? _aliasResolver;
public ProductMapper(IProductAliasResolver? aliasResolver = null)
{
_aliasResolver = aliasResolver;
}
public async Task<ProductMappingResult> MapAsync(
NormalizedProduct product,
ProductMappingContext context,
CancellationToken cancellationToken = default)
{
var warnings = new List<string>();
var errors = new List<ProductMappingError>();
PackageUrl? parsedPurl = null;
CommonPlatformEnumeration? parsedCpe = null;
var aliases = new List<ProductAlias>();
// Parse PURL if present
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var purlResult = PurlParser.Parse(product.Purl);
if (purlResult.Success)
{
parsedPurl = purlResult.PackageUrl;
}
else if (context.ValidateIdentifiers)
{
warnings.Add($"Invalid PURL format: {purlResult.ErrorMessage}");
}
}
// Parse CPE if present
if (!string.IsNullOrWhiteSpace(product.Cpe))
{
var cpeResult = CpeParser.Parse(product.Cpe);
if (cpeResult.Success)
{
parsedCpe = cpeResult.Cpe;
}
else if (context.ValidateIdentifiers)
{
warnings.Add($"Invalid CPE format: {cpeResult.ErrorMessage}");
}
}
// Resolve aliases if requested
if (context.ResolveAliases && _aliasResolver != null)
{
if (parsedPurl != null)
{
var purlAliases = await _aliasResolver.ResolveAsync(
product.Purl!,
ProductIdentifierType.Purl,
cancellationToken);
aliases.AddRange(purlAliases);
}
if (parsedCpe != null)
{
var cpeAliases = await _aliasResolver.ResolveAsync(
product.Cpe!,
ProductIdentifierType.Cpe,
cancellationToken);
aliases.AddRange(cpeAliases);
}
}
// Determine canonical key
var canonicalKey = DetermineCanonicalKey(product, parsedPurl, parsedCpe);
// Determine mapping confidence
var confidence = DetermineConfidence(product, parsedPurl, parsedCpe);
// Extract vendor info
var vendor = ExtractVendorInfo(product, parsedPurl, parsedCpe);
var canonicalProduct = new CanonicalProduct(
CanonicalKey: canonicalKey,
Name: product.Name ?? parsedPurl?.Name ?? parsedCpe?.Product,
Version: product.Version ?? parsedPurl?.Version ?? parsedCpe?.Version,
Purl: parsedPurl,
Cpe: parsedCpe,
Aliases: aliases.Count > 0 ? aliases : null,
Vendor: vendor,
Hashes: product.Hashes);
return new ProductMappingResult(
OriginalProduct: product,
CanonicalProduct: canonicalProduct,
Success: true,
Confidence: confidence,
Warnings: warnings.Count > 0 ? warnings : null,
Errors: errors.Count > 0 ? errors : null);
}
public async Task<IReadOnlyList<ProductMappingResult>> MapBatchAsync(
IEnumerable<NormalizedProduct> products,
ProductMappingContext context,
CancellationToken cancellationToken = default)
{
var results = new List<ProductMappingResult>();
foreach (var product in products)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await MapAsync(product, context, cancellationToken);
results.Add(result);
}
return results;
}
public async Task<ProductAliasResult> ResolveAliasesAsync(
string identifier,
ProductIdentifierType identifierType,
CancellationToken cancellationToken = default)
{
if (_aliasResolver == null)
{
return new ProductAliasResult(
OriginalIdentifier: identifier,
OriginalType: identifierType,
Aliases: [],
Success: true,
Warnings: ["No alias resolver configured"]);
}
var aliases = await _aliasResolver.ResolveAsync(identifier, identifierType, cancellationToken);
return new ProductAliasResult(
OriginalIdentifier: identifier,
OriginalType: identifierType,
Aliases: aliases,
Success: true,
Warnings: null);
}
private static string DetermineCanonicalKey(
NormalizedProduct product,
PackageUrl? purl,
CommonPlatformEnumeration? cpe)
{
// Prefer PURL as canonical key (most precise)
if (purl != null)
{
return PurlParser.Build(purl);
}
// Fall back to CPE 2.3 format
if (cpe != null)
{
return CpeParser.ToCpe23(cpe.Raw) ?? cpe.Raw;
}
// Use original key
return product.Key;
}
private static ProductMappingConfidence DetermineConfidence(
NormalizedProduct product,
PackageUrl? purl,
CommonPlatformEnumeration? cpe)
{
// Exact match if we have both PURL and version
if (purl != null && !string.IsNullOrEmpty(purl.Version))
{
return ProductMappingConfidence.Exact;
}
// High confidence with CPE and version
if (cpe != null && cpe.Version != "*")
{
return ProductMappingConfidence.High;
}
// High confidence with PURL but no version
if (purl != null)
{
return ProductMappingConfidence.High;
}
// Medium confidence with CPE
if (cpe != null)
{
return ProductMappingConfidence.Medium;
}
// Low confidence if we have name but no identifiers
if (!string.IsNullOrEmpty(product.Name))
{
return ProductMappingConfidence.Low;
}
// Unknown if we only have a key
return ProductMappingConfidence.Unknown;
}
private static ProductVendorInfo? ExtractVendorInfo(
NormalizedProduct product,
PackageUrl? purl,
CommonPlatformEnumeration? cpe)
{
// Try to extract vendor from CPE
if (cpe != null && cpe.Vendor != "*" && cpe.Vendor != "-")
{
return new ProductVendorInfo(
VendorId: cpe.Vendor,
Name: FormatVendorName(cpe.Vendor),
Uri: null);
}
// Try to extract vendor from PURL namespace
if (purl != null && !string.IsNullOrEmpty(purl.Namespace))
{
return new ProductVendorInfo(
VendorId: purl.Namespace,
Name: purl.Namespace,
Uri: null);
}
return null;
}
private static string FormatVendorName(string vendorId)
{
// Convert vendor_name to Vendor Name
return string.Join(' ', vendorId
.Split('_', '-')
.Select(s => char.ToUpperInvariant(s[0]) + s[1..]));
}
}
/// <summary>
/// Interface for resolving product aliases.
/// </summary>
public interface IProductAliasResolver
{
/// <summary>
/// Resolves aliases for a product identifier.
/// </summary>
Task<IReadOnlyList<ProductAlias>> ResolveAsync(
string identifier,
ProductIdentifierType identifierType,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory product alias resolver for testing and basic usage.
/// </summary>
public sealed class InMemoryProductAliasResolver : IProductAliasResolver
{
private readonly Dictionary<string, List<ProductAlias>> _aliases = new(StringComparer.OrdinalIgnoreCase);
public void AddAlias(string identifier, ProductAlias alias)
{
if (!_aliases.TryGetValue(identifier, out var list))
{
list = [];
_aliases[identifier] = list;
}
list.Add(alias);
}
public void AddBidirectionalAlias(
string identifier1,
ProductIdentifierType type1,
string identifier2,
ProductIdentifierType type2,
ProductAliasSource source)
{
AddAlias(identifier1, new ProductAlias(type2, identifier2, source));
AddAlias(identifier2, new ProductAlias(type1, identifier1, source));
}
public Task<IReadOnlyList<ProductAlias>> ResolveAsync(
string identifier,
ProductIdentifierType identifierType,
CancellationToken cancellationToken = default)
{
if (_aliases.TryGetValue(identifier, out var aliases))
{
return Task.FromResult<IReadOnlyList<ProductAlias>>(aliases);
}
return Task.FromResult<IReadOnlyList<ProductAlias>>([]);
}
}

View File

@@ -0,0 +1,253 @@
using System.Text.RegularExpressions;
using System.Web;
namespace StellaOps.VexLens.Mapping;
/// <summary>
/// Parser for Package URL (PURL) identifiers.
/// Implements the PURL specification: https://github.com/package-url/purl-spec
/// </summary>
public static partial class PurlParser
{
// pkg:type/namespace/name@version?qualifiers#subpath
[GeneratedRegex(
@"^pkg:(?<type>[a-zA-Z][a-zA-Z0-9.+-]*)(?:/(?<namespace>[^/]+))?/(?<name>[^@?#]+)(?:@(?<version>[^?#]+))?(?:\?(?<qualifiers>[^#]+))?(?:#(?<subpath>.+))?$",
RegexOptions.Compiled)]
private static partial Regex PurlRegex();
/// <summary>
/// Parses a PURL string into its components.
/// </summary>
public static PurlParseResult Parse(string? purl)
{
if (string.IsNullOrWhiteSpace(purl))
{
return PurlParseResult.Failed("PURL cannot be null or empty");
}
var match = PurlRegex().Match(purl);
if (!match.Success)
{
return PurlParseResult.Failed("Invalid PURL format");
}
var type = match.Groups["type"].Value.ToLowerInvariant();
var namespaceGroup = match.Groups["namespace"];
var nameGroup = match.Groups["name"];
var versionGroup = match.Groups["version"];
var qualifiersGroup = match.Groups["qualifiers"];
var subpathGroup = match.Groups["subpath"];
var ns = namespaceGroup.Success ? DecodeComponent(namespaceGroup.Value) : null;
var name = DecodeComponent(nameGroup.Value);
var version = versionGroup.Success ? DecodeComponent(versionGroup.Value) : null;
var qualifiers = qualifiersGroup.Success ? ParseQualifiers(qualifiersGroup.Value) : null;
var subpath = subpathGroup.Success ? DecodeComponent(subpathGroup.Value) : null;
// Normalize namespace per type
ns = NormalizeNamespace(type, ns);
// Normalize name per type
name = NormalizeName(type, name);
var packageUrl = new PackageUrl(
Type: type,
Namespace: ns,
Name: name,
Version: version,
Qualifiers: qualifiers,
Subpath: subpath,
Raw: purl);
return PurlParseResult.Successful(packageUrl);
}
/// <summary>
/// Validates if a string is a valid PURL.
/// </summary>
public static bool IsValid(string? purl)
{
if (string.IsNullOrWhiteSpace(purl))
{
return false;
}
return PurlRegex().IsMatch(purl);
}
/// <summary>
/// Normalizes a PURL to canonical form.
/// </summary>
public static string? Normalize(string? purl)
{
var result = Parse(purl);
if (!result.Success || result.PackageUrl == null)
{
return null;
}
return Build(result.PackageUrl);
}
/// <summary>
/// Builds a PURL string from components.
/// </summary>
public static string Build(PackageUrl purl)
{
var sb = new System.Text.StringBuilder();
sb.Append("pkg:");
sb.Append(purl.Type);
if (!string.IsNullOrEmpty(purl.Namespace))
{
sb.Append('/');
sb.Append(EncodeComponent(purl.Namespace));
}
sb.Append('/');
sb.Append(EncodeComponent(purl.Name));
if (!string.IsNullOrEmpty(purl.Version))
{
sb.Append('@');
sb.Append(EncodeComponent(purl.Version));
}
if (purl.Qualifiers is { Count: > 0 })
{
sb.Append('?');
var first = true;
foreach (var (key, value) in purl.Qualifiers.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
if (!first)
{
sb.Append('&');
}
first = false;
sb.Append(EncodeComponent(key));
sb.Append('=');
sb.Append(EncodeComponent(value));
}
}
if (!string.IsNullOrEmpty(purl.Subpath))
{
sb.Append('#');
sb.Append(EncodeComponent(purl.Subpath));
}
return sb.ToString();
}
/// <summary>
/// Extracts the ecosystem/type from a PURL.
/// </summary>
public static string? GetEcosystem(string? purl)
{
var result = Parse(purl);
return result.Success ? result.PackageUrl?.Type : null;
}
/// <summary>
/// Checks if two PURLs refer to the same package (ignoring version).
/// </summary>
public static bool IsSamePackage(string? purl1, string? purl2)
{
var result1 = Parse(purl1);
var result2 = Parse(purl2);
if (!result1.Success || !result2.Success)
{
return false;
}
var p1 = result1.PackageUrl!;
var p2 = result2.PackageUrl!;
return string.Equals(p1.Type, p2.Type, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p1.Namespace, p2.Namespace, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p1.Name, p2.Name, StringComparison.OrdinalIgnoreCase);
}
private static string DecodeComponent(string component)
{
return HttpUtility.UrlDecode(component);
}
private static string EncodeComponent(string component)
{
// Percent-encode per PURL spec
return Uri.EscapeDataString(component);
}
private static IReadOnlyDictionary<string, string>? ParseQualifiers(string qualifiersStr)
{
if (string.IsNullOrEmpty(qualifiersStr))
{
return null;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var pairs = qualifiersStr.Split('&');
foreach (var pair in pairs)
{
var idx = pair.IndexOf('=');
if (idx > 0)
{
var key = DecodeComponent(pair[..idx]).ToLowerInvariant();
var value = DecodeComponent(pair[(idx + 1)..]);
result[key] = value;
}
}
return result.Count > 0 ? result : null;
}
private static string? NormalizeNamespace(string type, string? ns)
{
if (string.IsNullOrEmpty(ns))
{
return ns;
}
// Normalize per type-specific rules
return type switch
{
"npm" => ns.ToLowerInvariant(),
"nuget" => ns.ToLowerInvariant(),
"pypi" => ns.ToLowerInvariant().Replace('_', '-'),
"maven" => ns, // Case-sensitive
"golang" => ns.ToLowerInvariant(),
_ => ns
};
}
private static string NormalizeName(string type, string name)
{
// Normalize per type-specific rules
return type switch
{
"npm" => name.ToLowerInvariant(),
"nuget" => name.ToLowerInvariant(),
"pypi" => name.ToLowerInvariant().Replace('_', '-'),
"golang" => name.ToLowerInvariant(),
_ => name
};
}
}
/// <summary>
/// Result of PURL parsing.
/// </summary>
public sealed record PurlParseResult(
bool Success,
PackageUrl? PackageUrl,
string? ErrorMessage)
{
public static PurlParseResult Successful(PackageUrl purl) =>
new(true, purl, null);
public static PurlParseResult Failed(string error) =>
new(false, null, error);
}