Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed class AdvisoryMergeService
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> AliasCollisionCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.identity_conflicts",
|
||||
unit: "count",
|
||||
description: "Number of alias collisions detected during merge.");
|
||||
|
||||
private static readonly string[] PreferredAliasSchemes =
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
AliasSchemes.Ghsa,
|
||||
AliasSchemes.OsV,
|
||||
AliasSchemes.Msrc,
|
||||
};
|
||||
|
||||
private readonly AliasGraphResolver _aliasResolver;
|
||||
private readonly IAdvisoryStore _advisoryStore;
|
||||
private readonly AdvisoryPrecedenceMerger _precedenceMerger;
|
||||
private readonly MergeEventWriter _mergeEventWriter;
|
||||
private readonly IAdvisoryEventLog _eventLog;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CanonicalMerger _canonicalMerger;
|
||||
private readonly ILogger<AdvisoryMergeService> _logger;
|
||||
|
||||
public AdvisoryMergeService(
|
||||
AliasGraphResolver aliasResolver,
|
||||
IAdvisoryStore advisoryStore,
|
||||
AdvisoryPrecedenceMerger precedenceMerger,
|
||||
MergeEventWriter mergeEventWriter,
|
||||
CanonicalMerger canonicalMerger,
|
||||
IAdvisoryEventLog eventLog,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryMergeService> logger)
|
||||
{
|
||||
_aliasResolver = aliasResolver ?? throw new ArgumentNullException(nameof(aliasResolver));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_precedenceMerger = precedenceMerger ?? throw new ArgumentNullException(nameof(precedenceMerger));
|
||||
_mergeEventWriter = mergeEventWriter ?? throw new ArgumentNullException(nameof(mergeEventWriter));
|
||||
_canonicalMerger = canonicalMerger ?? throw new ArgumentNullException(nameof(canonicalMerger));
|
||||
_eventLog = eventLog ?? throw new ArgumentNullException(nameof(eventLog));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(seedAdvisoryKey);
|
||||
|
||||
var component = await _aliasResolver.BuildComponentAsync(seedAdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var inputs = new List<Advisory>();
|
||||
|
||||
foreach (var advisoryKey in component.AdvisoryKeys)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var advisory = await _advisoryStore.FindAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (advisory is not null)
|
||||
{
|
||||
inputs.Add(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Alias component seeded by {Seed} contains no persisted advisories", seedAdvisoryKey);
|
||||
return AdvisoryMergeResult.Empty(seedAdvisoryKey, component);
|
||||
}
|
||||
|
||||
var canonicalKey = SelectCanonicalKey(component) ?? seedAdvisoryKey;
|
||||
var canonicalMerge = ApplyCanonicalMergeIfNeeded(canonicalKey, inputs);
|
||||
var before = await _advisoryStore.FindAsync(canonicalKey, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedInputs = NormalizeInputs(inputs, canonicalKey).ToList();
|
||||
|
||||
PrecedenceMergeResult precedenceResult;
|
||||
try
|
||||
{
|
||||
precedenceResult = _precedenceMerger.Merge(normalizedInputs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to merge alias component seeded by {Seed}", seedAdvisoryKey);
|
||||
throw;
|
||||
}
|
||||
|
||||
var merged = precedenceResult.Advisory;
|
||||
var conflictDetails = precedenceResult.Conflicts;
|
||||
|
||||
if (component.Collisions.Count > 0)
|
||||
{
|
||||
foreach (var collision in component.Collisions)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("scheme", collision.Scheme ?? string.Empty),
|
||||
new("alias_value", collision.Value ?? string.Empty),
|
||||
new("advisory_count", collision.AdvisoryKeys.Count),
|
||||
};
|
||||
|
||||
AliasCollisionCounter.Add(1, tags);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alias collision {Scheme}:{Value} involves advisories {Advisories}",
|
||||
collision.Scheme,
|
||||
collision.Value,
|
||||
string.Join(", ", collision.AdvisoryKeys));
|
||||
}
|
||||
}
|
||||
|
||||
await _advisoryStore.UpsertAsync(merged, cancellationToken).ConfigureAwait(false);
|
||||
await _mergeEventWriter.AppendAsync(
|
||||
canonicalKey,
|
||||
before,
|
||||
merged,
|
||||
Array.Empty<Guid>(),
|
||||
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conflictSummaries = await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged, conflictSummaries);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MergeConflictSummary>> AppendEventLogAsync(
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyList<Advisory> inputs,
|
||||
Advisory merged,
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recordedAt = _timeProvider.GetUtcNow();
|
||||
var statements = new List<AdvisoryStatementInput>(inputs.Count + 1);
|
||||
var statementIds = new Dictionary<Advisory, Guid>(ReferenceEqualityComparer.Instance);
|
||||
|
||||
foreach (var advisory in inputs)
|
||||
{
|
||||
var statementId = Guid.NewGuid();
|
||||
statementIds[advisory] = statementId;
|
||||
statements.Add(new AdvisoryStatementInput(
|
||||
vulnerabilityKey,
|
||||
advisory,
|
||||
DetermineAsOf(advisory, recordedAt),
|
||||
InputDocumentIds: Array.Empty<Guid>(),
|
||||
StatementId: statementId,
|
||||
AdvisoryKey: advisory.AdvisoryKey));
|
||||
}
|
||||
|
||||
var canonicalStatementId = Guid.NewGuid();
|
||||
statementIds[merged] = canonicalStatementId;
|
||||
statements.Add(new AdvisoryStatementInput(
|
||||
vulnerabilityKey,
|
||||
merged,
|
||||
recordedAt,
|
||||
InputDocumentIds: Array.Empty<Guid>(),
|
||||
StatementId: canonicalStatementId,
|
||||
AdvisoryKey: merged.AdvisoryKey));
|
||||
|
||||
var conflictMaterialization = BuildConflictInputs(conflicts, vulnerabilityKey, statementIds, canonicalStatementId, recordedAt);
|
||||
var conflictInputs = conflictMaterialization.Inputs;
|
||||
var conflictSummaries = conflictMaterialization.Summaries;
|
||||
|
||||
if (statements.Count == 0 && conflictInputs.Count == 0)
|
||||
{
|
||||
return conflictSummaries.Count == 0
|
||||
? Array.Empty<MergeConflictSummary>()
|
||||
: conflictSummaries.ToArray();
|
||||
}
|
||||
|
||||
var request = new AdvisoryEventAppendRequest(statements, conflictInputs.Count > 0 ? conflictInputs : null);
|
||||
|
||||
try
|
||||
{
|
||||
await _eventLog.AppendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var conflict in conflictInputs)
|
||||
{
|
||||
conflict.Details.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return conflictSummaries.Count == 0
|
||||
? Array.Empty<MergeConflictSummary>()
|
||||
: conflictSummaries.ToArray();
|
||||
}
|
||||
|
||||
private static DateTimeOffset DetermineAsOf(Advisory advisory, DateTimeOffset fallback)
|
||||
{
|
||||
return (advisory.Modified ?? advisory.Published ?? fallback).ToUniversalTime();
|
||||
}
|
||||
|
||||
private static ConflictMaterialization BuildConflictInputs(
|
||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyDictionary<Advisory, Guid> statementIds,
|
||||
Guid canonicalStatementId,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
if (conflicts.Count == 0)
|
||||
{
|
||||
return new ConflictMaterialization(new List<AdvisoryConflictInput>(0), new List<MergeConflictSummary>(0));
|
||||
}
|
||||
|
||||
var inputs = new List<AdvisoryConflictInput>(conflicts.Count);
|
||||
var summaries = new List<MergeConflictSummary>(conflicts.Count);
|
||||
|
||||
foreach (var detail in conflicts)
|
||||
{
|
||||
if (!statementIds.TryGetValue(detail.Suppressed, out var suppressedId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var related = new List<Guid> { canonicalStatementId, suppressedId };
|
||||
if (statementIds.TryGetValue(detail.Primary, out var primaryId))
|
||||
{
|
||||
if (!related.Contains(primaryId))
|
||||
{
|
||||
related.Add(primaryId);
|
||||
}
|
||||
}
|
||||
|
||||
var payload = ConflictDetailPayload.FromDetail(detail);
|
||||
var explainer = payload.ToExplainer();
|
||||
|
||||
var canonicalJson = explainer.ToCanonicalJson();
|
||||
var document = JsonDocument.Parse(canonicalJson);
|
||||
var asOf = (detail.Primary.Modified ?? detail.Suppressed.Modified ?? recordedAt).ToUniversalTime();
|
||||
var conflictId = Guid.NewGuid();
|
||||
var statementIdArray = ImmutableArray.CreateRange(related);
|
||||
var conflictHash = explainer.ComputeHashHex(canonicalJson);
|
||||
|
||||
inputs.Add(new AdvisoryConflictInput(
|
||||
vulnerabilityKey,
|
||||
document,
|
||||
asOf,
|
||||
related,
|
||||
ConflictId: conflictId));
|
||||
|
||||
summaries.Add(new MergeConflictSummary(
|
||||
conflictId,
|
||||
vulnerabilityKey,
|
||||
statementIdArray,
|
||||
conflictHash,
|
||||
asOf,
|
||||
recordedAt,
|
||||
explainer));
|
||||
}
|
||||
|
||||
return new ConflictMaterialization(inputs, summaries);
|
||||
}
|
||||
|
||||
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
||||
{
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
yield return CloneWithKey(advisory, canonicalKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static Advisory CloneWithKey(Advisory source, string advisoryKey)
|
||||
=> new(
|
||||
advisoryKey,
|
||||
source.Title,
|
||||
source.Summary,
|
||||
source.Language,
|
||||
source.Published,
|
||||
source.Modified,
|
||||
source.Severity,
|
||||
source.ExploitKnown,
|
||||
source.Aliases,
|
||||
source.Credits,
|
||||
source.References,
|
||||
source.AffectedPackages,
|
||||
source.CvssMetrics,
|
||||
source.Provenance,
|
||||
source.Description,
|
||||
source.Cwes,
|
||||
source.CanonicalMetricId);
|
||||
|
||||
private CanonicalMergeResult? ApplyCanonicalMergeIfNeeded(string canonicalKey, List<Advisory> inputs)
|
||||
{
|
||||
if (inputs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ghsa = FindBySource(inputs, CanonicalSources.Ghsa);
|
||||
var nvd = FindBySource(inputs, CanonicalSources.Nvd);
|
||||
var osv = FindBySource(inputs, CanonicalSources.Osv);
|
||||
|
||||
var participatingSources = 0;
|
||||
if (ghsa is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (nvd is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (osv is not null)
|
||||
{
|
||||
participatingSources++;
|
||||
}
|
||||
|
||||
if (participatingSources < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = _canonicalMerger.Merge(canonicalKey, ghsa, nvd, osv);
|
||||
|
||||
inputs.RemoveAll(advisory => MatchesCanonicalSource(advisory));
|
||||
inputs.Add(result.Advisory);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Advisory? FindBySource(IEnumerable<Advisory> advisories, string source)
|
||||
=> advisories.FirstOrDefault(advisory => advisory.Provenance.Any(provenance =>
|
||||
!string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(provenance.Source, source, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
private static bool MatchesCanonicalSource(Advisory advisory)
|
||||
{
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(provenance.Source, CanonicalSources.Ghsa, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(provenance.Source, CanonicalSources.Nvd, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(provenance.Source, CanonicalSources.Osv, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MergeFieldDecision> ConvertFieldDecisions(ImmutableArray<FieldDecision>? decisions)
|
||||
{
|
||||
if (decisions is null || decisions.Value.IsDefaultOrEmpty)
|
||||
{
|
||||
return Array.Empty<MergeFieldDecision>();
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<MergeFieldDecision>(decisions.Value.Length);
|
||||
foreach (var decision in decisions.Value)
|
||||
{
|
||||
builder.Add(new MergeFieldDecision(
|
||||
decision.Field,
|
||||
decision.SelectedSource,
|
||||
decision.DecisionReason,
|
||||
decision.SelectedModified,
|
||||
decision.ConsideredSources.ToArray()));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static class CanonicalSources
|
||||
{
|
||||
public const string Ghsa = "ghsa";
|
||||
public const string Nvd = "nvd";
|
||||
public const string Osv = "osv";
|
||||
}
|
||||
|
||||
private sealed record ConflictMaterialization(
|
||||
List<AdvisoryConflictInput> Inputs,
|
||||
List<MergeConflictSummary> Summaries);
|
||||
|
||||
private static string? SelectCanonicalKey(AliasComponent component)
|
||||
{
|
||||
foreach (var scheme in PreferredAliasSchemes)
|
||||
{
|
||||
var alias = component.AliasMap.Values
|
||||
.SelectMany(static aliases => aliases)
|
||||
.FirstOrDefault(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(alias?.Value))
|
||||
{
|
||||
return alias.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (component.AliasMap.TryGetValue(component.SeedAdvisoryKey, out var seedAliases))
|
||||
{
|
||||
var primary = seedAliases.FirstOrDefault(record => string.Equals(record.Scheme, AliasStoreConstants.PrimaryScheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(primary?.Value))
|
||||
{
|
||||
return primary.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var firstAlias = component.AliasMap.Values.SelectMany(static aliases => aliases).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstAlias?.Value))
|
||||
{
|
||||
return firstAlias.Value;
|
||||
}
|
||||
|
||||
return component.SeedAdvisoryKey;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryMergeResult(
|
||||
string SeedAdvisoryKey,
|
||||
string CanonicalAdvisoryKey,
|
||||
AliasComponent Component,
|
||||
IReadOnlyList<Advisory> Inputs,
|
||||
Advisory? Previous,
|
||||
Advisory? Merged,
|
||||
IReadOnlyList<MergeConflictSummary> Conflicts)
|
||||
{
|
||||
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
||||
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null, Array.Empty<MergeConflictSummary>());
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Merges canonical advisories emitted by different sources into a single precedence-resolved advisory.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryPrecedenceMerger
|
||||
{
|
||||
private static readonly Meter MergeMeter = new("StellaOps.Concelier.Merge");
|
||||
private static readonly Counter<long> MergeCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.operations",
|
||||
unit: "count",
|
||||
description: "Number of merge invocations executed by the precedence engine.");
|
||||
|
||||
private static readonly Counter<long> OverridesCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.overrides",
|
||||
unit: "count",
|
||||
description: "Number of times lower-precedence advisories were overridden by higher-precedence sources.");
|
||||
|
||||
private static readonly Counter<long> RangeOverrideCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.range_overrides",
|
||||
unit: "count",
|
||||
description: "Number of affected-package range overrides performed during precedence merge.");
|
||||
|
||||
private static readonly Counter<long> ConflictCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.conflicts",
|
||||
unit: "count",
|
||||
description: "Number of precedence conflicts detected (severity, rank ties, etc.).");
|
||||
|
||||
private static readonly Counter<long> NormalizedRuleCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.normalized_rules",
|
||||
unit: "rule",
|
||||
description: "Number of normalized version rules retained after precedence merge.");
|
||||
|
||||
private static readonly Counter<long> MissingNormalizedRuleCounter = MergeMeter.CreateCounter<long>(
|
||||
"concelier.merge.normalized_rules_missing",
|
||||
unit: "package",
|
||||
description: "Number of affected packages with version ranges but no normalized rules.");
|
||||
|
||||
private static readonly Action<ILogger, MergeOverrideAudit, Exception?> OverrideLogged = LoggerMessage.Define<MergeOverrideAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1000, "AdvisoryOverride"),
|
||||
"Advisory precedence override {@Override}");
|
||||
|
||||
private static readonly Action<ILogger, PackageOverrideAudit, Exception?> RangeOverrideLogged = LoggerMessage.Define<PackageOverrideAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1001, "PackageRangeOverride"),
|
||||
"Affected package precedence override {@Override}");
|
||||
|
||||
private static readonly Action<ILogger, MergeFieldConflictAudit, Exception?> ConflictLogged = LoggerMessage.Define<MergeFieldConflictAudit>(
|
||||
LogLevel.Information,
|
||||
new EventId(1002, "PrecedenceConflict"),
|
||||
"Precedence conflict {@Conflict}");
|
||||
|
||||
private readonly AffectedPackagePrecedenceResolver _packageResolver;
|
||||
private readonly IReadOnlyDictionary<string, int> _precedence;
|
||||
private readonly int _fallbackRank;
|
||||
private readonly System.TimeProvider _timeProvider;
|
||||
private readonly ILogger<AdvisoryPrecedenceMerger> _logger;
|
||||
|
||||
public AdvisoryPrecedenceMerger()
|
||||
: this(new AffectedPackagePrecedenceResolver(), TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(AffectedPackagePrecedenceResolver packageResolver, System.TimeProvider? timeProvider = null)
|
||||
: this(packageResolver, packageResolver?.Precedence ?? AdvisoryPrecedenceDefaults.Rankings, timeProvider ?? TimeProvider.System, NullLogger<AdvisoryPrecedenceMerger>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
IReadOnlyDictionary<string, int> precedence,
|
||||
System.TimeProvider timeProvider)
|
||||
: this(packageResolver, precedence, timeProvider, NullLogger<AdvisoryPrecedenceMerger>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
AdvisoryPrecedenceOptions? options,
|
||||
System.TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPrecedenceMerger>? logger = null)
|
||||
: this(
|
||||
EnsureResolver(packageResolver, options, out var precedence),
|
||||
precedence,
|
||||
timeProvider,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public AdvisoryPrecedenceMerger(
|
||||
AffectedPackagePrecedenceResolver packageResolver,
|
||||
IReadOnlyDictionary<string, int> precedence,
|
||||
System.TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPrecedenceMerger>? logger)
|
||||
{
|
||||
_packageResolver = packageResolver ?? throw new ArgumentNullException(nameof(packageResolver));
|
||||
_precedence = precedence ?? throw new ArgumentNullException(nameof(precedence));
|
||||
_fallbackRank = _precedence.Count == 0 ? 10 : _precedence.Values.Max() + 1;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? NullLogger<AdvisoryPrecedenceMerger>.Instance;
|
||||
}
|
||||
|
||||
public PrecedenceMergeResult Merge(IEnumerable<Advisory> advisories)
|
||||
{
|
||||
if (advisories is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(advisories));
|
||||
}
|
||||
|
||||
var list = advisories.Where(static a => a is not null).ToList();
|
||||
if (list.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one advisory is required for merge.", nameof(advisories));
|
||||
}
|
||||
|
||||
var advisoryKey = list[0].AdvisoryKey;
|
||||
if (list.Any(advisory => !string.Equals(advisory.AdvisoryKey, advisoryKey, StringComparison.Ordinal)))
|
||||
{
|
||||
throw new ArgumentException("All advisories must share the same advisory key.", nameof(advisories));
|
||||
}
|
||||
|
||||
var ordered = list
|
||||
.Select(advisory => new AdvisoryEntry(advisory, GetRank(advisory)))
|
||||
.OrderBy(entry => entry.Rank)
|
||||
.ThenByDescending(entry => entry.Advisory.Provenance.Length)
|
||||
.ToArray();
|
||||
|
||||
MergeCounter.Add(1, new KeyValuePair<string, object?>("inputs", list.Count));
|
||||
|
||||
var primary = ordered[0].Advisory;
|
||||
|
||||
var title = PickString(ordered, advisory => advisory.Title) ?? advisoryKey;
|
||||
var summary = PickString(ordered, advisory => advisory.Summary);
|
||||
var language = PickString(ordered, advisory => advisory.Language);
|
||||
var severity = PickString(ordered, advisory => advisory.Severity);
|
||||
|
||||
var aliases = ordered
|
||||
.SelectMany(entry => entry.Advisory.Aliases)
|
||||
.Where(static alias => !string.IsNullOrWhiteSpace(alias))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var credits = ordered
|
||||
.SelectMany(entry => entry.Advisory.Credits)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
var references = ordered
|
||||
.SelectMany(entry => entry.Advisory.References)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
var packageResult = _packageResolver.Merge(ordered.SelectMany(entry => entry.Advisory.AffectedPackages));
|
||||
RecordNormalizedRuleMetrics(packageResult.Packages);
|
||||
var affectedPackages = packageResult.Packages;
|
||||
var cvssMetrics = ordered
|
||||
.SelectMany(entry => entry.Advisory.CvssMetrics)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
var published = PickDateTime(ordered, static advisory => advisory.Published);
|
||||
var modified = PickDateTime(ordered, static advisory => advisory.Modified) ?? published;
|
||||
|
||||
var provenance = ordered
|
||||
.SelectMany(entry => entry.Advisory.Provenance)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var precedenceTrace = ordered
|
||||
.SelectMany(entry => entry.Sources)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static source => source, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var mergeProvenance = new AdvisoryProvenance(
|
||||
source: "merge",
|
||||
kind: "precedence",
|
||||
value: string.Join("|", precedenceTrace),
|
||||
recordedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
provenance.Add(mergeProvenance);
|
||||
|
||||
var exploitKnown = ordered.Any(entry => entry.Advisory.ExploitKnown);
|
||||
|
||||
LogOverrides(advisoryKey, ordered);
|
||||
LogPackageOverrides(advisoryKey, packageResult.Overrides);
|
||||
var conflicts = new List<MergeConflictDetail>();
|
||||
RecordFieldConflicts(advisoryKey, ordered, conflicts);
|
||||
|
||||
var merged = new Advisory(
|
||||
advisoryKey,
|
||||
title,
|
||||
summary,
|
||||
language,
|
||||
published,
|
||||
modified,
|
||||
severity,
|
||||
exploitKnown,
|
||||
aliases,
|
||||
credits,
|
||||
references,
|
||||
affectedPackages,
|
||||
cvssMetrics,
|
||||
provenance);
|
||||
|
||||
return new PrecedenceMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static void RecordNormalizedRuleMetrics(IReadOnlyList<AffectedPackage> packages)
|
||||
{
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var package in packages)
|
||||
{
|
||||
var packageType = package.Type ?? string.Empty;
|
||||
var normalizedVersions = package.NormalizedVersions;
|
||||
if (normalizedVersions.Length > 0)
|
||||
{
|
||||
foreach (var rule in normalizedVersions)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("package_type", packageType),
|
||||
new("scheme", rule.Scheme ?? string.Empty),
|
||||
};
|
||||
|
||||
NormalizedRuleCounter.Add(1, tags);
|
||||
}
|
||||
}
|
||||
else if (package.VersionRanges.Length > 0)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("package_type", packageType),
|
||||
};
|
||||
|
||||
MissingNormalizedRuleCounter.Add(1, tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? PickString(IEnumerable<AdvisoryEntry> ordered, Func<Advisory, string?> selector)
|
||||
{
|
||||
foreach (var entry in ordered)
|
||||
{
|
||||
var value = selector(entry.Advisory);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private DateTimeOffset? PickDateTime(IEnumerable<AdvisoryEntry> ordered, Func<Advisory, DateTimeOffset?> selector)
|
||||
{
|
||||
foreach (var entry in ordered)
|
||||
{
|
||||
var value = selector(entry.Advisory);
|
||||
if (value.HasValue)
|
||||
{
|
||||
return value.Value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private int GetRank(Advisory advisory)
|
||||
{
|
||||
var best = _fallbackRank;
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_precedence.TryGetValue(provenance.Source, out var rank) && rank < best)
|
||||
{
|
||||
best = rank;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private void LogOverrides(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered)
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = ordered[0];
|
||||
var primaryRank = primary.Rank;
|
||||
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var candidate = ordered[i];
|
||||
if (candidate.Rank <= primaryRank)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("primary_source", FormatSourceLabel(primary.Sources)),
|
||||
new("suppressed_source", FormatSourceLabel(candidate.Sources)),
|
||||
new("primary_rank", primaryRank),
|
||||
new("suppressed_rank", candidate.Rank),
|
||||
};
|
||||
|
||||
OverridesCounter.Add(1, tags);
|
||||
|
||||
var audit = new MergeOverrideAudit(
|
||||
advisoryKey,
|
||||
primary.Sources,
|
||||
primaryRank,
|
||||
candidate.Sources,
|
||||
candidate.Rank,
|
||||
primary.Advisory.Aliases.Length,
|
||||
candidate.Advisory.Aliases.Length,
|
||||
primary.Advisory.Provenance.Length,
|
||||
candidate.Advisory.Provenance.Length);
|
||||
|
||||
OverrideLogged(_logger, audit, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPackageOverrides(string advisoryKey, IReadOnlyList<AffectedPackageOverride> overrides)
|
||||
{
|
||||
if (overrides.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var record in overrides)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("advisory_key", advisoryKey),
|
||||
new("package_type", record.Type),
|
||||
new("primary_source", FormatSourceLabel(record.PrimarySources)),
|
||||
new("suppressed_source", FormatSourceLabel(record.SuppressedSources)),
|
||||
new("primary_rank", record.PrimaryRank),
|
||||
new("suppressed_rank", record.SuppressedRank),
|
||||
new("primary_range_count", record.PrimaryRangeCount),
|
||||
new("suppressed_range_count", record.SuppressedRangeCount),
|
||||
};
|
||||
|
||||
RangeOverrideCounter.Add(1, tags);
|
||||
|
||||
var audit = new PackageOverrideAudit(
|
||||
advisoryKey,
|
||||
record.Type,
|
||||
record.Identifier,
|
||||
record.Platform,
|
||||
record.PrimaryRank,
|
||||
record.SuppressedRank,
|
||||
record.PrimarySources,
|
||||
record.SuppressedSources,
|
||||
record.PrimaryRangeCount,
|
||||
record.SuppressedRangeCount);
|
||||
|
||||
RangeOverrideLogged(_logger, audit, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFieldConflicts(string advisoryKey, IReadOnlyList<AdvisoryEntry> ordered, List<MergeConflictDetail> conflicts)
|
||||
{
|
||||
if (ordered.Count <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = ordered[0];
|
||||
var primarySeverity = NormalizeSeverity(primary.Advisory.Severity);
|
||||
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var candidate = ordered[i];
|
||||
var candidateSeverity = NormalizeSeverity(candidate.Advisory.Severity);
|
||||
|
||||
if (!string.IsNullOrEmpty(candidateSeverity))
|
||||
{
|
||||
var reason = string.IsNullOrEmpty(primarySeverity) ? "primary_missing" : "mismatch";
|
||||
if (string.IsNullOrEmpty(primarySeverity) || !string.Equals(primarySeverity, candidateSeverity, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RecordConflict(
|
||||
advisoryKey,
|
||||
"severity",
|
||||
reason,
|
||||
primary,
|
||||
candidate,
|
||||
primarySeverity ?? "(none)",
|
||||
candidateSeverity,
|
||||
conflicts);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.Rank == primary.Rank)
|
||||
{
|
||||
RecordConflict(
|
||||
advisoryKey,
|
||||
"precedence_tie",
|
||||
"equal_rank",
|
||||
primary,
|
||||
candidate,
|
||||
primary.Rank.ToString(CultureInfo.InvariantCulture),
|
||||
candidate.Rank.ToString(CultureInfo.InvariantCulture),
|
||||
conflicts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordConflict(
|
||||
string advisoryKey,
|
||||
string conflictType,
|
||||
string reason,
|
||||
AdvisoryEntry primary,
|
||||
AdvisoryEntry suppressed,
|
||||
string? primaryValue,
|
||||
string? suppressedValue,
|
||||
List<MergeConflictDetail> conflicts)
|
||||
{
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("type", conflictType),
|
||||
new("reason", reason),
|
||||
new("primary_source", FormatSourceLabel(primary.Sources)),
|
||||
new("suppressed_source", FormatSourceLabel(suppressed.Sources)),
|
||||
new("primary_rank", primary.Rank),
|
||||
new("suppressed_rank", suppressed.Rank),
|
||||
};
|
||||
|
||||
ConflictCounter.Add(1, tags);
|
||||
|
||||
var audit = new MergeFieldConflictAudit(
|
||||
advisoryKey,
|
||||
conflictType,
|
||||
reason,
|
||||
primary.Sources,
|
||||
primary.Rank,
|
||||
suppressed.Sources,
|
||||
suppressed.Rank,
|
||||
primaryValue,
|
||||
suppressedValue);
|
||||
|
||||
ConflictLogged(_logger, audit, null);
|
||||
|
||||
conflicts.Add(new MergeConflictDetail(
|
||||
primary.Advisory,
|
||||
suppressed.Advisory,
|
||||
conflictType,
|
||||
reason,
|
||||
primary.Sources.ToArray(),
|
||||
primary.Rank,
|
||||
suppressed.Sources.ToArray(),
|
||||
suppressed.Rank,
|
||||
primaryValue,
|
||||
suppressedValue));
|
||||
}
|
||||
|
||||
private readonly record struct AdvisoryEntry(Advisory Advisory, int Rank)
|
||||
{
|
||||
public IReadOnlyCollection<string> Sources { get; } = Advisory.Provenance
|
||||
.Select(static p => p.Source)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
=> SeverityNormalization.Normalize(severity);
|
||||
|
||||
private static AffectedPackagePrecedenceResolver EnsureResolver(
|
||||
AffectedPackagePrecedenceResolver? resolver,
|
||||
AdvisoryPrecedenceOptions? options,
|
||||
out IReadOnlyDictionary<string, int> precedence)
|
||||
{
|
||||
precedence = AdvisoryPrecedenceTable.Merge(AdvisoryPrecedenceDefaults.Rankings, options);
|
||||
|
||||
if (resolver is null)
|
||||
{
|
||||
return new AffectedPackagePrecedenceResolver(precedence);
|
||||
}
|
||||
|
||||
if (DictionaryEquals(resolver.Precedence, precedence))
|
||||
{
|
||||
return resolver;
|
||||
}
|
||||
|
||||
return new AffectedPackagePrecedenceResolver(precedence);
|
||||
}
|
||||
|
||||
private static bool DictionaryEquals(
|
||||
IReadOnlyDictionary<string, int> left,
|
||||
IReadOnlyDictionary<string, int> right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in left)
|
||||
{
|
||||
if (!right.TryGetValue(key, out var other) || other != value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatSourceLabel(IReadOnlyCollection<string> sources)
|
||||
{
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (sources.Count == 1)
|
||||
{
|
||||
return sources.First();
|
||||
}
|
||||
|
||||
return string.Join('|', sources.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).Take(3));
|
||||
}
|
||||
|
||||
private readonly record struct MergeOverrideAudit(
|
||||
string AdvisoryKey,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
int PrimaryAliasCount,
|
||||
int SuppressedAliasCount,
|
||||
int PrimaryProvenanceCount,
|
||||
int SuppressedProvenanceCount);
|
||||
|
||||
private readonly record struct PackageOverrideAudit(
|
||||
string AdvisoryKey,
|
||||
string PackageType,
|
||||
string Identifier,
|
||||
string? Platform,
|
||||
int PrimaryRank,
|
||||
int SuppressedRank,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int PrimaryRangeCount,
|
||||
int SuppressedRangeCount);
|
||||
|
||||
private readonly record struct MergeFieldConflictAudit(
|
||||
string AdvisoryKey,
|
||||
string ConflictType,
|
||||
string Reason,
|
||||
IReadOnlyCollection<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyCollection<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Merge.Options;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies source precedence rules to affected package sets so authoritative distro ranges override generic registry data.
|
||||
/// </summary>
|
||||
public sealed class AffectedPackagePrecedenceResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, int> _precedence;
|
||||
private readonly int _fallbackRank;
|
||||
|
||||
public AffectedPackagePrecedenceResolver()
|
||||
: this(AdvisoryPrecedenceDefaults.Rankings)
|
||||
{
|
||||
}
|
||||
|
||||
public AffectedPackagePrecedenceResolver(AdvisoryPrecedenceOptions? options)
|
||||
: this(AdvisoryPrecedenceTable.Merge(AdvisoryPrecedenceDefaults.Rankings, options))
|
||||
{
|
||||
}
|
||||
|
||||
public AffectedPackagePrecedenceResolver(IReadOnlyDictionary<string, int> precedence)
|
||||
{
|
||||
_precedence = precedence ?? throw new ArgumentNullException(nameof(precedence));
|
||||
_fallbackRank = precedence.Count == 0 ? 10 : precedence.Values.Max() + 1;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, int> Precedence => _precedence;
|
||||
|
||||
public AffectedPackagePrecedenceResult Merge(IEnumerable<AffectedPackage> packages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
|
||||
var grouped = packages
|
||||
.Where(static pkg => pkg is not null)
|
||||
.GroupBy(pkg => (pkg.Type, pkg.Identifier, pkg.Platform ?? string.Empty));
|
||||
|
||||
var resolved = new List<AffectedPackage>();
|
||||
var overrides = new List<AffectedPackageOverride>();
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var ordered = group
|
||||
.Select(pkg => new PackageEntry(pkg, GetPrecedence(pkg)))
|
||||
.OrderBy(static entry => entry.Rank)
|
||||
.ThenByDescending(static entry => entry.Package.Provenance.Length)
|
||||
.ThenByDescending(static entry => entry.Package.VersionRanges.Length)
|
||||
.ToList();
|
||||
|
||||
var primary = ordered[0];
|
||||
var provenance = ordered
|
||||
.SelectMany(static entry => entry.Package.Provenance)
|
||||
.Where(static p => p is not null)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
var statuses = ordered
|
||||
.SelectMany(static entry => entry.Package.Statuses)
|
||||
.Distinct(AffectedPackageStatusEqualityComparer.Instance)
|
||||
.ToImmutableArray();
|
||||
|
||||
var normalizedRules = ordered
|
||||
.SelectMany(static entry => entry.Package.NormalizedVersions)
|
||||
.Distinct(NormalizedVersionRuleEqualityComparer.Instance)
|
||||
.OrderBy(static rule => rule, NormalizedVersionRuleComparer.Instance)
|
||||
.ToImmutableArray();
|
||||
|
||||
foreach (var candidate in ordered.Skip(1))
|
||||
{
|
||||
if (candidate.Package.VersionRanges.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
overrides.Add(new AffectedPackageOverride(
|
||||
primary.Package.Type,
|
||||
primary.Package.Identifier,
|
||||
string.IsNullOrWhiteSpace(primary.Package.Platform) ? null : primary.Package.Platform,
|
||||
primary.Rank,
|
||||
candidate.Rank,
|
||||
ExtractSources(primary.Package),
|
||||
ExtractSources(candidate.Package),
|
||||
primary.Package.VersionRanges.Length,
|
||||
candidate.Package.VersionRanges.Length));
|
||||
}
|
||||
|
||||
var merged = new AffectedPackage(
|
||||
primary.Type,
|
||||
primary.Identifier,
|
||||
string.IsNullOrWhiteSpace(primary.Platform) ? null : primary.Platform,
|
||||
primary.Package.VersionRanges,
|
||||
statuses,
|
||||
provenance,
|
||||
normalizedRules);
|
||||
|
||||
resolved.Add(merged);
|
||||
}
|
||||
|
||||
var packagesResult = resolved
|
||||
.OrderBy(static pkg => pkg.Type, StringComparer.Ordinal)
|
||||
.ThenBy(static pkg => pkg.Identifier, StringComparer.Ordinal)
|
||||
.ThenBy(static pkg => pkg.Platform, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AffectedPackagePrecedenceResult(packagesResult, overrides.ToImmutableArray());
|
||||
}
|
||||
|
||||
private int GetPrecedence(AffectedPackage package)
|
||||
{
|
||||
var bestRank = _fallbackRank;
|
||||
foreach (var provenance in package.Provenance)
|
||||
{
|
||||
if (provenance is null || string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_precedence.TryGetValue(provenance.Source, out var rank) && rank < bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRank;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractSources(AffectedPackage package)
|
||||
{
|
||||
if (package.Provenance.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return package.Provenance
|
||||
.Select(static p => p.Source)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private readonly record struct PackageEntry(AffectedPackage Package, int Rank)
|
||||
{
|
||||
public string Type => Package.Type;
|
||||
|
||||
public string Identifier => Package.Identifier;
|
||||
|
||||
public string? Platform => string.IsNullOrWhiteSpace(Package.Platform) ? null : Package.Platform;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AffectedPackagePrecedenceResult(
|
||||
IReadOnlyList<AffectedPackage> Packages,
|
||||
IReadOnlyList<AffectedPackageOverride> Overrides);
|
||||
|
||||
public sealed record AffectedPackageOverride(
|
||||
string Type,
|
||||
string Identifier,
|
||||
string? Platform,
|
||||
int PrimaryRank,
|
||||
int SuppressedRank,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int PrimaryRangeCount,
|
||||
int SuppressedRangeCount);
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed class AliasGraphResolver
|
||||
{
|
||||
private readonly IAliasStore _aliasStore;
|
||||
|
||||
public AliasGraphResolver(IAliasStore aliasStore)
|
||||
{
|
||||
_aliasStore = aliasStore ?? throw new ArgumentNullException(nameof(aliasStore));
|
||||
}
|
||||
|
||||
public async Task<AliasIdentityResult> ResolveAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
var aliases = await _aliasStore.GetByAdvisoryAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
var collisions = new List<AliasCollision>();
|
||||
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
var candidates = await _aliasStore.GetByAliasAsync(alias.Scheme, alias.Value, cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = candidates
|
||||
.Select(static candidate => candidate.AdvisoryKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (advisoryKeys.Length <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collisions.Add(new AliasCollision(alias.Scheme, alias.Value, advisoryKeys));
|
||||
}
|
||||
|
||||
var unique = new Dictionary<string, AliasCollision>(StringComparer.Ordinal);
|
||||
foreach (var collision in collisions)
|
||||
{
|
||||
var key = $"{collision.Scheme}\u0001{collision.Value}";
|
||||
if (!unique.ContainsKey(key))
|
||||
{
|
||||
unique[key] = collision;
|
||||
}
|
||||
}
|
||||
|
||||
var distinctCollisions = unique.Values.ToArray();
|
||||
|
||||
return new AliasIdentityResult(advisoryKey, aliases, distinctCollisions);
|
||||
}
|
||||
|
||||
public async Task<AliasComponent> BuildComponentAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
|
||||
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var queue = new Queue<string>();
|
||||
var collisionMap = new Dictionary<string, AliasCollision>(StringComparer.Ordinal);
|
||||
|
||||
var aliasCache = new Dictionary<string, IReadOnlyList<AliasRecord>>(StringComparer.OrdinalIgnoreCase);
|
||||
queue.Enqueue(advisoryKey);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var aliases = await GetAliasesAsync(current, cancellationToken, aliasCache).ConfigureAwait(false);
|
||||
aliasCache[current] = aliases;
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
var aliasRecords = await GetAdvisoriesForAliasAsync(alias.Scheme, alias.Value, cancellationToken).ConfigureAwait(false);
|
||||
var advisoryKeys = aliasRecords
|
||||
.Select(static record => record.AdvisoryKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (advisoryKeys.Length <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in advisoryKeys)
|
||||
{
|
||||
if (!visited.Contains(candidate))
|
||||
{
|
||||
queue.Enqueue(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
var collision = new AliasCollision(alias.Scheme, alias.Value, advisoryKeys);
|
||||
var key = $"{collision.Scheme}\u0001{collision.Value}";
|
||||
collisionMap.TryAdd(key, collision);
|
||||
}
|
||||
}
|
||||
|
||||
var aliasMap = new Dictionary<string, IReadOnlyList<AliasRecord>>(aliasCache, StringComparer.OrdinalIgnoreCase);
|
||||
return new AliasComponent(advisoryKey, visited.ToArray(), collisionMap.Values.ToArray(), aliasMap);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AliasRecord>> GetAliasesAsync(
|
||||
string advisoryKey,
|
||||
CancellationToken cancellationToken,
|
||||
IDictionary<string, IReadOnlyList<AliasRecord>> cache)
|
||||
{
|
||||
if (cache.TryGetValue(advisoryKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var aliases = await _aliasStore.GetByAdvisoryAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
cache[advisoryKey] = aliases;
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<AliasRecord>> GetAdvisoriesForAliasAsync(
|
||||
string scheme,
|
||||
string value,
|
||||
CancellationToken cancellationToken)
|
||||
=> _aliasStore.GetByAliasAsync(scheme, value, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AliasIdentityResult(string AdvisoryKey, IReadOnlyList<AliasRecord> Aliases, IReadOnlyList<AliasCollision> Collisions);
|
||||
|
||||
public sealed record AliasComponent(
|
||||
string SeedAdvisoryKey,
|
||||
IReadOnlyList<string> AdvisoryKeys,
|
||||
IReadOnlyList<AliasCollision> Collisions,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<AliasRecord>> AliasMap);
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic hashes over canonical advisory JSON payloads.
|
||||
/// </summary>
|
||||
public sealed class CanonicalHashCalculator
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(false);
|
||||
|
||||
public byte[] ComputeHash(Advisory? advisory)
|
||||
{
|
||||
if (advisory is null)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(CanonicalJsonSerializer.Normalize(advisory));
|
||||
var payload = Utf8NoBom.GetBytes(canonical);
|
||||
return SHA256.HashData(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical conflict detail used to materialize structured payloads for persistence and explainers.
|
||||
/// </summary>
|
||||
public sealed record ConflictDetailPayload(
|
||||
string Type,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue)
|
||||
{
|
||||
public static ConflictDetailPayload FromDetail(MergeConflictDetail detail)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detail);
|
||||
|
||||
return new ConflictDetailPayload(
|
||||
detail.ConflictType,
|
||||
detail.Reason,
|
||||
detail.PrimarySources,
|
||||
detail.PrimaryRank,
|
||||
detail.SuppressedSources,
|
||||
detail.SuppressedRank,
|
||||
detail.PrimaryValue,
|
||||
detail.SuppressedValue);
|
||||
}
|
||||
|
||||
public MergeConflictExplainerPayload ToExplainer() =>
|
||||
new(
|
||||
Type,
|
||||
Reason,
|
||||
PrimarySources,
|
||||
PrimaryRank,
|
||||
SuppressedSources,
|
||||
SuppressedRank,
|
||||
PrimaryValue,
|
||||
SuppressedValue);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed record MergeConflictDetail(
|
||||
Advisory Primary,
|
||||
Advisory Suppressed,
|
||||
string ConflictType,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue);
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Structured payload describing a precedence conflict between advisory sources.
|
||||
/// </summary>
|
||||
public sealed record MergeConflictExplainerPayload(
|
||||
string Type,
|
||||
string Reason,
|
||||
IReadOnlyList<string> PrimarySources,
|
||||
int PrimaryRank,
|
||||
IReadOnlyList<string> SuppressedSources,
|
||||
int SuppressedRank,
|
||||
string? PrimaryValue,
|
||||
string? SuppressedValue)
|
||||
{
|
||||
public string ToCanonicalJson() => CanonicalJsonSerializer.Serialize(this);
|
||||
|
||||
public string ComputeHashHex(string? canonicalJson = null)
|
||||
{
|
||||
var json = canonicalJson ?? ToCanonicalJson();
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
public static MergeConflictExplainerPayload FromCanonicalJson(string canonicalJson)
|
||||
=> CanonicalJsonSerializer.Deserialize<MergeConflictExplainerPayload>(canonicalJson);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a persisted advisory conflict including hashes and structured explainer payload.
|
||||
/// </summary>
|
||||
public sealed record MergeConflictSummary(
|
||||
Guid ConflictId,
|
||||
string VulnerabilityKey,
|
||||
ImmutableArray<Guid> StatementIds,
|
||||
string ConflictHash,
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset RecordedAt,
|
||||
MergeConflictExplainerPayload Explainer);
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
|
||||
|
||||
/// <summary>
|
||||
/// Persists merge events with canonical before/after hashes for auditability.
|
||||
/// </summary>
|
||||
public sealed class MergeEventWriter
|
||||
{
|
||||
private readonly IMergeEventStore _mergeEventStore;
|
||||
private readonly CanonicalHashCalculator _hashCalculator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<MergeEventWriter> _logger;
|
||||
|
||||
public MergeEventWriter(
|
||||
IMergeEventStore mergeEventStore,
|
||||
CanonicalHashCalculator hashCalculator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<MergeEventWriter> logger)
|
||||
{
|
||||
_mergeEventStore = mergeEventStore ?? throw new ArgumentNullException(nameof(mergeEventStore));
|
||||
_hashCalculator = hashCalculator ?? throw new ArgumentNullException(nameof(hashCalculator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<MergeEventRecord> AppendAsync(
|
||||
string advisoryKey,
|
||||
Advisory? before,
|
||||
Advisory after,
|
||||
IReadOnlyList<Guid> inputDocumentIds,
|
||||
IReadOnlyList<MergeFieldDecision>? fieldDecisions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
ArgumentNullException.ThrowIfNull(after);
|
||||
|
||||
var beforeHash = _hashCalculator.ComputeHash(before);
|
||||
var afterHash = _hashCalculator.ComputeHash(after);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var documentIds = inputDocumentIds?.ToArray() ?? Array.Empty<Guid>();
|
||||
|
||||
var record = new MergeEventRecord(
|
||||
Guid.NewGuid(),
|
||||
advisoryKey,
|
||||
beforeHash,
|
||||
afterHash,
|
||||
timestamp,
|
||||
documentIds,
|
||||
fieldDecisions ?? Array.Empty<MergeFieldDecision>());
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(beforeHash, afterHash))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Merge event for {AdvisoryKey} changed hash {BeforeHash} -> {AfterHash}",
|
||||
advisoryKey,
|
||||
Convert.ToHexString(beforeHash),
|
||||
Convert.ToHexString(afterHash));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Merge event for {AdvisoryKey} recorded without hash change", advisoryKey);
|
||||
}
|
||||
|
||||
await _mergeEventStore.AppendAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Services;
|
||||
|
||||
public sealed record PrecedenceMergeResult(
|
||||
Advisory Advisory,
|
||||
IReadOnlyList<MergeConflictDetail> Conflicts);
|
||||
Reference in New Issue
Block a user