diff --git a/SPRINTS.md b/SPRINTS.md
index c511c84e..e90e4e6c 100644
--- a/SPRINTS.md
+++ b/SPRINTS.md
@@ -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.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.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.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.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 | 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.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.
Instructions to work:
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. |
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/bulletin-sample.json.zip b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/bulletin-sample.json.zip
new file mode 100644
index 00000000..52ef47b2
Binary files /dev/null and b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/bulletin-sample.json.zip differ
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/listing.html b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/listing.html
new file mode 100644
index 00000000..56e04c36
--- /dev/null
+++ b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/listing.html
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json
new file mode 100644
index 00000000..f382b68c
--- /dev/null
+++ b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/Fixtures/nkcki-advisories.snapshot.json
@@ -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"
+ }
+]
\ No newline at end of file
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs
new file mode 100644
index 00000000..84f1c4f9
--- /dev/null
+++ b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/RuNkckiConnectorTests.cs
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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 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);
+
+ 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(RuNkckiOptions.HttpClientName, builderOptions =>
+ {
+ builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
+ });
+
+ try
+ {
+ var provider = services.BuildServiceProvider();
+ var bootstrapper = provider.GetRequiredService();
+ 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);
+}
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/RuNkckiMapperTests.cs b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/RuNkckiMapperTests.cs
new file mode 100644
index 00000000..cd74a046
--- /dev/null
+++ b/src/StellaOps.Feedser.Source.Ru.Nkcki.Tests/RuNkckiMapperTests.cs
@@ -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));
+ }
+}
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiMapper.cs b/src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiMapper.cs
new file mode 100644
index 00000000..ad348ca9
--- /dev/null
+++ b/src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiMapper.cs
@@ -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 SeverityLookup = new Dictionary(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 BuildAliases(RuNkckiVulnerabilityDto dto)
+ {
+ var aliases = new HashSet(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 BuildReferences(RuNkckiVulnerabilityDto dto, DocumentRecord document, DateTimeOffset recordedAt)
+ {
+ var references = new List
+ {
+ 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 BuildPackages(RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt)
+ {
+ if (string.IsNullOrWhiteSpace(dto.VulnerableSoftwareText))
+ {
+ return Array.Empty();
+ }
+
+ var identifier = dto.VulnerableSoftwareText!.Replace('\n', ' ').Replace('\r', ' ').Trim();
+ if (identifier.Length == 0)
+ {
+ return Array.Empty();
+ }
+
+ 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 BuildCvssMetrics(RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt, out string? severity)
+ {
+ severity = null;
+ var metrics = new List();
+
+ 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 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;
+ }
+}
diff --git a/src/StellaOps.Feedser.Source.Ru.Nkcki/TASKS.md b/src/StellaOps.Feedser.Source.Ru.Nkcki/TASKS.md
index 937f0637..739974a4 100644
--- a/src/StellaOps.Feedser.Source.Ru.Nkcki/TASKS.md
+++ b/src/StellaOps.Feedser.Source.Ru.Nkcki/TASKS.md
@@ -2,10 +2,10 @@
| 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-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-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-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`.
2025-10-11 research trail: normalized payload target `[{"scheme":"semver","type":"range","min":"","minInclusive":true,"max":"","maxInclusive":false,"notes":"ru.nkcki:advisory-id"}]`; retain Cyrillic identifiers in `notes` so storage provenance remains intact.|
-|FEEDCONN-NKCKI-02-005 Deterministic fixtures & tests|QA|Testing|**TODO** – Add regression tests supporting `UPDATE_NKCKI_FIXTURES=1` for snapshot regeneration.|
+|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|**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|**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|**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-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.|