304 lines
9.8 KiB
C#
304 lines
9.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Builds an alias-driven identity graph that groups advisories referring to the same vulnerability.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Groups the provided advisories into identity clusters using normalized aliases.
|
|
/// </summary>
|
|
public IReadOnlyList<AdvisoryIdentityCluster> Resolve(IEnumerable<Advisory> advisories)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(advisories);
|
|
|
|
var materialized = advisories
|
|
.Where(static advisory => advisory is not null)
|
|
.Distinct()
|
|
.ToArray();
|
|
|
|
if (materialized.Length == 0)
|
|
{
|
|
return Array.Empty<AdvisoryIdentityCluster>();
|
|
}
|
|
|
|
var aliasIndex = BuildAliasIndex(materialized);
|
|
var visited = new HashSet<Advisory>();
|
|
var clusters = new List<AdvisoryIdentityCluster>();
|
|
|
|
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<string, List<AdvisoryAliasEntry>> BuildAliasIndex(IEnumerable<Advisory> advisories)
|
|
{
|
|
var index = new Dictionary<string, List<AdvisoryAliasEntry>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var advisory in advisories)
|
|
{
|
|
foreach (var alias in ExtractAliases(advisory))
|
|
{
|
|
if (!index.TryGetValue(alias.Normalized, out var list))
|
|
{
|
|
list = new List<AdvisoryAliasEntry>();
|
|
index[alias.Normalized] = list;
|
|
}
|
|
|
|
list.Add(new AdvisoryAliasEntry(advisory, alias.Normalized, alias.Scheme));
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
private static IReadOnlyList<AliasBinding> TraverseComponent(
|
|
Advisory root,
|
|
HashSet<Advisory> visited,
|
|
Dictionary<string, List<AdvisoryAliasEntry>> aliasIndex)
|
|
{
|
|
var stack = new Stack<Advisory>();
|
|
stack.Push(root);
|
|
|
|
var bindings = new Dictionary<Advisory, AliasBinding>(ReferenceEqualityComparer<Advisory>.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<AliasBinding> 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<AliasProjection> ExtractAliases(Advisory advisory)
|
|
{
|
|
var seen = new HashSet<string>(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<string> 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<AliasProjection> _aliases = new(HashSetAliasComparer.Instance);
|
|
|
|
public AliasBinding(Advisory advisory)
|
|
{
|
|
Advisory = advisory ?? throw new ArgumentNullException(nameof(advisory));
|
|
}
|
|
|
|
public Advisory Advisory { get; }
|
|
|
|
public IReadOnlyCollection<AliasProjection> 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<AliasProjection>
|
|
{
|
|
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<T> : IEqualityComparer<T>
|
|
where T : class
|
|
{
|
|
public static readonly ReferenceEqualityComparer<T> Instance = new();
|
|
|
|
public bool Equals(T? x, T? y) => ReferenceEquals(x, y);
|
|
|
|
public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj);
|
|
}
|
|
}
|