todays product advirories implemented
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user