446 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			446 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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<string, string> SeverityLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 | |
|     {
 | |
|         ["критический"] = "critical",
 | |
|         ["высокий"] = "high",
 | |
|         ["средний"] = "medium",
 | |
|         ["умеренный"] = "medium",
 | |
|         ["низкий"] = "low",
 | |
|         ["информационный"] = "informational",
 | |
|     }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
 | |
| 
 | |
|     public static Advisory Map(RuNkckiVulnerabilityDto dto, DocumentRecord document, DateTimeOffset recordedAt)
 | |
|     {
 | |
|         ArgumentNullException.ThrowIfNull(dto);
 | |
|         ArgumentNullException.ThrowIfNull(document);
 | |
| 
 | |
|         var advisoryProvenance = new AdvisoryProvenance(
 | |
|             RuNkckiConnectorPlugin.SourceName,
 | |
|             "advisory",
 | |
|             dto.AdvisoryKey,
 | |
|             recordedAt,
 | |
|             new[] { ProvenanceFieldMasks.Advisory });
 | |
| 
 | |
|         var aliases = BuildAliases(dto);
 | |
|         var references = BuildReferences(dto, document, recordedAt);
 | |
|         var packages = BuildPackages(dto, recordedAt);
 | |
|         var cvssMetrics = BuildCvssMetrics(dto, recordedAt, out var severityFromCvss);
 | |
|         var severityFromRating = NormalizeSeverity(dto.CvssRating);
 | |
|         var severity = severityFromRating ?? severityFromCvss;
 | |
| 
 | |
|         if (severityFromRating is not null && severityFromCvss is not null)
 | |
|         {
 | |
|             severity = ChooseMoreSevere(severityFromRating, severityFromCvss);
 | |
|         }
 | |
| 
 | |
|         var exploitKnown = DetermineExploitKnown(dto);
 | |
| 
 | |
|         return new Advisory(
 | |
|             advisoryKey: dto.AdvisoryKey,
 | |
|             title: dto.Description ?? dto.AdvisoryKey,
 | |
|             summary: dto.Description,
 | |
|             language: "ru",
 | |
|             published: dto.DatePublished,
 | |
|             modified: dto.DateUpdated,
 | |
|             severity: severity,
 | |
|             exploitKnown: exploitKnown,
 | |
|             aliases: aliases,
 | |
|             references: references,
 | |
|             affectedPackages: packages,
 | |
|             cvssMetrics: cvssMetrics,
 | |
|             provenance: new[] { advisoryProvenance });
 | |
|     }
 | |
| 
 | |
|     private static IReadOnlyList<string> BuildAliases(RuNkckiVulnerabilityDto dto)
 | |
|     {
 | |
|         var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 | |
|         if (!string.IsNullOrWhiteSpace(dto.FstecId))
 | |
|         {
 | |
|             aliases.Add(dto.FstecId!);
 | |
|         }
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(dto.MitreId))
 | |
|         {
 | |
|             aliases.Add(dto.MitreId!);
 | |
|         }
 | |
| 
 | |
|         return aliases.ToImmutableSortedSet(StringComparer.Ordinal).ToImmutableArray();
 | |
|     }
 | |
| 
 | |
|     private static IReadOnlyList<AdvisoryReference> BuildReferences(RuNkckiVulnerabilityDto dto, DocumentRecord document, DateTimeOffset recordedAt)
 | |
|     {
 | |
|         var references = new List<AdvisoryReference>();
 | |
|         var seen = new HashSet<string>(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<AffectedPackage> 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<string>.Empty);
 | |
|             return CreatePackages(new[] { fallbackEntry }, dto, recordedAt);
 | |
|         }
 | |
| 
 | |
|         return Array.Empty<AffectedPackage>();
 | |
|     }
 | |
| 
 | |
|     private static IReadOnlyList<AffectedPackage> CreatePackages(IEnumerable<RuNkckiSoftwareEntry> 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<AffectedPackage>();
 | |
| 
 | |
|         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<CvssMetric> BuildCvssMetrics(RuNkckiVulnerabilityDto dto, DateTimeOffset recordedAt, out string? severity)
 | |
|     {
 | |
|         severity = null;
 | |
|         var metrics = new List<CvssMetric>();
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(dto.CvssVector) && CvssMetricNormalizer.TryNormalize(null, dto.CvssVector, dto.CvssScore, null, out var normalized))
 | |
|         {
 | |
|             var provenance = new AdvisoryProvenance(
 | |
|                 RuNkckiConnectorPlugin.SourceName,
 | |
|                 "cvss",
 | |
|                 normalized.Vector,
 | |
|                 recordedAt,
 | |
|                 new[] { ProvenanceFieldMasks.CvssMetrics });
 | |
|             var metric = normalized.ToModel(provenance);
 | |
|             metrics.Add(metric);
 | |
|             severity ??= metric.BaseSeverity;
 | |
|         }
 | |
| 
 | |
|         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<string> levels, string value)
 | |
|         {
 | |
|             for (var i = 0; i < levels.Length; i++)
 | |
|             {
 | |
|                 if (string.Equals(levels[i], value, StringComparison.OrdinalIgnoreCase))
 | |
|                 {
 | |
|                     return i;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             return -1;
 | |
|         }
 | |
| 
 | |
|         var firstIndex = IndexOf(order.AsSpan(), first);
 | |
|         var secondIndex = IndexOf(order.AsSpan(), second);
 | |
| 
 | |
|         if (firstIndex == -1 && secondIndex == -1)
 | |
|         {
 | |
|             return first;
 | |
|         }
 | |
| 
 | |
|         if (firstIndex == -1)
 | |
|         {
 | |
|             return second;
 | |
|         }
 | |
| 
 | |
|         if (secondIndex == -1)
 | |
|         {
 | |
|             return first;
 | |
|         }
 | |
| 
 | |
|         return firstIndex <= secondIndex ? first : second;
 | |
|     }
 | |
| 
 | |
|     private static bool DetermineExploitKnown(RuNkckiVulnerabilityDto dto)
 | |
|     {
 | |
|         if (!string.IsNullOrWhiteSpace(dto.MethodOfExploitation))
 | |
|         {
 | |
|             return true;
 | |
|         }
 | |
| 
 | |
|         if (!string.IsNullOrWhiteSpace(dto.Impact))
 | |
|         {
 | |
|             var impact = dto.Impact.Trim().ToUpperInvariant();
 | |
|             if (impact is "ACE" or "RCE" or "LPE")
 | |
|             {
 | |
|                 return true;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     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<AffectedVersionRange> Ranges, IReadOnlyList<NormalizedVersionRule> Normalized) BuildRangeMetadata(
 | |
|         RuNkckiSoftwareEntry entry,
 | |
|         DateTimeOffset recordedAt)
 | |
|     {
 | |
|         if (entry.RangeExpressions.IsDefaultOrEmpty || entry.RangeExpressions.Length == 0)
 | |
|         {
 | |
|             return (Array.Empty<AffectedVersionRange>(), Array.Empty<NormalizedVersionRule>());
 | |
|         }
 | |
| 
 | |
|         var ranges = new List<AffectedVersionRange>();
 | |
|         var normalized = new List<NormalizedVersionRule>();
 | |
|         var dedupe = new HashSet<string>(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<AffectedVersionRange>() : ranges,
 | |
|             normalized.Count == 0 ? Array.Empty<NormalizedVersionRule>() : 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";
 | |
|     }
 | |
| }
 |