Some checks failed
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
Build Test Deploy / build-test (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
314 lines
10 KiB
C#
314 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using StellaOps.Feedser.Models;
|
|
using StellaOps.Feedser.Normalization.Distro;
|
|
using StellaOps.Feedser.Storage.Mongo.Documents;
|
|
|
|
namespace StellaOps.Feedser.Source.Distro.Suse.Internal;
|
|
|
|
internal static class SuseMapper
|
|
{
|
|
public static Advisory Map(SuseAdvisoryDto dto, DocumentRecord document, DateTimeOffset recordedAt)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dto);
|
|
ArgumentNullException.ThrowIfNull(document);
|
|
|
|
var aliases = BuildAliases(dto);
|
|
var references = BuildReferences(dto, recordedAt);
|
|
var packages = BuildPackages(dto, recordedAt);
|
|
|
|
var fetchProvenance = new AdvisoryProvenance(
|
|
SuseConnectorPlugin.SourceName,
|
|
"document",
|
|
document.Uri,
|
|
document.FetchedAt.ToUniversalTime());
|
|
|
|
var mapProvenance = new AdvisoryProvenance(
|
|
SuseConnectorPlugin.SourceName,
|
|
"mapping",
|
|
dto.AdvisoryId,
|
|
recordedAt);
|
|
|
|
var published = dto.Published;
|
|
var modified = DateTimeOffset.Compare(recordedAt, dto.Published) >= 0 ? recordedAt : dto.Published;
|
|
|
|
return new Advisory(
|
|
advisoryKey: dto.AdvisoryId,
|
|
title: dto.Title ?? dto.AdvisoryId,
|
|
summary: dto.Summary,
|
|
language: "en",
|
|
published: published,
|
|
modified: modified,
|
|
severity: null,
|
|
exploitKnown: false,
|
|
aliases: aliases,
|
|
references: references,
|
|
affectedPackages: packages,
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { fetchProvenance, mapProvenance });
|
|
}
|
|
|
|
private static string[] BuildAliases(SuseAdvisoryDto dto)
|
|
{
|
|
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
dto.AdvisoryId
|
|
};
|
|
|
|
foreach (var cve in dto.CveIds ?? Array.Empty<string>())
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(cve))
|
|
{
|
|
aliases.Add(cve.Trim());
|
|
}
|
|
}
|
|
|
|
return aliases.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray();
|
|
}
|
|
|
|
private static AdvisoryReference[] BuildReferences(SuseAdvisoryDto dto, DateTimeOffset recordedAt)
|
|
{
|
|
if (dto.References is null || dto.References.Count == 0)
|
|
{
|
|
return Array.Empty<AdvisoryReference>();
|
|
}
|
|
|
|
var references = new List<AdvisoryReference>(dto.References.Count);
|
|
foreach (var reference in dto.References)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reference.Url))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var provenance = new AdvisoryProvenance(
|
|
SuseConnectorPlugin.SourceName,
|
|
"reference",
|
|
reference.Url,
|
|
recordedAt);
|
|
|
|
references.Add(new AdvisoryReference(
|
|
reference.Url.Trim(),
|
|
NormalizeReferenceKind(reference.Kind),
|
|
reference.Kind,
|
|
reference.Title,
|
|
provenance));
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
// Ignore malformed URLs to keep advisory mapping resilient.
|
|
}
|
|
}
|
|
|
|
return references.Count == 0
|
|
? Array.Empty<AdvisoryReference>()
|
|
: references
|
|
.OrderBy(static reference => reference.Url, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
}
|
|
|
|
private static string? NormalizeReferenceKind(string? kind)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(kind))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return kind.Trim().ToLowerInvariant() switch
|
|
{
|
|
"cve" => "cve",
|
|
"self" => "advisory",
|
|
"external" => "external",
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<AffectedPackage> BuildPackages(SuseAdvisoryDto dto, DateTimeOffset recordedAt)
|
|
{
|
|
if (dto.Packages is null || dto.Packages.Count == 0)
|
|
{
|
|
return Array.Empty<AffectedPackage>();
|
|
}
|
|
|
|
var packages = new List<AffectedPackage>(dto.Packages.Count);
|
|
foreach (var package in dto.Packages)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(package.CanonicalNevra))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Nevra? nevra;
|
|
if (!Nevra.TryParse(package.CanonicalNevra, out nevra))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var affectedProvenance = new AdvisoryProvenance(
|
|
SuseConnectorPlugin.SourceName,
|
|
"affected",
|
|
$"{package.Platform}:{package.CanonicalNevra}",
|
|
recordedAt);
|
|
|
|
var ranges = BuildVersionRanges(package, nevra!, recordedAt);
|
|
if (ranges.Count == 0 && string.Equals(package.Status, "not_affected", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
packages.Add(new AffectedPackage(
|
|
AffectedPackageTypes.Rpm,
|
|
identifier: nevra!.ToCanonicalString(),
|
|
platform: package.Platform,
|
|
versionRanges: ranges,
|
|
statuses: BuildStatuses(package, affectedProvenance),
|
|
provenance: new[] { affectedProvenance }));
|
|
}
|
|
|
|
return packages.Count == 0
|
|
? Array.Empty<AffectedPackage>()
|
|
: packages
|
|
.OrderBy(static pkg => pkg.Platform, StringComparer.OrdinalIgnoreCase)
|
|
.ThenBy(static pkg => pkg.Identifier, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
}
|
|
|
|
private static IReadOnlyList<AffectedPackageStatus> BuildStatuses(SusePackageStateDto package, AdvisoryProvenance provenance)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(package.Status))
|
|
{
|
|
return Array.Empty<AffectedPackageStatus>();
|
|
}
|
|
|
|
return new[]
|
|
{
|
|
new AffectedPackageStatus(package.Status, provenance)
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(SusePackageStateDto package, Nevra nevra, DateTimeOffset recordedAt)
|
|
{
|
|
var introducedComponent = ParseNevraComponent(package.IntroducedVersion, nevra);
|
|
var fixedComponent = ParseNevraComponent(package.FixedVersion, nevra);
|
|
var lastAffectedComponent = ParseNevraComponent(package.LastAffectedVersion, nevra);
|
|
|
|
if (introducedComponent is null && fixedComponent is null && lastAffectedComponent is null)
|
|
{
|
|
return Array.Empty<AffectedVersionRange>();
|
|
}
|
|
|
|
var rangeProvenance = new AdvisoryProvenance(
|
|
SuseConnectorPlugin.SourceName,
|
|
"range",
|
|
$"{package.Platform}:{nevra.ToCanonicalString()}",
|
|
recordedAt);
|
|
|
|
var extensions = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["suse.status"] = package.Status
|
|
};
|
|
|
|
var rangeExpression = BuildRangeExpression(package.IntroducedVersion, package.FixedVersion, package.LastAffectedVersion);
|
|
|
|
var range = new AffectedVersionRange(
|
|
rangeKind: "nevra",
|
|
introducedVersion: package.IntroducedVersion,
|
|
fixedVersion: package.FixedVersion,
|
|
lastAffectedVersion: package.LastAffectedVersion,
|
|
rangeExpression: rangeExpression,
|
|
provenance: rangeProvenance,
|
|
primitives: new RangePrimitives(
|
|
SemVer: null,
|
|
Nevra: new NevraPrimitive(introducedComponent, fixedComponent, lastAffectedComponent),
|
|
Evr: null,
|
|
VendorExtensions: extensions));
|
|
|
|
return new[] { range };
|
|
}
|
|
|
|
private static NevraComponent? ParseNevraComponent(string? version, Nevra nevra)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(version))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!TrySplitNevraVersion(version.Trim(), out var epoch, out var ver, out var rel))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new NevraComponent(
|
|
nevra.Name,
|
|
epoch,
|
|
ver,
|
|
rel,
|
|
string.IsNullOrWhiteSpace(nevra.Architecture) ? null : nevra.Architecture);
|
|
}
|
|
|
|
private static bool TrySplitNevraVersion(string value, out int epoch, out string version, out string release)
|
|
{
|
|
epoch = 0;
|
|
version = string.Empty;
|
|
release = string.Empty;
|
|
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var trimmed = value.Trim();
|
|
var dashIndex = trimmed.LastIndexOf('-');
|
|
if (dashIndex <= 0 || dashIndex >= trimmed.Length - 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
release = trimmed[(dashIndex + 1)..];
|
|
var versionSegment = trimmed[..dashIndex];
|
|
|
|
var epochIndex = versionSegment.IndexOf(':');
|
|
if (epochIndex >= 0)
|
|
{
|
|
var epochPart = versionSegment[..epochIndex];
|
|
version = epochIndex < versionSegment.Length - 1 ? versionSegment[(epochIndex + 1)..] : string.Empty;
|
|
if (epochPart.Length > 0 && !int.TryParse(epochPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out epoch))
|
|
{
|
|
epoch = 0;
|
|
return false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
version = versionSegment;
|
|
}
|
|
|
|
return !string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(release);
|
|
}
|
|
|
|
private static string? BuildRangeExpression(string? introduced, string? fixedVersion, string? lastAffected)
|
|
{
|
|
var parts = new List<string>(3);
|
|
if (!string.IsNullOrWhiteSpace(introduced))
|
|
{
|
|
parts.Add($"introduced:{introduced}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(fixedVersion))
|
|
{
|
|
parts.Add($"fixed:{fixedVersion}");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(lastAffected))
|
|
{
|
|
parts.Add($"last:{lastAffected}");
|
|
}
|
|
|
|
return parts.Count == 0 ? null : string.Join(" ", parts);
|
|
}
|
|
}
|