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.Normalization.SemVer; 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(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); void AddReference(string? url, string kind, string? sourceTag, string? summary) { if (string.IsNullOrWhiteSpace(url)) { return; } var key = $"{kind}|{url}"; if (!seen.Add(key)) { return; } var provenance = new AdvisoryProvenance( RuNkckiConnectorPlugin.SourceName, "reference", url, recordedAt, new[] { ProvenanceFieldMasks.References }); references.Add(new AdvisoryReference(url, kind, sourceTag, summary, provenance)); } AddReference(document.Uri, "details", "ru-nkcki", null); if (!string.IsNullOrWhiteSpace(dto.FstecId)) { var slug = dto.FstecId!.Contains(':', StringComparison.Ordinal) ? dto.FstecId[(dto.FstecId.IndexOf(':') + 1)..] : dto.FstecId; AddReference($"https://bdu.fstec.ru/vul/{slug}", "details", "bdu", null); } foreach (var url in dto.Urls) { var kind = url.Contains("cert.gov.ru", StringComparison.OrdinalIgnoreCase) ? "details" : "external"; var sourceTag = url.Contains("siemens", StringComparison.OrdinalIgnoreCase) ? "vendor" : null; AddReference(url, kind, sourceTag, null); } if (dto.Cwe?.Number is int number) { AddReference( $"https://cwe.mitre.org/data/definitions/{number}.html", "cwe", "cwe", dto.Cwe.Description); } return references; } private static IReadOnlyList BuildPackages(RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt) { if (!dto.VulnerableSoftwareEntries.IsDefaultOrEmpty && dto.VulnerableSoftwareEntries.Length > 0) { return CreatePackages(dto.VulnerableSoftwareEntries, dto, recordedAt); } if (!string.IsNullOrWhiteSpace(dto.VulnerableSoftwareText)) { var fallbackEntry = new RuNkckiSoftwareEntry( dto.VulnerableSoftwareText!, dto.VulnerableSoftwareText!, ImmutableArray.Empty); return CreatePackages(new[] { fallbackEntry }, dto, recordedAt); } return Array.Empty(); } private static IReadOnlyList CreatePackages(IEnumerable entries, RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt) { var type = DeterminePackageType(dto); var platform = dto.ProductCategories.IsDefaultOrEmpty || dto.ProductCategories.Length == 0 ? null : string.Join(", ", dto.ProductCategories); var packages = new List(); foreach (var entry in entries) { if (string.IsNullOrWhiteSpace(entry.Identifier)) { continue; } var packageProvenance = new AdvisoryProvenance( RuNkckiConnectorPlugin.SourceName, "package", entry.Evidence, 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 })); var rangeMetadata = BuildRangeMetadata(entry, recordedAt); packages.Add(new AffectedPackage( type, entry.Identifier, platform, rangeMetadata.Ranges, new[] { status }, new[] { packageProvenance }, rangeMetadata.Normalized)); } return packages; } 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; } if (!string.IsNullOrWhiteSpace(dto.CvssVectorV4)) { var vector = dto.CvssVectorV4.StartsWith("CVSS:", StringComparison.OrdinalIgnoreCase) ? dto.CvssVectorV4 : $"CVSS:4.0/{dto.CvssVectorV4}"; var score = dto.CvssScoreV4.HasValue ? Math.Round(dto.CvssScoreV4.Value, 1, MidpointRounding.AwayFromZero) : 0.0; var severityV4 = DetermineCvss4Severity(score); var provenance = new AdvisoryProvenance( RuNkckiConnectorPlugin.SourceName, "cvss", vector, recordedAt, new[] { ProvenanceFieldMasks.CvssMetrics }); metrics.Add(new CvssMetric("4.0", vector, score, severityV4, provenance)); severity ??= severityV4; } 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; } private static string DeterminePackageType(RuNkckiVulnerabilityDto dto) { if (dto.VulnerableSoftwareHasCpe == true) { return AffectedPackageTypes.Cpe; } if (!dto.ProductCategories.IsDefault && dto.ProductCategories.Any(static category => category.Contains("ics", StringComparison.OrdinalIgnoreCase) || category.Contains("scada", StringComparison.OrdinalIgnoreCase))) { return AffectedPackageTypes.IcsVendor; } return AffectedPackageTypes.Vendor; } private static (IReadOnlyList Ranges, IReadOnlyList Normalized) BuildRangeMetadata( RuNkckiSoftwareEntry entry, DateTimeOffset recordedAt) { if (entry.RangeExpressions.IsDefaultOrEmpty || entry.RangeExpressions.Length == 0) { return (Array.Empty(), Array.Empty()); } var ranges = new List(); var normalized = new List(); var dedupe = new HashSet(StringComparer.Ordinal); foreach (var expression in entry.RangeExpressions) { if (string.IsNullOrWhiteSpace(expression)) { continue; } var results = SemVerRangeRuleBuilder.Build(expression, provenanceNote: entry.Evidence); if (results.Count == 0) { continue; } foreach (var result in results) { var key = $"{result.Primitive.Introduced}|{result.Primitive.Fixed}|{result.Primitive.LastAffected}|{result.Expression}"; if (!dedupe.Add(key)) { continue; } var provenance = new AdvisoryProvenance( RuNkckiConnectorPlugin.SourceName, "package-range", entry.Evidence, recordedAt, new[] { ProvenanceFieldMasks.VersionRanges }); ranges.Add(new AffectedVersionRange( NormalizedVersionSchemes.SemVer, result.Primitive.Introduced, result.Primitive.Fixed, result.Primitive.LastAffected, result.Expression, provenance, new RangePrimitives(result.Primitive, null, null, null))); normalized.Add(result.NormalizedRule); } } return ( ranges.Count == 0 ? Array.Empty() : ranges, normalized.Count == 0 ? Array.Empty() : normalized); } private static string DetermineCvss4Severity(double score) { if (score <= 0.0) { return "none"; } if (score < 4.0) { return "low"; } if (score < 7.0) { return "medium"; } if (score < 9.0) { return "high"; } return "critical"; } }