up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -8,7 +8,7 @@ using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Json.Schema;
|
||||
using StellaOps.Concelier.Bson;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Common.Json;
|
||||
@@ -495,7 +495,7 @@ public sealed class AdobeConnector : IFeedConnector
|
||||
using var jsonDocument = JsonDocument.Parse(json);
|
||||
_schemaValidator.Validate(jsonDocument, Schema, metadata.AdvisoryId);
|
||||
|
||||
var payload = StellaOps.Concelier.Bson.BsonDocument.Parse(json);
|
||||
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(json);
|
||||
var dtoRecord = new DtoRecord(
|
||||
Guid.NewGuid(),
|
||||
document.Id,
|
||||
@@ -546,9 +546,9 @@ public sealed class AdobeConnector : IFeedConnector
|
||||
AdobeBulletinDto? dto;
|
||||
try
|
||||
{
|
||||
var json = dtoRecord.Payload.ToJson(new StellaOps.Concelier.Bson.IO.JsonWriterSettings
|
||||
var json = dtoRecord.Payload.ToJson(new StellaOps.Concelier.Documents.IO.JsonWriterSettings
|
||||
{
|
||||
OutputMode = StellaOps.Concelier.Bson.IO.JsonOutputMode.RelaxedExtendedJson,
|
||||
OutputMode = StellaOps.Concelier.Documents.IO.JsonOutputMode.RelaxedExtendedJson,
|
||||
});
|
||||
|
||||
dto = JsonSerializer.Deserialize<AdobeBulletinDto>(json, SerializerOptions);
|
||||
@@ -609,13 +609,13 @@ public sealed class AdobeConnector : IFeedConnector
|
||||
private async Task<AdobeCursor> GetCursorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
|
||||
return AdobeCursor.FromBsonDocument(record?.Cursor);
|
||||
return AdobeCursor.FromDocumentObject(record?.Cursor);
|
||||
}
|
||||
|
||||
private async Task UpdateCursorAsync(AdobeCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var updatedAt = _timeProvider.GetUtcNow();
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), updatedAt, cancellationToken).ConfigureAwait(false);
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToDocumentObject(), updatedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Advisory BuildAdvisory(AdobeBulletinDto dto, DateTimeOffset recordedAt)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe;
|
||||
|
||||
public sealed class VndrAdobeConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "vndr-adobe";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
=> services.GetService<AdobeConnector>() is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.GetRequiredService<AdobeConnector>();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe;
|
||||
|
||||
public sealed class VndrAdobeConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "vndr-adobe";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
=> services.GetService<AdobeConnector>() is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.GetRequiredService<AdobeConnector>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe;
|
||||
|
||||
public sealed class AdobeDiagnostics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Concelier.Connector.Vndr.Adobe";
|
||||
private static readonly string MeterVersion = "1.0.0";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _fetchAttempts;
|
||||
private readonly Counter<long> _fetchDocuments;
|
||||
private readonly Counter<long> _fetchFailures;
|
||||
private readonly Counter<long> _fetchUnchanged;
|
||||
|
||||
public AdobeDiagnostics()
|
||||
{
|
||||
_meter = new Meter(MeterName, MeterVersion);
|
||||
_fetchAttempts = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.attempts",
|
||||
unit: "operations",
|
||||
description: "Number of Adobe index fetch operations.");
|
||||
_fetchDocuments = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.documents",
|
||||
unit: "documents",
|
||||
description: "Number of Adobe advisory documents captured.");
|
||||
_fetchFailures = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.failures",
|
||||
unit: "operations",
|
||||
description: "Number of Adobe fetch failures.");
|
||||
_fetchUnchanged = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.unchanged",
|
||||
unit: "documents",
|
||||
description: "Number of Adobe advisories skipped due to unchanged content.");
|
||||
}
|
||||
|
||||
public Meter Meter => _meter;
|
||||
|
||||
public void FetchAttempt() => _fetchAttempts.Add(1);
|
||||
|
||||
public void FetchDocument() => _fetchDocuments.Add(1);
|
||||
|
||||
public void FetchFailure() => _fetchFailures.Add(1);
|
||||
|
||||
public void FetchUnchanged() => _fetchUnchanged.Add(1);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe;
|
||||
|
||||
public sealed class AdobeDiagnostics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Concelier.Connector.Vndr.Adobe";
|
||||
private static readonly string MeterVersion = "1.0.0";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _fetchAttempts;
|
||||
private readonly Counter<long> _fetchDocuments;
|
||||
private readonly Counter<long> _fetchFailures;
|
||||
private readonly Counter<long> _fetchUnchanged;
|
||||
|
||||
public AdobeDiagnostics()
|
||||
{
|
||||
_meter = new Meter(MeterName, MeterVersion);
|
||||
_fetchAttempts = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.attempts",
|
||||
unit: "operations",
|
||||
description: "Number of Adobe index fetch operations.");
|
||||
_fetchDocuments = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.documents",
|
||||
unit: "documents",
|
||||
description: "Number of Adobe advisory documents captured.");
|
||||
_fetchFailures = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.failures",
|
||||
unit: "operations",
|
||||
description: "Number of Adobe fetch failures.");
|
||||
_fetchUnchanged = _meter.CreateCounter<long>(
|
||||
name: "adobe.fetch.unchanged",
|
||||
unit: "documents",
|
||||
description: "Number of Adobe advisories skipped due to unchanged content.");
|
||||
}
|
||||
|
||||
public Meter Meter => _meter;
|
||||
|
||||
public void FetchAttempt() => _fetchAttempts.Add(1);
|
||||
|
||||
public void FetchDocument() => _fetchDocuments.Add(1);
|
||||
|
||||
public void FetchFailure() => _fetchFailures.Add(1);
|
||||
|
||||
public void FetchUnchanged() => _fetchUnchanged.Add(1);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Vndr.Adobe.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe;
|
||||
|
||||
public static class AdobeServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdobeConnector(this IServiceCollection services, Action<AdobeOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<AdobeOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static opts => opts.Validate());
|
||||
|
||||
services.AddSourceHttpClient(AdobeOptions.HttpClientName, static (sp, options) =>
|
||||
{
|
||||
var adobeOptions = sp.GetRequiredService<IOptions<AdobeOptions>>().Value;
|
||||
options.BaseAddress = adobeOptions.IndexUri;
|
||||
options.UserAgent = "StellaOps.Concelier.VndrAdobe/1.0";
|
||||
options.Timeout = TimeSpan.FromSeconds(20);
|
||||
options.AllowedHosts.Clear();
|
||||
options.AllowedHosts.Add(adobeOptions.IndexUri.Host);
|
||||
foreach (var additional in adobeOptions.AdditionalIndexUris)
|
||||
{
|
||||
options.AllowedHosts.Add(additional.Host);
|
||||
}
|
||||
});
|
||||
|
||||
services.TryAddSingleton<AdobeDiagnostics>();
|
||||
services.AddTransient<AdobeConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Vndr.Adobe.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe;
|
||||
|
||||
public static class AdobeServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdobeConnector(this IServiceCollection services, Action<AdobeOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<AdobeOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static opts => opts.Validate());
|
||||
|
||||
services.AddSourceHttpClient(AdobeOptions.HttpClientName, static (sp, options) =>
|
||||
{
|
||||
var adobeOptions = sp.GetRequiredService<IOptions<AdobeOptions>>().Value;
|
||||
options.BaseAddress = adobeOptions.IndexUri;
|
||||
options.UserAgent = "StellaOps.Concelier.VndrAdobe/1.0";
|
||||
options.Timeout = TimeSpan.FromSeconds(20);
|
||||
options.AllowedHosts.Clear();
|
||||
options.AllowedHosts.Add(adobeOptions.IndexUri.Host);
|
||||
foreach (var additional in adobeOptions.AdditionalIndexUris)
|
||||
{
|
||||
options.AllowedHosts.Add(additional.Host);
|
||||
}
|
||||
});
|
||||
|
||||
services.TryAddSingleton<AdobeDiagnostics>();
|
||||
services.AddTransient<AdobeConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Configuration;
|
||||
|
||||
public sealed class AdobeOptions
|
||||
{
|
||||
public const string HttpClientName = "source-vndr-adobe";
|
||||
|
||||
public Uri IndexUri { get; set; } = new("https://helpx.adobe.com/security/security-bulletin.html");
|
||||
|
||||
public List<Uri> AdditionalIndexUris { get; } = new();
|
||||
|
||||
public TimeSpan InitialBackfill { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
public TimeSpan WindowOverlap { get; set; } = TimeSpan.FromDays(3);
|
||||
|
||||
public int MaxEntriesPerFetch { get; set; } = 100;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (IndexUri is null || !IndexUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("IndexUri must be an absolute URI.", nameof(IndexUri));
|
||||
}
|
||||
|
||||
foreach (var uri in AdditionalIndexUris)
|
||||
{
|
||||
if (uri is null || !uri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("Additional index URIs must be absolute.", nameof(AdditionalIndexUris));
|
||||
}
|
||||
}
|
||||
|
||||
if (InitialBackfill <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException("InitialBackfill must be positive.", nameof(InitialBackfill));
|
||||
}
|
||||
|
||||
if (WindowOverlap < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException("WindowOverlap cannot be negative.", nameof(WindowOverlap));
|
||||
}
|
||||
|
||||
if (MaxEntriesPerFetch <= 0)
|
||||
{
|
||||
throw new ArgumentException("MaxEntriesPerFetch must be positive.", nameof(MaxEntriesPerFetch));
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Configuration;
|
||||
|
||||
public sealed class AdobeOptions
|
||||
{
|
||||
public const string HttpClientName = "source-vndr-adobe";
|
||||
|
||||
public Uri IndexUri { get; set; } = new("https://helpx.adobe.com/security/security-bulletin.html");
|
||||
|
||||
public List<Uri> AdditionalIndexUris { get; } = new();
|
||||
|
||||
public TimeSpan InitialBackfill { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
public TimeSpan WindowOverlap { get; set; } = TimeSpan.FromDays(3);
|
||||
|
||||
public int MaxEntriesPerFetch { get; set; } = 100;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (IndexUri is null || !IndexUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("IndexUri must be an absolute URI.", nameof(IndexUri));
|
||||
}
|
||||
|
||||
foreach (var uri in AdditionalIndexUris)
|
||||
{
|
||||
if (uri is null || !uri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("Additional index URIs must be absolute.", nameof(AdditionalIndexUris));
|
||||
}
|
||||
}
|
||||
|
||||
if (InitialBackfill <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException("InitialBackfill must be positive.", nameof(InitialBackfill));
|
||||
}
|
||||
|
||||
if (WindowOverlap < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException("WindowOverlap cannot be negative.", nameof(WindowOverlap));
|
||||
}
|
||||
|
||||
if (MaxEntriesPerFetch <= 0)
|
||||
{
|
||||
throw new ArgumentException("MaxEntriesPerFetch must be positive.", nameof(MaxEntriesPerFetch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeBulletinDto(
|
||||
string AdvisoryId,
|
||||
string Title,
|
||||
DateTimeOffset Published,
|
||||
IReadOnlyList<AdobeProductEntry> Products,
|
||||
IReadOnlyList<string> Cves,
|
||||
string DetailUrl,
|
||||
string? Summary)
|
||||
{
|
||||
public static AdobeBulletinDto Create(
|
||||
string advisoryId,
|
||||
string title,
|
||||
DateTimeOffset published,
|
||||
IEnumerable<AdobeProductEntry>? products,
|
||||
IEnumerable<string>? cves,
|
||||
Uri detailUri,
|
||||
string? summary)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(title);
|
||||
ArgumentNullException.ThrowIfNull(detailUri);
|
||||
|
||||
var productList = products?
|
||||
.Where(static p => !string.IsNullOrWhiteSpace(p.Product))
|
||||
.Select(static p => p with { Product = p.Product.Trim() })
|
||||
.Distinct(AdobeProductEntryComparer.Instance)
|
||||
.OrderBy(static p => p.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static p => p.Platform, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static p => p.Track, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
?? new List<AdobeProductEntry>();
|
||||
|
||||
var cveList = cves?.Where(static c => !string.IsNullOrWhiteSpace(c))
|
||||
.Select(static c => c.Trim().ToUpperInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static c => c, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<string>();
|
||||
|
||||
return new AdobeBulletinDto(
|
||||
advisoryId.ToUpperInvariant(),
|
||||
title.Trim(),
|
||||
published.ToUniversalTime(),
|
||||
productList,
|
||||
cveList,
|
||||
detailUri.ToString(),
|
||||
string.IsNullOrWhiteSpace(summary) ? null : summary.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AdobeProductEntry(
|
||||
string Product,
|
||||
string Track,
|
||||
string Platform,
|
||||
string? AffectedVersion,
|
||||
string? UpdatedVersion,
|
||||
string? Priority,
|
||||
string? Availability);
|
||||
|
||||
internal sealed class AdobeProductEntryComparer : IEqualityComparer<AdobeProductEntry>
|
||||
{
|
||||
public static AdobeProductEntryComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(AdobeProductEntry? x, AdobeProductEntry? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.Product, y.Product, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Track, y.Track, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Platform, y.Platform, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.AffectedVersion, y.AffectedVersion, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.UpdatedVersion, y.UpdatedVersion, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Priority, y.Priority, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Availability, y.Availability, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(AdobeProductEntry obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Product, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Track, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Platform, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.AffectedVersion, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.UpdatedVersion, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Priority, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Availability, StringComparer.OrdinalIgnoreCase);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeBulletinDto(
|
||||
string AdvisoryId,
|
||||
string Title,
|
||||
DateTimeOffset Published,
|
||||
IReadOnlyList<AdobeProductEntry> Products,
|
||||
IReadOnlyList<string> Cves,
|
||||
string DetailUrl,
|
||||
string? Summary)
|
||||
{
|
||||
public static AdobeBulletinDto Create(
|
||||
string advisoryId,
|
||||
string title,
|
||||
DateTimeOffset published,
|
||||
IEnumerable<AdobeProductEntry>? products,
|
||||
IEnumerable<string>? cves,
|
||||
Uri detailUri,
|
||||
string? summary)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(advisoryId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(title);
|
||||
ArgumentNullException.ThrowIfNull(detailUri);
|
||||
|
||||
var productList = products?
|
||||
.Where(static p => !string.IsNullOrWhiteSpace(p.Product))
|
||||
.Select(static p => p with { Product = p.Product.Trim() })
|
||||
.Distinct(AdobeProductEntryComparer.Instance)
|
||||
.OrderBy(static p => p.Product, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static p => p.Platform, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static p => p.Track, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
?? new List<AdobeProductEntry>();
|
||||
|
||||
var cveList = cves?.Where(static c => !string.IsNullOrWhiteSpace(c))
|
||||
.Select(static c => c.Trim().ToUpperInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static c => c, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<string>();
|
||||
|
||||
return new AdobeBulletinDto(
|
||||
advisoryId.ToUpperInvariant(),
|
||||
title.Trim(),
|
||||
published.ToUniversalTime(),
|
||||
productList,
|
||||
cveList,
|
||||
detailUri.ToString(),
|
||||
string.IsNullOrWhiteSpace(summary) ? null : summary.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AdobeProductEntry(
|
||||
string Product,
|
||||
string Track,
|
||||
string Platform,
|
||||
string? AffectedVersion,
|
||||
string? UpdatedVersion,
|
||||
string? Priority,
|
||||
string? Availability);
|
||||
|
||||
internal sealed class AdobeProductEntryComparer : IEqualityComparer<AdobeProductEntry>
|
||||
{
|
||||
public static AdobeProductEntryComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(AdobeProductEntry? x, AdobeProductEntry? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.Product, y.Product, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Track, y.Track, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Platform, y.Platform, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.AffectedVersion, y.AffectedVersion, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.UpdatedVersion, y.UpdatedVersion, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Priority, y.Priority, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Availability, y.Availability, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(AdobeProductEntry obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Product, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Track, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Platform, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.AffectedVersion, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.UpdatedVersion, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Priority, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Availability, StringComparer.OrdinalIgnoreCase);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeCursor(
|
||||
DateTimeOffset? LastPublished,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
IReadOnlyDictionary<string, AdobeFetchCacheEntry>? FetchCache)
|
||||
{
|
||||
public static AdobeCursor Empty { get; } = new(null, Array.Empty<Guid>(), Array.Empty<Guid>(), null);
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument();
|
||||
if (LastPublished.HasValue)
|
||||
{
|
||||
document["lastPublished"] = LastPublished.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
document["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString()));
|
||||
document["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString()));
|
||||
|
||||
if (FetchCache is { Count: > 0 })
|
||||
{
|
||||
var cacheDocument = new BsonDocument();
|
||||
foreach (var (key, entry) in FetchCache)
|
||||
{
|
||||
cacheDocument[key] = entry.ToBson();
|
||||
}
|
||||
|
||||
document["fetchCache"] = cacheDocument;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static AdobeCursor FromBsonDocument(BsonDocument? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
DateTimeOffset? lastPublished = null;
|
||||
if (document.TryGetValue("lastPublished", out var lastPublishedValue))
|
||||
{
|
||||
lastPublished = ReadDateTime(lastPublishedValue);
|
||||
}
|
||||
|
||||
var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
|
||||
var pendingMappings = ReadGuidArray(document, "pendingMappings");
|
||||
var fetchCache = ReadFetchCache(document);
|
||||
|
||||
return new AdobeCursor(lastPublished, pendingDocuments, pendingMappings, fetchCache);
|
||||
}
|
||||
|
||||
public AdobeCursor WithLastPublished(DateTimeOffset? value)
|
||||
=> this with { LastPublished = value?.ToUniversalTime() };
|
||||
|
||||
public AdobeCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() };
|
||||
|
||||
public AdobeCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() };
|
||||
|
||||
public AdobeCursor WithFetchCache(IDictionary<string, AdobeFetchCacheEntry>? cache)
|
||||
{
|
||||
if (cache is null)
|
||||
{
|
||||
return this with { FetchCache = null };
|
||||
}
|
||||
|
||||
var target = new Dictionary<string, AdobeFetchCacheEntry>(cache, StringComparer.Ordinal);
|
||||
return this with { FetchCache = target };
|
||||
}
|
||||
|
||||
public bool TryGetFetchCache(string key, out AdobeFetchCacheEntry entry)
|
||||
{
|
||||
var cache = FetchCache;
|
||||
if (cache is null)
|
||||
{
|
||||
entry = AdobeFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cache.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
entry = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry = AdobeFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadDateTime(BsonValue value)
|
||||
{
|
||||
return value.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
|
||||
{
|
||||
return Array.Empty<Guid>();
|
||||
}
|
||||
|
||||
var list = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
list.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, AdobeFetchCacheEntry>? ReadFetchCache(BsonDocument document)
|
||||
{
|
||||
if (!document.TryGetValue("fetchCache", out var value) || value is not BsonDocument cacheDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dictionary = new Dictionary<string, AdobeFetchCacheEntry>(StringComparer.Ordinal);
|
||||
foreach (var element in cacheDocument.Elements)
|
||||
{
|
||||
if (element.Value is BsonDocument entryDocument)
|
||||
{
|
||||
dictionary[element.Name] = AdobeFetchCacheEntry.FromBson(entryDocument);
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AdobeFetchCacheEntry(string Sha256)
|
||||
{
|
||||
public static AdobeFetchCacheEntry Empty { get; } = new(string.Empty);
|
||||
|
||||
public BsonDocument ToBson()
|
||||
{
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["sha256"] = Sha256,
|
||||
};
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static AdobeFetchCacheEntry FromBson(BsonDocument document)
|
||||
{
|
||||
var sha = document.TryGetValue("sha256", out var shaValue) ? shaValue.AsString : string.Empty;
|
||||
return new AdobeFetchCacheEntry(sha);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeCursor(
|
||||
DateTimeOffset? LastPublished,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
IReadOnlyDictionary<string, AdobeFetchCacheEntry>? FetchCache)
|
||||
{
|
||||
public static AdobeCursor Empty { get; } = new(null, Array.Empty<Guid>(), Array.Empty<Guid>(), null);
|
||||
|
||||
public DocumentObject ToDocumentObject()
|
||||
{
|
||||
var document = new DocumentObject();
|
||||
if (LastPublished.HasValue)
|
||||
{
|
||||
document["lastPublished"] = LastPublished.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
document["pendingDocuments"] = new DocumentArray(PendingDocuments.Select(id => id.ToString()));
|
||||
document["pendingMappings"] = new DocumentArray(PendingMappings.Select(id => id.ToString()));
|
||||
|
||||
if (FetchCache is { Count: > 0 })
|
||||
{
|
||||
var cacheDocument = new DocumentObject();
|
||||
foreach (var (key, entry) in FetchCache)
|
||||
{
|
||||
cacheDocument[key] = entry.ToBson();
|
||||
}
|
||||
|
||||
document["fetchCache"] = cacheDocument;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static AdobeCursor FromDocumentObject(DocumentObject? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
DateTimeOffset? lastPublished = null;
|
||||
if (document.TryGetValue("lastPublished", out var lastPublishedValue))
|
||||
{
|
||||
lastPublished = ReadDateTime(lastPublishedValue);
|
||||
}
|
||||
|
||||
var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
|
||||
var pendingMappings = ReadGuidArray(document, "pendingMappings");
|
||||
var fetchCache = ReadFetchCache(document);
|
||||
|
||||
return new AdobeCursor(lastPublished, pendingDocuments, pendingMappings, fetchCache);
|
||||
}
|
||||
|
||||
public AdobeCursor WithLastPublished(DateTimeOffset? value)
|
||||
=> this with { LastPublished = value?.ToUniversalTime() };
|
||||
|
||||
public AdobeCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() };
|
||||
|
||||
public AdobeCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() };
|
||||
|
||||
public AdobeCursor WithFetchCache(IDictionary<string, AdobeFetchCacheEntry>? cache)
|
||||
{
|
||||
if (cache is null)
|
||||
{
|
||||
return this with { FetchCache = null };
|
||||
}
|
||||
|
||||
var target = new Dictionary<string, AdobeFetchCacheEntry>(cache, StringComparer.Ordinal);
|
||||
return this with { FetchCache = target };
|
||||
}
|
||||
|
||||
public bool TryGetFetchCache(string key, out AdobeFetchCacheEntry entry)
|
||||
{
|
||||
var cache = FetchCache;
|
||||
if (cache is null)
|
||||
{
|
||||
entry = AdobeFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cache.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
entry = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry = AdobeFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadDateTime(DocumentValue value)
|
||||
{
|
||||
return value.DocumentType switch
|
||||
{
|
||||
DocumentType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
|
||||
DocumentType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(DocumentObject document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not DocumentArray array)
|
||||
{
|
||||
return Array.Empty<Guid>();
|
||||
}
|
||||
|
||||
var list = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
list.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, AdobeFetchCacheEntry>? ReadFetchCache(DocumentObject document)
|
||||
{
|
||||
if (!document.TryGetValue("fetchCache", out var value) || value is not DocumentObject cacheDocument)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dictionary = new Dictionary<string, AdobeFetchCacheEntry>(StringComparer.Ordinal);
|
||||
foreach (var element in cacheDocument.Elements)
|
||||
{
|
||||
if (element.Value is DocumentObject entryDocument)
|
||||
{
|
||||
dictionary[element.Name] = AdobeFetchCacheEntry.FromBson(entryDocument);
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AdobeFetchCacheEntry(string Sha256)
|
||||
{
|
||||
public static AdobeFetchCacheEntry Empty { get; } = new(string.Empty);
|
||||
|
||||
public DocumentObject ToBson()
|
||||
{
|
||||
var document = new DocumentObject
|
||||
{
|
||||
["sha256"] = Sha256,
|
||||
};
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static AdobeFetchCacheEntry FromBson(DocumentObject document)
|
||||
{
|
||||
var sha = document.TryGetValue("sha256", out var shaValue) ? shaValue.AsString : string.Empty;
|
||||
return new AdobeFetchCacheEntry(sha);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +1,405 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal static class AdobeDetailParser
|
||||
{
|
||||
private static readonly HtmlParser Parser = new();
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{4,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] DateMarkers = { "date published", "release date", "published" };
|
||||
|
||||
public static AdobeBulletinDto Parse(string html, AdobeDocumentMetadata metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(html);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
using var document = Parser.ParseDocument(html);
|
||||
var title = metadata.Title ?? document.QuerySelector("h1")?.TextContent?.Trim() ?? metadata.AdvisoryId;
|
||||
var summary = document.QuerySelector("p")?.TextContent?.Trim();
|
||||
|
||||
var published = metadata.PublishedUtc ?? TryExtractPublished(document) ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var cves = ExtractCves(document.Body?.TextContent ?? string.Empty);
|
||||
var products = ExtractProductEntries(title, document);
|
||||
|
||||
return AdobeBulletinDto.Create(
|
||||
metadata.AdvisoryId,
|
||||
title,
|
||||
published,
|
||||
products,
|
||||
cves,
|
||||
metadata.DetailUri,
|
||||
summary);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCves(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in CveRegex.Matches(text))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(match.Value))
|
||||
{
|
||||
set.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return set.Count == 0 ? Array.Empty<string>() : set.OrderBy(static cve => cve, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdobeProductEntry> ExtractProductEntries(string title, IDocument document)
|
||||
{
|
||||
var builders = new Dictionary<AdobeProductKey, AdobeProductEntryBuilder>(AdobeProductKeyComparer.Instance);
|
||||
|
||||
foreach (var builder in ParseAffectedTable(document))
|
||||
{
|
||||
builders[builder.Key] = builder;
|
||||
}
|
||||
|
||||
foreach (var updated in ParseUpdatedTable(document))
|
||||
{
|
||||
if (builders.TryGetValue(updated.Key, out var builder))
|
||||
{
|
||||
builder.UpdatedVersion ??= updated.UpdatedVersion;
|
||||
builder.Priority ??= updated.Priority;
|
||||
builder.Availability ??= updated.Availability;
|
||||
}
|
||||
else
|
||||
{
|
||||
builders[updated.Key] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
if (builders.Count == 0 && !string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
var fallback = new AdobeProductEntryBuilder(
|
||||
NormalizeWhitespace(title),
|
||||
string.Empty,
|
||||
string.Empty)
|
||||
{
|
||||
AffectedVersion = null,
|
||||
UpdatedVersion = null,
|
||||
Priority = null,
|
||||
Availability = null
|
||||
};
|
||||
|
||||
builders[fallback.Key] = fallback;
|
||||
}
|
||||
|
||||
return builders.Values
|
||||
.Select(static builder => builder.ToEntry())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<AdobeProductEntryBuilder> ParseAffectedTable(IDocument document)
|
||||
{
|
||||
var table = FindTableByHeader(document, "Affected Versions");
|
||||
if (table is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var row in table.Rows.Skip(1))
|
||||
{
|
||||
var cells = row.Cells;
|
||||
if (cells.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = NormalizeWhitespace(cells[0]?.TextContent);
|
||||
var track = NormalizeWhitespace(cells.ElementAtOrDefault(1)?.TextContent);
|
||||
var platformText = NormalizeWhitespace(cells.ElementAtOrDefault(3)?.TextContent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var affectedCell = cells[2];
|
||||
foreach (var line in ExtractLines(affectedCell))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (platform, versionText) = SplitPlatformLine(line, platformText);
|
||||
var builder = new AdobeProductEntryBuilder(product, track, platform)
|
||||
{
|
||||
AffectedVersion = versionText
|
||||
};
|
||||
|
||||
yield return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdobeProductEntryBuilder> ParseUpdatedTable(IDocument document)
|
||||
{
|
||||
var table = FindTableByHeader(document, "Updated Versions");
|
||||
if (table is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var row in table.Rows.Skip(1))
|
||||
{
|
||||
var cells = row.Cells;
|
||||
if (cells.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = NormalizeWhitespace(cells[0]?.TextContent);
|
||||
var track = NormalizeWhitespace(cells.ElementAtOrDefault(1)?.TextContent);
|
||||
var platformText = NormalizeWhitespace(cells.ElementAtOrDefault(3)?.TextContent);
|
||||
var priority = NormalizeWhitespace(cells.ElementAtOrDefault(4)?.TextContent);
|
||||
var availability = NormalizeWhitespace(cells.ElementAtOrDefault(5)?.TextContent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var updatedCell = cells[2];
|
||||
var lines = ExtractLines(updatedCell);
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
lines.Add(updatedCell.TextContent ?? string.Empty);
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (platform, versionText) = SplitPlatformLine(line, platformText);
|
||||
var builder = new AdobeProductEntryBuilder(product, track, platform)
|
||||
{
|
||||
UpdatedVersion = versionText,
|
||||
Priority = priority,
|
||||
Availability = availability
|
||||
};
|
||||
|
||||
yield return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IHtmlTableElement? FindTableByHeader(IDocument document, string headerText)
|
||||
{
|
||||
return document
|
||||
.QuerySelectorAll("table")
|
||||
.OfType<IHtmlTableElement>()
|
||||
.FirstOrDefault(table => table.TextContent.Contains(headerText, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static List<string> ExtractLines(IElement? cell)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (cell is null)
|
||||
{
|
||||
return lines;
|
||||
}
|
||||
|
||||
var paragraphs = cell.QuerySelectorAll("p").Select(static p => p.TextContent).ToArray();
|
||||
if (paragraphs.Length > 0)
|
||||
{
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
var normalized = NormalizeWhitespace(paragraph);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
lines.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
var items = cell.QuerySelectorAll("li").Select(static li => li.TextContent).ToArray();
|
||||
if (items.Length > 0)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var normalized = NormalizeWhitespace(item);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
lines.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
var raw = NormalizeWhitespace(cell.TextContent);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
lines.AddRange(raw.Split(new[] { '\n' }, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static (string Platform, string? Version) SplitPlatformLine(string line, string? fallbackPlatform)
|
||||
{
|
||||
var separatorIndex = line.IndexOf('-', StringComparison.Ordinal);
|
||||
if (separatorIndex > 0 && separatorIndex < line.Length - 1)
|
||||
{
|
||||
var prefix = line[..separatorIndex].Trim();
|
||||
var versionText = line[(separatorIndex + 1)..].Trim();
|
||||
return (NormalizePlatform(prefix) ?? NormalizePlatform(fallbackPlatform) ?? fallbackPlatform ?? string.Empty, versionText);
|
||||
}
|
||||
|
||||
return (NormalizePlatform(fallbackPlatform) ?? fallbackPlatform ?? string.Empty, line.Trim());
|
||||
}
|
||||
|
||||
private static string? NormalizePlatform(string? platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = platform.Trim();
|
||||
return trimmed.ToLowerInvariant() switch
|
||||
{
|
||||
"win" or "windows" => "Windows",
|
||||
"mac" or "macos" or "mac os" => "macOS",
|
||||
"windows & macos" or "windows & macos" => "Windows & macOS",
|
||||
_ => trimmed
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryExtractPublished(IDocument document)
|
||||
{
|
||||
var candidates = new List<string?>();
|
||||
candidates.Add(document.QuerySelector("time")?.GetAttribute("datetime"));
|
||||
candidates.Add(document.QuerySelector("time")?.TextContent);
|
||||
|
||||
foreach (var marker in DateMarkers)
|
||||
{
|
||||
var element = document.All.FirstOrDefault(node => node.TextContent.Contains(marker, StringComparison.OrdinalIgnoreCase));
|
||||
if (element is not null)
|
||||
{
|
||||
candidates.Add(element.TextContent);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (TryParseDate(candidate, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTimeOffset result)
|
||||
{
|
||||
result = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result))
|
||||
{
|
||||
result = result.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
{
|
||||
result = new DateTimeOffset(date, TimeSpan.Zero).ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeWhitespace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sanitized = value ?? string.Empty;
|
||||
return string.Join(" ", sanitized.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private sealed record AdobeProductKey(string Product, string Track, string Platform);
|
||||
|
||||
private sealed class AdobeProductKeyComparer : IEqualityComparer<AdobeProductKey>
|
||||
{
|
||||
public static AdobeProductKeyComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(AdobeProductKey? x, AdobeProductKey? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.Product, y.Product, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Track, y.Track, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Platform, y.Platform, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(AdobeProductKey obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Product, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Track, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Platform, StringComparer.OrdinalIgnoreCase);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AdobeProductEntryBuilder
|
||||
{
|
||||
public AdobeProductEntryBuilder(string product, string track, string platform)
|
||||
{
|
||||
Product = NormalizeWhitespace(product);
|
||||
Track = NormalizeWhitespace(track);
|
||||
Platform = NormalizeWhitespace(platform);
|
||||
}
|
||||
|
||||
public AdobeProductKey Key => new(Product, Track, Platform);
|
||||
|
||||
public string Product { get; }
|
||||
public string Track { get; }
|
||||
public string Platform { get; }
|
||||
|
||||
public string? AffectedVersion { get; set; }
|
||||
public string? UpdatedVersion { get; set; }
|
||||
public string? Priority { get; set; }
|
||||
public string? Availability { get; set; }
|
||||
|
||||
public AdobeProductEntry ToEntry()
|
||||
=> new(Product, Track, Platform, AffectedVersion, UpdatedVersion, Priority, Availability);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal static class AdobeDetailParser
|
||||
{
|
||||
private static readonly HtmlParser Parser = new();
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{4,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] DateMarkers = { "date published", "release date", "published" };
|
||||
|
||||
public static AdobeBulletinDto Parse(string html, AdobeDocumentMetadata metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(html);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
using var document = Parser.ParseDocument(html);
|
||||
var title = metadata.Title ?? document.QuerySelector("h1")?.TextContent?.Trim() ?? metadata.AdvisoryId;
|
||||
var summary = document.QuerySelector("p")?.TextContent?.Trim();
|
||||
|
||||
var published = metadata.PublishedUtc ?? TryExtractPublished(document) ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var cves = ExtractCves(document.Body?.TextContent ?? string.Empty);
|
||||
var products = ExtractProductEntries(title, document);
|
||||
|
||||
return AdobeBulletinDto.Create(
|
||||
metadata.AdvisoryId,
|
||||
title,
|
||||
published,
|
||||
products,
|
||||
cves,
|
||||
metadata.DetailUri,
|
||||
summary);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCves(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in CveRegex.Matches(text))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(match.Value))
|
||||
{
|
||||
set.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return set.Count == 0 ? Array.Empty<string>() : set.OrderBy(static cve => cve, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdobeProductEntry> ExtractProductEntries(string title, IDocument document)
|
||||
{
|
||||
var builders = new Dictionary<AdobeProductKey, AdobeProductEntryBuilder>(AdobeProductKeyComparer.Instance);
|
||||
|
||||
foreach (var builder in ParseAffectedTable(document))
|
||||
{
|
||||
builders[builder.Key] = builder;
|
||||
}
|
||||
|
||||
foreach (var updated in ParseUpdatedTable(document))
|
||||
{
|
||||
if (builders.TryGetValue(updated.Key, out var builder))
|
||||
{
|
||||
builder.UpdatedVersion ??= updated.UpdatedVersion;
|
||||
builder.Priority ??= updated.Priority;
|
||||
builder.Availability ??= updated.Availability;
|
||||
}
|
||||
else
|
||||
{
|
||||
builders[updated.Key] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
if (builders.Count == 0 && !string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
var fallback = new AdobeProductEntryBuilder(
|
||||
NormalizeWhitespace(title),
|
||||
string.Empty,
|
||||
string.Empty)
|
||||
{
|
||||
AffectedVersion = null,
|
||||
UpdatedVersion = null,
|
||||
Priority = null,
|
||||
Availability = null
|
||||
};
|
||||
|
||||
builders[fallback.Key] = fallback;
|
||||
}
|
||||
|
||||
return builders.Values
|
||||
.Select(static builder => builder.ToEntry())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<AdobeProductEntryBuilder> ParseAffectedTable(IDocument document)
|
||||
{
|
||||
var table = FindTableByHeader(document, "Affected Versions");
|
||||
if (table is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var row in table.Rows.Skip(1))
|
||||
{
|
||||
var cells = row.Cells;
|
||||
if (cells.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = NormalizeWhitespace(cells[0]?.TextContent);
|
||||
var track = NormalizeWhitespace(cells.ElementAtOrDefault(1)?.TextContent);
|
||||
var platformText = NormalizeWhitespace(cells.ElementAtOrDefault(3)?.TextContent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var affectedCell = cells[2];
|
||||
foreach (var line in ExtractLines(affectedCell))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (platform, versionText) = SplitPlatformLine(line, platformText);
|
||||
var builder = new AdobeProductEntryBuilder(product, track, platform)
|
||||
{
|
||||
AffectedVersion = versionText
|
||||
};
|
||||
|
||||
yield return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AdobeProductEntryBuilder> ParseUpdatedTable(IDocument document)
|
||||
{
|
||||
var table = FindTableByHeader(document, "Updated Versions");
|
||||
if (table is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var row in table.Rows.Skip(1))
|
||||
{
|
||||
var cells = row.Cells;
|
||||
if (cells.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = NormalizeWhitespace(cells[0]?.TextContent);
|
||||
var track = NormalizeWhitespace(cells.ElementAtOrDefault(1)?.TextContent);
|
||||
var platformText = NormalizeWhitespace(cells.ElementAtOrDefault(3)?.TextContent);
|
||||
var priority = NormalizeWhitespace(cells.ElementAtOrDefault(4)?.TextContent);
|
||||
var availability = NormalizeWhitespace(cells.ElementAtOrDefault(5)?.TextContent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var updatedCell = cells[2];
|
||||
var lines = ExtractLines(updatedCell);
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
lines.Add(updatedCell.TextContent ?? string.Empty);
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var (platform, versionText) = SplitPlatformLine(line, platformText);
|
||||
var builder = new AdobeProductEntryBuilder(product, track, platform)
|
||||
{
|
||||
UpdatedVersion = versionText,
|
||||
Priority = priority,
|
||||
Availability = availability
|
||||
};
|
||||
|
||||
yield return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IHtmlTableElement? FindTableByHeader(IDocument document, string headerText)
|
||||
{
|
||||
return document
|
||||
.QuerySelectorAll("table")
|
||||
.OfType<IHtmlTableElement>()
|
||||
.FirstOrDefault(table => table.TextContent.Contains(headerText, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static List<string> ExtractLines(IElement? cell)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (cell is null)
|
||||
{
|
||||
return lines;
|
||||
}
|
||||
|
||||
var paragraphs = cell.QuerySelectorAll("p").Select(static p => p.TextContent).ToArray();
|
||||
if (paragraphs.Length > 0)
|
||||
{
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
var normalized = NormalizeWhitespace(paragraph);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
lines.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
var items = cell.QuerySelectorAll("li").Select(static li => li.TextContent).ToArray();
|
||||
if (items.Length > 0)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var normalized = NormalizeWhitespace(item);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
lines.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
var raw = NormalizeWhitespace(cell.TextContent);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
lines.AddRange(raw.Split(new[] { '\n' }, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static (string Platform, string? Version) SplitPlatformLine(string line, string? fallbackPlatform)
|
||||
{
|
||||
var separatorIndex = line.IndexOf('-', StringComparison.Ordinal);
|
||||
if (separatorIndex > 0 && separatorIndex < line.Length - 1)
|
||||
{
|
||||
var prefix = line[..separatorIndex].Trim();
|
||||
var versionText = line[(separatorIndex + 1)..].Trim();
|
||||
return (NormalizePlatform(prefix) ?? NormalizePlatform(fallbackPlatform) ?? fallbackPlatform ?? string.Empty, versionText);
|
||||
}
|
||||
|
||||
return (NormalizePlatform(fallbackPlatform) ?? fallbackPlatform ?? string.Empty, line.Trim());
|
||||
}
|
||||
|
||||
private static string? NormalizePlatform(string? platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = platform.Trim();
|
||||
return trimmed.ToLowerInvariant() switch
|
||||
{
|
||||
"win" or "windows" => "Windows",
|
||||
"mac" or "macos" or "mac os" => "macOS",
|
||||
"windows & macos" or "windows & macos" => "Windows & macOS",
|
||||
_ => trimmed
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryExtractPublished(IDocument document)
|
||||
{
|
||||
var candidates = new List<string?>();
|
||||
candidates.Add(document.QuerySelector("time")?.GetAttribute("datetime"));
|
||||
candidates.Add(document.QuerySelector("time")?.TextContent);
|
||||
|
||||
foreach (var marker in DateMarkers)
|
||||
{
|
||||
var element = document.All.FirstOrDefault(node => node.TextContent.Contains(marker, StringComparison.OrdinalIgnoreCase));
|
||||
if (element is not null)
|
||||
{
|
||||
candidates.Add(element.TextContent);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (TryParseDate(candidate, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTimeOffset result)
|
||||
{
|
||||
result = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result))
|
||||
{
|
||||
result = result.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
{
|
||||
result = new DateTimeOffset(date, TimeSpan.Zero).ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeWhitespace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sanitized = value ?? string.Empty;
|
||||
return string.Join(" ", sanitized.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private sealed record AdobeProductKey(string Product, string Track, string Platform);
|
||||
|
||||
private sealed class AdobeProductKeyComparer : IEqualityComparer<AdobeProductKey>
|
||||
{
|
||||
public static AdobeProductKeyComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(AdobeProductKey? x, AdobeProductKey? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.Product, y.Product, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Track, y.Track, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Platform, y.Platform, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(AdobeProductKey obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Product, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Track, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.Platform, StringComparer.OrdinalIgnoreCase);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AdobeProductEntryBuilder
|
||||
{
|
||||
public AdobeProductEntryBuilder(string product, string track, string platform)
|
||||
{
|
||||
Product = NormalizeWhitespace(product);
|
||||
Track = NormalizeWhitespace(track);
|
||||
Platform = NormalizeWhitespace(platform);
|
||||
}
|
||||
|
||||
public AdobeProductKey Key => new(Product, Track, Platform);
|
||||
|
||||
public string Product { get; }
|
||||
public string Track { get; }
|
||||
public string Platform { get; }
|
||||
|
||||
public string? AffectedVersion { get; set; }
|
||||
public string? UpdatedVersion { get; set; }
|
||||
public string? Priority { get; set; }
|
||||
public string? Availability { get; set; }
|
||||
|
||||
public AdobeProductEntry ToEntry()
|
||||
=> new(Product, Track, Platform, AffectedVersion, UpdatedVersion, Priority, Availability);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeDocumentMetadata(
|
||||
string AdvisoryId,
|
||||
string? Title,
|
||||
DateTimeOffset? PublishedUtc,
|
||||
Uri DetailUri)
|
||||
{
|
||||
private const string AdvisoryIdKey = "advisoryId";
|
||||
private const string TitleKey = "title";
|
||||
private const string PublishedKey = "published";
|
||||
|
||||
public static AdobeDocumentMetadata FromDocument(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (document.Metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException("Adobe document metadata is missing.");
|
||||
}
|
||||
|
||||
var advisoryId = document.Metadata.TryGetValue(AdvisoryIdKey, out var idValue) ? idValue : null;
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
throw new InvalidOperationException("Adobe document advisoryId metadata missing.");
|
||||
}
|
||||
|
||||
var title = document.Metadata.TryGetValue(TitleKey, out var titleValue) ? titleValue : null;
|
||||
DateTimeOffset? published = null;
|
||||
if (document.Metadata.TryGetValue(PublishedKey, out var publishedValue)
|
||||
&& DateTimeOffset.TryParse(publishedValue, out var parsedPublished))
|
||||
{
|
||||
published = parsedPublished.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(document.Uri, UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
throw new InvalidOperationException("Adobe document URI invalid.");
|
||||
}
|
||||
|
||||
return new AdobeDocumentMetadata(advisoryId.Trim(), string.IsNullOrWhiteSpace(title) ? null : title.Trim(), published, detailUri);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeDocumentMetadata(
|
||||
string AdvisoryId,
|
||||
string? Title,
|
||||
DateTimeOffset? PublishedUtc,
|
||||
Uri DetailUri)
|
||||
{
|
||||
private const string AdvisoryIdKey = "advisoryId";
|
||||
private const string TitleKey = "title";
|
||||
private const string PublishedKey = "published";
|
||||
|
||||
public static AdobeDocumentMetadata FromDocument(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (document.Metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException("Adobe document metadata is missing.");
|
||||
}
|
||||
|
||||
var advisoryId = document.Metadata.TryGetValue(AdvisoryIdKey, out var idValue) ? idValue : null;
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
throw new InvalidOperationException("Adobe document advisoryId metadata missing.");
|
||||
}
|
||||
|
||||
var title = document.Metadata.TryGetValue(TitleKey, out var titleValue) ? titleValue : null;
|
||||
DateTimeOffset? published = null;
|
||||
if (document.Metadata.TryGetValue(PublishedKey, out var publishedValue)
|
||||
&& DateTimeOffset.TryParse(publishedValue, out var parsedPublished))
|
||||
{
|
||||
published = parsedPublished.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(document.Uri, UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
throw new InvalidOperationException("Adobe document URI invalid.");
|
||||
}
|
||||
|
||||
return new AdobeDocumentMetadata(advisoryId.Trim(), string.IsNullOrWhiteSpace(title) ? null : title.Trim(), published, detailUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeIndexEntry(string AdvisoryId, Uri DetailUri, DateTimeOffset PublishedUtc, string? Title);
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal sealed record AdobeIndexEntry(string AdvisoryId, Uri DetailUri, DateTimeOffset PublishedUtc, string? Title);
|
||||
|
||||
@@ -1,159 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal static class AdobeIndexParser
|
||||
{
|
||||
private static readonly HtmlParser Parser = new();
|
||||
private static readonly Regex AdvisoryIdRegex = new("(APSB|APA)\\d{2}-\\d{2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] ExplicitFormats =
|
||||
{
|
||||
"MMMM d, yyyy",
|
||||
"MMM d, yyyy",
|
||||
"M/d/yyyy",
|
||||
"MM/dd/yyyy",
|
||||
"yyyy-MM-dd",
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<AdobeIndexEntry> Parse(string html, Uri baseUri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
ArgumentNullException.ThrowIfNull(baseUri);
|
||||
|
||||
var document = Parser.ParseDocument(html);
|
||||
var map = new Dictionary<string, AdobeIndexEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var anchors = document.QuerySelectorAll("a[href]");
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
var href = anchor.GetAttribute("href");
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!href.Contains("/security/products/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryExtractAdvisoryId(anchor.TextContent, href, out var advisoryId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(baseUri, href, out var detailUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var published = TryResolvePublished(anchor) ?? DateTimeOffset.UtcNow;
|
||||
var entry = new AdobeIndexEntry(advisoryId.ToUpperInvariant(), detailUri, published, anchor.TextContent?.Trim());
|
||||
map[entry.AdvisoryId] = entry;
|
||||
}
|
||||
|
||||
return map.Values
|
||||
.OrderBy(static e => e.PublishedUtc)
|
||||
.ThenBy(static e => e.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryExtractAdvisoryId(string? text, string href, out string advisoryId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var match = AdvisoryIdRegex.Match(text);
|
||||
if (match.Success)
|
||||
{
|
||||
advisoryId = match.Value.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var hrefMatch = AdvisoryIdRegex.Match(href);
|
||||
if (hrefMatch.Success)
|
||||
{
|
||||
advisoryId = hrefMatch.Value.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
advisoryId = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryResolvePublished(IElement anchor)
|
||||
{
|
||||
var row = anchor.Closest("tr");
|
||||
if (row is not null)
|
||||
{
|
||||
var cells = row.GetElementsByTagName("td");
|
||||
if (cells.Length >= 2)
|
||||
{
|
||||
for (var idx = 1; idx < cells.Length; idx++)
|
||||
{
|
||||
if (TryParseDate(cells[idx].TextContent, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sibling = anchor.NextElementSibling;
|
||||
while (sibling is not null)
|
||||
{
|
||||
if (TryParseDate(sibling.TextContent, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
sibling = sibling.NextElementSibling;
|
||||
}
|
||||
|
||||
if (TryParseDate(anchor.ParentElement?.TextContent, out var parentDate))
|
||||
{
|
||||
return parentDate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTimeOffset result)
|
||||
{
|
||||
result = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result))
|
||||
{
|
||||
return Normalize(ref result);
|
||||
}
|
||||
|
||||
foreach (var format in ExplicitFormats)
|
||||
{
|
||||
if (DateTime.TryParseExact(trimmed, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
{
|
||||
result = new DateTimeOffset(date, TimeSpan.Zero);
|
||||
return Normalize(ref result);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool Normalize(ref DateTimeOffset value)
|
||||
{
|
||||
value = value.ToUniversalTime();
|
||||
value = new DateTimeOffset(value.Year, value.Month, value.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal static class AdobeIndexParser
|
||||
{
|
||||
private static readonly HtmlParser Parser = new();
|
||||
private static readonly Regex AdvisoryIdRegex = new("(APSB|APA)\\d{2}-\\d{2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] ExplicitFormats =
|
||||
{
|
||||
"MMMM d, yyyy",
|
||||
"MMM d, yyyy",
|
||||
"M/d/yyyy",
|
||||
"MM/dd/yyyy",
|
||||
"yyyy-MM-dd",
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<AdobeIndexEntry> Parse(string html, Uri baseUri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
ArgumentNullException.ThrowIfNull(baseUri);
|
||||
|
||||
var document = Parser.ParseDocument(html);
|
||||
var map = new Dictionary<string, AdobeIndexEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
var anchors = document.QuerySelectorAll("a[href]");
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
var href = anchor.GetAttribute("href");
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!href.Contains("/security/products/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryExtractAdvisoryId(anchor.TextContent, href, out var advisoryId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(baseUri, href, out var detailUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var published = TryResolvePublished(anchor) ?? DateTimeOffset.UtcNow;
|
||||
var entry = new AdobeIndexEntry(advisoryId.ToUpperInvariant(), detailUri, published, anchor.TextContent?.Trim());
|
||||
map[entry.AdvisoryId] = entry;
|
||||
}
|
||||
|
||||
return map.Values
|
||||
.OrderBy(static e => e.PublishedUtc)
|
||||
.ThenBy(static e => e.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryExtractAdvisoryId(string? text, string href, out string advisoryId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var match = AdvisoryIdRegex.Match(text);
|
||||
if (match.Success)
|
||||
{
|
||||
advisoryId = match.Value.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var hrefMatch = AdvisoryIdRegex.Match(href);
|
||||
if (hrefMatch.Success)
|
||||
{
|
||||
advisoryId = hrefMatch.Value.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
advisoryId = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryResolvePublished(IElement anchor)
|
||||
{
|
||||
var row = anchor.Closest("tr");
|
||||
if (row is not null)
|
||||
{
|
||||
var cells = row.GetElementsByTagName("td");
|
||||
if (cells.Length >= 2)
|
||||
{
|
||||
for (var idx = 1; idx < cells.Length; idx++)
|
||||
{
|
||||
if (TryParseDate(cells[idx].TextContent, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sibling = anchor.NextElementSibling;
|
||||
while (sibling is not null)
|
||||
{
|
||||
if (TryParseDate(sibling.TextContent, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
sibling = sibling.NextElementSibling;
|
||||
}
|
||||
|
||||
if (TryParseDate(anchor.ParentElement?.TextContent, out var parentDate))
|
||||
{
|
||||
return parentDate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTimeOffset result)
|
||||
{
|
||||
result = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result))
|
||||
{
|
||||
return Normalize(ref result);
|
||||
}
|
||||
|
||||
foreach (var format in ExplicitFormats)
|
||||
{
|
||||
if (DateTime.TryParseExact(trimmed, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
{
|
||||
result = new DateTimeOffset(date, TimeSpan.Zero);
|
||||
return Normalize(ref result);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool Normalize(ref DateTimeOffset value)
|
||||
{
|
||||
value = value.ToUniversalTime();
|
||||
value = new DateTimeOffset(value.Year, value.Month, value.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal static class AdobeSchemaProvider
|
||||
{
|
||||
private static readonly Lazy<JsonSchema> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => Cached.Value;
|
||||
|
||||
private static JsonSchema Load()
|
||||
{
|
||||
var assembly = typeof(AdobeSchemaProvider).GetTypeInfo().Assembly;
|
||||
const string resourceName = "StellaOps.Concelier.Connector.Vndr.Adobe.Schemas.adobe-bulletin.schema.json";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded schema '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var schemaText = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaText);
|
||||
}
|
||||
}
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Internal;
|
||||
|
||||
internal static class AdobeSchemaProvider
|
||||
{
|
||||
private static readonly Lazy<JsonSchema> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => Cached.Value;
|
||||
|
||||
private static JsonSchema Load()
|
||||
{
|
||||
var assembly = typeof(AdobeSchemaProvider).GetTypeInfo().Assembly;
|
||||
const string resourceName = "StellaOps.Concelier.Connector.Vndr.Adobe.Schemas.adobe-bulletin.schema.json";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded schema '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
var schemaText = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaText);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user