Rename Vexer to Excititor
This commit is contained in:
23
src/StellaOps.Excititor.Connectors.Cisco.CSAF/AGENTS.md
Normal file
23
src/StellaOps.Excititor.Connectors.Cisco.CSAF/AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector responsible for ingesting Cisco CSAF VEX advisories and handing raw documents to normalizers with Cisco-specific metadata.
|
||||
## Scope
|
||||
- Discovery of Cisco CSAF collection endpoints, authentication (when required), and pagination routines.
|
||||
- HTTP retries/backoff, checksum verification, and document deduplication before storage.
|
||||
- Mapping Cisco advisory identifiers, product hierarchies, and severity hints into connector metadata.
|
||||
- Surfacing provider trust configuration aligned with policy expectations.
|
||||
## Participants
|
||||
- Worker drives scheduled pulls; WebService may trigger manual runs.
|
||||
- CSAF normalizer consumes raw documents to emit claims.
|
||||
- Policy module references connector trust hints (e.g., Cisco signing identities).
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector` using shared abstractions for HTTP/resume handling.
|
||||
- Provides options for API tokens, rate limits, and concurrency.
|
||||
## In/Out of scope
|
||||
In: data fetching, provider metadata, retry controls, raw document persistence.
|
||||
Out: normalization/export, attestation, Mongo wiring (handled in other modules).
|
||||
## Observability & security expectations
|
||||
- Log fetch batches with document counts/durations; mask credentials.
|
||||
- Emit metrics for rate-limit hits, retries, and quarantine events.
|
||||
## Tests
|
||||
- Unit tests plus HTTP harness fixtures will live in `../StellaOps.Excititor.Connectors.Cisco.CSAF.Tests`.
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF;
|
||||
|
||||
public sealed class CiscoCsafConnector : VexConnectorBase
|
||||
{
|
||||
private static readonly VexConnectorDescriptor DescriptorInstance = new(
|
||||
id: "excititor:cisco",
|
||||
kind: VexProviderKind.Vendor,
|
||||
displayName: "Cisco CSAF")
|
||||
{
|
||||
Tags = ImmutableArray.Create("cisco", "csaf"),
|
||||
};
|
||||
|
||||
private readonly CiscoProviderMetadataLoader _metadataLoader;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IVexConnectorStateRepository _stateRepository;
|
||||
private readonly IEnumerable<IVexConnectorOptionsValidator<CiscoConnectorOptions>> _validators;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private CiscoConnectorOptions? _options;
|
||||
private CiscoProviderMetadataResult? _providerMetadata;
|
||||
|
||||
public CiscoCsafConnector(
|
||||
CiscoProviderMetadataLoader metadataLoader,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
IEnumerable<IVexConnectorOptionsValidator<CiscoConnectorOptions>>? validators,
|
||||
ILogger<CiscoCsafConnector> logger,
|
||||
TimeProvider timeProvider)
|
||||
: base(DescriptorInstance, logger, timeProvider)
|
||||
{
|
||||
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<CiscoConnectorOptions>>();
|
||||
}
|
||||
|
||||
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
_options = VexConnectorOptionsBinder.Bind(
|
||||
Descriptor,
|
||||
settings,
|
||||
validators: _validators);
|
||||
|
||||
_providerMetadata = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
LogConnectorEvent(LogLevel.Information, "validate", "Cisco CSAF metadata loaded.", new Dictionary<string, object?>
|
||||
{
|
||||
["baseUriCount"] = _providerMetadata.Provider.BaseUris.Length,
|
||||
["fromOffline"] = _providerMetadata.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 (_providerMetadata is null)
|
||||
{
|
||||
_providerMetadata = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
|
||||
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
|
||||
var digestList = new List<string>(knownDigests);
|
||||
var since = context.Since ?? state?.LastUpdated ?? DateTimeOffset.MinValue;
|
||||
var latestTimestamp = state?.LastUpdated ?? since;
|
||||
var stateChanged = false;
|
||||
|
||||
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
|
||||
foreach (var directory in _providerMetadata.Provider.BaseUris)
|
||||
{
|
||||
await foreach (var advisory in EnumerateCatalogAsync(client, directory, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var published = advisory.LastModified ?? advisory.Published ?? DateTimeOffset.MinValue;
|
||||
if (published <= since)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var contentResponse = await client.GetAsync(advisory.DocumentUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
contentResponse.EnsureSuccessStatusCode();
|
||||
var payload = await contentResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rawDocument = CreateRawDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
advisory.DocumentUri,
|
||||
payload,
|
||||
BuildMetadata(builder => builder
|
||||
.Add("cisco.csaf.advisoryId", advisory.Id)
|
||||
.Add("cisco.csaf.revision", advisory.Revision)
|
||||
.Add("cisco.csaf.published", advisory.Published?.ToString("O"))
|
||||
.Add("cisco.csaf.modified", advisory.LastModified?.ToString("O"))
|
||||
.Add("cisco.csaf.sha256", advisory.Sha256)));
|
||||
|
||||
if (!digestSet.Add(rawDocument.Digest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
|
||||
digestList.Add(rawDocument.Digest);
|
||||
stateChanged = true;
|
||||
if (published > latestTimestamp)
|
||||
{
|
||||
latestTimestamp = published;
|
||||
}
|
||||
|
||||
yield return rawDocument;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged)
|
||||
{
|
||||
var newState = new VexConnectorState(
|
||||
Descriptor.Id,
|
||||
latestTimestamp == DateTimeOffset.MinValue ? state?.LastUpdated : latestTimestamp,
|
||||
digestList.ToImmutableArray());
|
||||
await _stateRepository.SaveAsync(newState, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException("CiscoCsafConnector relies on CSAF normalizers for document processing.");
|
||||
|
||||
private async IAsyncEnumerable<CiscoAdvisoryEntry> EnumerateCatalogAsync(HttpClient client, Uri directory, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var nextUri = BuildIndexUri(directory, null);
|
||||
while (nextUri is not null)
|
||||
{
|
||||
using var response = await client.GetAsync(nextUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var page = JsonSerializer.Deserialize<CiscoAdvisoryIndex>(json, _serializerOptions);
|
||||
if (page?.Advisories is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var advisory in page.Advisories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(advisory.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(advisory.Url, UriKind.RelativeOrAbsolute, out var documentUri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!documentUri.IsAbsoluteUri)
|
||||
{
|
||||
documentUri = new Uri(directory, documentUri);
|
||||
}
|
||||
|
||||
yield return new CiscoAdvisoryEntry(
|
||||
advisory.Id ?? documentUri.Segments.LastOrDefault()?.Trim('/') ?? documentUri.ToString(),
|
||||
documentUri,
|
||||
advisory.Revision,
|
||||
advisory.Published,
|
||||
advisory.LastModified,
|
||||
advisory.Sha256);
|
||||
}
|
||||
|
||||
nextUri = ResolveNextUri(directory, page.Next);
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildIndexUri(Uri directory, string? relative)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
{
|
||||
var baseText = directory.ToString();
|
||||
if (!baseText.EndsWith('/'))
|
||||
{
|
||||
baseText += "/";
|
||||
}
|
||||
|
||||
return new Uri(new Uri(baseText, UriKind.Absolute), "index.json");
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(relative, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
return absolute;
|
||||
}
|
||||
|
||||
var baseTextRelative = directory.ToString();
|
||||
if (!baseTextRelative.EndsWith('/'))
|
||||
{
|
||||
baseTextRelative += "/";
|
||||
}
|
||||
|
||||
return new Uri(new Uri(baseTextRelative, UriKind.Absolute), relative);
|
||||
}
|
||||
|
||||
private static Uri? ResolveNextUri(Uri directory, string? next)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildIndexUri(directory, next);
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisoryIndex
|
||||
{
|
||||
public List<CiscoAdvisory>? Advisories { get; init; }
|
||||
public string? Next { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisory
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public string? Revision { get; init; }
|
||||
public DateTimeOffset? Published { get; init; }
|
||||
public DateTimeOffset? LastModified { get; init; }
|
||||
public string? Sha256 { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisoryEntry(
|
||||
string Id,
|
||||
Uri DocumentUri,
|
||||
string? Revision,
|
||||
DateTimeOffset? Published,
|
||||
DateTimeOffset? LastModified,
|
||||
string? Sha256);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
|
||||
public sealed class CiscoConnectorOptions : IValidatableObject
|
||||
{
|
||||
public const string HttpClientName = "cisco-csaf";
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint for Cisco CSAF provider metadata discovery.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string MetadataUri { get; set; } = "https://api.security.cisco.com/.well-known/csaf/provider-metadata.json";
|
||||
|
||||
/// <summary>
|
||||
/// Optional bearer token used when Cisco endpoints require authentication.
|
||||
/// </summary>
|
||||
public string? ApiToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How long provider metadata remains cached.
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheDuration { get; set; } = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer offline snapshots when fetching metadata.
|
||||
/// </summary>
|
||||
public bool PreferOfflineSnapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, provider metadata will be persisted to the given file path.
|
||||
/// </summary>
|
||||
public bool PersistOfflineSnapshot { get; set; }
|
||||
|
||||
public string? OfflineSnapshotPath { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(MetadataUri))
|
||||
{
|
||||
yield return new ValidationResult("MetadataUri must be provided.", new[] { nameof(MetadataUri) });
|
||||
}
|
||||
else if (!Uri.TryCreate(MetadataUri, UriKind.Absolute, out _))
|
||||
{
|
||||
yield return new ValidationResult("MetadataUri must be an absolute URI.", new[] { nameof(MetadataUri) });
|
||||
}
|
||||
|
||||
if (MetadataCacheDuration <= TimeSpan.Zero)
|
||||
{
|
||||
yield return new ValidationResult("MetadataCacheDuration must be greater than zero.", new[] { nameof(MetadataCacheDuration) });
|
||||
}
|
||||
|
||||
if (PersistOfflineSnapshot && string.IsNullOrWhiteSpace(OfflineSnapshotPath))
|
||||
{
|
||||
yield return new ValidationResult("OfflineSnapshotPath must be provided when PersistOfflineSnapshot is enabled.", new[] { nameof(OfflineSnapshotPath) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
|
||||
public sealed class CiscoConnectorOptionsValidator : IVexConnectorOptionsValidator<CiscoConnectorOptions>
|
||||
{
|
||||
public void Validate(VexConnectorDescriptor descriptor, CiscoConnectorOptions options, IList<string> errors)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(errors);
|
||||
|
||||
var validationResults = new List<ValidationResult>();
|
||||
if (!Validator.TryValidateObject(options, new ValidationContext(options), validationResults, validateAllProperties: true))
|
||||
{
|
||||
foreach (var result in validationResults)
|
||||
{
|
||||
errors.Add(result.ErrorMessage ?? "Cisco connector options validation failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.DependencyInjection;
|
||||
|
||||
public static class CiscoConnectorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCiscoCsafConnector(this IServiceCollection services, Action<CiscoConnectorOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<CiscoConnectorOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
configure?.Invoke(options);
|
||||
})
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IVexConnectorOptionsValidator<CiscoConnectorOptions>, CiscoConnectorOptionsValidator>());
|
||||
|
||||
services.AddHttpClient(CiscoConnectorOptions.HttpClientName)
|
||||
.ConfigureHttpClient((provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<CiscoConnectorOptions>>().Value;
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
if (!string.IsNullOrWhiteSpace(options.ApiToken))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken);
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<CiscoProviderMetadataLoader>();
|
||||
services.AddSingleton<IVexConnector, CiscoCsafConnector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
|
||||
public sealed class CiscoProviderMetadataLoader
|
||||
{
|
||||
public const string CacheKey = "StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly ILogger<CiscoProviderMetadataLoader> _logger;
|
||||
private readonly CiscoConnectorOptions _options;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public CiscoProviderMetadataLoader(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache memoryCache,
|
||||
IOptions<CiscoConnectorOptions> options,
|
||||
ILogger<CiscoProviderMetadataLoader> logger,
|
||||
IFileSystem? fileSystem = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_fileSystem = fileSystem ?? new FileSystem();
|
||||
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CiscoProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is not null && !cached.IsExpired())
|
||||
{
|
||||
_logger.LogDebug("Returning cached Cisco provider metadata (expires {Expires}).", cached.ExpiresAt);
|
||||
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is not null && !cached.IsExpired())
|
||||
{
|
||||
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
|
||||
}
|
||||
|
||||
CacheEntry? previous = cached;
|
||||
|
||||
if (!_options.PreferOfflineSnapshot)
|
||||
{
|
||||
var network = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
|
||||
if (network is not null)
|
||||
{
|
||||
StoreCache(network);
|
||||
return new CiscoProviderMetadataResult(network.Provider, network.FetchedAt, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
var offline = TryLoadFromOffline();
|
||||
if (offline is not null)
|
||||
{
|
||||
var entry = offline with
|
||||
{
|
||||
FetchedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
||||
FromOffline = true,
|
||||
};
|
||||
StoreCache(entry);
|
||||
return new CiscoProviderMetadataResult(entry.Provider, entry.FetchedAt, true, false);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to load Cisco CSAF provider metadata from network or offline snapshot.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
|
||||
{
|
||||
request.Headers.IfNoneMatch.Add(etag);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
|
||||
{
|
||||
_logger.LogDebug("Cisco provider metadata not modified (etag {ETag}).", previous.ETag);
|
||||
return previous with
|
||||
{
|
||||
FetchedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
||||
};
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var provider = ParseProvider(payload);
|
||||
var etagHeader = response.Headers.ETag?.ToString();
|
||||
|
||||
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
|
||||
_logger.LogDebug("Persisted Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
return new CacheEntry(
|
||||
provider,
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
||||
etagHeader,
|
||||
FromOffline: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch Cisco provider metadata from {Uri}; falling back to offline snapshot when available.", _options.MetadataUri);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private CacheEntry? TryLoadFromOffline()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
|
||||
{
|
||||
_logger.LogWarning("Cisco offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
|
||||
var provider = ParseProvider(payload);
|
||||
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, null, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load Cisco provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private VexProvider ParseProvider(string payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
throw new InvalidOperationException("Cisco provider metadata payload was empty.");
|
||||
}
|
||||
|
||||
ProviderMetadataDocument? document;
|
||||
try
|
||||
{
|
||||
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse Cisco provider metadata.", ex);
|
||||
}
|
||||
|
||||
if (document?.Metadata?.Publisher?.ContactDetails is null || string.IsNullOrWhiteSpace(document.Metadata.Publisher.ContactDetails.Id))
|
||||
{
|
||||
throw new InvalidOperationException("Cisco provider metadata did not include a publisher identifier.");
|
||||
}
|
||||
|
||||
var discovery = new VexProviderDiscovery(document.Discovery?.WellKnown, document.Discovery?.RolIe);
|
||||
var trust = document.Trust is null
|
||||
? VexProviderTrust.Default
|
||||
: new VexProviderTrust(
|
||||
document.Trust.Weight ?? 1.0,
|
||||
document.Trust.Cosign is null ? null : new VexCosignTrust(document.Trust.Cosign.Issuer ?? string.Empty, document.Trust.Cosign.IdentityPattern ?? string.Empty),
|
||||
document.Trust.PgpFingerprints ?? Enumerable.Empty<string>());
|
||||
|
||||
var directories = document.Distributions?.Directories is null
|
||||
? Enumerable.Empty<Uri>()
|
||||
: document.Distributions.Directories
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)!
|
||||
.Select(static uri => uri!);
|
||||
|
||||
return new VexProvider(
|
||||
id: document.Metadata.Publisher.ContactDetails.Id,
|
||||
displayName: document.Metadata.Publisher.Name ?? document.Metadata.Publisher.ContactDetails.Id,
|
||||
kind: document.Metadata.Publisher.Category?.Equals("vendor", StringComparison.OrdinalIgnoreCase) == true ? VexProviderKind.Vendor : VexProviderKind.Hub,
|
||||
baseUris: directories,
|
||||
discovery: discovery,
|
||||
trust: trust,
|
||||
enabled: true);
|
||||
}
|
||||
|
||||
private void StoreCache(CacheEntry entry)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = entry.ExpiresAt,
|
||||
};
|
||||
_memoryCache.Set(CacheKey, entry, options);
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(
|
||||
VexProvider Provider,
|
||||
DateTimeOffset FetchedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
string? ETag,
|
||||
bool FromOffline)
|
||||
{
|
||||
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CiscoProviderMetadataResult(
|
||||
VexProvider Provider,
|
||||
DateTimeOffset FetchedAt,
|
||||
bool FromOfflineSnapshot,
|
||||
bool ServedFromCache);
|
||||
|
||||
#region document models
|
||||
|
||||
internal sealed class ProviderMetadataDocument
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("metadata")]
|
||||
public ProviderMetadataMetadata Metadata { get; set; } = new();
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("discovery")]
|
||||
public ProviderMetadataDiscovery? Discovery { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("trust")]
|
||||
public ProviderMetadataTrust? Trust { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("distributions")]
|
||||
public ProviderMetadataDistributions? Distributions { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataMetadata
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("publisher")]
|
||||
public ProviderMetadataPublisher Publisher { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataPublisher
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("category")]
|
||||
public string? Category { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("contact_details")]
|
||||
public ProviderMetadataPublisherContact ContactDetails { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataPublisherContact
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataDiscovery
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("well_known")]
|
||||
public Uri? WellKnown { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("rolie")]
|
||||
public Uri? RolIe { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataTrust
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("weight")]
|
||||
public double? Weight { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("cosign")]
|
||||
public ProviderMetadataTrustCosign? Cosign { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("pgp_fingerprints")]
|
||||
public string[]? PgpFingerprints { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataTrustCosign
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("identity_pattern")]
|
||||
public string? IdentityPattern { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProviderMetadataDistributions
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("directories")]
|
||||
public string[]? Directories { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -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="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
7
src/StellaOps.Excititor.Connectors.Cisco.CSAF/TASKS.md
Normal file
7
src/StellaOps.Excititor.Connectors.Cisco.CSAF/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
||||
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-CISCO-01-001 – Endpoint discovery & auth plumbing|Team Excititor Connectors – Cisco|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added `CiscoProviderMetadataLoader` with bearer token support, offline snapshot fallback, DI helpers, and tests covering network/offline discovery to unblock subsequent fetch work.|
|
||||
|EXCITITOR-CONN-CISCO-01-002 – CSAF pull loop & pagination|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-17)** – Implemented paginated advisory fetch using provider directories, raw document persistence with dedupe/state tracking, offline resiliency, and unit coverage.|
|
||||
|EXCITITOR-CONN-CISCO-01-003 – Provider trust metadata|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-002, EXCITITOR-POLICY-01-001|TODO – Emit cosign/PGP trust metadata and advisory provenance hints for policy weighting.|
|
||||
Reference in New Issue
Block a user