448 lines
16 KiB
C#
448 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using StellaOps.Concelier.Models;
|
|
using StellaOps.Concelier.Normalization.Cvss;
|
|
using StellaOps.Concelier.Normalization.SemVer;
|
|
using StellaOps.Concelier.Storage.Mongo.Documents;
|
|
|
|
namespace StellaOps.Concelier.Connector.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);
|
|
}
|
|
}
|