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:
331
src/VexLens/StellaOps.VexLens/Mapping/CpeParser.cs
Normal file
331
src/VexLens/StellaOps.VexLens/Mapping/CpeParser.cs
Normal 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);
|
||||
}
|
||||
169
src/VexLens/StellaOps.VexLens/Mapping/IProductMapper.cs
Normal file
169
src/VexLens/StellaOps.VexLens/Mapping/IProductMapper.cs
Normal 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);
|
||||
259
src/VexLens/StellaOps.VexLens/Mapping/ProductIdentityMatcher.cs
Normal file
259
src/VexLens/StellaOps.VexLens/Mapping/ProductIdentityMatcher.cs
Normal 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
|
||||
}
|
||||
301
src/VexLens/StellaOps.VexLens/Mapping/ProductMapper.cs
Normal file
301
src/VexLens/StellaOps.VexLens/Mapping/ProductMapper.cs
Normal 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>>([]);
|
||||
}
|
||||
}
|
||||
253
src/VexLens/StellaOps.VexLens/Mapping/PurlParser.cs
Normal file
253
src/VexLens/StellaOps.VexLens/Mapping/PurlParser.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user