Rename Vexer to Excititor
This commit is contained in:
23
src/StellaOps.Excititor.Connectors.MSRC.CSAF/AGENTS.md
Normal file
23
src/StellaOps.Excititor.Connectors.MSRC.CSAF/AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Connector for Microsoft Security Response Center (MSRC) CSAF advisories, handling authenticated downloads, throttling, and raw document persistence.
|
||||
## Scope
|
||||
- MSRC API onboarding (AAD client credentials), metadata discovery, and CSAF listing retrieval.
|
||||
- Download pipeline with retry/backoff, checksum validation, and document deduplication.
|
||||
- Mapping MSRC-specific identifiers (CVE, ADV, KB) and remediation guidance into connector metadata.
|
||||
- Emitting trust metadata (AAD issuer, signing certificates) for policy weighting.
|
||||
## Participants
|
||||
- Worker schedules MSRC pulls honoring rate limits; WebService may trigger manual runs for urgent updates.
|
||||
- CSAF normalizer processes retrieved documents into claims.
|
||||
- Policy subsystem references connector trust hints for consensus scoring.
|
||||
## Interfaces & contracts
|
||||
- Implements `IVexConnector`, requires configuration options for tenant/client/secret or managed identity.
|
||||
- Uses shared HTTP helpers, resume markers, and telemetry from Abstractions module.
|
||||
## In/Out of scope
|
||||
In: authenticated fetching, raw document storage, metadata mapping, retry logic.
|
||||
Out: normalization/export, attestation, storage implementations (handled elsewhere).
|
||||
## Observability & security expectations
|
||||
- Log request batches, rate-limit responses, and token refresh events without leaking secrets.
|
||||
- Track metrics for documents fetched, retries, and failure categories.
|
||||
## Tests
|
||||
- Connector tests with mocked MSRC endpoints and AAD token flow will live in `../StellaOps.Excititor.Connectors.MSRC.CSAF.Tests`.
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
|
||||
|
||||
public interface IMsrcTokenProvider
|
||||
{
|
||||
ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MsrcTokenProvider : IMsrcTokenProvider, IDisposable
|
||||
{
|
||||
private const string CachePrefix = "StellaOps.Excititor.Connectors.MSRC.CSAF.Token";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<MsrcTokenProvider> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly MsrcConnectorOptions _options;
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
||||
public MsrcTokenProvider(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
IFileSystem fileSystem,
|
||||
IOptions<MsrcConnectorOptions> options,
|
||||
ILogger<MsrcTokenProvider> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate(_fileSystem);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.PreferOfflineToken)
|
||||
{
|
||||
return LoadOfflineToken();
|
||||
}
|
||||
|
||||
var cacheKey = CreateCacheKey();
|
||||
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out var cachedToken) &&
|
||||
cachedToken is not null &&
|
||||
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_cache.TryGetValue<MsrcAccessToken>(cacheKey, out cachedToken) &&
|
||||
cachedToken is not null &&
|
||||
!cachedToken.IsExpired(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
var token = await RequestTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var absoluteExpiration = token.ExpiresAt == DateTimeOffset.MaxValue
|
||||
? (DateTimeOffset?)null
|
||||
: token.ExpiresAt;
|
||||
|
||||
var options = new MemoryCacheEntryOptions();
|
||||
if (absoluteExpiration.HasValue)
|
||||
{
|
||||
options.AbsoluteExpiration = absoluteExpiration.Value;
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, token, options);
|
||||
return token;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private MsrcAccessToken LoadOfflineToken()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.StaticAccessToken))
|
||||
{
|
||||
return new MsrcAccessToken(_options.StaticAccessToken!, "Bearer", DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.OfflineTokenPath))
|
||||
{
|
||||
throw new InvalidOperationException("Offline token mode is enabled but no token was provided.");
|
||||
}
|
||||
|
||||
if (!_fileSystem.File.Exists(_options.OfflineTokenPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Offline token path '{_options.OfflineTokenPath}' does not exist.");
|
||||
}
|
||||
|
||||
var token = _fileSystem.File.ReadAllText(_options.OfflineTokenPath).Trim();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
throw new InvalidOperationException("Offline token file was empty.");
|
||||
}
|
||||
|
||||
return new MsrcAccessToken(token, "Bearer", DateTimeOffset.MaxValue);
|
||||
}
|
||||
|
||||
private async Task<MsrcAccessToken> RequestTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Fetching MSRC AAD access token for tenant {TenantId}.", _options.TenantId);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(MsrcConnectorOptions.TokenClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, BuildTokenUri())
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = _options.ClientId,
|
||||
["client_secret"] = _options.ClientSecret!,
|
||||
["grant_type"] = "client_credentials",
|
||||
["scope"] = string.IsNullOrWhiteSpace(_options.Scope) ? MsrcConnectorOptions.DefaultScope : _options.Scope,
|
||||
}),
|
||||
};
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to acquire MSRC access token ({(int)response.StatusCode}). Response: {payload}");
|
||||
}
|
||||
|
||||
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Token endpoint returned an empty payload.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenResponse.AccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("Token endpoint response did not include an access_token.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = tokenResponse.ExpiresIn > _options.ExpiryLeewaySeconds
|
||||
? now.AddSeconds(tokenResponse.ExpiresIn - _options.ExpiryLeewaySeconds)
|
||||
: now.AddMinutes(5);
|
||||
|
||||
return new MsrcAccessToken(tokenResponse.AccessToken!, tokenResponse.TokenType ?? "Bearer", expiresAt);
|
||||
}
|
||||
|
||||
private string CreateCacheKey()
|
||||
=> $"{CachePrefix}:{_options.TenantId}:{_options.ClientId}:{_options.Scope}";
|
||||
|
||||
private Uri BuildTokenUri()
|
||||
=> new($"https://login.microsoftonline.com/{_options.TenantId}/oauth2/v2.0/token");
|
||||
|
||||
public void Dispose() => _refreshLock.Dispose();
|
||||
|
||||
private sealed record TokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string? AccessToken { get; init; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string? TokenType { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MsrcAccessToken(string Value, string Type, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
|
||||
public sealed class MsrcConnectorOptions
|
||||
{
|
||||
public const string TokenClientName = "excititor.connector.msrc.token";
|
||||
public const string DefaultScope = "https://api.msrc.microsoft.com/.default";
|
||||
|
||||
/// <summary>
|
||||
/// Azure AD tenant identifier (GUID or domain).
|
||||
/// </summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Azure AD application (client) identifier.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Azure AD application secret for client credential flow.
|
||||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
/// <summary>
|
||||
/// OAuth scope requested for MSRC API access.
|
||||
/// </summary>
|
||||
public string Scope { get; set; } = DefaultScope;
|
||||
|
||||
/// <summary>
|
||||
/// When true, token acquisition is skipped and the connector expects offline handling.
|
||||
/// </summary>
|
||||
public bool PreferOfflineToken { get; set; }
|
||||
/// <summary>
|
||||
/// Optional path to a pre-provisioned bearer token used when <see cref="PreferOfflineToken"/> is enabled.
|
||||
/// </summary>
|
||||
public string? OfflineTokenPath { get; set; }
|
||||
/// <summary>
|
||||
/// Optional fixed bearer token for constrained environments (e.g., short-lived offline bundles).
|
||||
/// </summary>
|
||||
public string? StaticAccessToken { get; set; }
|
||||
/// <summary>
|
||||
/// Minimum buffer (seconds) subtracted from token expiry before refresh.
|
||||
/// </summary>
|
||||
public int ExpiryLeewaySeconds { get; set; } = 60;
|
||||
|
||||
public void Validate(IFileSystem? fileSystem = null)
|
||||
{
|
||||
if (PreferOfflineToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(OfflineTokenPath) && string.IsNullOrWhiteSpace(StaticAccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("OfflineTokenPath or StaticAccessToken must be provided when PreferOfflineToken is enabled.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
throw new InvalidOperationException("TenantId is required when not operating in offline token mode.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientId))
|
||||
{
|
||||
throw new InvalidOperationException("ClientId is required when not operating in offline token mode.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ClientSecret))
|
||||
{
|
||||
throw new InvalidOperationException("ClientSecret is required when not operating in offline token mode.");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Scope))
|
||||
{
|
||||
Scope = DefaultScope;
|
||||
}
|
||||
|
||||
if (ExpiryLeewaySeconds < 10)
|
||||
{
|
||||
ExpiryLeewaySeconds = 10;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OfflineTokenPath))
|
||||
{
|
||||
var fs = fileSystem ?? new FileSystem();
|
||||
var directory = Path.GetDirectoryName(OfflineTokenPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !fs.Directory.Exists(directory))
|
||||
{
|
||||
fs.Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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.MSRC.CSAF.Authentication;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.DependencyInjection;
|
||||
|
||||
public static class MsrcConnectorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddMsrcCsafConnector(this IServiceCollection services, Action<MsrcConnectorOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||
|
||||
services.AddOptions<MsrcConnectorOptions>()
|
||||
.Configure(options => configure?.Invoke(options));
|
||||
|
||||
services.AddHttpClient(MsrcConnectorOptions.TokenClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.MSRC.CSAF/1.0");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(static () => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
});
|
||||
|
||||
services.AddSingleton<IMsrcTokenProvider, MsrcTokenProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<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" />
|
||||
</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.MSRC.CSAF/TASKS.md
Normal file
7
src/StellaOps.Excititor.Connectors.MSRC.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-MS-01-001 – AAD onboarding & token cache|Team Excititor Connectors – MSRC|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added MSRC connector project with configurable AAD options, token provider (offline/online modes), DI wiring, and unit tests covering caching and fallback scenarios.|
|
||||
|EXCITITOR-CONN-MS-01-002 – CSAF download pipeline|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-001, EXCITITOR-STORAGE-01-003|TODO – Fetch CSAF packages with retry/backoff, checksum verification, and raw document persistence plus quarantine for schema failures.|
|
||||
|EXCITITOR-CONN-MS-01-003 – Trust metadata & provenance hints|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-002, EXCITITOR-POLICY-01-001|TODO – Emit cosign/AAD issuer metadata, attach provenance details, and document policy integration.|
|
||||
Reference in New Issue
Block a user