Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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>());
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);