Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.ServiceSecurity.Tests/ServiceSecurityAnalyzerTests.cs
2026-01-22 19:08:46 +02:00

283 lines
10 KiB
C#

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<ServiceSecurityReport> 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<ServiceSecurityReport> 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<string, string>? properties = null)
{
var props = properties is null
? ImmutableDictionary<string, string>.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<string, string> BuildProperties(params (string Key, string Value)[] entries)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
values[entry.Key] = entry.Value;
}
return values;
}
private sealed class StubAdvisoryProvider : IServiceAdvisoryProvider
{
public Task<IReadOnlyList<ServiceAdvisoryMatch>> GetMatchesAsync(
ParsedService service,
CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<ServiceAdvisoryMatch>>(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;
}
}