Resolve Concelier/Excititor merge conflicts
This commit is contained in:
23
src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/AGENTS.md
Normal file
23
src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector for Ubuntu CSAF advisories (USN VEX data), managing discovery, incremental pulls, and raw document persistence.
|
||||
## Scope
|
||||
- Ubuntu CSAF metadata discovery, release channel awareness, and pagination handling.
|
||||
- HTTP client with retries/backoff, checksum validation, and deduplication.
|
||||
- Mapping Ubuntu identifiers (USN numbers, package metadata) into connector metadata for downstream policy.
|
||||
- Emitting trust configuration (GPG fingerprints, cosign options) for policy weighting.
|
||||
## Participants
|
||||
- Worker schedules regular pulls; WebService can initiate manual ingest/resume.
|
||||
- CSAF normalizer converts raw documents into claims.
|
||||
- Policy engine leverages connector-supplied trust metadata.
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector`, using shared abstractions for HTTP/resume markers and telemetry.
|
||||
- Provides options for release channels (stable/LTS) and offline seed bundles.
|
||||
## In/Out of scope
|
||||
In: data fetching, metadata mapping, raw persistence, trust hints.
|
||||
Out: normalization/export, storage internals, attestation.
|
||||
## Observability & security expectations
|
||||
- Log release window fetch metrics, rate limits, and deduplication stats; mask secrets.
|
||||
- Emit counters for newly ingested vs unchanged USNs and quota usage.
|
||||
## Tests
|
||||
- Connector tests with mocked Ubuntu CSAF endpoints will live in `../StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests`.
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
|
||||
public sealed class UbuntuConnectorOptions
|
||||
{
|
||||
public const string HttpClientName = "excititor.connector.ubuntu.catalog";
|
||||
|
||||
/// <summary>
|
||||
/// Root index that lists Ubuntu CSAF channels.
|
||||
/// </summary>
|
||||
public Uri IndexUri { get; set; } = new("https://ubuntu.com/security/csaf/index.json");
|
||||
|
||||
/// <summary>
|
||||
/// Channels to include (e.g. stable, esm, lts).
|
||||
/// </summary>
|
||||
public IList<string> Channels { get; } = new List<string> { "stable" };
|
||||
|
||||
/// <summary>
|
||||
/// Duration to cache discovery metadata.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
/// <summary>
|
||||
/// Prefer offline snapshot when available.
|
||||
/// </summary>
|
||||
public bool PreferOfflineSnapshot { get; set; }
|
||||
/// <summary>
|
||||
/// Optional file path for offline index snapshot.
|
||||
/// </summary>
|
||||
public string? OfflineSnapshotPath { get; set; }
|
||||
/// <summary>
|
||||
/// Controls persistence of network responses to <see cref="OfflineSnapshotPath"/>.
|
||||
/// </summary>
|
||||
public bool PersistOfflineSnapshot { get; set; } = true;
|
||||
|
||||
public void Validate(IFileSystem? fileSystem = null)
|
||||
{
|
||||
if (IndexUri is null || !IndexUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("IndexUri must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (IndexUri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new InvalidOperationException("IndexUri must use HTTP or HTTPS.");
|
||||
}
|
||||
|
||||
if (Channels.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one channel must be specified.");
|
||||
}
|
||||
|
||||
for (var i = Channels.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Channels[i]))
|
||||
{
|
||||
Channels.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (Channels.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Channel names cannot be empty.");
|
||||
}
|
||||
|
||||
if (MetadataCacheDuration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("MetadataCacheDuration must be positive.");
|
||||
}
|
||||
|
||||
if (PreferOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
|
||||
{
|
||||
throw new InvalidOperationException("OfflineSnapshotPath must be provided when PreferOfflineSnapshot is enabled.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OfflineSnapshotPath))
|
||||
{
|
||||
var fs = fileSystem ?? new FileSystem();
|
||||
var directory = Path.GetDirectoryName(OfflineSnapshotPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
|
||||
{
|
||||
fs.Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
|
||||
public sealed class UbuntuConnectorOptionsValidator : IVexConnectorOptionsValidator<UbuntuConnectorOptions>
|
||||
{
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
public UbuntuConnectorOptionsValidator(IFileSystem fileSystem)
|
||||
{
|
||||
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||
}
|
||||
|
||||
public void Validate(VexConnectorDescriptor descriptor, UbuntuConnectorOptions options, IList<string> errors)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(errors);
|
||||
|
||||
try
|
||||
{
|
||||
options.Validate(_fileSystem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.DependencyInjection;
|
||||
|
||||
public static class UbuntuConnectorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddUbuntuCsafConnector(this IServiceCollection services, Action<UbuntuConnectorOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||
|
||||
services.AddOptions<UbuntuConnectorOptions>()
|
||||
.Configure(options => configure?.Invoke(options));
|
||||
|
||||
services.AddSingleton<IVexConnectorOptionsValidator<UbuntuConnectorOptions>, UbuntuConnectorOptionsValidator>();
|
||||
|
||||
services.AddHttpClient(UbuntuConnectorOptions.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.Ubuntu.CSAF/1.0");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
});
|
||||
|
||||
services.AddSingleton<UbuntuCatalogLoader>();
|
||||
services.AddSingleton<IVexConnector, UbuntuCsafConnector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
|
||||
public sealed class UbuntuCatalogLoader
|
||||
{
|
||||
public const string CachePrefix = "StellaOps.Excititor.Connectors.Ubuntu.CSAF.Index";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<UbuntuCatalogLoader> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public UbuntuCatalogLoader(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache memoryCache,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<UbuntuCatalogLoader> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
|
||||
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<UbuntuCatalogResult> LoadAsync(UbuntuConnectorOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate(_fileSystem);
|
||||
|
||||
var cacheKey = CreateCacheKey(options);
|
||||
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out var cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return cached.ToResult(fromCache: true);
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue<CacheEntry>(cacheKey, out cached) && cached is not null && !cached.IsExpired(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return cached.ToResult(fromCache: true);
|
||||
}
|
||||
|
||||
CacheEntry? entry = null;
|
||||
if (options.PreferOfflineSnapshot)
|
||||
{
|
||||
entry = LoadFromOffline(options);
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException("PreferOfflineSnapshot is enabled but no offline Ubuntu snapshot was found.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
entry = await TryFetchFromNetworkAsync(options, cancellationToken).ConfigureAwait(false)
|
||||
?? LoadFromOffline(options);
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to load Ubuntu CSAF index from network or offline snapshot.");
|
||||
}
|
||||
|
||||
var cacheOptions = new MemoryCacheEntryOptions();
|
||||
if (entry.MetadataCacheDuration > TimeSpan.Zero)
|
||||
{
|
||||
cacheOptions.AbsoluteExpiration = _timeProvider.GetUtcNow().Add(entry.MetadataCacheDuration);
|
||||
}
|
||||
|
||||
_memoryCache.Set(cacheKey, entry with { CachedAt = _timeProvider.GetUtcNow() }, cacheOptions);
|
||||
return entry.ToResult(fromCache: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CacheEntry?> TryFetchFromNetworkAsync(UbuntuConnectorOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(UbuntuConnectorOptions.HttpClientName);
|
||||
using var response = await client.GetAsync(options.IndexUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadata = ParseMetadata(payload, options.Channels);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entry = new CacheEntry(metadata, now, now, options.MetadataCacheDuration, false);
|
||||
|
||||
PersistSnapshotIfNeeded(options, metadata, now);
|
||||
return entry;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch Ubuntu CSAF index from {Uri}; attempting offline fallback if available.", options.IndexUri);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CacheEntry? LoadFromOffline(UbuntuConnectorOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_fileSystem.File.Exists(options.OfflineSnapshotPath))
|
||||
{
|
||||
_logger.LogWarning("Ubuntu offline snapshot path {Path} does not exist.", options.OfflineSnapshotPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = _fileSystem.File.ReadAllText(options.OfflineSnapshotPath);
|
||||
var snapshot = JsonSerializer.Deserialize<UbuntuCatalogSnapshot>(payload, _serializerOptions);
|
||||
if (snapshot is null)
|
||||
{
|
||||
throw new InvalidOperationException("Offline snapshot payload was empty.");
|
||||
}
|
||||
|
||||
return new CacheEntry(snapshot.Metadata, snapshot.FetchedAt, _timeProvider.GetUtcNow(), options.MetadataCacheDuration, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load Ubuntu CSAF index from offline snapshot {Path}.", options.OfflineSnapshotPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private UbuntuCatalogMetadata ParseMetadata(string payload, IList<string> channels)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
throw new InvalidOperationException("Ubuntu index payload was empty.");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
|
||||
var generatedAt = root.TryGetProperty("generated", out var generatedElement) && generatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(generatedElement.GetString(), out var generated)
|
||||
? generated
|
||||
: _timeProvider.GetUtcNow();
|
||||
|
||||
var channelSet = new HashSet<string>(channels, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!root.TryGetProperty("channels", out var channelsElement) || channelsElement.ValueKind is not JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException("Ubuntu index did not include a channels array.");
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<UbuChannelCatalog>();
|
||||
foreach (var channelElement in channelsElement.EnumerateArray())
|
||||
{
|
||||
var name = channelElement.TryGetProperty("name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String ? nameElement.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(name) || !channelSet.Contains(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!channelElement.TryGetProperty("catalogUrl", out var urlElement) || urlElement.ValueKind != JsonValueKind.String || !Uri.TryCreate(urlElement.GetString(), UriKind.Absolute, out var catalogUri))
|
||||
{
|
||||
_logger.LogWarning("Channel {Channel} did not specify a valid catalogUrl.", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
string? sha256 = null;
|
||||
if (channelElement.TryGetProperty("sha256", out var shaElement) && shaElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
sha256 = shaElement.GetString();
|
||||
}
|
||||
|
||||
DateTimeOffset? lastUpdated = null;
|
||||
if (channelElement.TryGetProperty("lastUpdated", out var updatedElement) && updatedElement.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(updatedElement.GetString(), out var updated))
|
||||
{
|
||||
lastUpdated = updated;
|
||||
}
|
||||
|
||||
builder.Add(new UbuChannelCatalog(name!, catalogUri, sha256, lastUpdated));
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("None of the requested Ubuntu channels were present in the index.");
|
||||
}
|
||||
|
||||
return new UbuntuCatalogMetadata(generatedAt, builder.ToImmutable());
|
||||
}
|
||||
|
||||
private void PersistSnapshotIfNeeded(UbuntuConnectorOptions options, UbuntuCatalogMetadata metadata, DateTimeOffset fetchedAt)
|
||||
{
|
||||
if (!options.PersistOfflineSnapshot || string.IsNullOrWhiteSpace(options.OfflineSnapshotPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = new UbuntuCatalogSnapshot(metadata, fetchedAt);
|
||||
var payload = JsonSerializer.Serialize(snapshot, _serializerOptions);
|
||||
_fileSystem.File.WriteAllText(options.OfflineSnapshotPath, payload);
|
||||
_logger.LogDebug("Persisted Ubuntu CSAF index snapshot to {Path}.", options.OfflineSnapshotPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist Ubuntu CSAF index snapshot to {Path}.", options.OfflineSnapshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateCacheKey(UbuntuConnectorOptions options)
|
||||
=> $"{CachePrefix}:{options.IndexUri}:{string.Join(',', options.Channels)}";
|
||||
|
||||
private sealed record CacheEntry(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt, DateTimeOffset CachedAt, TimeSpan MetadataCacheDuration, bool FromOfflineSnapshot)
|
||||
{
|
||||
public bool IsExpired(DateTimeOffset now)
|
||||
=> MetadataCacheDuration > TimeSpan.Zero && now >= CachedAt + MetadataCacheDuration;
|
||||
|
||||
public UbuntuCatalogResult ToResult(bool fromCache)
|
||||
=> new(Metadata, FetchedAt, fromCache, FromOfflineSnapshot);
|
||||
}
|
||||
|
||||
private sealed record UbuntuCatalogSnapshot(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt);
|
||||
}
|
||||
|
||||
public sealed record UbuntuCatalogMetadata(DateTimeOffset GeneratedAt, ImmutableArray<UbuChannelCatalog> Channels);
|
||||
|
||||
public sealed record UbuChannelCatalog(string Name, Uri CatalogUri, string? Sha256, DateTimeOffset? LastUpdated);
|
||||
|
||||
public sealed record UbuntuCatalogResult(UbuntuCatalogMetadata Metadata, DateTimeOffset FetchedAt, bool FromCache, bool FromOfflineSnapshot);
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Storage.Mongo\StellaOps.Excititor.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
8
src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md
Normal file
8
src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.md).
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-UBUNTU-01-001 – Ubuntu CSAF discovery & channels|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added Ubuntu connector project with configurable channel options, catalog loader (network/offline), DI wiring, and discovery unit tests.|
|
||||
|EXCITITOR-CONN-UBUNTU-01-002 – Incremental fetch & deduplication|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-UBUNTU-01-001, EXCITITOR-STORAGE-01-003|**DOING (2025-10-19)** – Fetch CSAF bundles with ETag handling, checksum validation, deduplication, and raw persistence.|
|
||||
|EXCITITOR-CONN-UBUNTU-01-003 – Trust metadata & provenance|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-UBUNTU-01-002, EXCITITOR-POLICY-01-001|TODO – Emit Ubuntu signing metadata (GPG fingerprints) plus provenance hints for policy weighting and diagnostics.|
|
||||
> Remark (2025-10-19, EXCITITOR-CONN-UBUNTU-01-002): Prerequisites EXCITITOR-CONN-UBUNTU-01-001 and EXCITITOR-STORAGE-01-003 verified as **DONE**; advancing to DOING per Wave 0 kickoff.
|
||||
@@ -0,0 +1,502 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF;
|
||||
|
||||
public sealed class UbuntuCsafConnector : VexConnectorBase
|
||||
{
|
||||
private const string EtagTokenPrefix = "etag:";
|
||||
|
||||
private static readonly VexConnectorDescriptor DescriptorInstance = new(
|
||||
id: "excititor:ubuntu",
|
||||
kind: VexProviderKind.Distro,
|
||||
displayName: "Ubuntu CSAF")
|
||||
{
|
||||
Tags = ImmutableArray.Create("ubuntu", "csaf", "usn"),
|
||||
};
|
||||
|
||||
private readonly UbuntuCatalogLoader _catalogLoader;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IVexConnectorStateRepository _stateRepository;
|
||||
private readonly IEnumerable<IVexConnectorOptionsValidator<UbuntuConnectorOptions>> _validators;
|
||||
|
||||
private UbuntuConnectorOptions? _options;
|
||||
private UbuntuCatalogResult? _catalog;
|
||||
|
||||
public UbuntuCsafConnector(
|
||||
UbuntuCatalogLoader catalogLoader,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
IEnumerable<IVexConnectorOptionsValidator<UbuntuConnectorOptions>> validators,
|
||||
ILogger<UbuntuCsafConnector> logger,
|
||||
TimeProvider timeProvider)
|
||||
: base(DescriptorInstance, logger, timeProvider)
|
||||
{
|
||||
_catalogLoader = catalogLoader ?? throw new ArgumentNullException(nameof(catalogLoader));
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<UbuntuConnectorOptions>>();
|
||||
}
|
||||
|
||||
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
_options = VexConnectorOptionsBinder.Bind(
|
||||
Descriptor,
|
||||
settings,
|
||||
validators: _validators);
|
||||
|
||||
_catalog = await _catalogLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
LogConnectorEvent(LogLevel.Information, "validate", "Ubuntu CSAF index loaded.", new Dictionary<string, object?>
|
||||
{
|
||||
["channelCount"] = _catalog.Metadata.Channels.Length,
|
||||
["fromOffline"] = _catalog.FromOfflineSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (_options is null)
|
||||
{
|
||||
throw new InvalidOperationException("Connector must be validated before fetch operations.");
|
||||
}
|
||||
|
||||
if (_catalog is null)
|
||||
{
|
||||
_catalog = await _catalogLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var knownTokens = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
|
||||
var digestSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var tokenSet = new HashSet<string>(StringComparer.Ordinal);
|
||||
var tokenList = new List<string>(knownTokens.Length + 16);
|
||||
var etagMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var token in knownTokens)
|
||||
{
|
||||
tokenSet.Add(token);
|
||||
tokenList.Add(token);
|
||||
if (TryParseEtagToken(token, out var uri, out var etag))
|
||||
{
|
||||
etagMap[uri] = etag;
|
||||
}
|
||||
else
|
||||
{
|
||||
digestSet.Add(token);
|
||||
}
|
||||
}
|
||||
|
||||
var since = context.Since ?? state?.LastUpdated ?? DateTimeOffset.MinValue;
|
||||
var latestTimestamp = state?.LastUpdated ?? since;
|
||||
var stateChanged = false;
|
||||
|
||||
foreach (var channel in _catalog.Metadata.Channels)
|
||||
{
|
||||
await foreach (var entry in EnumerateChannelResourcesAsync(channel, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var entryTimestamp = entry.LastModified ?? channel.LastUpdated ?? _catalog.Metadata.GeneratedAt;
|
||||
if (entryTimestamp <= since)
|
||||
{
|
||||
if (entryTimestamp > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var expectedDigest = entry.Sha256 is null ? null : NormalizeDigest(entry.Sha256);
|
||||
if (expectedDigest is not null && digestSet.Contains(expectedDigest))
|
||||
{
|
||||
if (entryTimestamp > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
etagMap.TryGetValue(entry.DocumentUri.ToString(), out var knownEtag);
|
||||
|
||||
var download = await DownloadDocumentAsync(entry, knownEtag, cancellationToken).ConfigureAwait(false);
|
||||
if (download is null)
|
||||
{
|
||||
if (entryTimestamp > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = download.Document;
|
||||
if (!digestSet.Add(document.Digest))
|
||||
{
|
||||
if (entryTimestamp > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
if (tokenSet.Add(document.Digest))
|
||||
{
|
||||
tokenList.Add(document.Digest);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(download.ETag))
|
||||
{
|
||||
var etagValue = download.ETag!;
|
||||
etagMap[entry.DocumentUri.ToString()] = etagValue;
|
||||
var etagToken = CreateEtagToken(entry.DocumentUri, etagValue);
|
||||
if (tokenSet.Add(etagToken))
|
||||
{
|
||||
tokenList.Add(etagToken);
|
||||
}
|
||||
}
|
||||
|
||||
stateChanged = true;
|
||||
if (entryTimestamp > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
yield return document;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged || latestTimestamp > (state?.LastUpdated ?? DateTimeOffset.MinValue))
|
||||
{
|
||||
var newState = new VexConnectorState(
|
||||
Descriptor.Id,
|
||||
latestTimestamp == DateTimeOffset.MinValue ? state?.LastUpdated : latestTimestamp,
|
||||
tokenList.ToImmutableArray());
|
||||
|
||||
await _stateRepository.SaveAsync(newState, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException("UbuntuCsafConnector relies on CSAF normalizers for document processing.");
|
||||
|
||||
public UbuntuCatalogResult? GetCachedCatalog() => _catalog;
|
||||
|
||||
private async IAsyncEnumerable<UbuntuCatalogEntry> EnumerateChannelResourcesAsync(UbuChannelCatalog channel, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(UbuntuConnectorOptions.HttpClientName);
|
||||
HttpResponseMessage? response = null;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync(channel.CatalogUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("resources", out var resourcesElement) || resourcesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "fetch.catalog.empty", "Ubuntu CSAF channel catalog missing 'resources' array.", new Dictionary<string, object?>
|
||||
{
|
||||
["channel"] = channel.Name,
|
||||
["catalog"] = channel.CatalogUri.ToString(),
|
||||
});
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var resource in resourcesElement.EnumerateArray())
|
||||
{
|
||||
var type = GetString(resource, "type");
|
||||
if (type is not null && !type.Equals("csaf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var uriText = GetString(resource, "url")
|
||||
?? GetString(resource, "canonical")
|
||||
?? GetString(resource, "download")
|
||||
?? GetString(resource, "uri");
|
||||
|
||||
if (uriText is null || !Uri.TryCreate(uriText, UriKind.Absolute, out var documentUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sha256 = TryGetHash(resource);
|
||||
var etag = GetString(resource, "etag");
|
||||
var lastModified = ParseDate(resource, "last_modified")
|
||||
?? ParseDate(resource, "published")
|
||||
?? ParseDate(resource, "released")
|
||||
?? channel.LastUpdated;
|
||||
var title = GetString(resource, "title");
|
||||
var version = GetString(resource, "version");
|
||||
var advisoryId = GetString(resource, "id") ?? ExtractAdvisoryId(documentUri, title);
|
||||
|
||||
yield return new UbuntuCatalogEntry(
|
||||
channel.Name,
|
||||
advisoryId,
|
||||
documentUri,
|
||||
sha256,
|
||||
etag,
|
||||
lastModified,
|
||||
title,
|
||||
version);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "fetch.catalog.failure", "Failed to enumerate Ubuntu CSAF channel catalog.", new Dictionary<string, object?>
|
||||
{
|
||||
["channel"] = channel.Name,
|
||||
["catalog"] = channel.CatalogUri.ToString(),
|
||||
}, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadResult?> DownloadDocumentAsync(UbuntuCatalogEntry entry, string? knownEtag, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(UbuntuConnectorOptions.HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, entry.DocumentUri);
|
||||
if (!string.IsNullOrWhiteSpace(knownEtag))
|
||||
{
|
||||
request.Headers.IfNoneMatch.TryParseAdd(EnsureQuoted(knownEtag));
|
||||
}
|
||||
|
||||
HttpResponseMessage? response = null;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Debug, "fetch.document.not_modified", "Ubuntu CSAF document not modified per ETag.", new Dictionary<string, object?>
|
||||
{
|
||||
["uri"] = entry.DocumentUri.ToString(),
|
||||
["etag"] = knownEtag,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (entry.Sha256 is not null)
|
||||
{
|
||||
var expected = NormalizeDigest(entry.Sha256);
|
||||
var actual = "sha256:" + ComputeSha256Hex(payload);
|
||||
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "fetch.document.checksum_mismatch", "Ubuntu CSAF document checksum mismatch; skipping document.", new Dictionary<string, object?>
|
||||
{
|
||||
["uri"] = entry.DocumentUri.ToString(),
|
||||
["expected"] = expected,
|
||||
["actual"] = actual,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var etagHeader = response.Headers.ETag?.Tag;
|
||||
var etagValue = !string.IsNullOrWhiteSpace(etagHeader)
|
||||
? Unquote(etagHeader!)
|
||||
: entry.ETag is null ? null : Unquote(entry.ETag);
|
||||
|
||||
var metadata = BuildMetadata(builder =>
|
||||
{
|
||||
builder.Add("ubuntu.channel", entry.Channel);
|
||||
builder.Add("ubuntu.uri", entry.DocumentUri.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(entry.AdvisoryId))
|
||||
{
|
||||
builder.Add("ubuntu.advisoryId", entry.AdvisoryId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Title))
|
||||
{
|
||||
builder.Add("ubuntu.title", entry.Title!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Version))
|
||||
{
|
||||
builder.Add("ubuntu.version", entry.Version!);
|
||||
}
|
||||
|
||||
if (entry.LastModified is { } modified)
|
||||
{
|
||||
builder.Add("ubuntu.lastModified", modified.ToString("O"));
|
||||
}
|
||||
|
||||
if (entry.Sha256 is not null)
|
||||
{
|
||||
builder.Add("ubuntu.sha256", NormalizeDigest(entry.Sha256));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(etagValue))
|
||||
{
|
||||
builder.Add("ubuntu.etag", etagValue!);
|
||||
}
|
||||
});
|
||||
|
||||
var document = CreateRawDocument(VexDocumentFormat.Csaf, entry.DocumentUri, payload, metadata);
|
||||
return new DownloadResult(document, etagValue);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogConnectorEvent(LogLevel.Warning, "fetch.document.failure", "Failed to download Ubuntu CSAF document.", new Dictionary<string, object?>
|
||||
{
|
||||
["uri"] = entry.DocumentUri.ToString(),
|
||||
}, ex);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed[7..];
|
||||
}
|
||||
|
||||
return "sha256:" + trimmed.Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
SHA256.HashData(payload, buffer);
|
||||
return Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = property.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetHash(JsonElement resource)
|
||||
{
|
||||
if (resource.TryGetProperty("hashes", out var hashesElement) && hashesElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (hashesElement.TryGetProperty("sha256", out var hash) && hash.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = hash.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetString(resource, "sha256");
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDate(JsonElement element, string propertyName)
|
||||
{
|
||||
var text = GetString(element, propertyName);
|
||||
if (text is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var value)
|
||||
? value
|
||||
: (DateTimeOffset?)null;
|
||||
}
|
||||
|
||||
private static string ExtractAdvisoryId(Uri uri, string? title)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return title!;
|
||||
}
|
||||
|
||||
var segments = uri.Segments;
|
||||
if (segments.Length > 0)
|
||||
{
|
||||
var candidate = segments[^1].Trim('/');
|
||||
if (candidate.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
candidate = candidate[..^5];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return uri.AbsolutePath.Trim('/');
|
||||
}
|
||||
|
||||
private static string EnsureQuoted(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.StartsWith('"') ? trimmed : $"\"{trimmed}\"";
|
||||
}
|
||||
|
||||
private static string Unquote(string value)
|
||||
=> value.Trim().Trim('"');
|
||||
|
||||
private static string CreateEtagToken(Uri uri, string etag)
|
||||
=> $"{EtagTokenPrefix}{uri}|{etag}";
|
||||
|
||||
private static bool TryParseEtagToken(string token, out string uri, out string etag)
|
||||
{
|
||||
uri = string.Empty;
|
||||
etag = string.Empty;
|
||||
if (!token.StartsWith(EtagTokenPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var separatorIndex = token.IndexOf('|', EtagTokenPrefix.Length);
|
||||
if (separatorIndex < 0 || separatorIndex == EtagTokenPrefix.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = token[EtagTokenPrefix.Length..separatorIndex];
|
||||
etag = token[(separatorIndex + 1)..];
|
||||
return !string.IsNullOrWhiteSpace(uri) && !string.IsNullOrWhiteSpace(etag);
|
||||
}
|
||||
|
||||
private sealed record UbuntuCatalogEntry(
|
||||
string Channel,
|
||||
string? AdvisoryId,
|
||||
Uri DocumentUri,
|
||||
string? Sha256,
|
||||
string? ETag,
|
||||
DateTimeOffset? LastModified,
|
||||
string? Title,
|
||||
string? Version);
|
||||
|
||||
private sealed record DownloadResult(VexRawDocument Document, string? ETag);
|
||||
}
|
||||
Reference in New Issue
Block a user