Rename Concelier Source modules to Connector
This commit is contained in:
		
							
								
								
									
										64
									
								
								src/StellaOps.Concelier.Connector.Nvd/Internal/NvdCursor.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/StellaOps.Concelier.Connector.Nvd/Internal/NvdCursor.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| using System.Linq; | ||||
| using MongoDB.Bson; | ||||
| using StellaOps.Concelier.Connector.Common.Cursors; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Nvd.Internal; | ||||
|  | ||||
| internal sealed record NvdCursor( | ||||
|     TimeWindowCursorState Window, | ||||
|     IReadOnlyCollection<Guid> PendingDocuments, | ||||
|     IReadOnlyCollection<Guid> PendingMappings) | ||||
| { | ||||
|     public static NvdCursor Empty { get; } = new(TimeWindowCursorState.Empty, Array.Empty<Guid>(), Array.Empty<Guid>()); | ||||
|  | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var document = new BsonDocument(); | ||||
|         Window.WriteTo(document); | ||||
|         document["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())); | ||||
|         document["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())); | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     public static NvdCursor FromBsonDocument(BsonDocument? document) | ||||
|     { | ||||
|         if (document is null || document.ElementCount == 0) | ||||
|         { | ||||
|             return Empty; | ||||
|         } | ||||
|  | ||||
|         var window = TimeWindowCursorState.FromBsonDocument(document); | ||||
|         var pendingDocuments = ReadGuidArray(document, "pendingDocuments"); | ||||
|         var pendingMappings = ReadGuidArray(document, "pendingMappings"); | ||||
|  | ||||
|         return new NvdCursor(window, pendingDocuments, pendingMappings); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field) | ||||
|     { | ||||
|         if (!document.TryGetValue(field, out var value) || value is not BsonArray array) | ||||
|         { | ||||
|             return Array.Empty<Guid>(); | ||||
|         } | ||||
|  | ||||
|         var results = new List<Guid>(array.Count); | ||||
|         foreach (var element in array) | ||||
|         { | ||||
|             if (Guid.TryParse(element.AsString, out var guid)) | ||||
|             { | ||||
|                 results.Add(guid); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
|  | ||||
|     public NvdCursor WithWindow(TimeWindow window) | ||||
|         => this with { Window = Window.WithWindow(window) }; | ||||
|  | ||||
|     public NvdCursor WithPendingDocuments(IEnumerable<Guid> ids) | ||||
|         => this with { PendingDocuments = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() }; | ||||
|  | ||||
|     public NvdCursor WithPendingMappings(IEnumerable<Guid> ids) | ||||
|         => this with { PendingMappings = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() }; | ||||
| } | ||||
| @@ -0,0 +1,76 @@ | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Nvd.Internal; | ||||
|  | ||||
| public sealed class NvdDiagnostics : IDisposable | ||||
| { | ||||
|     public const string MeterName = "StellaOps.Concelier.Connector.Nvd"; | ||||
|     public const string MeterVersion = "1.0.0"; | ||||
|  | ||||
|     private readonly Meter _meter; | ||||
|     private readonly Counter<long> _fetchAttempts; | ||||
|     private readonly Counter<long> _fetchDocuments; | ||||
|     private readonly Counter<long> _fetchFailures; | ||||
|     private readonly Counter<long> _fetchUnchanged; | ||||
|     private readonly Counter<long> _parseSuccess; | ||||
|     private readonly Counter<long> _parseFailures; | ||||
|     private readonly Counter<long> _parseQuarantine; | ||||
|     private readonly Counter<long> _mapSuccess; | ||||
|  | ||||
|     public NvdDiagnostics() | ||||
|     { | ||||
|         _meter = new Meter(MeterName, MeterVersion); | ||||
|         _fetchAttempts = _meter.CreateCounter<long>( | ||||
|             name: "nvd.fetch.attempts", | ||||
|             unit: "operations", | ||||
|             description: "Number of NVD fetch operations attempted, including paginated windows."); | ||||
|         _fetchDocuments = _meter.CreateCounter<long>( | ||||
|             name: "nvd.fetch.documents", | ||||
|             unit: "documents", | ||||
|             description: "Count of NVD documents fetched and persisted."); | ||||
|         _fetchFailures = _meter.CreateCounter<long>( | ||||
|             name: "nvd.fetch.failures", | ||||
|             unit: "operations", | ||||
|             description: "Count of NVD fetch attempts that resulted in an error or missing document."); | ||||
|         _fetchUnchanged = _meter.CreateCounter<long>( | ||||
|             name: "nvd.fetch.unchanged", | ||||
|             unit: "operations", | ||||
|             description: "Count of NVD fetch attempts returning 304 Not Modified."); | ||||
|         _parseSuccess = _meter.CreateCounter<long>( | ||||
|             name: "nvd.parse.success", | ||||
|             unit: "documents", | ||||
|             description: "Count of NVD documents successfully validated and converted into DTOs."); | ||||
|         _parseFailures = _meter.CreateCounter<long>( | ||||
|             name: "nvd.parse.failures", | ||||
|             unit: "documents", | ||||
|             description: "Count of NVD documents that failed parsing due to missing content or read errors."); | ||||
|         _parseQuarantine = _meter.CreateCounter<long>( | ||||
|             name: "nvd.parse.quarantine", | ||||
|             unit: "documents", | ||||
|             description: "Count of NVD documents quarantined due to schema validation failures."); | ||||
|         _mapSuccess = _meter.CreateCounter<long>( | ||||
|             name: "nvd.map.success", | ||||
|             unit: "advisories", | ||||
|             description: "Count of canonical advisories produced by NVD mapping."); | ||||
|     } | ||||
|  | ||||
|     public void FetchAttempt() => _fetchAttempts.Add(1); | ||||
|  | ||||
|     public void FetchDocument() => _fetchDocuments.Add(1); | ||||
|  | ||||
|     public void FetchFailure() => _fetchFailures.Add(1); | ||||
|  | ||||
|     public void FetchUnchanged() => _fetchUnchanged.Add(1); | ||||
|  | ||||
|     public void ParseSuccess() => _parseSuccess.Add(1); | ||||
|  | ||||
|     public void ParseFailure() => _parseFailures.Add(1); | ||||
|  | ||||
|     public void ParseQuarantine() => _parseQuarantine.Add(1); | ||||
|  | ||||
|     public void MapSuccess(long count = 1) => _mapSuccess.Add(count); | ||||
|  | ||||
|     public Meter Meter => _meter; | ||||
|  | ||||
|     public void Dispose() => _meter.Dispose(); | ||||
| } | ||||
							
								
								
									
										774
									
								
								src/StellaOps.Concelier.Connector.Nvd/Internal/NvdMapper.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										774
									
								
								src/StellaOps.Concelier.Connector.Nvd/Internal/NvdMapper.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,774 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using NuGet.Versioning; | ||||
| using StellaOps.Concelier.Models; | ||||
| using StellaOps.Concelier.Normalization.Identifiers; | ||||
| using StellaOps.Concelier.Normalization.Cvss; | ||||
| using StellaOps.Concelier.Normalization.Text; | ||||
| using StellaOps.Concelier.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Nvd.Internal; | ||||
|  | ||||
| internal static class NvdMapper | ||||
| { | ||||
|     public static IReadOnlyList<Advisory> Map(JsonDocument document, DocumentRecord sourceDocument, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|         ArgumentNullException.ThrowIfNull(sourceDocument); | ||||
|  | ||||
|         if (!document.RootElement.TryGetProperty("vulnerabilities", out var vulnerabilities) || vulnerabilities.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             return Array.Empty<Advisory>(); | ||||
|         } | ||||
|  | ||||
|         var advisories = new List<Advisory>(vulnerabilities.GetArrayLength()); | ||||
|         var index = 0; | ||||
|         foreach (var vulnerability in vulnerabilities.EnumerateArray()) | ||||
|         { | ||||
|             if (!vulnerability.TryGetProperty("cve", out var cve) || cve.ValueKind != JsonValueKind.Object) | ||||
|             { | ||||
|                 index++; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!cve.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) | ||||
|             { | ||||
|                 index++; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var cveId = idElement.GetString(); | ||||
|             var advisoryKey = string.IsNullOrWhiteSpace(cveId) | ||||
|                 ? $"nvd:{sourceDocument.Id:N}:{index}" | ||||
|                 : cveId; | ||||
|  | ||||
|             var published = TryGetDateTime(cve, "published"); | ||||
|             var modified = TryGetDateTime(cve, "lastModified"); | ||||
|             var description = GetNormalizedDescription(cve); | ||||
|  | ||||
|             var weaknessMetadata = GetWeaknessMetadata(cve); | ||||
|             var references = GetReferences(cve, sourceDocument, recordedAt, weaknessMetadata); | ||||
|             var affectedPackages = GetAffectedPackages(cve, cveId, sourceDocument, recordedAt); | ||||
|             var cvssMetrics = GetCvssMetrics(cve, sourceDocument, recordedAt, out var severity); | ||||
|             var weaknesses = BuildWeaknesses(weaknessMetadata, recordedAt); | ||||
|             var canonicalMetricId = cvssMetrics.Count > 0 | ||||
|                 ? $"{cvssMetrics[0].Version}|{cvssMetrics[0].Vector}" | ||||
|                 : null; | ||||
|  | ||||
|             var provenance = new[] | ||||
|             { | ||||
|                 new AdvisoryProvenance( | ||||
|                     NvdConnectorPlugin.SourceName, | ||||
|                     "document", | ||||
|                     sourceDocument.Uri, | ||||
|                     sourceDocument.FetchedAt, | ||||
|                     new[] { ProvenanceFieldMasks.Advisory }), | ||||
|                 new AdvisoryProvenance( | ||||
|                     NvdConnectorPlugin.SourceName, | ||||
|                     "mapping", | ||||
|                     string.IsNullOrWhiteSpace(cveId) ? advisoryKey : cveId, | ||||
|                     recordedAt, | ||||
|                     new[] { ProvenanceFieldMasks.Advisory }), | ||||
|             }; | ||||
|  | ||||
|             var title = string.IsNullOrWhiteSpace(cveId) ? advisoryKey : cveId; | ||||
|  | ||||
|             var aliasCandidates = new List<string>(capacity: 2); | ||||
|             if (!string.IsNullOrWhiteSpace(cveId)) | ||||
|             { | ||||
|                 aliasCandidates.Add(cveId); | ||||
|             } | ||||
|  | ||||
|             aliasCandidates.Add(advisoryKey); | ||||
|  | ||||
|             var advisory = new Advisory( | ||||
|                 advisoryKey: advisoryKey, | ||||
|                 title: title, | ||||
|                 summary: string.IsNullOrEmpty(description.Text) ? null : description.Text, | ||||
|                 language: description.Language, | ||||
|                 published: published, | ||||
|                 modified: modified, | ||||
|                 severity: severity, | ||||
|                 exploitKnown: false, | ||||
|                 aliases: aliasCandidates, | ||||
|                 references: references, | ||||
|                 affectedPackages: affectedPackages, | ||||
|                 cvssMetrics: cvssMetrics, | ||||
|                 provenance: provenance, | ||||
|                 description: string.IsNullOrEmpty(description.Text) ? null : description.Text, | ||||
|                 cwes: weaknesses, | ||||
|                 canonicalMetricId: canonicalMetricId); | ||||
|  | ||||
|             advisories.Add(advisory); | ||||
|             index++; | ||||
|         } | ||||
|  | ||||
|         return advisories; | ||||
|     } | ||||
|  | ||||
|     private static NormalizedDescription GetNormalizedDescription(JsonElement cve) | ||||
|     { | ||||
|         var candidates = new List<LocalizedText>(); | ||||
|  | ||||
|         if (cve.TryGetProperty("descriptions", out var descriptions) && descriptions.ValueKind == JsonValueKind.Array) | ||||
|         { | ||||
|             foreach (var item in descriptions.EnumerateArray()) | ||||
|             { | ||||
|                 if (item.ValueKind != JsonValueKind.Object) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var text = item.TryGetProperty("value", out var valueElement) && valueElement.ValueKind == JsonValueKind.String | ||||
|                     ? valueElement.GetString() | ||||
|                     : null; | ||||
|                 var lang = item.TryGetProperty("lang", out var langElement) && langElement.ValueKind == JsonValueKind.String | ||||
|                     ? langElement.GetString() | ||||
|                     : null; | ||||
|  | ||||
|                 if (!string.IsNullOrWhiteSpace(text)) | ||||
|                 { | ||||
|                     candidates.Add(new LocalizedText(text, lang)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return DescriptionNormalizer.Normalize(candidates); | ||||
|     } | ||||
|  | ||||
|     private static DateTimeOffset? TryGetDateTime(JsonElement element, string propertyName) | ||||
|     { | ||||
|         if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return DateTimeOffset.TryParse(property.GetString(), out var parsed) ? parsed : null; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AdvisoryReference> GetReferences( | ||||
|         JsonElement cve, | ||||
|         DocumentRecord document, | ||||
|         DateTimeOffset recordedAt, | ||||
|         IReadOnlyList<WeaknessMetadata> weaknesses) | ||||
|     { | ||||
|         var references = new List<AdvisoryReference>(); | ||||
|         if (!cve.TryGetProperty("references", out var referencesElement) || referencesElement.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             AppendWeaknessReferences(references, weaknesses, recordedAt); | ||||
|             return references; | ||||
|         } | ||||
|  | ||||
|         foreach (var reference in referencesElement.EnumerateArray()) | ||||
|         { | ||||
|             if (!reference.TryGetProperty("url", out var urlElement) || urlElement.ValueKind != JsonValueKind.String) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var url = urlElement.GetString(); | ||||
|             if (string.IsNullOrWhiteSpace(url) || !Validation.LooksLikeHttpUrl(url)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var sourceTag = reference.TryGetProperty("source", out var sourceElement) ? sourceElement.GetString() : null; | ||||
|             string? kind = null; | ||||
|             if (reference.TryGetProperty("tags", out var tagsElement) && tagsElement.ValueKind == JsonValueKind.Array) | ||||
|             { | ||||
|                 kind = tagsElement.EnumerateArray().Select(static t => t.GetString()).FirstOrDefault(static tag => !string.IsNullOrWhiteSpace(tag))?.ToLowerInvariant(); | ||||
|             } | ||||
|  | ||||
|             references.Add(new AdvisoryReference( | ||||
|                 url: url, | ||||
|                 kind: kind, | ||||
|                 sourceTag: sourceTag, | ||||
|                 summary: null, | ||||
|                 provenance: new AdvisoryProvenance( | ||||
|                     NvdConnectorPlugin.SourceName, | ||||
|                     "reference", | ||||
|                     url, | ||||
|                     recordedAt, | ||||
|                     new[] { ProvenanceFieldMasks.References }))); | ||||
|         } | ||||
|  | ||||
|         AppendWeaknessReferences(references, weaknesses, recordedAt); | ||||
|         return references; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<WeaknessMetadata> GetWeaknessMetadata(JsonElement cve) | ||||
|     { | ||||
|         if (!cve.TryGetProperty("weaknesses", out var weaknesses) || weaknesses.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             return Array.Empty<WeaknessMetadata>(); | ||||
|         } | ||||
|  | ||||
|         var list = new List<WeaknessMetadata>(weaknesses.GetArrayLength()); | ||||
|         foreach (var weakness in weaknesses.EnumerateArray()) | ||||
|         { | ||||
|             if (!weakness.TryGetProperty("description", out var descriptions) || descriptions.ValueKind != JsonValueKind.Array) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             string? cweId = null; | ||||
|             string? name = null; | ||||
|  | ||||
|             foreach (var description in descriptions.EnumerateArray()) | ||||
|             { | ||||
|                 if (description.ValueKind != JsonValueKind.Object) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!description.TryGetProperty("value", out var valueElement) || valueElement.ValueKind != JsonValueKind.String) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var value = valueElement.GetString(); | ||||
|                 if (string.IsNullOrWhiteSpace(value)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var trimmed = value.Trim(); | ||||
|                 if (trimmed.StartsWith("CWE-", StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     cweId ??= trimmed.ToUpperInvariant(); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     name ??= trimmed; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(cweId)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             list.Add(new WeaknessMetadata(cweId, name)); | ||||
|         } | ||||
|  | ||||
|         return list.Count == 0 ? Array.Empty<WeaknessMetadata>() : list; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AdvisoryWeakness> BuildWeaknesses(IReadOnlyList<WeaknessMetadata> metadata, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (metadata.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AdvisoryWeakness>(); | ||||
|         } | ||||
|  | ||||
|         var list = new List<AdvisoryWeakness>(metadata.Count); | ||||
|         foreach (var entry in metadata) | ||||
|         { | ||||
|             var provenance = new AdvisoryProvenance( | ||||
|                 NvdConnectorPlugin.SourceName, | ||||
|                 "weakness", | ||||
|                 entry.CweId, | ||||
|                 recordedAt, | ||||
|                 new[] { ProvenanceFieldMasks.Weaknesses }); | ||||
|  | ||||
|             var provenanceArray = ImmutableArray.Create(provenance); | ||||
|             list.Add(new AdvisoryWeakness( | ||||
|                 taxonomy: "cwe", | ||||
|                 identifier: entry.CweId, | ||||
|                 name: entry.Name, | ||||
|                 uri: BuildCweUrl(entry.CweId), | ||||
|                 provenance: provenanceArray)); | ||||
|         } | ||||
|  | ||||
|         return list; | ||||
|     } | ||||
|  | ||||
|     private static void AppendWeaknessReferences( | ||||
|         List<AdvisoryReference> references, | ||||
|         IReadOnlyList<WeaknessMetadata> weaknesses, | ||||
|         DateTimeOffset recordedAt) | ||||
|     { | ||||
|         if (weaknesses.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var existing = new HashSet<string>(references.Select(reference => reference.Url), StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         foreach (var weakness in weaknesses) | ||||
|         { | ||||
|             var url = BuildCweUrl(weakness.CweId); | ||||
|             if (url is null || existing.Contains(url)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var provenance = new AdvisoryProvenance( | ||||
|                 NvdConnectorPlugin.SourceName, | ||||
|                 "reference", | ||||
|                 url, | ||||
|                 recordedAt, | ||||
|                 new[] { ProvenanceFieldMasks.References }); | ||||
|  | ||||
|             references.Add(new AdvisoryReference(url, "weakness", weakness.CweId, weakness.Name, provenance)); | ||||
|             existing.Add(url); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AffectedPackage> GetAffectedPackages(JsonElement cve, string? cveId, DocumentRecord document, DateTimeOffset recordedAt) | ||||
|     { | ||||
|         var packages = new Dictionary<string, PackageAccumulator>(StringComparer.Ordinal); | ||||
|         if (!cve.TryGetProperty("configurations", out var configurations) || configurations.ValueKind != JsonValueKind.Object) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackage>(); | ||||
|         } | ||||
|  | ||||
|         if (!configurations.TryGetProperty("nodes", out var nodes) || nodes.ValueKind != JsonValueKind.Array) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackage>(); | ||||
|         } | ||||
|  | ||||
|         foreach (var node in nodes.EnumerateArray()) | ||||
|         { | ||||
|             if (!node.TryGetProperty("cpeMatch", out var matches) || matches.ValueKind != JsonValueKind.Array) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             foreach (var match in matches.EnumerateArray()) | ||||
|             { | ||||
|                 if (match.TryGetProperty("vulnerable", out var vulnerableElement) && vulnerableElement.ValueKind == JsonValueKind.False) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!match.TryGetProperty("criteria", out var criteriaElement) || criteriaElement.ValueKind != JsonValueKind.String) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var criteria = criteriaElement.GetString(); | ||||
|                 if (string.IsNullOrWhiteSpace(criteria)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var identifier = IdentifierNormalizer.TryNormalizeCpe(criteria, out var normalizedCpe) && !string.IsNullOrWhiteSpace(normalizedCpe) | ||||
|                     ? normalizedCpe | ||||
|                     : criteria.Trim(); | ||||
|  | ||||
|                 var provenance = new AdvisoryProvenance( | ||||
|                     NvdConnectorPlugin.SourceName, | ||||
|                     "cpe", | ||||
|                     document.Uri, | ||||
|                     recordedAt, | ||||
|                     new[] { ProvenanceFieldMasks.AffectedPackages }); | ||||
|                 if (!packages.TryGetValue(identifier, out var accumulator)) | ||||
|                 { | ||||
|                     accumulator = new PackageAccumulator(); | ||||
|                     packages[identifier] = accumulator; | ||||
|                 } | ||||
|  | ||||
|                 var range = BuildVersionRange(match, criteria, provenance); | ||||
|                 if (range is not null) | ||||
|                 { | ||||
|                     accumulator.Ranges.Add(range); | ||||
|                 } | ||||
|  | ||||
|                 accumulator.Provenance.Add(provenance); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (packages.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AffectedPackage>(); | ||||
|         } | ||||
|  | ||||
|         return packages | ||||
|             .OrderBy(static kvp => kvp.Key, StringComparer.Ordinal) | ||||
|             .Select(kvp => | ||||
|             { | ||||
|                 var ranges = kvp.Value.Ranges.Count == 0 | ||||
|                     ? Array.Empty<AffectedVersionRange>() | ||||
|                     : kvp.Value.Ranges | ||||
|                         .OrderBy(static range => range, AffectedVersionRangeComparer.Instance) | ||||
|                         .ToArray(); | ||||
|  | ||||
|                 var provenance = kvp.Value.Provenance | ||||
|                     .OrderBy(static p => p.Source, StringComparer.Ordinal) | ||||
|                     .ThenBy(static p => p.Kind, StringComparer.Ordinal) | ||||
|                     .ThenBy(static p => p.Value, StringComparer.Ordinal) | ||||
|                     .ThenBy(static p => p.RecordedAt.UtcDateTime) | ||||
|                     .ToArray(); | ||||
|  | ||||
|                 var normalizedNote = string.IsNullOrWhiteSpace(cveId) | ||||
|                     ? $"nvd:{document.Id:N}" | ||||
|                     : $"nvd:{cveId}"; | ||||
|  | ||||
|                 var normalizedVersions = new List<NormalizedVersionRule>(ranges.Length); | ||||
|                 foreach (var range in ranges) | ||||
|                 { | ||||
|                     var rule = range.ToNormalizedVersionRule(normalizedNote); | ||||
|                     if (rule is not null) | ||||
|                     { | ||||
|                         normalizedVersions.Add(rule); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 return new AffectedPackage( | ||||
|                     type: AffectedPackageTypes.Cpe, | ||||
|                     identifier: kvp.Key, | ||||
|                     platform: null, | ||||
|                     versionRanges: ranges, | ||||
|                     statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|                     provenance: provenance, | ||||
|                     normalizedVersions: normalizedVersions.Count == 0 | ||||
|                         ? Array.Empty<NormalizedVersionRule>() | ||||
|                         : normalizedVersions.ToArray()); | ||||
|             }) | ||||
|             .ToArray(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<CvssMetric> GetCvssMetrics(JsonElement cve, DocumentRecord document, DateTimeOffset recordedAt, out string? severity) | ||||
|     { | ||||
|         severity = null; | ||||
|         if (!cve.TryGetProperty("metrics", out var metrics) || metrics.ValueKind != JsonValueKind.Object) | ||||
|         { | ||||
|             return Array.Empty<CvssMetric>(); | ||||
|         } | ||||
|  | ||||
|         var sources = new[] { "cvssMetricV31", "cvssMetricV30", "cvssMetricV2" }; | ||||
|         foreach (var source in sources) | ||||
|         { | ||||
|             if (!metrics.TryGetProperty(source, out var array) || array.ValueKind != JsonValueKind.Array) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var list = new List<CvssMetric>(); | ||||
|             foreach (var item in array.EnumerateArray()) | ||||
|             { | ||||
|                 if (!item.TryGetProperty("cvssData", out var data) || data.ValueKind != JsonValueKind.Object) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!data.TryGetProperty("vectorString", out var vectorElement) || vectorElement.ValueKind != JsonValueKind.String) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!data.TryGetProperty("baseScore", out var scoreElement) || scoreElement.ValueKind != JsonValueKind.Number) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!data.TryGetProperty("baseSeverity", out var severityElement) || severityElement.ValueKind != JsonValueKind.String) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var vector = vectorElement.GetString() ?? string.Empty; | ||||
|                 var baseScore = scoreElement.GetDouble(); | ||||
|                 var baseSeverity = severityElement.GetString(); | ||||
|                 var versionToken = source switch | ||||
|                 { | ||||
|                     "cvssMetricV30" => "3.0", | ||||
|                     "cvssMetricV31" => "3.1", | ||||
|                     _ => "2.0", | ||||
|                 }; | ||||
|  | ||||
|                 if (!CvssMetricNormalizer.TryNormalize(versionToken, vector, baseScore, baseSeverity, out var normalized)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 severity ??= normalized.BaseSeverity; | ||||
|  | ||||
|                 list.Add(normalized.ToModel(new AdvisoryProvenance( | ||||
|                     NvdConnectorPlugin.SourceName, | ||||
|                     "cvss", | ||||
|                     normalized.Vector, | ||||
|                     recordedAt, | ||||
|                     new[] { ProvenanceFieldMasks.CvssMetrics }))); | ||||
|             } | ||||
|  | ||||
|             if (list.Count > 0) | ||||
|             { | ||||
|                 return list; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return Array.Empty<CvssMetric>(); | ||||
|     } | ||||
|  | ||||
|     private static AffectedVersionRange? BuildVersionRange(JsonElement match, string criteria, AdvisoryProvenance provenance) | ||||
|     { | ||||
|         static string? ReadString(JsonElement parent, string property) | ||||
|         { | ||||
|             if (!parent.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.String) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             var text = value.GetString(); | ||||
|             return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); | ||||
|         } | ||||
|  | ||||
|         var version = ReadString(match, "version"); | ||||
|         if (string.Equals(version, "*", StringComparison.Ordinal)) | ||||
|         { | ||||
|             version = null; | ||||
|         } | ||||
|  | ||||
|         version ??= TryExtractVersionFromCriteria(criteria); | ||||
|  | ||||
|         var versionStartIncluding = ReadString(match, "versionStartIncluding"); | ||||
|         var versionStartExcluding = ReadString(match, "versionStartExcluding"); | ||||
|         var versionEndIncluding = ReadString(match, "versionEndIncluding"); | ||||
|         var versionEndExcluding = ReadString(match, "versionEndExcluding"); | ||||
|  | ||||
|         var vendorExtensions = new Dictionary<string, string>(StringComparer.Ordinal); | ||||
|         if (versionStartIncluding is not null) | ||||
|         { | ||||
|             vendorExtensions["versionStartIncluding"] = versionStartIncluding; | ||||
|         } | ||||
|  | ||||
|         if (versionStartExcluding is not null) | ||||
|         { | ||||
|             vendorExtensions["versionStartExcluding"] = versionStartExcluding; | ||||
|         } | ||||
|  | ||||
|         if (versionEndIncluding is not null) | ||||
|         { | ||||
|             vendorExtensions["versionEndIncluding"] = versionEndIncluding; | ||||
|         } | ||||
|  | ||||
|         if (versionEndExcluding is not null) | ||||
|         { | ||||
|             vendorExtensions["versionEndExcluding"] = versionEndExcluding; | ||||
|         } | ||||
|  | ||||
|         if (version is not null) | ||||
|         { | ||||
|             vendorExtensions["version"] = version; | ||||
|         } | ||||
|  | ||||
|         string? introduced = null; | ||||
|         string? fixedVersion = null; | ||||
|         string? lastAffected = null; | ||||
|         string? exactVersion = null; | ||||
|         var expressionParts = new List<string>(); | ||||
|  | ||||
|         var introducedInclusive = true; | ||||
|         var fixedInclusive = false; | ||||
|         var lastInclusive = true; | ||||
|  | ||||
|         if (versionStartIncluding is not null) | ||||
|         { | ||||
|             introduced = versionStartIncluding; | ||||
|             introducedInclusive = true; | ||||
|             expressionParts.Add($">={versionStartIncluding}"); | ||||
|         } | ||||
|  | ||||
|         if (versionStartExcluding is not null) | ||||
|         { | ||||
|             if (introduced is null) | ||||
|             { | ||||
|                 introduced = versionStartExcluding; | ||||
|                 introducedInclusive = false; | ||||
|             } | ||||
|             expressionParts.Add($">{versionStartExcluding}"); | ||||
|         } | ||||
|  | ||||
|         if (versionEndExcluding is not null) | ||||
|         { | ||||
|             fixedVersion = versionEndExcluding; | ||||
|             fixedInclusive = false; | ||||
|             expressionParts.Add($"<{versionEndExcluding}"); | ||||
|         } | ||||
|  | ||||
|         if (versionEndIncluding is not null) | ||||
|         { | ||||
|             lastAffected = versionEndIncluding; | ||||
|             lastInclusive = true; | ||||
|             expressionParts.Add($"<={versionEndIncluding}"); | ||||
|         } | ||||
|  | ||||
|         if (version is not null) | ||||
|         { | ||||
|             introduced = version; | ||||
|             introducedInclusive = true; | ||||
|             lastAffected = version; | ||||
|             lastInclusive = true; | ||||
|             exactVersion = version; | ||||
|             expressionParts.Add($"=={version}"); | ||||
|         } | ||||
|  | ||||
|         if (introduced is null && fixedVersion is null && lastAffected is null && vendorExtensions.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var rangeExpression = expressionParts.Count > 0 ? string.Join(' ', expressionParts) : null; | ||||
|         IReadOnlyDictionary<string, string>? extensions = vendorExtensions.Count == 0 ? null : vendorExtensions; | ||||
|  | ||||
|         SemVerPrimitive? semVerPrimitive = null; | ||||
|         if (TryBuildSemVerPrimitive( | ||||
|             introduced, | ||||
|             introducedInclusive, | ||||
|             fixedVersion, | ||||
|             fixedInclusive, | ||||
|             lastAffected, | ||||
|             lastInclusive, | ||||
|             exactVersion, | ||||
|             rangeExpression, | ||||
|             out var primitive)) | ||||
|         { | ||||
|             semVerPrimitive = primitive; | ||||
|         } | ||||
|  | ||||
|         var primitives = semVerPrimitive is null && extensions is null | ||||
|             ? null | ||||
|             : new RangePrimitives(semVerPrimitive, null, null, extensions); | ||||
|  | ||||
|         var provenanceValue = provenance.Value ?? criteria; | ||||
|         var rangeProvenance = new AdvisoryProvenance( | ||||
|             provenance.Source, | ||||
|             provenance.Kind, | ||||
|             provenanceValue, | ||||
|             provenance.RecordedAt, | ||||
|             new[] { ProvenanceFieldMasks.VersionRanges }); | ||||
|  | ||||
|         return new AffectedVersionRange( | ||||
|             rangeKind: "cpe", | ||||
|             introducedVersion: introduced, | ||||
|             fixedVersion: fixedVersion, | ||||
|             lastAffectedVersion: lastAffected, | ||||
|             rangeExpression: rangeExpression, | ||||
|             provenance: rangeProvenance, | ||||
|             primitives); | ||||
|     } | ||||
|  | ||||
|     private static bool TryBuildSemVerPrimitive( | ||||
|         string? introduced, | ||||
|         bool introducedInclusive, | ||||
|         string? fixedVersion, | ||||
|         bool fixedInclusive, | ||||
|         string? lastAffected, | ||||
|         bool lastInclusive, | ||||
|         string? exactVersion, | ||||
|         string? constraintExpression, | ||||
|         out SemVerPrimitive? primitive) | ||||
|     { | ||||
|         primitive = null; | ||||
|  | ||||
|         if (!TryNormalizeSemVer(introduced, out var normalizedIntroduced) | ||||
|             || !TryNormalizeSemVer(fixedVersion, out var normalizedFixed) | ||||
|             || !TryNormalizeSemVer(lastAffected, out var normalizedLast) | ||||
|             || !TryNormalizeSemVer(exactVersion, out var normalizedExact)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (normalizedIntroduced is null && normalizedFixed is null && normalizedLast is null && normalizedExact is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         primitive = new SemVerPrimitive( | ||||
|             Introduced: normalizedIntroduced, | ||||
|             IntroducedInclusive: normalizedIntroduced is null ? true : introducedInclusive, | ||||
|             Fixed: normalizedFixed, | ||||
|             FixedInclusive: normalizedFixed is null ? false : fixedInclusive, | ||||
|             LastAffected: normalizedLast, | ||||
|             LastAffectedInclusive: normalizedLast is null ? false : lastInclusive, | ||||
|             ConstraintExpression: constraintExpression, | ||||
|             ExactValue: normalizedExact); | ||||
|  | ||||
|     return true; | ||||
|     } | ||||
|  | ||||
|     private static bool TryNormalizeSemVer(string? value, out string? normalized) | ||||
|     { | ||||
|         normalized = null; | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         var trimmed = value.Trim(); | ||||
|         if (trimmed.StartsWith("v", StringComparison.OrdinalIgnoreCase) && trimmed.Length > 1) | ||||
|         { | ||||
|             trimmed = trimmed[1..]; | ||||
|         } | ||||
|  | ||||
|         if (!NuGetVersion.TryParse(trimmed, out var parsed)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         normalized = parsed.ToNormalizedString(); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string? BuildCweUrl(string cweId) | ||||
|     { | ||||
|         var dashIndex = cweId.IndexOf('-'); | ||||
|         if (dashIndex < 0 || dashIndex == cweId.Length - 1) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var digits = new StringBuilder(); | ||||
|         for (var i = dashIndex + 1; i < cweId.Length; i++) | ||||
|         { | ||||
|             var ch = cweId[i]; | ||||
|             if (char.IsDigit(ch)) | ||||
|             { | ||||
|                 digits.Append(ch); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return digits.Length == 0 ? null : $"https://cwe.mitre.org/data/definitions/{digits}.html"; | ||||
|     } | ||||
|  | ||||
|     private static string? TryExtractVersionFromCriteria(string criteria) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(criteria)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var segments = criteria.Split(':'); | ||||
|         if (segments.Length < 6) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var version = segments[5]; | ||||
|         if (string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (string.Equals(version, "*", StringComparison.Ordinal) || string.Equals(version, "-", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return version; | ||||
|     } | ||||
|  | ||||
|     private readonly record struct WeaknessMetadata(string CweId, string? Name); | ||||
|  | ||||
|     private sealed class PackageAccumulator | ||||
|     { | ||||
|         public List<AffectedVersionRange> Ranges { get; } = new(); | ||||
|  | ||||
|         public List<AdvisoryProvenance> Provenance { get; } = new(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| using System.IO; | ||||
| using System.Reflection; | ||||
| using System.Threading; | ||||
| using Json.Schema; | ||||
|  | ||||
| namespace StellaOps.Concelier.Connector.Nvd.Internal; | ||||
|  | ||||
| internal static class NvdSchemaProvider | ||||
| { | ||||
|     private static readonly Lazy<JsonSchema> Cached = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication); | ||||
|  | ||||
|     public static JsonSchema Schema => Cached.Value; | ||||
|  | ||||
|     private static JsonSchema LoadSchema() | ||||
|     { | ||||
|         var assembly = typeof(NvdSchemaProvider).GetTypeInfo().Assembly; | ||||
|         const string resourceName = "StellaOps.Concelier.Connector.Nvd.Schemas.nvd-vulnerability.schema.json"; | ||||
|  | ||||
|         using var stream = assembly.GetManifestResourceStream(resourceName) | ||||
|             ?? throw new InvalidOperationException($"Embedded schema '{resourceName}' not found."); | ||||
|         using var reader = new StreamReader(stream); | ||||
|         var schemaText = reader.ReadToEnd(); | ||||
|         return JsonSchema.FromText(schemaText); | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user