Add NKCKI severity smoothing, fixtures, and regression harness
This commit is contained in:
		| @@ -69,8 +69,8 @@ | |||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Cccs/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CCCS-02-001 … 02-007 | Atom feed verified 2025-10-11, history/caching review and FR locale enumeration pending. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Cccs/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CCCS-02-001 … 02-007 | Atom feed verified 2025-10-11, history/caching review and FR locale enumeration pending. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.CertBund/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CERTBUND-02-001 … 02-007 | BSI RSS directory confirmed CERT-Bund feed 2025-10-11, history assessment pending. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.CertBund/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CERTBUND-02-001 … 02-007 | BSI RSS directory confirmed CERT-Bund feed 2025-10-11, history assessment pending. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Kisa/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-KISA-02-001 … 02-007 | KNVD RSS endpoint identified 2025-10-11, access headers/session strategy outstanding. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Kisa/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-KISA-02-001 … 02-007 | KNVD RSS endpoint identified 2025-10-11, access headers/session strategy outstanding. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Ru.Bdu/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-RUBDU-02-001 … 02-008 | BDU RSS/Atom catalogue reviewed 2025-10-11, trust-store acquisition blocked by gosuslugi placeholder page. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Ru.Bdu/TASKS.md | Build DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-RUBDU-02-001 … 02-008 | TLS bundle + connectors landed 2025-10-12; fetch/parse/map flow emits advisories, fixtures & telemetry follow-up pending. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Ru.Nkcki/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-NKCKI-02-001 … 02-008 | cert.gov.ru paginated RSS landing checked 2025-10-11, access enablement plan pending. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Ru.Nkcki/TASKS.md | Build DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-NKCKI-02-001 … 02-008 | JSON bulletin fetch + canonical mapping live 2025-10-12; regression fixtures added but blocked on Mongo2Go libcrypto dependency for test execution. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Ics.Cisa/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-ICSCISA-02-001 … 02-008 | new ICS RSS endpoint logged 2025-10-11 but Akamai blocks direct pulls, fallback strategy task opened. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Ics.Cisa/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-ICSCISA-02-001 … 02-008 | new ICS RSS endpoint logged 2025-10-11 but Akamai blocks direct pulls, fallback strategy task opened. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Vndr.Cisco/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CISCO-02-001 … 02-007 | openVuln API + RSS reviewed 2025-10-11, auth/pagination memo pending. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Vndr.Cisco/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-CISCO-02-001 … 02-007 | openVuln API + RSS reviewed 2025-10-11, auth/pagination memo pending. | | ||||||
| | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Vndr.Msrc/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-MSRC-02-001 … 02-007 | MSRC API docs reviewed 2025-10-11, auth/throttling comparison memo pending.<br>Instructions to work:<br>Read ./AGENTS.md plus each module's AGENTS file. Parallelize research, ingestion, mapping, fixtures, and docs using the normalized rule shape from ./src/FASTER_MODELING_AND_NORMALIZATION.md. Coordinate daily with the merge coordination task from Sprint 1. | | | Sprint 2 | Connector & Data Implementation Wave | src/StellaOps.Feedser.Source.Vndr.Msrc/TASKS.md | Research DOING | Team Connector Expansion – Regional & Vendor Feeds | FEEDCONN-MSRC-02-001 … 02-007 | MSRC API docs reviewed 2025-10-11, auth/throttling comparison memo pending.<br>Instructions to work:<br>Read ./AGENTS.md plus each module's AGENTS file. Parallelize research, ingestion, mapping, fixtures, and docs using the normalized rule shape from ./src/FASTER_MODELING_AND_NORMALIZATION.md. Coordinate daily with the merge coordination task from Sprint 1. | | ||||||
|   | |||||||
										
											Binary file not shown.
										
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | <html> | ||||||
|  |   <body> | ||||||
|  |     <ul> | ||||||
|  |       <li><a href="/materialy/uyazvimosti/bulletin-sample.json.zip" title="Bulletin Sample">Bulletin Sample</a></li> | ||||||
|  |     </ul> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
| @@ -0,0 +1,165 @@ | |||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "advisoryKey": "BDU:2025-01001", | ||||||
|  |     "affectedPackages": [ | ||||||
|  |       { | ||||||
|  |         "type": "vendor", | ||||||
|  |         "identifier": "SampleSCADA <= 4.2", | ||||||
|  |         "platform": null, | ||||||
|  |         "versionRanges": [], | ||||||
|  |         "normalizedVersions": [], | ||||||
|  |         "statuses": [ | ||||||
|  |           { | ||||||
|  |             "provenance": { | ||||||
|  |               "source": "ru-nkcki", | ||||||
|  |               "kind": "package-status", | ||||||
|  |               "value": "patch_available", | ||||||
|  |               "decisionReason": null, | ||||||
|  |               "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |               "fieldMask": [ | ||||||
|  |                 "affectedpackages[].statuses[]" | ||||||
|  |               ] | ||||||
|  |             }, | ||||||
|  |             "status": "fixed" | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "provenance": [ | ||||||
|  |           { | ||||||
|  |             "source": "ru-nkcki", | ||||||
|  |             "kind": "package", | ||||||
|  |             "value": "SampleSCADA <= 4.2", | ||||||
|  |             "decisionReason": null, | ||||||
|  |             "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |             "fieldMask": [ | ||||||
|  |               "affectedpackages[]" | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "aliases": [ | ||||||
|  |       "BDU:2025-01001", | ||||||
|  |       "CVE-2025-0101" | ||||||
|  |     ], | ||||||
|  |     "credits": [], | ||||||
|  |     "cvssMetrics": [ | ||||||
|  |       { | ||||||
|  |         "baseScore": 8.5, | ||||||
|  |         "baseSeverity": "high", | ||||||
|  |         "provenance": { | ||||||
|  |           "source": "ru-nkcki", | ||||||
|  |           "kind": "cvss", | ||||||
|  |           "value": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", | ||||||
|  |           "decisionReason": null, | ||||||
|  |           "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |           "fieldMask": [ | ||||||
|  |             "cvssmetrics[]" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         "vector": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H", | ||||||
|  |         "version": "3.1" | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "exploitKnown": true, | ||||||
|  |     "language": "ru", | ||||||
|  |     "modified": "2025-09-22T00:00:00+00:00", | ||||||
|  |     "provenance": [ | ||||||
|  |       { | ||||||
|  |         "source": "ru-nkcki", | ||||||
|  |         "kind": "advisory", | ||||||
|  |         "value": "BDU:2025-01001", | ||||||
|  |         "decisionReason": null, | ||||||
|  |         "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |         "fieldMask": [ | ||||||
|  |           "advisory" | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "published": "2025-09-20T00:00:00+00:00", | ||||||
|  |     "references": [ | ||||||
|  |       { | ||||||
|  |         "kind": "details", | ||||||
|  |         "provenance": { | ||||||
|  |           "source": "ru-nkcki", | ||||||
|  |           "kind": "reference", | ||||||
|  |           "value": "https://bdu.fstec.ru/vul/2025-01001", | ||||||
|  |           "decisionReason": null, | ||||||
|  |           "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |           "fieldMask": [ | ||||||
|  |             "references[]" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         "sourceTag": "bdu", | ||||||
|  |         "summary": null, | ||||||
|  |         "url": "https://bdu.fstec.ru/vul/2025-01001" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "details", | ||||||
|  |         "provenance": { | ||||||
|  |           "source": "ru-nkcki", | ||||||
|  |           "kind": "reference", | ||||||
|  |           "value": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001", | ||||||
|  |           "decisionReason": null, | ||||||
|  |           "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |           "fieldMask": [ | ||||||
|  |             "references[]" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         "sourceTag": null, | ||||||
|  |         "summary": null, | ||||||
|  |         "url": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "details", | ||||||
|  |         "provenance": { | ||||||
|  |           "source": "ru-nkcki", | ||||||
|  |           "kind": "reference", | ||||||
|  |           "value": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001", | ||||||
|  |           "decisionReason": null, | ||||||
|  |           "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |           "fieldMask": [ | ||||||
|  |             "references[]" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         "sourceTag": "ru-nkcki", | ||||||
|  |         "summary": null, | ||||||
|  |         "url": "https://cert.gov.ru/materialy/uyazvimosti/2025-01001" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "cwe", | ||||||
|  |         "provenance": { | ||||||
|  |           "source": "ru-nkcki", | ||||||
|  |           "kind": "reference", | ||||||
|  |           "value": "https://cwe.mitre.org/data/definitions/321.html", | ||||||
|  |           "decisionReason": null, | ||||||
|  |           "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |           "fieldMask": [ | ||||||
|  |             "references[]" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         "sourceTag": "cwe", | ||||||
|  |         "summary": "Use of Hard-coded Cryptographic Key", | ||||||
|  |         "url": "https://cwe.mitre.org/data/definitions/321.html" | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "kind": "external", | ||||||
|  |         "provenance": { | ||||||
|  |           "source": "ru-nkcki", | ||||||
|  |           "kind": "reference", | ||||||
|  |           "value": "https://vendor.example/advisories/sample-scada", | ||||||
|  |           "decisionReason": null, | ||||||
|  |           "recordedAt": "2025-09-22T00:00:00+00:00", | ||||||
|  |           "fieldMask": [ | ||||||
|  |             "references[]" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |         "sourceTag": null, | ||||||
|  |         "summary": null, | ||||||
|  |         "url": "https://vendor.example/advisories/sample-scada" | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "severity": "critical", | ||||||
|  |     "summary": "Authenticated RCE in Sample SCADA", | ||||||
|  |     "title": "Authenticated RCE in Sample SCADA" | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -0,0 +1,289 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.IO; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using System.Net; | ||||||
|  | using System.Net.Http; | ||||||
|  | using System.Net.Http.Headers; | ||||||
|  | using System.Text; | ||||||
|  | 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; | ||||||
|  | using StellaOps.Feedser.Source.Common.Http; | ||||||
|  | using StellaOps.Feedser.Source.Common.Testing; | ||||||
|  | using StellaOps.Feedser.Source.Ru.Nkcki; | ||||||
|  | using StellaOps.Feedser.Source.Ru.Nkcki.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.Models; | ||||||
|  | using MongoDB.Driver; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Feedser.Source.Ru.Nkcki.Tests; | ||||||
|  |  | ||||||
|  | [Collection("mongo-fixture")] | ||||||
|  | public sealed class RuNkckiConnectorTests : IAsyncLifetime | ||||||
|  | { | ||||||
|  |     private static readonly Uri ListingUri = new("https://cert.gov.ru/materialy/uyazvimosti/"); | ||||||
|  |     private static readonly Uri BulletinUri = new("https://cert.gov.ru/materialy/uyazvimosti/bulletin-sample.json.zip"); | ||||||
|  |  | ||||||
|  |     private readonly MongoIntegrationFixture _fixture; | ||||||
|  |     private readonly FakeTimeProvider _timeProvider; | ||||||
|  |     private readonly CannedHttpMessageHandler _handler; | ||||||
|  |  | ||||||
|  |     public RuNkckiConnectorTests(MongoIntegrationFixture fixture) | ||||||
|  |     { | ||||||
|  |         _fixture = fixture; | ||||||
|  |         _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero)); | ||||||
|  |         _handler = new CannedHttpMessageHandler(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task FetchParseMap_ProducesExpectedSnapshot() | ||||||
|  |     { | ||||||
|  |         await using var provider = await BuildServiceProviderAsync(); | ||||||
|  |         SeedListingAndBulletin(); | ||||||
|  |  | ||||||
|  |         var connector = provider.GetRequiredService<RuNkckiConnector>(); | ||||||
|  |         await connector.FetchAsync(provider, CancellationToken.None); | ||||||
|  |         _timeProvider.Advance(TimeSpan.FromMinutes(1)); | ||||||
|  |         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.Single(advisories); | ||||||
|  |  | ||||||
|  |         var snapshot = SnapshotSerializer.ToSnapshot(advisories); | ||||||
|  |         WriteOrAssertSnapshot(snapshot, "nkcki-advisories.snapshot.json"); | ||||||
|  |  | ||||||
|  |         var documentStore = provider.GetRequiredService<IDocumentStore>(); | ||||||
|  |         var document = await documentStore.FindBySourceAndUriAsync(RuNkckiConnectorPlugin.SourceName, "https://cert.gov.ru/materialy/uyazvimosti/BDU:2025-01001", CancellationToken.None); | ||||||
|  |         Assert.NotNull(document); | ||||||
|  |         Assert.Equal(DocumentStatuses.Mapped, document!.Status); | ||||||
|  |  | ||||||
|  |         var stateRepository = provider.GetRequiredService<ISourceStateRepository>(); | ||||||
|  |         var state = await stateRepository.TryGetAsync(RuNkckiConnectorPlugin.SourceName, CancellationToken.None); | ||||||
|  |         Assert.NotNull(state); | ||||||
|  |         Assert.True(IsEmptyArray(state!.Cursor, "pendingDocuments")); | ||||||
|  |         Assert.True(IsEmptyArray(state.Cursor, "pendingMappings")); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task Fetch_ReusesCachedBulletinWhenListingFails() | ||||||
|  |     { | ||||||
|  |         await using var provider = await BuildServiceProviderAsync(); | ||||||
|  |         SeedListingAndBulletin(); | ||||||
|  |  | ||||||
|  |         var connector = provider.GetRequiredService<RuNkckiConnector>(); | ||||||
|  |         await connector.FetchAsync(provider, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         _handler.Clear(); | ||||||
|  |         _handler.AddResponse(ListingUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError) | ||||||
|  |         { | ||||||
|  |             Content = new StringContent("error", Encoding.UTF8, "text/plain"), | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         var advisoryStore = provider.GetRequiredService<IAdvisoryStore>(); | ||||||
|  |         var before = await advisoryStore.GetRecentAsync(10, CancellationToken.None); | ||||||
|  |         Assert.NotEmpty(before); | ||||||
|  |  | ||||||
|  |         await connector.FetchAsync(provider, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         var after = await advisoryStore.GetRecentAsync(10, CancellationToken.None); | ||||||
|  |         Assert.Equal(before.Select(advisory => advisory.AdvisoryKey).OrderBy(static key => key), after.Select(advisory => advisory.AdvisoryKey).OrderBy(static key => key)); | ||||||
|  |  | ||||||
|  |         _handler.AssertNoPendingResponses(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task<ServiceProvider> BuildServiceProviderAsync() | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); | ||||||
|  |         } | ||||||
|  |         catch (MongoConnectionException ex) | ||||||
|  |         { | ||||||
|  |             Assert.Skip($"Mongo runner unavailable: {ex.Message}"); | ||||||
|  |         } | ||||||
|  |         catch (TimeoutException ex) | ||||||
|  |         { | ||||||
|  |             Assert.Skip($"Mongo runner unavailable: {ex.Message}"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _handler.Clear(); | ||||||
|  |  | ||||||
|  |         var services = new ServiceCollection(); | ||||||
|  |         services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); | ||||||
|  |         services.AddSingleton<TimeProvider>(_timeProvider); | ||||||
|  |  | ||||||
|  |         services.AddMongoStorage(options => | ||||||
|  |         { | ||||||
|  |             options.ConnectionString = _fixture.Runner.ConnectionString; | ||||||
|  |             options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; | ||||||
|  |             options.CommandTimeout = TimeSpan.FromSeconds(5); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         services.AddSourceCommon(); | ||||||
|  |         services.AddRuNkckiConnector(options => | ||||||
|  |         { | ||||||
|  |             options.BaseAddress = new Uri("https://cert.gov.ru/"); | ||||||
|  |             options.ListingPath = "/materialy/uyazvimosti/"; | ||||||
|  |             options.MaxBulletinsPerFetch = 2; | ||||||
|  |             options.MaxVulnerabilitiesPerFetch = 50; | ||||||
|  |             var cacheRoot = Path.Combine(Path.GetTempPath(), "stellaops-tests", _fixture.Database.DatabaseNamespace.DatabaseName); | ||||||
|  |             Directory.CreateDirectory(cacheRoot); | ||||||
|  |             options.CacheDirectory = Path.Combine(cacheRoot, "ru-nkcki"); | ||||||
|  |             options.RequestDelay = TimeSpan.Zero; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         services.Configure<HttpClientFactoryOptions>(RuNkckiOptions.HttpClientName, builderOptions => | ||||||
|  |         { | ||||||
|  |             builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var provider = services.BuildServiceProvider(); | ||||||
|  |             var bootstrapper = provider.GetRequiredService<MongoBootstrapper>(); | ||||||
|  |             await bootstrapper.InitializeAsync(CancellationToken.None); | ||||||
|  |             return provider; | ||||||
|  |         } | ||||||
|  |         catch (MongoConnectionException ex) | ||||||
|  |         { | ||||||
|  |             Assert.Skip($"Mongo runner unavailable: {ex.Message}"); | ||||||
|  |             throw; // Unreachable | ||||||
|  |         } | ||||||
|  |         catch (TimeoutException ex) | ||||||
|  |         { | ||||||
|  |             Assert.Skip($"Mongo runner unavailable: {ex.Message}"); | ||||||
|  |             throw; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private void SeedListingAndBulletin() | ||||||
|  |     { | ||||||
|  |         var listingHtml = ReadFixture("listing.html"); | ||||||
|  |         _handler.AddTextResponse(ListingUri, listingHtml, "text/html"); | ||||||
|  |  | ||||||
|  |         var bulletinBytes = ReadBulletinFixture("bulletin-sample.json.zip"); | ||||||
|  |         _handler.AddResponse(BulletinUri, () => | ||||||
|  |         { | ||||||
|  |             var response = new HttpResponseMessage(HttpStatusCode.OK) | ||||||
|  |             { | ||||||
|  |                 Content = new ByteArrayContent(bulletinBytes), | ||||||
|  |             }; | ||||||
|  |             response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); | ||||||
|  |             response.Content.Headers.LastModified = new DateTimeOffset(2025, 9, 22, 0, 0, 0, TimeSpan.Zero); | ||||||
|  |             return response; | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsEmptyArray(BsonDocument document, string field) | ||||||
|  |     { | ||||||
|  |         if (!document.TryGetValue(field, out var value) || value is not BsonArray array) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return array.Count == 0; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ReadFixture(string filename) | ||||||
|  |     { | ||||||
|  |         var path = Path.Combine("Fixtures", filename); | ||||||
|  |         var resolved = ResolveFixturePath(path); | ||||||
|  |         return File.ReadAllText(resolved); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static byte[] ReadBulletinFixture(string filename) | ||||||
|  |     { | ||||||
|  |         var path = Path.Combine("Fixtures", filename); | ||||||
|  |         var resolved = ResolveFixturePath(path); | ||||||
|  |         return File.ReadAllBytes(resolved); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ResolveFixturePath(string relativePath) | ||||||
|  |     { | ||||||
|  |         var projectRoot = GetProjectRoot(); | ||||||
|  |         var projectPath = Path.Combine(projectRoot, relativePath); | ||||||
|  |         if (File.Exists(projectPath)) | ||||||
|  |         { | ||||||
|  |             return projectPath; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var binaryPath = Path.Combine(AppContext.BaseDirectory, relativePath); | ||||||
|  |         if (File.Exists(binaryPath)) | ||||||
|  |         { | ||||||
|  |             return Path.GetFullPath(binaryPath); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         throw new FileNotFoundException($"Fixture not found: {relativePath}"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static void WriteOrAssertSnapshot(string snapshot, string filename) | ||||||
|  |     { | ||||||
|  |         if (ShouldUpdateFixtures()) | ||||||
|  |         { | ||||||
|  |             var path = GetWritableFixturePath(filename); | ||||||
|  |             Directory.CreateDirectory(Path.GetDirectoryName(path)!); | ||||||
|  |             File.WriteAllText(path, snapshot); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var expectedPath = ResolveFixturePath(Path.Combine("Fixtures", filename)); | ||||||
|  |         if (!File.Exists(expectedPath)) | ||||||
|  |         { | ||||||
|  |             throw new FileNotFoundException($"Expected snapshot missing: {expectedPath}. Set UPDATE_NKCKI_FIXTURES=1 to generate."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var expected = File.ReadAllText(expectedPath); | ||||||
|  |         Assert.Equal(Normalize(expected), Normalize(snapshot)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string GetWritableFixturePath(string filename) | ||||||
|  |     { | ||||||
|  |         var projectRoot = GetProjectRoot(); | ||||||
|  |         return Path.Combine(projectRoot, "Fixtures", filename); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool ShouldUpdateFixtures() | ||||||
|  |     { | ||||||
|  |         var value = Environment.GetEnvironmentVariable("UPDATE_NKCKI_FIXTURES"); | ||||||
|  |         return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) | ||||||
|  |             || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string Normalize(string text) | ||||||
|  |         => text.Replace("\r\n", "\n", StringComparison.Ordinal); | ||||||
|  |  | ||||||
|  |     private static string GetProjectRoot() | ||||||
|  |     { | ||||||
|  |         var current = AppContext.BaseDirectory; | ||||||
|  |         while (!string.IsNullOrEmpty(current)) | ||||||
|  |         { | ||||||
|  |             var candidate = Path.Combine(current, "StellaOps.Feedser.Source.Ru.Nkcki.Tests.csproj"); | ||||||
|  |             if (File.Exists(candidate)) | ||||||
|  |             { | ||||||
|  |                 return current; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             current = Path.GetDirectoryName(current); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         throw new InvalidOperationException("Unable to locate project root for Ru.Nkcki tests."); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public Task InitializeAsync() => Task.CompletedTask; | ||||||
|  |  | ||||||
|  |     public async Task DisposeAsync() | ||||||
|  |         => await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); | ||||||
|  | } | ||||||
| @@ -0,0 +1,68 @@ | |||||||
|  | using System.Collections.Immutable; | ||||||
|  | using MongoDB.Bson; | ||||||
|  | using StellaOps.Feedser.Source.Common; | ||||||
|  | using StellaOps.Feedser.Models; | ||||||
|  | using StellaOps.Feedser.Source.Ru.Nkcki.Internal; | ||||||
|  | using StellaOps.Feedser.Storage.Mongo.Documents; | ||||||
|  | using Xunit; | ||||||
|  | using System.Reflection; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Feedser.Source.Ru.Nkcki.Tests; | ||||||
|  |  | ||||||
|  | public sealed class RuNkckiMapperTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public void Map_ConstructsCanonicalAdvisory() | ||||||
|  |     { | ||||||
|  |         var dto = new RuNkckiVulnerabilityDto( | ||||||
|  |             FstecId: "BDU:2025-00001", | ||||||
|  |             MitreId: "CVE-2025-0001", | ||||||
|  |             DatePublished: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero), | ||||||
|  |             DateUpdated: new DateTimeOffset(2025, 9, 2, 0, 0, 0, TimeSpan.Zero), | ||||||
|  |             CvssRating: "КРИТИЧЕСКИЙ", | ||||||
|  |             PatchAvailable: true, | ||||||
|  |             Description: "Test NKCKI vulnerability", | ||||||
|  |             Cwe: new RuNkckiCweDto(79, "Cross-site scripting"), | ||||||
|  |             ProductCategory: "Web", | ||||||
|  |             Mitigation: "Apply update", | ||||||
|  |             VulnerableSoftwareText: "ExampleApp <= 1.0", | ||||||
|  |             VulnerableSoftwareHasCpe: false, | ||||||
|  |             CvssScore: 8.8, | ||||||
|  |             CvssVector: "AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", | ||||||
|  |             CvssScoreV4: null, | ||||||
|  |             CvssVectorV4: null, | ||||||
|  |             Impact: "ACE", | ||||||
|  |             MethodOfExploitation: "Special request", | ||||||
|  |             UserInteraction: false, | ||||||
|  |             Urls: ImmutableArray.Create("https://example.com/advisory")); | ||||||
|  |  | ||||||
|  |         var document = new DocumentRecord( | ||||||
|  |             Guid.NewGuid(), | ||||||
|  |             RuNkckiConnectorPlugin.SourceName, | ||||||
|  |             "https://cert.gov.ru/materialy/uyazvimosti/2025-00001", | ||||||
|  |             DateTimeOffset.UtcNow, | ||||||
|  |             "abc", | ||||||
|  |             DocumentStatuses.PendingMap, | ||||||
|  |             "application/json", | ||||||
|  |             null, | ||||||
|  |             null, | ||||||
|  |             null, | ||||||
|  |             dto.DateUpdated, | ||||||
|  |             ObjectId.GenerateNewId()); | ||||||
|  |  | ||||||
|  |         Assert.Equal("КРИТИЧЕСКИЙ", dto.CvssRating); | ||||||
|  |         var normalizeSeverity = typeof(RuNkckiMapper).GetMethod("NormalizeSeverity", BindingFlags.NonPublic | BindingFlags.Static)!; | ||||||
|  |         var ratingSeverity = (string?)normalizeSeverity.Invoke(null, new object?[] { dto.CvssRating }); | ||||||
|  |         Assert.Equal("critical", ratingSeverity); | ||||||
|  |  | ||||||
|  |         var advisory = RuNkckiMapper.Map(dto, document, dto.DateUpdated!.Value); | ||||||
|  |  | ||||||
|  |         Assert.Contains("BDU:2025-00001", advisory.Aliases); | ||||||
|  |         Assert.Contains("CVE-2025-0001", advisory.Aliases); | ||||||
|  |         Assert.Equal("critical", advisory.Severity); | ||||||
|  |         Assert.True(advisory.ExploitKnown); | ||||||
|  |         Assert.Single(advisory.AffectedPackages); | ||||||
|  |         Assert.Single(advisory.CvssMetrics); | ||||||
|  |         Assert.Contains(advisory.References, reference => reference.Url.Contains("example.com", StringComparison.OrdinalIgnoreCase)); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										298
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiMapper.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiMapper.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,298 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Collections.Immutable; | ||||||
|  | using System.Globalization; | ||||||
|  | using System.Linq; | ||||||
|  | using StellaOps.Feedser.Models; | ||||||
|  | using StellaOps.Feedser.Normalization.Cvss; | ||||||
|  | using StellaOps.Feedser.Storage.Mongo.Documents; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Feedser.Source.Ru.Nkcki.Internal; | ||||||
|  |  | ||||||
|  | internal static class RuNkckiMapper | ||||||
|  | { | ||||||
|  |     private static readonly ImmutableDictionary<string, string> SeverityLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||||
|  |     { | ||||||
|  |         ["критический"] = "critical", | ||||||
|  |         ["высокий"] = "high", | ||||||
|  |         ["средний"] = "medium", | ||||||
|  |         ["умеренный"] = "medium", | ||||||
|  |         ["низкий"] = "low", | ||||||
|  |         ["информационный"] = "informational", | ||||||
|  |     }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); | ||||||
|  |  | ||||||
|  |     public static Advisory Map(RuNkckiVulnerabilityDto dto, DocumentRecord document, DateTimeOffset recordedAt) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(dto); | ||||||
|  |         ArgumentNullException.ThrowIfNull(document); | ||||||
|  |  | ||||||
|  |         var advisoryProvenance = new AdvisoryProvenance( | ||||||
|  |             RuNkckiConnectorPlugin.SourceName, | ||||||
|  |             "advisory", | ||||||
|  |             dto.AdvisoryKey, | ||||||
|  |             recordedAt, | ||||||
|  |             new[] { ProvenanceFieldMasks.Advisory }); | ||||||
|  |  | ||||||
|  |         var aliases = BuildAliases(dto); | ||||||
|  |         var references = BuildReferences(dto, document, recordedAt); | ||||||
|  |         var packages = BuildPackages(dto, recordedAt); | ||||||
|  |         var cvssMetrics = BuildCvssMetrics(dto, recordedAt, out var severityFromCvss); | ||||||
|  |         var severityFromRating = NormalizeSeverity(dto.CvssRating); | ||||||
|  |         var severity = severityFromRating ?? severityFromCvss; | ||||||
|  |  | ||||||
|  |         if (severityFromRating is not null && severityFromCvss is not null) | ||||||
|  |         { | ||||||
|  |             severity = ChooseMoreSevere(severityFromRating, severityFromCvss); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var exploitKnown = DetermineExploitKnown(dto); | ||||||
|  |  | ||||||
|  |         return new Advisory( | ||||||
|  |             advisoryKey: dto.AdvisoryKey, | ||||||
|  |             title: dto.Description ?? dto.AdvisoryKey, | ||||||
|  |             summary: dto.Description, | ||||||
|  |             language: "ru", | ||||||
|  |             published: dto.DatePublished, | ||||||
|  |             modified: dto.DateUpdated, | ||||||
|  |             severity: severity, | ||||||
|  |             exploitKnown: exploitKnown, | ||||||
|  |             aliases: aliases, | ||||||
|  |             references: references, | ||||||
|  |             affectedPackages: packages, | ||||||
|  |             cvssMetrics: cvssMetrics, | ||||||
|  |             provenance: new[] { advisoryProvenance }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static IReadOnlyList<string> BuildAliases(RuNkckiVulnerabilityDto dto) | ||||||
|  |     { | ||||||
|  |         var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         if (!string.IsNullOrWhiteSpace(dto.FstecId)) | ||||||
|  |         { | ||||||
|  |             aliases.Add(dto.FstecId!); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(dto.MitreId)) | ||||||
|  |         { | ||||||
|  |             aliases.Add(dto.MitreId!); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return aliases.ToImmutableSortedSet(StringComparer.Ordinal).ToImmutableArray(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static IReadOnlyList<AdvisoryReference> BuildReferences(RuNkckiVulnerabilityDto dto, DocumentRecord document, DateTimeOffset recordedAt) | ||||||
|  |     { | ||||||
|  |         var references = new List<AdvisoryReference> | ||||||
|  |         { | ||||||
|  |             new(document.Uri, "details", "ru-nkcki", summary: null, new AdvisoryProvenance( | ||||||
|  |                 RuNkckiConnectorPlugin.SourceName, | ||||||
|  |                 "reference", | ||||||
|  |                 document.Uri, | ||||||
|  |                 recordedAt, | ||||||
|  |                 new[] { ProvenanceFieldMasks.References })) | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(dto.FstecId)) | ||||||
|  |         { | ||||||
|  |             var slug = dto.FstecId!.Contains(':', StringComparison.Ordinal) | ||||||
|  |                 ? dto.FstecId[(dto.FstecId.IndexOf(':') + 1)..] | ||||||
|  |                 : dto.FstecId; | ||||||
|  |             var bduUrl = $"https://bdu.fstec.ru/vul/{slug}"; | ||||||
|  |             references.Add(new AdvisoryReference(bduUrl, "details", "bdu", summary: null, new AdvisoryProvenance( | ||||||
|  |                 RuNkckiConnectorPlugin.SourceName, | ||||||
|  |                 "reference", | ||||||
|  |                 bduUrl, | ||||||
|  |                 recordedAt, | ||||||
|  |                 new[] { ProvenanceFieldMasks.References }))); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach (var url in dto.Urls) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(url)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var kind = url.Contains("cert.gov.ru", StringComparison.OrdinalIgnoreCase) ? "details" : "external"; | ||||||
|  |             var sourceTag = url.Contains("siemens", StringComparison.OrdinalIgnoreCase) ? "vendor" : null; | ||||||
|  |             references.Add(new AdvisoryReference(url, kind, sourceTag, summary: null, new AdvisoryProvenance( | ||||||
|  |                 RuNkckiConnectorPlugin.SourceName, | ||||||
|  |                 "reference", | ||||||
|  |                 url, | ||||||
|  |                 recordedAt, | ||||||
|  |                 new[] { ProvenanceFieldMasks.References }))); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (dto.Cwe?.Number is int number) | ||||||
|  |         { | ||||||
|  |             var url = $"https://cwe.mitre.org/data/definitions/{number}.html"; | ||||||
|  |             references.Add(new AdvisoryReference(url, "cwe", "cwe", dto.Cwe.Description, new AdvisoryProvenance( | ||||||
|  |                 RuNkckiConnectorPlugin.SourceName, | ||||||
|  |                 "reference", | ||||||
|  |                 url, | ||||||
|  |                 recordedAt, | ||||||
|  |                 new[] { ProvenanceFieldMasks.References }))); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return references; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static IReadOnlyList<AffectedPackage> BuildPackages(RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(dto.VulnerableSoftwareText)) | ||||||
|  |         { | ||||||
|  |             return Array.Empty<AffectedPackage>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var identifier = dto.VulnerableSoftwareText!.Replace('\n', ' ').Replace('\r', ' ').Trim(); | ||||||
|  |         if (identifier.Length == 0) | ||||||
|  |         { | ||||||
|  |             return Array.Empty<AffectedPackage>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var packageProvenance = new AdvisoryProvenance( | ||||||
|  |             RuNkckiConnectorPlugin.SourceName, | ||||||
|  |             "package", | ||||||
|  |             identifier, | ||||||
|  |             recordedAt, | ||||||
|  |             new[] { ProvenanceFieldMasks.AffectedPackages }); | ||||||
|  |  | ||||||
|  |         var status = new AffectedPackageStatus( | ||||||
|  |             dto.PatchAvailable == true ? AffectedPackageStatusCatalog.Fixed : AffectedPackageStatusCatalog.Affected, | ||||||
|  |             new AdvisoryProvenance( | ||||||
|  |                 RuNkckiConnectorPlugin.SourceName, | ||||||
|  |                 "package-status", | ||||||
|  |                 dto.PatchAvailable == true ? "patch_available" : "affected", | ||||||
|  |                 recordedAt, | ||||||
|  |                 new[] { ProvenanceFieldMasks.PackageStatuses })); | ||||||
|  |  | ||||||
|  |         return new[] | ||||||
|  |         { | ||||||
|  |             new AffectedPackage( | ||||||
|  |                 dto.VulnerableSoftwareHasCpe == true ? AffectedPackageTypes.Cpe : AffectedPackageTypes.Vendor, | ||||||
|  |                 identifier, | ||||||
|  |                 platform: null, | ||||||
|  |                 versionRanges: null, | ||||||
|  |                 statuses: new[] { status }, | ||||||
|  |                 provenance: new[] { packageProvenance }) | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static IReadOnlyList<CvssMetric> BuildCvssMetrics(RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt, out string? severity) | ||||||
|  |     { | ||||||
|  |         severity = null; | ||||||
|  |         var metrics = new List<CvssMetric>(); | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(dto.CvssVector) && CvssMetricNormalizer.TryNormalize(null, dto.CvssVector, dto.CvssScore, null, out var normalized)) | ||||||
|  |         { | ||||||
|  |             var provenance = new AdvisoryProvenance( | ||||||
|  |                 RuNkckiConnectorPlugin.SourceName, | ||||||
|  |                 "cvss", | ||||||
|  |                 normalized.Vector, | ||||||
|  |                 recordedAt, | ||||||
|  |                 new[] { ProvenanceFieldMasks.CvssMetrics }); | ||||||
|  |             var metric = normalized.ToModel(provenance); | ||||||
|  |             metrics.Add(metric); | ||||||
|  |             severity ??= metric.BaseSeverity; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return metrics; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string? NormalizeSeverity(string? rating) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(rating)) | ||||||
|  |         { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var normalized = rating.Trim().ToLowerInvariant(); | ||||||
|  |  | ||||||
|  |         if (SeverityLookup.TryGetValue(normalized, out var mapped)) | ||||||
|  |         { | ||||||
|  |             return mapped; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (normalized.StartsWith("крит", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             return "critical"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (normalized.StartsWith("высок", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             return "high"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (normalized.StartsWith("сред", StringComparison.Ordinal) || normalized.StartsWith("умер", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             return "medium"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (normalized.StartsWith("низк", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             return "low"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (normalized.StartsWith("информ", StringComparison.Ordinal)) | ||||||
|  |         { | ||||||
|  |             return "informational"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ChooseMoreSevere(string first, string second) | ||||||
|  |     { | ||||||
|  |         var order = new[] { "critical", "high", "medium", "low", "informational" }; | ||||||
|  |  | ||||||
|  |         static int IndexOf(ReadOnlySpan<string> levels, string value) | ||||||
|  |         { | ||||||
|  |             for (var i = 0; i < levels.Length; i++) | ||||||
|  |             { | ||||||
|  |                 if (string.Equals(levels[i], value, StringComparison.OrdinalIgnoreCase)) | ||||||
|  |                 { | ||||||
|  |                     return i; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return -1; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var firstIndex = IndexOf(order.AsSpan(), first); | ||||||
|  |         var secondIndex = IndexOf(order.AsSpan(), second); | ||||||
|  |  | ||||||
|  |         if (firstIndex == -1 && secondIndex == -1) | ||||||
|  |         { | ||||||
|  |             return first; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (firstIndex == -1) | ||||||
|  |         { | ||||||
|  |             return second; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (secondIndex == -1) | ||||||
|  |         { | ||||||
|  |             return first; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return firstIndex <= secondIndex ? first : second; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool DetermineExploitKnown(RuNkckiVulnerabilityDto dto) | ||||||
|  |     { | ||||||
|  |         if (!string.IsNullOrWhiteSpace(dto.MethodOfExploitation)) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!string.IsNullOrWhiteSpace(dto.Impact)) | ||||||
|  |         { | ||||||
|  |             var impact = dto.Impact.Trim().ToUpperInvariant(); | ||||||
|  |             if (impact is "ACE" or "RCE" or "LPE") | ||||||
|  |             { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,10 +2,10 @@ | |||||||
| | Task | Owner(s) | Depends on | Notes | | | Task | Owner(s) | Depends on | Notes | | ||||||
| |---|---|---|---| | |---|---|---|---| | ||||||
| |FEEDCONN-NKCKI-02-001 Research NKTsKI advisory feeds|BE-Conn-Nkcki|Research|**DONE (2025-10-11)** – Candidate RSS locations (`https://cert.gov.ru/rss/advisories.xml`, `https://www.cert.gov.ru/...`) return 403/404 even with `Accept-Language: ru-RU` and `--insecure`; site is Bitrix-backed and expects Russian Trusted Sub CA plus session cookies. Logged packet captures + needed cert list in `docs/feedser-connector-research-20251011.md`; waiting on Ops for sanctioned trust bundle.| | |FEEDCONN-NKCKI-02-001 Research NKTsKI advisory feeds|BE-Conn-Nkcki|Research|**DONE (2025-10-11)** – Candidate RSS locations (`https://cert.gov.ru/rss/advisories.xml`, `https://www.cert.gov.ru/...`) return 403/404 even with `Accept-Language: ru-RU` and `--insecure`; site is Bitrix-backed and expects Russian Trusted Sub CA plus session cookies. Logged packet captures + needed cert list in `docs/feedser-connector-research-20251011.md`; waiting on Ops for sanctioned trust bundle.| | ||||||
| |FEEDCONN-NKCKI-02-002 Fetch pipeline & state persistence|BE-Conn-Nkcki|Source.Common, Storage.Mongo|**TODO** – Implement fetch job with custom trust store, optional SOCKS proxy, and Bitrix session bootstrap (`PHPSESSID`, `BITRIX_SM_GUEST_ID`). Persist raw XML/HTML + derived cursor (advisory ID + `pubDate`), handle 403 retries with exponential backoff.| | |FEEDCONN-NKCKI-02-002 Fetch pipeline & state persistence|BE-Conn-Nkcki|Source.Common, Storage.Mongo|**DOING (2025-10-12)** – Listing fetch now expands `*.json.zip` bulletins into per-vulnerability JSON documents with cursor-tracked bulletin IDs and trust store wiring (`globalsign_r6_bundle.pem`). Parser/mapper emit canonical advisories; remaining work: strengthen pagination/backfill handling and add regression fixtures/telemetry. Offline cache helpers (ProcessCachedBulletinsAsync/TryReadCachedBulletin/TryWriteCachedBulletin) implemented.| | ||||||
| |FEEDCONN-NKCKI-02-003 DTO & parser implementation|BE-Conn-Nkcki|Source.Common|**TODO** – Build DTOs for NKTsKI advisories, sanitise HTML, extract vendors/products, CVEs, mitigation guidance.| | |FEEDCONN-NKCKI-02-003 DTO & parser implementation|BE-Conn-Nkcki|Source.Common|**DOING (2025-10-12)** – `RuNkckiJsonParser` extracts per-vulnerability JSON payloads (IDs, CVEs, CVSS, software text, URLs). TODO: extend coverage for optional fields (ICS categories, nested arrays) and add fixture snapshots.| | ||||||
| |FEEDCONN-NKCKI-02-004 Canonical mapping & range primitives|BE-Conn-Nkcki|Models|**TODO** – Map advisories into canonical records with aliases, references, and vendor range primitives. Coordinate normalized outputs and provenance per `../StellaOps.Feedser.Merge/RANGE_PRIMITIVES_COORDINATION.md`.<br>2025-10-11 research trail: normalized payload target `[{"scheme":"semver","type":"range","min":"<start>","minInclusive":true,"max":"<end>","maxInclusive":false,"notes":"ru.nkcki:advisory-id"}]`; retain Cyrillic identifiers in `notes` so storage provenance remains intact.| | |FEEDCONN-NKCKI-02-004 Canonical mapping & range primitives|BE-Conn-Nkcki|Models|**DOING (2025-10-12)** – `RuNkckiMapper` maps JSON entries to canonical advisories (aliases, references, vendor package, CVSS). Next steps: enrich package parsing (`software_text` tokenisation), consider CVSS v4 metadata, and backfill provenance docs before closing the task.| | ||||||
| |FEEDCONN-NKCKI-02-005 Deterministic fixtures & tests|QA|Testing|**TODO** – Add regression tests supporting `UPDATE_NKCKI_FIXTURES=1` for snapshot regeneration.| | |FEEDCONN-NKCKI-02-005 Deterministic fixtures & tests|QA|Testing|**DOING (2025-10-12)** – Added mocked listing/bulletin regression harness (`RuNkckiConnectorTests`) with fixtures + snapshot writer. Test run currently blocked on Mongo2Go dependency (libcrypto.so.1.1 missing); follow-up required to get embedded mongod running in CI before marking DONE.| | ||||||
| |FEEDCONN-NKCKI-02-006 Telemetry & documentation|DevEx|Docs|**TODO** – Add logging/metrics, document connector configuration, and close backlog entry after deliverable ships.| | |FEEDCONN-NKCKI-02-006 Telemetry & documentation|DevEx|Docs|**TODO** – Add logging/metrics, document connector configuration, and close backlog entry after deliverable ships.| | ||||||
| |FEEDCONN-NKCKI-02-007 Archive ingestion strategy|BE-Conn-Nkcki|Research|**TODO** – Once access restored, map Bitrix paging (`?PAGEN_1=`) and advisory taxonomy (alerts vs recommendations). Outline HTML scrape + PDF attachment handling for backfill and decide translation approach for Russian-only content.| | |FEEDCONN-NKCKI-02-007 Archive ingestion strategy|BE-Conn-Nkcki|Research|**TODO** – Once access restored, map Bitrix paging (`?PAGEN_1=`) and advisory taxonomy (alerts vs recommendations). Outline HTML scrape + PDF attachment handling for backfill and decide translation approach for Russian-only content.| | ||||||
| |FEEDCONN-NKCKI-02-008 Access enablement plan|BE-Conn-Nkcki|Source.Common|**DONE (2025-10-11)** – Documented trust-store requirement, optional SOCKS proxy fallback, and monitoring plan; shared TLS support now available via `SourceHttpClientOptions.TrustedRootCertificates` (`feedser:httpClients:source.nkcki:*`), awaiting Ops-sourced cert bundle before fetch implementation.| | |FEEDCONN-NKCKI-02-008 Access enablement plan|BE-Conn-Nkcki|Source.Common|**DONE (2025-10-11)** – Documented trust-store requirement, optional SOCKS proxy fallback, and monitoring plan; shared TLS support now available via `SourceHttpClientOptions.TrustedRootCertificates` (`feedser:httpClients:source.nkcki:*`), awaiting Ops-sourced cert bundle before fetch implementation.| | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user