Files
git.stella-ops.org/src/StellaOps.Feedser.Source.Ghsa/Internal/GhsaMapper.cs
Vladimir Moushkov c65061602b
Some checks failed
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
commit
2025-10-16 19:44:10 +03:00

448 lines
16 KiB
C#

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<string> 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<AdvisoryReference>()
.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<AffectedPackage> CreateAffectedPackages(GhsaRecordDto dto, DateTimeOffset recordedAt)
{
if (dto.Affected.Count == 0)
{
return Array.Empty<AffectedPackage>();
}
var packages = new List<AffectedPackage>(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<AdvisoryCredit> CreateCredits(IReadOnlyList<GhsaCreditDto> credits, DateTimeOffset recordedAt)
{
if (credits.Count == 0)
{
return Array.Empty<AdvisoryCredit>();
}
var results = new List<AdvisoryCredit>(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<string>();
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<AdvisoryCredit>() : results;
}
private static IReadOnlyList<AdvisoryWeakness> CreateWeaknesses(IReadOnlyList<GhsaWeaknessDto> cwes, DateTimeOffset recordedAt)
{
if (cwes.Count == 0)
{
return Array.Empty<AdvisoryWeakness>();
}
var list = new List<AdvisoryWeakness>(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<AdvisoryWeakness>() : list;
}
private static IReadOnlyList<CvssMetric> CreateCvssMetrics(GhsaCvssDto? cvss, DateTimeOffset recordedAt, out string? severity, out string? canonicalMetricId)
{
severity = null;
canonicalMetricId = null;
if (cvss is null)
{
return Array.Empty<CvssMetric>();
}
var vector = Validation.TrimToNull(cvss.VectorString);
if (!CvssMetricNormalizer.TryNormalize(null, vector, cvss.Score, cvss.Severity, out var normalized))
{
return Array.Empty<CvssMetric>();
}
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<AffectedVersionRange> Ranges, IReadOnlyList<NormalizedVersionRule> 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<AffectedVersionRange>(results.Count);
var normalized = new List<NormalizedVersionRule>(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<AffectedVersionRange>(), Array.Empty<NormalizedVersionRule>());
}
var fallbackRule = fallbackRange.ToNormalizedVersionRule(note);
var normalizedFallback = fallbackRule is null
? Array.Empty<NormalizedVersionRule>()
: new[] { fallbackRule };
return (new[] { fallbackRange }, normalizedFallback);
}
private static (IReadOnlyList<AffectedVersionRange> Ranges, IReadOnlyList<NormalizedVersionRule> 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<AffectedVersionRange>(), Array.Empty<NormalizedVersionRule>());
}
return (new[] { range }, Array.Empty<NormalizedVersionRule>());
}
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<string, string> CreateVendorExtensions(string ecosystem, string packageName)
=> new Dictionary<string, string>(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);
}
}