Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.Distro.RedHat.Internal.Models;
internal sealed class RedHatCsafEnvelope
{
[JsonPropertyName("document")]
public RedHatDocumentSection? Document { get; init; }
[JsonPropertyName("product_tree")]
public RedHatProductTree? ProductTree { get; init; }
[JsonPropertyName("vulnerabilities")]
public IReadOnlyList<RedHatVulnerability>? Vulnerabilities { get; init; }
}
internal sealed class RedHatDocumentSection
{
[JsonPropertyName("aggregate_severity")]
public RedHatAggregateSeverity? AggregateSeverity { get; init; }
[JsonPropertyName("lang")]
public string? Lang { get; init; }
[JsonPropertyName("notes")]
public IReadOnlyList<RedHatDocumentNote>? Notes { get; init; }
[JsonPropertyName("references")]
public IReadOnlyList<RedHatReference>? References { get; init; }
[JsonPropertyName("title")]
public string? Title { get; init; }
[JsonPropertyName("tracking")]
public RedHatTracking? Tracking { get; init; }
}
internal sealed class RedHatAggregateSeverity
{
[JsonPropertyName("text")]
public string? Text { get; init; }
}
internal sealed class RedHatDocumentNote
{
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("text")]
public string? Text { get; init; }
public bool CategoryEquals(string value)
=> !string.IsNullOrWhiteSpace(Category)
&& string.Equals(Category, value, StringComparison.OrdinalIgnoreCase);
}
internal sealed class RedHatTracking
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("initial_release_date")]
public DateTimeOffset? InitialReleaseDate { get; init; }
[JsonPropertyName("current_release_date")]
public DateTimeOffset? CurrentReleaseDate { get; init; }
}
internal sealed class RedHatReference
{
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
[JsonPropertyName("url")]
public string? Url { get; init; }
}
internal sealed class RedHatProductTree
{
[JsonPropertyName("branches")]
public IReadOnlyList<RedHatProductBranch>? Branches { get; init; }
}
internal sealed class RedHatProductBranch
{
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("product")]
public RedHatProductNodeInfo? Product { get; init; }
[JsonPropertyName("branches")]
public IReadOnlyList<RedHatProductBranch>? Branches { get; init; }
}
internal sealed class RedHatProductNodeInfo
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("product_id")]
public string? ProductId { get; init; }
[JsonPropertyName("product_identification_helper")]
public RedHatProductIdentificationHelper? ProductIdentificationHelper { get; init; }
}
internal sealed class RedHatProductIdentificationHelper
{
[JsonPropertyName("cpe")]
public string? Cpe { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
}
internal sealed class RedHatVulnerability
{
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("references")]
public IReadOnlyList<RedHatReference>? References { get; init; }
[JsonPropertyName("scores")]
public IReadOnlyList<RedHatVulnerabilityScore>? Scores { get; init; }
[JsonPropertyName("product_status")]
public RedHatProductStatus? ProductStatus { get; init; }
}
internal sealed class RedHatVulnerabilityScore
{
[JsonPropertyName("cvss_v3")]
public RedHatCvssV3? CvssV3 { get; init; }
}
internal sealed class RedHatCvssV3
{
[JsonPropertyName("baseScore")]
public double? BaseScore { get; init; }
[JsonPropertyName("baseSeverity")]
public string? BaseSeverity { get; init; }
[JsonPropertyName("vectorString")]
public string? VectorString { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
}
internal sealed class RedHatProductStatus
{
[JsonPropertyName("fixed")]
public IReadOnlyList<string>? Fixed { get; init; }
[JsonPropertyName("first_fixed")]
public IReadOnlyList<string>? FirstFixed { get; init; }
[JsonPropertyName("known_affected")]
public IReadOnlyList<string>? KnownAffected { get; init; }
[JsonPropertyName("known_not_affected")]
public IReadOnlyList<string>? KnownNotAffected { get; init; }
[JsonPropertyName("under_investigation")]
public IReadOnlyList<string>? UnderInvestigation { get; init; }
}

View File

@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MongoDB.Bson;
namespace StellaOps.Concelier.Connector.Distro.RedHat.Internal;
internal sealed record RedHatCursor(
DateTimeOffset? LastReleasedOn,
IReadOnlyCollection<string> ProcessedAdvisoryIds,
IReadOnlyCollection<Guid> PendingDocuments,
IReadOnlyCollection<Guid> PendingMappings,
IReadOnlyDictionary<string, RedHatCachedFetchMetadata> FetchCache)
{
private static readonly IReadOnlyCollection<string> EmptyStringList = Array.Empty<string>();
private static readonly IReadOnlyCollection<Guid> EmptyGuidList = Array.Empty<Guid>();
private static readonly IReadOnlyDictionary<string, RedHatCachedFetchMetadata> EmptyCache =
new Dictionary<string, RedHatCachedFetchMetadata>(StringComparer.OrdinalIgnoreCase);
public static RedHatCursor Empty { get; } = new(null, EmptyStringList, EmptyGuidList, EmptyGuidList, EmptyCache);
public static RedHatCursor FromBsonDocument(BsonDocument? document)
{
if (document is null || document.ElementCount == 0)
{
return Empty;
}
DateTimeOffset? lastReleased = null;
if (document.TryGetValue("lastReleasedOn", out var lastReleasedValue))
{
lastReleased = ReadDateTimeOffset(lastReleasedValue);
}
var processed = ReadStringSet(document, "processedAdvisories");
var pendingDocuments = ReadGuidSet(document, "pendingDocuments");
var pendingMappings = ReadGuidSet(document, "pendingMappings");
var fetchCache = ReadFetchCache(document);
return new RedHatCursor(lastReleased, processed, pendingDocuments, pendingMappings, fetchCache);
}
public BsonDocument ToBsonDocument()
{
var document = new BsonDocument();
if (LastReleasedOn.HasValue)
{
document["lastReleasedOn"] = LastReleasedOn.Value.UtcDateTime;
}
document["processedAdvisories"] = new BsonArray(ProcessedAdvisoryIds);
document["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString()));
document["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString()));
var cacheArray = new BsonArray();
foreach (var (key, metadata) in FetchCache)
{
var cacheDoc = new BsonDocument
{
["uri"] = key
};
if (!string.IsNullOrWhiteSpace(metadata.ETag))
{
cacheDoc["etag"] = metadata.ETag;
}
if (metadata.LastModified.HasValue)
{
cacheDoc["lastModified"] = metadata.LastModified.Value.UtcDateTime;
}
cacheArray.Add(cacheDoc);
}
document["fetchCache"] = cacheArray;
return document;
}
public RedHatCursor WithLastReleased(DateTimeOffset? releasedOn, IEnumerable<string> advisoryIds)
{
var normalizedIds = advisoryIds?.Where(static id => !string.IsNullOrWhiteSpace(id))
.Select(static id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
return this with
{
LastReleasedOn = releasedOn,
ProcessedAdvisoryIds = normalizedIds
};
}
public RedHatCursor AddProcessedAdvisories(IEnumerable<string> advisoryIds)
{
if (advisoryIds is null)
{
return this;
}
var set = new HashSet<string>(ProcessedAdvisoryIds, StringComparer.OrdinalIgnoreCase);
foreach (var id in advisoryIds)
{
if (!string.IsNullOrWhiteSpace(id))
{
set.Add(id.Trim());
}
}
return this with { ProcessedAdvisoryIds = set.ToArray() };
}
public RedHatCursor WithPendingDocuments(IEnumerable<Guid> ids)
{
var list = ids?.Distinct().ToArray() ?? Array.Empty<Guid>();
return this with { PendingDocuments = list };
}
public RedHatCursor WithPendingMappings(IEnumerable<Guid> ids)
{
var list = ids?.Distinct().ToArray() ?? Array.Empty<Guid>();
return this with { PendingMappings = list };
}
public RedHatCursor WithFetchCache(string requestUri, string? etag, DateTimeOffset? lastModified)
{
var cache = new Dictionary<string, RedHatCachedFetchMetadata>(FetchCache, StringComparer.OrdinalIgnoreCase)
{
[requestUri] = new RedHatCachedFetchMetadata(etag, lastModified)
};
return this with { FetchCache = cache };
}
public RedHatCursor PruneFetchCache(IEnumerable<string> keepUris)
{
if (FetchCache.Count == 0)
{
return this;
}
var keepSet = new HashSet<string>(keepUris ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase);
if (keepSet.Count == 0)
{
return this with { FetchCache = EmptyCache };
}
var cache = new Dictionary<string, RedHatCachedFetchMetadata>(StringComparer.OrdinalIgnoreCase);
foreach (var uri in keepSet)
{
if (FetchCache.TryGetValue(uri, out var metadata))
{
cache[uri] = metadata;
}
}
return this with { FetchCache = cache };
}
public RedHatCachedFetchMetadata? TryGetFetchCache(string requestUri)
{
if (FetchCache.TryGetValue(requestUri, out var metadata))
{
return metadata;
}
return null;
}
private static IReadOnlyCollection<string> ReadStringSet(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
{
return EmptyStringList;
}
var results = new List<string>(array.Count);
foreach (var element in array)
{
if (element.BsonType == BsonType.String)
{
var str = element.AsString.Trim();
if (!string.IsNullOrWhiteSpace(str))
{
results.Add(str);
}
}
}
return results;
}
private static IReadOnlyCollection<Guid> ReadGuidSet(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
{
return EmptyGuidList;
}
var results = new List<Guid>(array.Count);
foreach (var element in array)
{
if (element.BsonType == BsonType.String && Guid.TryParse(element.AsString, out var guid))
{
results.Add(guid);
}
}
return results;
}
private static IReadOnlyDictionary<string, RedHatCachedFetchMetadata> ReadFetchCache(BsonDocument document)
{
if (!document.TryGetValue("fetchCache", out var value) || value is not BsonArray array || array.Count == 0)
{
return EmptyCache;
}
var results = new Dictionary<string, RedHatCachedFetchMetadata>(StringComparer.OrdinalIgnoreCase);
foreach (var element in array.OfType<BsonDocument>())
{
if (!element.TryGetValue("uri", out var uriValue) || uriValue.BsonType != BsonType.String)
{
continue;
}
var uri = uriValue.AsString;
var etag = element.TryGetValue("etag", out var etagValue) && etagValue.BsonType == BsonType.String
? etagValue.AsString
: null;
DateTimeOffset? lastModified = null;
if (element.TryGetValue("lastModified", out var lastModifiedValue))
{
lastModified = ReadDateTimeOffset(lastModifiedValue);
}
results[uri] = new RedHatCachedFetchMetadata(etag, lastModified);
}
return results;
}
private static DateTimeOffset? ReadDateTimeOffset(BsonValue value)
{
return value.BsonType switch
{
BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null,
};
}
}
internal sealed record RedHatCachedFetchMetadata(string? ETag, DateTimeOffset? LastModified);

View File

@@ -0,0 +1,758 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Distro.RedHat.Internal.Models;
using StellaOps.Concelier.Normalization.Cvss;
using StellaOps.Concelier.Normalization.Distro;
using StellaOps.Concelier.Normalization.Identifiers;
using StellaOps.Concelier.Normalization.Text;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
namespace StellaOps.Concelier.Connector.Distro.RedHat.Internal;
internal static class RedHatMapper
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
public static Advisory? Map(string sourceName, DtoRecord dto, DocumentRecord document, JsonDocument payload)
{
ArgumentException.ThrowIfNullOrEmpty(sourceName);
ArgumentNullException.ThrowIfNull(dto);
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(payload);
var csaf = JsonSerializer.Deserialize<RedHatCsafEnvelope>(payload.RootElement.GetRawText(), SerializerOptions);
var documentSection = csaf?.Document;
if (documentSection is null)
{
return null;
}
var tracking = documentSection.Tracking;
var advisoryKey = NormalizeId(tracking?.Id)
?? NormalizeId(TryGetMetadata(document, "advisoryId"))
?? NormalizeId(document.Uri)
?? dto.DocumentId.ToString();
var title = !string.IsNullOrWhiteSpace(documentSection.Title)
? DescriptionNormalizer.Normalize(new[] { new LocalizedText(documentSection.Title, documentSection.Lang) }).Text
: string.Empty;
if (string.IsNullOrEmpty(title))
{
title = advisoryKey;
}
var description = NormalizeSummary(documentSection);
var summary = string.IsNullOrEmpty(description.Text) ? null : description.Text;
var severity = NormalizeSeverity(documentSection.AggregateSeverity?.Text);
var published = tracking?.InitialReleaseDate;
var modified = tracking?.CurrentReleaseDate ?? published;
var language = description.Language;
var aliases = BuildAliases(advisoryKey, csaf);
var references = BuildReferences(sourceName, dto.ValidatedAt, documentSection, csaf);
var productIndex = RedHatProductIndex.Build(csaf.ProductTree);
var affectedPackages = BuildAffectedPackages(sourceName, dto.ValidatedAt, csaf, productIndex);
var cvssMetrics = BuildCvssMetrics(sourceName, dto.ValidatedAt, advisoryKey, csaf);
var provenance = new[]
{
new AdvisoryProvenance(sourceName, "advisory", advisoryKey, dto.ValidatedAt),
};
return new Advisory(
advisoryKey,
title,
summary,
language,
published,
modified,
severity,
exploitKnown: false,
aliases,
references,
affectedPackages,
cvssMetrics,
provenance);
}
private static IReadOnlyCollection<string> BuildAliases(string advisoryKey, RedHatCsafEnvelope csaf)
{
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
advisoryKey,
};
if (csaf.Vulnerabilities is not null)
{
foreach (var vulnerability in csaf.Vulnerabilities)
{
if (!string.IsNullOrWhiteSpace(vulnerability?.Cve))
{
aliases.Add(vulnerability!.Cve!.Trim());
}
}
}
return aliases;
}
private static NormalizedDescription NormalizeSummary(RedHatDocumentSection documentSection)
{
var summaryNotes = new List<LocalizedText>();
var otherNotes = new List<LocalizedText>();
if (documentSection.Notes is not null)
{
foreach (var note in documentSection.Notes)
{
if (note is null || string.IsNullOrWhiteSpace(note.Text))
{
continue;
}
var candidate = new LocalizedText(note.Text, documentSection.Lang);
if (note.CategoryEquals("summary"))
{
summaryNotes.Add(candidate);
}
else
{
otherNotes.Add(candidate);
}
}
}
var combined = summaryNotes.Count > 0
? summaryNotes.Concat(otherNotes).ToList()
: otherNotes;
return DescriptionNormalizer.Normalize(combined);
}
private static IReadOnlyCollection<AdvisoryReference> BuildReferences(
string sourceName,
DateTimeOffset recordedAt,
RedHatDocumentSection? documentSection,
RedHatCsafEnvelope csaf)
{
var references = new List<AdvisoryReference>();
if (documentSection is not null)
{
AppendReferences(sourceName, recordedAt, documentSection.References, references);
}
if (csaf.Vulnerabilities is not null)
{
foreach (var vulnerability in csaf.Vulnerabilities)
{
AppendReferences(sourceName, recordedAt, vulnerability?.References, references);
}
}
return NormalizeReferences(references);
}
private static void AppendReferences(string sourceName, DateTimeOffset recordedAt, IReadOnlyList<RedHatReference>? items, ICollection<AdvisoryReference> references)
{
if (items is null)
{
return;
}
foreach (var reference in items)
{
if (reference is null || string.IsNullOrWhiteSpace(reference.Url))
{
continue;
}
var url = reference.Url.Trim();
if (!Validation.LooksLikeHttpUrl(url))
{
continue;
}
var provenance = new AdvisoryProvenance(sourceName, "reference", url, recordedAt);
references.Add(new AdvisoryReference(url, reference.Category, null, reference.Summary, provenance));
}
}
private static IReadOnlyCollection<AdvisoryReference> NormalizeReferences(IReadOnlyCollection<AdvisoryReference> references)
{
if (references.Count == 0)
{
return Array.Empty<AdvisoryReference>();
}
var map = new Dictionary<string, AdvisoryReference>(StringComparer.OrdinalIgnoreCase);
foreach (var reference in references)
{
if (!map.TryGetValue(reference.Url, out var existing))
{
map[reference.Url] = reference;
continue;
}
map[reference.Url] = MergeReferences(existing, reference);
}
return map.Values
.OrderBy(static r => r.Kind is null ? 1 : 0)
.ThenBy(static r => r.Kind ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(static r => r.Url, StringComparer.OrdinalIgnoreCase)
.ThenBy(static r => r.SourceTag ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static AdvisoryReference MergeReferences(AdvisoryReference existing, AdvisoryReference candidate)
{
var kind = existing.Kind ?? candidate.Kind;
var sourceTag = existing.SourceTag ?? candidate.SourceTag;
var summary = ChoosePreferredSummary(existing.Summary, candidate.Summary);
var provenance = existing.Provenance.RecordedAt <= candidate.Provenance.RecordedAt
? existing.Provenance
: candidate.Provenance;
if (kind == existing.Kind
&& sourceTag == existing.SourceTag
&& summary == existing.Summary
&& provenance == existing.Provenance)
{
return existing;
}
if (kind == candidate.Kind
&& sourceTag == candidate.SourceTag
&& summary == candidate.Summary
&& provenance == candidate.Provenance)
{
return candidate;
}
return new AdvisoryReference(existing.Url, kind, sourceTag, summary, provenance);
}
private static string? ChoosePreferredSummary(string? left, string? right)
{
var leftValue = string.IsNullOrWhiteSpace(left) ? null : left;
var rightValue = string.IsNullOrWhiteSpace(right) ? null : right;
if (leftValue is null)
{
return rightValue;
}
if (rightValue is null)
{
return leftValue;
}
return leftValue.Length >= rightValue.Length ? leftValue : rightValue;
}
private static IReadOnlyCollection<AffectedPackage> BuildAffectedPackages(
string sourceName,
DateTimeOffset recordedAt,
RedHatCsafEnvelope csaf,
RedHatProductIndex productIndex)
{
var rpmPackages = new Dictionary<string, RedHatAffectedRpm>(StringComparer.OrdinalIgnoreCase);
var baseProducts = new Dictionary<string, RedHatProductStatusEntry>(StringComparer.OrdinalIgnoreCase);
var knownAffectedByBase = BuildKnownAffectedIndex(csaf);
if (csaf.Vulnerabilities is not null)
{
foreach (var vulnerability in csaf.Vulnerabilities)
{
if (vulnerability?.ProductStatus is null)
{
continue;
}
RegisterAll(vulnerability.ProductStatus.Fixed, RedHatProductStatuses.Fixed, productIndex, rpmPackages, baseProducts);
RegisterAll(vulnerability.ProductStatus.FirstFixed, RedHatProductStatuses.FirstFixed, productIndex, rpmPackages, baseProducts);
RegisterAll(vulnerability.ProductStatus.KnownAffected, RedHatProductStatuses.KnownAffected, productIndex, rpmPackages, baseProducts);
RegisterAll(vulnerability.ProductStatus.KnownNotAffected, RedHatProductStatuses.KnownNotAffected, productIndex, rpmPackages, baseProducts);
RegisterAll(vulnerability.ProductStatus.UnderInvestigation, RedHatProductStatuses.UnderInvestigation, productIndex, rpmPackages, baseProducts);
}
}
var affected = new List<AffectedPackage>(rpmPackages.Count + baseProducts.Count);
foreach (var rpm in rpmPackages.Values)
{
if (rpm.Statuses.Count == 0)
{
continue;
}
var ranges = new List<AffectedVersionRange>();
var statuses = new List<AffectedPackageStatus>();
var provenance = new AdvisoryProvenance(sourceName, "package.nevra", rpm.ProductId ?? rpm.Nevra, recordedAt);
var lastKnownAffected = knownAffectedByBase.TryGetValue(rpm.BaseProductId, out var candidate)
? candidate
: null;
if (!string.IsNullOrWhiteSpace(lastKnownAffected)
&& string.Equals(lastKnownAffected, rpm.Nevra, StringComparison.OrdinalIgnoreCase))
{
lastKnownAffected = null;
}
if (rpm.Statuses.Contains(RedHatProductStatuses.Fixed) || rpm.Statuses.Contains(RedHatProductStatuses.FirstFixed))
{
ranges.Add(new AffectedVersionRange(
"nevra",
introducedVersion: null,
fixedVersion: rpm.Nevra,
lastAffectedVersion: lastKnownAffected,
rangeExpression: null,
provenance: provenance,
primitives: BuildNevraPrimitives(null, rpm.Nevra, lastKnownAffected)));
}
if (!rpm.Statuses.Contains(RedHatProductStatuses.Fixed)
&& !rpm.Statuses.Contains(RedHatProductStatuses.FirstFixed)
&& rpm.Statuses.Contains(RedHatProductStatuses.KnownAffected))
{
ranges.Add(new AffectedVersionRange(
"nevra",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: rpm.Nevra,
rangeExpression: null,
provenance: provenance,
primitives: BuildNevraPrimitives(null, null, rpm.Nevra)));
}
if (rpm.Statuses.Contains(RedHatProductStatuses.KnownNotAffected))
{
statuses.Add(new AffectedPackageStatus(RedHatProductStatuses.KnownNotAffected, provenance));
}
if (rpm.Statuses.Contains(RedHatProductStatuses.UnderInvestigation))
{
statuses.Add(new AffectedPackageStatus(RedHatProductStatuses.UnderInvestigation, provenance));
}
if (ranges.Count == 0 && statuses.Count == 0)
{
continue;
}
affected.Add(new AffectedPackage(
AffectedPackageTypes.Rpm,
rpm.Nevra,
rpm.Platform,
ranges,
statuses,
new[] { provenance }));
}
foreach (var baseEntry in baseProducts.Values)
{
if (baseEntry.Statuses.Count == 0)
{
continue;
}
var node = baseEntry.Node;
if (string.IsNullOrWhiteSpace(node.Cpe))
{
continue;
}
if (!IdentifierNormalizer.TryNormalizeCpe(node.Cpe, out var normalizedCpe))
{
continue;
}
var provenance = new AdvisoryProvenance(sourceName, "oval", node.ProductId, recordedAt);
var statuses = new List<AffectedPackageStatus>();
if (baseEntry.Statuses.Contains(RedHatProductStatuses.KnownAffected))
{
statuses.Add(new AffectedPackageStatus(RedHatProductStatuses.KnownAffected, provenance));
}
if (baseEntry.Statuses.Contains(RedHatProductStatuses.KnownNotAffected))
{
statuses.Add(new AffectedPackageStatus(RedHatProductStatuses.KnownNotAffected, provenance));
}
if (baseEntry.Statuses.Contains(RedHatProductStatuses.UnderInvestigation))
{
statuses.Add(new AffectedPackageStatus(RedHatProductStatuses.UnderInvestigation, provenance));
}
if (statuses.Count == 0)
{
continue;
}
affected.Add(new AffectedPackage(
AffectedPackageTypes.Cpe,
normalizedCpe!,
node.Name,
Array.Empty<AffectedVersionRange>(),
statuses,
new[] { provenance }));
}
return affected;
}
private static Dictionary<string, string> BuildKnownAffectedIndex(RedHatCsafEnvelope csaf)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (csaf.Vulnerabilities is null)
{
return map;
}
foreach (var vulnerability in csaf.Vulnerabilities)
{
var entries = vulnerability?.ProductStatus?.KnownAffected;
if (entries is null)
{
continue;
}
foreach (var entry in entries)
{
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var colonIndex = entry.IndexOf(':');
if (colonIndex <= 0)
{
continue;
}
var baseId = entry[..colonIndex].Trim();
if (string.IsNullOrEmpty(baseId))
{
continue;
}
var candidate = NormalizeNevra(entry[(colonIndex + 1)..]);
if (!string.IsNullOrEmpty(candidate))
{
map[baseId] = candidate;
}
}
}
return map;
}
private static void RegisterAll(
IReadOnlyList<string>? entries,
string status,
RedHatProductIndex productIndex,
IDictionary<string, RedHatAffectedRpm> rpmPackages,
IDictionary<string, RedHatProductStatusEntry> baseProducts)
{
if (entries is null)
{
return;
}
foreach (var entry in entries)
{
RegisterProductStatus(entry, status, productIndex, rpmPackages, baseProducts);
}
}
private static void RegisterProductStatus(
string? rawEntry,
string status,
RedHatProductIndex productIndex,
IDictionary<string, RedHatAffectedRpm> rpmPackages,
IDictionary<string, RedHatProductStatusEntry> baseProducts)
{
if (string.IsNullOrWhiteSpace(rawEntry) || !IsActionableStatus(status))
{
return;
}
var entry = rawEntry.Trim();
var colonIndex = entry.IndexOf(':');
if (colonIndex <= 0 || colonIndex == entry.Length - 1)
{
if (productIndex.TryGetValue(entry, out var baseOnly))
{
var aggregate = baseProducts.TryGetValue(baseOnly.ProductId, out var existing)
? existing
: new RedHatProductStatusEntry(baseOnly);
aggregate.Statuses.Add(status);
baseProducts[baseOnly.ProductId] = aggregate;
}
return;
}
var baseId = entry[..colonIndex];
var packageId = entry[(colonIndex + 1)..];
if (productIndex.TryGetValue(baseId, out var baseNode))
{
var aggregate = baseProducts.TryGetValue(baseNode.ProductId, out var existing)
? existing
: new RedHatProductStatusEntry(baseNode);
aggregate.Statuses.Add(status);
baseProducts[baseNode.ProductId] = aggregate;
}
if (!productIndex.TryGetValue(packageId, out var packageNode))
{
return;
}
var nevra = NormalizeNevra(packageNode.Name ?? packageNode.ProductId);
if (string.IsNullOrEmpty(nevra))
{
return;
}
var platform = baseProducts.TryGetValue(baseId, out var baseEntry)
? baseEntry.Node.Name ?? baseId
: baseId;
var key = string.Join('|', nevra, platform ?? string.Empty);
if (!rpmPackages.TryGetValue(key, out var rpm))
{
rpm = new RedHatAffectedRpm(nevra, baseId, platform, packageNode.ProductId);
rpmPackages[key] = rpm;
}
rpm.Statuses.Add(status);
}
private static bool IsActionableStatus(string status)
{
return status.Equals(RedHatProductStatuses.Fixed, StringComparison.OrdinalIgnoreCase)
|| status.Equals(RedHatProductStatuses.FirstFixed, StringComparison.OrdinalIgnoreCase)
|| status.Equals(RedHatProductStatuses.KnownAffected, StringComparison.OrdinalIgnoreCase)
|| status.Equals(RedHatProductStatuses.KnownNotAffected, StringComparison.OrdinalIgnoreCase)
|| status.Equals(RedHatProductStatuses.UnderInvestigation, StringComparison.OrdinalIgnoreCase);
}
private static IReadOnlyCollection<CvssMetric> BuildCvssMetrics(
string sourceName,
DateTimeOffset recordedAt,
string advisoryKey,
RedHatCsafEnvelope csaf)
{
var metrics = new List<CvssMetric>();
if (csaf.Vulnerabilities is null)
{
return metrics;
}
foreach (var vulnerability in csaf.Vulnerabilities)
{
if (vulnerability?.Scores is null)
{
continue;
}
foreach (var score in vulnerability.Scores)
{
var cvss = score?.CvssV3;
if (cvss is null)
{
continue;
}
if (!CvssMetricNormalizer.TryNormalize(cvss.Version, cvss.VectorString, cvss.BaseScore, cvss.BaseSeverity, out var normalized))
{
continue;
}
var provenance = new AdvisoryProvenance(sourceName, "cvss", vulnerability.Cve ?? advisoryKey, recordedAt);
metrics.Add(normalized.ToModel(provenance));
}
}
return metrics;
}
private static string? NormalizeSeverity(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant() switch
{
"critical" => "critical",
"important" => "high",
"moderate" => "medium",
"low" => "low",
"none" => "none",
_ => value.Trim().ToLowerInvariant(),
};
}
private static string? TryGetMetadata(DocumentRecord document, string key)
{
if (document.Metadata is null)
{
return null;
}
return document.Metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: null;
}
private static RangePrimitives BuildNevraPrimitives(string? introduced, string? fixedVersion, string? lastAffected)
{
var primitive = new NevraPrimitive(
ParseNevraComponent(introduced),
ParseNevraComponent(fixedVersion),
ParseNevraComponent(lastAffected));
return new RangePrimitives(null, primitive, null, null);
}
private static NevraComponent? ParseNevraComponent(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!Nevra.TryParse(value, out var parsed) || parsed is null)
{
return null;
}
return new NevraComponent(parsed.Name, parsed.Epoch, parsed.Version, parsed.Release, parsed.Architecture);
}
private static string? NormalizeId(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeNevra(string? value)
{
return string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim();
}
}
internal sealed class RedHatAffectedRpm
{
public RedHatAffectedRpm(string nevra, string baseProductId, string? platform, string? productId)
{
Nevra = nevra;
BaseProductId = baseProductId;
Platform = platform;
ProductId = productId;
Statuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
public string Nevra { get; }
public string BaseProductId { get; }
public string? Platform { get; }
public string? ProductId { get; }
public HashSet<string> Statuses { get; }
}
internal sealed class RedHatProductStatusEntry
{
public RedHatProductStatusEntry(RedHatProductNode node)
{
Node = node;
Statuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
public RedHatProductNode Node { get; }
public HashSet<string> Statuses { get; }
}
internal static class RedHatProductStatuses
{
public const string Fixed = "fixed";
public const string FirstFixed = "first_fixed";
public const string KnownAffected = "known_affected";
public const string KnownNotAffected = "known_not_affected";
public const string UnderInvestigation = "under_investigation";
}
internal sealed class RedHatProductIndex
{
private readonly Dictionary<string, RedHatProductNode> _products;
private RedHatProductIndex(Dictionary<string, RedHatProductNode> products)
{
_products = products;
}
public static RedHatProductIndex Build(RedHatProductTree? tree)
{
var products = new Dictionary<string, RedHatProductNode>(StringComparer.OrdinalIgnoreCase);
if (tree?.Branches is not null)
{
foreach (var branch in tree.Branches)
{
Traverse(branch, products);
}
}
return new RedHatProductIndex(products);
}
public bool TryGetValue(string productId, out RedHatProductNode node)
=> _products.TryGetValue(productId, out node);
private static void Traverse(RedHatProductBranch? branch, IDictionary<string, RedHatProductNode> products)
{
if (branch is null)
{
return;
}
if (branch.Product is not null && !string.IsNullOrWhiteSpace(branch.Product.ProductId))
{
var id = branch.Product.ProductId.Trim();
products[id] = new RedHatProductNode(
id,
branch.Product.Name ?? branch.Name ?? id,
branch.Product.ProductIdentificationHelper?.Cpe,
branch.Product.ProductIdentificationHelper?.Purl);
}
if (branch.Branches is null)
{
return;
}
foreach (var child in branch.Branches)
{
Traverse(child, products);
}
}
}
internal sealed record RedHatProductNode(string ProductId, string? Name, string? Cpe, string? Purl);

View File

@@ -0,0 +1,66 @@
using System;
using System.Text.Json;
namespace StellaOps.Concelier.Connector.Distro.RedHat.Internal;
internal readonly record struct RedHatSummaryItem(string AdvisoryId, DateTimeOffset ReleasedOn, Uri ResourceUri)
{
private static readonly string[] AdvisoryFields =
{
"RHSA",
"RHBA",
"RHEA",
"RHUI",
"RHBG",
"RHBO",
"advisory"
};
public static bool TryParse(JsonElement element, out RedHatSummaryItem item)
{
item = default;
string? advisoryId = null;
foreach (var field in AdvisoryFields)
{
if (element.TryGetProperty(field, out var advisoryProperty) && advisoryProperty.ValueKind == JsonValueKind.String)
{
var candidate = advisoryProperty.GetString();
if (!string.IsNullOrWhiteSpace(candidate))
{
advisoryId = candidate.Trim();
break;
}
}
}
if (string.IsNullOrWhiteSpace(advisoryId))
{
return false;
}
if (!element.TryGetProperty("released_on", out var releasedProperty) || releasedProperty.ValueKind != JsonValueKind.String)
{
return false;
}
if (!DateTimeOffset.TryParse(releasedProperty.GetString(), out var releasedOn))
{
return false;
}
if (!element.TryGetProperty("resource_url", out var resourceProperty) || resourceProperty.ValueKind != JsonValueKind.String)
{
return false;
}
var resourceValue = resourceProperty.GetString();
if (!Uri.TryCreate(resourceValue, UriKind.Absolute, out var resourceUri))
{
return false;
}
item = new RedHatSummaryItem(advisoryId!, releasedOn.ToUniversalTime(), resourceUri);
return true;
}
}