using System.Collections.Immutable; using StellaOps.Concelier.SbomIntegration.Models; using StellaOps.Scanner.ServiceSecurity; using StellaOps.Scanner.ServiceSecurity.Analyzers; using StellaOps.Scanner.ServiceSecurity.Models; using StellaOps.Scanner.ServiceSecurity.Policy; using StellaOps.TestKit; using Xunit; namespace StellaOps.Scanner.ServiceSecurity.Tests; public sealed class ServiceSecurityAnalyzerTests { private static readonly TimeProvider FixedTimeProviderInstance = new FixedTimeProvider(new DateTimeOffset(2026, 1, 19, 0, 0, 0, TimeSpan.Zero)); [Trait("Category", TestCategories.Unit)] [Fact] public async Task EndpointSchemeAnalyzer_FlagsInsecureSchemes() { var service = CreateService( "svc-1", "api", endpoints: [ "http://api.example.com", "https://api.example.com", "ws://api.example.com", "ftp://api.example.com" ]); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new EndpointSchemeAnalyzer(), service); Assert.Equal(3, report.Findings.Length); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.InsecureEndpointScheme); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.DeprecatedProtocol); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task AuthenticationAnalyzer_FlagsUnauthenticatedScenarios() { var service = CreateService( "svc-1", "billing", authenticated: false, crossesTrustBoundary: true, endpoints: ["https://billing.example.com"], data: [Flow(classification: "PII")]); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new AuthenticationAnalyzer(), service); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.UnauthenticatedEndpoint); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.CrossesTrustBoundaryWithoutAuth); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.SensitiveDataExposed); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task TrustBoundaryAnalyzer_BuildsDependencyChains() { var serviceA = CreateService( "svc-a", "gateway", authenticated: true, endpoints: ["https://api.example.com"], data: [Flow(destinationRef: "svc-b", classification: "PII")], properties: BuildProperties(("x-trust-boundary", "external"))); var serviceB = CreateService( "svc-b", "auth", authenticated: false, properties: BuildProperties(("x-trust-boundary", "internal"))); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new TrustBoundaryAnalyzer(), serviceA, serviceB); Assert.NotEmpty(report.DependencyChains); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.CrossesTrustBoundaryWithoutAuth); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task DataFlowAnalyzer_FlagsSensitiveUnencryptedFlows() { var serviceA = CreateService( "svc-a", "front", authenticated: true, data: [Flow(destinationRef: "svc-b", classification: "PII")]); var serviceB = CreateService( "svc-b", "processor", authenticated: false, endpoints: ["http://processor.internal"]); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new DataFlowAnalyzer(), serviceA, serviceB); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.SensitiveDataExposed); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.UnencryptedDataFlow); Assert.NotNull(report.DataFlowGraph); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task RateLimitingAnalyzer_FlagsDisabledRateLimit() { var service = CreateService( "svc-1", "api", endpoints: ["https://api.example.com"], properties: BuildProperties(("x-rate-limited", "false"))); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new RateLimitingAnalyzer(), service); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.MissingRateLimiting); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ServiceVulnerabilityMatcher_FlagsDeprecatedAndAdvisoryMatches() { var policy = ServiceSecurityPolicyDefaults.Default with { DeprecatedServices = ImmutableArray.Create(new DeprecatedServicePolicy { Name = "redis", BeforeVersion = "6.0", Severity = Severity.High, CveId = "CVE-2026-0001", Reason = "Pre-6.0 releases are out of support." }) }; var service = CreateService("svc-redis", "redis", version: "5.0.1"); var provider = new StubAdvisoryProvider(); var report = await RunAnalyzer(policy, new ServiceVulnerabilityMatcher(provider), service); Assert.Equal(2, report.Findings.Length); Assert.All(report.Findings, finding => Assert.Equal(ServiceSecurityFindingType.KnownVulnerableServiceVersion, finding.Type)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task NestedServiceAnalyzer_DetectsCyclesAndOrphans() { var serviceA = CreateService( "svc-a", "alpha", data: [Flow(sourceRef: "svc-a", destinationRef: "svc-b", classification: "PII")]); var serviceB = CreateService( "svc-b", "beta", data: [Flow(sourceRef: "svc-b", destinationRef: "svc-a", classification: "PII")]); var orphan = CreateService("svc-c", "orphan"); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new NestedServiceAnalyzer(), serviceA, serviceB, orphan); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.CircularDependency); Assert.Contains(report.Findings, finding => finding.Type == ServiceSecurityFindingType.OrphanedService); Assert.NotNull(report.Topology); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Analyzer_SummaryCountsMatchFindings() { var service = CreateService( "svc-1", "api", authenticated: false, endpoints: ["http://api.example.com"]); var report = await RunAnalyzer(ServiceSecurityPolicyDefaults.Default, new EndpointSchemeAnalyzer(), new AuthenticationAnalyzer(), service); Assert.Equal(report.Findings.Length, report.Summary.TotalFindings); Assert.True(report.Summary.FindingsByType.Count >= 2); } private static async Task RunAnalyzer( ServiceSecurityPolicy policy, IServiceSecurityCheck check, params ParsedService[] services) { var analyzer = new ServiceSecurityAnalyzer(new[] { check }, FixedTimeProviderInstance); return await analyzer.AnalyzeAsync(services, policy); } private static async Task RunAnalyzer( ServiceSecurityPolicy policy, IServiceSecurityCheck first, IServiceSecurityCheck second, params ParsedService[] services) { var analyzer = new ServiceSecurityAnalyzer(new IServiceSecurityCheck[] { first, second }, FixedTimeProviderInstance); return await analyzer.AnalyzeAsync(services, policy); } private static ParsedService CreateService( string bomRef, string name, bool authenticated = true, bool crossesTrustBoundary = false, string? version = null, string[]? endpoints = null, ParsedDataFlow[]? data = null, ParsedService[]? nested = null, IReadOnlyDictionary? properties = null) { var props = properties is null ? ImmutableDictionary.Empty : ImmutableDictionary.CreateRange(StringComparer.OrdinalIgnoreCase, properties); return new ParsedService { BomRef = bomRef, Name = name, Version = version, Authenticated = authenticated, CrossesTrustBoundary = crossesTrustBoundary, Endpoints = endpoints?.ToImmutableArray() ?? [], Data = data?.ToImmutableArray() ?? [], NestedServices = nested?.ToImmutableArray() ?? [], Properties = props }; } private static ParsedDataFlow Flow( string? sourceRef = null, string? destinationRef = null, string classification = "PII", DataFlowDirection direction = DataFlowDirection.Outbound) { return new ParsedDataFlow { Direction = direction, Classification = classification, SourceRef = sourceRef, DestinationRef = destinationRef }; } private static IReadOnlyDictionary BuildProperties(params (string Key, string Value)[] entries) { var values = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entry in entries) { values[entry.Key] = entry.Value; } return values; } private sealed class StubAdvisoryProvider : IServiceAdvisoryProvider { public Task> GetMatchesAsync( ParsedService service, CancellationToken ct = default) { return Task.FromResult>(new[] { new ServiceAdvisoryMatch { CveId = "CVE-2026-1234", Severity = Severity.High, Description = "Service version is affected." } }); } } private sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _fixed; public FixedTimeProvider(DateTimeOffset fixedTime) { _fixed = fixedTime; } public override DateTimeOffset GetUtcNow() => _fixed; } }