audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MirrorRateLimitingExtensions.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.2 - Mirror Server Rate Limiting Setup
|
||||
// Description: Extension methods for integrating mirror rate limiting with Router
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring mirror server rate limiting using Router library.
|
||||
/// </summary>
|
||||
public static class MirrorRateLimitingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section path for sources configuration.
|
||||
/// </summary>
|
||||
public const string SourcesConfigSection = "sources";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration section path for mirror server.
|
||||
/// </summary>
|
||||
public const string MirrorServerConfigSection = "sources:mirrorServer";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration section path for rate limits.
|
||||
/// </summary>
|
||||
public const string RateLimitsConfigSection = "sources:mirrorServer:rateLimits";
|
||||
|
||||
/// <summary>
|
||||
/// Adds mirror rate limiting services using Router library integration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration instance.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddMirrorRateLimiting(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var mirrorConfig = configuration
|
||||
.GetSection(RateLimitsConfigSection)
|
||||
.Get<MirrorRateLimitConfig>();
|
||||
|
||||
if (mirrorConfig is null || !mirrorConfig.IsEnabled)
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
mirrorConfig.Validate();
|
||||
|
||||
// Build Router-compatible configuration and register
|
||||
var routerConfigSection = BuildRouterConfiguration(mirrorConfig);
|
||||
|
||||
// Register Router rate limiting services
|
||||
// This maps our MirrorRateLimitConfig to Router's RateLimitConfig
|
||||
var routerConfig = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(routerConfigSection)
|
||||
.Build();
|
||||
|
||||
// Register the Router rate limiting services
|
||||
// Note: The actual Router library would have its own AddRouterRateLimiting extension
|
||||
// This is a bridge to that library
|
||||
services.Configure<MirrorRateLimitConfig>(
|
||||
configuration.GetSection(RateLimitsConfigSection));
|
||||
|
||||
// Register middleware options
|
||||
services.AddSingleton(mirrorConfig);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds rate limiting middleware for mirror endpoints.
|
||||
/// </summary>
|
||||
/// <param name="app">Application builder.</param>
|
||||
/// <returns>Application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseMirrorRateLimiting(this IApplicationBuilder app)
|
||||
{
|
||||
var config = app.ApplicationServices.GetService<MirrorRateLimitConfig>();
|
||||
|
||||
if (config is null || !config.IsEnabled)
|
||||
{
|
||||
return app;
|
||||
}
|
||||
|
||||
// Apply rate limiting to mirror endpoints
|
||||
app.UseWhen(
|
||||
context => context.Request.Path.StartsWithSegments("/api/mirror"),
|
||||
branch =>
|
||||
{
|
||||
// The Router library middleware would be used here
|
||||
// branch.UseMiddleware<RateLimitMiddleware>();
|
||||
|
||||
// For now, use our custom middleware adapter
|
||||
branch.UseMiddleware<MirrorRateLimitMiddleware>();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds Router-compatible configuration from MirrorRateLimitConfig.
|
||||
/// </summary>
|
||||
private static IEnumerable<KeyValuePair<string, string?>> BuildRouterConfiguration(
|
||||
MirrorRateLimitConfig mirrorConfig)
|
||||
{
|
||||
var config = new Dictionary<string, string?>();
|
||||
|
||||
// Map activation threshold
|
||||
config["rate_limiting:process_back_pressure_when_more_than_per_5min"] =
|
||||
mirrorConfig.ActivationThresholdPer5Min.ToString();
|
||||
|
||||
// Map instance-level config
|
||||
if (mirrorConfig.ForInstance is not null)
|
||||
{
|
||||
config["rate_limiting:for_instance:per_seconds"] =
|
||||
mirrorConfig.ForInstance.PerSeconds.ToString();
|
||||
config["rate_limiting:for_instance:max_requests"] =
|
||||
mirrorConfig.ForInstance.MaxRequests.ToString();
|
||||
|
||||
if (mirrorConfig.ForInstance.AllowBurstForSeconds.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_instance:allow_burst_for_seconds"] =
|
||||
mirrorConfig.ForInstance.AllowBurstForSeconds.Value.ToString();
|
||||
}
|
||||
|
||||
if (mirrorConfig.ForInstance.AllowMaxBurstRequests.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_instance:allow_max_burst_requests"] =
|
||||
mirrorConfig.ForInstance.AllowMaxBurstRequests.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Map environment-level config
|
||||
if (mirrorConfig.ForEnvironment is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(mirrorConfig.ForEnvironment.ValkeyConnection))
|
||||
{
|
||||
config["rate_limiting:for_environment:valkey_connection"] =
|
||||
mirrorConfig.ForEnvironment.ValkeyConnection;
|
||||
}
|
||||
|
||||
config["rate_limiting:for_environment:valkey_bucket"] =
|
||||
mirrorConfig.ForEnvironment.ValkeyBucket;
|
||||
config["rate_limiting:for_environment:per_seconds"] =
|
||||
mirrorConfig.ForEnvironment.PerSeconds.ToString();
|
||||
config["rate_limiting:for_environment:max_requests"] =
|
||||
mirrorConfig.ForEnvironment.MaxRequests.ToString();
|
||||
|
||||
if (mirrorConfig.ForEnvironment.AllowBurstForSeconds.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_environment:allow_burst_for_seconds"] =
|
||||
mirrorConfig.ForEnvironment.AllowBurstForSeconds.Value.ToString();
|
||||
}
|
||||
|
||||
if (mirrorConfig.ForEnvironment.AllowMaxBurstRequests.HasValue)
|
||||
{
|
||||
config["rate_limiting:for_environment:allow_max_burst_requests"] =
|
||||
mirrorConfig.ForEnvironment.AllowMaxBurstRequests.Value.ToString();
|
||||
}
|
||||
|
||||
// Map route-specific limits
|
||||
foreach (var (routeName, routeConfig) in mirrorConfig.ForEnvironment.Routes)
|
||||
{
|
||||
var routePrefix = $"rate_limiting:for_environment:microservices:mirror:routes:{routeName}";
|
||||
|
||||
config[$"{routePrefix}:pattern"] = routeConfig.Pattern;
|
||||
config[$"{routePrefix}:match_type"] = routeConfig.MatchType.ToString();
|
||||
config[$"{routePrefix}:per_seconds"] = routeConfig.PerSeconds.ToString();
|
||||
config[$"{routePrefix}:max_requests"] = routeConfig.MaxRequests.ToString();
|
||||
|
||||
if (routeConfig.AllowBurstForSeconds.HasValue)
|
||||
{
|
||||
config[$"{routePrefix}:allow_burst_for_seconds"] =
|
||||
routeConfig.AllowBurstForSeconds.Value.ToString();
|
||||
}
|
||||
|
||||
if (routeConfig.AllowMaxBurstRequests.HasValue)
|
||||
{
|
||||
config[$"{routePrefix}:allow_max_burst_requests"] =
|
||||
routeConfig.AllowMaxBurstRequests.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Map circuit breaker config
|
||||
if (mirrorConfig.ForEnvironment.CircuitBreaker is not null)
|
||||
{
|
||||
config["rate_limiting:for_environment:circuit_breaker:failure_threshold"] =
|
||||
mirrorConfig.ForEnvironment.CircuitBreaker.FailureThreshold.ToString();
|
||||
config["rate_limiting:for_environment:circuit_breaker:timeout_seconds"] =
|
||||
mirrorConfig.ForEnvironment.CircuitBreaker.TimeoutSeconds.ToString();
|
||||
config["rate_limiting:for_environment:circuit_breaker:half_open_timeout"] =
|
||||
mirrorConfig.ForEnvironment.CircuitBreaker.HalfOpenTimeoutSeconds.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middleware for applying rate limits to mirror endpoints.
|
||||
/// Bridges to Router library rate limiting.
|
||||
/// </summary>
|
||||
public class MirrorRateLimitMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly MirrorRateLimitConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Simple in-memory counters for instance-level limiting
|
||||
// Environment-level would use Valkey via Router library
|
||||
private readonly Dictionary<string, RateLimitCounter> _counters = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public MirrorRateLimitMiddleware(
|
||||
RequestDelegate next,
|
||||
MirrorRateLimitConfig config,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_next = next;
|
||||
_config = config;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!_config.IsEnabled)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
var clientId = GetClientIdentifier(context);
|
||||
|
||||
// Check instance-level limits first
|
||||
if (_config.ForInstance is not null)
|
||||
{
|
||||
var (allowed, retryAfter) = CheckInstanceLimit(clientId, path);
|
||||
if (!allowed)
|
||||
{
|
||||
await WriteRateLimitResponse(context, retryAfter, "instance");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Environment-level checking would go through Router's ValkeyRateLimitStore
|
||||
// For now, we skip environment checks if no Valkey is configured
|
||||
if (_config.ForEnvironment is not null &&
|
||||
!string.IsNullOrWhiteSpace(_config.ForEnvironment.ValkeyConnection))
|
||||
{
|
||||
// TODO: Integrate with Router's EnvironmentRateLimiter via Valkey
|
||||
// var (allowed, retryAfter) = await CheckEnvironmentLimitAsync(clientId, path);
|
||||
// if (!allowed) { ... }
|
||||
}
|
||||
|
||||
// Add rate limit headers
|
||||
AddRateLimitHeaders(context, path);
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private (bool Allowed, TimeSpan RetryAfter) CheckInstanceLimit(string clientId, string path)
|
||||
{
|
||||
if (_config.ForInstance is null)
|
||||
return (true, TimeSpan.Zero);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var window = TimeSpan.FromSeconds(_config.ForInstance.PerSeconds);
|
||||
var key = $"{clientId}:{path}";
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_counters.TryGetValue(key, out var counter) ||
|
||||
now - counter.WindowStart >= window)
|
||||
{
|
||||
counter = new RateLimitCounter(now, 0);
|
||||
}
|
||||
|
||||
if (counter.Count >= _config.ForInstance.MaxRequests)
|
||||
{
|
||||
var windowEnd = counter.WindowStart + window;
|
||||
var retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
|
||||
return (false, retryAfter);
|
||||
}
|
||||
|
||||
_counters[key] = counter with { Count = counter.Count + 1 };
|
||||
return (true, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetClientIdentifier(HttpContext context)
|
||||
{
|
||||
// Use client IP or authenticated user ID
|
||||
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(forwardedFor))
|
||||
{
|
||||
return forwardedFor.Split(',')[0].Trim();
|
||||
}
|
||||
|
||||
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private void AddRateLimitHeaders(HttpContext context, string path)
|
||||
{
|
||||
if (_config.ForInstance is not null)
|
||||
{
|
||||
context.Response.Headers["X-RateLimit-Limit"] =
|
||||
_config.ForInstance.MaxRequests.ToString();
|
||||
context.Response.Headers["X-RateLimit-Window"] =
|
||||
_config.ForInstance.PerSeconds.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteRateLimitResponse(
|
||||
HttpContext context,
|
||||
TimeSpan retryAfter,
|
||||
string scope)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.Response.Headers["Retry-After"] =
|
||||
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
context.Response.Headers["X-RateLimit-Scope"] = scope;
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "rate_limit_exceeded",
|
||||
message = $"Mirror rate limit exceeded. Try again in {(int)retryAfter.TotalSeconds} seconds.",
|
||||
retryAfter = (int)retryAfter.TotalSeconds,
|
||||
scope
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record RateLimitCounter(DateTimeOffset WindowStart, int Count);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MirrorRateLimitConfig.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.1 - Rate Limit Configuration Models
|
||||
// Description: Rate limiting configuration for mirror server endpoints.
|
||||
// Maps to Router library's RateLimitConfig structure.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration for mirror server endpoints.
|
||||
/// This maps to Router library's <c>RateLimitConfig</c> for Gateway execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configuration example:
|
||||
/// <code>
|
||||
/// mirrorServer:
|
||||
/// rateLimits:
|
||||
/// forInstance:
|
||||
/// perSeconds: 60
|
||||
/// maxRequests: 100
|
||||
/// forEnvironment:
|
||||
/// valkeyConnection: "localhost:6379"
|
||||
/// perSeconds: 3600
|
||||
/// maxRequests: 10000
|
||||
/// routes:
|
||||
/// index:
|
||||
/// pattern: "/api/mirror/index"
|
||||
/// perSeconds: 60
|
||||
/// maxRequests: 60
|
||||
/// bundle:
|
||||
/// pattern: "/api/mirror/bundle/*"
|
||||
/// perSeconds: 60
|
||||
/// maxRequests: 600
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed record MirrorRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Instance-level rate limits (in-memory, per-process).
|
||||
/// Maps to Router's <c>rate_limiting.for_instance</c>.
|
||||
/// </summary>
|
||||
public InstanceRateLimitConfig? ForInstance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment-level rate limits (Valkey-backed, distributed).
|
||||
/// Maps to Router's <c>rate_limiting.for_environment</c>.
|
||||
/// </summary>
|
||||
public EnvironmentRateLimitConfig? ForEnvironment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Activation threshold: only check environment limits when traffic exceeds this per 5 min.
|
||||
/// Set to 0 to always check. Default: 1000.
|
||||
/// </summary>
|
||||
public int ActivationThresholdPer5Min { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether rate limiting is configured (at least one scope defined).
|
||||
/// </summary>
|
||||
public bool IsEnabled => ForInstance is not null || ForEnvironment is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public MirrorRateLimitConfig Validate()
|
||||
{
|
||||
if (ActivationThresholdPer5Min < 0)
|
||||
throw new ArgumentException("Activation threshold must be >= 0", nameof(ActivationThresholdPer5Min));
|
||||
|
||||
ForInstance?.Validate();
|
||||
ForEnvironment?.Validate();
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instance-level rate limit configuration (in-memory, per-process).
|
||||
/// </summary>
|
||||
public sealed record InstanceRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Time window in seconds for the rate limit.
|
||||
/// </summary>
|
||||
public int PerSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests allowed in the time window.
|
||||
/// </summary>
|
||||
public int MaxRequests { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Optional burst window in seconds.
|
||||
/// </summary>
|
||||
public int? AllowBurstForSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum burst requests allowed.
|
||||
/// </summary>
|
||||
public int? AllowMaxBurstRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException("PerSeconds must be > 0", nameof(PerSeconds));
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException("MaxRequests must be > 0", nameof(MaxRequests));
|
||||
|
||||
if (AllowBurstForSeconds is <= 0)
|
||||
throw new ArgumentException("AllowBurstForSeconds must be > 0 if specified", nameof(AllowBurstForSeconds));
|
||||
|
||||
if (AllowMaxBurstRequests is <= 0)
|
||||
throw new ArgumentException("AllowMaxBurstRequests must be > 0 if specified", nameof(AllowMaxBurstRequests));
|
||||
|
||||
// Both burst values must be set together or neither
|
||||
if ((AllowBurstForSeconds.HasValue) != (AllowMaxBurstRequests.HasValue))
|
||||
throw new ArgumentException("AllowBurstForSeconds and AllowMaxBurstRequests must both be set or neither");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment-level rate limit configuration (Valkey-backed, distributed).
|
||||
/// </summary>
|
||||
public sealed record EnvironmentRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Valkey connection string.
|
||||
/// </summary>
|
||||
public string? ValkeyConnection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Valkey bucket/prefix for rate limit keys.
|
||||
/// </summary>
|
||||
public string ValkeyBucket { get; init; } = "stella-mirror-rate-limit";
|
||||
|
||||
/// <summary>
|
||||
/// Time window in seconds.
|
||||
/// </summary>
|
||||
public int PerSeconds { get; init; } = 3600;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests in the time window.
|
||||
/// </summary>
|
||||
public int MaxRequests { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Optional burst window in seconds.
|
||||
/// </summary>
|
||||
public int? AllowBurstForSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum burst requests allowed.
|
||||
/// </summary>
|
||||
public int? AllowMaxBurstRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-route rate limit overrides.
|
||||
/// Keys are route names (e.g., "index", "bundle"), values are route configs.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, RouteRateLimitConfig> Routes { get; init; }
|
||||
= ImmutableDictionary<string, RouteRateLimitConfig>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker configuration for Valkey resilience.
|
||||
/// </summary>
|
||||
public CircuitBreakerConfig? CircuitBreaker { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ValkeyBucket))
|
||||
throw new ArgumentException("ValkeyBucket is required", nameof(ValkeyBucket));
|
||||
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException("PerSeconds must be > 0", nameof(PerSeconds));
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException("MaxRequests must be > 0", nameof(MaxRequests));
|
||||
|
||||
if (AllowBurstForSeconds is <= 0)
|
||||
throw new ArgumentException("AllowBurstForSeconds must be > 0 if specified", nameof(AllowBurstForSeconds));
|
||||
|
||||
if (AllowMaxBurstRequests is <= 0)
|
||||
throw new ArgumentException("AllowMaxBurstRequests must be > 0 if specified", nameof(AllowMaxBurstRequests));
|
||||
|
||||
// Both burst values must be set together or neither
|
||||
if ((AllowBurstForSeconds.HasValue) != (AllowMaxBurstRequests.HasValue))
|
||||
throw new ArgumentException("AllowBurstForSeconds and AllowMaxBurstRequests must both be set or neither");
|
||||
|
||||
foreach (var (name, config) in Routes)
|
||||
{
|
||||
config.Validate(name);
|
||||
}
|
||||
|
||||
CircuitBreaker?.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-route rate limit configuration.
|
||||
/// </summary>
|
||||
public sealed record RouteRateLimitConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Route pattern: exact ("/api/mirror/index"), prefix ("/api/mirror/bundle/*"), or regex.
|
||||
/// </summary>
|
||||
public required string Pattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern match type: Exact, Prefix, or Regex.
|
||||
/// </summary>
|
||||
public RouteMatchType MatchType { get; init; } = RouteMatchType.Prefix;
|
||||
|
||||
/// <summary>
|
||||
/// Time window in seconds.
|
||||
/// </summary>
|
||||
public int PerSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests in the time window.
|
||||
/// </summary>
|
||||
public int MaxRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional burst window in seconds.
|
||||
/// </summary>
|
||||
public int? AllowBurstForSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum burst requests allowed.
|
||||
/// </summary>
|
||||
public int? AllowMaxBurstRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate(string routeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Pattern))
|
||||
throw new ArgumentException($"Route '{routeName}': Pattern is required");
|
||||
|
||||
if (PerSeconds <= 0)
|
||||
throw new ArgumentException($"Route '{routeName}': PerSeconds must be > 0");
|
||||
|
||||
if (MaxRequests <= 0)
|
||||
throw new ArgumentException($"Route '{routeName}': MaxRequests must be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Route pattern match type.
|
||||
/// </summary>
|
||||
public enum RouteMatchType
|
||||
{
|
||||
/// <summary>Exact path match.</summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>Prefix match (pattern ends with *).</summary>
|
||||
Prefix,
|
||||
|
||||
/// <summary>Regular expression match.</summary>
|
||||
Regex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker configuration for Valkey resilience.
|
||||
/// </summary>
|
||||
public sealed record CircuitBreakerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of failures before opening the circuit.
|
||||
/// </summary>
|
||||
public int FailureThreshold { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds to keep circuit open before attempting recovery.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds in half-open state before full reset.
|
||||
/// </summary>
|
||||
public int HalfOpenTimeoutSeconds { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (FailureThreshold < 1)
|
||||
throw new ArgumentException("FailureThreshold must be >= 1", nameof(FailureThreshold));
|
||||
|
||||
if (TimeoutSeconds < 1)
|
||||
throw new ArgumentException("TimeoutSeconds must be >= 1", nameof(TimeoutSeconds));
|
||||
|
||||
if (HalfOpenTimeoutSeconds < 1)
|
||||
throw new ArgumentException("HalfOpenTimeoutSeconds must be >= 1", nameof(HalfOpenTimeoutSeconds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceConfiguration.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.1 - Configuration Models
|
||||
// Description: Root configuration for advisory data sources and mirror server
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for advisory data sources.
|
||||
/// Supports direct upstream sources (NVD, OSV, GHSA, etc.) and StellaOps mirrors.
|
||||
/// </summary>
|
||||
public sealed record SourcesConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Source mode: "direct" for upstream sources, "mirror" for StellaOps mirrors.
|
||||
/// Default is "mirror" for simpler setup.
|
||||
/// </summary>
|
||||
public SourceMode Mode { get; init; } = SourceMode.Mirror;
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps mirror endpoint when using mirror mode.
|
||||
/// </summary>
|
||||
public string? MirrorEndpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mirror server configuration when exposing gathered data as a mirror.
|
||||
/// </summary>
|
||||
public MirrorServerConfig MirrorServer { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Individual source configurations (NVD, OSV, GHSA, etc.).
|
||||
/// Each source can be enabled/disabled and configured independently.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, SourceConfig> Sources { get; init; }
|
||||
= ImmutableDictionary<string, SourceConfig>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-enable sources that pass connectivity checks.
|
||||
/// When true, all sources are enabled by default during setup.
|
||||
/// </summary>
|
||||
public bool AutoEnableHealthySources { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for connectivity checks in seconds.
|
||||
/// </summary>
|
||||
public int ConnectivityCheckTimeoutSeconds { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source mode for advisory data ingestion.
|
||||
/// </summary>
|
||||
public enum SourceMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Direct connection to upstream sources (NVD, OSV, GHSA, etc.).
|
||||
/// Requires individual source credentials and network access.
|
||||
/// </summary>
|
||||
Direct,
|
||||
|
||||
/// <summary>
|
||||
/// Connection to StellaOps pre-aggregated mirror.
|
||||
/// Simpler setup, single endpoint, pre-normalized data.
|
||||
/// </summary>
|
||||
Mirror,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid mode: use mirror as primary, fall back to direct sources.
|
||||
/// </summary>
|
||||
Hybrid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for an individual advisory source.
|
||||
/// </summary>
|
||||
public sealed record SourceConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this source is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Priority for merge ordering (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// API key or token for authenticated sources.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom endpoint URL (overrides default).
|
||||
/// </summary>
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request delay between API calls (rate limiting).
|
||||
/// </summary>
|
||||
public TimeSpan RequestDelay { get; init; } = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
/// <summary>
|
||||
/// Backoff duration after failures.
|
||||
/// </summary>
|
||||
public TimeSpan FailureBackoff { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum pages to fetch per sync cycle.
|
||||
/// </summary>
|
||||
public int MaxPagesPerFetch { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Source-specific metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; }
|
||||
= ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for exposing gathered data as a mirror server.
|
||||
/// </summary>
|
||||
public sealed record MirrorServerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the mirror server is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root directory for mirror exports.
|
||||
/// </summary>
|
||||
public string ExportRoot { get; init; } = "./exports/mirror";
|
||||
|
||||
/// <summary>
|
||||
/// Authentication mode for mirror clients.
|
||||
/// </summary>
|
||||
public MirrorAuthMode Authentication { get; init; } = MirrorAuthMode.Anonymous;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth configuration when using OAuth authentication.
|
||||
/// </summary>
|
||||
public OAuthMirrorConfig? OAuth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting configuration for mirror endpoints.
|
||||
/// Maps to Router library rate limiting.
|
||||
/// </summary>
|
||||
public MirrorRateLimitConfig RateLimits { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signing key path for DSSE attestations on mirror bundles.
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include attestations in mirror bundles.
|
||||
/// </summary>
|
||||
public bool IncludeAttestations { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentication mode for mirror server.
|
||||
/// </summary>
|
||||
public enum MirrorAuthMode
|
||||
{
|
||||
/// <summary>
|
||||
/// No authentication required (open access).
|
||||
/// </summary>
|
||||
Anonymous,
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 2.0 client credentials flow.
|
||||
/// </summary>
|
||||
OAuth,
|
||||
|
||||
/// <summary>
|
||||
/// API key-based authentication.
|
||||
/// </summary>
|
||||
ApiKey,
|
||||
|
||||
/// <summary>
|
||||
/// mTLS client certificate authentication.
|
||||
/// </summary>
|
||||
Mtls
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OAuth configuration for mirror server authentication.
|
||||
/// </summary>
|
||||
public sealed record OAuthMirrorConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// OAuth issuer URL (for discovery).
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Issuer { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Required audience in access tokens.
|
||||
/// </summary>
|
||||
public string? Audience { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required scopes for access.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RequiredScopes { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate issuer HTTPS metadata.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Token clock skew tolerance.
|
||||
/// </summary>
|
||||
public TimeSpan ClockSkew { get; init; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISourceRegistry.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Registry Interface
|
||||
// Description: Interface for managing and checking advisory source connectivity
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for managing advisory data sources (NVD, OSV, GHSA, etc.).
|
||||
/// Provides connectivity checking and auto-configuration capabilities.
|
||||
/// </summary>
|
||||
public interface ISourceRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all registered source definitions.
|
||||
/// </summary>
|
||||
IReadOnlyList<SourceDefinition> GetAllSources();
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific source definition by ID.
|
||||
/// </summary>
|
||||
SourceDefinition? GetSource(string sourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by category.
|
||||
/// </summary>
|
||||
IReadOnlyList<SourceDefinition> GetSourcesByCategory(SourceCategory category);
|
||||
|
||||
/// <summary>
|
||||
/// Check connectivity for a specific source.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">Source identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connectivity result with status and error details if failed.</returns>
|
||||
Task<SourceConnectivityResult> CheckConnectivityAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check all sources and auto-configure based on availability.
|
||||
/// Sources that pass connectivity checks are enabled by default.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Aggregated check result with enabled/disabled sources.</returns>
|
||||
Task<SourceCheckResult> CheckAllAndAutoConfigureAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check connectivity for multiple sources in parallel.
|
||||
/// </summary>
|
||||
/// <param name="sourceIds">Source identifiers to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Individual results for each source.</returns>
|
||||
Task<ImmutableArray<SourceConnectivityResult>> CheckMultipleAsync(
|
||||
IEnumerable<string> sourceIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enable a source for data ingestion.
|
||||
/// </summary>
|
||||
Task<bool> EnableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Disable a source.
|
||||
/// </summary>
|
||||
Task<bool> DisableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get currently enabled sources.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<string>> GetEnabledSourcesAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a specific source is enabled.
|
||||
/// </summary>
|
||||
bool IsEnabled(string sourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Retry connectivity check for a failed source.
|
||||
/// </summary>
|
||||
Task<SourceConnectivityResult> RetryCheckAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the last connectivity check result for a source.
|
||||
/// </summary>
|
||||
SourceConnectivityResult? GetLastCheckResult(string sourceId);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceCheckResult.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Check Aggregation
|
||||
// Description: Aggregated result of checking multiple sources
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated result of checking all sources for connectivity.
|
||||
/// Used by the setup wizard to auto-enable healthy sources.
|
||||
/// </summary>
|
||||
public sealed record SourceCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Individual results for each checked source.
|
||||
/// </summary>
|
||||
public ImmutableArray<SourceConnectivityResult> Results { get; init; }
|
||||
= ImmutableArray<SourceConnectivityResult>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source IDs that passed connectivity checks and are auto-enabled.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EnabledSources { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source IDs that failed connectivity checks.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> DisabledSources { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source IDs that are degraded but still enabled.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> DegradedSources { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the check was performed.
|
||||
/// </summary>
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration of all checks.
|
||||
/// </summary>
|
||||
public TimeSpan TotalDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether all sources are healthy.
|
||||
/// </summary>
|
||||
public bool AllHealthy => DisabledSources.Length == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any sources failed.
|
||||
/// </summary>
|
||||
public bool HasFailures => DisabledSources.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of sources checked.
|
||||
/// </summary>
|
||||
public int TotalChecked => Results.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Number of healthy sources.
|
||||
/// </summary>
|
||||
public int HealthyCount => EnabledSources.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Number of failed sources.
|
||||
/// </summary>
|
||||
public int FailedCount => DisabledSources.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Summary message for display.
|
||||
/// </summary>
|
||||
public string Summary => AllHealthy
|
||||
? $"All {TotalChecked} sources are healthy"
|
||||
: $"{HealthyCount}/{TotalChecked} sources healthy, {FailedCount} failed";
|
||||
|
||||
/// <summary>
|
||||
/// Get results grouped by category.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<SourceCategory, ImmutableArray<SourceConnectivityResult>> ByCategory(
|
||||
ISourceRegistry registry)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<SourceCategory, ImmutableArray<SourceConnectivityResult>>();
|
||||
|
||||
var groups = Results
|
||||
.GroupBy(r => registry.GetSource(r.SourceId)?.Category ?? SourceCategory.Other)
|
||||
.ToDictionary(g => g.Key, g => g.ToImmutableArray());
|
||||
|
||||
foreach (var (category, results) in groups)
|
||||
{
|
||||
builder[category] = results;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get failed results for display.
|
||||
/// </summary>
|
||||
public ImmutableArray<SourceConnectivityResult> GetFailedResults()
|
||||
=> Results.Where(r => r.Status == SourceConnectivityStatus.Failed).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Create an empty result.
|
||||
/// </summary>
|
||||
public static SourceCheckResult Empty(DateTimeOffset? checkedAt = null)
|
||||
=> new()
|
||||
{
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
TotalDuration = TimeSpan.Zero
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create result from individual results.
|
||||
/// </summary>
|
||||
public static SourceCheckResult FromResults(
|
||||
IEnumerable<SourceConnectivityResult> results,
|
||||
DateTimeOffset checkedAt,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var resultArray = results.ToImmutableArray();
|
||||
var enabled = new List<string>();
|
||||
var disabled = new List<string>();
|
||||
var degraded = new List<string>();
|
||||
|
||||
foreach (var result in resultArray)
|
||||
{
|
||||
switch (result.Status)
|
||||
{
|
||||
case SourceConnectivityStatus.Healthy:
|
||||
enabled.Add(result.SourceId);
|
||||
break;
|
||||
case SourceConnectivityStatus.Degraded:
|
||||
enabled.Add(result.SourceId);
|
||||
degraded.Add(result.SourceId);
|
||||
break;
|
||||
case SourceConnectivityStatus.Failed:
|
||||
disabled.Add(result.SourceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new SourceCheckResult
|
||||
{
|
||||
Results = resultArray,
|
||||
EnabledSources = enabled.ToImmutableArray(),
|
||||
DisabledSources = disabled.ToImmutableArray(),
|
||||
DegradedSources = degraded.ToImmutableArray(),
|
||||
CheckedAt = checkedAt,
|
||||
TotalDuration = duration
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceConnectivityResult.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Connectivity Models
|
||||
// Description: Result types for source connectivity checks
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a connectivity check for a single source.
|
||||
/// </summary>
|
||||
public sealed record SourceConnectivityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Source identifier.
|
||||
/// </summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Connectivity status.
|
||||
/// </summary>
|
||||
public SourceConnectivityStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the check was performed.
|
||||
/// </summary>
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Response latency if successful.
|
||||
/// </summary>
|
||||
public TimeSpan? Latency { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code for categorization.
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code if applicable.
|
||||
/// </summary>
|
||||
public int? HttpStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Possible reasons for the failure (for user display).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> PossibleReasons { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Steps to remediate the issue.
|
||||
/// </summary>
|
||||
public ImmutableArray<RemediationStep> RemediationSteps { get; init; }
|
||||
= ImmutableArray<RemediationStep>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL for more information.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional diagnostic data.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Diagnostics { get; init; }
|
||||
= ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source is healthy (can be enabled).
|
||||
/// </summary>
|
||||
public bool IsHealthy => Status is SourceConnectivityStatus.Healthy or SourceConnectivityStatus.Degraded;
|
||||
|
||||
/// <summary>
|
||||
/// Create a healthy result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult Healthy(
|
||||
string sourceId,
|
||||
TimeSpan latency,
|
||||
DateTimeOffset? checkedAt = null)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Healthy,
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a degraded result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult Degraded(
|
||||
string sourceId,
|
||||
TimeSpan latency,
|
||||
string message,
|
||||
DateTimeOffset? checkedAt = null)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Degraded,
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
Latency = latency,
|
||||
ErrorMessage = message
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult Failed(
|
||||
string sourceId,
|
||||
string errorCode,
|
||||
string errorMessage,
|
||||
ImmutableArray<string> possibleReasons,
|
||||
ImmutableArray<RemediationStep> remediationSteps,
|
||||
DateTimeOffset? checkedAt = null,
|
||||
TimeSpan? latency = null,
|
||||
int? httpStatusCode = null)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Failed,
|
||||
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
|
||||
Latency = latency,
|
||||
ErrorCode = errorCode,
|
||||
ErrorMessage = errorMessage,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
PossibleReasons = possibleReasons,
|
||||
RemediationSteps = remediationSteps
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a not-found result.
|
||||
/// </summary>
|
||||
public static SourceConnectivityResult NotFound(string sourceId)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
Status = SourceConnectivityStatus.Failed,
|
||||
CheckedAt = DateTimeOffset.UtcNow,
|
||||
ErrorCode = "SOURCE_NOT_FOUND",
|
||||
ErrorMessage = $"Source '{sourceId}' is not registered",
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The source ID may be misspelled",
|
||||
"This source may not be available in your region"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep { Order = 1, Description = "Verify the source ID is correct" },
|
||||
new RemediationStep { Order = 2, Description = "Run 'stella sources list' to see available sources" })
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connectivity status for a source.
|
||||
/// </summary>
|
||||
public enum SourceConnectivityStatus
|
||||
{
|
||||
/// <summary>Source is unknown or not checked.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Source is fully available.</summary>
|
||||
Healthy,
|
||||
|
||||
/// <summary>Source is available but with issues (slow, rate limited, etc.).</summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>Source is not available.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Connectivity check is in progress.</summary>
|
||||
Checking,
|
||||
|
||||
/// <summary>Source is disabled by configuration.</summary>
|
||||
Disabled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A step to remediate a connectivity issue.
|
||||
/// </summary>
|
||||
public sealed record RemediationStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step order (1-based).
|
||||
/// </summary>
|
||||
public int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the step.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional command to run.
|
||||
/// </summary>
|
||||
public string? Command { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of command (bash, powershell, url, etc.).
|
||||
/// </summary>
|
||||
public CommandType CommandType { get; init; } = CommandType.Bash;
|
||||
|
||||
/// <summary>
|
||||
/// URL for more information.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of remediation command.
|
||||
/// </summary>
|
||||
public enum CommandType
|
||||
{
|
||||
/// <summary>Bash/shell command.</summary>
|
||||
Bash,
|
||||
|
||||
/// <summary>PowerShell command.</summary>
|
||||
PowerShell,
|
||||
|
||||
/// <summary>URL to open.</summary>
|
||||
Url,
|
||||
|
||||
/// <summary>StellaOps CLI command.</summary>
|
||||
StellaCli,
|
||||
|
||||
/// <summary>Environment variable to set.</summary>
|
||||
EnvVar
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceDefinitions.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Catalog
|
||||
// Description: Static catalog of all supported advisory data sources
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Definition of an advisory data source.
|
||||
/// </summary>
|
||||
public sealed record SourceDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the source (e.g., "nvd", "ghsa", "osv").
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source category.
|
||||
/// </summary>
|
||||
public SourceCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type (upstream, mirror, etc.).
|
||||
/// </summary>
|
||||
public SourceType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Brief description of the source.
|
||||
/// </summary>
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Base API endpoint URL.
|
||||
/// </summary>
|
||||
public required string BaseEndpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoint for connectivity verification.
|
||||
/// </summary>
|
||||
public required string HealthCheckEndpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Named HTTP client for this source.
|
||||
/// </summary>
|
||||
public string HttpClientName { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Whether authentication is required.
|
||||
/// </summary>
|
||||
public bool RequiresAuthentication { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name for API key/token.
|
||||
/// </summary>
|
||||
public string? CredentialEnvVar { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for obtaining credentials.
|
||||
/// </summary>
|
||||
public string? CredentialUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status page URL for the source.
|
||||
/// </summary>
|
||||
public string? StatusPageUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default priority for merge ordering.
|
||||
/// </summary>
|
||||
public int DefaultPriority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Geographic regions this source covers (if region-specific).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Regions { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source is enabled by default.
|
||||
/// </summary>
|
||||
public bool EnabledByDefault { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tags for filtering/grouping sources.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of advisory source.
|
||||
/// </summary>
|
||||
public enum SourceCategory
|
||||
{
|
||||
/// <summary>Primary vulnerability databases (NVD, OSV).</summary>
|
||||
Primary,
|
||||
|
||||
/// <summary>Vendor-specific advisories (Red Hat, Microsoft).</summary>
|
||||
Vendor,
|
||||
|
||||
/// <summary>Linux distribution advisories (Debian, Ubuntu).</summary>
|
||||
Distribution,
|
||||
|
||||
/// <summary>Language ecosystem advisories (npm, PyPI).</summary>
|
||||
Ecosystem,
|
||||
|
||||
/// <summary>National CERTs and government sources.</summary>
|
||||
Cert,
|
||||
|
||||
/// <summary>CSAF/VEX document sources.</summary>
|
||||
Csaf,
|
||||
|
||||
/// <summary>Exploit and threat intelligence sources.</summary>
|
||||
Threat,
|
||||
|
||||
/// <summary>StellaOps mirrors.</summary>
|
||||
Mirror,
|
||||
|
||||
/// <summary>Other/uncategorized sources.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of source connection.
|
||||
/// </summary>
|
||||
public enum SourceType
|
||||
{
|
||||
/// <summary>Direct upstream API connection.</summary>
|
||||
Upstream,
|
||||
|
||||
/// <summary>StellaOps pre-aggregated mirror.</summary>
|
||||
StellaMirror,
|
||||
|
||||
/// <summary>Local file-based source.</summary>
|
||||
LocalFile,
|
||||
|
||||
/// <summary>Custom/user-defined source.</summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static catalog of all supported sources.
|
||||
/// </summary>
|
||||
public static class SourceDefinitions
|
||||
{
|
||||
// ===== Primary Databases =====
|
||||
|
||||
public static readonly SourceDefinition Nvd = new()
|
||||
{
|
||||
Id = "nvd",
|
||||
DisplayName = "NVD (NIST)",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "NIST National Vulnerability Database",
|
||||
BaseEndpoint = "https://services.nvd.nist.gov/rest/json/cves/2.0",
|
||||
HealthCheckEndpoint = "https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=1",
|
||||
HttpClientName = "NvdClient",
|
||||
RequiresAuthentication = false, // Optional but recommended
|
||||
CredentialEnvVar = "NVD_API_KEY",
|
||||
CredentialUrl = "https://nvd.nist.gov/developers/request-an-api-key",
|
||||
StatusPageUrl = "https://nvd.nist.gov/",
|
||||
DocumentationUrl = "https://nvd.nist.gov/developers/vulnerabilities",
|
||||
DefaultPriority = 10,
|
||||
Tags = ImmutableArray.Create("cve", "primary", "global")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Osv = new()
|
||||
{
|
||||
Id = "osv",
|
||||
DisplayName = "OSV (Google)",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Open Source Vulnerabilities database",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "OsvClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://osv.dev",
|
||||
DocumentationUrl = "https://osv.dev/docs/",
|
||||
DefaultPriority = 15,
|
||||
Tags = ImmutableArray.Create("osv", "primary", "ecosystem")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Ghsa = new()
|
||||
{
|
||||
Id = "ghsa",
|
||||
DisplayName = "GitHub Security Advisories",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "GitHub Security Advisories database",
|
||||
BaseEndpoint = "https://api.github.com/graphql",
|
||||
HealthCheckEndpoint = "https://api.github.com/zen",
|
||||
HttpClientName = "GhsaClient",
|
||||
RequiresAuthentication = true,
|
||||
CredentialEnvVar = "GITHUB_PAT",
|
||||
CredentialUrl = "https://github.com/settings/tokens",
|
||||
StatusPageUrl = "https://www.githubstatus.com/",
|
||||
DocumentationUrl = "https://docs.github.com/en/graphql/reference/objects#securityadvisory",
|
||||
DefaultPriority = 20,
|
||||
Tags = ImmutableArray.Create("github", "primary", "ecosystem")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Cve = new()
|
||||
{
|
||||
Id = "cve",
|
||||
DisplayName = "CVE.org (MITRE)",
|
||||
Category = SourceCategory.Primary,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "MITRE CVE Program",
|
||||
BaseEndpoint = "https://cveawg.mitre.org/api/",
|
||||
HealthCheckEndpoint = "https://cveawg.mitre.org/api/cve/CVE-2021-44228",
|
||||
HttpClientName = "CveClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://cve.mitre.org/",
|
||||
DocumentationUrl = "https://cveawg.mitre.org/api/",
|
||||
DefaultPriority = 5,
|
||||
Tags = ImmutableArray.Create("cve", "primary", "authoritative")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Epss = new()
|
||||
{
|
||||
Id = "epss",
|
||||
DisplayName = "EPSS (FIRST)",
|
||||
Category = SourceCategory.Threat,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Exploit Prediction Scoring System",
|
||||
BaseEndpoint = "https://api.first.org/data/v1",
|
||||
HealthCheckEndpoint = "https://api.first.org/data/v1/epss?cve=CVE-2021-44228",
|
||||
HttpClientName = "EpssClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://www.first.org/epss/",
|
||||
DocumentationUrl = "https://www.first.org/epss/api",
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("epss", "threat", "scoring")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Kev = new()
|
||||
{
|
||||
Id = "kev",
|
||||
DisplayName = "CISA KEV",
|
||||
Category = SourceCategory.Threat,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Known Exploited Vulnerabilities Catalog",
|
||||
BaseEndpoint = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
||||
HealthCheckEndpoint = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
|
||||
HttpClientName = "KevClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
||||
DocumentationUrl = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
||||
DefaultPriority = 25,
|
||||
Tags = ImmutableArray.Create("kev", "threat", "exploit")
|
||||
};
|
||||
|
||||
// ===== Vendor Advisories =====
|
||||
|
||||
public static readonly SourceDefinition RedHat = new()
|
||||
{
|
||||
Id = "redhat",
|
||||
DisplayName = "Red Hat Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Red Hat Security Data API",
|
||||
BaseEndpoint = "https://access.redhat.com/hydra/rest/securitydata/",
|
||||
HealthCheckEndpoint = "https://access.redhat.com/hydra/rest/securitydata/cve.json?per_page=1",
|
||||
HttpClientName = "RedHatClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://status.redhat.com/",
|
||||
DocumentationUrl = "https://access.redhat.com/documentation/en-us/red_hat_security_data_api/",
|
||||
DefaultPriority = 30,
|
||||
Tags = ImmutableArray.Create("redhat", "vendor", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Microsoft = new()
|
||||
{
|
||||
Id = "microsoft",
|
||||
DisplayName = "Microsoft Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Microsoft Security Response Center",
|
||||
BaseEndpoint = "https://api.msrc.microsoft.com/sug/v2.0/en-US/",
|
||||
HealthCheckEndpoint = "https://api.msrc.microsoft.com/sug/v2.0/en-US/affectedProduct",
|
||||
HttpClientName = "MsrcClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://msrc.microsoft.com/",
|
||||
DocumentationUrl = "https://msrc.microsoft.com/update-guide/",
|
||||
DefaultPriority = 35,
|
||||
Tags = ImmutableArray.Create("microsoft", "vendor", "windows")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Amazon = new()
|
||||
{
|
||||
Id = "amazon",
|
||||
DisplayName = "Amazon Linux Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Amazon Linux Security Center",
|
||||
BaseEndpoint = "https://alas.aws.amazon.com/",
|
||||
HealthCheckEndpoint = "https://alas.aws.amazon.com/alas.rss",
|
||||
HttpClientName = "AmazonClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://status.aws.amazon.com/",
|
||||
DefaultPriority = 40,
|
||||
Tags = ImmutableArray.Create("amazon", "vendor", "cloud", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Google = new()
|
||||
{
|
||||
Id = "google",
|
||||
DisplayName = "Google Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Google Security Bulletins",
|
||||
BaseEndpoint = "https://source.android.com/docs/security/bulletin/",
|
||||
HealthCheckEndpoint = "https://source.android.com/docs/security/bulletin/",
|
||||
HttpClientName = "GoogleClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 45,
|
||||
Tags = ImmutableArray.Create("google", "vendor", "android")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Oracle = new()
|
||||
{
|
||||
Id = "oracle",
|
||||
DisplayName = "Oracle Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Oracle Security Alerts",
|
||||
BaseEndpoint = "https://www.oracle.com/security-alerts/",
|
||||
HealthCheckEndpoint = "https://www.oracle.com/security-alerts/",
|
||||
HttpClientName = "OracleClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("oracle", "vendor", "java")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Apple = new()
|
||||
{
|
||||
Id = "apple",
|
||||
DisplayName = "Apple Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Apple Security Updates",
|
||||
BaseEndpoint = "https://support.apple.com/en-us/HT201222",
|
||||
HealthCheckEndpoint = "https://support.apple.com/en-us/HT201222",
|
||||
HttpClientName = "AppleClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 55,
|
||||
Tags = ImmutableArray.Create("apple", "vendor", "macos", "ios")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Cisco = new()
|
||||
{
|
||||
Id = "cisco",
|
||||
DisplayName = "Cisco Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Cisco Security Advisories",
|
||||
BaseEndpoint = "https://tools.cisco.com/security/center/publicationService.x",
|
||||
HealthCheckEndpoint = "https://tools.cisco.com/security/center/publicationListing.x",
|
||||
HttpClientName = "CiscoClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://status.cisco.com/",
|
||||
DefaultPriority = 60,
|
||||
Tags = ImmutableArray.Create("cisco", "vendor", "network")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Fortinet = new()
|
||||
{
|
||||
Id = "fortinet",
|
||||
DisplayName = "Fortinet PSIRT",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Fortinet Product Security Incident Response Team",
|
||||
BaseEndpoint = "https://www.fortiguard.com/psirt",
|
||||
HealthCheckEndpoint = "https://www.fortiguard.com/psirt",
|
||||
HttpClientName = "FortinetClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 65,
|
||||
Tags = ImmutableArray.Create("fortinet", "vendor", "network", "security")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Juniper = new()
|
||||
{
|
||||
Id = "juniper",
|
||||
DisplayName = "Juniper Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Juniper Security Advisories",
|
||||
BaseEndpoint = "https://supportportal.juniper.net/s/",
|
||||
HealthCheckEndpoint = "https://supportportal.juniper.net/s/",
|
||||
HttpClientName = "JuniperClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 70,
|
||||
Tags = ImmutableArray.Create("juniper", "vendor", "network")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Palo = new()
|
||||
{
|
||||
Id = "paloalto",
|
||||
DisplayName = "Palo Alto Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Palo Alto Networks Security Advisories",
|
||||
BaseEndpoint = "https://security.paloaltonetworks.com/",
|
||||
HealthCheckEndpoint = "https://security.paloaltonetworks.com/",
|
||||
HttpClientName = "PaloClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 75,
|
||||
Tags = ImmutableArray.Create("paloalto", "vendor", "network", "security")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Vmware = new()
|
||||
{
|
||||
Id = "vmware",
|
||||
DisplayName = "VMware Security",
|
||||
Category = SourceCategory.Vendor,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "VMware Security Advisories",
|
||||
BaseEndpoint = "https://www.vmware.com/security/advisories.html",
|
||||
HealthCheckEndpoint = "https://www.vmware.com/security/advisories.html",
|
||||
HttpClientName = "VmwareClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 80,
|
||||
Tags = ImmutableArray.Create("vmware", "vendor", "virtualization")
|
||||
};
|
||||
|
||||
// ===== Linux Distributions =====
|
||||
|
||||
public static readonly SourceDefinition Debian = new()
|
||||
{
|
||||
Id = "debian",
|
||||
DisplayName = "Debian Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Debian Security Tracker",
|
||||
BaseEndpoint = "https://security-tracker.debian.org/tracker/data/json",
|
||||
HealthCheckEndpoint = "https://security-tracker.debian.org/tracker/",
|
||||
HttpClientName = "DebianClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://security-tracker.debian.org/tracker/",
|
||||
DocumentationUrl = "https://www.debian.org/security/",
|
||||
DefaultPriority = 30,
|
||||
Tags = ImmutableArray.Create("debian", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Ubuntu = new()
|
||||
{
|
||||
Id = "ubuntu",
|
||||
DisplayName = "Ubuntu Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Ubuntu Security Notices",
|
||||
BaseEndpoint = "https://ubuntu.com/security/cves.json",
|
||||
HealthCheckEndpoint = "https://ubuntu.com/security/cves.json?limit=1",
|
||||
HttpClientName = "UbuntuClient",
|
||||
RequiresAuthentication = false,
|
||||
StatusPageUrl = "https://ubuntu.com/security",
|
||||
DefaultPriority = 32,
|
||||
Tags = ImmutableArray.Create("ubuntu", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Alpine = new()
|
||||
{
|
||||
Id = "alpine",
|
||||
DisplayName = "Alpine Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Alpine Linux Security Database",
|
||||
BaseEndpoint = "https://secdb.alpinelinux.org/",
|
||||
HealthCheckEndpoint = "https://secdb.alpinelinux.org/",
|
||||
HttpClientName = "AlpineClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 34,
|
||||
Tags = ImmutableArray.Create("alpine", "distro", "linux", "container")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Suse = new()
|
||||
{
|
||||
Id = "suse",
|
||||
DisplayName = "SUSE Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "SUSE Security Updates",
|
||||
BaseEndpoint = "https://www.suse.com/support/update/",
|
||||
HealthCheckEndpoint = "https://www.suse.com/support/update/",
|
||||
HttpClientName = "SuseClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 36,
|
||||
Tags = ImmutableArray.Create("suse", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Rhel = new()
|
||||
{
|
||||
Id = "rhel",
|
||||
DisplayName = "RHEL Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Red Hat Enterprise Linux Security",
|
||||
BaseEndpoint = "https://access.redhat.com/hydra/rest/securitydata/",
|
||||
HealthCheckEndpoint = "https://access.redhat.com/hydra/rest/securitydata/cve.json?per_page=1",
|
||||
HttpClientName = "RhelClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 38,
|
||||
Tags = ImmutableArray.Create("rhel", "distro", "linux", "enterprise")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Centos = new()
|
||||
{
|
||||
Id = "centos",
|
||||
DisplayName = "CentOS Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "CentOS Security Updates",
|
||||
BaseEndpoint = "https://lists.centos.org/pipermail/centos-announce/",
|
||||
HealthCheckEndpoint = "https://lists.centos.org/pipermail/centos-announce/",
|
||||
HttpClientName = "CentosClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 40,
|
||||
Tags = ImmutableArray.Create("centos", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Fedora = new()
|
||||
{
|
||||
Id = "fedora",
|
||||
DisplayName = "Fedora Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Fedora Security Updates",
|
||||
BaseEndpoint = "https://bodhi.fedoraproject.org/updates/",
|
||||
HealthCheckEndpoint = "https://bodhi.fedoraproject.org/updates/?status=stable&type=security&rows_per_page=1",
|
||||
HttpClientName = "FedoraClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 42,
|
||||
Tags = ImmutableArray.Create("fedora", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Arch = new()
|
||||
{
|
||||
Id = "arch",
|
||||
DisplayName = "Arch Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Arch Linux Security Tracker",
|
||||
BaseEndpoint = "https://security.archlinux.org/",
|
||||
HealthCheckEndpoint = "https://security.archlinux.org/issues/all.json",
|
||||
HttpClientName = "ArchClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 44,
|
||||
Tags = ImmutableArray.Create("arch", "distro", "linux")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Gentoo = new()
|
||||
{
|
||||
Id = "gentoo",
|
||||
DisplayName = "Gentoo Security",
|
||||
Category = SourceCategory.Distribution,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Gentoo Linux Security Advisories",
|
||||
BaseEndpoint = "https://security.gentoo.org/glsa/feed.rss",
|
||||
HealthCheckEndpoint = "https://security.gentoo.org/",
|
||||
HttpClientName = "GentooClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 46,
|
||||
Tags = ImmutableArray.Create("gentoo", "distro", "linux")
|
||||
};
|
||||
|
||||
// ===== Language Ecosystems =====
|
||||
|
||||
public static readonly SourceDefinition Npm = new()
|
||||
{
|
||||
Id = "npm",
|
||||
DisplayName = "npm Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "npm Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "NpmClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 50,
|
||||
Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition PyPi = new()
|
||||
{
|
||||
Id = "pypi",
|
||||
DisplayName = "PyPI Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Python Package Index Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "PyPiClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 52,
|
||||
Tags = ImmutableArray.Create("pypi", "ecosystem", "python")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Go = new()
|
||||
{
|
||||
Id = "go",
|
||||
DisplayName = "Go Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Go Vulnerability Database",
|
||||
BaseEndpoint = "https://vuln.go.dev/",
|
||||
HealthCheckEndpoint = "https://vuln.go.dev/",
|
||||
HttpClientName = "GoClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 54,
|
||||
Tags = ImmutableArray.Create("go", "ecosystem", "golang")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition RubyGems = new()
|
||||
{
|
||||
Id = "rubygems",
|
||||
DisplayName = "RubyGems Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "RubyGems Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "RubyGemsClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 56,
|
||||
Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Nuget = new()
|
||||
{
|
||||
Id = "nuget",
|
||||
DisplayName = "NuGet Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "NuGet Security Advisories (via GHSA)",
|
||||
BaseEndpoint = "https://api.github.com/graphql",
|
||||
HealthCheckEndpoint = "https://api.github.com/zen",
|
||||
HttpClientName = "NugetClient",
|
||||
RequiresAuthentication = true,
|
||||
CredentialEnvVar = "GITHUB_PAT",
|
||||
DefaultPriority = 58,
|
||||
Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Maven = new()
|
||||
{
|
||||
Id = "maven",
|
||||
DisplayName = "Maven Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Maven Central Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "MavenClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 60,
|
||||
Tags = ImmutableArray.Create("maven", "ecosystem", "java")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Crates = new()
|
||||
{
|
||||
Id = "crates",
|
||||
DisplayName = "Crates.io Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Rust Crates.io Security Advisories",
|
||||
BaseEndpoint = "https://rustsec.org/advisories/",
|
||||
HealthCheckEndpoint = "https://rustsec.org/advisories/",
|
||||
HttpClientName = "CratesClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 62,
|
||||
Tags = ImmutableArray.Create("crates", "ecosystem", "rust")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Packagist = new()
|
||||
{
|
||||
Id = "packagist",
|
||||
DisplayName = "Packagist Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "PHP Packagist Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "PackagistClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 64,
|
||||
Tags = ImmutableArray.Create("packagist", "ecosystem", "php")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Hex = new()
|
||||
{
|
||||
Id = "hex",
|
||||
DisplayName = "Hex.pm Advisories",
|
||||
Category = SourceCategory.Ecosystem,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Elixir Hex.pm Security Advisories (via OSV)",
|
||||
BaseEndpoint = "https://api.osv.dev/v1",
|
||||
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
|
||||
HttpClientName = "HexClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 66,
|
||||
Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang")
|
||||
};
|
||||
|
||||
// ===== CSAF/VEX Sources =====
|
||||
|
||||
public static readonly SourceDefinition Csaf = new()
|
||||
{
|
||||
Id = "csaf",
|
||||
DisplayName = "CSAF Aggregator",
|
||||
Category = SourceCategory.Csaf,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Common Security Advisory Framework",
|
||||
BaseEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
||||
HealthCheckEndpoint = "https://csaf-aggregator.oasis-open.org/",
|
||||
HttpClientName = "CsafClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 70,
|
||||
Tags = ImmutableArray.Create("csaf", "vex", "structured")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CsafTc = new()
|
||||
{
|
||||
Id = "csaf-tc",
|
||||
DisplayName = "CSAF TC Trusted Publishers",
|
||||
Category = SourceCategory.Csaf,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "OASIS CSAF TC Trusted Publisher List",
|
||||
BaseEndpoint = "https://csaf.io/",
|
||||
HealthCheckEndpoint = "https://csaf.io/",
|
||||
HttpClientName = "CsafTcClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 72,
|
||||
Tags = ImmutableArray.Create("csaf", "oasis", "trusted")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition Vex = new()
|
||||
{
|
||||
Id = "vex",
|
||||
DisplayName = "VEX Hub",
|
||||
Category = SourceCategory.Csaf,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Vulnerability Exploitability eXchange documents",
|
||||
BaseEndpoint = "https://vexhub.example.com/",
|
||||
HealthCheckEndpoint = "https://vexhub.example.com/",
|
||||
HttpClientName = "VexClient",
|
||||
RequiresAuthentication = false,
|
||||
DefaultPriority = 74,
|
||||
Tags = ImmutableArray.Create("vex", "exploitability")
|
||||
};
|
||||
|
||||
// ===== CERTs =====
|
||||
|
||||
public static readonly SourceDefinition CertFr = new()
|
||||
{
|
||||
Id = "cert-fr",
|
||||
DisplayName = "CERT-FR",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "French CERT",
|
||||
BaseEndpoint = "https://www.cert.ssi.gouv.fr/",
|
||||
HealthCheckEndpoint = "https://www.cert.ssi.gouv.fr/",
|
||||
HttpClientName = "CertFrClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("FR", "EU"),
|
||||
DefaultPriority = 80,
|
||||
Tags = ImmutableArray.Create("cert", "france", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertDe = new()
|
||||
{
|
||||
Id = "cert-de",
|
||||
DisplayName = "CERT-Bund (Germany)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "German Federal CERT",
|
||||
BaseEndpoint = "https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Cyber-Sicherheitslage/Technische-Sicherheitshinweise/",
|
||||
HealthCheckEndpoint = "https://www.bsi.bund.de/",
|
||||
HttpClientName = "CertDeClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("DE", "EU"),
|
||||
DefaultPriority = 82,
|
||||
Tags = ImmutableArray.Create("cert", "germany", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertAt = new()
|
||||
{
|
||||
Id = "cert-at",
|
||||
DisplayName = "CERT.at (Austria)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Austrian CERT",
|
||||
BaseEndpoint = "https://cert.at/",
|
||||
HealthCheckEndpoint = "https://cert.at/",
|
||||
HttpClientName = "CertAtClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("AT", "EU"),
|
||||
DefaultPriority = 84,
|
||||
Tags = ImmutableArray.Create("cert", "austria", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertBe = new()
|
||||
{
|
||||
Id = "cert-be",
|
||||
DisplayName = "CERT.be (Belgium)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Belgian CERT",
|
||||
BaseEndpoint = "https://cert.be/",
|
||||
HealthCheckEndpoint = "https://cert.be/",
|
||||
HttpClientName = "CertBeClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("BE", "EU"),
|
||||
DefaultPriority = 86,
|
||||
Tags = ImmutableArray.Create("cert", "belgium", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertCh = new()
|
||||
{
|
||||
Id = "cert-ch",
|
||||
DisplayName = "NCSC-CH (Switzerland)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Swiss National Cyber Security Centre",
|
||||
BaseEndpoint = "https://www.ncsc.admin.ch/",
|
||||
HealthCheckEndpoint = "https://www.ncsc.admin.ch/",
|
||||
HttpClientName = "CertChClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("CH"),
|
||||
DefaultPriority = 88,
|
||||
Tags = ImmutableArray.Create("cert", "switzerland")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition CertEu = new()
|
||||
{
|
||||
Id = "cert-eu",
|
||||
DisplayName = "CERT-EU",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "EU CERT for EU Institutions",
|
||||
BaseEndpoint = "https://cert.europa.eu/",
|
||||
HealthCheckEndpoint = "https://cert.europa.eu/",
|
||||
HttpClientName = "CertEuClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("EU"),
|
||||
DefaultPriority = 90,
|
||||
Tags = ImmutableArray.Create("cert", "eu")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition JpCert = new()
|
||||
{
|
||||
Id = "jpcert",
|
||||
DisplayName = "JPCERT/CC (Japan)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "Japan Computer Emergency Response Team",
|
||||
BaseEndpoint = "https://www.jpcert.or.jp/english/",
|
||||
HealthCheckEndpoint = "https://www.jpcert.or.jp/english/",
|
||||
HttpClientName = "JpCertClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("JP", "APAC"),
|
||||
DefaultPriority = 92,
|
||||
Tags = ImmutableArray.Create("cert", "japan", "apac")
|
||||
};
|
||||
|
||||
public static readonly SourceDefinition UsCert = new()
|
||||
{
|
||||
Id = "us-cert",
|
||||
DisplayName = "CISA (US-CERT)",
|
||||
Category = SourceCategory.Cert,
|
||||
Type = SourceType.Upstream,
|
||||
Description = "US Cybersecurity and Infrastructure Security Agency",
|
||||
BaseEndpoint = "https://www.cisa.gov/news-events/cybersecurity-advisories",
|
||||
HealthCheckEndpoint = "https://www.cisa.gov/",
|
||||
HttpClientName = "UsCertClient",
|
||||
RequiresAuthentication = false,
|
||||
Regions = ImmutableArray.Create("US", "NA"),
|
||||
DefaultPriority = 94,
|
||||
Tags = ImmutableArray.Create("cert", "us", "cisa")
|
||||
};
|
||||
|
||||
// ===== StellaOps Mirror =====
|
||||
|
||||
public static readonly SourceDefinition StellaMirror = new()
|
||||
{
|
||||
Id = "stella-mirror",
|
||||
DisplayName = "StellaOps Mirror",
|
||||
Category = SourceCategory.Mirror,
|
||||
Type = SourceType.StellaMirror,
|
||||
Description = "StellaOps Pre-aggregated Advisory Mirror",
|
||||
BaseEndpoint = "https://mirror.stella-ops.org/api/v1",
|
||||
HealthCheckEndpoint = "https://mirror.stella-ops.org/api/v1/health",
|
||||
HttpClientName = "StellaMirrorClient",
|
||||
RequiresAuthentication = false, // Can be configured for OAuth
|
||||
StatusPageUrl = "https://status.stella-ops.org/",
|
||||
DocumentationUrl = "https://docs.stella-ops.org/mirror/",
|
||||
DefaultPriority = 1, // Highest priority when using mirror mode
|
||||
Tags = ImmutableArray.Create("stella", "mirror", "aggregated")
|
||||
};
|
||||
|
||||
// ===== All Sources Collection =====
|
||||
|
||||
/// <summary>
|
||||
/// All registered source definitions.
|
||||
/// Must be declared after all individual sources due to static initialization order.
|
||||
/// </summary>
|
||||
public static readonly ImmutableArray<SourceDefinition> All = ImmutableArray.Create(
|
||||
// Primary databases
|
||||
Nvd, Osv, Ghsa, Cve, Epss, Kev,
|
||||
// Vendor advisories
|
||||
RedHat, Microsoft, Amazon, Google, Oracle, Apple, Cisco, Fortinet, Juniper, Palo, Vmware,
|
||||
// Linux distributions
|
||||
Debian, Ubuntu, Alpine, Suse, Rhel, Centos, Fedora, Arch, Gentoo,
|
||||
// Ecosystems
|
||||
Npm, PyPi, Go, RubyGems, Nuget, Maven, Crates, Packagist, Hex,
|
||||
// CSAF/VEX
|
||||
Csaf, CsafTc, Vex,
|
||||
// CERTs
|
||||
CertFr, CertDe, CertAt, CertBe, CertCh, CertEu, JpCert, UsCert,
|
||||
// Mirrors
|
||||
StellaMirror);
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by category.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetByCategory(SourceCategory category)
|
||||
=> All.Where(s => s.Category == category).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by tag.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetByTag(string tag)
|
||||
=> All.Where(s => s.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Get sources by region.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetByRegion(string region)
|
||||
=> All.Where(s => s.Regions.Length == 0 || s.Regions.Contains(region, StringComparer.OrdinalIgnoreCase))
|
||||
.ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Get sources that require authentication.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SourceDefinition> GetAuthenticatedSources()
|
||||
=> All.Where(s => s.RequiresAuthentication).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Find a source by ID.
|
||||
/// </summary>
|
||||
public static SourceDefinition? FindById(string sourceId)
|
||||
=> All.FirstOrDefault(s => s.Id.Equals(sourceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceErrorDetails.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Error Details
|
||||
// Description: Detailed error information with why/how explanations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error information for a source connectivity failure.
|
||||
/// Provides "why" and "how to fix" explanations for users.
|
||||
/// </summary>
|
||||
public sealed record SourceErrorDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code for categorization.
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code if applicable.
|
||||
/// </summary>
|
||||
public int? HttpStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Possible reasons for the failure.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> PossibleReasons { get; init; }
|
||||
= ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Steps to remediate the issue.
|
||||
/// </summary>
|
||||
public ImmutableArray<RemediationStep> RemediationSteps { get; init; }
|
||||
= ImmutableArray<RemediationStep>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating source-specific error details.
|
||||
/// </summary>
|
||||
public static class SourceErrorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create error details from an HTTP response.
|
||||
/// </summary>
|
||||
public static SourceErrorDetails FromHttpResponse(
|
||||
SourceDefinition source,
|
||||
HttpStatusCode statusCode,
|
||||
string? responseBody = null)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => CreateAuthError(source, statusCode),
|
||||
HttpStatusCode.Forbidden => CreateForbiddenError(source, statusCode),
|
||||
HttpStatusCode.NotFound => CreateNotFoundError(source, statusCode),
|
||||
HttpStatusCode.TooManyRequests => CreateRateLimitError(source, statusCode),
|
||||
HttpStatusCode.ServiceUnavailable => CreateServiceDownError(source, statusCode),
|
||||
HttpStatusCode.BadGateway => CreateNetworkError(source, statusCode),
|
||||
HttpStatusCode.GatewayTimeout => CreateTimeoutError(source, statusCode),
|
||||
_ => CreateGenericHttpError(source, statusCode, responseBody)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create error details from a network exception.
|
||||
/// </summary>
|
||||
public static SourceErrorDetails FromNetworkException(
|
||||
SourceDefinition source,
|
||||
Exception exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
HttpRequestException httpEx when httpEx.InnerException is System.Net.Sockets.SocketException =>
|
||||
CreateDnsError(source),
|
||||
HttpRequestException httpEx when httpEx.Message.Contains("SSL", StringComparison.OrdinalIgnoreCase) =>
|
||||
CreateSslError(source),
|
||||
TaskCanceledException =>
|
||||
CreateTimeoutError(source, null),
|
||||
_ => CreateNetworkError(source, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateAuthError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
var (envVar, tokenUrl, tokenScopes) = GetSourceCredentialInfo(source.Id);
|
||||
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "AUTH_REQUIRED",
|
||||
Message = $"Authentication failed for {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
$"API key or token for {source.DisplayName} is not set",
|
||||
"The API key may have expired",
|
||||
"The API key may have been revoked"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Obtain an API key from {source.DisplayName}",
|
||||
DocumentationUrl = tokenUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = $"Set the {envVar} environment variable",
|
||||
Command = $"export {envVar}=\"your-api-key\"",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Retry the connectivity check",
|
||||
Command = $"stella sources check --source {source.Id}",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateForbiddenError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
var (envVar, tokenUrl, tokenScopes) = GetSourceCredentialInfo(source.Id);
|
||||
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "ACCESS_DENIED",
|
||||
Message = $"Access denied to {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The API key lacks required permissions/scopes",
|
||||
"Your account may not have access to this resource",
|
||||
"IP-based access restrictions may be in place"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Verify your API key has the required scopes: {tokenScopes}",
|
||||
DocumentationUrl = tokenUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Generate a new API key with correct permissions",
|
||||
CommandType = CommandType.Url,
|
||||
DocumentationUrl = tokenUrl
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateNotFoundError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "ENDPOINT_NOT_FOUND",
|
||||
Message = $"Endpoint not found for {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The API endpoint may have changed",
|
||||
"The source may be temporarily unavailable",
|
||||
"A custom endpoint URL may be incorrect"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Check the {source.DisplayName} status page",
|
||||
DocumentationUrl = source.StatusPageUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Verify custom endpoint configuration if set",
|
||||
Command = "stella config get concelier.sources",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateRateLimitError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
var (envVar, tokenUrl, _) = GetSourceCredentialInfo(source.Id);
|
||||
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "RATE_LIMITED",
|
||||
Message = $"Rate limit exceeded for {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"Too many requests in a short period",
|
||||
"API key may have lower rate limits",
|
||||
"Multiple instances may be sharing the same key"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Wait a few minutes and retry",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Consider using an API key for higher rate limits",
|
||||
DocumentationUrl = tokenUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Adjust request delay in configuration",
|
||||
Command = $"stella config set concelier.sources.{source.Id}.requestDelay 00:00:01",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateServiceDownError(SourceDefinition source, HttpStatusCode statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "SERVICE_DOWN",
|
||||
Message = $"{source.DisplayName} is currently unavailable",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The service may be undergoing maintenance",
|
||||
"There may be an outage affecting the service",
|
||||
"Network routing issues may be occurring"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Check {source.DisplayName} status page",
|
||||
DocumentationUrl = source.StatusPageUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Wait and retry later",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Consider using cached/offline data temporarily",
|
||||
Command = "stella sources enable-offline-fallback",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateNetworkError(SourceDefinition source, HttpStatusCode? statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "NETWORK_ERROR",
|
||||
Message = $"Network error connecting to {source.DisplayName}",
|
||||
HttpStatusCode = statusCode.HasValue ? (int)statusCode.Value : null,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"Firewall may be blocking the connection",
|
||||
"Proxy configuration may be required",
|
||||
"Network connectivity issues"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Check network connectivity",
|
||||
Command = $"curl -v {source.HealthCheckEndpoint}",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Verify firewall rules allow outbound HTTPS",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 3,
|
||||
Description = "Configure proxy if required",
|
||||
Command = "export HTTPS_PROXY=http://proxy:8080",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateDnsError(SourceDefinition source)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "DNS_ERROR",
|
||||
Message = $"DNS resolution failed for {source.DisplayName}",
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"DNS server may be unreachable",
|
||||
"The hostname may be incorrect",
|
||||
"Network configuration issues"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Check DNS resolution",
|
||||
Command = $"nslookup {new Uri(source.HealthCheckEndpoint).Host}",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Verify DNS server configuration",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateSslError(SourceDefinition source)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "SSL_ERROR",
|
||||
Message = $"SSL/TLS error connecting to {source.DisplayName}",
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"SSL certificate may be invalid or expired",
|
||||
"Certificate chain verification failed",
|
||||
"TLS version mismatch"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Verify the SSL certificate",
|
||||
Command = $"openssl s_client -connect {new Uri(source.HealthCheckEndpoint).Host}:443",
|
||||
CommandType = CommandType.Bash
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Update CA certificates if outdated",
|
||||
Command = "sudo update-ca-certificates",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateTimeoutError(SourceDefinition source, HttpStatusCode? statusCode)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "TIMEOUT",
|
||||
Message = $"Connection to {source.DisplayName} timed out",
|
||||
HttpStatusCode = statusCode.HasValue ? (int)statusCode.Value : null,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"The service may be slow or overloaded",
|
||||
"Network latency may be high",
|
||||
"Connection timeout may be too short"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Increase connection timeout",
|
||||
Command = "stella config set concelier.connectivityCheckTimeoutSeconds 60",
|
||||
CommandType = CommandType.StellaCli
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Check network latency",
|
||||
Command = $"ping {new Uri(source.HealthCheckEndpoint).Host}",
|
||||
CommandType = CommandType.Bash
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static SourceErrorDetails CreateGenericHttpError(
|
||||
SourceDefinition source,
|
||||
HttpStatusCode statusCode,
|
||||
string? responseBody)
|
||||
{
|
||||
return new SourceErrorDetails
|
||||
{
|
||||
Code = "HTTP_ERROR",
|
||||
Message = $"HTTP {(int)statusCode} response from {source.DisplayName}",
|
||||
HttpStatusCode = (int)statusCode,
|
||||
PossibleReasons = ImmutableArray.Create(
|
||||
"An unexpected error occurred",
|
||||
"The API may be experiencing issues"),
|
||||
RemediationSteps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = $"Check {source.DisplayName} status page",
|
||||
DocumentationUrl = source.StatusPageUrl,
|
||||
CommandType = CommandType.Url
|
||||
},
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 2,
|
||||
Description = "Retry the connectivity check",
|
||||
Command = $"stella sources check --source {source.Id}",
|
||||
CommandType = CommandType.StellaCli
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static (string EnvVar, string TokenUrl, string Scopes) GetSourceCredentialInfo(string sourceId)
|
||||
{
|
||||
return sourceId.ToUpperInvariant() switch
|
||||
{
|
||||
"NVD" => ("NVD_API_KEY", "https://nvd.nist.gov/developers/request-an-api-key", "N/A"),
|
||||
"GHSA" => ("GITHUB_PAT", "https://github.com/settings/tokens", "read:packages, read:org"),
|
||||
"OSV" => ("OSV_API_KEY", "https://osv.dev", "N/A (optional)"),
|
||||
"EPSS" => ("EPSS_API_KEY", "https://www.first.org/epss/api", "N/A (no auth required)"),
|
||||
"KEV" => ("KEV_API_KEY", "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", "N/A (no auth)"),
|
||||
"REDHAT" => ("REDHAT_API_KEY", "https://access.redhat.com/hydra/rest/securitydata/", "N/A"),
|
||||
"DEBIAN" => ("DEBIAN_API_KEY", "https://security-tracker.debian.org/", "N/A (no auth)"),
|
||||
"UBUNTU" => ("UBUNTU_API_KEY", "https://ubuntu.com/security/cves", "N/A (no auth)"),
|
||||
_ => ($"{sourceId.ToUpperInvariant()}_API_KEY", "", "")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceRegistry.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Registry Implementation
|
||||
// Description: Registry for managing and checking advisory source connectivity
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISourceRegistry"/>.
|
||||
/// Manages source connectivity checking with auto-enable for healthy sources.
|
||||
/// </summary>
|
||||
public sealed class SourceRegistry : ISourceRegistry
|
||||
{
|
||||
private readonly ImmutableArray<SourceDefinition> _sources;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<SourceRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SourcesConfiguration _configuration;
|
||||
private readonly ConcurrentDictionary<string, bool> _enabledSources;
|
||||
private readonly ConcurrentDictionary<string, SourceConnectivityResult> _lastCheckResults;
|
||||
|
||||
public SourceRegistry(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<SourceRegistry> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
SourcesConfiguration? configuration = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_configuration = configuration ?? new SourcesConfiguration();
|
||||
_sources = SourceDefinitions.All;
|
||||
_enabledSources = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
_lastCheckResults = new ConcurrentDictionary<string, SourceConnectivityResult>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Initialize enabled state from definitions
|
||||
foreach (var source in _sources)
|
||||
{
|
||||
_enabledSources[source.Id] = source.EnabledByDefault;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<SourceDefinition> GetAllSources() => _sources;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SourceDefinition? GetSource(string sourceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
return _sources.FirstOrDefault(s => s.Id.Equals(sourceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<SourceDefinition> GetSourcesByCategory(SourceCategory category)
|
||||
=> _sources.Where(s => s.Category == category).ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceConnectivityResult> CheckConnectivityAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
var notFound = SourceConnectivityResult.NotFound(sourceId);
|
||||
_lastCheckResults[sourceId] = notFound;
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var checkedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_configuration.ConnectivityCheckTimeoutSeconds));
|
||||
|
||||
var client = _httpClientFactory.CreateClient(source.HttpClientName);
|
||||
|
||||
// Set appropriate headers for the source
|
||||
ConfigureClientHeaders(client, source);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Checking connectivity for source {SourceId} at {Endpoint}",
|
||||
sourceId, source.HealthCheckEndpoint);
|
||||
|
||||
var response = await client.GetAsync(
|
||||
source.HealthCheckEndpoint,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cts.Token);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = SourceConnectivityResult.Healthy(sourceId, stopwatch.Elapsed, checkedAt);
|
||||
_lastCheckResults[sourceId] = result;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Source {SourceId} is healthy (latency: {Latency}ms)",
|
||||
sourceId, stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle non-success status codes
|
||||
var errorDetails = SourceErrorFactory.FromHttpResponse(source, response.StatusCode);
|
||||
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
errorDetails.Code,
|
||||
errorDetails.Message,
|
||||
errorDetails.PossibleReasons,
|
||||
errorDetails.RemediationSteps,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed,
|
||||
(int)response.StatusCode);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Source {SourceId} failed connectivity check: {StatusCode} - {ErrorMessage}",
|
||||
sourceId, response.StatusCode, errorDetails.Message);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorDetails = SourceErrorFactory.FromNetworkException(source, ex);
|
||||
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
errorDetails.Code,
|
||||
errorDetails.Message,
|
||||
errorDetails.PossibleReasons,
|
||||
errorDetails.RemediationSteps,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogWarning(ex,
|
||||
"Source {SourceId} failed connectivity check (network error): {ErrorMessage}",
|
||||
sourceId, errorDetails.Message);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorDetails = SourceErrorFactory.FromNetworkException(source, new TaskCanceledException());
|
||||
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
errorDetails.Code,
|
||||
errorDetails.Message,
|
||||
errorDetails.PossibleReasons,
|
||||
errorDetails.RemediationSteps,
|
||||
checkedAt,
|
||||
stopwatch.Elapsed);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Source {SourceId} connectivity check timed out after {Timeout}s",
|
||||
sourceId, _configuration.ConnectivityCheckTimeoutSeconds);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var failedResult = SourceConnectivityResult.Failed(
|
||||
sourceId,
|
||||
"UNEXPECTED_ERROR",
|
||||
$"Unexpected error: {ex.Message}",
|
||||
ImmutableArray.Create("An unexpected error occurred during connectivity check"),
|
||||
ImmutableArray.Create(new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Check the logs for detailed error information",
|
||||
CommandType = CommandType.Bash
|
||||
}),
|
||||
checkedAt,
|
||||
stopwatch.Elapsed);
|
||||
|
||||
_lastCheckResults[sourceId] = failedResult;
|
||||
|
||||
_logger.LogError(ex,
|
||||
"Source {SourceId} connectivity check failed with unexpected error",
|
||||
sourceId);
|
||||
|
||||
return failedResult;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceCheckResult> CheckAllAndAutoConfigureAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting connectivity check for {SourceCount} sources",
|
||||
_sources.Length);
|
||||
|
||||
// Check all sources in parallel with limited concurrency
|
||||
var semaphore = new SemaphoreSlim(10); // Max 10 concurrent checks
|
||||
var tasks = _sources.Select(async source =>
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await CheckConnectivityAsync(source.Id, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
stopwatch.Stop();
|
||||
|
||||
// Auto-configure based on results
|
||||
if (_configuration.AutoEnableHealthySources)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
_enabledSources[result.SourceId] = result.IsHealthy;
|
||||
}
|
||||
}
|
||||
|
||||
var checkResult = SourceCheckResult.FromResults(results, startTime, stopwatch.Elapsed);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Connectivity check completed: {HealthyCount}/{Total} healthy, {FailedCount} failed (duration: {Duration}ms)",
|
||||
checkResult.HealthyCount,
|
||||
checkResult.TotalChecked,
|
||||
checkResult.FailedCount,
|
||||
stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
return checkResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<SourceConnectivityResult>> CheckMultipleAsync(
|
||||
IEnumerable<string> sourceIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = sourceIds.ToList();
|
||||
var tasks = ids.Select(id => CheckConnectivityAsync(id, cancellationToken));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> EnableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
var source = GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to enable unknown source: {SourceId}", sourceId);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_enabledSources[sourceId] = true;
|
||||
_logger.LogInformation("Enabled source: {SourceId}", sourceId);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DisableSourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
|
||||
|
||||
var source = GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to disable unknown source: {SourceId}", sourceId);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_enabledSources[sourceId] = false;
|
||||
_logger.LogInformation("Disabled source: {SourceId}", sourceId);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<string>> GetEnabledSourcesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var enabled = _enabledSources
|
||||
.Where(kvp => kvp.Value)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(enabled);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceConnectivityResult> RetryCheckAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Retrying connectivity check for source: {SourceId}", sourceId);
|
||||
return CheckConnectivityAsync(sourceId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the last connectivity check result for a source.
|
||||
/// </summary>
|
||||
public SourceConnectivityResult? GetLastCheckResult(string sourceId)
|
||||
{
|
||||
return _lastCheckResults.GetValueOrDefault(sourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a source is currently enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled(string sourceId)
|
||||
{
|
||||
return _enabledSources.GetValueOrDefault(sourceId);
|
||||
}
|
||||
|
||||
private static void ConfigureClientHeaders(HttpClient client, SourceDefinition source)
|
||||
{
|
||||
// Set a reasonable timeout
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Set User-Agent
|
||||
if (!client.DefaultRequestHeaders.Contains("User-Agent"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add(
|
||||
"User-Agent",
|
||||
$"StellaOps.Concelier/{typeof(SourceRegistry).Assembly.GetName().Version} (+https://stella-ops.org)");
|
||||
}
|
||||
|
||||
// Set Accept header for JSON APIs
|
||||
if (!client.DefaultRequestHeaders.Contains("Accept"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
}
|
||||
|
||||
// Source-specific headers would be configured via named HttpClient in DI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourcesServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: 3.3 - Source Registry DI Registration
|
||||
// Description: Extension methods for registering source services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering advisory source services.
|
||||
/// </summary>
|
||||
public static class SourcesServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds advisory source registry and related services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration instance.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourcesRegistry(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind configuration
|
||||
services.Configure<SourcesConfiguration>(
|
||||
configuration.GetSection("sources"));
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register the source registry
|
||||
services.AddSingleton<ISourceRegistry, SourceRegistry>();
|
||||
|
||||
// Configure HTTP clients for sources
|
||||
ConfigureSourceHttpClients(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds advisory source registry with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configure">Configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourcesRegistry(
|
||||
this IServiceCollection services,
|
||||
Action<SourcesConfiguration> configure)
|
||||
{
|
||||
var config = new SourcesConfiguration();
|
||||
configure(config);
|
||||
|
||||
services.AddSingleton(config);
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register the source registry
|
||||
services.AddSingleton<ISourceRegistry, SourceRegistry>();
|
||||
|
||||
// Configure HTTP clients for sources
|
||||
ConfigureSourceHttpClients(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureSourceHttpClients(IServiceCollection services)
|
||||
{
|
||||
// Configure named HTTP clients for each source
|
||||
// These can be overridden by the application for custom configuration
|
||||
|
||||
// NVD client
|
||||
services.AddHttpClient("NvdClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://services.nvd.nist.gov/rest/json/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// OSV client
|
||||
services.AddHttpClient("OsvClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.osv.dev/v1/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// GHSA client
|
||||
services.AddHttpClient("GhsaClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.github.com/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Concelier");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// KEV client
|
||||
services.AddHttpClient("KevClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://www.cisa.gov/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// EPSS client
|
||||
services.AddHttpClient("EpssClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.first.org/data/v1/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// CVE/MITRE client
|
||||
services.AddHttpClient("CveClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://cveawg.mitre.org/api/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Red Hat client
|
||||
services.AddHttpClient("RedHatClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://access.redhat.com/hydra/rest/securitydata/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Debian client
|
||||
services.AddHttpClient("DebianClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://security-tracker.debian.org/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Ubuntu client
|
||||
services.AddHttpClient("UbuntuClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://ubuntu.com/security/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Alpine client
|
||||
services.AddHttpClient("AlpineClient", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://secdb.alpinelinux.org/");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// StellaOps Mirror client
|
||||
services.AddHttpClient("StellaMirrorClient", client =>
|
||||
{
|
||||
// Base address would be configured from settings
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
|
||||
// Default client for sources without specific configuration
|
||||
services.AddHttpClient("DefaultSourceClient", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.BackportProof.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PackageEcosystem enum.
|
||||
/// </summary>
|
||||
public sealed class PackageEcosystemTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(PackageEcosystem.Deb)]
|
||||
[InlineData(PackageEcosystem.Rpm)]
|
||||
[InlineData(PackageEcosystem.Apk)]
|
||||
[InlineData(PackageEcosystem.Unknown)]
|
||||
public void PackageEcosystem_AllValues_AreDefined(PackageEcosystem ecosystem)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(ecosystem));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackageEcosystem_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<PackageEcosystem>();
|
||||
Assert.Equal(4, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProductContext record.
|
||||
/// </summary>
|
||||
public sealed class ProductContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProductContext_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var context = new ProductContext(
|
||||
Distro: "debian",
|
||||
Release: "bookworm",
|
||||
RepoScope: "main",
|
||||
Architecture: "amd64");
|
||||
|
||||
Assert.Equal("debian", context.Distro);
|
||||
Assert.Equal("bookworm", context.Release);
|
||||
Assert.Equal("main", context.RepoScope);
|
||||
Assert.Equal("amd64", context.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductContext_OptionalProperties_CanBeNull()
|
||||
{
|
||||
var context = new ProductContext(
|
||||
Distro: "alpine",
|
||||
Release: "3.19",
|
||||
RepoScope: null,
|
||||
Architecture: null);
|
||||
|
||||
Assert.Null(context.RepoScope);
|
||||
Assert.Null(context.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductContext_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var c1 = new ProductContext("rhel", "9", "main", "x86_64");
|
||||
var c2 = new ProductContext("rhel", "9", "main", "x86_64");
|
||||
|
||||
Assert.Equal(c1, c2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PackageKey record.
|
||||
/// </summary>
|
||||
public sealed class PackageKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void PackageKey_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var key = new PackageKey(
|
||||
Ecosystem: PackageEcosystem.Deb,
|
||||
PackageName: "nginx",
|
||||
SourcePackageName: "nginx");
|
||||
|
||||
Assert.Equal(PackageEcosystem.Deb, key.Ecosystem);
|
||||
Assert.Equal("nginx", key.PackageName);
|
||||
Assert.Equal("nginx", key.SourcePackageName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackageKey_SourcePackage_CanBeNull()
|
||||
{
|
||||
var key = new PackageKey(
|
||||
Ecosystem: PackageEcosystem.Rpm,
|
||||
PackageName: "httpd",
|
||||
SourcePackageName: null);
|
||||
|
||||
Assert.Null(key.SourcePackageName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EvidenceTier enum.
|
||||
/// </summary>
|
||||
public sealed class EvidenceTierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(EvidenceTier.Unknown, 0)]
|
||||
[InlineData(EvidenceTier.NvdRange, 5)]
|
||||
[InlineData(EvidenceTier.UpstreamCommit, 4)]
|
||||
[InlineData(EvidenceTier.SourcePatch, 3)]
|
||||
[InlineData(EvidenceTier.Changelog, 2)]
|
||||
[InlineData(EvidenceTier.DistroOval, 1)]
|
||||
public void EvidenceTier_Values_HaveCorrectNumericValue(EvidenceTier tier, int expected)
|
||||
{
|
||||
Assert.Equal(expected, (int)tier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceTier_DistroOval_IsHighestConfidence()
|
||||
{
|
||||
// Tier 1 is highest confidence (lowest numeric value)
|
||||
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
|
||||
var highestConfidence = allTiers.OrderBy(t => (int)t).First();
|
||||
|
||||
Assert.Equal(EvidenceTier.DistroOval, highestConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceTier_NvdRange_IsLowestConfidence()
|
||||
{
|
||||
// Tier 5 is lowest confidence (highest numeric value)
|
||||
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
|
||||
var lowestConfidence = allTiers.OrderByDescending(t => (int)t).First();
|
||||
|
||||
Assert.Equal(EvidenceTier.NvdRange, lowestConfidence);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for FixStatus enum.
|
||||
/// </summary>
|
||||
public sealed class FixStatusTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(FixStatus.Patched)]
|
||||
[InlineData(FixStatus.Vulnerable)]
|
||||
[InlineData(FixStatus.NotAffected)]
|
||||
[InlineData(FixStatus.WontFix)]
|
||||
[InlineData(FixStatus.UnderInvestigation)]
|
||||
[InlineData(FixStatus.Unknown)]
|
||||
public void FixStatus_AllValues_AreDefined(FixStatus status)
|
||||
{
|
||||
Assert.True(Enum.IsDefined(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixStatus_AllValues_AreCounted()
|
||||
{
|
||||
var values = Enum.GetValues<FixStatus>();
|
||||
Assert.Equal(6, values.Length);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RulePriority enum.
|
||||
/// </summary>
|
||||
public sealed class RulePriorityTests
|
||||
{
|
||||
[Fact]
|
||||
public void RulePriority_DistroNativeOval_IsHighestPriority()
|
||||
{
|
||||
var allPriorities = Enum.GetValues<RulePriority>();
|
||||
var highest = allPriorities.Max(p => (int)p);
|
||||
|
||||
Assert.Equal((int)RulePriority.DistroNativeOval, highest);
|
||||
Assert.Equal(100, highest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_NvdRangeHeuristic_IsLowestPriority()
|
||||
{
|
||||
var allPriorities = Enum.GetValues<RulePriority>();
|
||||
var lowest = allPriorities.Min(p => (int)p);
|
||||
|
||||
Assert.Equal((int)RulePriority.NvdRangeHeuristic, lowest);
|
||||
Assert.Equal(20, lowest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_LegacyAliases_MatchNewValues()
|
||||
{
|
||||
Assert.Equal(RulePriority.DistroNativeOval, RulePriority.DistroNative);
|
||||
Assert.Equal(RulePriority.ChangelogExplicitCve, RulePriority.VendorCsaf);
|
||||
Assert.Equal(RulePriority.NvdRangeHeuristic, RulePriority.ThirdParty);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RulePriority.NvdRangeHeuristic, 20)]
|
||||
[InlineData(RulePriority.UpstreamCommitPartialMatch, 45)]
|
||||
[InlineData(RulePriority.UpstreamCommitExactParity, 55)]
|
||||
[InlineData(RulePriority.SourcePatchFuzzyMatch, 60)]
|
||||
[InlineData(RulePriority.SourcePatchExactMatch, 70)]
|
||||
[InlineData(RulePriority.ChangelogBugIdMapped, 75)]
|
||||
[InlineData(RulePriority.ChangelogExplicitCve, 85)]
|
||||
[InlineData(RulePriority.DerivativeOvalMedium, 90)]
|
||||
[InlineData(RulePriority.DerivativeOvalHigh, 95)]
|
||||
[InlineData(RulePriority.DistroNativeOval, 100)]
|
||||
public void RulePriority_Values_HaveCorrectNumericValue(RulePriority priority, int expected)
|
||||
{
|
||||
Assert.Equal(expected, (int)priority);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for EvidencePointer record.
|
||||
/// </summary>
|
||||
public sealed class EvidencePointerTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvidencePointer_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var fetchedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: "debian-tracker",
|
||||
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-0001",
|
||||
SourceDigest: "sha256:abc123",
|
||||
FetchedAt: fetchedAt,
|
||||
TierSource: EvidenceTier.DistroOval);
|
||||
|
||||
Assert.Equal("debian-tracker", pointer.SourceType);
|
||||
Assert.Equal("https://security-tracker.debian.org/tracker/CVE-2024-0001", pointer.SourceUrl);
|
||||
Assert.Equal("sha256:abc123", pointer.SourceDigest);
|
||||
Assert.Equal(fetchedAt, pointer.FetchedAt);
|
||||
Assert.Equal(EvidenceTier.DistroOval, pointer.TierSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_TierSource_DefaultsToUnknown()
|
||||
{
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: "nvd",
|
||||
SourceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2024-0001",
|
||||
SourceDigest: null,
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(EvidenceTier.Unknown, pointer.TierSource);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for VersionRange record.
|
||||
/// </summary>
|
||||
public sealed class VersionRangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void VersionRange_FullRange_ContainsAllBoundaries()
|
||||
{
|
||||
var range = new VersionRange(
|
||||
MinVersion: "1.0.0",
|
||||
MinInclusive: true,
|
||||
MaxVersion: "2.0.0",
|
||||
MaxInclusive: false);
|
||||
|
||||
Assert.Equal("1.0.0", range.MinVersion);
|
||||
Assert.True(range.MinInclusive);
|
||||
Assert.Equal("2.0.0", range.MaxVersion);
|
||||
Assert.False(range.MaxInclusive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionRange_OpenEnded_AllowsNullBoundaries()
|
||||
{
|
||||
// All versions up to 2.0.0 (exclusive)
|
||||
var range = new VersionRange(
|
||||
MinVersion: null,
|
||||
MinInclusive: false,
|
||||
MaxVersion: "2.0.0",
|
||||
MaxInclusive: false);
|
||||
|
||||
Assert.Null(range.MinVersion);
|
||||
Assert.Equal("2.0.0", range.MaxVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.BackportProof\StellaOps.Concelier.BackportProof.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -387,6 +387,7 @@ public sealed class CanonicalMergerTests
|
||||
IEnumerable<AffectedPackage>? packages = null,
|
||||
IEnumerable<CvssMetric>? metrics = null,
|
||||
IEnumerable<AdvisoryReference>? references = null,
|
||||
IEnumerable<AdvisoryCredit>? credits = null,
|
||||
IEnumerable<AdvisoryWeakness>? weaknesses = null,
|
||||
string? canonicalMetricId = null)
|
||||
{
|
||||
@@ -407,7 +408,7 @@ public sealed class CanonicalMergerTests
|
||||
severity: severity,
|
||||
exploitKnown: false,
|
||||
aliases: new[] { advisoryKey },
|
||||
credits: Array.Empty<AdvisoryCredit>(),
|
||||
credits: credits ?? Array.Empty<AdvisoryCredit>(),
|
||||
references: references ?? Array.Empty<AdvisoryReference>(),
|
||||
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
|
||||
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SourceRegistryTests.cs
|
||||
// Sprint: SPRINT_20260114_SOURCES_SETUP
|
||||
// Task: Unit tests for Source Registry
|
||||
// Description: Unit tests for the SourceRegistry implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Concelier.Core.Configuration;
|
||||
using StellaOps.Concelier.Core.Sources;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Sources;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SourceRegistryTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
|
||||
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock = new();
|
||||
|
||||
private SourceRegistry CreateRegistry(SourcesConfiguration? configuration = null)
|
||||
{
|
||||
return new SourceRegistry(
|
||||
_httpClientFactoryMock.Object,
|
||||
NullLogger<SourceRegistry>.Instance,
|
||||
_timeProvider,
|
||||
configuration);
|
||||
}
|
||||
|
||||
#region GetAllSources Tests
|
||||
|
||||
[Fact]
|
||||
public void GetAllSources_ReturnsAllDefinedSources()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var sources = registry.GetAllSources();
|
||||
|
||||
Assert.NotEmpty(sources);
|
||||
Assert.True(sources.Count >= 30, "Expected at least 30 sources defined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllSources_ContainsExpectedSources()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var sources = registry.GetAllSources();
|
||||
|
||||
var sourceIds = sources.Select(s => s.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("nvd", sourceIds);
|
||||
Assert.Contains("ghsa", sourceIds);
|
||||
Assert.Contains("osv", sourceIds);
|
||||
Assert.Contains("epss", sourceIds);
|
||||
Assert.Contains("kev", sourceIds);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSource Tests
|
||||
|
||||
[Fact]
|
||||
public void GetSource_ReturnsSource_ForValidId()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var source = registry.GetSource("nvd");
|
||||
|
||||
Assert.NotNull(source);
|
||||
Assert.Equal("nvd", source.Id);
|
||||
Assert.Contains("NVD", source.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSource_IsCaseInsensitive()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var source1 = registry.GetSource("NVD");
|
||||
var source2 = registry.GetSource("nvd");
|
||||
var source3 = registry.GetSource("Nvd");
|
||||
|
||||
Assert.NotNull(source1);
|
||||
Assert.NotNull(source2);
|
||||
Assert.NotNull(source3);
|
||||
Assert.Equal(source1.Id, source2.Id);
|
||||
Assert.Equal(source2.Id, source3.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSource_ReturnsNull_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var source = registry.GetSource("unknown-source-xyz");
|
||||
|
||||
Assert.Null(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSource_ThrowsArgumentException_ForNullOrEmpty()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// ArgumentNullException for null, ArgumentException for empty/whitespace
|
||||
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(null!));
|
||||
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(""));
|
||||
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSourcesByCategory Tests
|
||||
|
||||
[Fact]
|
||||
public void GetSourcesByCategory_ReturnsSourcesInCategory()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var primarySources = registry.GetSourcesByCategory(SourceCategory.Primary);
|
||||
|
||||
Assert.NotEmpty(primarySources);
|
||||
Assert.All(primarySources, s => Assert.Equal(SourceCategory.Primary, s.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSourcesByCategory_ReturnsEmptyList_ForEmptyCategory()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var sources = registry.GetSourcesByCategory(SourceCategory.Other);
|
||||
|
||||
// Other category may be empty or contain sources
|
||||
Assert.NotNull(sources);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnableSourceAsync/DisableSourceAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EnableSourceAsync_EnablesSource_ReturnsTrue()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.True(registry.IsEnabled("nvd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableSourceAsync_ReturnsFalse_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.EnableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableSourceAsync_DisablesSource_ReturnsTrue()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.False(registry.IsEnabled("nvd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableSourceAsync_ReturnsFalse_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.DisableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEnabledSourcesAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnabledSourcesAsync_ReturnsEnabledSources()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
|
||||
await registry.EnableSourceAsync("ghsa", TestContext.Current.CancellationToken);
|
||||
await registry.DisableSourceAsync("osv", TestContext.Current.CancellationToken);
|
||||
|
||||
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Contains("nvd", enabled, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("ghsa", enabled, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsEnabled Tests
|
||||
|
||||
[Fact]
|
||||
public void IsEnabled_ReturnsTrue_ForEnabledSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// By default, most sources should be enabled
|
||||
var isEnabled = registry.IsEnabled("nvd");
|
||||
|
||||
// NVD is enabled by default
|
||||
Assert.True(isEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsEnabled_ReturnsFalse_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var isEnabled = registry.IsEnabled("unknown-source-xyz");
|
||||
|
||||
Assert.False(isEnabled);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckConnectivityAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsNotFound_ForUnknownSource()
|
||||
{
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
|
||||
Assert.Equal("SOURCE_NOT_FOUND", result.ErrorCode);
|
||||
Assert.False(result.IsHealthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsHealthy_ForSuccessfulResponse()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Healthy, result.Status);
|
||||
Assert.True(result.IsHealthy);
|
||||
Assert.NotNull(result.Latency);
|
||||
Assert.Equal(FixedNow, result.CheckedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsFailed_ForHttpError()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
|
||||
Assert.False(result.IsHealthy);
|
||||
Assert.Equal(401, result.HttpStatusCode);
|
||||
Assert.NotEmpty(result.PossibleReasons);
|
||||
Assert.NotEmpty(result.RemediationSteps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_ReturnsFailed_ForNetworkError()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
|
||||
Assert.False(result.IsHealthy);
|
||||
Assert.NotNull(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_UsesTimeProvider()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(FixedNow, result.CheckedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConnectivityAsync_StoresLastResult()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(handlerMock.Object);
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
var lastResult = registry.GetLastCheckResult("nvd");
|
||||
|
||||
Assert.NotNull(lastResult);
|
||||
Assert.Equal("nvd", lastResult.SourceId);
|
||||
Assert.Equal(SourceConnectivityStatus.Healthy, lastResult.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckAllAndAutoConfigureAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_ChecksAllSources()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEmpty(result.Results);
|
||||
Assert.Equal(registry.GetAllSources().Count, result.TotalChecked);
|
||||
Assert.True(result.AllHealthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_ReturnsAggregatedResult()
|
||||
{
|
||||
var callCount = 0;
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
// Make some requests fail
|
||||
return callCount % 5 == 0
|
||||
? new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotEmpty(result.Results);
|
||||
Assert.True(result.HealthyCount > 0);
|
||||
Assert.True(result.FailedCount > 0);
|
||||
Assert.False(result.AllHealthy);
|
||||
Assert.True(result.HasFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_AutoEnablesHealthySources()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var configuration = new SourcesConfiguration { AutoEnableHealthySources = true };
|
||||
var registry = CreateRegistry(configuration);
|
||||
|
||||
// Disable all sources first
|
||||
foreach (var source in registry.GetAllSources())
|
||||
{
|
||||
await registry.DisableSourceAsync(source.Id, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// All healthy sources should now be enabled
|
||||
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(result.HealthyCount, enabled.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAllAndAutoConfigureAsync_RecordsDuration()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.TotalDuration >= TimeSpan.Zero);
|
||||
Assert.Equal(FixedNow, result.CheckedAt);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckMultipleAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckMultipleAsync_ChecksSpecifiedSources()
|
||||
{
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
var sourceIds = new[] { "nvd", "ghsa", "osv" };
|
||||
|
||||
var results = await registry.CheckMultipleAsync(sourceIds, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(3, results.Length);
|
||||
Assert.All(results, r => Assert.True(r.IsHealthy));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RetryCheckAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RetryCheckAsync_RetriesConnectivityCheck()
|
||||
{
|
||||
var callCount = 0;
|
||||
var handlerMock = new Mock<HttpMessageHandler>();
|
||||
handlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
Interlocked.Increment(ref callCount);
|
||||
// Fail first time, succeed second time
|
||||
return callCount == 1
|
||||
? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
// Return a new HttpClient for each CreateClient call to avoid reuse issues
|
||||
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(handlerMock.Object));
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// First check fails
|
||||
var firstResult = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
|
||||
Assert.False(firstResult.IsHealthy);
|
||||
|
||||
// Retry succeeds
|
||||
var retryResult = await registry.RetryCheckAsync("nvd", TestContext.Current.CancellationToken);
|
||||
Assert.True(retryResult.IsHealthy);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using StellaOps.Attestor.ProofChain.Models;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.ProofService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProofEvidence and related models used by BackportProofService.
|
||||
/// </summary>
|
||||
public sealed class ProofEvidenceModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProofEvidence_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { cve = "CVE-2026-0001", severity = "HIGH" });
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:distro:debian:DSA-1234",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "debian",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:abc123def456"
|
||||
};
|
||||
|
||||
Assert.Equal("evidence:distro:debian:DSA-1234", evidence.EvidenceId);
|
||||
Assert.Equal(EvidenceType.DistroAdvisory, evidence.Type);
|
||||
Assert.Equal("debian", evidence.Source);
|
||||
Assert.Equal("sha256:abc123def456", evidence.DataHash);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EvidenceType.DistroAdvisory)]
|
||||
[InlineData(EvidenceType.ChangelogMention)]
|
||||
[InlineData(EvidenceType.PatchHeader)]
|
||||
[InlineData(EvidenceType.BinaryFingerprint)]
|
||||
[InlineData(EvidenceType.VersionComparison)]
|
||||
[InlineData(EvidenceType.BuildCatalog)]
|
||||
public void ProofEvidence_Type_AllValues_AreValid(EvidenceType type)
|
||||
{
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { test = true });
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = $"evidence:{type.ToString().ToLowerInvariant()}:test",
|
||||
Type = type,
|
||||
Source = "test-source",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:test"
|
||||
};
|
||||
|
||||
Assert.Equal(type, evidence.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofEvidence_DataJson_ContainsStructuredData()
|
||||
{
|
||||
var advisoryData = new
|
||||
{
|
||||
distro = "ubuntu",
|
||||
advisory_id = "USN-1234-1",
|
||||
packages = new[] { "libcurl4", "curl" },
|
||||
fixed_version = "7.68.0-1ubuntu2.15"
|
||||
};
|
||||
var dataJson = JsonSerializer.SerializeToElement(advisoryData);
|
||||
|
||||
var evidence = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:distro:ubuntu:USN-1234-1",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "ubuntu",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:structured123"
|
||||
};
|
||||
|
||||
Assert.Equal(JsonValueKind.Object, evidence.Data.ValueKind);
|
||||
Assert.Equal("ubuntu", evidence.Data.GetProperty("distro").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofEvidence_RecordEquality_WorksCorrectly()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { key = "value" });
|
||||
|
||||
var evidence1 = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:test:eq",
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = "changelog",
|
||||
Timestamp = timestamp,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:equal"
|
||||
};
|
||||
|
||||
var evidence2 = new ProofEvidence
|
||||
{
|
||||
EvidenceId = "evidence:test:eq",
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = "changelog",
|
||||
Timestamp = timestamp,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:equal"
|
||||
};
|
||||
|
||||
Assert.Equal(evidence1, evidence2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ProofBlob model.
|
||||
/// </summary>
|
||||
public sealed class ProofBlobModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProofBlob_RequiredProperties_MustBeSet()
|
||||
{
|
||||
var evidences = new List<ProofEvidence>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:test:001",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "debian",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = JsonSerializer.SerializeToElement(new { }),
|
||||
DataHash = "sha256:ev1"
|
||||
}
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:proof123",
|
||||
SubjectId = "CVE-2026-0001:pkg:deb/debian/curl@7.64.0",
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = evidences,
|
||||
Method = "distro_advisory",
|
||||
Confidence = 0.95,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:2026-01-14"
|
||||
};
|
||||
|
||||
Assert.Equal("sha256:proof123", proof.ProofId);
|
||||
Assert.Equal("CVE-2026-0001:pkg:deb/debian/curl@7.64.0", proof.SubjectId);
|
||||
Assert.Equal(ProofBlobType.BackportFixed, proof.Type);
|
||||
Assert.Equal(0.95, proof.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_WithMultipleEvidences_ContainsAll()
|
||||
{
|
||||
var dataJson = JsonSerializer.SerializeToElement(new { });
|
||||
var evidences = new List<ProofEvidence>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:distro:dsa",
|
||||
Type = EvidenceType.DistroAdvisory,
|
||||
Source = "debian",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:dsa"
|
||||
},
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:changelog:debian",
|
||||
Type = EvidenceType.ChangelogMention,
|
||||
Source = "debian-changelog",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:changelog"
|
||||
},
|
||||
new()
|
||||
{
|
||||
EvidenceId = "evidence:patch:fix",
|
||||
Type = EvidenceType.PatchHeader,
|
||||
Source = "git-patch",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Data = dataJson,
|
||||
DataHash = "sha256:patch"
|
||||
}
|
||||
};
|
||||
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:multiproof",
|
||||
SubjectId = "CVE-2026-0002:pkg:npm/lodash@4.17.20",
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = evidences,
|
||||
Method = "combined",
|
||||
Confidence = 0.92,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:2026-01-14"
|
||||
};
|
||||
|
||||
Assert.Equal(3, proof.Evidences.Count);
|
||||
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.DistroAdvisory);
|
||||
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.ChangelogMention);
|
||||
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.PatchHeader);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProofBlobType.BackportFixed)]
|
||||
[InlineData(ProofBlobType.NotAffected)]
|
||||
[InlineData(ProofBlobType.Vulnerable)]
|
||||
[InlineData(ProofBlobType.Unknown)]
|
||||
public void ProofBlob_Type_AllValues_AreValid(ProofBlobType type)
|
||||
{
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = $"sha256:{type.ToString().ToLowerInvariant()}",
|
||||
SubjectId = "CVE-2026-TYPE:pkg:test/pkg@1.0.0",
|
||||
Type = type,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "test",
|
||||
Confidence = 0.5,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.Equal(type, proof.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_Confidence_InValidRange()
|
||||
{
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:conf",
|
||||
SubjectId = "CVE-2026-CONF:pkg:test/pkg@1.0.0",
|
||||
Type = ProofBlobType.BackportFixed,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "test",
|
||||
Confidence = 0.87,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.InRange(proof.Confidence, 0.0, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_ProofHash_IsOptional()
|
||||
{
|
||||
var proofWithoutHash = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:nohash",
|
||||
SubjectId = "CVE-2026-NH:pkg:test/pkg@1.0.0",
|
||||
Type = ProofBlobType.Unknown,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "test",
|
||||
Confidence = 0.0,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.Null(proofWithoutHash.ProofHash);
|
||||
|
||||
var proofWithHash = proofWithoutHash with { ProofHash = "sha256:computed" };
|
||||
Assert.Equal("sha256:computed", proofWithHash.ProofHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofBlob_SubjectId_ContainsCveAndPurl()
|
||||
{
|
||||
var proof = new ProofBlob
|
||||
{
|
||||
ProofId = "sha256:subject",
|
||||
SubjectId = "CVE-2026-12345:pkg:pypi/django@4.2.0",
|
||||
Type = ProofBlobType.NotAffected,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Evidences = Array.Empty<ProofEvidence>(),
|
||||
Method = "vex",
|
||||
Confidence = 1.0,
|
||||
ToolVersion = "1.0.0",
|
||||
SnapshotId = "snapshot:test"
|
||||
};
|
||||
|
||||
Assert.Contains("CVE-2026-12345", proof.SubjectId);
|
||||
Assert.Contains("pkg:pypi/django@4.2.0", proof.SubjectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"diagnosticMessages": true,
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
Reference in New Issue
Block a user