Initial commit (history squashed)

This commit is contained in:
2025-10-07 10:14:21 +03:00
committed by Vladimir Moushkov
commit 6cbfd47ecd
621 changed files with 54480 additions and 0 deletions

View File

@@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Feedser.Merge.Options;
using StellaOps.Feedser.Merge.Services;
using StellaOps.Feedser.Models;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class AdvisoryPrecedenceMergerTests
{
[Fact]
public void Merge_PrefersVendorPrecedenceOverNvd()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
var expectedMergeTimestamp = timeProvider.GetUtcNow();
var merged = merger.Merge(new[] { nvd, redHat });
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
Assert.Equal("Red Hat Security Advisory", merged.Title);
Assert.Equal("Vendor-confirmed impact on RHEL 9.", merged.Summary);
Assert.Equal("high", merged.Severity);
Assert.Equal(redHat.Published, merged.Published);
Assert.Equal(redHat.Modified, merged.Modified);
Assert.Contains("RHSA-2025:0001", merged.Aliases);
Assert.Contains("CVE-2025-1000", merged.Aliases);
var package = Assert.Single(merged.AffectedPackages);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
Assert.Empty(package.VersionRanges); // NVD range suppressed by vendor precedence
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "redhat");
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "nvd");
var mergeProvenance = merged.Provenance.Single(p => p.Source == "merge");
Assert.Equal("precedence", mergeProvenance.Kind);
Assert.Equal(expectedMergeTimestamp, mergeProvenance.RecordedAt);
Assert.Contains("redhat", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
Assert.Contains("nvd", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Merge_KevOnlyTogglesExploitKnown()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero));
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd", timeProvider.GetUtcNow());
var baseAdvisory = new Advisory(
"CVE-2025-2000",
"CVE-2025-2000",
"Base registry summary",
"en",
new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 1, 6, 0, 0, 0, TimeSpan.Zero),
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-2000" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:a:example:product:2.0:*:*:*:*:*:*:*",
null,
new[]
{
new AffectedVersionRange(
"semver",
"2.0.0",
"2.0.5",
null,
"<2.0.5",
new AdvisoryProvenance("nvd", "cpe_match", "product", timeProvider.GetUtcNow()))
},
Array.Empty<AffectedPackageStatus>(),
new[] { nvdProvenance })
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { nvdProvenance });
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", timeProvider.GetUtcNow());
var kevAdvisory = new Advisory(
"CVE-2025-2000",
"Known Exploited Vulnerability",
summary: null,
language: null,
published: null,
modified: null,
severity: null,
exploitKnown: true,
aliases: new[] { "KEV-CVE-2025-2000" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[] { kevProvenance });
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory });
Assert.True(merged.ExploitKnown);
Assert.Equal("medium", merged.Severity); // KEV must not override severity
Assert.Equal("Base registry summary", merged.Summary);
Assert.Contains("CVE-2025-2000", merged.Aliases);
Assert.Contains("KEV-CVE-2025-2000", merged.Aliases);
Assert.Contains(merged.Provenance, provenance => provenance.Source == "kev");
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge");
}
[Fact]
public void Merge_RespectsConfiguredPrecedenceOverrides()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));
var options = new AdvisoryPrecedenceOptions
{
Ranks = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["nvd"] = 0,
["redhat"] = 5,
}
};
var logger = new TestLogger<AdvisoryPrecedenceMerger>();
using var metrics = new MetricCollector("StellaOps.Feedser.Merge");
var merger = new AdvisoryPrecedenceMerger(
new AffectedPackagePrecedenceResolver(),
options,
timeProvider,
logger);
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
var merged = merger.Merge(new[] { redHat, nvd });
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
Assert.Equal("CVE-2025-1000", merged.Title); // NVD preferred
Assert.Equal("NVD summary", merged.Summary);
Assert.Equal("medium", merged.Severity);
var package = Assert.Single(merged.AffectedPackages);
Assert.NotEmpty(package.VersionRanges); // Vendor range no longer overrides
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
var overrideMeasurement = Assert.Single(metrics.Measurements, m => m.Name == "feedser.merge.overrides");
Assert.Equal(1, overrideMeasurement.Value);
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "primary_source" && string.Equals(tag.Value?.ToString(), "nvd", StringComparison.OrdinalIgnoreCase));
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "suppressed_source" && tag.Value?.ToString()?.Contains("redhat", StringComparison.OrdinalIgnoreCase) == true);
var logEntry = Assert.Single(logger.Entries, entry => entry.EventId.Name == "AdvisoryOverride");
Assert.Equal(LogLevel.Information, logEntry.Level);
Assert.NotNull(logEntry.StructuredState);
Assert.Contains(logEntry.StructuredState!, kvp =>
(string.Equals(kvp.Key, "Override", StringComparison.Ordinal) ||
string.Equals(kvp.Key, "@Override", StringComparison.Ordinal)) &&
kvp.Value is not null);
}
private static (Advisory Vendor, Advisory Registry) CreateVendorAndRegistryAdvisories()
{
var redHatPublished = new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero);
var redHatModified = redHatPublished.AddDays(1);
var redHatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:0001", redHatModified);
var redHatPackage = new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
"rhel-9",
Array.Empty<AffectedVersionRange>(),
new[] { new AffectedPackageStatus("known_affected", redHatProvenance) },
new[] { redHatProvenance });
var redHat = new Advisory(
"CVE-2025-1000",
"Red Hat Security Advisory",
"Vendor-confirmed impact on RHEL 9.",
"en",
redHatPublished,
redHatModified,
"high",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000", "RHSA-2025:0001" },
references: new[]
{
new AdvisoryReference(
"https://access.redhat.com/errata/RHSA-2025:0001",
"advisory",
"redhat",
"Red Hat errata",
redHatProvenance)
},
affectedPackages: new[] { redHatPackage },
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",
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:0001", redHatModified))
},
provenance: new[] { redHatProvenance });
var nvdPublished = new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero);
var nvdModified = nvdPublished.AddDays(2);
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", nvdModified);
var nvdPackage = new AffectedPackage(
AffectedPackageTypes.Cpe,
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
"rhel-9",
new[]
{
new AffectedVersionRange(
"cpe",
null,
null,
null,
"<=9.0",
new AdvisoryProvenance("nvd", "cpe_match", "RHEL", nvdModified))
},
Array.Empty<AffectedPackageStatus>(),
new[] { nvdProvenance });
var nvd = new Advisory(
"CVE-2025-1000",
"CVE-2025-1000",
"NVD summary",
"en",
nvdPublished,
nvdModified,
"medium",
exploitKnown: false,
aliases: new[] { "CVE-2025-1000" },
references: new[]
{
new AdvisoryReference(
"https://nvd.nist.gov/vuln/detail/CVE-2025-1000",
"advisory",
"nvd",
"NVD advisory",
nvdProvenance)
},
affectedPackages: new[] { nvdPackage },
cvssMetrics: new[]
{
new CvssMetric(
"3.1",
"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
6.8,
"medium",
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", nvdModified))
},
provenance: new[] { nvdProvenance });
return (redHat, nvd);
}
}

View File

@@ -0,0 +1,88 @@
using System;
using StellaOps.Feedser.Merge.Services;
using StellaOps.Feedser.Models;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class AffectedPackagePrecedenceResolverTests
{
[Fact]
public void Merge_PrefersRedHatOverNvdForSameCpe()
{
var redHat = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
platform: "RHEL 9",
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: new[]
{
new AffectedPackageStatus(
status: "known_affected",
provenance: new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("redhat", "oval", "RHEL-9", DateTimeOffset.Parse("2025-10-01T00:00:00Z"))
});
var nvd = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
platform: "RHEL 9",
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "cpe",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: "<=9.0",
provenance: new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("nvd", "cpe_match", "RHEL-9", DateTimeOffset.Parse("2025-09-30T00:00:00Z"))
});
var resolver = new AffectedPackagePrecedenceResolver();
var merged = resolver.Merge(new[] { nvd, redHat });
var package = Assert.Single(merged);
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
Assert.Empty(package.VersionRanges); // NVD range overridden
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
}
[Fact]
public void Merge_KeepsNvdWhenNoHigherPrecedence()
{
var nvd = new AffectedPackage(
type: AffectedPackageTypes.Cpe,
identifier: "cpe:2.3:a:example:product:1.0:*:*:*:*:*:*:*",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.0.1",
lastAffectedVersion: null,
rangeExpression: "<1.0.1",
provenance: new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z")))
},
provenance: new[]
{
new AdvisoryProvenance("nvd", "cpe_match", "product", DateTimeOffset.Parse("2025-09-01T00:00:00Z"))
});
var resolver = new AffectedPackagePrecedenceResolver();
var merged = resolver.Merge(new[] { nvd });
var package = Assert.Single(merged);
Assert.Equal(nvd.Identifier, package.Identifier);
Assert.Equal(nvd.VersionRanges.Single().RangeExpression, package.VersionRanges.Single().RangeExpression);
Assert.Equal("nvd", package.Provenance.Single().Source);
}
}

View File

@@ -0,0 +1,86 @@
using System.Linq;
using StellaOps.Feedser.Merge.Services;
using StellaOps.Feedser.Models;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class CanonicalHashCalculatorTests
{
private static readonly Advisory SampleAdvisory = new(
advisoryKey: "CVE-2024-0001",
title: "Sample advisory",
summary: "A sample summary",
language: "EN",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
severity: "high",
exploitKnown: true,
aliases: new[] { "GHSA-xyz", "CVE-2024-0001" },
references: new[]
{
new AdvisoryReference("https://example.com/advisory", "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty),
new AdvisoryReference("https://example.com/blog", "article", "blog", summary: null, provenance: AdvisoryProvenance.Empty),
},
affectedPackages: new[]
{
new AffectedPackage(
type: AffectedPackageTypes.SemVer,
identifier: "pkg:npm/sample@1.0.0",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange("semver", "1.0.0", "1.2.0", null, null, AdvisoryProvenance.Empty),
new AffectedVersionRange("semver", "1.2.0", null, null, null, AdvisoryProvenance.Empty),
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { AdvisoryProvenance.Empty })
},
cvssMetrics: new[]
{
new CvssMetric("3.1", "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "critical", AdvisoryProvenance.Empty)
},
provenance: new[] { AdvisoryProvenance.Empty });
[Fact]
public void ComputeHash_ReturnsDeterministicValue()
{
var calculator = new CanonicalHashCalculator();
var first = calculator.ComputeHash(SampleAdvisory);
var second = calculator.ComputeHash(SampleAdvisory);
Assert.Equal(first, second);
}
[Fact]
public void ComputeHash_IgnoresOrderingDifferences()
{
var calculator = new CanonicalHashCalculator();
var reordered = new Advisory(
SampleAdvisory.AdvisoryKey,
SampleAdvisory.Title,
SampleAdvisory.Summary,
SampleAdvisory.Language,
SampleAdvisory.Published,
SampleAdvisory.Modified,
SampleAdvisory.Severity,
SampleAdvisory.ExploitKnown,
aliases: SampleAdvisory.Aliases.Reverse().ToArray(),
references: SampleAdvisory.References.Reverse().ToArray(),
affectedPackages: SampleAdvisory.AffectedPackages.Reverse().ToArray(),
cvssMetrics: SampleAdvisory.CvssMetrics.Reverse().ToArray(),
provenance: SampleAdvisory.Provenance.Reverse().ToArray());
var originalHash = calculator.ComputeHash(SampleAdvisory);
var reorderedHash = calculator.ComputeHash(reordered);
Assert.Equal(originalHash, reorderedHash);
}
[Fact]
public void ComputeHash_NullReturnsEmpty()
{
var calculator = new CanonicalHashCalculator();
Assert.Empty(calculator.ComputeHash(null));
}
}

View File

@@ -0,0 +1,84 @@
using StellaOps.Feedser.Merge.Comparers;
using StellaOps.Feedser.Normalization.Distro;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class DebianEvrComparerTests
{
[Theory]
[InlineData("1:1.2.3-1", 1, "1.2.3", "1")]
[InlineData("1.2.3-1", 0, "1.2.3", "1")]
[InlineData("2:4.5", 2, "4.5", "")]
[InlineData("abc", 0, "abc", "")]
public void TryParse_ReturnsComponents(string input, int expectedEpoch, string expectedVersion, string expectedRevision)
{
var success = DebianEvr.TryParse(input, out var evr);
Assert.True(success);
Assert.NotNull(evr);
Assert.Equal(expectedEpoch, evr!.Epoch);
Assert.Equal(expectedVersion, evr.Version);
Assert.Equal(expectedRevision, evr.Revision);
Assert.Equal(input, evr.Original);
}
[Theory]
[InlineData("")]
[InlineData(":1.0-1")]
[InlineData("1:")]
public void TryParse_InvalidInputs_ReturnFalse(string input)
{
var success = DebianEvr.TryParse(input, out var evr);
Assert.False(success);
Assert.Null(evr);
}
[Fact]
public void Compare_PrefersHigherEpoch()
{
var lower = "0:2.0-1";
var higher = "1:1.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_UsesVersionOrdering()
{
var lower = "0:1.2.3-1";
var higher = "0:1.10.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_TildeRanksEarlier()
{
var prerelease = "0:1.0~beta1-1";
var stable = "0:1.0-1";
Assert.True(DebianEvrComparer.Instance.Compare(prerelease, stable) < 0);
}
[Fact]
public void Compare_RevisionBreaksTies()
{
var first = "0:1.0-1";
var second = "0:1.0-2";
Assert.True(DebianEvrComparer.Instance.Compare(second, first) > 0);
}
[Fact]
public void Compare_FallsBackToOrdinalForInvalid()
{
var left = "not-an-evr";
var right = "also-not";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(DebianEvrComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
}

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Feedser.Merge.Services;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Storage.Mongo.MergeEvents;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class MergeEventWriterTests
{
[Fact]
public async Task AppendAsync_WritesRecordWithComputedHashes()
{
var store = new InMemoryMergeEventStore();
var calculator = new CanonicalHashCalculator();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
var before = CreateAdvisory("CVE-2024-0001", "Initial");
var after = CreateAdvisory("CVE-2024-0001", "Sample", summary: "Updated");
var documentIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
var record = await writer.AppendAsync("CVE-2024-0001", before, after, documentIds, CancellationToken.None);
Assert.NotEqual(Guid.Empty, record.Id);
Assert.Equal("CVE-2024-0001", record.AdvisoryKey);
Assert.True(record.AfterHash.Length > 0);
Assert.Equal(timeProvider.GetUtcNow(), record.MergedAt);
Assert.Equal(documentIds, record.InputDocumentIds);
Assert.NotNull(store.LastRecord);
Assert.Same(store.LastRecord, record);
}
[Fact]
public async Task AppendAsync_NullBeforeUsesEmptyHash()
{
var store = new InMemoryMergeEventStore();
var calculator = new CanonicalHashCalculator();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2024-05-01T00:00:00Z"));
var writer = new MergeEventWriter(store, calculator, timeProvider, NullLogger<MergeEventWriter>.Instance);
var after = CreateAdvisory("CVE-2024-0002", "Changed");
var record = await writer.AppendAsync("CVE-2024-0002", null, after, Array.Empty<Guid>(), CancellationToken.None);
Assert.Empty(record.BeforeHash);
Assert.True(record.AfterHash.Length > 0);
}
private static Advisory CreateAdvisory(string advisoryKey, string title, string? summary = null)
{
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z"),
severity: "medium",
exploitKnown: false,
aliases: new[] { advisoryKey },
references: new[]
{
new AdvisoryReference("https://example.com/" + advisoryKey.ToLowerInvariant(), "external", "vendor", summary: null, provenance: AdvisoryProvenance.Empty)
},
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
}
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)
=> Task.FromResult<IReadOnlyList<MergeEventRecord>>(Array.Empty<MergeEventRecord>());
}
}

View File

@@ -0,0 +1,211 @@
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 });
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
namespace StellaOps.Feedser.Merge.Tests;
internal sealed class MetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly List<MetricMeasurement> _measurements = new();
public MetricCollector(string meterName)
{
if (string.IsNullOrWhiteSpace(meterName))
{
throw new ArgumentException("Meter name is required", nameof(meterName));
}
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == meterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagArray = new KeyValuePair<string, object?>[tags.Length];
for (var i = 0; i < tags.Length; i++)
{
tagArray[i] = tags[i];
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagArray));
});
_listener.Start();
}
public IReadOnlyList<MetricMeasurement> Measurements => _measurements;
public void Dispose()
{
_listener.Dispose();
}
internal sealed record MetricMeasurement(
string Name,
long Value,
IReadOnlyList<KeyValuePair<string, object?>> Tags);
}

View File

@@ -0,0 +1,108 @@
using StellaOps.Feedser.Merge.Comparers;
using StellaOps.Feedser.Normalization.Distro;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class NevraComparerTests
{
[Theory]
[InlineData("kernel-1:4.18.0-348.7.1.el8_5.x86_64", "kernel", 1, "4.18.0", "348.7.1.el8_5", "x86_64")]
[InlineData("bash-5.1.8-2.fc35.x86_64", "bash", 0, "5.1.8", "2.fc35", "x86_64")]
[InlineData("openssl-libs-1:1.1.1k-7.el8", "openssl-libs", 1, "1.1.1k", "7.el8", null)]
[InlineData("java-11-openjdk-1:11.0.23.0.9-2.el9_4.ppc64le", "java-11-openjdk", 1, "11.0.23.0.9", "2.el9_4", "ppc64le")]
[InlineData("bash-0:5.2.15-3.el9_4.arm64", "bash", 0, "5.2.15", "3.el9_4", "arm64")]
[InlineData("podman-3:4.9.3-1.el9.x86_64", "podman", 3, "4.9.3", "1.el9", "x86_64")]
public void TryParse_ReturnsExpectedComponents(string input, string expectedName, int expectedEpoch, string expectedVersion, string expectedRelease, string? expectedArch)
{
var success = Nevra.TryParse(input, out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Equal(expectedName, nevra!.Name);
Assert.Equal(expectedEpoch, nevra.Epoch);
Assert.Equal(expectedVersion, nevra.Version);
Assert.Equal(expectedRelease, nevra.Release);
Assert.Equal(expectedArch, nevra.Architecture);
Assert.Equal(input, nevra.Original);
}
[Theory]
[InlineData("")]
[InlineData("kernel4.18.0-80.el8")]
[InlineData("kernel-4.18.0")]
public void TryParse_InvalidInputs_ReturnFalse(string input)
{
var success = Nevra.TryParse(input, out var nevra);
Assert.False(success);
Assert.Null(nevra);
}
[Fact]
public void TryParse_TrimsWhitespace()
{
var success = Nevra.TryParse(" kernel-0:4.18.0-80.el8.x86_64 ", out var nevra);
Assert.True(success);
Assert.NotNull(nevra);
Assert.Equal("kernel", nevra!.Name);
Assert.Equal("4.18.0", nevra.Version);
}
[Fact]
public void Compare_PrefersHigherEpoch()
{
var older = "kernel-0:4.18.0-348.7.1.el8_5.x86_64";
var newer = "kernel-1:4.18.0-348.7.1.el8_5.x86_64";
Assert.True(NevraComparer.Instance.Compare(newer, older) > 0);
Assert.True(NevraComparer.Instance.Compare(older, newer) < 0);
}
[Fact]
public void Compare_UsesRpmVersionOrdering()
{
var lower = "kernel-0:4.18.0-80.el8.x86_64";
var higher = "kernel-0:4.18.11-80.el8.x86_64";
Assert.True(NevraComparer.Instance.Compare(higher, lower) > 0);
}
[Fact]
public void Compare_UsesReleaseOrdering()
{
var el8 = "bash-0:5.1.0-1.el8.x86_64";
var el9 = "bash-0:5.1.0-1.el9.x86_64";
Assert.True(NevraComparer.Instance.Compare(el9, el8) > 0);
}
[Fact]
public void Compare_TildeRanksEarlier()
{
var prerelease = "bash-0:5.1.0~beta-1.fc34.x86_64";
var stable = "bash-0:5.1.0-1.fc34.x86_64";
Assert.True(NevraComparer.Instance.Compare(prerelease, stable) < 0);
}
[Fact]
public void Compare_ConsidersArchitecture()
{
var noarch = "pkg-0:1.0-1.noarch";
var arch = "pkg-0:1.0-1.x86_64";
Assert.True(NevraComparer.Instance.Compare(noarch, arch) < 0);
}
[Fact]
public void Compare_FallsBackToOrdinalForInvalid()
{
var left = "not-a-nevra";
var right = "also-not";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(NevraComparer.Instance.Compare(left, right));
Assert.Equal(expected, actual);
}
}

View File

@@ -0,0 +1,67 @@
using StellaOps.Feedser.Merge.Comparers;
namespace StellaOps.Feedser.Merge.Tests;
public sealed class SemanticVersionRangeResolverTests
{
[Theory]
[InlineData("1.2.3", true)]
[InlineData("1.2.3-beta.1", true)]
[InlineData("invalid", false)]
[InlineData(null, false)]
public void TryParse_ReturnsExpected(string? input, bool expected)
{
var success = SemanticVersionRangeResolver.TryParse(input, out var version);
Assert.Equal(expected, success);
Assert.Equal(expected, version is not null);
}
[Fact]
public void Compare_ParsesSemanticVersions()
{
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3", "1.2.2") > 0);
Assert.True(SemanticVersionRangeResolver.Compare("1.2.3-beta", "1.2.3") < 0);
}
[Fact]
public void Compare_UsesOrdinalFallbackForInvalid()
{
var left = "zzz";
var right = "aaa";
var expected = Math.Sign(string.CompareOrdinal(left, right));
var actual = Math.Sign(SemanticVersionRangeResolver.Compare(left, right));
Assert.Equal(expected, actual);
}
[Fact]
public void ResolveWindows_WithFixedVersion_ComputesExclusiveUpper()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", "1.2.0", null);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.2.0"), exclusive);
Assert.Null(inclusive);
}
[Fact]
public void ResolveWindows_WithLastAffectedOnly_ComputesInclusiveAndExclusive()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows("1.0.0", null, "1.1.5");
Assert.Equal(SemanticVersionRangeResolver.Parse("1.0.0"), introduced);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.6"), exclusive);
Assert.Equal(SemanticVersionRangeResolver.Parse("1.1.5"), inclusive);
}
[Fact]
public void ResolveWindows_WithNeither_ReturnsNullBounds()
{
var (introduced, exclusive, inclusive) = SemanticVersionRangeResolver.ResolveWindows(null, null, null);
Assert.Null(introduced);
Assert.Null(exclusive);
Assert.Null(inclusive);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Feedser.Merge/StellaOps.Feedser.Merge.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Normalization/StellaOps.Feedser.Normalization.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Storage.Mongo/StellaOps.Feedser.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace StellaOps.Feedser.Merge.Tests;
internal sealed class TestLogger<T> : ILogger<T>
{
private static readonly IDisposable NoopScope = new DisposableScope();
public List<LogEntry> Entries { get; } = new();
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
=> NoopScope;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (formatter is null)
{
throw new ArgumentNullException(nameof(formatter));
}
IReadOnlyList<KeyValuePair<string, object?>>? structuredState = null;
if (state is IReadOnlyList<KeyValuePair<string, object?>> list)
{
structuredState = list.ToArray();
}
else if (state is IEnumerable<KeyValuePair<string, object?>> enumerable)
{
structuredState = enumerable.ToArray();
}
Entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception), structuredState));
}
internal sealed record LogEntry(
LogLevel Level,
EventId EventId,
string Message,
IReadOnlyList<KeyValuePair<string, object?>>? StructuredState);
private sealed class DisposableScope : IDisposable
{
public void Dispose()
{
}
}
}