using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.Logging; using StellaOps.Scanner.WebService.Security; namespace StellaOps.Scanner.WebService.Options; /// /// Validation helpers for . /// public static class ScannerWebServiceOptionsValidator { private static readonly HashSet SupportedStorageDrivers = new(StringComparer.OrdinalIgnoreCase) { "mongo" }; private static readonly HashSet SupportedQueueDrivers = new(StringComparer.OrdinalIgnoreCase) { "redis", "nats", "rabbitmq" }; private static readonly HashSet SupportedArtifactDrivers = new(StringComparer.OrdinalIgnoreCase) { "minio" }; private static readonly HashSet SupportedEventDrivers = new(StringComparer.OrdinalIgnoreCase) { "redis" }; public static void Validate(ScannerWebServiceOptions options) { ArgumentNullException.ThrowIfNull(options); if (options.SchemaVersion <= 0) { throw new InvalidOperationException("Scanner configuration requires a positive schemaVersion."); } options.Storage ??= new ScannerWebServiceOptions.StorageOptions(); ValidateStorage(options.Storage); options.Queue ??= new ScannerWebServiceOptions.QueueOptions(); ValidateQueue(options.Queue); options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions(); ValidateArtifactStore(options.ArtifactStore); options.Features ??= new ScannerWebServiceOptions.FeatureFlagOptions(); options.Plugins ??= new ScannerWebServiceOptions.PluginOptions(); options.Telemetry ??= new ScannerWebServiceOptions.TelemetryOptions(); ValidateTelemetry(options.Telemetry); options.Authority ??= new ScannerWebServiceOptions.AuthorityOptions(); ValidateAuthority(options.Authority); options.Signing ??= new ScannerWebServiceOptions.SigningOptions(); ValidateSigning(options.Signing); options.Api ??= new ScannerWebServiceOptions.ApiOptions(); if (string.IsNullOrWhiteSpace(options.Api.BasePath)) { throw new InvalidOperationException("API basePath must be configured."); } if (string.IsNullOrWhiteSpace(options.Api.ScansSegment)) { throw new InvalidOperationException("API scansSegment must be configured."); } if (string.IsNullOrWhiteSpace(options.Api.ReportsSegment)) { throw new InvalidOperationException("API reportsSegment must be configured."); } if (string.IsNullOrWhiteSpace(options.Api.PolicySegment)) { throw new InvalidOperationException("API policySegment must be configured."); } if (string.IsNullOrWhiteSpace(options.Api.RuntimeSegment)) { throw new InvalidOperationException("API runtimeSegment must be configured."); } options.Events ??= new ScannerWebServiceOptions.EventsOptions(); ValidateEvents(options.Events); options.Runtime ??= new ScannerWebServiceOptions.RuntimeOptions(); ValidateRuntime(options.Runtime); } private static void ValidateStorage(ScannerWebServiceOptions.StorageOptions storage) { if (!SupportedStorageDrivers.Contains(storage.Driver)) { throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Supported drivers: mongo."); } if (string.IsNullOrWhiteSpace(storage.Dsn)) { throw new InvalidOperationException("Storage DSN must be configured."); } if (storage.CommandTimeoutSeconds <= 0) { throw new InvalidOperationException("Storage commandTimeoutSeconds must be greater than zero."); } if (storage.HealthCheckTimeoutSeconds <= 0) { throw new InvalidOperationException("Storage healthCheckTimeoutSeconds must be greater than zero."); } } private static void ValidateQueue(ScannerWebServiceOptions.QueueOptions queue) { if (!SupportedQueueDrivers.Contains(queue.Driver)) { throw new InvalidOperationException($"Unsupported queue driver '{queue.Driver}'. Supported drivers: redis, nats, rabbitmq."); } if (string.IsNullOrWhiteSpace(queue.Dsn)) { throw new InvalidOperationException("Queue DSN must be configured."); } if (string.IsNullOrWhiteSpace(queue.Namespace)) { throw new InvalidOperationException("Queue namespace must be configured."); } if (queue.VisibilityTimeoutSeconds <= 0) { throw new InvalidOperationException("Queue visibilityTimeoutSeconds must be greater than zero."); } if (queue.LeaseHeartbeatSeconds <= 0) { throw new InvalidOperationException("Queue leaseHeartbeatSeconds must be greater than zero."); } if (queue.MaxDeliveryAttempts <= 0) { throw new InvalidOperationException("Queue maxDeliveryAttempts must be greater than zero."); } } private static void ValidateArtifactStore(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore) { if (!SupportedArtifactDrivers.Contains(artifactStore.Driver)) { throw new InvalidOperationException($"Unsupported artifact store driver '{artifactStore.Driver}'. Supported drivers: minio."); } if (string.IsNullOrWhiteSpace(artifactStore.Endpoint)) { throw new InvalidOperationException("Artifact store endpoint must be configured."); } if (string.IsNullOrWhiteSpace(artifactStore.Bucket)) { throw new InvalidOperationException("Artifact store bucket must be configured."); } if (artifactStore.EnableObjectLock && artifactStore.ObjectLockRetentionDays <= 0) { throw new InvalidOperationException("Artifact store objectLockRetentionDays must be greater than zero when object lock is enabled."); } } private static void ValidateEvents(ScannerWebServiceOptions.EventsOptions eventsOptions) { if (!eventsOptions.Enabled) { return; } if (!SupportedEventDrivers.Contains(eventsOptions.Driver)) { throw new InvalidOperationException($"Unsupported events driver '{eventsOptions.Driver}'. Supported drivers: redis."); } if (string.IsNullOrWhiteSpace(eventsOptions.Dsn)) { throw new InvalidOperationException("Events DSN must be configured when event emission is enabled."); } if (string.IsNullOrWhiteSpace(eventsOptions.Stream)) { throw new InvalidOperationException("Events stream must be configured when event emission is enabled."); } if (eventsOptions.PublishTimeoutSeconds <= 0) { throw new InvalidOperationException("Events publishTimeoutSeconds must be greater than zero."); } if (eventsOptions.MaxStreamLength < 0) { throw new InvalidOperationException("Events maxStreamLength must be zero or greater."); } } private static void ValidateTelemetry(ScannerWebServiceOptions.TelemetryOptions telemetry) { if (string.IsNullOrWhiteSpace(telemetry.MinimumLogLevel)) { throw new InvalidOperationException("Telemetry minimumLogLevel must be configured."); } if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _)) { throw new InvalidOperationException($"Telemetry minimumLogLevel '{telemetry.MinimumLogLevel}' is invalid."); } if (!string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint) && !Uri.TryCreate(telemetry.OtlpEndpoint, UriKind.Absolute, out _)) { throw new InvalidOperationException("Telemetry OTLP endpoint must be an absolute URI when specified."); } foreach (var attribute in telemetry.ResourceAttributes) { if (string.IsNullOrWhiteSpace(attribute.Key)) { throw new InvalidOperationException("Telemetry resource attribute keys must be non-empty."); } } foreach (var header in telemetry.OtlpHeaders) { if (string.IsNullOrWhiteSpace(header.Key)) { throw new InvalidOperationException("Telemetry OTLP header keys must be non-empty."); } } } private static void ValidateAuthority(ScannerWebServiceOptions.AuthorityOptions authority) { authority.Resilience ??= new ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions(); NormalizeList(authority.Audiences, toLower: false); NormalizeList(authority.RequiredScopes, toLower: true); NormalizeList(authority.BypassNetworks, toLower: false); NormalizeList(authority.ClientScopes, toLower: true); NormalizeResilience(authority.Resilience); if (authority.RequiredScopes.Count == 0) { authority.RequiredScopes.Add(ScannerAuthorityScopes.ScansEnqueue); } if (authority.ClientScopes.Count == 0) { foreach (var scope in authority.RequiredScopes) { authority.ClientScopes.Add(scope); } } if (authority.BackchannelTimeoutSeconds <= 0) { throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero."); } if (authority.TokenClockSkewSeconds < 0 || authority.TokenClockSkewSeconds > 300) { throw new InvalidOperationException("Authority tokenClockSkewSeconds must be between 0 and 300 seconds."); } if (!authority.Enabled) { return; } if (string.IsNullOrWhiteSpace(authority.Issuer)) { throw new InvalidOperationException("Authority issuer must be configured when authority is enabled."); } if (!Uri.TryCreate(authority.Issuer, UriKind.Absolute, out var issuerUri)) { throw new InvalidOperationException("Authority issuer must be an absolute URI."); } if (authority.RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Authority issuer must use HTTPS when requireHttpsMetadata is enabled."); } if (!string.IsNullOrWhiteSpace(authority.MetadataAddress) && !Uri.TryCreate(authority.MetadataAddress, UriKind.Absolute, out _)) { throw new InvalidOperationException("Authority metadataAddress must be an absolute URI when specified."); } if (authority.Audiences.Count == 0) { throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled."); } if (!authority.AllowAnonymousFallback) { if (string.IsNullOrWhiteSpace(authority.ClientId)) { throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled."); } if (string.IsNullOrWhiteSpace(authority.ClientSecret)) { throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled."); } } } private static void ValidateSigning(ScannerWebServiceOptions.SigningOptions signing) { if (signing.EnvelopeTtlSeconds <= 0) { throw new InvalidOperationException("Signing envelopeTtlSeconds must be greater than zero."); } if (!signing.Enabled) { return; } if (string.IsNullOrWhiteSpace(signing.KeyId)) { throw new InvalidOperationException("Signing keyId must be configured when signing is enabled."); } if (string.IsNullOrWhiteSpace(signing.Algorithm)) { throw new InvalidOperationException("Signing algorithm must be configured when signing is enabled."); } if (string.IsNullOrWhiteSpace(signing.KeyPem) && string.IsNullOrWhiteSpace(signing.KeyPemFile)) { throw new InvalidOperationException("Signing requires keyPem or keyPemFile when enabled."); } } private static void NormalizeList(IList values, bool toLower) { if (values is null || values.Count == 0) { return; } var seen = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = values.Count - 1; i >= 0; i--) { var entry = values[i]; if (string.IsNullOrWhiteSpace(entry)) { values.RemoveAt(i); continue; } var normalized = toLower ? entry.Trim().ToLowerInvariant() : entry.Trim(); if (!seen.Add(normalized)) { values.RemoveAt(i); continue; } values[i] = normalized; } } private static void NormalizeResilience(ScannerWebServiceOptions.AuthorityOptions.ResilienceOptions resilience) { if (resilience.RetryDelays is null) { return; } foreach (var delay in resilience.RetryDelays.ToArray()) { if (delay <= TimeSpan.Zero) { throw new InvalidOperationException("Authority resilience retryDelays must be greater than zero."); } } if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value < TimeSpan.Zero) { throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero."); } } private static void ValidateRuntime(ScannerWebServiceOptions.RuntimeOptions runtime) { if (runtime.MaxBatchSize <= 0) { throw new InvalidOperationException("Runtime maxBatchSize must be greater than zero."); } if (runtime.MaxPayloadBytes <= 0) { throw new InvalidOperationException("Runtime maxPayloadBytes must be greater than zero."); } if (runtime.EventTtlDays <= 0) { throw new InvalidOperationException("Runtime eventTtlDays must be greater than zero."); } if (runtime.PerNodeEventsPerSecond <= 0) { throw new InvalidOperationException("Runtime perNodeEventsPerSecond must be greater than zero."); } if (runtime.PerNodeBurst <= 0) { throw new InvalidOperationException("Runtime perNodeBurst must be greater than zero."); } if (runtime.PerTenantEventsPerSecond <= 0) { throw new InvalidOperationException("Runtime perTenantEventsPerSecond must be greater than zero."); } if (runtime.PerTenantBurst <= 0) { throw new InvalidOperationException("Runtime perTenantBurst must be greater than zero."); } if (runtime.PolicyCacheTtlSeconds <= 0) { throw new InvalidOperationException("Runtime policyCacheTtlSeconds must be greater than zero."); } } }