212 lines
8.3 KiB
C#
212 lines
8.3 KiB
C#
using System;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Microsoft.Extensions.Time.Testing;
|
||
using MongoDB.Driver;
|
||
using StellaOps.Feedser.Merge.Services;
|
||
using StellaOps.Feedser.Models;
|
||
using StellaOps.Feedser.Storage.Mongo;
|
||
using StellaOps.Feedser.Storage.Mongo.MergeEvents;
|
||
using StellaOps.Feedser.Testing;
|
||
|
||
namespace StellaOps.Feedser.Merge.Tests;
|
||
|
||
[Collection("mongo-fixture")]
|
||
public sealed class MergePrecedenceIntegrationTests : IAsyncLifetime
|
||
{
|
||
private readonly MongoIntegrationFixture _fixture;
|
||
private MergeEventStore? _mergeEventStore;
|
||
private MergeEventWriter? _mergeEventWriter;
|
||
private AdvisoryPrecedenceMerger? _merger;
|
||
private FakeTimeProvider? _timeProvider;
|
||
|
||
public MergePrecedenceIntegrationTests(MongoIntegrationFixture fixture)
|
||
{
|
||
_fixture = fixture;
|
||
}
|
||
|
||
[Fact]
|
||
public async Task MergePipeline_PsirtOverridesNvd_AndKevOnlyTogglesExploitKnown()
|
||
{
|
||
await EnsureInitializedAsync();
|
||
|
||
var merger = _merger!;
|
||
var writer = _mergeEventWriter!;
|
||
var store = _mergeEventStore!;
|
||
var timeProvider = _timeProvider!;
|
||
|
||
var expectedTimestamp = timeProvider.GetUtcNow();
|
||
|
||
var nvd = CreateNvdBaseline();
|
||
var vendor = CreateVendorOverride();
|
||
var kev = CreateKevSignal();
|
||
|
||
var merged = merger.Merge(new[] { nvd, vendor, kev });
|
||
|
||
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
||
Assert.Equal("Vendor Security Advisory", merged.Title);
|
||
Assert.Equal("Critical impact on supported platforms.", merged.Summary);
|
||
Assert.Equal("critical", merged.Severity);
|
||
Assert.True(merged.ExploitKnown);
|
||
|
||
var affected = Assert.Single(merged.AffectedPackages);
|
||
Assert.Empty(affected.VersionRanges);
|
||
Assert.Contains(affected.Statuses, status => status.Status == "known_affected" && status.Provenance.Source == "vendor");
|
||
|
||
var mergeProvenance = Assert.Single(merged.Provenance, p => p.Source == "merge");
|
||
Assert.Equal("precedence", mergeProvenance.Kind);
|
||
Assert.Equal(expectedTimestamp, mergeProvenance.RecordedAt);
|
||
Assert.Contains("vendor", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||
Assert.Contains("kev", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
||
|
||
var inputDocumentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||
var record = await writer.AppendAsync(merged.AdvisoryKey, nvd, merged, inputDocumentIds, CancellationToken.None);
|
||
|
||
Assert.Equal(expectedTimestamp, record.MergedAt);
|
||
Assert.Equal(inputDocumentIds, record.InputDocumentIds);
|
||
Assert.NotEqual(record.BeforeHash, record.AfterHash);
|
||
|
||
var records = await store.GetRecentAsync(merged.AdvisoryKey, 5, CancellationToken.None);
|
||
var persisted = Assert.Single(records);
|
||
Assert.Equal(record.Id, persisted.Id);
|
||
Assert.Equal(merged.AdvisoryKey, persisted.AdvisoryKey);
|
||
Assert.True(persisted.AfterHash.Length > 0);
|
||
Assert.True(persisted.BeforeHash.Length > 0);
|
||
}
|
||
|
||
public async Task InitializeAsync()
|
||
{
|
||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero))
|
||
{
|
||
AutoAdvanceAmount = TimeSpan.Zero,
|
||
};
|
||
_merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), _timeProvider);
|
||
_mergeEventStore = new MergeEventStore(_fixture.Database, NullLogger<MergeEventStore>.Instance);
|
||
_mergeEventWriter = new MergeEventWriter(_mergeEventStore, new CanonicalHashCalculator(), _timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||
await DropMergeCollectionAsync();
|
||
}
|
||
|
||
public Task DisposeAsync() => Task.CompletedTask;
|
||
|
||
private async Task EnsureInitializedAsync()
|
||
{
|
||
if (_mergeEventWriter is null)
|
||
{
|
||
await InitializeAsync();
|
||
}
|
||
}
|
||
|
||
private async Task DropMergeCollectionAsync()
|
||
{
|
||
try
|
||
{
|
||
await _fixture.Database.DropCollectionAsync(MongoStorageDefaults.Collections.MergeEvent);
|
||
}
|
||
catch (MongoCommandException ex) when (ex.CodeName == "NamespaceNotFound" || ex.Message.Contains("ns not found", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
// Collection has not been created yet – safe to ignore.
|
||
}
|
||
}
|
||
|
||
private static Advisory CreateNvdBaseline()
|
||
{
|
||
var provenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", DateTimeOffset.Parse("2025-02-10T00:00:00Z"));
|
||
return new Advisory(
|
||
"CVE-2025-1000",
|
||
"CVE-2025-1000",
|
||
"Baseline description from NVD.",
|
||
"en",
|
||
DateTimeOffset.Parse("2025-02-05T00:00:00Z"),
|
||
DateTimeOffset.Parse("2025-02-10T12:00:00Z"),
|
||
"medium",
|
||
exploitKnown: false,
|
||
aliases: new[] { "CVE-2025-1000" },
|
||
references: new[]
|
||
{
|
||
new AdvisoryReference("https://nvd.nist.gov/vuln/detail/CVE-2025-1000", "advisory", "nvd", "NVD reference", provenance),
|
||
},
|
||
affectedPackages: new[]
|
||
{
|
||
new AffectedPackage(
|
||
AffectedPackageTypes.Cpe,
|
||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||
"vendor-os",
|
||
new[]
|
||
{
|
||
new AffectedVersionRange(
|
||
rangeKind: "cpe",
|
||
introducedVersion: null,
|
||
fixedVersion: null,
|
||
lastAffectedVersion: null,
|
||
rangeExpression: "<=1.0",
|
||
provenance: provenance)
|
||
},
|
||
Array.Empty<AffectedPackageStatus>(),
|
||
new[] { provenance })
|
||
},
|
||
cvssMetrics: new[]
|
||
{
|
||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", provenance)
|
||
},
|
||
provenance: new[] { provenance });
|
||
}
|
||
|
||
private static Advisory CreateVendorOverride()
|
||
{
|
||
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-1000", DateTimeOffset.Parse("2025-02-11T00:00:00Z"));
|
||
return new Advisory(
|
||
"CVE-2025-1000",
|
||
"Vendor Security Advisory",
|
||
"Critical impact on supported platforms.",
|
||
"en",
|
||
DateTimeOffset.Parse("2025-02-06T00:00:00Z"),
|
||
DateTimeOffset.Parse("2025-02-11T06:00:00Z"),
|
||
"critical",
|
||
exploitKnown: false,
|
||
aliases: new[] { "CVE-2025-1000", "VSA-2025-1000" },
|
||
references: new[]
|
||
{
|
||
new AdvisoryReference("https://vendor.example/advisories/VSA-2025-1000", "advisory", "vendor", "Vendor advisory", provenance),
|
||
},
|
||
affectedPackages: new[]
|
||
{
|
||
new AffectedPackage(
|
||
AffectedPackageTypes.Cpe,
|
||
"cpe:2.3:o:vendor:product:1.0:*:*:*:*:*:*:*",
|
||
"vendor-os",
|
||
Array.Empty<AffectedVersionRange>(),
|
||
new[]
|
||
{
|
||
new AffectedPackageStatus("known_affected", provenance)
|
||
},
|
||
new[] { provenance })
|
||
},
|
||
cvssMetrics: new[]
|
||
{
|
||
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 10.0, "critical", provenance)
|
||
},
|
||
provenance: new[] { provenance });
|
||
}
|
||
|
||
private static Advisory CreateKevSignal()
|
||
{
|
||
var provenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-1000", DateTimeOffset.Parse("2025-02-12T00:00:00Z"));
|
||
return new Advisory(
|
||
"CVE-2025-1000",
|
||
"Known Exploited Vulnerability",
|
||
null,
|
||
null,
|
||
published: null,
|
||
modified: null,
|
||
severity: null,
|
||
exploitKnown: true,
|
||
aliases: new[] { "KEV-CVE-2025-1000" },
|
||
references: Array.Empty<AdvisoryReference>(),
|
||
affectedPackages: Array.Empty<AffectedPackage>(),
|
||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||
provenance: new[] { provenance });
|
||
}
|
||
}
|