Rename Concelier Source modules to Connector

This commit is contained in:
master
2025-10-18 20:11:18 +03:00
parent 89ede53cc3
commit 052da7a7d0
789 changed files with 1489 additions and 1489 deletions

View File

@@ -0,0 +1,27 @@
# AGENTS
## Role
Oracle PSIRT connector for Critical Patch Updates (CPU) and Security Alerts; authoritative vendor ranges and severities for Oracle products; establishes PSIRT precedence over registry or distro where applicable.
## Scope
- Harvest CPU calendar pages and per-advisory content; window by CPU cycle (Jan/Apr/Jul/Oct) and last modified timestamps.
- Validate HTML or JSON; extract CVE lists, affected products, components, versions, fixed patch levels; map to canonical with aliases and psirt_flags.
- Persist raw documents; maintain source_state across cycles; idempotent mapping.
## Participants
- Source.Common (HTTP, validators).
- Storage.Mongo (document, dto, advisory, alias, affected, reference, psirt_flags, source_state).
- Models (canonical; affected ranges for vendor products).
- Core/WebService (jobs: source:oracle:fetch|parse|map).
- Merge engine (later) to prefer PSIRT ranges over NVD for Oracle products.
## Interfaces & contracts
- Alias scheme includes CPU:YYYY-QQ plus individual advisory ids when present; include CVE mappings.
- Affected entries capture product/component and fixedBy patch version; references include product notes and patch docs; kind=advisory or patch.
- Provenance.method=parser; value includes CPU cycle and advisory slug.
## In/Out of scope
In: PSIRT authoritative mapping, cycles handling, precedence signaling.
Out: signing or patch artifact downloads.
## Observability & security expectations
- Metrics: SourceDiagnostics emits `concelier.source.http.*` counters/histograms tagged `concelier.source=oracle`, so observability dashboards slice on that tag to monitor fetch pages, CPU cycle coverage, parse failures, and map affected counts.
- Logs: cycle tags, advisory ids, extraction timings; redact nothing sensitive.
## Tests
- Author and review coverage in `../StellaOps.Concelier.Connector.Vndr.Oracle.Tests`.
- Shared fixtures (e.g., `MongoIntegrationFixture`, `ConnectorTestHarness`) live in `../StellaOps.Concelier.Testing`.
- Keep fixtures deterministic; match new cases to real-world advisories or regression scenarios.

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
public sealed class OracleOptions
{
public const string HttpClientName = "vndr-oracle";
public List<Uri> AdvisoryUris { get; set; } = new();
public List<Uri> CalendarUris { get; set; } = new();
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromSeconds(1);
public void Validate()
{
if (AdvisoryUris.Count == 0 && CalendarUris.Count == 0)
{
throw new InvalidOperationException("Oracle connector requires at least one advisory or calendar URI.");
}
if (AdvisoryUris.Any(uri => uri is null || !uri.IsAbsoluteUri))
{
throw new InvalidOperationException("All Oracle AdvisoryUris must be absolute URIs.");
}
if (CalendarUris.Any(uri => uri is null || !uri.IsAbsoluteUri))
{
throw new InvalidOperationException("All Oracle CalendarUris must be absolute URIs.");
}
if (RequestDelay < TimeSpan.Zero)
{
throw new InvalidOperationException("RequestDelay cannot be negative.");
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal sealed record OracleAffectedEntry(
[property: JsonPropertyName("product")] string Product,
[property: JsonPropertyName("component")] string? Component,
[property: JsonPropertyName("supportedVersions")] string? SupportedVersions,
[property: JsonPropertyName("notes")] string? Notes,
[property: JsonPropertyName("cves")] IReadOnlyList<string> CveIds);

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
public sealed class OracleCalendarFetcher
{
private static readonly Regex AnchorRegex = new("<a[^>]+href=\"(?<url>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly IHttpClientFactory _httpClientFactory;
private readonly OracleOptions _options;
private readonly ILogger<OracleCalendarFetcher> _logger;
public OracleCalendarFetcher(
IHttpClientFactory httpClientFactory,
IOptions<OracleOptions> options,
ILogger<OracleCalendarFetcher> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyCollection<Uri>> GetAdvisoryUrisAsync(CancellationToken cancellationToken)
{
if (_options.CalendarUris.Count == 0)
{
return Array.Empty<Uri>();
}
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var client = _httpClientFactory.CreateClient(OracleOptions.HttpClientName);
foreach (var calendarUri in _options.CalendarUris)
{
try
{
var content = await client.GetStringAsync(calendarUri, cancellationToken).ConfigureAwait(false);
foreach (var link in ExtractLinks(calendarUri, content))
{
discovered.Add(link.AbsoluteUri);
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
{
_logger.LogWarning(ex, "Oracle calendar fetch failed for {Uri}", calendarUri);
}
}
return discovered
.Select(static uri => new Uri(uri, UriKind.Absolute))
.OrderBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IEnumerable<Uri> ExtractLinks(Uri baseUri, string html)
{
if (string.IsNullOrWhiteSpace(html))
{
yield break;
}
foreach (Match match in AnchorRegex.Matches(html))
{
if (!match.Success)
{
continue;
}
var href = match.Groups["url"].Value?.Trim();
if (string.IsNullOrEmpty(href))
{
continue;
}
if (!Uri.TryCreate(baseUri, href, out var uri) || !uri.IsAbsoluteUri)
{
continue;
}
yield return uri;
}
}
}

View File

@@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MongoDB.Bson;
using StellaOps.Concelier.Storage.Mongo.Documents;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal sealed record OracleCursor(
DateTimeOffset? LastProcessed,
IReadOnlyCollection<Guid> PendingDocuments,
IReadOnlyCollection<Guid> PendingMappings,
IReadOnlyDictionary<string, OracleFetchCacheEntry> FetchCache)
{
private static readonly IReadOnlyCollection<Guid> EmptyGuidCollection = Array.Empty<Guid>();
private static readonly IReadOnlyDictionary<string, OracleFetchCacheEntry> EmptyFetchCache =
new Dictionary<string, OracleFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
public static OracleCursor Empty { get; } = new(null, EmptyGuidCollection, EmptyGuidCollection, EmptyFetchCache);
public BsonDocument ToBsonDocument()
{
var document = new BsonDocument
{
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
};
if (LastProcessed.HasValue)
{
document["lastProcessed"] = LastProcessed.Value.UtcDateTime;
}
if (FetchCache.Count > 0)
{
var cacheDocument = new BsonDocument();
foreach (var (key, entry) in FetchCache)
{
cacheDocument[key] = entry.ToBsonDocument();
}
document["fetchCache"] = cacheDocument;
}
return document;
}
public static OracleCursor FromBson(BsonDocument? document)
{
if (document is null || document.ElementCount == 0)
{
return Empty;
}
var lastProcessed = document.TryGetValue("lastProcessed", out var value)
? ParseDate(value)
: null;
return new OracleCursor(
lastProcessed,
ReadGuidArray(document, "pendingDocuments"),
ReadGuidArray(document, "pendingMappings"),
ReadFetchCache(document));
}
public OracleCursor WithLastProcessed(DateTimeOffset? timestamp)
=> this with { LastProcessed = timestamp };
public OracleCursor WithPendingDocuments(IEnumerable<Guid> ids)
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidCollection };
public OracleCursor WithPendingMappings(IEnumerable<Guid> ids)
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidCollection };
public OracleCursor WithFetchCache(IDictionary<string, OracleFetchCacheEntry> cache)
{
if (cache is null || cache.Count == 0)
{
return this with { FetchCache = EmptyFetchCache };
}
return this with { FetchCache = new Dictionary<string, OracleFetchCacheEntry>(cache, StringComparer.OrdinalIgnoreCase) };
}
public bool TryGetFetchCache(string key, out OracleFetchCacheEntry entry)
{
if (FetchCache.Count == 0)
{
entry = OracleFetchCacheEntry.Empty;
return false;
}
return FetchCache.TryGetValue(key, out entry!);
}
private static DateTimeOffset? ParseDate(BsonValue value)
=> value.BsonType switch
{
BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null,
};
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var raw) || raw is not BsonArray array)
{
return Array.Empty<Guid>();
}
var result = new List<Guid>(array.Count);
foreach (var element in array)
{
if (element is null)
{
continue;
}
if (Guid.TryParse(element.ToString(), out var guid))
{
result.Add(guid);
}
}
return result;
}
private static IReadOnlyDictionary<string, OracleFetchCacheEntry> ReadFetchCache(BsonDocument document)
{
if (!document.TryGetValue("fetchCache", out var raw) || raw is not BsonDocument cacheDocument || cacheDocument.ElementCount == 0)
{
return EmptyFetchCache;
}
var cache = new Dictionary<string, OracleFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var element in cacheDocument.Elements)
{
if (element.Value is not BsonDocument entryDocument)
{
continue;
}
cache[element.Name] = OracleFetchCacheEntry.FromBson(entryDocument);
}
return cache;
}
}
internal sealed record OracleFetchCacheEntry(string? Sha256, string? ETag, DateTimeOffset? LastModified)
{
public static OracleFetchCacheEntry Empty { get; } = new(string.Empty, null, null);
public BsonDocument ToBsonDocument()
{
var document = new BsonDocument
{
["sha256"] = Sha256 ?? string.Empty,
};
if (!string.IsNullOrWhiteSpace(ETag))
{
document["etag"] = ETag;
}
if (LastModified.HasValue)
{
document["lastModified"] = LastModified.Value.UtcDateTime;
}
return document;
}
public static OracleFetchCacheEntry FromBson(BsonDocument document)
{
var sha = document.TryGetValue("sha256", out var shaValue) ? shaValue.ToString() : string.Empty;
string? etag = null;
if (document.TryGetValue("etag", out var etagValue) && !etagValue.IsBsonNull)
{
etag = etagValue.ToString();
}
DateTimeOffset? lastModified = null;
if (document.TryGetValue("lastModified", out var lastModifiedValue))
{
lastModified = lastModifiedValue.BsonType switch
{
BsonType.DateTime => DateTime.SpecifyKind(lastModifiedValue.ToUniversalTime(), DateTimeKind.Utc),
BsonType.String when DateTimeOffset.TryParse(lastModifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null,
};
}
return new OracleFetchCacheEntry(sha, etag, lastModified);
}
public static OracleFetchCacheEntry FromDocument(DocumentRecord document)
{
ArgumentNullException.ThrowIfNull(document);
return new OracleFetchCacheEntry(
document.Sha256 ?? string.Empty,
document.Etag,
document.LastModified?.ToUniversalTime());
}
public bool Matches(DocumentRecord document)
{
ArgumentNullException.ThrowIfNull(document);
if (!string.IsNullOrEmpty(Sha256) && !string.IsNullOrEmpty(document.Sha256))
{
return string.Equals(Sha256, document.Sha256, StringComparison.OrdinalIgnoreCase);
}
if (!string.IsNullOrEmpty(ETag) && !string.IsNullOrEmpty(document.Etag))
{
return string.Equals(ETag, document.Etag, StringComparison.Ordinal);
}
if (LastModified.HasValue && document.LastModified.HasValue)
{
return LastModified.Value.ToUniversalTime() == document.LastModified.Value.ToUniversalTime();
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using StellaOps.Concelier.Storage.Mongo.Documents;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal sealed record OracleDocumentMetadata(
string AdvisoryId,
string Title,
DateTimeOffset Published,
Uri DetailUri)
{
private const string AdvisoryIdKey = "oracle.advisoryId";
private const string TitleKey = "oracle.title";
private const string PublishedKey = "oracle.published";
public static IReadOnlyDictionary<string, string> CreateMetadata(string advisoryId, string title, DateTimeOffset published)
=> new Dictionary<string, string>(StringComparer.Ordinal)
{
[AdvisoryIdKey] = advisoryId,
[TitleKey] = title,
[PublishedKey] = published.ToString("O"),
};
public static OracleDocumentMetadata FromDocument(DocumentRecord document)
{
ArgumentNullException.ThrowIfNull(document);
if (document.Metadata is null)
{
throw new InvalidOperationException("Oracle document metadata missing.");
}
var metadata = document.Metadata;
if (!metadata.TryGetValue(AdvisoryIdKey, out var advisoryId) || string.IsNullOrWhiteSpace(advisoryId))
{
throw new InvalidOperationException("Oracle advisory id metadata missing.");
}
if (!metadata.TryGetValue(TitleKey, out var title) || string.IsNullOrWhiteSpace(title))
{
throw new InvalidOperationException("Oracle title metadata missing.");
}
if (!metadata.TryGetValue(PublishedKey, out var publishedRaw) || !DateTimeOffset.TryParse(publishedRaw, out var published))
{
throw new InvalidOperationException("Oracle published metadata invalid.");
}
if (!Uri.TryCreate(document.Uri, UriKind.Absolute, out var detailUri))
{
throw new InvalidOperationException("Oracle document URI invalid.");
}
return new OracleDocumentMetadata(advisoryId.Trim(), title.Trim(), published.ToUniversalTime(), detailUri);
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal sealed record OracleDto(
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("detailUrl")] string DetailUrl,
[property: JsonPropertyName("published")] DateTimeOffset Published,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("references")] IReadOnlyList<string> References,
[property: JsonPropertyName("cveIds")] IReadOnlyList<string> CveIds,
[property: JsonPropertyName("affected")] IReadOnlyList<OracleAffectedEntry> Affected,
[property: JsonPropertyName("patchDocuments")] IReadOnlyList<OraclePatchDocument> PatchDocuments);

View File

@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal static class OracleDtoValidator
{
private const int MaxAdvisoryIdLength = 128;
private const int MaxTitleLength = 512;
private const int MaxContentLength = 200_000;
private const int MaxReferenceCount = 100;
private const int MaxCveCount = 1_024;
private const int MaxAffectedCount = 2_048;
private const int MaxPatchDocumentCount = 512;
private const int MaxProductLength = 512;
private const int MaxComponentLength = 512;
private const int MaxSupportedVersionsLength = 4_096;
private const int MaxNotesLength = 1_024;
private const int MaxPatchTitleLength = 512;
private const int MaxPatchUrlLength = 1_024;
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryNormalize(OracleDto dto, out OracleDto normalized, out string? failureReason)
{
ArgumentNullException.ThrowIfNull(dto);
failureReason = null;
normalized = dto;
var advisoryId = dto.AdvisoryId?.Trim();
if (string.IsNullOrWhiteSpace(advisoryId))
{
failureReason = "AdvisoryId is required.";
return false;
}
if (advisoryId.Length > MaxAdvisoryIdLength)
{
failureReason = $"AdvisoryId exceeds {MaxAdvisoryIdLength} characters.";
return false;
}
var title = string.IsNullOrWhiteSpace(dto.Title) ? advisoryId : dto.Title.Trim();
if (title.Length > MaxTitleLength)
{
title = title.Substring(0, MaxTitleLength);
}
var detailUrlRaw = dto.DetailUrl?.Trim();
if (string.IsNullOrWhiteSpace(detailUrlRaw) || !Uri.TryCreate(detailUrlRaw, UriKind.Absolute, out var detailUri))
{
failureReason = "DetailUrl must be an absolute URI.";
return false;
}
if (!string.Equals(detailUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(detailUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
failureReason = "DetailUrl must use HTTP or HTTPS.";
return false;
}
if (dto.Published == default)
{
failureReason = "Published timestamp is required.";
return false;
}
var published = dto.Published.ToUniversalTime();
var content = dto.Content?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(content))
{
failureReason = "Advisory content is empty.";
return false;
}
if (content.Length > MaxContentLength)
{
content = content.Substring(0, MaxContentLength);
}
var references = NormalizeReferences(dto.References);
var cveIds = NormalizeCveIds(dto.CveIds);
var affected = NormalizeAffected(dto.Affected);
var patchDocuments = NormalizePatchDocuments(dto.PatchDocuments);
normalized = dto with
{
AdvisoryId = advisoryId,
Title = title,
DetailUrl = detailUri.ToString(),
Published = published,
Content = content,
References = references,
CveIds = cveIds,
Affected = affected,
PatchDocuments = patchDocuments,
};
return true;
}
private static IReadOnlyList<string> NormalizeReferences(IReadOnlyList<string>? references)
{
if (references is null || references.Count == 0)
{
return Array.Empty<string>();
}
var normalized = new List<string>(Math.Min(references.Count, MaxReferenceCount));
foreach (var reference in references.Where(static reference => !string.IsNullOrWhiteSpace(reference)))
{
var trimmed = reference.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
&& (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
{
normalized.Add(uri.ToString());
}
if (normalized.Count >= MaxReferenceCount)
{
break;
}
}
if (normalized.Count == 0)
{
return Array.Empty<string>();
}
return normalized
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static url => url, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IReadOnlyList<string> NormalizeCveIds(IReadOnlyList<string>? cveIds)
{
if (cveIds is null || cveIds.Count == 0)
{
return Array.Empty<string>();
}
var normalized = new List<string>(Math.Min(cveIds.Count, MaxCveCount));
foreach (var cve in cveIds.Where(static value => !string.IsNullOrWhiteSpace(value)))
{
var candidate = cve.Trim().ToUpperInvariant();
if (!CveRegex.IsMatch(candidate))
{
continue;
}
normalized.Add(candidate);
if (normalized.Count >= MaxCveCount)
{
break;
}
}
if (normalized.Count == 0)
{
return Array.Empty<string>();
}
return normalized
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IReadOnlyList<OracleAffectedEntry> NormalizeAffected(IReadOnlyList<OracleAffectedEntry>? entries)
{
if (entries is null || entries.Count == 0)
{
return Array.Empty<OracleAffectedEntry>();
}
var normalized = new List<OracleAffectedEntry>(Math.Min(entries.Count, MaxAffectedCount));
foreach (var entry in entries)
{
if (entry is null)
{
continue;
}
var product = TrimToLength(entry.Product, MaxProductLength);
if (string.IsNullOrWhiteSpace(product))
{
continue;
}
var component = TrimToNull(entry.Component, MaxComponentLength);
var versions = TrimToNull(entry.SupportedVersions, MaxSupportedVersionsLength);
var notes = TrimToNull(entry.Notes, MaxNotesLength);
var cves = NormalizeCveIds(entry.CveIds);
normalized.Add(new OracleAffectedEntry(product, component, versions, notes, cves));
if (normalized.Count >= MaxAffectedCount)
{
break;
}
}
return normalized.Count == 0 ? Array.Empty<OracleAffectedEntry>() : normalized;
}
private static IReadOnlyList<OraclePatchDocument> NormalizePatchDocuments(IReadOnlyList<OraclePatchDocument>? documents)
{
if (documents is null || documents.Count == 0)
{
return Array.Empty<OraclePatchDocument>();
}
var normalized = new List<OraclePatchDocument>(Math.Min(documents.Count, MaxPatchDocumentCount));
foreach (var document in documents)
{
if (document is null)
{
continue;
}
var product = TrimToLength(document.Product, MaxProductLength);
if (string.IsNullOrWhiteSpace(product))
{
continue;
}
var title = TrimToNull(document.Title, MaxPatchTitleLength);
var urlRaw = TrimToLength(document.Url, MaxPatchUrlLength);
if (string.IsNullOrWhiteSpace(urlRaw))
{
continue;
}
if (!Uri.TryCreate(urlRaw, UriKind.Absolute, out var uri)
|| (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
normalized.Add(new OraclePatchDocument(product, title, uri.ToString()));
if (normalized.Count >= MaxPatchDocumentCount)
{
break;
}
}
return normalized.Count == 0 ? Array.Empty<OraclePatchDocument>() : normalized;
}
private static string TrimToLength(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
if (trimmed.Length <= maxLength)
{
return trimmed;
}
return trimmed[..maxLength];
}
private static string? TrimToNull(string? value, int maxLength)
{
var trimmed = TrimToLength(value, maxLength);
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
}

View File

@@ -0,0 +1,426 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Packages;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Concelier.Storage.Mongo.PsirtFlags;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal static class OracleMapper
{
private static readonly Regex FixedVersionRegex = new("(?:Fixed|Fix)\\s+(?:in|available in|for)\\s+(?<value>[A-Za-z0-9._-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex PatchNumberRegex = new("Patch\\s+(?<value>\\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static (Advisory Advisory, PsirtFlagRecord Flag) Map(
OracleDto dto,
DocumentRecord document,
DtoRecord dtoRecord,
string sourceName,
DateTimeOffset mappedAt)
{
ArgumentNullException.ThrowIfNull(dto);
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(dtoRecord);
ArgumentException.ThrowIfNullOrEmpty(sourceName);
var advisoryKey = $"oracle/{dto.AdvisoryId}";
var fetchProvenance = new AdvisoryProvenance(sourceName, "document", document.Uri, document.FetchedAt.ToUniversalTime());
var mappingProvenance = new AdvisoryProvenance(sourceName, "mapping", dto.AdvisoryId, mappedAt.ToUniversalTime());
var aliases = BuildAliases(dto);
var references = BuildReferences(dto, sourceName, mappedAt);
var affectedPackages = BuildAffectedPackages(dto, sourceName, mappedAt);
var advisory = new Advisory(
advisoryKey,
dto.Title,
dto.Content,
language: "en",
published: dto.Published.ToUniversalTime(),
modified: null,
severity: null,
exploitKnown: false,
aliases,
references,
affectedPackages,
Array.Empty<CvssMetric>(),
new[] { fetchProvenance, mappingProvenance });
var flag = new PsirtFlagRecord(
advisoryKey,
"Oracle",
sourceName,
dto.AdvisoryId,
mappedAt.ToUniversalTime());
return (advisory, flag);
}
private static IReadOnlyList<string> BuildAliases(OracleDto dto)
{
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
$"ORACLE:{dto.AdvisoryId}".ToUpperInvariant(),
};
foreach (var cve in dto.CveIds)
{
if (!string.IsNullOrWhiteSpace(cve))
{
aliases.Add(cve.Trim().ToUpperInvariant());
}
}
return aliases
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IReadOnlyList<AdvisoryReference> BuildReferences(OracleDto dto, string sourceName, DateTimeOffset recordedAt)
{
var comparer = StringComparer.OrdinalIgnoreCase;
var entries = new List<(AdvisoryReference Reference, int Priority)>
{
(new AdvisoryReference(
dto.DetailUrl,
"advisory",
"oracle",
dto.Title,
new AdvisoryProvenance(sourceName, "reference", dto.DetailUrl, recordedAt.ToUniversalTime())), 0),
};
foreach (var document in dto.PatchDocuments)
{
var summary = document.Title ?? document.Product;
entries.Add((new AdvisoryReference(
document.Url,
"patch",
"oracle",
summary,
new AdvisoryProvenance(sourceName, "reference", document.Url, recordedAt.ToUniversalTime())), 1));
}
foreach (var url in dto.References)
{
entries.Add((new AdvisoryReference(
url,
"reference",
null,
null,
new AdvisoryProvenance(sourceName, "reference", url, recordedAt.ToUniversalTime())), 2));
}
foreach (var cve in dto.CveIds)
{
if (string.IsNullOrWhiteSpace(cve))
{
continue;
}
var cveUrl = $"https://www.cve.org/CVERecord?id={cve}";
entries.Add((new AdvisoryReference(
cveUrl,
"advisory",
cve,
null,
new AdvisoryProvenance(sourceName, "reference", cveUrl, recordedAt.ToUniversalTime())), 3));
}
return entries
.GroupBy(tuple => tuple.Reference.Url, comparer)
.Select(group => group
.OrderBy(t => t.Priority)
.ThenBy(t => t.Reference.Kind ?? string.Empty, comparer)
.ThenBy(t => t.Reference.Url, comparer)
.First())
.OrderBy(t => t.Priority)
.ThenBy(t => t.Reference.Kind ?? string.Empty, comparer)
.ThenBy(t => t.Reference.Url, comparer)
.Select(t => t.Reference)
.ToArray();
}
private static IReadOnlyList<AffectedPackage> BuildAffectedPackages(OracleDto dto, string sourceName, DateTimeOffset recordedAt)
{
if (dto.Affected.Count == 0)
{
return Array.Empty<AffectedPackage>();
}
var packages = new List<AffectedPackage>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in dto.Affected)
{
if (entry is null)
{
continue;
}
var component = NormalizeComponent(entry.Component);
var notes = entry.Notes;
foreach (var segment in SplitSupportedVersions(entry.Product, entry.SupportedVersions))
{
if (string.IsNullOrWhiteSpace(segment.Product))
{
continue;
}
var identifier = CreateIdentifier(segment.Product, component);
var baseExpression = segment.Versions ?? entry.SupportedVersions ?? string.Empty;
var composedExpression = baseExpression;
if (!string.IsNullOrEmpty(notes))
{
composedExpression = string.IsNullOrEmpty(composedExpression)
? $"notes: {notes}"
: $"{composedExpression} (notes: {notes})";
}
var rangeExpression = string.IsNullOrWhiteSpace(composedExpression) ? null : composedExpression;
var (fixedVersion, patchNumber) = ExtractFixMetadata(notes);
var rangeProvenance = new AdvisoryProvenance(sourceName, "range", identifier, recordedAt.ToUniversalTime());
var rangePrimitives = BuildVendorRangePrimitives(entry, segment, component, baseExpression, rangeExpression, notes, fixedVersion, patchNumber);
var ranges = rangeExpression is null && string.IsNullOrEmpty(fixedVersion)
? Array.Empty<AffectedVersionRange>()
: new[]
{
new AffectedVersionRange(
rangeKind: "vendor",
introducedVersion: null,
fixedVersion: fixedVersion,
lastAffectedVersion: null,
rangeExpression: rangeExpression,
provenance: rangeProvenance,
primitives: rangePrimitives),
};
var provenance = new[]
{
new AdvisoryProvenance(sourceName, "affected", identifier, recordedAt.ToUniversalTime()),
};
var package = new AffectedPackage(
AffectedPackageTypes.Vendor,
identifier,
component,
ranges,
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: provenance);
var key = $"{identifier}::{component}::{ranges.FirstOrDefault()?.CreateDeterministicKey()}";
if (seen.Add(key))
{
packages.Add(package);
}
}
}
return packages.Count == 0 ? Array.Empty<AffectedPackage>() : packages;
}
private static IEnumerable<(string Product, string? Versions)> SplitSupportedVersions(string product, string? supportedVersions)
{
var normalizedProduct = string.IsNullOrWhiteSpace(product) ? "Oracle Product" : product.Trim();
if (string.IsNullOrWhiteSpace(supportedVersions))
{
yield return (normalizedProduct, null);
yield break;
}
var segments = supportedVersions.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length <= 1)
{
yield return (normalizedProduct, supportedVersions.Trim());
yield break;
}
foreach (var segment in segments)
{
var text = segment.Trim();
if (text.Length == 0)
{
continue;
}
var colonIndex = text.IndexOf(':');
if (colonIndex > 0)
{
var name = text[..colonIndex].Trim();
var versions = text[(colonIndex + 1)..].Trim();
yield return (string.IsNullOrEmpty(name) ? normalizedProduct : name, versions);
}
else
{
yield return (normalizedProduct, text);
}
}
}
private static RangePrimitives? BuildVendorRangePrimitives(
OracleAffectedEntry entry,
(string Product, string? Versions) segment,
string? component,
string? baseExpression,
string? rangeExpression,
string? notes,
string? fixedVersion,
string? patchNumber)
{
var extensions = new Dictionary<string, string>(StringComparer.Ordinal);
AddExtension(extensions, "oracle.product", segment.Product);
AddExtension(extensions, "oracle.productRaw", entry.Product);
AddExtension(extensions, "oracle.component", component);
AddExtension(extensions, "oracle.componentRaw", entry.Component);
AddExtension(extensions, "oracle.segmentVersions", segment.Versions);
AddExtension(extensions, "oracle.supportedVersions", entry.SupportedVersions);
AddExtension(extensions, "oracle.rangeExpression", rangeExpression);
AddExtension(extensions, "oracle.baseExpression", baseExpression);
AddExtension(extensions, "oracle.notes", notes);
AddExtension(extensions, "oracle.fixedVersion", fixedVersion);
AddExtension(extensions, "oracle.patchNumber", patchNumber);
var versionTokens = ExtractVersionTokens(baseExpression);
if (versionTokens.Count > 0)
{
extensions["oracle.versionTokens"] = string.Join('|', versionTokens);
var normalizedTokens = versionTokens
.Select(NormalizeSemVerToken)
.Where(static token => !string.IsNullOrEmpty(token))
.Cast<string>()
.Distinct(StringComparer.Ordinal)
.ToArray();
if (normalizedTokens.Length > 0)
{
extensions["oracle.versionTokens.normalized"] = string.Join('|', normalizedTokens);
}
}
if (extensions.Count == 0)
{
return null;
}
return new RangePrimitives(null, null, null, extensions);
}
private static IReadOnlyList<string> ExtractVersionTokens(string? baseExpression)
{
if (string.IsNullOrWhiteSpace(baseExpression))
{
return Array.Empty<string>();
}
var tokens = new List<string>();
foreach (var token in baseExpression.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
{
var value = token.Trim();
if (value.Length == 0 || !value.Any(char.IsDigit))
{
continue;
}
tokens.Add(value);
}
return tokens.Count == 0 ? Array.Empty<string>() : tokens;
}
private static string? NormalizeSemVerToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
if (PackageCoordinateHelper.TryParseSemVer(token, out _, out var normalized) && !string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
if (Version.TryParse(token, out var parsed))
{
if (parsed.Build >= 0 && parsed.Revision >= 0)
{
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}.{parsed.Revision}";
}
if (parsed.Build >= 0)
{
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}";
}
return $"{parsed.Major}.{parsed.Minor}";
}
return null;
}
private static void AddExtension(Dictionary<string, string> extensions, string key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
extensions[key] = value.Trim();
}
private static string? NormalizeComponent(string? component)
{
if (string.IsNullOrWhiteSpace(component))
{
return null;
}
var trimmed = component.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
private static string CreateIdentifier(string product, string? component)
{
var normalizedProduct = product.Trim();
if (string.IsNullOrEmpty(component))
{
return normalizedProduct;
}
return $"{normalizedProduct}::{component}";
}
private static (string? FixedVersion, string? PatchNumber) ExtractFixMetadata(string? notes)
{
if (string.IsNullOrWhiteSpace(notes))
{
return (null, null);
}
string? fixedVersion = null;
string? patchNumber = null;
var match = FixedVersionRegex.Match(notes);
if (match.Success)
{
fixedVersion = match.Groups["value"].Value.Trim();
}
match = PatchNumberRegex.Match(notes);
if (match.Success)
{
patchNumber = match.Groups["value"].Value.Trim();
fixedVersion ??= patchNumber;
}
return (fixedVersion, patchNumber);
}
}

View File

@@ -0,0 +1,457 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal static class OracleParser
{
private static readonly Regex AnchorRegex = new("<a[^>]+href=\"(?<url>https?://[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled);
private static readonly Regex WhitespaceRegex = new("\\s+", RegexOptions.Compiled);
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex UpdatedDateRegex = new("\"updatedDate\"\\s*:\\s*\"(?<value>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly string[] AllowedReferenceTokens =
{
"security-alerts",
"/kb/",
"/patches",
"/rs",
"/support/",
"/mos/",
"/technicalresources/",
"/technetwork/"
};
public static OracleDto Parse(string html, OracleDocumentMetadata metadata)
{
ArgumentException.ThrowIfNullOrEmpty(html);
ArgumentNullException.ThrowIfNull(metadata);
var parser = new HtmlParser();
var document = parser.ParseDocument(html);
var published = ExtractPublishedDate(document) ?? metadata.Published;
var content = Sanitize(html);
var affected = ExtractAffectedEntries(document);
var references = ExtractReferences(html);
var patchDocuments = ExtractPatchDocuments(document, metadata.DetailUri);
var cveIds = ExtractCveIds(document, content, affected);
return new OracleDto(
metadata.AdvisoryId,
metadata.Title,
metadata.DetailUri.ToString(),
published,
content,
references,
cveIds,
affected,
patchDocuments);
}
private static string Sanitize(string html)
{
var withoutTags = TagRegex.Replace(html, " ");
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
return WhitespaceRegex.Replace(decoded, " ").Trim();
}
private static IReadOnlyList<string> ExtractReferences(string html)
{
var references = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in AnchorRegex.Matches(html))
{
if (!match.Success)
{
continue;
}
var raw = match.Groups["url"].Value?.Trim();
if (string.IsNullOrEmpty(raw))
{
continue;
}
var decoded = System.Net.WebUtility.HtmlDecode(raw) ?? raw;
if (!Uri.TryCreate(decoded, UriKind.Absolute, out var uri))
{
continue;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!ShouldIncludeReference(uri))
{
continue;
}
references.Add(uri.ToString());
}
return references.Count == 0
? Array.Empty<string>()
: references.OrderBy(url => url, StringComparer.OrdinalIgnoreCase).ToArray();
}
private static bool ShouldIncludeReference(Uri uri)
{
if (uri.Host.EndsWith("cve.org", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!uri.Host.EndsWith("oracle.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (uri.Query.Contains("type=doc", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var path = uri.AbsolutePath ?? string.Empty;
return AllowedReferenceTokens.Any(token => path.Contains(token, StringComparison.OrdinalIgnoreCase));
}
private static DateTimeOffset? ExtractPublishedDate(IHtmlDocument document)
{
var meta = document.QuerySelectorAll("meta")
.FirstOrDefault(static element => string.Equals(element.GetAttribute("name"), "Updated Date", StringComparison.OrdinalIgnoreCase));
if (meta is not null && TryParseOracleDate(meta.GetAttribute("content"), out var parsed))
{
return parsed;
}
foreach (var script in document.Scripts)
{
var text = script.TextContent;
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var match = UpdatedDateRegex.Match(text);
if (!match.Success)
{
continue;
}
if (TryParseOracleDate(match.Groups["value"].Value, out var embedded))
{
return embedded;
}
}
return null;
}
private static bool TryParseOracleDate(string? value, out DateTimeOffset result)
{
if (!string.IsNullOrWhiteSpace(value)
&& DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result))
{
result = result.ToUniversalTime();
return true;
}
result = default;
return false;
}
private static IReadOnlyList<OracleAffectedEntry> ExtractAffectedEntries(IHtmlDocument document)
{
var entries = new List<OracleAffectedEntry>();
foreach (var table in document.QuerySelectorAll("table"))
{
if (table is not IHtmlTableElement tableElement)
{
continue;
}
if (!IsRiskMatrixTable(tableElement))
{
continue;
}
var lastProduct = string.Empty;
var lastComponent = string.Empty;
var lastVersions = string.Empty;
var lastNotes = string.Empty;
IReadOnlyList<string> lastCves = Array.Empty<string>();
foreach (var body in tableElement.Bodies)
{
foreach (var row in body.Rows)
{
if (row is not IHtmlTableRowElement tableRow || tableRow.Cells.Length == 0)
{
continue;
}
var cveText = NormalizeCellText(GetCellText(tableRow, 0));
var cves = ExtractCvesFromText(cveText);
if (cves.Count == 0 && lastCves.Count > 0)
{
cves = lastCves;
}
else if (cves.Count > 0)
{
lastCves = cves;
}
var product = NormalizeCellText(GetCellText(tableRow, 1));
if (string.IsNullOrEmpty(product))
{
product = lastProduct;
}
else
{
lastProduct = product;
}
var component = NormalizeCellText(GetCellText(tableRow, 2));
if (string.IsNullOrEmpty(component))
{
component = lastComponent;
}
else
{
lastComponent = component;
}
var supportedVersions = NormalizeCellText(GetCellTextFromEnd(tableRow, 2));
if (string.IsNullOrEmpty(supportedVersions))
{
supportedVersions = lastVersions;
}
else
{
lastVersions = supportedVersions;
}
var notes = NormalizeCellText(GetCellTextFromEnd(tableRow, 1));
if (string.IsNullOrEmpty(notes))
{
notes = lastNotes;
}
else
{
lastNotes = notes;
}
if (string.IsNullOrEmpty(product) || cves.Count == 0)
{
continue;
}
entries.Add(new OracleAffectedEntry(
product,
string.IsNullOrEmpty(component) ? null : component,
string.IsNullOrEmpty(supportedVersions) ? null : supportedVersions,
string.IsNullOrEmpty(notes) ? null : notes,
cves));
}
}
}
return entries.Count == 0 ? Array.Empty<OracleAffectedEntry>() : entries;
}
private static IReadOnlyList<string> ExtractCveIds(IHtmlDocument document, string content, IReadOnlyList<OracleAffectedEntry> affectedEntries)
{
var cves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(content))
{
foreach (Match match in CveRegex.Matches(content))
{
cves.Add(match.Value.ToUpperInvariant());
}
}
foreach (var entry in affectedEntries)
{
foreach (var cve in entry.CveIds)
{
if (!string.IsNullOrWhiteSpace(cve))
{
cves.Add(cve.ToUpperInvariant());
}
}
}
var bodyText = document.Body?.TextContent;
if (!string.IsNullOrWhiteSpace(bodyText))
{
foreach (Match match in CveRegex.Matches(bodyText))
{
cves.Add(match.Value.ToUpperInvariant());
}
}
return cves.Count == 0
? Array.Empty<string>()
: cves.OrderBy(static id => id, StringComparer.OrdinalIgnoreCase).ToArray();
}
private static IReadOnlyList<OraclePatchDocument> ExtractPatchDocuments(IHtmlDocument document, Uri detailUri)
{
var results = new List<OraclePatchDocument>();
foreach (var table in document.QuerySelectorAll("table"))
{
if (table is not IHtmlTableElement tableElement)
{
continue;
}
if (!TableHasPatchHeader(tableElement))
{
continue;
}
foreach (var body in tableElement.Bodies)
{
foreach (var row in body.Rows)
{
if (row is not IHtmlTableRowElement tableRow || tableRow.Cells.Length < 2)
{
continue;
}
var product = NormalizeCellText(tableRow.Cells[0]?.TextContent);
if (string.IsNullOrEmpty(product))
{
continue;
}
var anchor = tableRow.Cells[1]?.QuerySelector("a");
if (anchor is null)
{
continue;
}
var href = anchor.GetAttribute("href");
if (string.IsNullOrWhiteSpace(href))
{
continue;
}
var decoded = System.Net.WebUtility.HtmlDecode(href) ?? href;
if (!Uri.TryCreate(detailUri, decoded, out var uri) || !uri.IsAbsoluteUri)
{
continue;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var title = NormalizeCellText(anchor.TextContent);
results.Add(new OraclePatchDocument(product, string.IsNullOrEmpty(title) ? null : title, uri.ToString()));
}
}
}
return results.Count == 0 ? Array.Empty<OraclePatchDocument>() : results;
}
private static bool IsRiskMatrixTable(IHtmlTableElement table)
{
var headerText = table.Head?.TextContent;
if (string.IsNullOrWhiteSpace(headerText))
{
return false;
}
return headerText.Contains("CVE ID", StringComparison.OrdinalIgnoreCase)
&& headerText.Contains("Supported Versions", StringComparison.OrdinalIgnoreCase);
}
private static bool TableHasPatchHeader(IHtmlTableElement table)
{
var headerText = table.Head?.TextContent;
if (string.IsNullOrWhiteSpace(headerText))
{
return false;
}
return headerText.Contains("Affected Products and Versions", StringComparison.OrdinalIgnoreCase)
&& headerText.Contains("Patch Availability Document", StringComparison.OrdinalIgnoreCase);
}
private static string? GetCellText(IHtmlTableRowElement row, int index)
{
if (index < 0 || index >= row.Cells.Length)
{
return null;
}
return row.Cells[index]?.TextContent;
}
private static string? GetCellTextFromEnd(IHtmlTableRowElement row, int offsetFromEnd)
{
if (offsetFromEnd <= 0)
{
return null;
}
var index = row.Cells.Length - offsetFromEnd;
return index >= 0 ? row.Cells[index]?.TextContent : null;
}
private static IReadOnlyList<string> ExtractCvesFromText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return Array.Empty<string>();
}
var matches = CveRegex.Matches(text);
if (matches.Count == 0)
{
return Array.Empty<string>();
}
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Success)
{
set.Add(match.Value.ToUpperInvariant());
}
}
return set.Count == 0
? Array.Empty<string>()
: set.OrderBy(static id => id, StringComparer.Ordinal).ToArray();
}
private static string NormalizeCellText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var cleaned = value.Replace('\u00A0', ' ');
cleaned = WhitespaceRegex.Replace(cleaned, " ");
return cleaned.Trim();
}
}

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
internal sealed record OraclePatchDocument(
[property: JsonPropertyName("product")] string Product,
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("url")] string Url);

View File

@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
internal static class OracleJobKinds
{
public const string Fetch = "source:vndr-oracle:fetch";
public const string Parse = "source:vndr-oracle:parse";
public const string Map = "source:vndr-oracle:map";
}
internal sealed class OracleFetchJob : IJob
{
private readonly OracleConnector _connector;
public OracleFetchJob(OracleConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.FetchAsync(context.Services, cancellationToken);
}
internal sealed class OracleParseJob : IJob
{
private readonly OracleConnector _connector;
public OracleParseJob(OracleConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.ParseAsync(context.Services, cancellationToken);
}
internal sealed class OracleMapJob : IJob
{
private readonly OracleConnector _connector;
public OracleMapJob(OracleConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.MapAsync(context.Services, cancellationToken);
}

View File

@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
using StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Concelier.Storage.Mongo.PsirtFlags;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
public sealed class OracleConnector : IFeedConnector
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
private readonly SourceFetchService _fetchService;
private readonly RawDocumentStorage _rawDocumentStorage;
private readonly IDocumentStore _documentStore;
private readonly IDtoStore _dtoStore;
private readonly IAdvisoryStore _advisoryStore;
private readonly IPsirtFlagStore _psirtFlagStore;
private readonly ISourceStateRepository _stateRepository;
private readonly OracleCalendarFetcher _calendarFetcher;
private readonly OracleOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<OracleConnector> _logger;
public OracleConnector(
SourceFetchService fetchService,
RawDocumentStorage rawDocumentStorage,
IDocumentStore documentStore,
IDtoStore dtoStore,
IAdvisoryStore advisoryStore,
IPsirtFlagStore psirtFlagStore,
ISourceStateRepository stateRepository,
OracleCalendarFetcher calendarFetcher,
IOptions<OracleOptions> options,
TimeProvider? timeProvider,
ILogger<OracleConnector> logger)
{
_fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
_dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
_psirtFlagStore = psirtFlagStore ?? throw new ArgumentNullException(nameof(psirtFlagStore));
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
_calendarFetcher = calendarFetcher ?? throw new ArgumentNullException(nameof(calendarFetcher));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
_options.Validate();
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string SourceName => VndrOracleConnectorPlugin.SourceName;
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
var pendingDocuments = cursor.PendingDocuments.ToList();
var pendingMappings = cursor.PendingMappings.ToList();
var fetchCache = new Dictionary<string, OracleFetchCacheEntry>(cursor.FetchCache, StringComparer.OrdinalIgnoreCase);
var touchedResources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var now = _timeProvider.GetUtcNow();
var advisoryUris = await ResolveAdvisoryUrisAsync(cancellationToken).ConfigureAwait(false);
foreach (var uri in advisoryUris)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var cacheKey = uri.AbsoluteUri;
touchedResources.Add(cacheKey);
var advisoryId = DeriveAdvisoryId(uri);
var title = advisoryId.Replace('-', ' ');
var published = now;
var metadata = OracleDocumentMetadata.CreateMetadata(advisoryId, title, published);
var existing = await _documentStore.FindBySourceAndUriAsync(SourceName, uri.ToString(), cancellationToken).ConfigureAwait(false);
var request = new SourceFetchRequest(OracleOptions.HttpClientName, SourceName, uri)
{
Metadata = metadata,
ETag = existing?.Etag,
LastModified = existing?.LastModified,
AcceptHeaders = new[] { "text/html", "application/xhtml+xml", "text/plain;q=0.5" },
};
var result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Document is null)
{
continue;
}
var cacheEntry = OracleFetchCacheEntry.FromDocument(result.Document);
if (existing is not null
&& string.Equals(existing.Status, DocumentStatuses.Mapped, StringComparison.Ordinal)
&& cursor.TryGetFetchCache(cacheKey, out var cached)
&& cached.Matches(result.Document))
{
_logger.LogDebug("Oracle advisory {AdvisoryId} unchanged; skipping parse/map", advisoryId);
await _documentStore.UpdateStatusAsync(result.Document.Id, existing.Status, cancellationToken).ConfigureAwait(false);
pendingDocuments.Remove(result.Document.Id);
pendingMappings.Remove(result.Document.Id);
fetchCache[cacheKey] = cacheEntry;
continue;
}
fetchCache[cacheKey] = cacheEntry;
if (!pendingDocuments.Contains(result.Document.Id))
{
pendingDocuments.Add(result.Document.Id);
}
if (_options.RequestDelay > TimeSpan.Zero)
{
await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Oracle fetch failed for {Uri}", uri);
await _stateRepository.MarkFailureAsync(SourceName, _timeProvider.GetUtcNow(), TimeSpan.FromMinutes(10), ex.Message, cancellationToken).ConfigureAwait(false);
throw;
}
}
if (fetchCache.Count > 0 && touchedResources.Count > 0)
{
var stale = fetchCache.Keys.Where(key => !touchedResources.Contains(key)).ToArray();
foreach (var key in stale)
{
fetchCache.Remove(key);
}
}
var updatedCursor = cursor
.WithPendingDocuments(pendingDocuments)
.WithPendingMappings(pendingMappings)
.WithFetchCache(fetchCache)
.WithLastProcessed(now);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
}
public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
if (cursor.PendingDocuments.Count == 0)
{
return;
}
var pendingDocuments = cursor.PendingDocuments.ToList();
var pendingMappings = cursor.PendingMappings.ToList();
foreach (var documentId in cursor.PendingDocuments)
{
cancellationToken.ThrowIfCancellationRequested();
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
if (document is null)
{
pendingDocuments.Remove(documentId);
pendingMappings.Remove(documentId);
continue;
}
if (!document.GridFsId.HasValue)
{
_logger.LogWarning("Oracle document {DocumentId} missing GridFS payload", document.Id);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
pendingDocuments.Remove(documentId);
pendingMappings.Remove(documentId);
continue;
}
OracleDto dto;
try
{
var metadata = OracleDocumentMetadata.FromDocument(document);
var content = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
var html = System.Text.Encoding.UTF8.GetString(content);
dto = OracleParser.Parse(html, metadata);
}
catch (Exception ex)
{
_logger.LogError(ex, "Oracle parse failed for document {DocumentId}", document.Id);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
pendingDocuments.Remove(documentId);
pendingMappings.Remove(documentId);
continue;
}
if (!OracleDtoValidator.TryNormalize(dto, out var normalized, out var validationError))
{
_logger.LogWarning("Oracle validation failed for document {DocumentId}: {Reason}", document.Id, validationError ?? "unknown");
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
pendingDocuments.Remove(documentId);
pendingMappings.Remove(documentId);
continue;
}
dto = normalized;
var json = JsonSerializer.Serialize(dto, SerializerOptions);
var payload = BsonDocument.Parse(json);
var validatedAt = _timeProvider.GetUtcNow();
var existingDto = await _dtoStore.FindByDocumentIdAsync(document.Id, cancellationToken).ConfigureAwait(false);
var dtoRecord = existingDto is null
? new DtoRecord(Guid.NewGuid(), document.Id, SourceName, "oracle.advisory.v1", payload, validatedAt)
: existingDto with
{
Payload = payload,
SchemaVersion = "oracle.advisory.v1",
ValidatedAt = validatedAt,
};
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
pendingDocuments.Remove(documentId);
if (!pendingMappings.Contains(documentId))
{
pendingMappings.Add(documentId);
}
}
var updatedCursor = cursor
.WithPendingDocuments(pendingDocuments)
.WithPendingMappings(pendingMappings);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
}
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
if (cursor.PendingMappings.Count == 0)
{
return;
}
var pendingMappings = cursor.PendingMappings.ToList();
foreach (var documentId in cursor.PendingMappings)
{
cancellationToken.ThrowIfCancellationRequested();
var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
if (dtoRecord is null || document is null)
{
pendingMappings.Remove(documentId);
continue;
}
OracleDto? dto;
try
{
var json = dtoRecord.Payload.ToJson();
dto = JsonSerializer.Deserialize<OracleDto>(json, SerializerOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Oracle DTO deserialization failed for document {DocumentId}", documentId);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
pendingMappings.Remove(documentId);
continue;
}
if (dto is null)
{
_logger.LogWarning("Oracle DTO payload deserialized as null for document {DocumentId}", documentId);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
pendingMappings.Remove(documentId);
continue;
}
var mappedAt = _timeProvider.GetUtcNow();
var (advisory, flag) = OracleMapper.Map(dto, document, dtoRecord, SourceName, mappedAt);
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
await _psirtFlagStore.UpsertAsync(flag, cancellationToken).ConfigureAwait(false);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
pendingMappings.Remove(documentId);
}
var updatedCursor = cursor.WithPendingMappings(pendingMappings);
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
}
private async Task<OracleCursor> GetCursorAsync(CancellationToken cancellationToken)
{
var record = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
return OracleCursor.FromBson(record?.Cursor);
}
private async Task UpdateCursorAsync(OracleCursor cursor, CancellationToken cancellationToken)
{
var completedAt = _timeProvider.GetUtcNow();
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), completedAt, cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyCollection<Uri>> ResolveAdvisoryUrisAsync(CancellationToken cancellationToken)
{
var uris = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var uri in _options.AdvisoryUris)
{
if (uri is not null)
{
uris.Add(uri.AbsoluteUri);
}
}
var calendarUris = await _calendarFetcher.GetAdvisoryUrisAsync(cancellationToken).ConfigureAwait(false);
foreach (var uri in calendarUris)
{
uris.Add(uri.AbsoluteUri);
}
return uris
.Select(static value => new Uri(value, UriKind.Absolute))
.OrderBy(static value => value.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string DeriveAdvisoryId(Uri uri)
{
var segments = uri.Segments;
if (segments.Length == 0)
{
return uri.AbsoluteUri;
}
var slug = segments[^1].Trim('/');
if (string.IsNullOrWhiteSpace(slug))
{
return uri.AbsoluteUri;
}
return slug.Replace('.', '-');
}
}

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
public sealed class OracleDependencyInjectionRoutine : IDependencyInjectionRoutine
{
private const string ConfigurationSection = "concelier:sources:oracle";
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddOracleConnector(options =>
{
configuration.GetSection(ConfigurationSection).Bind(options);
options.Validate();
});
services.AddTransient<OracleFetchJob>();
services.AddTransient<OracleParseJob>();
services.AddTransient<OracleMapJob>();
services.PostConfigure<JobSchedulerOptions>(options =>
{
EnsureJob(options, OracleJobKinds.Fetch, typeof(OracleFetchJob));
EnsureJob(options, OracleJobKinds.Parse, typeof(OracleParseJob));
EnsureJob(options, OracleJobKinds.Map, typeof(OracleMapJob));
});
return services;
}
private static void EnsureJob(JobSchedulerOptions options, string kind, Type jobType)
{
if (options.Definitions.ContainsKey(kind))
{
return;
}
options.Definitions[kind] = new JobDefinition(
kind,
jobType,
options.DefaultTimeout,
options.DefaultLeaseDuration,
CronExpression: null,
Enabled: true);
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
using StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
public static class OracleServiceCollectionExtensions
{
public static IServiceCollection AddOracleConnector(this IServiceCollection services, Action<OracleOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<OracleOptions>()
.Configure(configure)
.PostConfigure(static opts => opts.Validate());
services.AddSourceHttpClient(OracleOptions.HttpClientName, static (sp, clientOptions) =>
{
var options = sp.GetRequiredService<IOptions<OracleOptions>>().Value;
clientOptions.Timeout = TimeSpan.FromSeconds(30);
clientOptions.UserAgent = "StellaOps.Concelier.Oracle/1.0";
clientOptions.AllowedHosts.Clear();
foreach (var uri in options.AdvisoryUris)
{
clientOptions.AllowedHosts.Add(uri.Host);
}
foreach (var uri in options.CalendarUris)
{
clientOptions.AllowedHosts.Add(uri.Host);
}
});
services.AddTransient<OracleCalendarFetcher>();
services.AddTransient<OracleConnector>();
return services;
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.Vndr.Oracle.Tests")]

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|Oracle options & HttpClient configuration|BE-Conn-Oracle|Source.Common|**DONE** `AddOracleConnector` wires options and allowlisted HttpClient.|
|CPU calendar plus advisory fetchers|BE-Conn-Oracle|Source.Common|**DONE** resume/backfill scenario covered with new integration test and fetch cache pruning verified.|
|Extractor for products/components/fix levels|BE-Conn-Oracle|Source.Common|**DONE** HTML risk matrices parsed into vendor packages with fix heuristics and normalized versions.|
|DTO schema and validation|BE-Conn-Oracle, QA|Source.Common|**DONE** `OracleDtoValidator` enforces required fields and quarantines malformed payloads.|
|Canonical mapping with psirt_flags|BE-Conn-Oracle|Models|**DONE** mapper now emits CVE aliases, patch references, and vendor affected packages under psirt flag provenance.|
|SourceState and dedupe|BE-Conn-Oracle|Storage.Mongo|**DONE** cursor fetch cache tracks SHA/ETag to skip unchanged advisories and clear pending work.|
|Golden fixtures and precedence tests (later with merge)|QA|Source.Vndr.Oracle|**DONE** snapshot fixtures and psirt flag assertions added in `OracleConnectorTests`.|
|Dependency injection routine & job registration|BE-Conn-Oracle|Core|**DONE** `OracleDependencyInjectionRoutine` registers connector and fetch/parse/map jobs with scheduler defaults.|
|Implement Oracle connector skeleton|BE-Conn-Oracle|Source.Common|**DONE** fetch/parse/map pipeline persists documents, DTOs, advisories, psirt flags.|
|Range primitives & provenance backfill|BE-Conn-Oracle|Models, Storage.Mongo|**DONE** vendor primitives emitted (extensions + fix parsing), provenance tagging/logging extended, snapshots refreshed.|

View File

@@ -0,0 +1,21 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
public sealed class VndrOracleConnectorPlugin : IConnectorPlugin
{
public const string SourceName = "vndr-oracle";
public string Name => SourceName;
public bool IsAvailable(IServiceProvider services)
=> services.GetService<OracleConnector>() is not null;
public IFeedConnector Create(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return services.GetRequiredService<OracleConnector>();
}
}