tests fixes and sprints work
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"serialNumber": "urn:uuid:00000000-0000-0000-0000-000000000000",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"bom-ref": "root",
|
||||
"name": "sample-app",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"bom-ref": "svc-api",
|
||||
"name": "api-gateway",
|
||||
"version": "2.1.0",
|
||||
"endpoints": [
|
||||
"https://api.example.com",
|
||||
"http://legacy.example.com"
|
||||
],
|
||||
"authenticated": false,
|
||||
"crossesTrustBoundary": true,
|
||||
"properties": [
|
||||
{ "name": "x-trust-boundary", "value": "external" },
|
||||
{ "name": "x-rate-limited", "value": "false" }
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"direction": "outbound",
|
||||
"classification": "PII",
|
||||
"destination": "svc-auth"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"bom-ref": "svc-auth",
|
||||
"name": "auth",
|
||||
"version": "1.0.0",
|
||||
"authenticated": false,
|
||||
"endpoints": [
|
||||
"http://auth.internal"
|
||||
],
|
||||
"properties": [
|
||||
{ "name": "x-trust-boundary", "value": "internal" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
using StellaOps.Scanner.ServiceSecurity;
|
||||
using StellaOps.Scanner.ServiceSecurity.Analyzers;
|
||||
using StellaOps.Scanner.ServiceSecurity.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ServiceSecurity.Tests;
|
||||
|
||||
public sealed class ServiceSecurityIntegrationTests
|
||||
{
|
||||
private static readonly TimeProvider FixedTimeProviderInstance =
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 1, 19, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private static readonly string FixturesRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures");
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ParsedSbom_WithServices_ProducesFindings()
|
||||
{
|
||||
var sbomPath = Path.Combine(FixturesRoot, "sample-services.cdx.json");
|
||||
await using var stream = File.OpenRead(sbomPath);
|
||||
var parser = new ParsedSbomParser(NullLogger<ParsedSbomParser>.Instance);
|
||||
var parsed = await parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
||||
|
||||
var analyzer = new ServiceSecurityAnalyzer(
|
||||
new IServiceSecurityCheck[]
|
||||
{
|
||||
new EndpointSchemeAnalyzer(),
|
||||
new AuthenticationAnalyzer(),
|
||||
new RateLimitingAnalyzer(),
|
||||
new TrustBoundaryAnalyzer(),
|
||||
new DataFlowAnalyzer(),
|
||||
new NestedServiceAnalyzer()
|
||||
},
|
||||
FixedTimeProviderInstance);
|
||||
|
||||
var report = await analyzer.AnalyzeAsync(parsed.Services, ServiceSecurityPolicyDefaults.Default);
|
||||
|
||||
Assert.NotEmpty(report.Findings);
|
||||
Assert.NotEmpty(report.DependencyChains);
|
||||
Assert.NotNull(report.DataFlowGraph);
|
||||
Assert.NotNull(report.Topology);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Performance)]
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_HandlesHundredServicesQuickly()
|
||||
{
|
||||
var services = Enumerable.Range(0, 100)
|
||||
.Select(index => new ParsedService
|
||||
{
|
||||
BomRef = $"svc-{index}",
|
||||
Name = $"service-{index}",
|
||||
Authenticated = index % 2 == 0,
|
||||
CrossesTrustBoundary = index % 3 == 0,
|
||||
Endpoints = ["https://service.example.com"],
|
||||
Data =
|
||||
[
|
||||
new ParsedDataFlow
|
||||
{
|
||||
Direction = DataFlowDirection.Outbound,
|
||||
Classification = index % 2 == 0 ? "PII" : "public",
|
||||
DestinationRef = $"svc-{(index + 1) % 100}"
|
||||
}
|
||||
]
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var analyzer = new ServiceSecurityAnalyzer(
|
||||
new IServiceSecurityCheck[]
|
||||
{
|
||||
new EndpointSchemeAnalyzer(),
|
||||
new AuthenticationAnalyzer(),
|
||||
new RateLimitingAnalyzer(),
|
||||
new TrustBoundaryAnalyzer(),
|
||||
new DataFlowAnalyzer()
|
||||
},
|
||||
FixedTimeProviderInstance);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var report = await analyzer.AnalyzeAsync(services, ServiceSecurityPolicyDefaults.Default);
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.NotNull(report);
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixed;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixed = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using StellaOps.Scanner.ServiceSecurity.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ServiceSecurity.Tests;
|
||||
|
||||
public sealed class ServiceSecurityPolicyLoaderTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReturnsDefaultWhenMissing()
|
||||
{
|
||||
var loader = new ServiceSecurityPolicyLoader();
|
||||
var policy = await loader.LoadAsync(path: null);
|
||||
|
||||
Assert.Equal(ServiceSecurityPolicyDefaults.Default.DataClassifications.Sensitive,
|
||||
policy.DataClassifications.Sensitive);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LoadAsync_LoadsYamlPolicy()
|
||||
{
|
||||
var yaml = """
|
||||
serviceSecurityPolicy:
|
||||
requireAuthentication:
|
||||
forTrustBoundaryCrossing: false
|
||||
forSensitiveData: true
|
||||
exceptions:
|
||||
- servicePattern: "internal-*"
|
||||
reason: "mTLS"
|
||||
allowedSchemes:
|
||||
external: [https]
|
||||
internal: [https, http]
|
||||
dataClassifications:
|
||||
sensitive: [pii, auth]
|
||||
deprecatedServices:
|
||||
- name: "redis"
|
||||
beforeVersion: "6.0"
|
||||
cveId: "CVE-2026-0001"
|
||||
internalHostSuffixes: ["internal", "corp"]
|
||||
version: "policy-1"
|
||||
""";
|
||||
|
||||
var loader = new ServiceSecurityPolicyLoader();
|
||||
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.yaml");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(path, yaml);
|
||||
var policy = await loader.LoadAsync(path);
|
||||
|
||||
Assert.False(policy.RequireAuthentication.ForTrustBoundaryCrossing);
|
||||
Assert.Contains("https", policy.AllowedSchemes.External, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("internal", policy.InternalHostSuffixes, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal("policy-1", policy.Version);
|
||||
Assert.Single(policy.DeprecatedServices);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.ServiceSecurity/StellaOps.Scanner.ServiceSecurity.csproj" />
|
||||
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user