333 lines
14 KiB
C#
333 lines
14 KiB
C#
#pragma warning disable CONCELIER0001 // AdvisoryMergeService is deprecated - tests verify existing behavior during Link-Not-Merge transition
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using StellaOps.Concelier.Core;
|
|
using Xunit;
|
|
using StellaOps.Concelier.Core.Events;
|
|
using StellaOps.Concelier.Merge.Services;
|
|
using StellaOps.Concelier.Models;
|
|
using StellaOps.Concelier.Storage.Advisories;
|
|
using StellaOps.Concelier.Storage.Aliases;
|
|
using StellaOps.Concelier.Storage.MergeEvents;
|
|
using StellaOps.Provenance;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Concelier.Merge.Tests;
|
|
|
|
public sealed class AdvisoryMergeServiceTests
|
|
{
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task MergeAsync_AppliesCanonicalRulesAndPersistsDecisions()
|
|
{
|
|
var aliasStore = new FakeAliasStore();
|
|
aliasStore.Register("GHSA-aaaa-bbbb-cccc",
|
|
(AliasSchemes.Ghsa, "GHSA-aaaa-bbbb-cccc"),
|
|
(AliasSchemes.Cve, "CVE-2025-4242"));
|
|
aliasStore.Register("CVE-2025-4242",
|
|
(AliasSchemes.Cve, "CVE-2025-4242"));
|
|
aliasStore.Register("OSV-2025-xyz",
|
|
(AliasSchemes.OsV, "OSV-2025-xyz"),
|
|
(AliasSchemes.Cve, "CVE-2025-4242"));
|
|
|
|
var advisoryStore = new FakeAdvisoryStore();
|
|
advisoryStore.Seed(CreateGhsaAdvisory(), CreateNvdAdvisory(), CreateOsvAdvisory());
|
|
|
|
var mergeEventStore = new InMemoryMergeEventStore();
|
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 4, 1, 0, 0, 0, TimeSpan.Zero));
|
|
var writer = new MergeEventWriter(mergeEventStore, new CanonicalHashCalculator(), timeProvider, NullLogger<MergeEventWriter>.Instance);
|
|
var precedenceMerger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
var aliasResolver = new AliasGraphResolver(aliasStore);
|
|
var canonicalMerger = new CanonicalMerger(timeProvider);
|
|
var eventLog = new RecordingAdvisoryEventLog();
|
|
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, eventLog, timeProvider, NullLogger<AdvisoryMergeService>.Instance);
|
|
|
|
var result = await service.MergeAsync("GHSA-aaaa-bbbb-cccc", CancellationToken.None);
|
|
|
|
Assert.NotNull(result.Merged);
|
|
Assert.Equal("OSV summary overrides", result.Merged!.Summary);
|
|
Assert.Empty(result.Conflicts);
|
|
|
|
var upserted = advisoryStore.LastUpserted;
|
|
Assert.NotNull(upserted);
|
|
Assert.Equal("CVE-2025-4242", upserted!.AdvisoryKey);
|
|
Assert.Equal("OSV summary overrides", upserted.Summary);
|
|
|
|
var mergeRecord = mergeEventStore.LastRecord;
|
|
Assert.NotNull(mergeRecord);
|
|
var summaryDecision = Assert.Single(mergeRecord!.FieldDecisions, decision => decision.Field == "summary");
|
|
Assert.Equal("osv", summaryDecision.SelectedSource);
|
|
Assert.Equal("freshness_override", summaryDecision.DecisionReason);
|
|
|
|
var appendRequest = eventLog.LastRequest;
|
|
Assert.NotNull(appendRequest);
|
|
Assert.Contains(appendRequest!.Statements, statement => string.Equals(statement.Advisory.AdvisoryKey, "CVE-2025-4242", StringComparison.OrdinalIgnoreCase));
|
|
Assert.True(appendRequest.Conflicts is null || appendRequest.Conflicts.Count == 0);
|
|
}
|
|
|
|
private static Advisory CreateGhsaAdvisory()
|
|
{
|
|
var recorded = DateTimeOffset.Parse("2025-03-01T00:00:00Z");
|
|
var provenance = new AdvisoryProvenance("ghsa", "map", "GHSA-aaaa-bbbb-cccc", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
|
return new Advisory(
|
|
"GHSA-aaaa-bbbb-cccc",
|
|
"Container escape",
|
|
"Initial GHSA summary.",
|
|
"en",
|
|
recorded,
|
|
recorded,
|
|
"medium",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-4242", "GHSA-aaaa-bbbb-cccc" },
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { provenance });
|
|
}
|
|
|
|
private static Advisory CreateNvdAdvisory()
|
|
{
|
|
var recorded = DateTimeOffset.Parse("2025-03-02T00:00:00Z");
|
|
var provenance = new AdvisoryProvenance("nvd", "map", "CVE-2025-4242", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
|
return new Advisory(
|
|
"CVE-2025-4242",
|
|
"CVE-2025-4242",
|
|
"Baseline NVD summary.",
|
|
"en",
|
|
recorded,
|
|
recorded,
|
|
"high",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-4242" },
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { provenance });
|
|
}
|
|
|
|
private static Advisory CreateOsvAdvisory()
|
|
{
|
|
var recorded = DateTimeOffset.Parse("2025-03-05T12:00:00Z");
|
|
var provenance = new AdvisoryProvenance("osv", "map", "OSV-2025-xyz", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
|
return new Advisory(
|
|
"OSV-2025-xyz",
|
|
"Container escape",
|
|
"OSV summary overrides",
|
|
"en",
|
|
recorded,
|
|
recorded,
|
|
"critical",
|
|
exploitKnown: false,
|
|
aliases: new[] { "OSV-2025-xyz", "CVE-2025-4242" },
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { provenance });
|
|
}
|
|
|
|
private static Advisory CreateVendorAdvisory()
|
|
{
|
|
var recorded = DateTimeOffset.Parse("2025-03-10T00:00:00Z");
|
|
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-5000", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
|
return new Advisory(
|
|
"VSA-2025-5000",
|
|
"Vendor overrides severity",
|
|
"Vendor states critical impact.",
|
|
"en",
|
|
recorded,
|
|
recorded,
|
|
"critical",
|
|
exploitKnown: false,
|
|
aliases: new[] { "VSA-2025-5000", "CVE-2025-5000" },
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { provenance });
|
|
}
|
|
|
|
private static Advisory CreateConflictingNvdAdvisory()
|
|
{
|
|
var recorded = DateTimeOffset.Parse("2025-03-09T00:00:00Z");
|
|
var provenance = new AdvisoryProvenance("nvd", "map", "CVE-2025-5000", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
|
return new Advisory(
|
|
"CVE-2025-5000",
|
|
"CVE-2025-5000",
|
|
"Baseline NVD entry.",
|
|
"en",
|
|
recorded,
|
|
recorded,
|
|
"medium",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-5000" },
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { provenance });
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task MergeAsync_PersistsConflictSummariesWithHashes()
|
|
{
|
|
var aliasStore = new FakeAliasStore();
|
|
aliasStore.Register("CVE-2025-5000",
|
|
(AliasSchemes.Cve, "CVE-2025-5000"));
|
|
aliasStore.Register("VSA-2025-5000",
|
|
(AliasSchemes.Cve, "CVE-2025-5000"));
|
|
|
|
var vendor = CreateVendorAdvisory();
|
|
var nvd = CreateConflictingNvdAdvisory();
|
|
|
|
var advisoryStore = new FakeAdvisoryStore();
|
|
advisoryStore.Seed(vendor, nvd);
|
|
|
|
var mergeEventStore = new InMemoryMergeEventStore();
|
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 4, 2, 0, 0, 0, TimeSpan.Zero));
|
|
var writer = new MergeEventWriter(mergeEventStore, new CanonicalHashCalculator(), timeProvider, NullLogger<MergeEventWriter>.Instance);
|
|
var precedenceMerger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
var aliasResolver = new AliasGraphResolver(aliasStore);
|
|
var canonicalMerger = new CanonicalMerger(timeProvider);
|
|
var eventLog = new RecordingAdvisoryEventLog();
|
|
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, eventLog, timeProvider, NullLogger<AdvisoryMergeService>.Instance);
|
|
|
|
var result = await service.MergeAsync("CVE-2025-5000", CancellationToken.None);
|
|
|
|
var conflict = Assert.Single(result.Conflicts);
|
|
Assert.Equal("CVE-2025-5000", conflict.VulnerabilityKey);
|
|
Assert.Equal("severity", conflict.Explainer.Type);
|
|
Assert.Equal("mismatch", conflict.Explainer.Reason);
|
|
Assert.Contains("vendor", conflict.Explainer.PrimarySources, StringComparer.OrdinalIgnoreCase);
|
|
Assert.Contains("nvd", conflict.Explainer.SuppressedSources, StringComparer.OrdinalIgnoreCase);
|
|
Assert.Equal(conflict.Explainer.ComputeHashHex(), conflict.ConflictHash);
|
|
Assert.True(conflict.StatementIds.Length >= 2);
|
|
Assert.Equal(timeProvider.GetUtcNow(), conflict.RecordedAt);
|
|
|
|
var appendRequest = eventLog.LastRequest;
|
|
Assert.NotNull(appendRequest);
|
|
var appendedConflict = Assert.Single(appendRequest!.Conflicts!);
|
|
Assert.Equal(conflict.ConflictId, appendedConflict.ConflictId);
|
|
Assert.Equal(conflict.StatementIds.ToArray(), appendedConflict.StatementIds.ToArray());
|
|
}
|
|
|
|
|
|
private sealed class RecordingAdvisoryEventLog : IAdvisoryEventLog
|
|
{
|
|
public AdvisoryEventAppendRequest? LastRequest { get; private set; }
|
|
|
|
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
|
|
{
|
|
LastRequest = request;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask AttachStatementProvenanceAsync(Guid statementId, DsseProvenance provenance, TrustInfo trust, CancellationToken cancellationToken)
|
|
=> ValueTask.CompletedTask;
|
|
|
|
public ValueTask<AdvisoryReplay> ReplayAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
}
|
|
|
|
private sealed class FakeAliasStore : IAliasStore
|
|
{
|
|
private readonly ConcurrentDictionary<string, List<AliasRecord>> _records = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public void Register(string advisoryKey, params (string Scheme, string Value)[] aliases)
|
|
{
|
|
var list = new List<AliasRecord>();
|
|
foreach (var (scheme, value) in aliases)
|
|
{
|
|
list.Add(new AliasRecord(advisoryKey, scheme, value, DateTimeOffset.UtcNow));
|
|
}
|
|
|
|
_records[advisoryKey] = list;
|
|
}
|
|
|
|
public Task<AliasUpsertResult> ReplaceAsync(string advisoryKey, IEnumerable<AliasEntry> aliases, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new AliasUpsertResult(advisoryKey, Array.Empty<AliasCollision>()));
|
|
}
|
|
|
|
public Task<IReadOnlyList<AliasRecord>> GetByAliasAsync(string scheme, string value, CancellationToken cancellationToken)
|
|
{
|
|
var matches = _records.Values
|
|
.SelectMany(static records => records)
|
|
.Where(record => string.Equals(record.Scheme, scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(record.Value, value, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
return Task.FromResult<IReadOnlyList<AliasRecord>>(matches);
|
|
}
|
|
|
|
public Task<IReadOnlyList<AliasRecord>> GetByAdvisoryAsync(string advisoryKey, CancellationToken cancellationToken)
|
|
{
|
|
if (_records.TryGetValue(advisoryKey, out var records))
|
|
{
|
|
return Task.FromResult<IReadOnlyList<AliasRecord>>(records);
|
|
}
|
|
|
|
return Task.FromResult<IReadOnlyList<AliasRecord>>(Array.Empty<AliasRecord>());
|
|
}
|
|
}
|
|
|
|
private sealed class FakeAdvisoryStore : IAdvisoryStore
|
|
{
|
|
private readonly ConcurrentDictionary<string, Advisory> _advisories = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public Advisory? LastUpserted { get; private set; }
|
|
|
|
public void Seed(params Advisory[] advisories)
|
|
{
|
|
foreach (var advisory in advisories)
|
|
{
|
|
_advisories[advisory.AdvisoryKey] = advisory;
|
|
}
|
|
}
|
|
|
|
public Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
|
|
{
|
|
_advisories.TryGetValue(advisoryKey, out var advisory);
|
|
return Task.FromResult(advisory);
|
|
}
|
|
|
|
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<Advisory>>(Array.Empty<Advisory>());
|
|
}
|
|
|
|
public Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken)
|
|
{
|
|
_advisories[advisory.AdvisoryKey] = advisory;
|
|
LastUpserted = advisory;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken)
|
|
{
|
|
return AsyncEnumerable.Empty<Advisory>();
|
|
}
|
|
}
|
|
|
|
private sealed class InMemoryMergeEventStore : IMergeEventStore
|
|
{
|
|
public MergeEventRecord? LastRecord { get; private set; }
|
|
|
|
public Task AppendAsync(MergeEventRecord record, CancellationToken cancellationToken)
|
|
{
|
|
LastRecord = record;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<MergeEventRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
|
|
}
|
|
}
|
|
}
|