Rename Feedser to Concelier
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user