using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Runtime.CompilerServices; using StellaOps.Concelier.Models; namespace StellaOps.Concelier.Merge.Identity; /// /// Builds an alias-driven identity graph that groups advisories referring to the same vulnerability. /// public sealed class AdvisoryIdentityResolver { private static readonly string[] CanonicalAliasPriority = { AliasSchemes.Cve, AliasSchemes.Rhsa, AliasSchemes.Usn, AliasSchemes.Dsa, AliasSchemes.SuseSu, AliasSchemes.Msrc, AliasSchemes.CiscoSa, AliasSchemes.OracleCpu, AliasSchemes.Vmsa, AliasSchemes.Apsb, AliasSchemes.Apa, AliasSchemes.AppleHt, AliasSchemes.ChromiumPost, AliasSchemes.Icsa, AliasSchemes.Jvndb, AliasSchemes.Jvn, AliasSchemes.Bdu, AliasSchemes.Vu, AliasSchemes.Ghsa, AliasSchemes.OsV, }; /// /// Groups the provided advisories into identity clusters using normalized aliases. /// public IReadOnlyList Resolve(IEnumerable advisories) { ArgumentNullException.ThrowIfNull(advisories); var materialized = advisories .Where(static advisory => advisory is not null) .Distinct() .ToArray(); if (materialized.Length == 0) { return Array.Empty(); } var aliasIndex = BuildAliasIndex(materialized); var visited = new HashSet(); var clusters = new List(); foreach (var advisory in materialized) { if (!visited.Add(advisory)) { continue; } var component = TraverseComponent(advisory, visited, aliasIndex); var key = DetermineCanonicalKey(component); var aliases = component .SelectMany(static entry => entry.Aliases) .Select(static alias => new AliasIdentity(alias.Normalized, alias.Scheme)); clusters.Add(new AdvisoryIdentityCluster(key, component.Select(static entry => entry.Advisory), aliases)); } return clusters .OrderBy(static cluster => cluster.AdvisoryKey, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } private static Dictionary> BuildAliasIndex(IEnumerable advisories) { var index = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var advisory in advisories) { foreach (var alias in ExtractAliases(advisory)) { if (!index.TryGetValue(alias.Normalized, out var list)) { list = new List(); index[alias.Normalized] = list; } list.Add(new AdvisoryAliasEntry(advisory, alias.Normalized, alias.Scheme)); } } return index; } private static IReadOnlyList TraverseComponent( Advisory root, HashSet visited, Dictionary> aliasIndex) { var stack = new Stack(); stack.Push(root); var bindings = new Dictionary(ReferenceEqualityComparer.Instance); while (stack.Count > 0) { var advisory = stack.Pop(); if (!bindings.TryGetValue(advisory, out var binding)) { binding = new AliasBinding(advisory); bindings[advisory] = binding; } foreach (var alias in ExtractAliases(advisory)) { binding.AddAlias(alias.Normalized, alias.Scheme); if (!aliasIndex.TryGetValue(alias.Normalized, out var neighbors)) { continue; } foreach (var neighbor in neighbors.Select(static entry => entry.Advisory)) { if (visited.Add(neighbor)) { stack.Push(neighbor); } if (!bindings.TryGetValue(neighbor, out var neighborBinding)) { neighborBinding = new AliasBinding(neighbor); bindings[neighbor] = neighborBinding; } neighborBinding.AddAlias(alias.Normalized, alias.Scheme); } } } return bindings.Values .OrderBy(static binding => binding.Advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } private static string DetermineCanonicalKey(IReadOnlyList component) { var aliases = component .SelectMany(static binding => binding.Aliases) .Where(static alias => !string.IsNullOrWhiteSpace(alias.Normalized)) .ToArray(); foreach (var scheme in CanonicalAliasPriority) { var candidate = aliases .Where(alias => string.Equals(alias.Scheme, scheme, StringComparison.OrdinalIgnoreCase)) .Select(static alias => alias.Normalized) .OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(); if (candidate is not null) { return candidate; } } var fallbackAlias = aliases .Select(static alias => alias.Normalized) .OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(fallbackAlias)) { return fallbackAlias; } var advisoryKey = component .Select(static binding => binding.Advisory.AdvisoryKey) .Where(static value => !string.IsNullOrWhiteSpace(value)) .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(); if (!string.IsNullOrWhiteSpace(advisoryKey)) { return advisoryKey.Trim(); } throw new InvalidOperationException("Unable to determine canonical advisory key for cluster."); } private static IEnumerable ExtractAliases(Advisory advisory) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var candidate in EnumerateAliasCandidates(advisory)) { if (string.IsNullOrWhiteSpace(candidate)) { continue; } var trimmed = candidate.Trim(); if (!seen.Add(trimmed)) { continue; } if (AliasSchemeRegistry.TryNormalize(trimmed, out var normalized, out var scheme) && !string.IsNullOrWhiteSpace(normalized)) { yield return new AliasProjection(normalized.Trim(), string.IsNullOrWhiteSpace(scheme) ? null : scheme); } else if (!string.IsNullOrWhiteSpace(normalized)) { yield return new AliasProjection(normalized.Trim(), null); } } } private static IEnumerable EnumerateAliasCandidates(Advisory advisory) { if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey)) { yield return advisory.AdvisoryKey; } if (!advisory.Aliases.IsDefaultOrEmpty) { foreach (var alias in advisory.Aliases) { if (!string.IsNullOrWhiteSpace(alias)) { yield return alias; } } } } private readonly record struct AdvisoryAliasEntry(Advisory Advisory, string Normalized, string? Scheme); private readonly record struct AliasProjection(string Normalized, string? Scheme); private sealed class AliasBinding { private readonly HashSet _aliases = new(HashSetAliasComparer.Instance); public AliasBinding(Advisory advisory) { Advisory = advisory ?? throw new ArgumentNullException(nameof(advisory)); } public Advisory Advisory { get; } public IReadOnlyCollection Aliases => _aliases; public void AddAlias(string normalized, string? scheme) { if (string.IsNullOrWhiteSpace(normalized)) { return; } _aliases.Add(new AliasProjection(normalized.Trim(), scheme is null ? null : scheme.Trim())); } } private sealed class HashSetAliasComparer : IEqualityComparer { public static readonly HashSetAliasComparer Instance = new(); public bool Equals(AliasProjection x, AliasProjection y) => string.Equals(x.Normalized, y.Normalized, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Scheme, y.Scheme, StringComparison.OrdinalIgnoreCase); public int GetHashCode(AliasProjection obj) { var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Normalized); if (!string.IsNullOrWhiteSpace(obj.Scheme)) { hash = HashCode.Combine(hash, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Scheme)); } return hash; } } private sealed class ReferenceEqualityComparer : IEqualityComparer where T : class { public static readonly ReferenceEqualityComparer Instance = new(); public bool Equals(T? x, T? y) => ReferenceEquals(x, y); public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj); } }