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,6 @@
{
"resultsPerPage": 1,
"startIndex": 0,
"totalResults": 1,
"vulnerabilities": "this-should-be-an-array"
}

View File

@@ -0,0 +1,69 @@
{
"resultsPerPage": 2,
"startIndex": 0,
"totalResults": 5,
"vulnerabilities": [
{
"cve": {
"id": "CVE-2024-1000",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-02-01T10:00:00Z",
"lastModified": "2024-02-02T10:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Multipage vulnerability one." }
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"baseScore": 9.8,
"baseSeverity": "CRITICAL"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_a:1.0:*:*:*:*:*:*:*" }
]
}
]
}
}
},
{
"cve": {
"id": "CVE-2024-1001",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-02-01T11:00:00Z",
"lastModified": "2024-02-02T11:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Multipage vulnerability two." }
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:P/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:L",
"baseScore": 5.1,
"baseSeverity": "MEDIUM"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_b:2.0:*:*:*:*:*:*:*" }
]
}
]
}
}
}
]
}

View File

@@ -0,0 +1,69 @@
{
"resultsPerPage": 2,
"startIndex": 2,
"totalResults": 5,
"vulnerabilities": [
{
"cve": {
"id": "CVE-2024-1002",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-02-01T12:00:00Z",
"lastModified": "2024-02-02T12:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Multipage vulnerability three." }
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N",
"baseScore": 3.1,
"baseSeverity": "LOW"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_c:3.0:*:*:*:*:*:*:*" }
]
}
]
}
}
},
{
"cve": {
"id": "CVE-2024-1003",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-02-01T13:00:00Z",
"lastModified": "2024-02-02T13:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Multipage vulnerability four." }
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:M/I:L/A:L",
"baseScore": 7.4,
"baseSeverity": "HIGH"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_d:4.0:*:*:*:*:*:*:*" }
]
}
]
}
}
}
]
}

View File

@@ -0,0 +1,38 @@
{
"resultsPerPage": 2,
"startIndex": 4,
"totalResults": 5,
"vulnerabilities": [
{
"cve": {
"id": "CVE-2024-1004",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-02-01T14:00:00Z",
"lastModified": "2024-02-02T14:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Multipage vulnerability five." }
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:L/I:H/A:L",
"baseScore": 7.9,
"baseSeverity": "HIGH"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_e:5.0:*:*:*:*:*:*:*" }
]
}
]
}
}
}
]
}

View File

@@ -0,0 +1,85 @@
{
"resultsPerPage": 2000,
"startIndex": 0,
"totalResults": 2,
"vulnerabilities": [
{
"cve": {
"id": "CVE-2024-0001",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-01-01T10:00:00Z",
"lastModified": "2024-01-02T10:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Example vulnerability one." }
],
"references": [
{
"url": "https://vendor.example.com/advisories/0001",
"source": "Vendor",
"tags": ["Vendor Advisory"]
}
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"baseScore": 9.8,
"baseSeverity": "CRITICAL"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_one:1.0:*:*:*:*:*:*:*" }
]
}
]
}
}
},
{
"cve": {
"id": "CVE-2024-0002",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-01-01T11:00:00Z",
"lastModified": "2024-01-02T11:00:00Z",
"descriptions": [
{ "lang": "fr", "value": "Description française" },
{ "lang": "en", "value": "Example vulnerability two." }
],
"references": [
{
"url": "https://cisa.example.gov/alerts/0002",
"source": "CISA",
"tags": ["US Government Resource"]
}
],
"metrics": {
"cvssMetricV30": [
{
"cvssData": {
"vectorString": "CVSS:3.0/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:L",
"baseScore": 4.6,
"baseSeverity": "MEDIUM"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_two:2.0:*:*:*:*:*:*:*" },
{ "vulnerable": false, "criteria": "cpe:2.3:a:example:product_two:2.1:*:*:*:*:*:*:*" }
]
}
]
}
}
}
]
}

View File

@@ -0,0 +1,45 @@
{
"resultsPerPage": 2000,
"startIndex": 0,
"totalResults": 1,
"vulnerabilities": [
{
"cve": {
"id": "CVE-2024-0003",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-01-01T12:00:00Z",
"lastModified": "2024-01-02T12:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Example vulnerability three." }
],
"references": [
{
"url": "https://example.org/patches/0003",
"source": "Vendor",
"tags": ["Patch"]
}
],
"metrics": {
"cvssMetricV2": [
{
"cvssData": {
"vectorString": "AV:N/AC:M/Au:N/C:P/I:P/A:P",
"baseScore": 6.8,
"baseSeverity": "MEDIUM"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_three:3.5:*:*:*:*:*:*:*" }
]
}
]
}
}
}
]
}

View File

@@ -0,0 +1,51 @@
{
"resultsPerPage": 2000,
"startIndex": 0,
"totalResults": 1,
"vulnerabilities": [
{
"cve": {
"id": "CVE-2024-0001",
"sourceIdentifier": "nvd@nist.gov",
"published": "2024-01-01T10:00:00Z",
"lastModified": "2024-01-03T12:00:00Z",
"descriptions": [
{ "lang": "en", "value": "Example vulnerability one updated." }
],
"references": [
{
"url": "https://vendor.example.com/advisories/0001",
"source": "Vendor",
"tags": ["Vendor Advisory"]
},
{
"url": "https://kb.example.com/articles/0001",
"source": "KnowledgeBase",
"tags": ["Third Party Advisory"]
}
],
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
"baseScore": 8.8,
"baseSeverity": "HIGH"
}
}
]
},
"configurations": {
"nodes": [
{
"cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_one:1.0:*:*:*:*:*:*:*" },
{ "vulnerable": true, "criteria": "cpe:2.3:a:example:product_one:1.1:*:*:*:*:*:*:*" }
]
}
]
}
}
}
]
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Bson;
using StellaOps.Feedser.Source.Common.Testing;
using StellaOps.Feedser.Source.Nvd;
using StellaOps.Feedser.Source.Nvd.Configuration;
using StellaOps.Feedser.Storage.Mongo;
using StellaOps.Feedser.Storage.Mongo.Advisories;
using StellaOps.Feedser.Storage.Mongo.Documents;
using StellaOps.Feedser.Testing;
using StellaOps.Feedser.Testing;
using System.Net;
namespace StellaOps.Feedser.Source.Nvd.Tests;
[Collection("mongo-fixture")]
public sealed class NvdConnectorHarnessTests : IAsyncLifetime
{
private readonly ConnectorTestHarness _harness;
public NvdConnectorHarnessTests(MongoIntegrationFixture fixture)
{
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), NvdOptions.HttpClientName);
}
[Fact]
public async Task FetchAsync_MultiPagePersistsStartIndexMetadata()
{
await _harness.ResetAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var timeProvider = _harness.TimeProvider;
var handler = _harness.Handler;
var windowStart = timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
var firstUri = BuildRequestUri(options, windowStart, windowEnd);
var secondUri = BuildRequestUri(options, windowStart, windowEnd, startIndex: 2);
var thirdUri = BuildRequestUri(options, windowStart, windowEnd, startIndex: 4);
handler.AddJsonResponse(firstUri, ReadFixture("nvd-multipage-1.json"));
handler.AddJsonResponse(secondUri, ReadFixture("nvd-multipage-2.json"));
handler.AddJsonResponse(thirdUri, ReadFixture("nvd-multipage-3.json"));
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddNvdConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.WindowSize = options.WindowSize;
opts.WindowOverlap = options.WindowOverlap;
opts.InitialBackfill = options.InitialBackfill;
});
});
var provider = _harness.ServiceProvider;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var firstDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, firstUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal("0", firstDocument!.Metadata["startIndex"]);
var secondDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, secondUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal("2", secondDocument!.Metadata["startIndex"]);
var thirdDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, thirdUri.ToString(), CancellationToken.None);
Assert.NotNull(thirdDocument);
Assert.Equal("4", thirdDocument!.Metadata["startIndex"]);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pending)
? pending.AsBsonArray
: new BsonArray();
Assert.Equal(3, pendingDocuments.Count);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => _harness.ResetAsync();
private static Uri BuildRequestUri(NvdOptions options, DateTimeOffset start, DateTimeOffset end, int startIndex = 0)
{
var builder = new UriBuilder(options.BaseEndpoint);
var parameters = new Dictionary<string, string>
{
["lastModifiedStartDate"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["lastModifiedEndDate"] = end.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["resultsPerPage"] = "2000",
};
if (startIndex > 0)
{
parameters["startIndex"] = startIndex.ToString(CultureInfo.InvariantCulture);
}
builder.Query = string.Join("&", parameters.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return builder.Uri;
}
private static string ReadFixture(string filename)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Nvd", "Fixtures", filename);
return File.ReadAllText(path);
}
}

View File

@@ -0,0 +1,607 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Bson;
using StellaOps.Feedser.Source.Common.Fetch;
using StellaOps.Feedser.Source.Common.Http;
using StellaOps.Feedser.Source.Common.Testing;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Nvd;
using StellaOps.Feedser.Source.Nvd.Configuration;
using StellaOps.Feedser.Source.Nvd.Internal;
using StellaOps.Feedser.Storage.Mongo;
using StellaOps.Feedser.Storage.Mongo.Advisories;
using StellaOps.Feedser.Storage.Mongo.Documents;
using StellaOps.Feedser.Storage.Mongo.Dtos;
using StellaOps.Feedser.Storage.Mongo.ChangeHistory;
using StellaOps.Feedser.Testing;
namespace StellaOps.Feedser.Source.Nvd.Tests;
[Collection("mongo-fixture")]
public sealed class NvdConnectorTests : IAsyncLifetime
{
private readonly MongoIntegrationFixture _fixture;
private FakeTimeProvider _timeProvider;
private readonly DateTimeOffset _initialNow;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public NvdConnectorTests(MongoIntegrationFixture fixture)
{
_fixture = fixture;
_initialNow = new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero);
_timeProvider = new FakeTimeProvider(_initialNow);
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_FlowProducesCanonicalAdvisories()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var window1Start = _timeProvider.GetUtcNow() - options.InitialBackfill;
var window1End = window1Start + options.WindowSize;
_handler.AddJsonResponse(BuildRequestUri(options, window1Start, window1End), ReadFixture("nvd-window-1.json"));
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "CVE-2024-0001");
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "CVE-2024-0002");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursorDocument = state!.Cursor;
Assert.NotNull(cursorDocument);
var lastWindowEnd = cursorDocument.TryGetValue("windowEnd", out var endValue) ? ReadDateTime(endValue) : (DateTimeOffset?)null;
Assert.Equal(window1End.UtcDateTime, lastWindowEnd?.UtcDateTime);
_timeProvider.Advance(TimeSpan.FromHours(1));
var now = _timeProvider.GetUtcNow();
var startCandidate = (lastWindowEnd ?? window1End) - options.WindowOverlap;
var backfillLimit = now - options.InitialBackfill;
var window2Start = startCandidate < backfillLimit ? backfillLimit : startCandidate;
var window2End = window2Start + options.WindowSize;
if (window2End > now)
{
window2End = now;
}
_handler.AddJsonResponse(BuildRequestUri(options, window2Start, window2End), ReadFixture("nvd-window-2.json"));
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(3, advisories.Count);
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "CVE-2024-0003");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var finalState = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(finalState);
var pendingDocuments = finalState!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs)
? pendingDocs.AsBsonArray
: new BsonArray();
Assert.Empty(pendingDocuments);
}
[Fact]
public async Task FetchAsync_MultiPageWindowFetchesAllPages()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var windowStart = _timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd), ReadFixture("nvd-multipage-1.json"));
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd, startIndex: 2), ReadFixture("nvd-multipage-2.json"));
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd, startIndex: 4), ReadFixture("nvd-multipage-3.json"));
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd, startIndex: 4), ReadFixture("nvd-multipage-3.json"));
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs)
? pendingDocs.AsBsonArray.Select(v => Guid.Parse(v.AsString)).ToArray()
: Array.Empty<Guid>();
Assert.Equal(3, pendingDocuments.Length);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var advisoryKeys = advisories.Select(advisory => advisory.AdvisoryKey).OrderBy(k => k).ToArray();
Assert.Equal(new[] { "CVE-2024-1000", "CVE-2024-1001", "CVE-2024-1002", "CVE-2024-1003", "CVE-2024-1004" }, advisoryKeys);
}
[Fact]
public async Task Observability_RecordsCountersForSuccessfulFlow()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
using var collector = new MetricCollector(NvdDiagnostics.MeterName);
var windowStart = _timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd), ReadFixture("nvd-multipage-1.json"));
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd, startIndex: 2), ReadFixture("nvd-multipage-2.json"));
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
Assert.Equal(3, collector.GetValue("nvd.fetch.attempts"));
Assert.Equal(3, collector.GetValue("nvd.fetch.documents"));
Assert.Equal(0, collector.GetValue("nvd.fetch.failures"));
Assert.Equal(0, collector.GetValue("nvd.fetch.unchanged"));
Assert.Equal(3, collector.GetValue("nvd.parse.success"));
Assert.Equal(0, collector.GetValue("nvd.parse.failures"));
Assert.Equal(0, collector.GetValue("nvd.parse.quarantine"));
Assert.Equal(5, collector.GetValue("nvd.map.success"));
}
[Fact]
public async Task ChangeHistory_RecordsDifferencesForModifiedCve()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var windowStart = _timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
_handler.AddJsonResponse(BuildRequestUri(options, windowStart, windowEnd), ReadFixture("nvd-window-1.json"));
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var historyStore = provider.GetRequiredService<IChangeHistoryStore>();
var historyEntries = await historyStore.GetRecentAsync("nvd", "CVE-2024-0001", 5, CancellationToken.None);
Assert.Empty(historyEntries);
_timeProvider.Advance(TimeSpan.FromHours(2));
var now = _timeProvider.GetUtcNow();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursorDocument = state!.Cursor;
var lastWindowEnd = cursorDocument.TryGetValue("windowEnd", out var endValue) ? ReadDateTime(endValue) : (DateTimeOffset?)null;
var startCandidate = (lastWindowEnd ?? windowEnd) - options.WindowOverlap;
var backfillLimit = now - options.InitialBackfill;
var window2Start = startCandidate < backfillLimit ? backfillLimit : startCandidate;
var window2End = window2Start + options.WindowSize;
if (window2End > now)
{
window2End = now;
}
_handler.AddJsonResponse(BuildRequestUri(options, window2Start, window2End), ReadFixture("nvd-window-update.json"));
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var updatedAdvisory = await advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.NotNull(updatedAdvisory);
Assert.Equal("high", updatedAdvisory!.Severity);
historyEntries = await historyStore.GetRecentAsync("nvd", "CVE-2024-0001", 5, CancellationToken.None);
Assert.NotEmpty(historyEntries);
var latest = historyEntries[0];
Assert.Equal("nvd", latest.SourceName);
Assert.Equal("CVE-2024-0001", latest.AdvisoryKey);
Assert.NotNull(latest.PreviousHash);
Assert.NotEqual(latest.PreviousHash, latest.CurrentHash);
Assert.Contains(latest.Changes, change => change.Field == "severity" && change.ChangeType == "Modified");
Assert.Contains(latest.Changes, change => change.Field == "references" && change.ChangeType == "Modified");
}
[Fact]
public async Task ParseAsync_InvalidSchema_QuarantinesDocumentAndEmitsMetric()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
using var collector = new MetricCollector(NvdDiagnostics.MeterName);
var windowStart = _timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
var requestUri = BuildRequestUri(options, windowStart, windowEnd);
_handler.AddJsonResponse(requestUri, ReadFixture("nvd-invalid-schema.json"));
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, requestUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Failed, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocs = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue) ? pendingDocsValue.AsBsonArray : new BsonArray();
Assert.Empty(pendingDocs);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue) ? pendingMappingsValue.AsBsonArray : new BsonArray();
Assert.Empty(pendingMappings);
Assert.Equal(1, collector.GetValue("nvd.fetch.documents"));
Assert.Equal(0, collector.GetValue("nvd.parse.success"));
Assert.Equal(1, collector.GetValue("nvd.parse.quarantine"));
Assert.Equal(0, collector.GetValue("nvd.map.success"));
}
[Fact]
public async Task ResetDatabase_IsolatesRuns()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var start = _timeProvider.GetUtcNow() - options.InitialBackfill;
var end = start + options.WindowSize;
_handler.AddJsonResponse(BuildRequestUri(options, start, end), ReadFixture("nvd-window-1.json"));
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var firstRunKeys = (await advisoryStore.GetRecentAsync(10, CancellationToken.None))
.Select(advisory => advisory.AdvisoryKey)
.OrderBy(k => k)
.ToArray();
Assert.Equal(new[] { "CVE-2024-0001", "CVE-2024-0002" }, firstRunKeys);
await ResetDatabaseAsync();
options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
start = _timeProvider.GetUtcNow() - options.InitialBackfill;
end = start + options.WindowSize;
_handler.AddJsonResponse(BuildRequestUri(options, start, end), ReadFixture("nvd-window-2.json"));
await EnsureServiceProviderAsync(options);
provider = _serviceProvider!;
connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var secondRunKeys = (await advisoryStore.GetRecentAsync(10, CancellationToken.None))
.Select(advisory => advisory.AdvisoryKey)
.OrderBy(k => k)
.ToArray();
Assert.Equal(new[] { "CVE-2024-0003" }, secondRunKeys);
}
private async Task EnsureServiceProviderAsync(NvdOptions options)
{
if (_serviceProvider is not null)
{
return;
}
_serviceProvider = await CreateServiceProviderAsync(options, _handler);
}
[Fact]
public async Task Resume_CompletesPendingDocumentsAfterRestart()
{
await ResetDatabaseAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var windowStart = _timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
var requestUri = BuildRequestUri(options, windowStart, windowEnd);
var fetchHandler = new CannedHttpMessageHandler();
fetchHandler.AddJsonResponse(requestUri, ReadFixture("nvd-window-1.json"));
Guid[] pendingDocumentIds;
await using (var fetchProvider = await CreateServiceProviderAsync(options, fetchHandler))
{
var connector = new NvdConnectorPlugin().Create(fetchProvider);
await connector.FetchAsync(fetchProvider, CancellationToken.None);
var stateRepository = fetchProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pending = state!.Cursor.TryGetValue("pendingDocuments", out var value)
? value.AsBsonArray
: new BsonArray();
Assert.NotEmpty(pending);
pendingDocumentIds = pending.Select(v => Guid.Parse(v.AsString)).ToArray();
}
var resumeHandler = new CannedHttpMessageHandler();
await using (var resumeProvider = await CreateServiceProviderAsync(options, resumeHandler))
{
var resumeConnector = new NvdConnectorPlugin().Create(resumeProvider);
await resumeConnector.ParseAsync(resumeProvider, CancellationToken.None);
await resumeConnector.MapAsync(resumeProvider, CancellationToken.None);
var documentStore = resumeProvider.GetRequiredService<IDocumentStore>();
foreach (var documentId in pendingDocumentIds)
{
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
}
var advisoryStore = resumeProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.NotEmpty(advisories);
var stateRepository = resumeProvider.GetRequiredService<ISourceStateRepository>();
var finalState = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(finalState);
var cursor = finalState!.Cursor;
var finalPendingDocs = cursor.TryGetValue("pendingDocuments", out var pendingDocs) ? pendingDocs.AsBsonArray : new BsonArray();
Assert.Empty(finalPendingDocs);
var finalPendingMappings = cursor.TryGetValue("pendingMappings", out var pendingMappings) ? pendingMappings.AsBsonArray : new BsonArray();
Assert.Empty(finalPendingMappings);
}
}
private Task ResetDatabaseAsync()
{
return ResetDatabaseInternalAsync();
}
private async Task<ServiceProvider> CreateServiceProviderAsync(NvdOptions options, CannedHttpMessageHandler handler)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(handler);
services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = _fixture.Runner.ConnectionString;
storageOptions.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(5);
});
services.AddSourceCommon();
services.AddNvdConnector(configure: opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.WindowSize = options.WindowSize;
opts.WindowOverlap = options.WindowOverlap;
opts.InitialBackfill = options.InitialBackfill;
});
services.Configure<HttpClientFactoryOptions>(NvdOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
var provider = services.BuildServiceProvider();
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
await bootstrapper.InitializeAsync(CancellationToken.None);
return provider;
}
private async Task ResetDatabaseInternalAsync()
{
if (_serviceProvider is not null)
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider.Dispose();
}
_serviceProvider = null;
}
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
_handler.Clear();
_timeProvider = new FakeTimeProvider(_initialNow);
}
private sealed class MetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly ConcurrentDictionary<string, long> _measurements = new(StringComparer.OrdinalIgnoreCase);
public MetricCollector(string meterName)
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == meterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
_measurements.AddOrUpdate(instrument.Name, measurement, (_, existing) => existing + measurement);
});
_listener.Start();
}
public long GetValue(string instrumentName)
=> _measurements.TryGetValue(instrumentName, out var value) ? value : 0;
public void Dispose()
{
_listener.Dispose();
}
}
private static Uri BuildRequestUri(NvdOptions options, DateTimeOffset start, DateTimeOffset end, int startIndex = 0)
{
var builder = new UriBuilder(options.BaseEndpoint);
var parameters = new Dictionary<string, string>
{
["lastModifiedStartDate"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["lastModifiedEndDate"] = end.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["resultsPerPage"] = "2000",
};
if (startIndex > 0)
{
parameters["startIndex"] = startIndex.ToString(CultureInfo.InvariantCulture);
}
builder.Query = string.Join("&", parameters.Select(static kvp => $"{System.Net.WebUtility.UrlEncode(kvp.Key)}={System.Net.WebUtility.UrlEncode(kvp.Value)}"));
return builder.Uri;
}
private static DateTimeOffset? ReadDateTime(BsonValue value)
{
return value.BsonType switch
{
BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null,
};
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Nvd", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var secondary = Path.Combine(baseDirectory, "Nvd", "Fixtures", filename);
if (File.Exists(secondary))
{
return File.ReadAllText(secondary);
}
throw new FileNotFoundException($"Fixture '{filename}' was not found in the test output directory.");
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await ResetDatabaseInternalAsync();
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Source.Common/StellaOps.Feedser.Source.Common.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Source.Nvd/StellaOps.Feedser.Source.Nvd.csproj" />
<ProjectReference Include="../StellaOps.Feedser.Storage.Mongo/StellaOps.Feedser.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Nvd/Fixtures/*.json" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>