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.Concelier.Models;
|
|
using StellaOps.Concelier.Normalization.Cvss;
|
|
using StellaOps.Concelier.Normalization.SemVer;
|
|
using StellaOps.Concelier.Storage.Mongo.Documents;
|
|
|
|
namespace StellaOps.Concelier.Connector.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";
|
|
}
|
|
}
|