tam
This commit is contained in:
		@@ -1,29 +0,0 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Plugin;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki;
 | 
			
		||||
 | 
			
		||||
public sealed class RuNkckiConnectorPlugin : IConnectorPlugin
 | 
			
		||||
{
 | 
			
		||||
    public string Name => "ru-nkcki";
 | 
			
		||||
 | 
			
		||||
    public bool IsAvailable(IServiceProvider services) => true;
 | 
			
		||||
 | 
			
		||||
    public IFeedConnector Create(IServiceProvider services) => new StubConnector(Name);
 | 
			
		||||
 | 
			
		||||
    private sealed class StubConnector : IFeedConnector
 | 
			
		||||
    {
 | 
			
		||||
        public StubConnector(string sourceName) => SourceName = sourceName;
 | 
			
		||||
 | 
			
		||||
        public string SourceName { get; }
 | 
			
		||||
 | 
			
		||||
        public Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken) => Task.CompletedTask;
 | 
			
		||||
 | 
			
		||||
        public Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken) => Task.CompletedTask;
 | 
			
		||||
 | 
			
		||||
        public Task MapAsync(IServiceProvider services, CancellationToken cancellationToken) => Task.CompletedTask;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -0,0 +1,127 @@
 | 
			
		||||
using System.Net;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki.Configuration;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Connector options for the Russian NKTsKI bulletin ingestion pipeline.
 | 
			
		||||
/// </summary>
 | 
			
		||||
public sealed class RuNkckiOptions
 | 
			
		||||
{
 | 
			
		||||
    public const string HttpClientName = "ru-nkcki";
 | 
			
		||||
 | 
			
		||||
    private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(90);
 | 
			
		||||
    private static readonly TimeSpan DefaultFailureBackoff = TimeSpan.FromMinutes(20);
 | 
			
		||||
    private static readonly TimeSpan DefaultListingCache = TimeSpan.FromMinutes(10);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Base endpoint used for resolving relative resource links.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public Uri BaseAddress { get; set; } = new("https://cert.gov.ru/", UriKind.Absolute);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Relative path to the bulletin listing page.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string ListingPath { get; set; } = "materialy/uyazvimosti/";
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Timeout applied to listing and bulletin fetch requests.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public TimeSpan RequestTimeout { get; set; } = DefaultRequestTimeout;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Backoff applied when the listing or attachments cannot be retrieved.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public TimeSpan FailureBackoff { get; set; } = DefaultFailureBackoff;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Maximum number of bulletin attachments downloaded per fetch run.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public int MaxBulletinsPerFetch { get; set; } = 5;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Maximum number of vulnerabilities ingested per fetch cycle across all attachments.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public int MaxVulnerabilitiesPerFetch { get; set; } = 250;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Maximum bulletin identifiers remembered to avoid refetching historical files.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public int KnownBulletinCapacity { get; set; } = 512;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Delay between sequential bulletin downloads.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public TimeSpan RequestDelay { get; set; } = TimeSpan.FromMilliseconds(250);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Duration the HTML listing can be cached before forcing a refetch.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public TimeSpan ListingCacheDuration { get; set; } = DefaultListingCache;
 | 
			
		||||
 | 
			
		||||
    public string UserAgent { get; set; } = "StellaOps/Feedser (+https://stella-ops.org)";
 | 
			
		||||
 | 
			
		||||
    public string AcceptLanguage { get; set; } = "ru-RU,ru;q=0.9,en-US;q=0.6,en;q=0.4";
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Absolute URI for the listing page.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public Uri ListingUri => new(BaseAddress, ListingPath);
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Optional directory for caching downloaded bulletins (relative paths resolve under the content root).
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public string? CacheDirectory { get; set; } = null;
 | 
			
		||||
 | 
			
		||||
    public void Validate()
 | 
			
		||||
    {
 | 
			
		||||
        if (BaseAddress is null || !BaseAddress.IsAbsoluteUri)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki BaseAddress must be an absolute URI.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(ListingPath))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki ListingPath must be provided.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (RequestTimeout <= TimeSpan.Zero)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki RequestTimeout must be positive.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (FailureBackoff < TimeSpan.Zero)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki FailureBackoff cannot be negative.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (MaxBulletinsPerFetch <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki MaxBulletinsPerFetch must be greater than zero.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (MaxVulnerabilitiesPerFetch <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki MaxVulnerabilitiesPerFetch must be greater than zero.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (KnownBulletinCapacity <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki KnownBulletinCapacity must be greater than zero.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (CacheDirectory is not null && CacheDirectory.Trim().Length == 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki CacheDirectory cannot be whitespace.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(UserAgent))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki UserAgent cannot be empty.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(AcceptLanguage))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("RuNkcki AcceptLanguage cannot be empty.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										108
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiCursor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/Internal/RuNkckiCursor.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki.Internal;
 | 
			
		||||
 | 
			
		||||
internal sealed record RuNkckiCursor(
 | 
			
		||||
    IReadOnlyCollection<Guid> PendingDocuments,
 | 
			
		||||
    IReadOnlyCollection<Guid> PendingMappings,
 | 
			
		||||
    IReadOnlyCollection<string> KnownBulletins,
 | 
			
		||||
    DateTimeOffset? LastListingFetchAt)
 | 
			
		||||
{
 | 
			
		||||
    private static readonly IReadOnlyCollection<Guid> EmptyGuids = Array.Empty<Guid>();
 | 
			
		||||
    private static readonly IReadOnlyCollection<string> EmptyBulletins = Array.Empty<string>();
 | 
			
		||||
 | 
			
		||||
    public static RuNkckiCursor Empty { get; } = new(EmptyGuids, EmptyGuids, EmptyBulletins, null);
 | 
			
		||||
 | 
			
		||||
    public RuNkckiCursor WithPendingDocuments(IEnumerable<Guid> documents)
 | 
			
		||||
        => this with { PendingDocuments = (documents ?? Enumerable.Empty<Guid>()).Distinct().ToArray() };
 | 
			
		||||
 | 
			
		||||
    public RuNkckiCursor WithPendingMappings(IEnumerable<Guid> mappings)
 | 
			
		||||
        => this with { PendingMappings = (mappings ?? Enumerable.Empty<Guid>()).Distinct().ToArray() };
 | 
			
		||||
 | 
			
		||||
    public RuNkckiCursor WithKnownBulletins(IEnumerable<string> bulletins)
 | 
			
		||||
        => this with { KnownBulletins = (bulletins ?? Enumerable.Empty<string>()).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() };
 | 
			
		||||
 | 
			
		||||
    public RuNkckiCursor WithLastListingFetch(DateTimeOffset? timestamp)
 | 
			
		||||
        => this with { LastListingFetchAt = timestamp };
 | 
			
		||||
 | 
			
		||||
    public BsonDocument ToBsonDocument()
 | 
			
		||||
    {
 | 
			
		||||
        var document = new BsonDocument
 | 
			
		||||
        {
 | 
			
		||||
            ["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
 | 
			
		||||
            ["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
 | 
			
		||||
            ["knownBulletins"] = new BsonArray(KnownBulletins),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (LastListingFetchAt.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            document["lastListingFetchAt"] = LastListingFetchAt.Value.UtcDateTime;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return document;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static RuNkckiCursor FromBson(BsonDocument? document)
 | 
			
		||||
    {
 | 
			
		||||
        if (document is null || document.ElementCount == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Empty;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
 | 
			
		||||
        var pendingMappings = ReadGuidArray(document, "pendingMappings");
 | 
			
		||||
        var knownBulletins = ReadStringArray(document, "knownBulletins");
 | 
			
		||||
        var lastListingFetch = document.TryGetValue("lastListingFetchAt", out var dateValue)
 | 
			
		||||
            ? ParseDate(dateValue)
 | 
			
		||||
            : null;
 | 
			
		||||
 | 
			
		||||
        return new RuNkckiCursor(pendingDocuments, pendingMappings, knownBulletins, lastListingFetch);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
 | 
			
		||||
    {
 | 
			
		||||
        if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyGuids;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = new List<Guid>(array.Count);
 | 
			
		||||
        foreach (var element in array)
 | 
			
		||||
        {
 | 
			
		||||
            if (Guid.TryParse(element?.ToString(), out var guid))
 | 
			
		||||
            {
 | 
			
		||||
                result.Add(guid);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyCollection<string> ReadStringArray(BsonDocument document, string field)
 | 
			
		||||
    {
 | 
			
		||||
        if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyBulletins;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = new List<string>(array.Count);
 | 
			
		||||
        foreach (var element in array)
 | 
			
		||||
        {
 | 
			
		||||
            var text = element?.ToString();
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(text))
 | 
			
		||||
            {
 | 
			
		||||
                result.Add(text);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static DateTimeOffset? ParseDate(BsonValue value)
 | 
			
		||||
        => value.BsonType switch
 | 
			
		||||
        {
 | 
			
		||||
            BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
 | 
			
		||||
            BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
 | 
			
		||||
            _ => null,
 | 
			
		||||
        };
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,169 @@
 | 
			
		||||
using System.Collections.Immutable;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki.Internal;
 | 
			
		||||
 | 
			
		||||
internal static class RuNkckiJsonParser
 | 
			
		||||
{
 | 
			
		||||
    public static RuNkckiVulnerabilityDto Parse(JsonElement element)
 | 
			
		||||
    {
 | 
			
		||||
        var fstecId = element.TryGetProperty("vuln_id", out var vulnIdElement) && vulnIdElement.TryGetProperty("FSTEC", out var fstec) ? Normalize(fstec.GetString()) : null;
 | 
			
		||||
        var mitreId = element.TryGetProperty("vuln_id", out vulnIdElement) && vulnIdElement.TryGetProperty("MITRE", out var mitre) ? Normalize(mitre.GetString()) : null;
 | 
			
		||||
 | 
			
		||||
        var datePublished = ParseDate(element.TryGetProperty("date_published", out var published) ? published.GetString() : null);
 | 
			
		||||
        var dateUpdated = ParseDate(element.TryGetProperty("date_updated", out var updated) ? updated.GetString() : null);
 | 
			
		||||
        var cvssRating = Normalize(element.TryGetProperty("cvss_rating", out var rating) ? rating.GetString() : null);
 | 
			
		||||
        bool? patchAvailable = element.TryGetProperty("patch_available", out var patch) ? patch.ValueKind switch
 | 
			
		||||
        {
 | 
			
		||||
            JsonValueKind.True => true,
 | 
			
		||||
            JsonValueKind.False => false,
 | 
			
		||||
            _ => null,
 | 
			
		||||
        } : null;
 | 
			
		||||
 | 
			
		||||
        var description = Normalize(element.TryGetProperty("description", out var desc) ? desc.GetString() : null);
 | 
			
		||||
        var mitigation = Normalize(element.TryGetProperty("mitigation", out var mitigationElement) ? mitigationElement.GetString() : null);
 | 
			
		||||
        var productCategory = Normalize(element.TryGetProperty("product_category", out var category) ? category.GetString() : null);
 | 
			
		||||
        var impact = Normalize(element.TryGetProperty("impact", out var impactElement) ? impactElement.GetString() : null);
 | 
			
		||||
        var method = Normalize(element.TryGetProperty("method_of_exploitation", out var methodElement) ? methodElement.GetString() : null);
 | 
			
		||||
 | 
			
		||||
        bool? userInteraction = element.TryGetProperty("user_interaction", out var uiElement) ? uiElement.ValueKind switch
 | 
			
		||||
        {
 | 
			
		||||
            JsonValueKind.True => true,
 | 
			
		||||
            JsonValueKind.False => false,
 | 
			
		||||
            _ => null,
 | 
			
		||||
        } : null;
 | 
			
		||||
 | 
			
		||||
        string? softwareText = null;
 | 
			
		||||
        bool? softwareHasCpe = null;
 | 
			
		||||
        if (element.TryGetProperty("vulnerable_software", out var softwareElement))
 | 
			
		||||
        {
 | 
			
		||||
            if (softwareElement.TryGetProperty("software_text", out var textElement))
 | 
			
		||||
            {
 | 
			
		||||
                softwareText = Normalize(textElement.GetString()?.Replace('\r', ' '));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (softwareElement.TryGetProperty("cpe", out var cpeElement))
 | 
			
		||||
            {
 | 
			
		||||
                softwareHasCpe = cpeElement.ValueKind switch
 | 
			
		||||
                {
 | 
			
		||||
                    JsonValueKind.True => true,
 | 
			
		||||
                    JsonValueKind.False => false,
 | 
			
		||||
                    _ => null,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        RuNkckiCweDto? cweDto = null;
 | 
			
		||||
        if (element.TryGetProperty("cwe", out var cweElement))
 | 
			
		||||
        {
 | 
			
		||||
            int? number = null;
 | 
			
		||||
            if (cweElement.TryGetProperty("cwe_number", out var numberElement))
 | 
			
		||||
            {
 | 
			
		||||
                if (numberElement.ValueKind == JsonValueKind.Number && numberElement.TryGetInt32(out var parsed))
 | 
			
		||||
                {
 | 
			
		||||
                    number = parsed;
 | 
			
		||||
                }
 | 
			
		||||
                else if (int.TryParse(numberElement.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt))
 | 
			
		||||
                {
 | 
			
		||||
                    number = parsedInt;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var cweDescription = Normalize(cweElement.TryGetProperty("cwe_description", out var descElement) ? descElement.GetString() : null);
 | 
			
		||||
            if (number.HasValue || !string.IsNullOrWhiteSpace(cweDescription))
 | 
			
		||||
            {
 | 
			
		||||
                cweDto = new RuNkckiCweDto(number, cweDescription);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        double? cvssScore = element.TryGetProperty("cvss", out var cvssElement) && cvssElement.TryGetProperty("cvss_score", out var scoreElement)
 | 
			
		||||
            ? ParseDouble(scoreElement)
 | 
			
		||||
            : null;
 | 
			
		||||
        var cvssVector = element.TryGetProperty("cvss", out cvssElement) && cvssElement.TryGetProperty("cvss_vector", out var vectorElement)
 | 
			
		||||
            ? Normalize(vectorElement.GetString())
 | 
			
		||||
            : null;
 | 
			
		||||
        double? cvssScoreV4 = element.TryGetProperty("cvss", out cvssElement) && cvssElement.TryGetProperty("cvss_score_v4", out var scoreV4Element)
 | 
			
		||||
            ? ParseDouble(scoreV4Element)
 | 
			
		||||
            : null;
 | 
			
		||||
        var cvssVectorV4 = element.TryGetProperty("cvss", out cvssElement) && cvssElement.TryGetProperty("cvss_vector_v4", out var vectorV4Element)
 | 
			
		||||
            ? Normalize(vectorV4Element.GetString())
 | 
			
		||||
            : null;
 | 
			
		||||
 | 
			
		||||
        var urls = element.TryGetProperty("urls", out var urlsElement) && urlsElement.ValueKind == JsonValueKind.Array
 | 
			
		||||
            ? urlsElement.EnumerateArray()
 | 
			
		||||
                .Select(static url => Normalize(url.GetString()))
 | 
			
		||||
                .Where(static url => !string.IsNullOrWhiteSpace(url))
 | 
			
		||||
                .Cast<string>()
 | 
			
		||||
                .ToImmutableArray()
 | 
			
		||||
            : ImmutableArray<string>.Empty;
 | 
			
		||||
 | 
			
		||||
        return new RuNkckiVulnerabilityDto(
 | 
			
		||||
            fstecId,
 | 
			
		||||
            mitreId,
 | 
			
		||||
            datePublished,
 | 
			
		||||
            dateUpdated,
 | 
			
		||||
            cvssRating,
 | 
			
		||||
            patchAvailable,
 | 
			
		||||
            description,
 | 
			
		||||
            cweDto,
 | 
			
		||||
            productCategory,
 | 
			
		||||
            mitigation,
 | 
			
		||||
            softwareText,
 | 
			
		||||
            softwareHasCpe,
 | 
			
		||||
            cvssScore,
 | 
			
		||||
            cvssVector,
 | 
			
		||||
            cvssScoreV4,
 | 
			
		||||
            cvssVectorV4,
 | 
			
		||||
            impact,
 | 
			
		||||
            method,
 | 
			
		||||
            userInteraction,
 | 
			
		||||
            urls);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static double? ParseDouble(JsonElement element)
 | 
			
		||||
    {
 | 
			
		||||
        if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var value))
 | 
			
		||||
        {
 | 
			
		||||
            return value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (element.ValueKind == JsonValueKind.String && double.TryParse(element.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed))
 | 
			
		||||
        {
 | 
			
		||||
            return parsed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static DateTimeOffset? ParseDate(string? value)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
 | 
			
		||||
        {
 | 
			
		||||
            return parsed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (DateTimeOffset.TryParse(value, CultureInfo.GetCultureInfo("ru-RU"), DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ruParsed))
 | 
			
		||||
        {
 | 
			
		||||
            return ruParsed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? Normalize(string? value)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return value.Replace('\r', ' ').Replace('\n', ' ').Trim();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,36 @@
 | 
			
		||||
using System.Collections.Immutable;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki.Internal;
 | 
			
		||||
 | 
			
		||||
internal sealed record RuNkckiVulnerabilityDto(
 | 
			
		||||
    string? FstecId,
 | 
			
		||||
    string? MitreId,
 | 
			
		||||
    DateTimeOffset? DatePublished,
 | 
			
		||||
    DateTimeOffset? DateUpdated,
 | 
			
		||||
    string? CvssRating,
 | 
			
		||||
    bool? PatchAvailable,
 | 
			
		||||
    string? Description,
 | 
			
		||||
    RuNkckiCweDto? Cwe,
 | 
			
		||||
    string? ProductCategory,
 | 
			
		||||
    string? Mitigation,
 | 
			
		||||
    string? VulnerableSoftwareText,
 | 
			
		||||
    bool? VulnerableSoftwareHasCpe,
 | 
			
		||||
    double? CvssScore,
 | 
			
		||||
    string? CvssVector,
 | 
			
		||||
    double? CvssScoreV4,
 | 
			
		||||
    string? CvssVectorV4,
 | 
			
		||||
    string? Impact,
 | 
			
		||||
    string? MethodOfExploitation,
 | 
			
		||||
    bool? UserInteraction,
 | 
			
		||||
    ImmutableArray<string> Urls)
 | 
			
		||||
{
 | 
			
		||||
    [JsonIgnore]
 | 
			
		||||
    public string AdvisoryKey => !string.IsNullOrWhiteSpace(FstecId)
 | 
			
		||||
        ? FstecId!
 | 
			
		||||
        : !string.IsNullOrWhiteSpace(MitreId)
 | 
			
		||||
            ? MitreId!
 | 
			
		||||
            : Guid.NewGuid().ToString();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed record RuNkckiCweDto(int? Number, string? Description);
 | 
			
		||||
							
								
								
									
										43
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/Jobs.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/Jobs.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
using StellaOps.Feedser.Core.Jobs;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki;
 | 
			
		||||
 | 
			
		||||
internal static class RuNkckiJobKinds
 | 
			
		||||
{
 | 
			
		||||
    public const string Fetch = "source:ru-nkcki:fetch";
 | 
			
		||||
    public const string Parse = "source:ru-nkcki:parse";
 | 
			
		||||
    public const string Map = "source:ru-nkcki:map";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class RuNkckiFetchJob : IJob
 | 
			
		||||
{
 | 
			
		||||
    private readonly RuNkckiConnector _connector;
 | 
			
		||||
 | 
			
		||||
    public RuNkckiFetchJob(RuNkckiConnector connector)
 | 
			
		||||
        => _connector = connector ?? throw new ArgumentNullException(nameof(connector));
 | 
			
		||||
 | 
			
		||||
    public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
 | 
			
		||||
        => _connector.FetchAsync(context.Services, cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class RuNkckiParseJob : IJob
 | 
			
		||||
{
 | 
			
		||||
    private readonly RuNkckiConnector _connector;
 | 
			
		||||
 | 
			
		||||
    public RuNkckiParseJob(RuNkckiConnector connector)
 | 
			
		||||
        => _connector = connector ?? throw new ArgumentNullException(nameof(connector));
 | 
			
		||||
 | 
			
		||||
    public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
 | 
			
		||||
        => _connector.ParseAsync(context.Services, cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class RuNkckiMapJob : IJob
 | 
			
		||||
{
 | 
			
		||||
    private readonly RuNkckiConnector _connector;
 | 
			
		||||
 | 
			
		||||
    public RuNkckiMapJob(RuNkckiConnector connector)
 | 
			
		||||
        => _connector = connector ?? throw new ArgumentNullException(nameof(connector));
 | 
			
		||||
 | 
			
		||||
    public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
 | 
			
		||||
        => _connector.MapAsync(context.Services, cancellationToken);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
using System.Runtime.CompilerServices;
 | 
			
		||||
 | 
			
		||||
[assembly: InternalsVisibleTo("StellaOps.Feedser.Source.Ru.Nkcki.Tests")]
 | 
			
		||||
							
								
								
									
										825
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/RuNkckiConnector.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										825
									
								
								src/StellaOps.Feedser.Source.Ru.Nkcki/RuNkckiConnector.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,825 @@
 | 
			
		||||
using System.Collections.Immutable;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.IO.Compression;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
using AngleSharp.Html.Parser;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using StellaOps.Feedser.Source.Common;
 | 
			
		||||
using StellaOps.Feedser.Source.Common.Fetch;
 | 
			
		||||
using StellaOps.Feedser.Source.Ru.Nkcki.Configuration;
 | 
			
		||||
using StellaOps.Feedser.Source.Ru.Nkcki.Internal;
 | 
			
		||||
using StellaOps.Feedser.Storage.Mongo;
 | 
			
		||||
using StellaOps.Feedser.Storage.Mongo.Advisories;
 | 
			
		||||
using StellaOps.Feedser.Storage.Mongo.Documents;
 | 
			
		||||
using StellaOps.Feedser.Storage.Mongo.Dtos;
 | 
			
		||||
using StellaOps.Plugin;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki;
 | 
			
		||||
 | 
			
		||||
public sealed class RuNkckiConnector : IFeedConnector
 | 
			
		||||
{
 | 
			
		||||
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
 | 
			
		||||
    {
 | 
			
		||||
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
 | 
			
		||||
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 | 
			
		||||
        WriteIndented = false,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private static readonly string[] ListingAcceptHeaders =
 | 
			
		||||
    {
 | 
			
		||||
        "text/html",
 | 
			
		||||
        "application/xhtml+xml;q=0.9",
 | 
			
		||||
        "text/plain;q=0.1",
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private static readonly string[] BulletinAcceptHeaders =
 | 
			
		||||
    {
 | 
			
		||||
        "application/zip",
 | 
			
		||||
        "application/octet-stream",
 | 
			
		||||
        "application/x-zip-compressed",
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private readonly SourceFetchService _fetchService;
 | 
			
		||||
    private readonly RawDocumentStorage _rawDocumentStorage;
 | 
			
		||||
    private readonly IDocumentStore _documentStore;
 | 
			
		||||
    private readonly IDtoStore _dtoStore;
 | 
			
		||||
    private readonly IAdvisoryStore _advisoryStore;
 | 
			
		||||
    private readonly ISourceStateRepository _stateRepository;
 | 
			
		||||
    private readonly RuNkckiOptions _options;
 | 
			
		||||
    private readonly TimeProvider _timeProvider;
 | 
			
		||||
    private readonly ILogger<RuNkckiConnector> _logger;
 | 
			
		||||
    private readonly string _cacheDirectory;
 | 
			
		||||
 | 
			
		||||
    private readonly HtmlParser _htmlParser = new();
 | 
			
		||||
 | 
			
		||||
    public RuNkckiConnector(
 | 
			
		||||
        SourceFetchService fetchService,
 | 
			
		||||
        RawDocumentStorage rawDocumentStorage,
 | 
			
		||||
        IDocumentStore documentStore,
 | 
			
		||||
        IDtoStore dtoStore,
 | 
			
		||||
        IAdvisoryStore advisoryStore,
 | 
			
		||||
        ISourceStateRepository stateRepository,
 | 
			
		||||
        IOptions<RuNkckiOptions> options,
 | 
			
		||||
        TimeProvider? timeProvider,
 | 
			
		||||
        ILogger<RuNkckiConnector> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
 | 
			
		||||
        _rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
 | 
			
		||||
        _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
 | 
			
		||||
        _dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
 | 
			
		||||
        _advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
 | 
			
		||||
        _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
 | 
			
		||||
        _options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
 | 
			
		||||
        _options.Validate();
 | 
			
		||||
        _timeProvider = timeProvider ?? TimeProvider.System;
 | 
			
		||||
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
        _cacheDirectory = ResolveCacheDirectory(_options.CacheDirectory);
 | 
			
		||||
        EnsureCacheDirectory();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public string SourceName => RuNkckiConnectorPlugin.SourceName;
 | 
			
		||||
 | 
			
		||||
    public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
 | 
			
		||||
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var pendingDocuments = cursor.PendingDocuments.ToHashSet();
 | 
			
		||||
        var pendingMappings = cursor.PendingMappings.ToHashSet();
 | 
			
		||||
        var knownBulletins = cursor.KnownBulletins.ToHashSet(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        var now = _timeProvider.GetUtcNow();
 | 
			
		||||
        var processed = 0;
 | 
			
		||||
 | 
			
		||||
        IReadOnlyList<BulletinAttachment> attachments = Array.Empty<BulletinAttachment>();
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var listingResult = await FetchListingAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (!listingResult.IsSuccess || listingResult.Content is null)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning("NKCKI listing fetch returned no content (status={Status})", listingResult.StatusCode);
 | 
			
		||||
                processed = await ProcessCachedBulletinsAsync(pendingDocuments, pendingMappings, knownBulletins, now, processed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                await UpdateCursorAsync(cursor
 | 
			
		||||
                    .WithPendingDocuments(pendingDocuments)
 | 
			
		||||
                    .WithPendingMappings(pendingMappings)
 | 
			
		||||
                    .WithKnownBulletins(NormalizeBulletins(knownBulletins))
 | 
			
		||||
                    .WithLastListingFetch(now), cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            attachments = await ParseListingAsync(listingResult.Content, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning(ex, "NKCKI listing fetch failed; attempting cached bulletins");
 | 
			
		||||
            processed = await ProcessCachedBulletinsAsync(pendingDocuments, pendingMappings, knownBulletins, now, processed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await UpdateCursorAsync(cursor
 | 
			
		||||
                .WithPendingDocuments(pendingDocuments)
 | 
			
		||||
                .WithPendingMappings(pendingMappings)
 | 
			
		||||
                .WithKnownBulletins(NormalizeBulletins(knownBulletins))
 | 
			
		||||
                .WithLastListingFetch(cursor.LastListingFetchAt ?? now), cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (attachments.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug("NKCKI listing contained no bulletin attachments");
 | 
			
		||||
            processed = await ProcessCachedBulletinsAsync(pendingDocuments, pendingMappings, knownBulletins, now, processed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await UpdateCursorAsync(cursor
 | 
			
		||||
                .WithPendingDocuments(pendingDocuments)
 | 
			
		||||
                .WithPendingMappings(pendingMappings)
 | 
			
		||||
                .WithKnownBulletins(NormalizeBulletins(knownBulletins))
 | 
			
		||||
                .WithLastListingFetch(now), cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var newAttachments = attachments
 | 
			
		||||
            .Where(attachment => !knownBulletins.Contains(attachment.Id))
 | 
			
		||||
            .Take(_options.MaxBulletinsPerFetch)
 | 
			
		||||
            .ToList();
 | 
			
		||||
 | 
			
		||||
        if (newAttachments.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            await UpdateCursorAsync(cursor
 | 
			
		||||
                .WithPendingDocuments(pendingDocuments)
 | 
			
		||||
                .WithPendingMappings(pendingMappings)
 | 
			
		||||
                .WithKnownBulletins(NormalizeBulletins(knownBulletins))
 | 
			
		||||
                .WithLastListingFetch(now), cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var attachment in newAttachments)
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var request = new SourceFetchRequest(RuNkckiOptions.HttpClientName, SourceName, attachment.Uri)
 | 
			
		||||
                {
 | 
			
		||||
                    AcceptHeaders = BulletinAcceptHeaders,
 | 
			
		||||
                    TimeoutOverride = _options.RequestTimeout,
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                var attachmentResult = await _fetchService.FetchContentAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                if (!attachmentResult.IsSuccess || attachmentResult.Content is null)
 | 
			
		||||
                {
 | 
			
		||||
                    if (TryReadCachedBulletin(attachment.Id, out var cachedBytes))
 | 
			
		||||
                    {
 | 
			
		||||
                        _logger.LogWarning("NKCKI bulletin {BulletinId} unavailable (status={Status}); using cached artefact", attachment.Id, attachmentResult.StatusCode);
 | 
			
		||||
                        processed = await ProcessBulletinEntriesAsync(cachedBytes, attachment.Id, pendingDocuments, pendingMappings, now, processed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                        knownBulletins.Add(attachment.Id);
 | 
			
		||||
                    }
 | 
			
		||||
                    else
 | 
			
		||||
                    {
 | 
			
		||||
                        _logger.LogWarning("NKCKI bulletin {BulletinId} returned no content (status={Status})", attachment.Id, attachmentResult.StatusCode);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                TryWriteCachedBulletin(attachment.Id, attachmentResult.Content);
 | 
			
		||||
                processed = await ProcessBulletinEntriesAsync(attachmentResult.Content, attachment.Id, pendingDocuments, pendingMappings, now, processed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                knownBulletins.Add(attachment.Id);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
 | 
			
		||||
            {
 | 
			
		||||
                if (TryReadCachedBulletin(attachment.Id, out var cachedBytes))
 | 
			
		||||
                {
 | 
			
		||||
                    _logger.LogWarning(ex, "NKCKI bulletin fetch failed for {BulletinId}; using cached artefact", attachment.Id);
 | 
			
		||||
                    processed = await ProcessBulletinEntriesAsync(cachedBytes, attachment.Id, pendingDocuments, pendingMappings, now, processed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    knownBulletins.Add(attachment.Id);
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    _logger.LogWarning(ex, "NKCKI bulletin fetch failed for {BulletinId}", attachment.Id);
 | 
			
		||||
                    await _stateRepository.MarkFailureAsync(SourceName, now, _options.FailureBackoff, ex.Message, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    throw;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (processed >= _options.MaxVulnerabilitiesPerFetch)
 | 
			
		||||
            {
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (_options.RequestDelay > TimeSpan.Zero)
 | 
			
		||||
            {
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                }
 | 
			
		||||
                catch (TaskCanceledException)
 | 
			
		||||
                {
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalizedBulletins = NormalizeBulletins(knownBulletins);
 | 
			
		||||
 | 
			
		||||
        var updatedCursor = cursor
 | 
			
		||||
            .WithPendingDocuments(pendingDocuments)
 | 
			
		||||
            .WithPendingMappings(pendingMappings)
 | 
			
		||||
            .WithKnownBulletins(normalizedBulletins)
 | 
			
		||||
            .WithLastListingFetch(now);
 | 
			
		||||
 | 
			
		||||
        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
 | 
			
		||||
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (cursor.PendingDocuments.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var pendingDocuments = cursor.PendingDocuments.ToList();
 | 
			
		||||
        var pendingMappings = cursor.PendingMappings.ToList();
 | 
			
		||||
 | 
			
		||||
        foreach (var documentId in cursor.PendingDocuments)
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (document is null)
 | 
			
		||||
            {
 | 
			
		||||
                pendingDocuments.Remove(documentId);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!document.GridFsId.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning("NKCKI document {DocumentId} missing GridFS payload", documentId);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingDocuments.Remove(documentId);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            byte[] payload;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                payload = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogError(ex, "NKCKI unable to download raw document {DocumentId}", documentId);
 | 
			
		||||
                throw;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            RuNkckiVulnerabilityDto? dto;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                dto = JsonSerializer.Deserialize<RuNkckiVulnerabilityDto>(payload, SerializerOptions);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning(ex, "NKCKI failed to deserialize document {DocumentId}", documentId);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingDocuments.Remove(documentId);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (dto is null)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning("NKCKI document {DocumentId} produced null DTO", documentId);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingDocuments.Remove(documentId);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var bson = MongoDB.Bson.BsonDocument.Parse(JsonSerializer.Serialize(dto, SerializerOptions));
 | 
			
		||||
            var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, SourceName, "ru-nkcki.v1", bson, _timeProvider.GetUtcNow());
 | 
			
		||||
            await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            pendingDocuments.Remove(documentId);
 | 
			
		||||
            if (!pendingMappings.Contains(documentId))
 | 
			
		||||
            {
 | 
			
		||||
                pendingMappings.Add(documentId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var updatedCursor = cursor
 | 
			
		||||
            .WithPendingDocuments(pendingDocuments)
 | 
			
		||||
            .WithPendingMappings(pendingMappings);
 | 
			
		||||
 | 
			
		||||
        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
 | 
			
		||||
        var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (cursor.PendingMappings.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var pendingMappings = cursor.PendingMappings.ToList();
 | 
			
		||||
 | 
			
		||||
        foreach (var documentId in cursor.PendingMappings)
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (document is null)
 | 
			
		||||
            {
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (dtoRecord is null)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning("NKCKI document {DocumentId} missing DTO payload", documentId);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            RuNkckiVulnerabilityDto dto;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                dto = JsonSerializer.Deserialize<RuNkckiVulnerabilityDto>(dtoRecord.Payload.ToString(), SerializerOptions) ?? throw new InvalidOperationException("DTO deserialized to null");
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogError(ex, "NKCKI failed to deserialize DTO for document {DocumentId}", documentId);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var advisory = RuNkckiMapper.Map(dto, document, dtoRecord.ValidatedAt);
 | 
			
		||||
                await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogError(ex, "NKCKI mapping failed for document {DocumentId}", documentId);
 | 
			
		||||
                await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                pendingMappings.Remove(documentId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var updatedCursor = cursor.WithPendingMappings(pendingMappings);
 | 
			
		||||
        await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<int> ProcessCachedBulletinsAsync(
 | 
			
		||||
        HashSet<Guid> pendingDocuments,
 | 
			
		||||
        HashSet<Guid> pendingMappings,
 | 
			
		||||
        HashSet<string> knownBulletins,
 | 
			
		||||
        DateTimeOffset now,
 | 
			
		||||
        int processed,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (!Directory.Exists(_cacheDirectory))
 | 
			
		||||
        {
 | 
			
		||||
            return processed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var updated = processed;
 | 
			
		||||
        var cacheFiles = Directory
 | 
			
		||||
            .EnumerateFiles(_cacheDirectory, "*.json.zip", SearchOption.TopDirectoryOnly)
 | 
			
		||||
            .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
            .ToList();
 | 
			
		||||
 | 
			
		||||
        foreach (var filePath in cacheFiles)
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            var bulletinId = ExtractBulletinIdFromCachePath(filePath);
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(bulletinId) || knownBulletins.Contains(bulletinId))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            byte[] content;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                content = File.ReadAllBytes(filePath);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogDebug(ex, "NKCKI failed to read cached bulletin at {CachePath}", filePath);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            updated = await ProcessBulletinEntriesAsync(content, bulletinId, pendingDocuments, pendingMappings, now, updated, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            knownBulletins.Add(bulletinId);
 | 
			
		||||
 | 
			
		||||
            if (updated >= _options.MaxVulnerabilitiesPerFetch)
 | 
			
		||||
            {
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return updated;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<int> ProcessBulletinEntriesAsync(
 | 
			
		||||
        byte[] content,
 | 
			
		||||
        string bulletinId,
 | 
			
		||||
        HashSet<Guid> pendingDocuments,
 | 
			
		||||
        HashSet<Guid> pendingMappings,
 | 
			
		||||
        DateTimeOffset now,
 | 
			
		||||
        int processed,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (content.Length == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return processed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var updated = processed;
 | 
			
		||||
        using var archiveStream = new MemoryStream(content, writable: false);
 | 
			
		||||
        using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
 | 
			
		||||
 | 
			
		||||
        foreach (var entry in archive.Entries.OrderBy(static e => e.FullName, StringComparer.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
            if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            using var entryStream = entry.Open();
 | 
			
		||||
            using var buffer = new MemoryStream();
 | 
			
		||||
            await entryStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (buffer.Length == 0)
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            buffer.Position = 0;
 | 
			
		||||
 | 
			
		||||
            using var document = await JsonDocument.ParseAsync(buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            updated = await ProcessBulletinJsonElementAsync(document.RootElement, entry.FullName, bulletinId, pendingDocuments, pendingMappings, now, updated, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (updated >= _options.MaxVulnerabilitiesPerFetch)
 | 
			
		||||
            {
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return updated;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<int> ProcessBulletinJsonElementAsync(
 | 
			
		||||
        JsonElement element,
 | 
			
		||||
        string entryName,
 | 
			
		||||
        string bulletinId,
 | 
			
		||||
        HashSet<Guid> pendingDocuments,
 | 
			
		||||
        HashSet<Guid> pendingMappings,
 | 
			
		||||
        DateTimeOffset now,
 | 
			
		||||
        int processed,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var updated = processed;
 | 
			
		||||
 | 
			
		||||
        switch (element.ValueKind)
 | 
			
		||||
        {
 | 
			
		||||
            case JsonValueKind.Array:
 | 
			
		||||
                foreach (var child in element.EnumerateArray())
 | 
			
		||||
                {
 | 
			
		||||
                    cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
 | 
			
		||||
                    if (updated >= _options.MaxVulnerabilitiesPerFetch)
 | 
			
		||||
                    {
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (child.ValueKind != JsonValueKind.Object)
 | 
			
		||||
                    {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    if (await ProcessVulnerabilityObjectAsync(child, entryName, bulletinId, pendingDocuments, pendingMappings, now, cancellationToken).ConfigureAwait(false))
 | 
			
		||||
                    {
 | 
			
		||||
                        updated++;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                break;
 | 
			
		||||
 | 
			
		||||
            case JsonValueKind.Object:
 | 
			
		||||
                if (await ProcessVulnerabilityObjectAsync(element, entryName, bulletinId, pendingDocuments, pendingMappings, now, cancellationToken).ConfigureAwait(false))
 | 
			
		||||
                {
 | 
			
		||||
                    updated++;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return updated;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<bool> ProcessVulnerabilityObjectAsync(
 | 
			
		||||
        JsonElement element,
 | 
			
		||||
        string entryName,
 | 
			
		||||
        string bulletinId,
 | 
			
		||||
        HashSet<Guid> pendingDocuments,
 | 
			
		||||
        HashSet<Guid> pendingMappings,
 | 
			
		||||
        DateTimeOffset now,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        RuNkckiVulnerabilityDto dto;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            dto = RuNkckiJsonParser.Parse(element);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug(ex, "NKCKI failed to parse vulnerability in bulletin {BulletinId} entry {Entry}", bulletinId, entryName);
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var payload = JsonSerializer.SerializeToUtf8Bytes(dto, SerializerOptions);
 | 
			
		||||
        var sha = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
 | 
			
		||||
        var documentUri = BuildDocumentUri(dto);
 | 
			
		||||
 | 
			
		||||
        var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, documentUri, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (existing is not null && string.Equals(existing.Sha256, sha, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var gridFsId = await _rawDocumentStorage.UploadAsync(SourceName, documentUri, payload, "application/json", null, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
        {
 | 
			
		||||
            ["ru-nkcki.bulletin"] = bulletinId,
 | 
			
		||||
            ["ru-nkcki.entry"] = entryName,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(dto.FstecId))
 | 
			
		||||
        {
 | 
			
		||||
            metadata["ru-nkcki.fstec_id"] = dto.FstecId!;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(dto.MitreId))
 | 
			
		||||
        {
 | 
			
		||||
            metadata["ru-nkcki.mitre_id"] = dto.MitreId!;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var recordId = existing?.Id ?? Guid.NewGuid();
 | 
			
		||||
        var lastModified = dto.DateUpdated ?? dto.DatePublished;
 | 
			
		||||
        var record = new DocumentRecord(
 | 
			
		||||
            recordId,
 | 
			
		||||
            SourceName,
 | 
			
		||||
            documentUri,
 | 
			
		||||
            now,
 | 
			
		||||
            sha,
 | 
			
		||||
            DocumentStatuses.PendingParse,
 | 
			
		||||
            "application/json",
 | 
			
		||||
            Headers: null,
 | 
			
		||||
            Metadata: metadata,
 | 
			
		||||
            Etag: null,
 | 
			
		||||
            LastModified: lastModified,
 | 
			
		||||
            GridFsId: gridFsId,
 | 
			
		||||
            ExpiresAt: null);
 | 
			
		||||
 | 
			
		||||
        var upserted = await _documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        pendingDocuments.Add(upserted.Id);
 | 
			
		||||
        pendingMappings.Remove(upserted.Id);
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<SourceFetchContentResult> FetchListingAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var request = new SourceFetchRequest(RuNkckiOptions.HttpClientName, SourceName, _options.ListingUri)
 | 
			
		||||
            {
 | 
			
		||||
                AcceptHeaders = ListingAcceptHeaders,
 | 
			
		||||
                TimeoutOverride = _options.RequestTimeout,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            return await _fetchService.FetchContentAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogError(ex, "NKCKI listing fetch failed for {ListingUri}", _options.ListingUri);
 | 
			
		||||
            await _stateRepository.MarkFailureAsync(SourceName, _timeProvider.GetUtcNow(), _options.FailureBackoff, ex.Message, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<IReadOnlyList<BulletinAttachment>> ParseListingAsync(byte[] content, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var html = Encoding.UTF8.GetString(content);
 | 
			
		||||
        var document = await _htmlParser.ParseDocumentAsync(html, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var anchors = document.QuerySelectorAll("a[href$='.json.zip']");
 | 
			
		||||
 | 
			
		||||
        var attachments = new List<BulletinAttachment>();
 | 
			
		||||
        foreach (var anchor in anchors)
 | 
			
		||||
        {
 | 
			
		||||
            var href = anchor.GetAttribute("href");
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(href))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!Uri.TryCreate(_options.BaseAddress, href, out var absoluteUri))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var id = DeriveBulletinId(absoluteUri);
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(id))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var title = anchor.GetAttribute("title");
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(title))
 | 
			
		||||
            {
 | 
			
		||||
                title = anchor.TextContent?.Trim();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            attachments.Add(new BulletinAttachment(id, absoluteUri, title ?? id));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return attachments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string DeriveBulletinId(Uri uri)
 | 
			
		||||
    {
 | 
			
		||||
        var fileName = Path.GetFileName(uri.AbsolutePath);
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(fileName))
 | 
			
		||||
        {
 | 
			
		||||
            return Guid.NewGuid().ToString("N");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            fileName = fileName[..^4];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            fileName = fileName[..^5];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return fileName.Replace('_', '-');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string BuildDocumentUri(RuNkckiVulnerabilityDto dto)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(dto.FstecId))
 | 
			
		||||
        {
 | 
			
		||||
            var slug = dto.FstecId.Contains(':', StringComparison.Ordinal)
 | 
			
		||||
                ? dto.FstecId[(dto.FstecId.IndexOf(':') + 1)..]
 | 
			
		||||
                : dto.FstecId;
 | 
			
		||||
            return $"https://cert.gov.ru/materialy/uyazvimosti/{slug}";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(dto.MitreId))
 | 
			
		||||
        {
 | 
			
		||||
            return $"https://nvd.nist.gov/vuln/detail/{dto.MitreId}";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $"https://cert.gov.ru/materialy/uyazvimosti/{Guid.NewGuid():N}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private string ResolveCacheDirectory(string? configuredPath)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(configuredPath))
 | 
			
		||||
        {
 | 
			
		||||
            return Path.GetFullPath(Path.IsPathRooted(configuredPath)
 | 
			
		||||
                ? configuredPath
 | 
			
		||||
                : Path.Combine(AppContext.BaseDirectory, configuredPath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Path.Combine(AppContext.BaseDirectory, "cache", RuNkckiConnectorPlugin.SourceName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void EnsureCacheDirectory()
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            Directory.CreateDirectory(_cacheDirectory);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning(ex, "NKCKI unable to ensure cache directory {CachePath}", _cacheDirectory);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private string GetBulletinCachePath(string bulletinId)
 | 
			
		||||
    {
 | 
			
		||||
        var fileStem = string.IsNullOrWhiteSpace(bulletinId)
 | 
			
		||||
            ? Guid.NewGuid().ToString("N")
 | 
			
		||||
            : Uri.EscapeDataString(bulletinId);
 | 
			
		||||
        return Path.Combine(_cacheDirectory, $"{fileStem}.json.zip");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string ExtractBulletinIdFromCachePath(string path)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(path))
 | 
			
		||||
        {
 | 
			
		||||
            return string.Empty;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var fileName = Path.GetFileName(path);
 | 
			
		||||
        if (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            fileName = fileName[..^4];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            fileName = fileName[..^5];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Uri.UnescapeDataString(fileName);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void TryWriteCachedBulletin(string bulletinId, byte[] content)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var cachePath = GetBulletinCachePath(bulletinId);
 | 
			
		||||
            Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
 | 
			
		||||
            File.WriteAllBytes(cachePath, content);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug(ex, "NKCKI failed to cache bulletin {BulletinId}", bulletinId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private bool TryReadCachedBulletin(string bulletinId, out byte[] content)
 | 
			
		||||
    {
 | 
			
		||||
        var cachePath = GetBulletinCachePath(bulletinId);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            if (File.Exists(cachePath))
 | 
			
		||||
            {
 | 
			
		||||
                content = File.ReadAllBytes(cachePath);
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug(ex, "NKCKI failed to read cached bulletin {BulletinId}", bulletinId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        content = Array.Empty<byte>();
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private IReadOnlyCollection<string> NormalizeBulletins(IEnumerable<string> bulletins)
 | 
			
		||||
    {
 | 
			
		||||
        var normalized = (bulletins ?? Enumerable.Empty<string>())
 | 
			
		||||
            .Where(static id => !string.IsNullOrWhiteSpace(id))
 | 
			
		||||
            .Distinct(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
            .OrderBy(static id => id, StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
            .ToList();
 | 
			
		||||
 | 
			
		||||
        if (normalized.Count <= _options.KnownBulletinCapacity)
 | 
			
		||||
        {
 | 
			
		||||
            return normalized.ToArray();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var skip = normalized.Count - _options.KnownBulletinCapacity;
 | 
			
		||||
        return normalized.Skip(skip).ToArray();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<RuNkckiCursor> GetCursorAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var state = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return state is null ? RuNkckiCursor.Empty : RuNkckiCursor.FromBson(state.Cursor);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Task UpdateCursorAsync(RuNkckiCursor cursor, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var document = cursor.ToBsonDocument();
 | 
			
		||||
        var completedAt = cursor.LastListingFetchAt ?? _timeProvider.GetUtcNow();
 | 
			
		||||
        return _stateRepository.UpdateCursorAsync(SourceName, document, completedAt, cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private readonly record struct BulletinAttachment(string Id, Uri Uri, string Title);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,19 @@
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using StellaOps.Plugin;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki;
 | 
			
		||||
 | 
			
		||||
public sealed class RuNkckiConnectorPlugin : IConnectorPlugin
 | 
			
		||||
{
 | 
			
		||||
    public const string SourceName = "ru-nkcki";
 | 
			
		||||
 | 
			
		||||
    public string Name => SourceName;
 | 
			
		||||
 | 
			
		||||
    public bool IsAvailable(IServiceProvider services) => services is not null;
 | 
			
		||||
 | 
			
		||||
    public IFeedConnector Create(IServiceProvider services)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
        return ActivatorUtilities.CreateInstance<RuNkckiConnector>(services);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,53 @@
 | 
			
		||||
using Microsoft.Extensions.Configuration;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using StellaOps.DependencyInjection;
 | 
			
		||||
using StellaOps.Feedser.Core.Jobs;
 | 
			
		||||
using StellaOps.Feedser.Source.Ru.Nkcki.Configuration;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki;
 | 
			
		||||
 | 
			
		||||
public sealed class RuNkckiDependencyInjectionRoutine : IDependencyInjectionRoutine
 | 
			
		||||
{
 | 
			
		||||
    private const string ConfigurationSection = "feedser:sources:ru-nkcki";
 | 
			
		||||
 | 
			
		||||
    public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(configuration);
 | 
			
		||||
 | 
			
		||||
        services.AddRuNkckiConnector(options =>
 | 
			
		||||
        {
 | 
			
		||||
            configuration.GetSection(ConfigurationSection).Bind(options);
 | 
			
		||||
            options.Validate();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddTransient<RuNkckiFetchJob>();
 | 
			
		||||
        services.AddTransient<RuNkckiParseJob>();
 | 
			
		||||
        services.AddTransient<RuNkckiMapJob>();
 | 
			
		||||
 | 
			
		||||
        services.PostConfigure<JobSchedulerOptions>(options =>
 | 
			
		||||
        {
 | 
			
		||||
            EnsureJob(options, RuNkckiJobKinds.Fetch, typeof(RuNkckiFetchJob));
 | 
			
		||||
            EnsureJob(options, RuNkckiJobKinds.Parse, typeof(RuNkckiParseJob));
 | 
			
		||||
            EnsureJob(options, RuNkckiJobKinds.Map, typeof(RuNkckiMapJob));
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void EnsureJob(JobSchedulerOptions schedulerOptions, string kind, Type jobType)
 | 
			
		||||
    {
 | 
			
		||||
        if (schedulerOptions.Definitions.ContainsKey(kind))
 | 
			
		||||
        {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        schedulerOptions.Definitions[kind] = new JobDefinition(
 | 
			
		||||
            kind,
 | 
			
		||||
            jobType,
 | 
			
		||||
            schedulerOptions.DefaultTimeout,
 | 
			
		||||
            schedulerOptions.DefaultLeaseDuration,
 | 
			
		||||
            CronExpression: null,
 | 
			
		||||
            Enabled: true);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,43 @@
 | 
			
		||||
using System.Net;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Feedser.Source.Common.Http;
 | 
			
		||||
using StellaOps.Feedser.Source.Ru.Nkcki.Configuration;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Feedser.Source.Ru.Nkcki;
 | 
			
		||||
 | 
			
		||||
public static class RuNkckiServiceCollectionExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static IServiceCollection AddRuNkckiConnector(this IServiceCollection services, Action<RuNkckiOptions> configure)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(services);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(configure);
 | 
			
		||||
 | 
			
		||||
        services.AddOptions<RuNkckiOptions>()
 | 
			
		||||
            .Configure(configure)
 | 
			
		||||
            .PostConfigure(static options => options.Validate());
 | 
			
		||||
 | 
			
		||||
        services.AddSourceHttpClient(RuNkckiOptions.HttpClientName, (sp, clientOptions) =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<RuNkckiOptions>>().Value;
 | 
			
		||||
            clientOptions.BaseAddress = options.BaseAddress;
 | 
			
		||||
            clientOptions.Timeout = options.RequestTimeout;
 | 
			
		||||
            clientOptions.UserAgent = options.UserAgent;
 | 
			
		||||
            clientOptions.AllowAutoRedirect = true;
 | 
			
		||||
            clientOptions.DefaultRequestHeaders["Accept-Language"] = options.AcceptLanguage;
 | 
			
		||||
            clientOptions.AllowedHosts.Clear();
 | 
			
		||||
            clientOptions.AllowedHosts.Add(options.BaseAddress.Host);
 | 
			
		||||
            clientOptions.ConfigureHandler = handler =>
 | 
			
		||||
            {
 | 
			
		||||
                handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
 | 
			
		||||
                handler.AllowAutoRedirect = true;
 | 
			
		||||
                handler.UseCookies = true;
 | 
			
		||||
                handler.CookieContainer = new CookieContainer();
 | 
			
		||||
            };
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddTransient<RuNkckiConnector>();
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,16 +1,22 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
 | 
			
		||||
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Source.Common/StellaOps.Feedser.Source.Common.csproj" />
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="AngleSharp" Version="1.1.1" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Core/StellaOps.Feedser.Core.csproj" />
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Normalization/StellaOps.Feedser.Normalization.csproj" />
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Source.Common/StellaOps.Feedser.Source.Common.csproj" />
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Models/StellaOps.Feedser.Models.csproj" />
 | 
			
		||||
    <ProjectReference Include="../StellaOps.Feedser.Storage.Mongo/StellaOps.Feedser.Storage.Mongo.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
 | 
			
		||||
</Project>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user