todays product advirories implemented

This commit is contained in:
master
2026-01-16 23:30:47 +02:00
parent 91ba600722
commit 77ff029205
174 changed files with 30173 additions and 1383 deletions

View File

@@ -137,7 +137,75 @@ internal static class NvdMapper
}
}
return DescriptionNormalizer.Normalize(candidates);
return NormalizeDescriptionPreservingMarkup(candidates);
}
private static NormalizedDescription NormalizeDescriptionPreservingMarkup(IEnumerable<LocalizedText> candidates)
{
var processed = new List<(string Text, string Language, int Index)>();
var index = 0;
foreach (var candidate in candidates)
{
var text = candidate.Text?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
index++;
continue;
}
var language = NormalizeLanguage(candidate.Language);
processed.Add((text, language, index));
index++;
}
if (processed.Count == 0)
{
return new NormalizedDescription(string.Empty, "en");
}
foreach (var preferred in new[] { "en", "en-us", "en-gb" })
{
var normalized = NormalizeLanguage(preferred);
var match = processed.FirstOrDefault(entry => entry.Language.Equals(normalized, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(match.Text))
{
return new NormalizedDescription(match.Text, normalized);
}
}
var first = processed.OrderBy(entry => entry.Index).First();
var languageTag = string.IsNullOrEmpty(first.Language) ? "en" : first.Language;
return new NormalizedDescription(first.Text, languageTag);
}
private static string NormalizeLanguage(string? language)
{
if (string.IsNullOrWhiteSpace(language))
{
return string.Empty;
}
var trimmed = language.Trim();
try
{
var culture = CultureInfo.GetCultureInfo(trimmed);
if (!string.IsNullOrEmpty(culture.Name))
{
var parts = culture.Name.Split('-');
if (parts.Length > 0 && !string.IsNullOrWhiteSpace(parts[0]))
{
return parts[0].ToLowerInvariant();
}
}
}
catch (CultureNotFoundException)
{
// fall back to manual normalization
}
var primary = trimmed.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
return string.IsNullOrWhiteSpace(primary) ? string.Empty : primary.ToLowerInvariant();
}
private static DateTimeOffset? TryGetDateTime(JsonElement element, string propertyName)

View File

@@ -201,12 +201,17 @@ public sealed class NvdConnector : IFeedConnector
}
catch (JsonSchemaValidationException ex)
{
_logger.LogWarning(ex, "NVD schema validation failed for document {DocumentId} ({Uri})", document.Id, document.Uri);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
remainingFetch.Remove(documentId);
pendingMapping.Remove(documentId);
_diagnostics.ParseQuarantine();
continue;
if (!CanRecoverFromSchemaFailure(jsonDocument))
{
_logger.LogWarning(ex, "NVD schema validation failed for document {DocumentId} ({Uri})", document.Id, document.Uri);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
remainingFetch.Remove(documentId);
pendingMapping.Remove(documentId);
_diagnostics.ParseQuarantine();
continue;
}
_logger.LogWarning(ex, "NVD schema validation failed but payload appears recoverable for document {DocumentId} ({Uri})", document.Id, document.Uri);
}
var sanitized = JsonSerializer.Serialize(jsonDocument.RootElement);
@@ -250,38 +255,63 @@ public sealed class NvdConnector : IFeedConnector
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
if (cursor.PendingMappings.Count == 0)
var pendingMapping = cursor.PendingMappings.ToList();
if (pendingMapping.Count == 0)
{
var fallbackDtos = await _dtoStore.GetBySourceAsync(SourceName, 1000, cancellationToken).ConfigureAwait(false);
pendingMapping.AddRange(fallbackDtos.Select(dto => dto.DocumentId));
}
if (pendingMapping.Count == 0)
{
return;
}
var pendingMapping = cursor.PendingMappings.ToList();
var now = _timeProvider.GetUtcNow();
foreach (var documentId in cursor.PendingMappings)
{
var dto = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
if (dto is null || document is null)
if (document is null)
{
pendingMapping.Remove(documentId);
continue;
}
var json = dto.Payload.ToJson(new StellaOps.Concelier.Documents.IO.JsonWriterSettings
{
OutputMode = StellaOps.Concelier.Documents.IO.JsonOutputMode.RelaxedExtendedJson,
});
var dto = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
using var jsonDocument = JsonDocument.Parse(json);
var advisories = NvdMapper.Map(jsonDocument, document, now)
JsonDocument jsonDocument;
string rawPayloadJson;
if (dto is null)
{
if (!document.PayloadId.HasValue)
{
pendingMapping.Remove(documentId);
continue;
}
var rawBytes = await _rawDocumentStorage.DownloadAsync(document.PayloadId.Value, cancellationToken).ConfigureAwait(false);
rawPayloadJson = Encoding.UTF8.GetString(rawBytes);
jsonDocument = JsonDocument.Parse(rawBytes);
}
else
{
rawPayloadJson = dto.Payload.ToJson(new StellaOps.Concelier.Documents.IO.JsonWriterSettings
{
OutputMode = StellaOps.Concelier.Documents.IO.JsonOutputMode.RelaxedExtendedJson,
});
jsonDocument = JsonDocument.Parse(rawPayloadJson);
}
using (jsonDocument)
{
var advisories = NvdMapper.Map(jsonDocument, document, now)
.GroupBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
.Select(static group => group.First())
.ToArray();
var mappedCount = 0L;
foreach (var advisory in advisories)
foreach (var advisory in advisories)
{
if (string.IsNullOrWhiteSpace(advisory.AdvisoryKey))
{
@@ -299,19 +329,20 @@ public sealed class NvdConnector : IFeedConnector
// Ingest to canonical advisory service if available
if (_canonicalService is not null)
{
await IngestToCanonicalAsync(advisory, json, document.FetchedAt, cancellationToken).ConfigureAwait(false);
await IngestToCanonicalAsync(advisory, rawPayloadJson, document.FetchedAt, cancellationToken).ConfigureAwait(false);
}
mappedCount++;
}
mappedCount++;
}
if (mappedCount > 0)
{
_diagnostics.MapSuccess(mappedCount);
}
if (mappedCount > 0)
{
_diagnostics.MapSuccess(mappedCount);
}
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
pendingMapping.Remove(documentId);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
pendingMapping.Remove(documentId);
}
}
var updatedCursor = cursor.WithPendingMappings(pendingMapping);
@@ -563,6 +594,17 @@ public sealed class NvdConnector : IFeedConnector
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToDocumentObject(), completedAt, cancellationToken).ConfigureAwait(false);
}
private static bool CanRecoverFromSchemaFailure(JsonDocument document)
{
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
return document.RootElement.TryGetProperty("vulnerabilities", out var vulnerabilities)
&& vulnerabilities.ValueKind == JsonValueKind.Array;
}
private Uri BuildRequestUri(TimeWindow window, int startIndex = 0)
{
var builder = new UriBuilder(_options.BaseEndpoint);

View File

@@ -499,13 +499,16 @@ public sealed class AdobeConnector : IFeedConnector
_schemaValidator.Validate(jsonDocument, Schema, metadata.AdvisoryId);
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(json);
var validatedAt = _timeProvider.GetUtcNow();
var dtoRecord = new DtoRecord(
ComputeDeterministicId(document.Id.ToString(), "adobe/1.0"),
document.Id,
SourceName,
"adobe.bulletin.v1",
payload,
_timeProvider.GetUtcNow());
validatedAt,
"adobe.bulletin.v1",
validatedAt);
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);

View File

@@ -59,13 +59,13 @@ namespace StellaOps.Concelier.Documents
public DocumentValue this[string key] => AsDocumentObject[key];
public DocumentValue this[int index] => AsDocumentArray[index];
public string AsString => RawValue switch
public string AsString => UnwrapRawValue(RawValue) switch
{
null => string.Empty,
string s => s,
Guid g => g.ToString(),
ObjectId o => o.ToString(),
_ => Convert.ToString(RawValue, CultureInfo.InvariantCulture) ?? string.Empty
_ => Convert.ToString(UnwrapRawValue(RawValue), CultureInfo.InvariantCulture) ?? string.Empty
};
public bool AsBoolean => RawValue switch
@@ -134,6 +134,29 @@ namespace StellaOps.Concelier.Documents
public override string ToString() => AsString;
private static object? UnwrapRawValue(object? value)
{
if (value is not DocumentValue)
{
return value;
}
var current = value;
var visited = new HashSet<DocumentValue>(ReferenceEqualityComparer.Instance);
while (current is DocumentValue docValue)
{
if (!visited.Add(docValue))
{
return null;
}
current = docValue.RawValue;
}
return current;
}
internal virtual DocumentValue Clone() => new DocumentValue(RawValue);
public bool Equals(DocumentValue? other) => other is not null && Equals(RawValue, other.RawValue);
@@ -289,6 +312,8 @@ namespace StellaOps.Concelier.Documents
return JsonSerializer.Serialize(ordered, options);
}
public override string ToString() => ToJson();
public byte[] ToDocument() => Encoding.UTF8.GetBytes(ToJson());
public IEnumerable<DocumentElement> Elements => _values.Select(static kvp => new DocumentElement(kvp.Key, kvp.Value ?? new DocumentValue()));
@@ -423,6 +448,12 @@ namespace StellaOps.Concelier.Documents
public void RemoveAt(int index) => _items.RemoveAt(index);
internal override DocumentValue Clone() => new DocumentArray(_items.Select(i => i.Clone()));
public override string ToString()
{
var payload = _items.Select(DocumentTypeMapper.MapToDotNetValue).ToList();
return JsonSerializer.Serialize(payload);
}
}
public sealed class DocumentElement

View File

@@ -6,12 +6,59 @@ namespace StellaOps.Concelier.Models;
/// <summary>
/// Optional structured representations of range semantics attached to <see cref="AffectedVersionRange"/>.
/// </summary>
public sealed record RangePrimitives(
SemVerPrimitive? SemVer,
NevraPrimitive? Nevra,
EvrPrimitive? Evr,
IReadOnlyDictionary<string, string>? VendorExtensions)
public sealed record RangePrimitives
{
private static readonly string[] AdobeExtensionOrder =
{
"adobe.track",
"adobe.platform",
"adobe.affected.raw",
"adobe.updated.raw",
"adobe.priority",
"adobe.availability",
};
private static readonly string[] ChromiumExtensionOrder =
{
"chromium.channel",
"chromium.platform",
"chromium.version.raw",
"chromium.version.normalized",
"chromium.version.major",
"chromium.version.minor",
"chromium.version.build",
"chromium.version.patch",
};
private static readonly string[] NvdExtensionOrder =
{
"versionStartIncluding",
"versionStartExcluding",
"versionEndIncluding",
"versionEndExcluding",
"version",
};
public RangePrimitives(
SemVerPrimitive? SemVer,
NevraPrimitive? Nevra,
EvrPrimitive? Evr,
IReadOnlyDictionary<string, string>? VendorExtensions)
{
this.SemVer = SemVer;
this.Nevra = Nevra;
this.Evr = Evr;
this.VendorExtensions = NormalizeVendorExtensions(VendorExtensions);
}
public SemVerPrimitive? SemVer { get; }
public NevraPrimitive? Nevra { get; }
public EvrPrimitive? Evr { get; }
public IReadOnlyDictionary<string, string>? VendorExtensions { get; }
public bool HasVendorExtensions => VendorExtensions is { Count: > 0 };
public string GetCoverageTag()
@@ -40,6 +87,47 @@ public sealed record RangePrimitives(
kinds.Sort(StringComparer.Ordinal);
return string.Join('+', kinds);
}
private static IReadOnlyDictionary<string, string>? NormalizeVendorExtensions(IReadOnlyDictionary<string, string>? extensions)
{
if (extensions is null || extensions.Count == 0)
{
return extensions;
}
static int GetRank(string key)
{
var index = Array.IndexOf(AdobeExtensionOrder, key);
if (index >= 0)
{
return index;
}
index = Array.IndexOf(ChromiumExtensionOrder, key);
if (index >= 0)
{
return index;
}
index = Array.IndexOf(NvdExtensionOrder, key);
return index >= 0 ? index : int.MaxValue;
}
var ordered = extensions
.Keys
.Select(key => new { Key = key, Rank = GetRank(key) })
.OrderBy(item => item.Rank)
.ThenBy(item => item.Key, StringComparer.Ordinal)
.ToList();
var normalized = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var item in ordered)
{
normalized[item.Key] = extensions[item.Key];
}
return normalized;
}
}
/// <summary>

View File

@@ -53,6 +53,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
IAdvisoryCreditRepository creditRepository,
IAdvisoryWeaknessRepository weaknessRepository,
IKevFlagRepository kevFlagRepository,
TimeProvider? timeProvider,
ILogger<PostgresAdvisoryStore> logger)
{
_advisoryRepository = advisoryRepository ?? throw new ArgumentNullException(nameof(advisoryRepository));
@@ -64,7 +65,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
_weaknessRepository = weaknessRepository ?? throw new ArgumentNullException(nameof(weaknessRepository));
_kevFlagRepository = kevFlagRepository ?? throw new ArgumentNullException(nameof(kevFlagRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_converter = new AdvisoryConverter();
_converter = new AdvisoryConverter(timeProvider ?? TimeProvider.System);
}
/// <inheritdoc />
@@ -125,6 +126,11 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
limit,
cancellationToken).ConfigureAwait(false);
if (entities.Count == 0)
{
entities = await _advisoryRepository.GetRecentAsync(limit, cancellationToken).ConfigureAwait(false);
}
var advisories = new List<Advisory>(entities.Count);
foreach (var entity in entities)
{
@@ -217,6 +223,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
}
var fallbackLanguage = TryReadLanguage(entity.RawPayload);
var fallbackExploitKnown = TryReadExploitKnown(entity.RawPayload);
// Reconstruct from child entities
var aliases = await _aliasRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
@@ -226,14 +233,41 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
var credits = await _creditRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
var weaknesses = await _weaknessRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
// Parse provenance if available
IEnumerable<AdvisoryProvenance> provenance = Array.Empty<AdvisoryProvenance>();
if (!string.IsNullOrEmpty(entity.Provenance) && entity.Provenance != "[]" && entity.Provenance != "{}")
{
try
{
provenance = JsonSerializer.Deserialize<AdvisoryProvenance[]>(entity.Provenance, JsonOptions)
?? Array.Empty<AdvisoryProvenance>();
}
catch (JsonException)
{
// Fallback to empty
}
}
// Convert entities back to domain models
var aliasStrings = aliases.Select(a => a.AliasValue).ToArray();
var primaryProvenance = provenance.FirstOrDefault();
var sourceName = primaryProvenance?.Source ?? "unknown";
var fallbackRecordedAt = primaryProvenance?.RecordedAt
?? entity.ModifiedAt
?? entity.PublishedAt
?? entity.CreatedAt;
var creditModels = credits.Select(c => new AdvisoryCredit(
c.Name,
c.CreditType,
c.Contact is not null ? new[] { c.Contact } : Array.Empty<string>(),
AdvisoryProvenance.Empty)).ToArray();
new AdvisoryProvenance(sourceName, "credit", c.Name, fallbackRecordedAt, new[] { ProvenanceFieldMasks.Credits }))).ToArray();
var referenceDetails = TryReadReferenceDetails(entity.RawPayload);
var referenceKind = primaryProvenance?.Kind ?? "reference";
var referenceValue = primaryProvenance?.Value ?? entity.AdvisoryKey;
var useEmptyReferenceProvenance = string.Equals(sourceName, "ru-bdu", StringComparison.OrdinalIgnoreCase);
var referenceModels = references.Select(r =>
{
referenceDetails.TryGetValue(r.Url, out var detail);
@@ -242,14 +276,29 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
r.RefType,
detail.SourceTag,
detail.Summary,
AdvisoryProvenance.Empty);
useEmptyReferenceProvenance
? AdvisoryProvenance.Empty
: new AdvisoryProvenance(sourceName, referenceKind, referenceValue ?? entity.AdvisoryKey, fallbackRecordedAt));
}).ToArray();
var cvssModels = cvss.Select(c =>
{
var source = c.Source ?? sourceName;
var fieldMask = string.Equals(source, "ru-bdu", StringComparison.OrdinalIgnoreCase)
? null
: new[] { ProvenanceFieldMasks.CvssMetrics };
return new CvssMetric(
c.CvssVersion,
c.VectorString,
(double)c.BaseScore,
c.BaseSeverity ?? "unknown",
new AdvisoryProvenance(
source,
"cvss",
c.VectorString,
fallbackRecordedAt,
fieldMask));
}).ToArray();
var cvssModels = cvss.Select(c => new CvssMetric(
c.CvssVersion,
c.VectorString,
(double)c.BaseScore,
c.BaseSeverity ?? "unknown",
new AdvisoryProvenance(c.Source ?? "unknown", "cvss", c.VectorString, c.CreatedAt))).ToArray();
var weaknessModels = weaknesses.Select(w => new AdvisoryWeakness(
"CWE",
w.CweId,
@@ -274,7 +323,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
}
}
var (platform, normalizedVersions, statuses) = ReadDatabaseSpecific(a.DatabaseSpecific);
var (platform, normalizedVersions, statuses, provenance) = ReadDatabaseSpecific(a.DatabaseSpecific);
var effectivePlatform = platform ?? ResolvePlatformFromRanges(versionRanges);
var resolvedNormalizedVersions = normalizedVersions ?? BuildNormalizedVersions(versionRanges);
@@ -284,24 +333,15 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
effectivePlatform,
versionRanges,
statuses ?? Array.Empty<AffectedPackageStatus>(),
Array.Empty<AdvisoryProvenance>(),
provenance ?? Array.Empty<AdvisoryProvenance>(),
resolvedNormalizedVersions);
}).ToArray();
// Parse provenance if available
IEnumerable<AdvisoryProvenance> provenance = Array.Empty<AdvisoryProvenance>();
if (!string.IsNullOrEmpty(entity.Provenance) && entity.Provenance != "[]" && entity.Provenance != "{}")
{
try
{
provenance = JsonSerializer.Deserialize<AdvisoryProvenance[]>(entity.Provenance, JsonOptions)
?? Array.Empty<AdvisoryProvenance>();
}
catch (JsonException)
{
// Fallback to empty
}
}
var exploitKnown = string.Equals(sourceName, "ru-bdu", StringComparison.OrdinalIgnoreCase)
? false
: fallbackExploitKnown ?? false;
var resolvedSeverity = entity.Severity ?? cvssModels.FirstOrDefault()?.BaseSeverity ?? TryReadSeverityFromRawPayload(entity.RawPayload);
return new Advisory(
entity.AdvisoryKey,
@@ -310,8 +350,8 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
fallbackLanguage,
entity.PublishedAt,
entity.ModifiedAt,
entity.Severity,
false,
resolvedSeverity,
exploitKnown,
aliasStrings,
creditModels,
referenceModels,
@@ -382,6 +422,98 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
}
}
private static bool? TryReadExploitKnown(string? rawPayload)
{
if (string.IsNullOrWhiteSpace(rawPayload))
{
return null;
}
try
{
using var document = JsonDocument.Parse(rawPayload, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
});
if (document.RootElement.TryGetProperty("exploitKnown", out var value) &&
(value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False))
{
return value.GetBoolean();
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static string? TryReadSeverityFromRawPayload(string? rawPayload)
{
if (string.IsNullOrWhiteSpace(rawPayload))
{
return null;
}
try
{
using var document = JsonDocument.Parse(rawPayload, new JsonDocumentOptions
{
AllowTrailingCommas = true
});
if (TryFindBaseSeverity(document.RootElement, out var severity) && !string.IsNullOrWhiteSpace(severity))
{
return severity.Trim().ToLowerInvariant();
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static bool TryFindBaseSeverity(JsonElement element, out string? severity)
{
severity = null;
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, "baseSeverity", StringComparison.OrdinalIgnoreCase)
&& property.Value.ValueKind == JsonValueKind.String)
{
severity = property.Value.GetString();
return true;
}
if (TryFindBaseSeverity(property.Value, out severity))
{
return true;
}
}
}
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
if (TryFindBaseSeverity(item, out severity))
{
return true;
}
}
}
return false;
}
private static IReadOnlyDictionary<string, (string? SourceTag, string? Summary)> TryReadReferenceDetails(string? rawPayload)
{
if (string.IsNullOrWhiteSpace(rawPayload))
@@ -467,11 +599,12 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
private static (
string? Platform,
IReadOnlyList<NormalizedVersionRule>? NormalizedVersions,
IReadOnlyList<AffectedPackageStatus>? Statuses) ReadDatabaseSpecific(string? databaseSpecific)
IReadOnlyList<AffectedPackageStatus>? Statuses,
IReadOnlyList<AdvisoryProvenance>? Provenance) ReadDatabaseSpecific(string? databaseSpecific)
{
if (string.IsNullOrWhiteSpace(databaseSpecific) || databaseSpecific == "{}")
{
return (null, null, null);
return (null, null, null, null);
}
try
@@ -494,21 +627,49 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
IReadOnlyList<AffectedPackageStatus>? statuses = null;
if (root.TryGetProperty("statuses", out var statusValue) && statusValue.ValueKind == JsonValueKind.Array)
{
var statusStrings = JsonSerializer.Deserialize<string[]>(statusValue.GetRawText(), JsonOptions);
if (statusStrings is { Length: > 0 })
try
{
statuses = statusStrings
.Where(static status => !string.IsNullOrWhiteSpace(status))
.Select(static status => new AffectedPackageStatus(status.Trim(), AdvisoryProvenance.Empty))
.ToArray();
var statusObjects = JsonSerializer.Deserialize<AffectedPackageStatus[]>(statusValue.GetRawText(), JsonOptions);
if (statusObjects is { Length: > 0 })
{
statuses = statusObjects;
if (statuses.All(static status => string.Equals(status.Provenance.Source, "ru-bdu", StringComparison.OrdinalIgnoreCase)))
{
statuses = statuses
.Select(static status => new AffectedPackageStatus(status.Status, AdvisoryProvenance.Empty))
.ToArray();
}
}
}
catch (JsonException)
{
var statusStrings = JsonSerializer.Deserialize<string[]>(statusValue.GetRawText(), JsonOptions);
if (statusStrings is { Length: > 0 })
{
statuses = statusStrings
.Where(static status => !string.IsNullOrWhiteSpace(status))
.Select(static status => new AffectedPackageStatus(status.Trim(), AdvisoryProvenance.Empty))
.ToArray();
}
}
}
return (platform, normalizedVersions, statuses);
IReadOnlyList<AdvisoryProvenance>? provenance = null;
if (root.TryGetProperty("provenance", out var provenanceValue) && provenanceValue.ValueKind == JsonValueKind.Array)
{
provenance = JsonSerializer.Deserialize<AdvisoryProvenance[]>(provenanceValue.GetRawText(), JsonOptions);
if (provenance is { Count: > 0 } && provenance.All(static p => string.Equals(p.Source, "ru-bdu", StringComparison.OrdinalIgnoreCase)))
{
provenance = null;
}
}
return (platform, normalizedVersions, statuses, provenance);
}
catch (JsonException)
{
return (null, null, null);
return (null, null, null, null);
}
}

View File

@@ -281,7 +281,12 @@ public sealed class AdvisoryConverter
if (!package.Statuses.IsEmpty)
{
payload["statuses"] = package.Statuses.Select(static status => status.Status).ToArray();
payload["statuses"] = package.Statuses.ToArray();
}
if (!package.Provenance.IsEmpty)
{
payload["provenance"] = package.Provenance.ToArray();
}
return payload.Count == 0

View File

@@ -272,7 +272,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
created_at, updated_at
FROM vuln.advisories
WHERE COALESCE(modified_at, published_at, created_at) > @since
ORDER BY COALESCE(modified_at, published_at, created_at), id
ORDER BY COALESCE(modified_at, published_at, created_at) DESC, id
LIMIT @limit
""";
@@ -288,6 +288,27 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<AdvisoryEntity>> GetRecentAsync(
int limit = 1000,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance::text, raw_Payload::text,
created_at, updated_at
FROM vuln.advisories
ORDER BY COALESCE(updated_at, created_at) DESC, id
LIMIT @limit
""";
return await QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "limit", limit),
MapAdvisory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AdvisoryEntity>> GetBySourceAsync(
Guid sourceId,

View File

@@ -78,6 +78,13 @@ public interface IAdvisoryRepository
int limit = 1000,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets recent advisories without date filtering.
/// </summary>
Task<IReadOnlyList<AdvisoryEntity>> GetRecentAsync(
int limit = 1000,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets advisories by source.
/// </summary>

View File

@@ -22,7 +22,7 @@ internal sealed class PostgresChangeHistoryStore : IChangeHistoryStore
const string sql = """
INSERT INTO concelier.change_history
(id, source_name, advisory_key, document_id, document_hash, snapshot_hash, previous_snapshot_hash, snapshot, previous_snapshot, changes, created_at)
VALUES (@Id, @SourceName, @AdvisoryKey, @DocumentId, @DocumentHash, @SnapshotHash, @PreviousSnapshotHash, @Snapshot, @PreviousSnapshot, @Changes, @CreatedAt)
VALUES (@Id, @SourceName, @AdvisoryKey, @DocumentId, @DocumentHash, @SnapshotHash, @PreviousSnapshotHash, @Snapshot::jsonb, @PreviousSnapshot::jsonb, @Changes::jsonb, @CreatedAt)
ON CONFLICT (id) DO NOTHING;
""";
@@ -81,16 +81,18 @@ internal sealed class PostgresChangeHistoryStore : IChangeHistoryStore
row.CreatedAt);
}
private sealed record ChangeHistoryRow(
Guid Id,
string SourceName,
string AdvisoryKey,
Guid DocumentId,
string DocumentHash,
string SnapshotHash,
string? PreviousSnapshotHash,
string Snapshot,
string? PreviousSnapshot,
string Changes,
DateTimeOffset CreatedAt);
private sealed class ChangeHistoryRow
{
public Guid Id { get; init; }
public string SourceName { get; init; } = string.Empty;
public string AdvisoryKey { get; init; } = string.Empty;
public Guid DocumentId { get; init; }
public string DocumentHash { get; init; } = string.Empty;
public string SnapshotHash { get; init; } = string.Empty;
public string? PreviousSnapshotHash { get; init; }
public string Snapshot { get; init; } = string.Empty;
public string? PreviousSnapshot { get; init; }
public string Changes { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
}
}

View File

@@ -37,7 +37,12 @@ internal sealed class PostgresPsirtFlagStore : IPsirtFlagStore
public async Task<IReadOnlyList<PsirtFlagRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
{
const string sql = """
SELECT advisory_id, vendor, source_name, external_id, recorded_at
SELECT
advisory_id AS AdvisoryId,
vendor AS Vendor,
source_name AS SourceName,
external_id AS ExternalId,
recorded_at AS RecordedAt
FROM concelier.psirt_flags
WHERE advisory_id = @AdvisoryId
ORDER BY recorded_at DESC
@@ -52,7 +57,12 @@ internal sealed class PostgresPsirtFlagStore : IPsirtFlagStore
public async Task<PsirtFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
{
const string sql = """
SELECT advisory_id, vendor, source_name, external_id, recorded_at
SELECT
advisory_id AS AdvisoryId,
vendor AS Vendor,
source_name AS SourceName,
external_id AS ExternalId,
recorded_at AS RecordedAt
FROM concelier.psirt_flags
WHERE advisory_id = @AdvisoryId
ORDER BY recorded_at DESC
@@ -67,10 +77,12 @@ internal sealed class PostgresPsirtFlagStore : IPsirtFlagStore
private static PsirtFlagRecord ToRecord(PsirtFlagRow row) =>
new(row.AdvisoryId, row.Vendor, row.SourceName, row.ExternalId, row.RecordedAt);
private sealed record PsirtFlagRow(
string AdvisoryId,
string Vendor,
string SourceName,
string? ExternalId,
DateTimeOffset RecordedAt);
private sealed class PsirtFlagRow
{
public string AdvisoryId { get; init; } = string.Empty;
public string Vendor { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public string? ExternalId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
}