Initial commit (history squashed)
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / authority-container (push) Has been cancelled
				
			Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -0,0 +1,263 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Http; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Feedser.Source.CertCc; | ||||
| using StellaOps.Feedser.Source.CertCc.Configuration; | ||||
| using StellaOps.Feedser.Source.CertCc.Internal; | ||||
| using StellaOps.Feedser.Source.Common; | ||||
| using StellaOps.Feedser.Source.Common.Http; | ||||
| using StellaOps.Feedser.Source.Common.Cursors; | ||||
| using StellaOps.Feedser.Source.Common.Testing; | ||||
| using StellaOps.Feedser.Storage.Mongo; | ||||
| using StellaOps.Feedser.Storage.Mongo.Documents; | ||||
| using StellaOps.Feedser.Testing; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.CertCc.Tests.CertCc; | ||||
|  | ||||
| [Collection("mongo-fixture")] | ||||
| public sealed class CertCcConnectorFetchTests : IAsyncLifetime | ||||
| { | ||||
|     private const string TestNoteId = "294418"; | ||||
|  | ||||
|     private readonly MongoIntegrationFixture _fixture; | ||||
|     private readonly FakeTimeProvider _timeProvider; | ||||
|     private readonly CannedHttpMessageHandler _handler; | ||||
|     private ServiceProvider? _serviceProvider; | ||||
|  | ||||
|     public CertCcConnectorFetchTests(MongoIntegrationFixture fixture) | ||||
|     { | ||||
|         _fixture = fixture; | ||||
|         _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 8, 0, 0, TimeSpan.Zero)); | ||||
|         _handler = new CannedHttpMessageHandler(); | ||||
|     } | ||||
|  | ||||
|     [Fact(Skip = "Superseded by snapshot regression coverage (FEEDCONN-CERTCC-02-005).")] | ||||
|     public async Task FetchAsync_PersistsSummaryAndDetailDocumentsAndUpdatesCursor() | ||||
|     { | ||||
|         var template = new CertCcOptions | ||||
|         { | ||||
|             BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute), | ||||
|             SummaryWindow = new TimeWindowCursorOptions | ||||
|             { | ||||
|                 WindowSize = TimeSpan.FromDays(30), | ||||
|                 Overlap = TimeSpan.FromDays(5), | ||||
|                 InitialBackfill = TimeSpan.FromDays(60), | ||||
|                 MinimumWindowSize = TimeSpan.FromDays(1), | ||||
|             }, | ||||
|             MaxMonthlySummaries = 3, | ||||
|             MaxNotesPerFetch = 3, | ||||
|             DetailRequestDelay = TimeSpan.Zero, | ||||
|         }; | ||||
|  | ||||
|         await EnsureServiceProviderAsync(template); | ||||
|         var provider = _serviceProvider!; | ||||
|  | ||||
|         _handler.Clear(); | ||||
|  | ||||
|         var planner = provider.GetRequiredService<CertCcSummaryPlanner>(); | ||||
|         var plan = planner.CreatePlan(state: null); | ||||
|         Assert.NotEmpty(plan.Requests); | ||||
|  | ||||
|         foreach (var request in plan.Requests) | ||||
|         { | ||||
|             _handler.AddJsonResponse(request.Uri, BuildSummaryPayload()); | ||||
|         } | ||||
|  | ||||
|         RegisterDetailResponses(); | ||||
|  | ||||
|         var connector = provider.GetRequiredService<CertCcConnector>(); | ||||
|         await connector.FetchAsync(provider, CancellationToken.None); | ||||
|  | ||||
|         var documentStore = provider.GetRequiredService<IDocumentStore>(); | ||||
|         foreach (var request in plan.Requests) | ||||
|         { | ||||
|             var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, request.Uri.ToString(), CancellationToken.None); | ||||
|             Assert.NotNull(record); | ||||
|             Assert.Equal(DocumentStatuses.PendingParse, record!.Status); | ||||
|             Assert.NotNull(record.Metadata); | ||||
|             Assert.Equal(request.Scope.ToString().ToLowerInvariant(), record.Metadata!["certcc.scope"]); | ||||
|             Assert.Equal(request.Year.ToString("D4"), record.Metadata["certcc.year"]); | ||||
|             if (request.Month.HasValue) | ||||
|             { | ||||
|                 Assert.Equal(request.Month.Value.ToString("D2"), record.Metadata["certcc.month"]); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 Assert.False(record.Metadata.ContainsKey("certcc.month")); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var uri in EnumerateDetailUris()) | ||||
|         { | ||||
|             var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None); | ||||
|             Assert.NotNull(record); | ||||
|             Assert.Equal(DocumentStatuses.PendingParse, record!.Status); | ||||
|             Assert.NotNull(record.Metadata); | ||||
|             Assert.Equal(TestNoteId, record.Metadata!["certcc.noteId"]); | ||||
|         } | ||||
|  | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         Assert.NotNull(state); | ||||
|  | ||||
|         BsonValue summaryValue; | ||||
|         Assert.True(state!.Cursor.TryGetValue("summary", out summaryValue)); | ||||
|         var summaryDocument = Assert.IsType<BsonDocument>(summaryValue); | ||||
|         Assert.True(summaryDocument.TryGetValue("start", out _)); | ||||
|         Assert.True(summaryDocument.TryGetValue("end", out _)); | ||||
|  | ||||
|         var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue) | ||||
|             ? pendingNotesValue.AsBsonArray.Count | ||||
|             : 0; | ||||
|         Assert.Equal(0, pendingNotesCount); | ||||
|  | ||||
|         var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue) | ||||
|             ? pendingSummariesValue.AsBsonArray.Count | ||||
|             : 0; | ||||
|         Assert.Equal(0, pendingSummariesCount); | ||||
|  | ||||
|         Assert.True(state.Cursor.TryGetValue("lastRun", out _)); | ||||
|  | ||||
|         Assert.True(_handler.Requests.Count >= plan.Requests.Count); | ||||
|         foreach (var request in _handler.Requests) | ||||
|         { | ||||
|             if (request.Headers.TryGetValue("Accept", out var accept)) | ||||
|             { | ||||
|                 Assert.Contains("application/json", accept, StringComparison.OrdinalIgnoreCase); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string BuildSummaryPayload() | ||||
|     { | ||||
|         return $$""" | ||||
|         { | ||||
|             "count": 1, | ||||
|             "notes": [ | ||||
|                 "VU#{TestNoteId}" | ||||
|             ] | ||||
|         } | ||||
|         """; | ||||
|     } | ||||
|  | ||||
|     private void RegisterDetailResponses() | ||||
|     { | ||||
|         foreach (var uri in EnumerateDetailUris()) | ||||
|         { | ||||
|             var fixtureName = uri.AbsolutePath.EndsWith("/vendors/", StringComparison.OrdinalIgnoreCase) | ||||
|                 ? "vu-294418-vendors.json" | ||||
|                 : uri.AbsolutePath.EndsWith("/vuls/", StringComparison.OrdinalIgnoreCase) | ||||
|                     ? "vu-294418-vuls.json" | ||||
|                     : "vu-294418.json"; | ||||
|  | ||||
|             _handler.AddJsonResponse(uri, ReadFixture(fixtureName)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IEnumerable<Uri> EnumerateDetailUris() | ||||
|     { | ||||
|         var baseUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute); | ||||
|         yield return new Uri(baseUri, $"{TestNoteId}/"); | ||||
|         yield return new Uri(baseUri, $"{TestNoteId}/vendors/"); | ||||
|         yield return new Uri(baseUri, $"{TestNoteId}/vuls/"); | ||||
|     } | ||||
|  | ||||
|     private async Task EnsureServiceProviderAsync(CertCcOptions template) | ||||
|     { | ||||
|         await DisposeServiceProviderAsync(); | ||||
|         await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); | ||||
|  | ||||
|         var services = new ServiceCollection(); | ||||
|         services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); | ||||
|         services.AddSingleton<TimeProvider>(_timeProvider); | ||||
|         services.AddSingleton(_handler); | ||||
|  | ||||
|         services.AddMongoStorage(options => | ||||
|         { | ||||
|             options.ConnectionString = _fixture.Runner.ConnectionString; | ||||
|             options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; | ||||
|             options.CommandTimeout = TimeSpan.FromSeconds(5); | ||||
|             options.RawDocumentRetention = TimeSpan.Zero; | ||||
|             options.RawDocumentRetentionTtlGrace = TimeSpan.FromMinutes(5); | ||||
|             options.RawDocumentRetentionSweepInterval = TimeSpan.FromHours(1); | ||||
|         }); | ||||
|  | ||||
|         services.AddSourceCommon(); | ||||
|         services.AddCertCcConnector(options => | ||||
|         { | ||||
|             options.BaseApiUri = template.BaseApiUri; | ||||
|             options.SummaryWindow = new TimeWindowCursorOptions | ||||
|             { | ||||
|                 WindowSize = template.SummaryWindow.WindowSize, | ||||
|                 Overlap = template.SummaryWindow.Overlap, | ||||
|                 InitialBackfill = template.SummaryWindow.InitialBackfill, | ||||
|                 MinimumWindowSize = template.SummaryWindow.MinimumWindowSize, | ||||
|             }; | ||||
|             options.MaxMonthlySummaries = template.MaxMonthlySummaries; | ||||
|             options.MaxNotesPerFetch = template.MaxNotesPerFetch; | ||||
|             options.DetailRequestDelay = template.DetailRequestDelay; | ||||
|             options.EnableDetailMapping = template.EnableDetailMapping; | ||||
|         }); | ||||
|  | ||||
|         services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions => | ||||
|         { | ||||
|             builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler); | ||||
|         }); | ||||
|  | ||||
|         _serviceProvider = services.BuildServiceProvider(); | ||||
|         var bootstrapper = _serviceProvider.GetRequiredService<MongoBootstrapper>(); | ||||
|         await bootstrapper.InitializeAsync(CancellationToken.None); | ||||
|     } | ||||
|  | ||||
|     private async Task DisposeServiceProviderAsync() | ||||
|     { | ||||
|         if (_serviceProvider is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (_serviceProvider is IAsyncDisposable asyncDisposable) | ||||
|         { | ||||
|             await asyncDisposable.DisposeAsync(); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             _serviceProvider.Dispose(); | ||||
|         } | ||||
|  | ||||
|         _serviceProvider = null; | ||||
|     } | ||||
|  | ||||
|     private static string ReadFixture(string filename) | ||||
|     { | ||||
|         var baseDirectory = AppContext.BaseDirectory; | ||||
|         var primary = Path.Combine(baseDirectory, "Fixtures", filename); | ||||
|         if (File.Exists(primary)) | ||||
|         { | ||||
|             return File.ReadAllText(primary); | ||||
|         } | ||||
|  | ||||
|         return File.ReadAllText(Path.Combine(baseDirectory, filename)); | ||||
|     } | ||||
|  | ||||
|     public Task InitializeAsync() | ||||
|     { | ||||
|         _handler.Clear(); | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     public async Task DisposeAsync() | ||||
|     { | ||||
|         await DisposeServiceProviderAsync(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,405 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Http; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Feedser.Models; | ||||
| using StellaOps.Feedser.Source.CertCc; | ||||
| using StellaOps.Feedser.Source.CertCc.Configuration; | ||||
| using StellaOps.Feedser.Source.Common; | ||||
| using StellaOps.Feedser.Source.Common.Cursors; | ||||
| using StellaOps.Feedser.Source.Common.Http; | ||||
| using StellaOps.Feedser.Source.Common.Testing; | ||||
| using StellaOps.Feedser.Storage.Mongo; | ||||
| using StellaOps.Feedser.Storage.Mongo.Documents; | ||||
| using StellaOps.Feedser.Storage.Mongo.Advisories; | ||||
| using StellaOps.Feedser.Testing; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.CertCc.Tests.CertCc; | ||||
|  | ||||
| [Collection("mongo-fixture")] | ||||
| public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime | ||||
| { | ||||
|     private static readonly Uri SeptemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/09/summary/"); | ||||
|     private static readonly Uri OctoberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/"); | ||||
|     private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/"); | ||||
|     private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/"); | ||||
|     private static readonly Uri VendorsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/"); | ||||
|     private static readonly Uri VulsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/"); | ||||
|     private static readonly Uri VendorStatusesDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/"); | ||||
|  | ||||
|     private readonly MongoIntegrationFixture _fixture; | ||||
|     private ConnectorTestHarness? _harness; | ||||
|  | ||||
|     public CertCcConnectorSnapshotTests(MongoIntegrationFixture fixture) | ||||
|     { | ||||
|         _fixture = fixture; | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task FetchSummaryAndDetails_ProducesDeterministicSnapshots() | ||||
|     { | ||||
|         var initialTime = new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero); | ||||
|         var harness = await EnsureHarnessAsync(initialTime); | ||||
|  | ||||
|         RegisterSummaryResponses(harness.Handler); | ||||
|         RegisterDetailResponses(harness.Handler); | ||||
|  | ||||
|         var connector = harness.ServiceProvider.GetRequiredService<CertCcConnector>(); | ||||
|         await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None); | ||||
|  | ||||
|         var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider); | ||||
|         WriteOrAssertSnapshot(documentsSnapshot, "certcc-documents.snapshot.json"); | ||||
|  | ||||
|         var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider); | ||||
|         WriteOrAssertSnapshot(stateSnapshot, "certcc-state.snapshot.json"); | ||||
|  | ||||
|         await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None); | ||||
|         await connector.MapAsync(harness.ServiceProvider, CancellationToken.None); | ||||
|  | ||||
|         var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider); | ||||
|         WriteOrAssertSnapshot(advisoriesSnapshot, "certcc-advisories.snapshot.json"); | ||||
|  | ||||
|         harness.TimeProvider.Advance(TimeSpan.FromMinutes(30)); | ||||
|         RegisterSummaryNotModifiedResponses(harness.Handler); | ||||
|         RegisterDetailNotModifiedResponses(harness.Handler); | ||||
|  | ||||
|         await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None); | ||||
|         harness.Handler.AssertNoPendingResponses(); | ||||
|  | ||||
|         var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests); | ||||
|         WriteOrAssertSnapshot(requestsSnapshot, "certcc-requests.snapshot.json"); | ||||
|     } | ||||
|  | ||||
|     private async Task<ConnectorTestHarness> EnsureHarnessAsync(DateTimeOffset initialTime) | ||||
|     { | ||||
|         if (_harness is not null) | ||||
|         { | ||||
|             return _harness; | ||||
|         } | ||||
|  | ||||
|         var harness = new ConnectorTestHarness(_fixture, initialTime, CertCcOptions.HttpClientName); | ||||
|         await harness.EnsureServiceProviderAsync(services => | ||||
|         { | ||||
|             services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); | ||||
|             services.AddCertCcConnector(options => | ||||
|             { | ||||
|                 options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute); | ||||
|                 options.SummaryWindow = new TimeWindowCursorOptions | ||||
|                 { | ||||
|                     WindowSize = TimeSpan.FromDays(30), | ||||
|                     Overlap = TimeSpan.FromDays(3), | ||||
|                     InitialBackfill = TimeSpan.FromDays(45), | ||||
|                     MinimumWindowSize = TimeSpan.FromDays(1), | ||||
|                 }; | ||||
|                 options.MaxMonthlySummaries = 2; | ||||
|                 options.MaxNotesPerFetch = 1; | ||||
|                 options.DetailRequestDelay = TimeSpan.Zero; | ||||
|                 options.EnableDetailMapping = true; | ||||
|             }); | ||||
|  | ||||
|             services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, options => | ||||
|             { | ||||
|                 options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         _harness = harness; | ||||
|         return harness; | ||||
|     } | ||||
|  | ||||
|     private static async Task<string> BuildDocumentsSnapshotAsync(IServiceProvider provider) | ||||
|     { | ||||
|         var documentStore = provider.GetRequiredService<IDocumentStore>(); | ||||
|         var uris = new[] | ||||
|         { | ||||
|             SeptemberSummaryUri, | ||||
|             OctoberSummaryUri, | ||||
|             new Uri("https://www.kb.cert.org/vuls/api/2025/11/summary/"), | ||||
|             YearlySummaryUri, | ||||
|             NoteDetailUri, | ||||
|             VendorsDetailUri, | ||||
|             VulsDetailUri, | ||||
|             VendorStatusesDetailUri, | ||||
|         }; | ||||
|  | ||||
|         var records = new List<object>(uris.Length); | ||||
|         foreach (var uri in uris) | ||||
|         { | ||||
|             var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None); | ||||
|             if (record is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var lastModified = record.Headers is not null | ||||
|                 && record.Headers.TryGetValue("Last-Modified", out var lastModifiedHeader) | ||||
|                 && DateTimeOffset.TryParse(lastModifiedHeader, out var parsedLastModified) | ||||
|                     ? parsedLastModified.ToUniversalTime().ToString("O") | ||||
|                     : record.LastModified?.ToUniversalTime().ToString("O"); | ||||
|  | ||||
|             records.Add(new | ||||
|             { | ||||
|                 record.Uri, | ||||
|                 record.Status, | ||||
|                 record.Sha256, | ||||
|                 record.ContentType, | ||||
|                 LastModified = lastModified, | ||||
|                 Metadata = record.Metadata is null | ||||
|                     ? null | ||||
|                     : record.Metadata | ||||
|                         .OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase) | ||||
|                         .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase), | ||||
|                 record.Etag, | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         var ordered = records | ||||
|             .OrderBy(static entry => entry.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return SnapshotSerializer.ToSnapshot(ordered); | ||||
|     } | ||||
|  | ||||
|     private static async Task<string> BuildStateSnapshotAsync(IServiceProvider provider) | ||||
|     { | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         Assert.NotNull(state); | ||||
|  | ||||
|         var cursor = state!.Cursor ?? new BsonDocument(); | ||||
|  | ||||
|         BsonDocument? summaryDocument = null; | ||||
|         if (cursor.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDoc) | ||||
|         { | ||||
|             summaryDocument = summaryDoc; | ||||
|         } | ||||
|  | ||||
|         var summary = summaryDocument is null | ||||
|             ? null | ||||
|             : new | ||||
|             { | ||||
|                 Start = summaryDocument.TryGetValue("start", out var startValue) ? ToIsoString(startValue) : null, | ||||
|                 End = summaryDocument.TryGetValue("end", out var endValue) ? ToIsoString(endValue) : null, | ||||
|             }; | ||||
|  | ||||
|         var snapshot = new | ||||
|         { | ||||
|             Summary = summary, | ||||
|             PendingNotes = cursor.TryGetValue("pendingNotes", out var pendingNotesValue) | ||||
|                 ? pendingNotesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static note => note, StringComparer.OrdinalIgnoreCase).ToArray() | ||||
|                 : Array.Empty<string>(), | ||||
|             PendingSummaries = cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue) | ||||
|                 ? pendingSummariesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray() | ||||
|                 : Array.Empty<string>(), | ||||
|             LastRun = cursor.TryGetValue("lastRun", out var lastRunValue) ? ToIsoString(lastRunValue) : null, | ||||
|             state.LastSuccess, | ||||
|             state.LastFailure, | ||||
|             state.FailCount, | ||||
|             state.BackoffUntil, | ||||
|         }; | ||||
|  | ||||
|         return SnapshotSerializer.ToSnapshot(snapshot); | ||||
|     } | ||||
|  | ||||
|     private static async Task<string> BuildAdvisoriesSnapshotAsync(IServiceProvider provider) | ||||
|     { | ||||
|         var advisoryStore = provider.GetRequiredService<IAdvisoryStore>(); | ||||
|         var advisories = new List<Advisory>(); | ||||
|         await foreach (var advisory in advisoryStore.StreamAsync(CancellationToken.None)) | ||||
|         { | ||||
|             advisories.Add(advisory); | ||||
|         } | ||||
|  | ||||
|         var ordered = advisories | ||||
|             .OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return SnapshotSerializer.ToSnapshot(ordered); | ||||
|     } | ||||
|  | ||||
|     private static string BuildRequestsSnapshot(IReadOnlyCollection<CannedHttpMessageHandler.CannedRequestRecord> requests) | ||||
|     { | ||||
|         var ordered = requests | ||||
|             .OrderBy(static request => request.Timestamp) | ||||
|             .Select(static request => new | ||||
|             { | ||||
|                 request.Method.Method, | ||||
|                 Uri = request.Uri.ToString(), | ||||
|                 Headers = new | ||||
|                 { | ||||
|                     Accept = TryGetHeader(request.Headers, "Accept"), | ||||
|                     IfNoneMatch = TryGetHeader(request.Headers, "If-None-Match"), | ||||
|                     IfModifiedSince = TryGetHeader(request.Headers, "If-Modified-Since"), | ||||
|                 }, | ||||
|             }) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return SnapshotSerializer.ToSnapshot(ordered); | ||||
|     } | ||||
|  | ||||
|     private static void RegisterSummaryResponses(CannedHttpMessageHandler handler) | ||||
|     { | ||||
|         AddJsonResponse(handler, SeptemberSummaryUri, "summary-2025-09.json", "\"certcc-summary-2025-09\"", new DateTimeOffset(2025, 9, 30, 12, 0, 0, TimeSpan.Zero)); | ||||
|         AddJsonResponse(handler, OctoberSummaryUri, "summary-2025-10.json", "\"certcc-summary-2025-10\"", new DateTimeOffset(2025, 10, 31, 12, 0, 0, TimeSpan.Zero)); | ||||
|         var novemberUri = new Uri("https://www.kb.cert.org/vuls/api/2025/11/summary/"); | ||||
|         AddJsonResponse(handler, novemberUri, "summary-2025-11.json", "\"certcc-summary-2025-11\"", new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero)); | ||||
|         AddJsonResponse(handler, YearlySummaryUri, "summary-2025.json", "\"certcc-summary-2025\"", new DateTimeOffset(2025, 10, 31, 12, 1, 0, TimeSpan.Zero)); | ||||
|     } | ||||
|  | ||||
|     private static void RegisterSummaryNotModifiedResponses(CannedHttpMessageHandler handler) | ||||
|     { | ||||
|         AddNotModified(handler, SeptemberSummaryUri, "\"certcc-summary-2025-09\""); | ||||
|         AddNotModified(handler, OctoberSummaryUri, "\"certcc-summary-2025-10\""); | ||||
|         var novemberUri = new Uri("https://www.kb.cert.org/vuls/api/2025/11/summary/"); | ||||
|         AddNotModified(handler, novemberUri, "\"certcc-summary-2025-11\""); | ||||
|         AddNotModified(handler, YearlySummaryUri, "\"certcc-summary-2025\""); | ||||
|     } | ||||
|  | ||||
|     private static void RegisterDetailResponses(CannedHttpMessageHandler handler) | ||||
|     { | ||||
|         AddJsonResponse(handler, NoteDetailUri, "vu-294418.json", "\"certcc-note-294418\"", new DateTimeOffset(2025, 10, 9, 16, 52, 0, TimeSpan.Zero)); | ||||
|         AddJsonResponse(handler, VendorsDetailUri, "vu-294418-vendors.json", "\"certcc-vendors-294418\"", new DateTimeOffset(2025, 10, 9, 17, 5, 0, TimeSpan.Zero)); | ||||
|         AddJsonResponse(handler, VulsDetailUri, "vu-294418-vuls.json", "\"certcc-vuls-294418\"", new DateTimeOffset(2025, 10, 9, 17, 10, 0, TimeSpan.Zero)); | ||||
|         AddJsonResponse(handler, VendorStatusesDetailUri, "vendor-statuses-294418.json", "\"certcc-vendor-statuses-294418\"", new DateTimeOffset(2025, 10, 9, 17, 12, 0, TimeSpan.Zero)); | ||||
|     } | ||||
|  | ||||
|     private static void RegisterDetailNotModifiedResponses(CannedHttpMessageHandler handler) | ||||
|     { | ||||
|         AddNotModified(handler, NoteDetailUri, "\"certcc-note-294418\""); | ||||
|         AddNotModified(handler, VendorsDetailUri, "\"certcc-vendors-294418\""); | ||||
|         AddNotModified(handler, VulsDetailUri, "\"certcc-vuls-294418\""); | ||||
|         AddNotModified(handler, VendorStatusesDetailUri, "\"certcc-vendor-statuses-294418\""); | ||||
|     } | ||||
|  | ||||
|     private static void AddJsonResponse(CannedHttpMessageHandler handler, Uri uri, string fixtureName, string etag, DateTimeOffset lastModified) | ||||
|     { | ||||
|         var payload = ReadFixture(fixtureName); | ||||
|         handler.AddResponse(HttpMethod.Get, uri, _ => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new StringContent(payload, Encoding.UTF8, "application/json"), | ||||
|             }; | ||||
|             response.Headers.ETag = new EntityTagHeaderValue(etag); | ||||
|             response.Headers.TryAddWithoutValidation("Last-Modified", lastModified.ToString("R")); | ||||
|             response.Content.Headers.LastModified = lastModified; | ||||
|             return response; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private static void AddNotModified(CannedHttpMessageHandler handler, Uri uri, string etag) | ||||
|     { | ||||
|         handler.AddResponse(HttpMethod.Get, uri, _ => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.NotModified); | ||||
|             response.Headers.ETag = new EntityTagHeaderValue(etag); | ||||
|             return response; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private static string ReadFixture(string filename) | ||||
|     { | ||||
|         var baseDir = AppContext.BaseDirectory; | ||||
|         var primary = Path.Combine(baseDir, "Fixtures", filename); | ||||
|         if (File.Exists(primary)) | ||||
|         { | ||||
|             return File.ReadAllText(primary); | ||||
|         } | ||||
|  | ||||
|         var fallback = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename); | ||||
|         if (File.Exists(fallback)) | ||||
|         { | ||||
|             return File.ReadAllText(fallback); | ||||
|         } | ||||
|  | ||||
|         throw new FileNotFoundException($"Missing CERT/CC fixture '{filename}'."); | ||||
|     } | ||||
|  | ||||
|     private static string? TryGetHeader(IReadOnlyDictionary<string, string> headers, string key) | ||||
|         => headers.TryGetValue(key, out var value) ? value : null; | ||||
|  | ||||
|     private static string? ToIsoString(BsonValue value) | ||||
|     { | ||||
|         return value.BsonType switch | ||||
|         { | ||||
|             BsonType.DateTime => value.ToUniversalTime().ToString("O"), | ||||
|             BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime().ToString("O"), | ||||
|             _ => null, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static void WriteOrAssertSnapshot(string snapshot, string filename) | ||||
|     { | ||||
|         var normalizedSnapshot = Normalize(snapshot); | ||||
|         if (ShouldUpdateFixtures() || !FixtureExists(filename)) | ||||
|         { | ||||
|             var path = GetWritablePath(filename); | ||||
|             Directory.CreateDirectory(Path.GetDirectoryName(path)!); | ||||
|             File.WriteAllText(path, normalizedSnapshot); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var expected = ReadFixture(filename); | ||||
|         var normalizedExpected = Normalize(expected); | ||||
|         if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal)) | ||||
|         { | ||||
|             var actualPath = Path.Combine(Path.GetDirectoryName(GetWritablePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json"); | ||||
|             Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!); | ||||
|             File.WriteAllText(actualPath, normalizedSnapshot); | ||||
|         } | ||||
|  | ||||
|         Assert.Equal(normalizedExpected, normalizedSnapshot); | ||||
|     } | ||||
|  | ||||
|     private static string GetWritablePath(string filename) | ||||
|     { | ||||
|         var baseDir = AppContext.BaseDirectory; | ||||
|         return Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename); | ||||
|     } | ||||
|  | ||||
|     private static string Normalize(string value) | ||||
|         => value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd(); | ||||
|  | ||||
|     private static bool ShouldUpdateFixtures() | ||||
|     { | ||||
|         var flag = Environment.GetEnvironmentVariable("UPDATE_CERTCC_FIXTURES"); | ||||
|         return string.Equals(flag, "1", StringComparison.Ordinal) || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     private static bool FixtureExists(string filename) | ||||
|     { | ||||
|         var baseDir = AppContext.BaseDirectory; | ||||
|         var primary = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename); | ||||
|         if (File.Exists(primary)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         var fallback = Path.Combine(baseDir, "Fixtures", filename); | ||||
|         return File.Exists(fallback); | ||||
|     } | ||||
|  | ||||
|     public Task InitializeAsync() => Task.CompletedTask; | ||||
|  | ||||
|     public async Task DisposeAsync() | ||||
|     { | ||||
|         if (_harness is not null) | ||||
|         { | ||||
|             await _harness.DisposeAsync(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,404 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using FluentAssertions; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Http; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Time.Testing; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Feedser.Source.CertCc; | ||||
| using StellaOps.Feedser.Source.CertCc.Configuration; | ||||
| using StellaOps.Feedser.Source.Common; | ||||
| using StellaOps.Feedser.Source.Common.Cursors; | ||||
| using StellaOps.Feedser.Source.Common.Http; | ||||
| using StellaOps.Feedser.Source.Common.Testing; | ||||
| using StellaOps.Feedser.Storage.Mongo; | ||||
| using StellaOps.Feedser.Storage.Mongo.Documents; | ||||
| using StellaOps.Feedser.Storage.Mongo.Advisories; | ||||
| using StellaOps.Feedser.Testing; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.CertCc.Tests.CertCc; | ||||
|  | ||||
| [Collection("mongo-fixture")] | ||||
| public sealed class CertCcConnectorTests : IAsyncLifetime | ||||
| { | ||||
|     private static readonly Uri MonthlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/"); | ||||
|     private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/"); | ||||
|     private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/"); | ||||
|     private static readonly Uri VendorsUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/"); | ||||
|     private static readonly Uri VulsUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/"); | ||||
|     private static readonly Uri VendorStatusesUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/"); | ||||
|  | ||||
|     private readonly MongoIntegrationFixture _fixture; | ||||
|     private readonly FakeTimeProvider _timeProvider; | ||||
|     private readonly CannedHttpMessageHandler _handler; | ||||
|  | ||||
|     public CertCcConnectorTests(MongoIntegrationFixture fixture) | ||||
|     { | ||||
|         _fixture = fixture; | ||||
|         _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 9, 30, 0, TimeSpan.Zero)); | ||||
|         _handler = new CannedHttpMessageHandler(); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task FetchParseMap_ProducesCanonicalAdvisory() | ||||
|     { | ||||
|         await using var provider = await BuildServiceProviderAsync(); | ||||
|         SeedSummaryResponses(); | ||||
|         SeedDetailResponses(); | ||||
|  | ||||
|         var connector = provider.GetRequiredService<CertCcConnector>(); | ||||
|         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); | ||||
|         advisories.Should().NotBeNull(); | ||||
|         advisories.Should().HaveCountGreaterThan(0); | ||||
|  | ||||
|         var advisory = advisories.FirstOrDefault(a => a.AdvisoryKey == "certcc/vu-294418"); | ||||
|         advisory.Should().NotBeNull(); | ||||
|         advisory!.Title.Should().ContainEquivalentOf("DrayOS"); | ||||
|         advisory.Summary.Should().NotBeNullOrWhiteSpace(); | ||||
|         advisory.Aliases.Should().Contain("VU#294418"); | ||||
|         advisory.Aliases.Should().Contain("CVE-2025-10547"); | ||||
|         advisory.AffectedPackages.Should().NotBeNull(); | ||||
|         advisory.AffectedPackages.Should().HaveCountGreaterThan(0); | ||||
|         advisory.AffectedPackages![0].NormalizedVersions.Should().NotBeNull(); | ||||
|  | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         state.Should().NotBeNull(); | ||||
|         var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue) | ||||
|             ? pendingDocsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingDocuments.Should().Be(0); | ||||
|         var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue) | ||||
|             ? pendingMappingsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingMappings.Should().Be(0); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Fetch_PersistsSummaryAndDetailDocuments() | ||||
|     { | ||||
|         await using var provider = await BuildServiceProviderAsync(); | ||||
|         SeedSummaryResponses(); | ||||
|         SeedDetailResponses(); | ||||
|  | ||||
|         var connector = provider.GetRequiredService<CertCcConnector>(); | ||||
|         await connector.FetchAsync(provider, CancellationToken.None); | ||||
|  | ||||
|         var documentStore = provider.GetRequiredService<IDocumentStore>(); | ||||
|  | ||||
|         var summaryDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, MonthlySummaryUri.ToString(), CancellationToken.None); | ||||
|         summaryDocument.Should().NotBeNull(); | ||||
|         summaryDocument!.Status.Should().Be(DocumentStatuses.PendingParse); | ||||
|  | ||||
|         var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None); | ||||
|         noteDocument.Should().NotBeNull(); | ||||
|         noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse); | ||||
|         noteDocument.Metadata.Should().NotBeNull(); | ||||
|         noteDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("note"); | ||||
|         noteDocument.Metadata.Should().ContainKey("certcc.noteId").WhoseValue.Should().Be("294418"); | ||||
|         noteDocument.Metadata.Should().ContainKey("certcc.vuid").WhoseValue.Should().Be("VU#294418"); | ||||
|  | ||||
|         var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None); | ||||
|         vendorsDocument.Should().NotBeNull(); | ||||
|         vendorsDocument!.Status.Should().Be(DocumentStatuses.PendingParse); | ||||
|         vendorsDocument.Metadata.Should().NotBeNull(); | ||||
|         vendorsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors"); | ||||
|  | ||||
|         var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None); | ||||
|         vulsDocument.Should().NotBeNull(); | ||||
|         vulsDocument!.Status.Should().Be(DocumentStatuses.PendingParse); | ||||
|         vulsDocument.Metadata.Should().NotBeNull(); | ||||
|         vulsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vuls"); | ||||
|  | ||||
|         var vendorStatusesDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorStatusesUri.ToString(), CancellationToken.None); | ||||
|         vendorStatusesDocument.Should().NotBeNull(); | ||||
|         vendorStatusesDocument!.Status.Should().Be(DocumentStatuses.PendingParse); | ||||
|         vendorStatusesDocument.Metadata.Should().NotBeNull(); | ||||
|         vendorStatusesDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors-vuls"); | ||||
|  | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         state.Should().NotBeNull(); | ||||
|         state!.Cursor.Should().NotBeNull(); | ||||
|         var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue) | ||||
|             ? pendingNotesValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingNotesCount.Should().Be(0); | ||||
|         var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue) | ||||
|             ? pendingSummariesValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingSummariesCount.Should().Be(0); | ||||
|  | ||||
|         var pendingDocumentsCount = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue) | ||||
|             ? pendingDocumentsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingDocumentsCount.Should().Be(4); | ||||
|  | ||||
|         var pendingMappingsCount = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue) | ||||
|             ? pendingMappingsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingMappingsCount.Should().Be(0); | ||||
|  | ||||
|         _handler.Requests.Should().Contain(request => request.Uri == NoteDetailUri); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Fetch_ReusesConditionalRequestsOnSubsequentRun() | ||||
|     { | ||||
|         await using var provider = await BuildServiceProviderAsync(); | ||||
|         SeedSummaryResponses(summaryEtag: "\"summary-oct\"", yearlyEtag: "\"summary-year\""); | ||||
|         SeedDetailResponses(detailEtag: "\"note-etag\"", vendorsEtag: "\"vendors-etag\"", vulsEtag: "\"vuls-etag\"", vendorStatusesEtag: "\"vendor-statuses-etag\""); | ||||
|  | ||||
|         var connector = provider.GetRequiredService<CertCcConnector>(); | ||||
|         await connector.FetchAsync(provider, CancellationToken.None); | ||||
|  | ||||
|         _handler.Clear(); | ||||
|         SeedSummaryNotModifiedResponses("\"summary-oct\"", "\"summary-year\""); | ||||
|         SeedDetailNotModifiedResponses("\"note-etag\"", "\"vendors-etag\"", "\"vuls-etag\"", "\"vendor-statuses-etag\""); | ||||
|         _timeProvider.Advance(TimeSpan.FromMinutes(15)); | ||||
|  | ||||
|         await connector.FetchAsync(provider, CancellationToken.None); | ||||
|  | ||||
|         var requests = _handler.Requests.ToArray(); | ||||
|         requests.Should().OnlyContain(r => | ||||
|             r.Uri == MonthlySummaryUri | ||||
|             || r.Uri == YearlySummaryUri | ||||
|             || r.Uri == NoteDetailUri | ||||
|             || r.Uri == VendorsUri | ||||
|             || r.Uri == VulsUri | ||||
|             || r.Uri == VendorStatusesUri); | ||||
|  | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         state.Should().NotBeNull(); | ||||
|         var pendingNotesCount = state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue) | ||||
|             ? pendingNotesValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingNotesCount.Should().Be(0); | ||||
|         var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue) | ||||
|             ? pendingSummariesValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingSummaries.Should().Be(0); | ||||
|  | ||||
|         var pendingDocuments = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue) | ||||
|             ? pendingDocumentsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingDocuments.Should().BeGreaterThan(0); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task Fetch_DetailFailureRecordsBackoffAndKeepsPendingNote() | ||||
|     { | ||||
|         await using var provider = await BuildServiceProviderAsync(); | ||||
|         SeedSummaryResponses(); | ||||
|         SeedDetailResponses(vendorsStatus: HttpStatusCode.InternalServerError); | ||||
|  | ||||
|         var connector = provider.GetRequiredService<CertCcConnector>(); | ||||
|         var failure = await Assert.ThrowsAnyAsync<Exception>(() => connector.FetchAsync(provider, CancellationToken.None)); | ||||
|         Assert.True(failure is HttpRequestException || failure is InvalidOperationException); | ||||
|  | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         state.Should().NotBeNull(); | ||||
|         state!.FailCount.Should().BeGreaterThan(0); | ||||
|         state.BackoffUntil.Should().NotBeNull(); | ||||
|         state.BackoffUntil.Should().BeAfter(_timeProvider.GetUtcNow()); | ||||
|         state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue(); | ||||
|         pendingNotesValue!.AsBsonArray.Should().Contain(value => value.AsString == "294418"); | ||||
|         var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue) | ||||
|             ? pendingSummariesValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingSummaries.Should().Be(0); | ||||
|     } | ||||
|  | ||||
|     public Task InitializeAsync() => Task.CompletedTask; | ||||
|  | ||||
|     public async Task DisposeAsync() | ||||
|     { | ||||
|         await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ParseAndMap_SkipWhenDetailMappingDisabled() | ||||
|     { | ||||
|         await using var provider = await BuildServiceProviderAsync(enableDetailMapping: false); | ||||
|         SeedSummaryResponses(); | ||||
|         SeedDetailResponses(); | ||||
|  | ||||
|         var connector = provider.GetRequiredService<CertCcConnector>(); | ||||
|         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); | ||||
|         advisories.Should().BeNullOrEmpty(); | ||||
|  | ||||
|         var documentStore = provider.GetRequiredService<IDocumentStore>(); | ||||
|         var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None); | ||||
|         noteDocument.Should().NotBeNull(); | ||||
|         noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse); | ||||
|  | ||||
|         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||
|         var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None); | ||||
|         state.Should().NotBeNull(); | ||||
|         var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue) | ||||
|             ? pendingDocsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingDocuments.Should().BeGreaterThan(0); | ||||
|         var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue) | ||||
|             ? pendingMappingsValue!.AsBsonArray.Count | ||||
|             : 0; | ||||
|         pendingMappings.Should().Be(0); | ||||
|     } | ||||
|  | ||||
|     private async Task<ServiceProvider> BuildServiceProviderAsync(bool enableDetailMapping = true) | ||||
|     { | ||||
|         await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); | ||||
|         _handler.Clear(); | ||||
|  | ||||
|         var services = new ServiceCollection(); | ||||
|         services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); | ||||
|         services.AddSingleton<TimeProvider>(_timeProvider); | ||||
|         services.AddSingleton(_handler); | ||||
|  | ||||
|         services.AddMongoStorage(options => | ||||
|         { | ||||
|             options.ConnectionString = _fixture.Runner.ConnectionString; | ||||
|             options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; | ||||
|             options.CommandTimeout = TimeSpan.FromSeconds(5); | ||||
|         }); | ||||
|  | ||||
|         services.AddSourceCommon(); | ||||
|         services.AddCertCcConnector(options => | ||||
|         { | ||||
|             options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/"); | ||||
|             options.SummaryWindow = new TimeWindowCursorOptions | ||||
|             { | ||||
|                 WindowSize = TimeSpan.FromDays(1), | ||||
|                 Overlap = TimeSpan.Zero, | ||||
|                 InitialBackfill = TimeSpan.FromDays(1), | ||||
|                 MinimumWindowSize = TimeSpan.FromHours(6), | ||||
|             }; | ||||
|             options.MaxMonthlySummaries = 1; | ||||
|             options.MaxNotesPerFetch = 5; | ||||
|             options.DetailRequestDelay = TimeSpan.Zero; | ||||
|             options.EnableDetailMapping = enableDetailMapping; | ||||
|         }); | ||||
|  | ||||
|         services.Configure<HttpClientFactoryOptions>(CertCcOptions.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 void SeedSummaryResponses(string summaryEtag = "\"summary-oct\"", string yearlyEtag = "\"summary-year\"") | ||||
|     { | ||||
|         AddJsonResponse(MonthlySummaryUri, ReadFixture("summary-2025-10.json"), summaryEtag); | ||||
|         AddJsonResponse(YearlySummaryUri, ReadFixture("summary-2025.json"), yearlyEtag); | ||||
|     } | ||||
|  | ||||
|     private void SeedSummaryNotModifiedResponses(string summaryEtag, string yearlyEtag) | ||||
|     { | ||||
|         AddNotModifiedResponse(MonthlySummaryUri, summaryEtag); | ||||
|         AddNotModifiedResponse(YearlySummaryUri, yearlyEtag); | ||||
|     } | ||||
|  | ||||
|     private void SeedDetailResponses( | ||||
|         string detailEtag = "\"note-etag\"", | ||||
|         string vendorsEtag = "\"vendors-etag\"", | ||||
|         string vulsEtag = "\"vuls-etag\"", | ||||
|         string vendorStatusesEtag = "\"vendor-statuses-etag\"", | ||||
|         HttpStatusCode vendorsStatus = HttpStatusCode.OK) | ||||
|     { | ||||
|         AddJsonResponse(NoteDetailUri, ReadFixture("vu-294418.json"), detailEtag); | ||||
|  | ||||
|         if (vendorsStatus == HttpStatusCode.OK) | ||||
|         { | ||||
|             AddJsonResponse(VendorsUri, ReadFixture("vu-294418-vendors.json"), vendorsEtag); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             _handler.AddResponse(VendorsUri, () => | ||||
|             { | ||||
|                 var response = new HttpResponseMessage(vendorsStatus) | ||||
|                 { | ||||
|                     Content = new StringContent("vendors error", Encoding.UTF8, "text/plain"), | ||||
|                 }; | ||||
|                 response.Headers.ETag = new EntityTagHeaderValue(vendorsEtag); | ||||
|                 return response; | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         AddJsonResponse(VulsUri, ReadFixture("vu-294418-vuls.json"), vulsEtag); | ||||
|         AddJsonResponse(VendorStatusesUri, ReadFixture("vendor-statuses-294418.json"), vendorStatusesEtag); | ||||
|     } | ||||
|  | ||||
|     private void SeedDetailNotModifiedResponses(string detailEtag, string vendorsEtag, string vulsEtag, string vendorStatusesEtag) | ||||
|     { | ||||
|         AddNotModifiedResponse(NoteDetailUri, detailEtag); | ||||
|         AddNotModifiedResponse(VendorsUri, vendorsEtag); | ||||
|         AddNotModifiedResponse(VulsUri, vulsEtag); | ||||
|         AddNotModifiedResponse(VendorStatusesUri, vendorStatusesEtag); | ||||
|     } | ||||
|  | ||||
|     private void AddJsonResponse(Uri uri, string json, string etag) | ||||
|     { | ||||
|         _handler.AddResponse(uri, () => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||
|             { | ||||
|                 Content = new StringContent(json, Encoding.UTF8, "application/json"), | ||||
|             }; | ||||
|             response.Headers.ETag = new EntityTagHeaderValue(etag); | ||||
|             return response; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private void AddNotModifiedResponse(Uri uri, string etag) | ||||
|     { | ||||
|         _handler.AddResponse(uri, () => | ||||
|         { | ||||
|             var response = new HttpResponseMessage(HttpStatusCode.NotModified); | ||||
|             response.Headers.ETag = new EntityTagHeaderValue(etag); | ||||
|             return response; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private static string ReadFixture(string filename) | ||||
|     { | ||||
|         var baseDirectory = AppContext.BaseDirectory; | ||||
|         var candidate = Path.Combine(baseDirectory, "Source", "CertCc", "Fixtures", filename); | ||||
|         if (File.Exists(candidate)) | ||||
|         { | ||||
|             return File.ReadAllText(candidate); | ||||
|         } | ||||
|  | ||||
|         var fallback = Path.Combine(baseDirectory, "Fixtures", filename); | ||||
|         return File.ReadAllText(fallback); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,91 @@ | ||||
| [ | ||||
|   { | ||||
|     "contentType": "application/json; charset=utf-8", | ||||
|     "etag": "\"certcc-summary-2025-09\"", | ||||
|     "lastModified": "2025-09-30T12:00:00.0000000+00:00", | ||||
|     "metadata": { | ||||
|       "attempts": "1", | ||||
|       "certcc.month": "09", | ||||
|       "certcc.scope": "monthly", | ||||
|       "certcc.year": "2025", | ||||
|       "fetchedAt": "2025-11-01T08:00:00.0000000+00:00" | ||||
|     }, | ||||
|     "sha256": "75e6271ec1a0a3a099d13aa23393d8ddf5fa9638a06748a84538f9b8891ea792", | ||||
|     "status": "pending-parse", | ||||
|     "uri": "https://www.kb.cert.org/vuls/api/2025/09/summary/" | ||||
|   }, | ||||
|   { | ||||
|     "contentType": "application/json; charset=utf-8", | ||||
|     "etag": "\"certcc-summary-2025-10\"", | ||||
|     "lastModified": "2025-10-31T12:00:00.0000000+00:00", | ||||
|     "metadata": { | ||||
|       "attempts": "1", | ||||
|       "certcc.month": "10", | ||||
|       "certcc.scope": "monthly", | ||||
|       "certcc.year": "2025", | ||||
|       "fetchedAt": "2025-11-01T08:00:00.0000000+00:00" | ||||
|     }, | ||||
|     "sha256": "75e6271ec1a0a3a099d13aa23393d8ddf5fa9638a06748a84538f9b8891ea792", | ||||
|     "status": "pending-parse", | ||||
|     "uri": "https://www.kb.cert.org/vuls/api/2025/10/summary/" | ||||
|   }, | ||||
|   { | ||||
|     "contentType": "application/json; charset=utf-8", | ||||
|     "etag": "\"certcc-summary-2025\"", | ||||
|     "lastModified": "2025-10-31T12:01:00.0000000+00:00", | ||||
|     "metadata": { | ||||
|       "attempts": "1", | ||||
|       "certcc.scope": "yearly", | ||||
|       "certcc.year": "2025", | ||||
|       "fetchedAt": "2025-11-01T08:00:00.0000000+00:00" | ||||
|     }, | ||||
|     "sha256": "75e6271ec1a0a3a099d13aa23393d8ddf5fa9638a06748a84538f9b8891ea792", | ||||
|     "status": "pending-parse", | ||||
|     "uri": "https://www.kb.cert.org/vuls/api/2025/summary/" | ||||
|   }, | ||||
|   { | ||||
|     "contentType": "application/json; charset=utf-8", | ||||
|     "etag": "\"certcc-note-294418\"", | ||||
|     "lastModified": "2025-10-09T16:52:00.0000000+00:00", | ||||
|     "metadata": { | ||||
|       "attempts": "1", | ||||
|       "certcc.endpoint": "note", | ||||
|       "certcc.noteId": "294418", | ||||
|       "certcc.vuid": "VU#294418", | ||||
|       "fetchedAt": "2025-11-01T08:00:00.0000000+00:00" | ||||
|     }, | ||||
|     "sha256": "fac27a3caa47fe319a2eb2a88145452daebaf3c8c6b5afe62cf9634b0824c003", | ||||
|     "status": "pending-parse", | ||||
|     "uri": "https://www.kb.cert.org/vuls/api/294418/" | ||||
|   }, | ||||
|   { | ||||
|     "contentType": "application/json; charset=utf-8", | ||||
|     "etag": "\"certcc-vendors-294418\"", | ||||
|     "lastModified": "2025-10-09T17:05:00.0000000+00:00", | ||||
|     "metadata": { | ||||
|       "attempts": "1", | ||||
|       "certcc.endpoint": "vendors", | ||||
|       "certcc.noteId": "294418", | ||||
|       "certcc.vuid": "VU#294418", | ||||
|       "fetchedAt": "2025-11-01T08:00:00.0000000+00:00" | ||||
|     }, | ||||
|     "sha256": "34036a3cdda490adf8ad26d74ac8ae3b85d591579e7dd26b1fd2f78fe5e401b8", | ||||
|     "status": "pending-parse", | ||||
|     "uri": "https://www.kb.cert.org/vuls/api/294418/vendors/" | ||||
|   }, | ||||
|   { | ||||
|     "contentType": "application/json; charset=utf-8", | ||||
|     "etag": "\"certcc-vuls-294418\"", | ||||
|     "lastModified": "2025-10-09T17:10:00.0000000+00:00", | ||||
|     "metadata": { | ||||
|       "attempts": "1", | ||||
|       "certcc.endpoint": "vuls", | ||||
|       "certcc.noteId": "294418", | ||||
|       "certcc.vuid": "VU#294418", | ||||
|       "fetchedAt": "2025-11-01T08:00:00.0000000+00:00" | ||||
|     }, | ||||
|     "sha256": "84c7c17fc37bffdee37cd020b43ec6cadc217a573135ba1c5cc2f0495846030a", | ||||
|     "status": "pending-parse", | ||||
|     "uri": "https://www.kb.cert.org/vuls/api/294418/vuls/" | ||||
|   } | ||||
| ] | ||||
| @@ -0,0 +1,13 @@ | ||||
| { | ||||
|   "backoffUntil": null, | ||||
|   "failCount": 0, | ||||
|   "lastFailure": null, | ||||
|   "lastRun": "2025-11-01T08:00:00.0000000Z", | ||||
|   "lastSuccess": "2025-11-01T08:00:00+00:00", | ||||
|   "pendingNotes": [], | ||||
|   "pendingSummaries": [], | ||||
|   "summary": { | ||||
|     "end": "2025-10-17T08:00:00.0000000Z", | ||||
|     "start": "2025-09-17T08:00:00.0000000Z" | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,4 @@ | ||||
| { | ||||
|     "count": 0, | ||||
|     "notes": [] | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| { | ||||
|     "count": 1, | ||||
|     "notes": [ | ||||
|         "VU#294418" | ||||
|     ] | ||||
| } | ||||
| @@ -0,0 +1,4 @@ | ||||
| { | ||||
|     "count": 0, | ||||
|     "notes": [] | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| { | ||||
|     "count": 1, | ||||
|     "notes": [ | ||||
|         "VU#294418" | ||||
|     ] | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| [ | ||||
|     { | ||||
|         "vul": "CVE-2025-10547", | ||||
|         "vendor": "DrayTek Corporation", | ||||
|         "status": "Affected", | ||||
|         "date_added": "2025-10-03T11:35:31.202991Z", | ||||
|         "dateupdated": "2025-10-03T11:40:09.944401Z", | ||||
|         "references": null, | ||||
|         "statement": null | ||||
|     } | ||||
| ] | ||||
| @@ -0,0 +1,12 @@ | ||||
| [ | ||||
|     { | ||||
|         "note": "294418", | ||||
|         "contact_date": "2025-09-15T19:03:33.664345Z", | ||||
|         "vendor": "DrayTek Corporation", | ||||
|         "references": "", | ||||
|         "statement": "The issue is confirmed, and here is the patch list\r\n\r\nV3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\r\nV2927/V2865/V2866\t4.5.1\r\nV2765/V2766/V2763/V2135\t4.5.1\r\nV2915\t4.4.6.1\r\nV2862/V2926\t3.9.9.12\r\nV2952/3220\t3.9.8.8\r\nV2860/V2925\t3.9.8.6\r\nV2133/V2762/V2832\t3.9.9.4\r\nV2620/LTE200\t3.9.9.5", | ||||
|         "dateupdated": "2025-10-03T11:35:31.190661Z", | ||||
|         "statement_date": "2025-09-16T02:27:51.346335Z", | ||||
|         "addendum": null | ||||
|     } | ||||
| ] | ||||
| @@ -0,0 +1,87 @@ | ||||
| { | ||||
|     "vuid": "VU#257161", | ||||
|     "idnumber": "257161", | ||||
|     "name": "Treck IP stacks contain multiple vulnerabilities", | ||||
|     "keywords": null, | ||||
|     "overview": "### Overview\r\nTreck IP stack implementations for embedded systems are affected by multiple vulnerabilities. This set of vulnerabilities was researched and reported by JSOF, who calls them [Ripple20](https://www.jsof-tech.com/ripple20/).\r\n\r\n### Description\r\nTreck IP network stack software is designed for and used in a variety of embedded systems. The software can be licensed and integrated in various ways, including compiled from source, licensed for modification and reuse and finally as a dynamic or static linked library. Treck IP software contains multiple vulnerabilities, most of which are caused by [memory management bugs](https://wiki.sei.cmu.edu/confluence/pages/viewpage.action?pageId=87152142). For more details on the vulnerabilities introduced by these bugs, see Treck's [ Vulnerability Response Information](https://treck.com/vulnerability-response-information/) and JSOF's  [Ripple20 advisory](https://www.jsof-tech.com/ripple20/).\r\n\r\nHistorically-related KASAGO TCP/IP middleware from Zuken Elmic (formerly Elmic Systems) is also affected by some of these vulnerabilities. \r\n\r\nThese vulnerabilities likely affect industrial control systems and medical devices. Please see ICS-CERT Advisory [ICSA-20-168-01](https://www.us-cert.gov/ics/advisories/icsa-20-168-01) for more information.\r\n\r\n### Impact ###\r\nThe impact of these vulnerabilities will vary due to the combination of build and runtime options used while developing different embedded systems. This diversity of implementations and the lack of supply chain visibility has  exasperated the problem of accurately assessing the impact of these vulnerabilities.  In summary, a remote, unauthenticated attacker may be able to use specially-crafted network packets to cause a denial of service, disclose information, or execute arbitrary code.\r\n\r\n### Solution\r\n#### Apply updates\r\nUpdate to the latest stable version of Treck IP stack software (6.0.1.67 or later). Please contact Treck at <security@treck.com>. Downstream users of embedded systems that incorporate Treck IP stacks should contact their embedded system vendor.\r\n\r\n#### Block anomalous IP traffic\r\nConsider blocking network attacks via deep packet inspection. In some cases, modern switches, routers, and firewalls will drop malformed packets with no additional configuration. It is recommended that such security features are not disabled. Below is a list of possible mitigations that can be applied as appropriate to your network environment.\r\n\r\n* Normalize or reject IP fragmented packets (IP Fragments) if not supported in your environment \r\n* Disable or block IP tunneling, both IPv6-in-IPv4 or IP-in-IP tunneling if not required\r\n* Block IP source routing and any IPv6 deprecated features like routing headers (see also [VU#267289](https://www.kb.cert.org/vuls/id/267289))\r\n* Enforce TCP inspection and reject malformed TCP packets \r\n* Block unused ICMP control messages such MTU Update and Address Mask updates\r\n* Normalize DNS through a secure recursive server or application layer firewall\r\n* Ensure that you are using reliable OSI layer 2 equipment (Ethernet)\r\n* Provide DHCP/DHCPv6 security with feature like DHCP snooping\r\n* Disable or block IPv6 multicast if not used in switching infrastructure\r\n\r\nFurther recommendations are available [here](https://github.com/CERTCC/PoC-Exploits/blob/master/vu-257161/recommendations.md).\r\n\r\n#### Detect anomalous IP traffic\r\nSuricata IDS has built-in decoder-event rules that can be customized to detect attempts to exploit these vulnerabilities. See the rule below for an example. A larger set of selected [vu-257161.rules](https://github.com/CERTCC/PoC-Exploits/blob/master/vu-257161/vu-257161.rules) are available from the CERT/CC Github repository.\r\n\r\n`#IP-in-IP tunnel with fragments`  \r\n`alert ip any any -> any any (msg:\"VU#257161:CVE-2020-11896, CVE-2020-11900 Fragments inside IP-in-IP tunnel https://kb.cert.org/vuls/id/257161\"; ip_proto:4; fragbits:M; sid:1367257161; rev:1;)`\r\n\r\n### Acknowledgements\r\nMoshe Kol and Shlomi Oberman of JSOF https://jsof-tech.com researched and reported these vulnerabilities. Treck worked closely with us and other stakeholders to coordinate the disclosure of these vulnerabilities.\r\n\r\nThis document was written by Vijay Sarvepalli.", | ||||
|     "clean_desc": null, | ||||
|     "impact": null, | ||||
|     "resolution": null, | ||||
|     "workarounds": null, | ||||
|     "sysaffected": null, | ||||
|     "thanks": null, | ||||
|     "author": null, | ||||
|     "public": [ | ||||
|         "https://www.jsof-tech.com/ripple20/", | ||||
|         "https://treck.com/vulnerability-response-information/", | ||||
|         "https://www.us-cert.gov/ics/advisories/icsa-20-168-01", | ||||
|         "https://jvn.jp/vu/JVNVU94736763/index.html" | ||||
|     ], | ||||
|     "cveids": [ | ||||
|         "CVE-2020-11902", | ||||
|         "CVE-2020-11913", | ||||
|         "CVE-2020-11898", | ||||
|         "CVE-2020-11907", | ||||
|         "CVE-2020-11901", | ||||
|         "CVE-2020-11903", | ||||
|         "CVE-2020-11904", | ||||
|         "CVE-2020-11906", | ||||
|         "CVE-2020-11910", | ||||
|         "CVE-2020-11911", | ||||
|         "CVE-2020-11912", | ||||
|         "CVE-2020-11914", | ||||
|         "CVE-2020-11899", | ||||
|         "CVE-2020-11896", | ||||
|         "CVE-2020-11897", | ||||
|         "CVE-2020-11905", | ||||
|         "CVE-2020-11908", | ||||
|         "CVE-2020-11900", | ||||
|         "CVE-2020-11909", | ||||
|         "CVE-2020-0597", | ||||
|         "CVE-2020-0595", | ||||
|         "CVE-2020-8674", | ||||
|         "CVE-2020-0594" | ||||
|     ], | ||||
|     "certadvisory": null, | ||||
|     "uscerttechnicalalert": null, | ||||
|     "datecreated": "2020-06-16T17:13:53.220714Z", | ||||
|     "publicdate": "2020-06-16T00:00:00Z", | ||||
|     "datefirstpublished": "2020-06-16T17:13:53.238540Z", | ||||
|     "dateupdated": "2022-09-20T01:54:35.485507Z", | ||||
|     "revision": 48, | ||||
|     "vrda_d1_directreport": null, | ||||
|     "vrda_d1_population": null, | ||||
|     "vrda_d1_impact": null, | ||||
|     "cam_widelyknown": null, | ||||
|     "cam_exploitation": null, | ||||
|     "cam_internetinfrastructure": null, | ||||
|     "cam_population": null, | ||||
|     "cam_impact": null, | ||||
|     "cam_easeofexploitation": null, | ||||
|     "cam_attackeraccessrequired": null, | ||||
|     "cam_scorecurrent": null, | ||||
|     "cam_scorecurrentwidelyknown": null, | ||||
|     "cam_scorecurrentwidelyknownexploited": null, | ||||
|     "ipprotocol": null, | ||||
|     "cvss_accessvector": null, | ||||
|     "cvss_accesscomplexity": null, | ||||
|     "cvss_authentication": null, | ||||
|     "cvss_confidentialityimpact": null, | ||||
|     "cvss_integrityimpact": null, | ||||
|     "cvss_availabilityimpact": null, | ||||
|     "cvss_exploitablity": null, | ||||
|     "cvss_remediationlevel": null, | ||||
|     "cvss_reportconfidence": null, | ||||
|     "cvss_collateraldamagepotential": null, | ||||
|     "cvss_targetdistribution": null, | ||||
|     "cvss_securityrequirementscr": null, | ||||
|     "cvss_securityrequirementsir": null, | ||||
|     "cvss_securityrequirementsar": null, | ||||
|     "cvss_basescore": null, | ||||
|     "cvss_basevector": null, | ||||
|     "cvss_temporalscore": null, | ||||
|     "cvss_environmentalscore": null, | ||||
|     "cvss_environmentalvector": null, | ||||
|     "metric": null, | ||||
|     "vulnote": 7 | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| [ | ||||
|     { | ||||
|         "note": "294418", | ||||
|         "contact_date": "2025-09-15T19:03:33.664345Z", | ||||
|         "vendor": "DrayTek Corporation", | ||||
|         "references": "", | ||||
|         "statement": "The issue is confirmed, and here is the patch list\r\n\r\nV3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\r\nV2927/V2865/V2866\t4.5.1\r\nV2765/V2766/V2763/V2135\t4.5.1\r\nV2915\t4.4.6.1\r\nV2862/V2926\t3.9.9.12\r\nV2952/3220\t3.9.8.8\r\nV2860/V2925\t3.9.8.6\r\nV2133/V2762/V2832\t3.9.9.4\r\nV2620/LTE200\t3.9.9.5", | ||||
|         "dateupdated": "2025-10-03T11:35:31.190661Z", | ||||
|         "statement_date": "2025-09-16T02:27:51.346335Z", | ||||
|         "addendum": null | ||||
|     } | ||||
| ] | ||||
| @@ -0,0 +1,11 @@ | ||||
| [ | ||||
|     { | ||||
|         "note": "294418", | ||||
|         "cve": "2025-10547", | ||||
|         "description": "An uninitialized variable in the HTTP CGI request arguments processing component of Vigor Routers running DrayOS may allow an attacker the ability to perform RCE on the appliance through memory corruption.", | ||||
|         "uid": "CVE-2025-10547", | ||||
|         "case_increment": 1, | ||||
|         "date_added": "2025-10-03T11:35:31.177872Z", | ||||
|         "dateupdated": "2025-10-03T11:40:09.915649Z" | ||||
|     } | ||||
| ] | ||||
| @@ -0,0 +1,63 @@ | ||||
| { | ||||
|     "vuid": "VU#294418", | ||||
|     "idnumber": "294418", | ||||
|     "name": "Vigor routers running DrayOS are vulnerable to RCE via EasyVPN and LAN web administration interface", | ||||
|     "keywords": null, | ||||
|     "overview": "### Overview\r\nA remote code execution (RCE) vulnerability, tracked as CVE-2025-10547, was discovered through the EasyVPN and LAN web administration interface of Vigor routers by Draytek. A script in the LAN web administration interface uses an unitialized variable, allowing an attacker to send specially crafted HTTP requests that cause memory corruption and potentially allow arbitrary code execution.\r\n\t\r\n### Description\r\nVigor routers are business-grade routers, designed for small to medium-sized businesses, made by Draytek. These routers provide routing, firewall, VPN, content-filtering, bandwidth management, LAN (local area network), and multi-WAN (wide area network) features. Draytek utilizes a proprietary firmware, DrayOS, on the Vigor router line. DrayOS features the EasyVPN and LAN Web Administrator tool s to facilitate LAN and VPN setup. According to the DrayTek [website](https://www.draytek.com/support/knowledge-base/12023), \"with EasyVPN, users no longer need to generate WireGuard keys, import OpenVPN configuration files, or upload certificates. Instead, VPN can be successfully established by simply entering the username and password or getting the OTP code by email.\" \r\n\r\nThe LAN Web Administrator provides a browser-based user interface for router management. When a user interacts with the LAN Web Administration interface, the user interface elements trigger actions that generate HTTP requests to interact with the local server. This process contains an uninitialized variable. Due to the uninitialized variable, an unauthenticated attacker could perform memory corruption on the router via specially crafted HTTP requests to hijack execution or inject malicious payloads. If EasyVPN is enabled, the flaw could be remotely exploited through the VPN interface.\r\n\r\n### Impact\r\nA remote, unathenticated attacker can exploit this vulnerability through accessing the LAN interface\u2014or potentially the WAN interface\u2014if EasyVPN is enabled or remote administration over the internet is activated. If a remote, unauthenticated attacker leverages this vulnerability, they can execute arbitrary code on the router (RCE) and gain full control of the device. A successful attack could result in a attacker gaining root access to a Vigor router to then install backdoors, reconfigure network settings, or block traffic. An attacker may also pivot for lateral movement via intercepting internal communications and bypassing VPNs. \r\n\r\n### Solution\r\nThe DrayTek Security team has developed a series of patches to remediate the vulnerability, and all users of Vigor routers should upgrade to the latest version ASAP. The patches can be found on the [resources](https://www.draytek.com/support/resources?type=version) page of the DrayTek webpage, and the security advisory can be found within the [about](https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/) section of the DrayTek webpage. Consult either the CVE [listing](https://nvd.nist.gov/vuln/detail/CVE-2025-10547) or the [advisory page](https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/) for a full list of affected products. \r\n\r\n### Acknowledgements\r\nThanks to the reporter, Pierre-Yves MAES of ChapsVision (pymaes@chapsvision.com). This document was written by Ayushi Kriplani.", | ||||
|     "clean_desc": null, | ||||
|     "impact": null, | ||||
|     "resolution": null, | ||||
|     "workarounds": null, | ||||
|     "sysaffected": null, | ||||
|     "thanks": null, | ||||
|     "author": null, | ||||
|     "public": [ | ||||
|         "https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/", | ||||
|         "https://www.draytek.com/support/resources?type=version" | ||||
|     ], | ||||
|     "cveids": [ | ||||
|         "CVE-2025-10547" | ||||
|     ], | ||||
|     "certadvisory": null, | ||||
|     "uscerttechnicalalert": null, | ||||
|     "datecreated": "2025-10-03T11:35:31.224065Z", | ||||
|     "publicdate": "2025-10-03T11:35:31.026053Z", | ||||
|     "datefirstpublished": "2025-10-03T11:35:31.247121Z", | ||||
|     "dateupdated": "2025-10-03T11:40:09.876722Z", | ||||
|     "revision": 2, | ||||
|     "vrda_d1_directreport": null, | ||||
|     "vrda_d1_population": null, | ||||
|     "vrda_d1_impact": null, | ||||
|     "cam_widelyknown": null, | ||||
|     "cam_exploitation": null, | ||||
|     "cam_internetinfrastructure": null, | ||||
|     "cam_population": null, | ||||
|     "cam_impact": null, | ||||
|     "cam_easeofexploitation": null, | ||||
|     "cam_attackeraccessrequired": null, | ||||
|     "cam_scorecurrent": null, | ||||
|     "cam_scorecurrentwidelyknown": null, | ||||
|     "cam_scorecurrentwidelyknownexploited": null, | ||||
|     "ipprotocol": null, | ||||
|     "cvss_accessvector": null, | ||||
|     "cvss_accesscomplexity": null, | ||||
|     "cvss_authentication": null, | ||||
|     "cvss_confidentialityimpact": null, | ||||
|     "cvss_integrityimpact": null, | ||||
|     "cvss_availabilityimpact": null, | ||||
|     "cvss_exploitablity": null, | ||||
|     "cvss_remediationlevel": null, | ||||
|     "cvss_reportconfidence": null, | ||||
|     "cvss_collateraldamagepotential": null, | ||||
|     "cvss_targetdistribution": null, | ||||
|     "cvss_securityrequirementscr": null, | ||||
|     "cvss_securityrequirementsir": null, | ||||
|     "cvss_securityrequirementsar": null, | ||||
|     "cvss_basescore": null, | ||||
|     "cvss_basevector": null, | ||||
|     "cvss_temporalscore": null, | ||||
|     "cvss_environmentalscore": null, | ||||
|     "cvss_environmentalvector": null, | ||||
|     "metric": null, | ||||
|     "vulnote": 142 | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| [ | ||||
|     { | ||||
|         "note": "294418", | ||||
|         "cve": "2025-10547", | ||||
|         "description": "An uninitialized variable in the HTTP CGI request arguments processing component of Vigor Routers running DrayOS may allow an attacker the ability to perform RCE on the appliance through memory corruption.", | ||||
|         "uid": "CVE-2025-10547", | ||||
|         "case_increment": 1, | ||||
|         "date_added": "2025-10-03T11:35:31.177872Z", | ||||
|         "dateupdated": "2025-10-03T11:40:09.915649Z" | ||||
|     } | ||||
| ] | ||||
| @@ -0,0 +1,118 @@ | ||||
| using System; | ||||
| using System.Globalization; | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Feedser.Models; | ||||
| using StellaOps.Feedser.Source.CertCc.Internal; | ||||
| using StellaOps.Feedser.Storage.Mongo.Documents; | ||||
| using StellaOps.Feedser.Storage.Mongo.Dtos; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.CertCc.Tests.Internal; | ||||
|  | ||||
| public sealed class CertCcMapperTests | ||||
| { | ||||
|     private static readonly DateTimeOffset PublishedAt = DateTimeOffset.Parse("2025-10-03T11:35:31Z", CultureInfo.InvariantCulture); | ||||
|  | ||||
|     [Fact] | ||||
|     public void Map_ProducesCanonicalAdvisoryWithVendorPrimitives() | ||||
|     { | ||||
|         const string vendorStatement = | ||||
|             "The issue is confirmed, and here is the patch list\n\n" + | ||||
|             "V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" + | ||||
|             "V2927/V2865/V2866\t4.5.1\n" + | ||||
|             "V2765/V2766/V2763/V2135\t4.5.1"; | ||||
|  | ||||
|         var vendor = new CertCcVendorDto( | ||||
|             "DrayTek Corporation", | ||||
|             ContactDate: PublishedAt.AddDays(-10), | ||||
|             StatementDate: PublishedAt.AddDays(-5), | ||||
|             Updated: PublishedAt, | ||||
|             Statement: vendorStatement, | ||||
|             Addendum: null, | ||||
|             References: new[] { "https://www.draytek.com/support/resources?type=version" }); | ||||
|  | ||||
|         var vendorStatus = new CertCcVendorStatusDto( | ||||
|             Vendor: "DrayTek Corporation", | ||||
|             CveId: "CVE-2025-10547", | ||||
|             Status: "Affected", | ||||
|             Statement: null, | ||||
|             References: Array.Empty<string>(), | ||||
|             DateAdded: PublishedAt, | ||||
|             DateUpdated: PublishedAt); | ||||
|  | ||||
|         var vulnerability = new CertCcVulnerabilityDto( | ||||
|             CveId: "CVE-2025-10547", | ||||
|             Description: null, | ||||
|             DateAdded: PublishedAt, | ||||
|             DateUpdated: PublishedAt); | ||||
|  | ||||
|         var metadata = new CertCcNoteMetadata( | ||||
|             VuId: "VU#294418", | ||||
|             IdNumber: "294418", | ||||
|             Title: "Vigor routers running DrayOS RCE via EasyVPN", | ||||
|             Overview: "Overview", | ||||
|             Summary: "Summary", | ||||
|             Published: PublishedAt, | ||||
|             Updated: PublishedAt.AddMinutes(5), | ||||
|             Created: PublishedAt, | ||||
|             Revision: 2, | ||||
|             CveIds: new[] { "CVE-2025-10547" }, | ||||
|             PublicUrls: new[] | ||||
|             { | ||||
|                 "https://www.draytek.com/about/security-advisory/use-of-uninitialized-variable-vulnerabilities/", | ||||
|                 "https://www.draytek.com/support/resources?type=version" | ||||
|             }, | ||||
|             PrimaryUrl: "https://www.kb.cert.org/vuls/id/294418/"); | ||||
|  | ||||
|         var dto = new CertCcNoteDto( | ||||
|             metadata, | ||||
|             Vendors: new[] { vendor }, | ||||
|             VendorStatuses: new[] { vendorStatus }, | ||||
|             Vulnerabilities: new[] { vulnerability }); | ||||
|  | ||||
|         var document = new DocumentRecord( | ||||
|             Guid.NewGuid(), | ||||
|             "cert-cc", | ||||
|             "https://www.kb.cert.org/vuls/id/294418/", | ||||
|             PublishedAt, | ||||
|             Sha256: new string('0', 64), | ||||
|             Status: "pending-map", | ||||
|             ContentType: "application/json", | ||||
|             Headers: null, | ||||
|             Metadata: null, | ||||
|             Etag: null, | ||||
|             LastModified: PublishedAt, | ||||
|             GridFsId: null); | ||||
|  | ||||
|         var dtoRecord = new DtoRecord( | ||||
|             Id: Guid.NewGuid(), | ||||
|             DocumentId: document.Id, | ||||
|             SourceName: "cert-cc", | ||||
|             SchemaVersion: "certcc.vince.note.v1", | ||||
|             Payload: new BsonDocument(), | ||||
|             ValidatedAt: PublishedAt.AddMinutes(1)); | ||||
|  | ||||
|         var advisory = CertCcMapper.Map(dto, document, dtoRecord, "cert-cc"); | ||||
|  | ||||
|         Assert.Equal("certcc/vu-294418", advisory.AdvisoryKey); | ||||
|         Assert.Contains("VU#294418", advisory.Aliases); | ||||
|         Assert.Contains("CVE-2025-10547", advisory.Aliases); | ||||
|         Assert.Equal("en", advisory.Language); | ||||
|         Assert.Equal(PublishedAt, advisory.Published); | ||||
|  | ||||
|         Assert.Contains(advisory.References, reference => reference.Url.Contains("/vuls/id/294418", StringComparison.OrdinalIgnoreCase)); | ||||
|  | ||||
|         var affected = Assert.Single(advisory.AffectedPackages); | ||||
|         Assert.Equal("vendor", affected.Type); | ||||
|         Assert.Equal("DrayTek Corporation", affected.Identifier); | ||||
|         Assert.Contains(affected.Statuses, status => status.Status == AffectedPackageStatusCatalog.Affected); | ||||
|  | ||||
|         var range = Assert.Single(affected.VersionRanges); | ||||
|         Assert.NotNull(range.Primitives); | ||||
|         Assert.NotNull(range.Primitives!.VendorExtensions); | ||||
|         Assert.Contains(range.Primitives.VendorExtensions!, kvp => kvp.Key == "certcc.vendor.patches"); | ||||
|  | ||||
|         Assert.NotEmpty(affected.NormalizedVersions); | ||||
|         Assert.Contains(affected.NormalizedVersions, rule => rule.Scheme == "certcc.vendor" && rule.Value == "4.5.1"); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,95 @@ | ||||
| using System; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Feedser.Source.CertCc.Configuration; | ||||
| using StellaOps.Feedser.Source.CertCc.Internal; | ||||
| using StellaOps.Feedser.Source.Common.Cursors; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.CertCc.Tests.Internal; | ||||
|  | ||||
| public sealed class CertCcSummaryPlannerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void CreatePlan_UsesInitialBackfillWindow() | ||||
|     { | ||||
|         var options = Options.Create(new CertCcOptions | ||||
|         { | ||||
|             SummaryWindow = new TimeWindowCursorOptions | ||||
|             { | ||||
|                 WindowSize = TimeSpan.FromDays(30), | ||||
|                 Overlap = TimeSpan.FromDays(3), | ||||
|                 InitialBackfill = TimeSpan.FromDays(120), | ||||
|                 MinimumWindowSize = TimeSpan.FromDays(1), | ||||
|             }, | ||||
|         }); | ||||
|  | ||||
|         var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-10T12:00:00Z")); | ||||
|         var planner = new CertCcSummaryPlanner(options, timeProvider); | ||||
|  | ||||
|         var plan = planner.CreatePlan(state: null); | ||||
|  | ||||
|         Assert.Equal(DateTimeOffset.Parse("2025-06-12T12:00:00Z"), plan.Window.Start); | ||||
|         Assert.Equal(DateTimeOffset.Parse("2025-07-12T12:00:00Z"), plan.Window.End); | ||||
|  | ||||
|         Assert.Equal(3, plan.Requests.Count); | ||||
|  | ||||
|         var monthly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Monthly).ToArray(); | ||||
|         Assert.Collection(monthly, | ||||
|             request => | ||||
|             { | ||||
|                 Assert.Equal(2025, request.Year); | ||||
|                 Assert.Equal(6, request.Month); | ||||
|                 Assert.Equal("https://www.kb.cert.org/vuls/api/2025/06/summary/", request.Uri.AbsoluteUri); | ||||
|             }, | ||||
|             request => | ||||
|             { | ||||
|                 Assert.Equal(2025, request.Year); | ||||
|                 Assert.Equal(7, request.Month); | ||||
|                 Assert.Equal("https://www.kb.cert.org/vuls/api/2025/07/summary/", request.Uri.AbsoluteUri); | ||||
|             }); | ||||
|  | ||||
|         var yearly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Yearly).ToArray(); | ||||
|         Assert.Single(yearly); | ||||
|         Assert.Equal(2025, yearly[0].Year); | ||||
|         Assert.Null(yearly[0].Month); | ||||
|         Assert.Equal("https://www.kb.cert.org/vuls/api/2025/summary/", yearly[0].Uri.AbsoluteUri); | ||||
|  | ||||
|         Assert.Equal(plan.Window.End, plan.NextState.LastWindowEnd); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void CreatePlan_AdvancesWindowRespectingOverlap() | ||||
|     { | ||||
|         var options = Options.Create(new CertCcOptions | ||||
|         { | ||||
|             SummaryWindow = new TimeWindowCursorOptions | ||||
|             { | ||||
|                 WindowSize = TimeSpan.FromDays(30), | ||||
|                 Overlap = TimeSpan.FromDays(10), | ||||
|                 InitialBackfill = TimeSpan.FromDays(90), | ||||
|                 MinimumWindowSize = TimeSpan.FromDays(1), | ||||
|             }, | ||||
|         }); | ||||
|  | ||||
|         var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-12-01T00:00:00Z")); | ||||
|         var planner = new CertCcSummaryPlanner(options, timeProvider); | ||||
|  | ||||
|         var first = planner.CreatePlan(null); | ||||
|         var second = planner.CreatePlan(first.NextState); | ||||
|  | ||||
|         Assert.True(second.Window.Start < second.Window.End); | ||||
|         Assert.Equal(first.Window.End - options.Value.SummaryWindow.Overlap, second.Window.Start); | ||||
|     } | ||||
|  | ||||
|     private sealed class TestTimeProvider : TimeProvider | ||||
|     { | ||||
|         private DateTimeOffset _now; | ||||
|  | ||||
|         public TestTimeProvider(DateTimeOffset now) => _now = now; | ||||
|  | ||||
|         public override DateTimeOffset GetUtcNow() => _now; | ||||
|  | ||||
|         public void Advance(TimeSpan delta) => _now = _now.Add(delta); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| using System.Linq; | ||||
| using StellaOps.Feedser.Source.CertCc.Internal; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Feedser.Source.CertCc.Tests.Internal; | ||||
|  | ||||
| public sealed class CertCcVendorStatementParserTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Parse_ReturnsPatchesForTabDelimitedList() | ||||
|     { | ||||
|         const string statement = | ||||
|             "V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" + | ||||
|             "V2927/V2865/V2866\t4.5.1\n" + | ||||
|             "V2765/V2766/V2763/V2135\t4.5.1"; | ||||
|  | ||||
|         var patches = CertCcVendorStatementParser.Parse(statement); | ||||
|  | ||||
|         Assert.Equal(11, patches.Count); | ||||
|         Assert.Contains(patches, patch => patch.Product == "V3912" && patch.Version == "4.4.3.6"); | ||||
|         Assert.Contains(patches, patch => patch.Product == "V2962" && patch.Version == "4.4.5.1"); | ||||
|         Assert.Equal(7, patches.Count(patch => patch.Version == "4.5.1")); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Parse_ReturnsEmptyWhenStatementMissing() | ||||
|     { | ||||
|         var patches = CertCcVendorStatementParser.Parse(null); | ||||
|         Assert.Empty(patches); | ||||
|     } | ||||
| } | ||||
| @@ -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.Source.Common/StellaOps.Feedser.Source.Common.csproj" /> | ||||
|     <ProjectReference Include="../StellaOps.Feedser.Source.CertCc/StellaOps.Feedser.Source.CertCc.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <None Update="Fixtures\*.json"> | ||||
|       <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> | ||||
|     </None> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
		Reference in New Issue
	
	Block a user