using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; using StellaOps.Feedser.Models; using StellaOps.Feedser.Normalization.Cvss; using StellaOps.Feedser.Normalization.SemVer; using StellaOps.Feedser.Storage.Mongo.Documents; namespace StellaOps.Feedser.Source.Ghsa.Internal; internal static class GhsaMapper { private static readonly HashSet SemVerEcosystems = new(StringComparer.OrdinalIgnoreCase) { "npm", "maven", "pip", "rubygems", "composer", "nuget", "go", "cargo", }; public static Advisory Map(GhsaRecordDto dto, DocumentRecord document, DateTimeOffset recordedAt) { ArgumentNullException.ThrowIfNull(dto); ArgumentNullException.ThrowIfNull(document); var fetchProvenance = new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "document", document.Uri, document.FetchedAt, new[] { ProvenanceFieldMasks.Advisory }); var mapProvenance = new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "mapping", dto.GhsaId, recordedAt, new[] { ProvenanceFieldMasks.Advisory }); var aliases = dto.Aliases .Where(static alias => !string.IsNullOrWhiteSpace(alias)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); var references = dto.References .Select(reference => CreateReference(reference, recordedAt)) .Where(static reference => reference is not null) .Cast() .ToList(); var affected = CreateAffectedPackages(dto, recordedAt); var credits = CreateCredits(dto.Credits, recordedAt); var weaknesses = CreateWeaknesses(dto.Cwes, recordedAt); var cvssMetrics = CreateCvssMetrics(dto.Cvss, recordedAt, out var cvssSeverity, out var canonicalMetricId); var severityHint = SeverityNormalization.Normalize(dto.Severity); var cvssSeverityHint = SeverityNormalization.Normalize(dto.Cvss?.Severity); var severity = severityHint ?? cvssSeverity ?? cvssSeverityHint; if (canonicalMetricId is null) { var fallbackSeverity = severityHint ?? cvssSeverityHint ?? cvssSeverity; if (!string.IsNullOrWhiteSpace(fallbackSeverity)) { canonicalMetricId = BuildSeverityCanonicalMetricId(fallbackSeverity); } } var summary = dto.Summary ?? dto.Description; var description = Validation.TrimToNull(dto.Description); return new Advisory( advisoryKey: dto.GhsaId, title: dto.Summary ?? dto.GhsaId, summary: summary, language: "en", published: dto.PublishedAt, modified: dto.UpdatedAt ?? dto.PublishedAt, severity: severity, exploitKnown: false, aliases: aliases, credits: credits, references: references, affectedPackages: affected, cvssMetrics: cvssMetrics, provenance: new[] { fetchProvenance, mapProvenance }, description: description, cwes: weaknesses, canonicalMetricId: canonicalMetricId); } private static string BuildSeverityCanonicalMetricId(string severity) => $"{GhsaConnectorPlugin.SourceName}:severity/{severity}"; private static AdvisoryReference? CreateReference(GhsaReferenceDto reference, DateTimeOffset recordedAt) { if (string.IsNullOrWhiteSpace(reference.Url) || !Validation.LooksLikeHttpUrl(reference.Url)) { return null; } var kind = reference.Type?.ToLowerInvariant(); return new AdvisoryReference( reference.Url, kind, reference.Name, summary: null, provenance: new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "reference", reference.Url, recordedAt, new[] { ProvenanceFieldMasks.References })); } private static IReadOnlyList CreateAffectedPackages(GhsaRecordDto dto, DateTimeOffset recordedAt) { if (dto.Affected.Count == 0) { return Array.Empty(); } var packages = new List(dto.Affected.Count); foreach (var affected in dto.Affected) { var ecosystem = string.IsNullOrWhiteSpace(affected.Ecosystem) ? "unknown" : affected.Ecosystem.Trim(); var packageName = string.IsNullOrWhiteSpace(affected.PackageName) ? "unknown-package" : affected.PackageName.Trim(); var identifier = $"{ecosystem.ToLowerInvariant()}:{packageName}"; var provenance = new[] { new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "affected", identifier, recordedAt, new[] { ProvenanceFieldMasks.AffectedPackages }), }; var rangeKind = SemVerEcosystems.Contains(ecosystem) ? "semver" : "vendor"; var packageType = SemVerEcosystems.Contains(ecosystem) ? AffectedPackageTypes.SemVer : AffectedPackageTypes.Vendor; var (ranges, normalizedVersions) = SemVerEcosystems.Contains(ecosystem) ? CreateSemVerVersionArtifacts(affected, identifier, ecosystem, packageName, recordedAt) : CreateVendorVersionArtifacts(affected, rangeKind, identifier, ecosystem, packageName, recordedAt); var statuses = new[] { new AffectedPackageStatus( "affected", new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "affected-status", identifier, recordedAt, new[] { ProvenanceFieldMasks.PackageStatuses })), }; packages.Add(new AffectedPackage( packageType, identifier, platform: null, versionRanges: ranges, statuses: statuses, provenance: provenance, normalizedVersions: normalizedVersions)); } return packages; } private static IReadOnlyList CreateCredits(IReadOnlyList credits, DateTimeOffset recordedAt) { if (credits.Count == 0) { return Array.Empty(); } var results = new List(credits.Count); foreach (var credit in credits) { var displayName = Validation.TrimToNull(credit.Name) ?? Validation.TrimToNull(credit.Login); if (displayName is null) { continue; } var contacts = new List(); if (!string.IsNullOrWhiteSpace(credit.ProfileUrl) && Validation.LooksLikeHttpUrl(credit.ProfileUrl)) { contacts.Add(credit.ProfileUrl.Trim()); } else if (!string.IsNullOrWhiteSpace(credit.Login)) { contacts.Add($"https://github.com/{credit.Login.Trim()}"); } var provenance = new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "credit", displayName, recordedAt, new[] { ProvenanceFieldMasks.Credits }); results.Add(new AdvisoryCredit(displayName, credit.Type, contacts, provenance)); } return results.Count == 0 ? Array.Empty() : results; } private static IReadOnlyList CreateWeaknesses(IReadOnlyList cwes, DateTimeOffset recordedAt) { if (cwes.Count == 0) { return Array.Empty(); } var list = new List(cwes.Count); foreach (var cwe in cwes) { if (cwe is null || string.IsNullOrWhiteSpace(cwe.CweId)) { continue; } var identifier = cwe.CweId.Trim(); var provenance = new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "weakness", identifier, recordedAt, new[] { ProvenanceFieldMasks.Weaknesses }); var provenanceArray = ImmutableArray.Create(provenance); list.Add(new AdvisoryWeakness( taxonomy: "cwe", identifier: identifier, name: Validation.TrimToNull(cwe.Name), uri: BuildCweUrl(identifier), provenance: provenanceArray)); } return list.Count == 0 ? Array.Empty() : list; } private static IReadOnlyList CreateCvssMetrics(GhsaCvssDto? cvss, DateTimeOffset recordedAt, out string? severity, out string? canonicalMetricId) { severity = null; canonicalMetricId = null; if (cvss is null) { return Array.Empty(); } var vector = Validation.TrimToNull(cvss.VectorString); if (!CvssMetricNormalizer.TryNormalize(null, vector, cvss.Score, cvss.Severity, out var normalized)) { return Array.Empty(); } severity = normalized.BaseSeverity; canonicalMetricId = $"{normalized.Version}|{normalized.Vector}"; var provenance = new AdvisoryProvenance( GhsaConnectorPlugin.SourceName, "cvss", normalized.Vector, recordedAt, new[] { ProvenanceFieldMasks.CvssMetrics }); return new[] { normalized.ToModel(provenance), }; } private static string? BuildCweUrl(string? cweId) { if (string.IsNullOrWhiteSpace(cweId)) { return null; } var trimmed = cweId.Trim(); var dashIndex = trimmed.IndexOf('-'); if (dashIndex < 0 || dashIndex == trimmed.Length - 1) { return null; } var digits = new StringBuilder(); for (var i = dashIndex + 1; i < trimmed.Length; i++) { var ch = trimmed[i]; if (char.IsDigit(ch)) { digits.Append(ch); } } return digits.Length == 0 ? null : $"https://cwe.mitre.org/data/definitions/{digits}.html"; } private static (IReadOnlyList Ranges, IReadOnlyList Normalized) CreateSemVerVersionArtifacts( GhsaAffectedDto affected, string identifier, string ecosystem, string packageName, DateTimeOffset recordedAt) { var note = BuildNormalizedNote(identifier); var results = SemVerRangeRuleBuilder.Build(affected.VulnerableRange, affected.PatchedVersion, note); if (results.Count > 0) { var ranges = new List(results.Count); var normalized = new List(results.Count); foreach (var result in results) { var primitive = result.Primitive; var rangeExpression = ResolveRangeExpression(result.Expression, primitive.ConstraintExpression, affected.VulnerableRange); ranges.Add(new AffectedVersionRange( rangeKind: "semver", introducedVersion: Validation.TrimToNull(primitive.Introduced), fixedVersion: Validation.TrimToNull(primitive.Fixed), lastAffectedVersion: Validation.TrimToNull(primitive.LastAffected), rangeExpression: rangeExpression, provenance: CreateRangeProvenance(identifier, recordedAt), primitives: new RangePrimitives( SemVer: primitive, Nevra: null, Evr: null, VendorExtensions: CreateVendorExtensions(ecosystem, packageName)))); normalized.Add(result.NormalizedRule); } return (ranges.ToArray(), normalized.ToArray()); } var fallbackRange = CreateFallbackRange("semver", affected, identifier, ecosystem, packageName, recordedAt); if (fallbackRange is null) { return (Array.Empty(), Array.Empty()); } var fallbackRule = fallbackRange.ToNormalizedVersionRule(note); var normalizedFallback = fallbackRule is null ? Array.Empty() : new[] { fallbackRule }; return (new[] { fallbackRange }, normalizedFallback); } private static (IReadOnlyList Ranges, IReadOnlyList Normalized) CreateVendorVersionArtifacts( GhsaAffectedDto affected, string rangeKind, string identifier, string ecosystem, string packageName, DateTimeOffset recordedAt) { var range = CreateFallbackRange(rangeKind, affected, identifier, ecosystem, packageName, recordedAt); if (range is null) { return (Array.Empty(), Array.Empty()); } return (new[] { range }, Array.Empty()); } private static AffectedVersionRange? CreateFallbackRange( string rangeKind, GhsaAffectedDto affected, string identifier, string ecosystem, string packageName, DateTimeOffset recordedAt) { var fixedVersion = Validation.TrimToNull(affected.PatchedVersion); var rangeExpression = Validation.TrimToNull(affected.VulnerableRange); if (fixedVersion is null && rangeExpression is null) { return null; } return new AffectedVersionRange( rangeKind, introducedVersion: null, fixedVersion: fixedVersion, lastAffectedVersion: null, rangeExpression: rangeExpression, provenance: CreateRangeProvenance(identifier, recordedAt), primitives: new RangePrimitives( SemVer: null, Nevra: null, Evr: null, VendorExtensions: CreateVendorExtensions(ecosystem, packageName))); } private static AdvisoryProvenance CreateRangeProvenance(string identifier, DateTimeOffset recordedAt) => new( GhsaConnectorPlugin.SourceName, "affected-range", identifier, recordedAt, new[] { ProvenanceFieldMasks.VersionRanges }); private static IReadOnlyDictionary CreateVendorExtensions(string ecosystem, string packageName) => new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ecosystem"] = ecosystem, ["package"] = packageName, }; private static string? BuildNormalizedNote(string identifier) { var trimmed = Validation.TrimToNull(identifier); return trimmed is null ? null : $"ghsa:{trimmed}"; } private static string? ResolveRangeExpression(string? parsedExpression, string? constraintExpression, string? fallbackExpression) { var parsed = Validation.TrimToNull(parsedExpression); if (parsed is not null) { return parsed; } var constraint = Validation.TrimToNull(constraintExpression); if (constraint is not null) { return constraint; } return Validation.TrimToNull(fallbackExpression); } }