Rename Concelier Source modules to Connector
This commit is contained in:
27
src/StellaOps.Concelier.Connector.Vndr.Oracle/AGENTS.md
Normal file
27
src/StellaOps.Concelier.Connector.Vndr.Oracle/AGENTS.md
Normal 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.
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
46
src/StellaOps.Concelier.Connector.Vndr.Oracle/Jobs.cs
Normal file
46
src/StellaOps.Concelier.Connector.Vndr.Oracle/Jobs.cs
Normal 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);
|
||||
}
|
||||
366
src/StellaOps.Concelier.Connector.Vndr.Oracle/OracleConnector.cs
Normal file
366
src/StellaOps.Concelier.Connector.Vndr.Oracle/OracleConnector.cs
Normal 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('.', '-');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.Vndr.Oracle.Tests")]
|
||||
@@ -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>
|
||||
|
||||
13
src/StellaOps.Concelier.Connector.Vndr.Oracle/TASKS.md
Normal file
13
src/StellaOps.Concelier.Connector.Vndr.Oracle/TASKS.md
Normal 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.|
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user