283 lines
10 KiB
C#
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;
|
|
}
|
|
}
|