using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; namespace StellaOps.Feedser.Source.Ru.Nkcki.Internal; internal static class RuNkckiJsonParser { private static readonly Regex ComparatorRegex = new( @"^(?.+?)\s*(?<=|>=|<|>|==|=)\s*(?.+?)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex RangeRegex = new( @"^(?.+?)\s+(?[\p{L}\p{N}\._-]+)\s*[-–]\s*(?[\p{L}\p{N}\._-]+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex QualifierRegex = new( @"^(?.+?)\s+(?[\p{L}\p{N}\._-]+)\s+(?(and\s+earlier|and\s+later|and\s+newer|до\s+и\s+включительно|и\s+ниже|и\s+выше|и\s+старше|и\s+позже))$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private static readonly Regex QualifierInlineRegex = new( @"верс(ии|ия)\s+(?[\p{L}\p{N}\._-]+)\s+(?и\s+ниже|и\s+выше|и\s+старше)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private static readonly Regex VersionWindowRegex = new( @"верс(ии|ия)\s+(?[\p{L}\p{N}\._-]+)\s+по\s+(?[\p{L}\p{N}\._-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private static readonly char[] SoftwareSplitDelimiters = { '\n', ';', '\u2022', '\u2023', '\r' }; private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase; 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 = ReadJoinedString(element, "description"); var mitigation = ReadJoinedString(element, "mitigation"); var productCategories = ReadStringCollection(element, "product_category"); var impact = ReadJoinedString(element, "impact"); var method = ReadJoinedString(element, "method_of_exploitation"); bool? userInteraction = element.TryGetProperty("user_interaction", out var uiElement) ? uiElement.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, _ => null, } : null; var (softwareText, softwareHasCpe, softwareEntries) = ParseVulnerableSoftware(element); 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 = ReadJoinedString(cweElement, "cwe_description") ?? Normalize(cweElement.GetString()); 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 = ReadUrls(element); var tags = ReadStringCollection(element, "tags"); return new RuNkckiVulnerabilityDto( fstecId, mitreId, datePublished, dateUpdated, cvssRating, patchAvailable, description, cweDto, productCategories, mitigation, softwareText, softwareHasCpe, softwareEntries, cvssScore, cvssVector, cvssScoreV4, cvssVectorV4, impact, method, userInteraction, urls, tags); } private static ImmutableArray ReadUrls(JsonElement element) { if (!element.TryGetProperty("urls", out var urlsElement)) { return ImmutableArray.Empty; } var collected = new List(); CollectUrls(urlsElement, collected); if (collected.Count == 0) { return ImmutableArray.Empty; } return collected .Select(Normalize) .Where(static url => !string.IsNullOrWhiteSpace(url)) .Select(static url => url!) .Distinct(OrdinalIgnoreCase) .OrderBy(static url => url, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } private static void CollectUrls(JsonElement element, ICollection results) { switch (element.ValueKind) { case JsonValueKind.String: var value = element.GetString(); if (!string.IsNullOrWhiteSpace(value)) { results.Add(value); } break; case JsonValueKind.Array: foreach (var child in element.EnumerateArray()) { CollectUrls(child, results); } break; case JsonValueKind.Object: if (element.TryGetProperty("url", out var urlProperty)) { CollectUrls(urlProperty, results); } if (element.TryGetProperty("href", out var hrefProperty)) { CollectUrls(hrefProperty, results); } foreach (var property in element.EnumerateObject()) { if (property.NameEquals("value") || property.NameEquals("link")) { CollectUrls(property.Value, results); } } break; } } private static string? ReadJoinedString(JsonElement element, string property) { if (!element.TryGetProperty(property, out var target)) { return null; } var values = ReadStringCollection(target); if (!values.IsDefaultOrEmpty) { return string.Join("; ", values); } return Normalize(target.ValueKind == JsonValueKind.String ? target.GetString() : target.ToString()); } private static ImmutableArray ReadStringCollection(JsonElement element, string property) { if (!element.TryGetProperty(property, out var target)) { return ImmutableArray.Empty; } return ReadStringCollection(target); } private static ImmutableArray ReadStringCollection(JsonElement element) { var builder = ImmutableArray.CreateBuilder(); CollectStrings(element, builder); return Deduplicate(builder); } private static void CollectStrings(JsonElement element, ImmutableArray.Builder builder) { switch (element.ValueKind) { case JsonValueKind.String: AddIfPresent(builder, Normalize(element.GetString())); break; case JsonValueKind.Number: AddIfPresent(builder, Normalize(element.ToString())); break; case JsonValueKind.True: builder.Add("true"); break; case JsonValueKind.False: builder.Add("false"); break; case JsonValueKind.Array: foreach (var child in element.EnumerateArray()) { CollectStrings(child, builder); } break; case JsonValueKind.Object: foreach (var property in element.EnumerateObject()) { CollectStrings(property.Value, builder); } break; } } private static ImmutableArray Deduplicate(ImmutableArray.Builder builder) { if (builder.Count == 0) { return ImmutableArray.Empty; } return builder .Where(static value => !string.IsNullOrWhiteSpace(value)) .Distinct(OrdinalIgnoreCase) .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } private static void AddIfPresent(ImmutableArray.Builder builder, string? value) { if (!string.IsNullOrWhiteSpace(value)) { builder.Add(value!); } } private static (string? Text, bool? HasCpe, ImmutableArray Entries) ParseVulnerableSoftware(JsonElement element) { if (!element.TryGetProperty("vulnerable_software", out var softwareElement)) { return (null, null, ImmutableArray.Empty); } string? softwareText = null; if (softwareElement.TryGetProperty("software_text", out var textElement)) { softwareText = Normalize(textElement.ValueKind == JsonValueKind.String ? textElement.GetString() : textElement.ToString()); } bool? softwareHasCpe = null; if (softwareElement.TryGetProperty("cpe", out var cpeElement)) { softwareHasCpe = cpeElement.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, _ => softwareHasCpe, }; } var entries = new List(); if (softwareElement.TryGetProperty("software", out var softwareNodes)) { entries.AddRange(ParseSoftwareEntries(softwareNodes)); } if (entries.Count == 0 && !string.IsNullOrWhiteSpace(softwareText)) { entries.AddRange(SplitSoftwareTextIntoEntries(softwareText)); } if (entries.Count == 0) { foreach (var fallbackProperty in new[] { "items", "aliases", "software_lines" }) { if (softwareElement.TryGetProperty(fallbackProperty, out var fallbackNodes)) { entries.AddRange(ParseSoftwareEntries(fallbackNodes)); } } } if (entries.Count == 0) { return (softwareText, softwareHasCpe, ImmutableArray.Empty); } var grouped = entries .GroupBy(static entry => entry.Identifier, OrdinalIgnoreCase) .Select(static group => { var evidence = string.Join( "; ", group.Select(static entry => entry.Evidence) .Where(static evidence => !string.IsNullOrWhiteSpace(evidence)) .Distinct(OrdinalIgnoreCase)); var ranges = group .SelectMany(static entry => entry.RangeExpressions) .Where(static range => !string.IsNullOrWhiteSpace(range)) .Distinct(OrdinalIgnoreCase) .OrderBy(static range => range, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); return new RuNkckiSoftwareEntry( group.Key, string.IsNullOrWhiteSpace(evidence) ? group.Key : evidence, ranges); }) .OrderBy(static entry => entry.Identifier, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); return (softwareText, softwareHasCpe, grouped); } private static IEnumerable ParseSoftwareEntries(JsonElement element) { switch (element.ValueKind) { case JsonValueKind.Array: foreach (var child in element.EnumerateArray()) { foreach (var entry in ParseSoftwareEntries(child)) { yield return entry; } } break; case JsonValueKind.Object: yield return CreateEntryFromObject(element); break; case JsonValueKind.String: foreach (var entry in SplitSoftwareTextIntoEntries(element.GetString() ?? string.Empty)) { yield return entry; } break; } } private static RuNkckiSoftwareEntry CreateEntryFromObject(JsonElement element) { var vendor = ReadFirstString(element, "vendor", "manufacturer", "organisation"); var name = ReadFirstString(element, "name", "product", "title"); var rawVersion = ReadFirstString(element, "version", "versions", "range"); var comment = ReadFirstString(element, "comment", "notes", "summary"); var identifierParts = new List(); if (!string.IsNullOrWhiteSpace(vendor)) { identifierParts.Add(vendor!); } if (!string.IsNullOrWhiteSpace(name)) { identifierParts.Add(name!); } var identifier = identifierParts.Count > 0 ? string.Join(" ", identifierParts) : ReadFirstString(element, "identifier") ?? name ?? rawVersion ?? comment ?? "unknown"; var evidenceParts = new List(identifierParts); if (!string.IsNullOrWhiteSpace(rawVersion)) { evidenceParts.Add(rawVersion!); } if (!string.IsNullOrWhiteSpace(comment)) { evidenceParts.Add(comment!); } var evidence = string.Join(" ", evidenceParts.Where(static part => !string.IsNullOrWhiteSpace(part))).Trim(); var rangeHints = new List(); if (!string.IsNullOrWhiteSpace(rawVersion)) { rangeHints.Add(rawVersion); } if (element.TryGetProperty("range", out var rangeElement)) { rangeHints.Add(Normalize(rangeElement.ToString())); } return CreateSoftwareEntry(identifier!, evidence, rangeHints); } private static IEnumerable SplitSoftwareTextIntoEntries(string text) { if (string.IsNullOrWhiteSpace(text)) { yield break; } var segments = text.Split(SoftwareSplitDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (segments.Length == 0) { segments = new[] { text }; } foreach (var segment in segments) { var normalized = Normalize(segment); if (string.IsNullOrWhiteSpace(normalized)) { continue; } var (identifier, hints) = ExtractIdentifierAndRangeHints(normalized!); yield return CreateSoftwareEntry(identifier, normalized!, hints); } } private static RuNkckiSoftwareEntry CreateSoftwareEntry(string identifier, string evidence, IEnumerable hints) { var normalizedIdentifier = Normalize(identifier) ?? "unknown"; var normalizedEvidence = Normalize(evidence) ?? normalizedIdentifier; var ranges = hints .Select(NormalizeRangeHint) .Where(static hint => !string.IsNullOrWhiteSpace(hint)) .Select(static hint => hint!) .Distinct(OrdinalIgnoreCase) .OrderBy(static hint => hint, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); return new RuNkckiSoftwareEntry(normalizedIdentifier, normalizedEvidence!, ranges); } private static string? NormalizeRangeHint(string? hint) { if (string.IsNullOrWhiteSpace(hint)) { return null; } var normalized = Normalize(hint)? .Replace("≤", "<=", StringComparison.Ordinal) .Replace("≥", ">=", StringComparison.Ordinal) .Replace("=>", ">=", StringComparison.Ordinal) .Replace("=<", "<=", StringComparison.Ordinal); if (string.IsNullOrWhiteSpace(normalized)) { return null; } return normalized; } private static (string Identifier, IReadOnlyList RangeHints) ExtractIdentifierAndRangeHints(string value) { if (string.IsNullOrWhiteSpace(value)) { return ("unknown", Array.Empty()); } var comparatorMatch = ComparatorRegex.Match(value); if (comparatorMatch.Success) { var name = Normalize(comparatorMatch.Groups["name"].Value); var version = Normalize(comparatorMatch.Groups["version"].Value); var op = comparatorMatch.Groups["operator"].Value; return (string.IsNullOrWhiteSpace(name) ? value : name!, new[] { $"{op} {version}" }); } var rangeMatch = RangeRegex.Match(value); if (rangeMatch.Success) { var name = Normalize(rangeMatch.Groups["name"].Value); var start = Normalize(rangeMatch.Groups["start"].Value); var end = Normalize(rangeMatch.Groups["end"].Value); return (string.IsNullOrWhiteSpace(name) ? value : name!, new[] { $">= {start}", $"<= {end}" }); } var qualifierMatch = QualifierRegex.Match(value); if (qualifierMatch.Success) { var name = Normalize(qualifierMatch.Groups["name"].Value); var version = Normalize(qualifierMatch.Groups["version"].Value); var qualifier = qualifierMatch.Groups["qualifier"].Value.ToLowerInvariant(); var hint = qualifier.Contains("ниж") || qualifier.Contains("earlier") || qualifier.Contains("включ") ? $"<= {version}" : $">= {version}"; return (string.IsNullOrWhiteSpace(name) ? value : name!, new[] { hint }); } var inlineQualifierMatch = QualifierInlineRegex.Match(value); if (inlineQualifierMatch.Success) { var version = Normalize(inlineQualifierMatch.Groups["version"].Value); var qualifier = inlineQualifierMatch.Groups["qualifier"].Value.ToLowerInvariant(); var hint = qualifier.Contains("ниж") ? $"<= {version}" : $">= {version}"; var name = Normalize(QualifierInlineRegex.Replace(value, string.Empty)); return (string.IsNullOrWhiteSpace(name) ? value : name!, new[] { hint }); } var windowMatch = VersionWindowRegex.Match(value); if (windowMatch.Success) { var start = Normalize(windowMatch.Groups["start"].Value); var end = Normalize(windowMatch.Groups["end"].Value); var name = Normalize(VersionWindowRegex.Replace(value, string.Empty)); return (string.IsNullOrWhiteSpace(name) ? value : name!, new[] { $">= {start}", $"<= {end}" }); } return (value, Array.Empty()); } private static string? ReadFirstString(JsonElement element, params string[] names) { foreach (var name in names) { if (element.TryGetProperty(name, out var property)) { switch (property.ValueKind) { case JsonValueKind.String: { var normalized = Normalize(property.GetString()); if (!string.IsNullOrWhiteSpace(normalized)) { return normalized; } break; } case JsonValueKind.Number: { var normalized = Normalize(property.ToString()); if (!string.IsNullOrWhiteSpace(normalized)) { return normalized; } break; } } } } return null; } 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; } var normalized = value .Replace('\r', ' ') .Replace('\n', ' ') .Trim(); while (normalized.Contains(" ", StringComparison.Ordinal)) { normalized = normalized.Replace(" ", " ", StringComparison.Ordinal); } return normalized.Length == 0 ? null : normalized; } }