feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
@@ -0,0 +1,919 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GatewayBoundaryExtractorTests.cs
|
||||
// Sprint: SPRINT_3800_0002_0003_boundary_gateway
|
||||
// Description: Unit tests for GatewayBoundaryExtractor.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Boundary;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public class GatewayBoundaryExtractorTests
|
||||
{
|
||||
private readonly GatewayBoundaryExtractor _extractor;
|
||||
|
||||
public GatewayBoundaryExtractorTests()
|
||||
{
|
||||
_extractor = new GatewayBoundaryExtractor(
|
||||
NullLogger<GatewayBoundaryExtractor>.Instance);
|
||||
}
|
||||
|
||||
#region Priority and CanHandle
|
||||
|
||||
[Fact]
|
||||
public void Priority_Returns250_HigherThanK8sExtractor()
|
||||
{
|
||||
Assert.Equal(250, _extractor.Priority);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("gateway", true)]
|
||||
[InlineData("kong", true)]
|
||||
[InlineData("Kong", true)]
|
||||
[InlineData("envoy", true)]
|
||||
[InlineData("istio", true)]
|
||||
[InlineData("apigateway", true)]
|
||||
[InlineData("traefik", true)]
|
||||
[InlineData("k8s", false)]
|
||||
[InlineData("static", false)]
|
||||
public void CanHandle_WithSource_ReturnsExpected(string source, bool expected)
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with { Source = source };
|
||||
Assert.Equal(expected, _extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithKongAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.route.path"] = "/api"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithIstioAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["istio.io/rev"] = "stable"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithTraefikAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["traefik.http.routers.my-router.rule"] = "Host(`example.com`)"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithEmptyAnnotations_ReturnsFalse()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty;
|
||||
Assert.False(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gateway Type Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKongSource_ReturnsKongGatewaySource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gateway:kong", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithEnvoySource_ReturnsEnvoyGatewaySource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "envoy", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "envoy"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gateway:envoy", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIstioAnnotations_ReturnsEnvoyGatewaySource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "gateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "gateway",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["istio.io/rev"] = "stable"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gateway:envoy", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithApiGatewaySource_ReturnsAwsApigwSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "apigateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "apigateway"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gateway:aws-apigw", result.Source);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exposure Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_DefaultGateway_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
Assert.True(result.Exposure.BehindProxy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithInternalFlag_ReturnsInternalExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.internal"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("internal", result.Exposure.Level);
|
||||
Assert.False(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIstioMesh_ReturnsInternalExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "envoy", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "envoy",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["istio.io/mesh-config"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("internal", result.Exposure.Level);
|
||||
Assert.False(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithAwsPrivateEndpoint_ReturnsInternalExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "apigateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "apigateway",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["apigateway.endpoint-type"] = "PRIVATE"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("internal", result.Exposure.Level);
|
||||
Assert.False(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Surface Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKongPath_ReturnsSurfaceWithPath()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.route.path"] = "/api/v1"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("/api/v1", result.Surface.Path);
|
||||
Assert.Equal("api", result.Surface.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKongHost_ReturnsSurfaceWithHost()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.route.host"] = "api.example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("api.example.com", result.Surface.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithGrpcAnnotation_ReturnsGrpcProtocol()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.protocol.grpc"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("grpc", result.Surface.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithWebsocketAnnotation_ReturnsWssProtocol()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.upgrade.websocket"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("wss", result.Surface.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_DefaultProtocol_ReturnsHttps()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("https", result.Surface.Protocol);
|
||||
Assert.Equal(443, result.Surface.Port);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Kong Auth Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKongJwtPlugin_ReturnsJwtAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.jwt"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("jwt", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKongKeyAuth_ReturnsApiKeyAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.key-auth"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("api_key", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKongAcl_ReturnsRoles()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.jwt"] = "enabled",
|
||||
["kong.plugin.acl.allow"] = "admin,editor,viewer"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.NotNull(result.Auth.Roles);
|
||||
Assert.Equal(3, result.Auth.Roles.Count);
|
||||
Assert.Contains("admin", result.Auth.Roles);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Envoy/Istio Auth Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIstioJwt_ReturnsJwtAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "envoy", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "envoy",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["istio.io/requestauthentication.jwt"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("jwt", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIstioMtls_ReturnsMtlsAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "envoy", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "envoy",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["istio.io/peerauthentication.mtls"] = "STRICT"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("mtls", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithEnvoyOidc_ReturnsOAuth2Auth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "envoy", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "envoy",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["envoy.filter.oidc.provider"] = "https://auth.example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("oauth2", result.Auth.Type);
|
||||
Assert.Equal("https://auth.example.com", result.Auth.Provider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AWS API Gateway Auth Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCognitoAuthorizer_ReturnsOAuth2Auth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "apigateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "apigateway",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["apigateway.authorizer.cognito"] = "user-pool-id"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("oauth2", result.Auth.Type);
|
||||
Assert.Equal("cognito", result.Auth.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithApiKeyRequired_ReturnsApiKeyAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "apigateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "apigateway",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["apigateway.api-key-required"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("api_key", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithLambdaAuthorizer_ReturnsCustomAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "apigateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "apigateway",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["apigateway.lambda-authorizer"] = "arn:aws:lambda:..."
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("custom", result.Auth.Type);
|
||||
Assert.Equal("lambda", result.Auth.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIamAuthorizer_ReturnsIamAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "apigateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "apigateway",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["apigateway.iam-authorizer"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("iam", result.Auth.Type);
|
||||
Assert.Equal("aws-iam", result.Auth.Provider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Traefik Auth Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTraefikBasicAuth_ReturnsBasicAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "traefik", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "traefik",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["traefik.http.middlewares.auth.basicauth.users"] = "admin:$$apr1$$..."
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("basic", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTraefikForwardAuth_ReturnsCustomAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "traefik", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "traefik",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["traefik.http.middlewares.auth.forwardauth.address"] = "https://auth.example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("custom", result.Auth.Type);
|
||||
Assert.Equal("https://auth.example.com", result.Auth.Provider);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Controls Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithRateLimit_ReturnsRateLimitControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.rate-limiting"] = "100"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "rate_limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIpRestriction_ReturnsIpAllowlistControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.ip-restriction.whitelist"] = "10.0.0.0/8"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "ip_allowlist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCors_ReturnsCorsControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.cors.origins"] = "https://example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "cors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithWaf_ReturnsWafControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.bot-detection"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "waf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithRequestValidation_ReturnsInputValidationControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.request-validation"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "input_validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithMultipleControls_ReturnsAllControls()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.rate-limiting"] = "100",
|
||||
["kong.plugin.cors.origins"] = "https://example.com",
|
||||
["kong.plugin.bot-detection"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Equal(3, result.Controls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoControls_ReturnsNullControls()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Controls);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Confidence and Metadata
|
||||
|
||||
[Fact]
|
||||
public void Extract_BaseConfidence_Returns0Point75()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "gateway", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "gateway"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.75, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKnownGateway_IncreasesConfidence()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.85, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithAuthAndRouteInfo_MaximizesConfidence()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.jwt"] = "enabled",
|
||||
["kong.route.path"] = "/api/v1"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.95, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_ReturnsNetworkKind()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("network", result.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_BuildsEvidenceRef_WithGatewayType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-123", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Namespace = "production",
|
||||
EnvironmentId = "env-456"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gateway/kong/production/env-456/root-123", result.EvidenceRef);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExtractAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_ReturnsSameResultAsExtract()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kong.plugin.jwt"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var syncResult = _extractor.Extract(root, null, context);
|
||||
var asyncResult = await _extractor.ExtractAsync(root, null, context);
|
||||
|
||||
Assert.NotNull(syncResult);
|
||||
Assert.NotNull(asyncResult);
|
||||
Assert.Equal(syncResult.Kind, asyncResult.Kind);
|
||||
Assert.Equal(syncResult.Source, asyncResult.Source);
|
||||
Assert.Equal(syncResult.Confidence, asyncResult.Confidence);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNullRoot_ThrowsArgumentNullException()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with { Source = "kong" };
|
||||
Assert.Throws<ArgumentNullException>(() => _extractor.Extract(null!, null, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WhenCannotHandle_ReturnsNull()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "static", null);
|
||||
var context = BoundaryExtractionContext.Empty with { Source = "static" };
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoAuth_ReturnsNullAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "kong", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "kong"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Auth);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IacBoundaryExtractorTests.cs
|
||||
// Sprint: SPRINT_3800_0002_0004_boundary_iac
|
||||
// Description: Unit tests for IacBoundaryExtractor.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Boundary;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public class IacBoundaryExtractorTests
|
||||
{
|
||||
private readonly IacBoundaryExtractor _extractor;
|
||||
|
||||
public IacBoundaryExtractorTests()
|
||||
{
|
||||
_extractor = new IacBoundaryExtractor(
|
||||
NullLogger<IacBoundaryExtractor>.Instance);
|
||||
}
|
||||
|
||||
#region Priority and CanHandle
|
||||
|
||||
[Fact]
|
||||
public void Priority_Returns150_BetweenBaseAndK8s()
|
||||
{
|
||||
Assert.Equal(150, _extractor.Priority);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("terraform", true)]
|
||||
[InlineData("Terraform", true)]
|
||||
[InlineData("cloudformation", true)]
|
||||
[InlineData("cfn", true)]
|
||||
[InlineData("pulumi", true)]
|
||||
[InlineData("helm", true)]
|
||||
[InlineData("iac", true)]
|
||||
[InlineData("k8s", false)]
|
||||
[InlineData("static", false)]
|
||||
[InlineData("kong", false)]
|
||||
public void CanHandle_WithSource_ReturnsExpected(string source, bool expected)
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with { Source = source };
|
||||
Assert.Equal(expected, _extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithTerraformAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.resource.aws_security_group"] = "sg-123"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithCloudFormationAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["cloudformation.AWS::EC2::SecurityGroup"] = "sg-123"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithHelmAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["helm.values.ingress.enabled"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithEmptyAnnotations_ReturnsFalse()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty;
|
||||
Assert.False(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IaC Type Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTerraformSource_ReturnsTerraformIacSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("iac:terraform", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCloudFormationSource_ReturnsCloudFormationIacSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "cloudformation", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "cloudformation"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("iac:cloudformation", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCfnSource_ReturnsCloudFormationIacSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "cfn", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "cfn"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("iac:cloudformation", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithPulumiSource_ReturnsPulumiIacSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "pulumi", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "pulumi"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("iac:pulumi", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithHelmSource_ReturnsHelmIacSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "helm", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "helm"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("iac:helm", result.Source);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Terraform Exposure Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTerraformPublicSecurityGroup_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_security_group.ingress.cidr"] = "0.0.0.0/0"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTerraformInternetFacingAlb_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_lb.internal"] = "false"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTerraformPublicIp_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_eip.public_ip"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTerraformPrivateResource_ReturnsInternalExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_vpc.private_subnets"] = "10.0.0.0/24"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("internal", result.Exposure.Level);
|
||||
Assert.False(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CloudFormation Exposure Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCloudFormationPublicSecurityGroup_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "cloudformation", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "cloudformation",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["cloudformation.AWS::EC2::SecurityGroup.Ingress"] = "0.0.0.0/0"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCloudFormationInternetFacingElb_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "cloudformation", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "cloudformation",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["cloudformation.AWS::ElasticLoadBalancingV2::LoadBalancer.Scheme"] = "internet-facing"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCloudFormationApiGateway_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "cloudformation", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "cloudformation",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["cloudformation.AWS::ApiGateway::RestApi"] = "my-api"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helm Exposure Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithHelmIngressEnabled_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "helm", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "helm",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["helm.values.ingress.enabled"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithHelmLoadBalancerService_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "helm", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "helm",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["helm.values.service.type"] = "LoadBalancer"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithHelmClusterIpService_ReturnsPrivateExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "helm", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "helm",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["helm.values.service.type"] = "ClusterIP"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("private", result.Exposure.Level);
|
||||
Assert.False(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Auth Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIamAuth_ReturnsIamAuthType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_iam_policy.auth"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("iam", result.Auth.Type);
|
||||
Assert.Equal("aws-iam", result.Auth.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithCognitoAuth_ReturnsOAuth2AuthType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "cloudformation", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "cloudformation",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["cloudformation.AWS::Cognito::UserPool"] = "my-pool"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("oauth2", result.Auth.Type);
|
||||
Assert.Equal("cognito", result.Auth.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithAzureAdAuth_ReturnsOAuth2AuthType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.azurerm_azure_ad_application"] = "my-app"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("oauth2", result.Auth.Type);
|
||||
Assert.Equal("azure-ad", result.Auth.Provider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithMtlsAuth_ReturnsMtlsAuthType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_acm_certificate.mtls"] = "enabled"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("mtls", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoAuth_ReturnsNullAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Auth);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Controls Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithSecurityGroup_ReturnsSecurityGroupControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_security_group.main"] = "sg-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "security_group");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithWaf_ReturnsWafControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_wafv2_web_acl.main"] = "waf-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "waf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithVpc_ReturnsNetworkIsolationControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_vpc.main"] = "vpc-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "network_isolation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNacl_ReturnsNetworkAclControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_network_acl.main"] = "nacl-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "network_acl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithDdosProtection_ReturnsDdosControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_shield_protection.main"] = "shield-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "ddos_protection");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTls_ReturnsEncryptionControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_acm_certificate.tls"] = "cert-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "encryption_in_transit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithPrivateEndpoint_ReturnsPrivateEndpointControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_vpc_endpoint.main"] = "vpce-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Contains(result.Controls, c => c.Type == "private_endpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithMultipleControls_ReturnsAllControls()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_security_group.main"] = "sg-123",
|
||||
["terraform.aws_wafv2_web_acl.main"] = "waf-123",
|
||||
["terraform.aws_vpc.main"] = "vpc-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Equal(3, result.Controls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoControls_ReturnsNullControls()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Controls);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Surface Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithHelmIngressPath_ReturnsSurfaceWithPath()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "helm", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "helm",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["helm.values.ingress.path"] = "/api/v1"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("/api/v1", result.Surface.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithHelmIngressHost_ReturnsSurfaceWithHost()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "helm", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "helm",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["helm.values.ingress.host"] = "api.example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("api.example.com", result.Surface.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_DefaultSurfaceType_ReturnsInfrastructure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("infrastructure", result.Surface.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_DefaultProtocol_ReturnsHttps()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("https", result.Surface.Protocol);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Confidence and Metadata
|
||||
|
||||
[Fact]
|
||||
public void Extract_BaseConfidence_Returns0Point6()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "iac", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "iac"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.6, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithKnownIacType_IncreasesConfidence()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.7, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithSecurityResources_IncreasesConfidence()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_security_group.main"] = "sg-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.8, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_MaxConfidence_CapsAt0Point85()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_security_group.main"] = "sg-123",
|
||||
["terraform.aws_wafv2_web_acl.main"] = "waf-123",
|
||||
["terraform.aws_vpc.main"] = "vpc-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Confidence <= 0.85);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_ReturnsNetworkKind()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("network", result.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_BuildsEvidenceRef_WithIacType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-123", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Namespace = "production",
|
||||
EnvironmentId = "env-456"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("iac/terraform/production/env-456/root-123", result.EvidenceRef);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExtractAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_ReturnsSameResultAsExtract()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_security_group.main"] = "sg-123"
|
||||
}
|
||||
};
|
||||
|
||||
var syncResult = _extractor.Extract(root, null, context);
|
||||
var asyncResult = await _extractor.ExtractAsync(root, null, context);
|
||||
|
||||
Assert.NotNull(syncResult);
|
||||
Assert.NotNull(asyncResult);
|
||||
Assert.Equal(syncResult.Kind, asyncResult.Kind);
|
||||
Assert.Equal(syncResult.Source, asyncResult.Source);
|
||||
Assert.Equal(syncResult.Confidence, asyncResult.Confidence);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNullRoot_ThrowsArgumentNullException()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with { Source = "terraform" };
|
||||
Assert.Throws<ArgumentNullException>(() => _extractor.Extract(null!, null, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WhenCannotHandle_ReturnsNull()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with { Source = "k8s" };
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithLoadBalancer_SetsBehindProxyTrue()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "terraform", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "terraform",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["terraform.aws_alb.main"] = "alb-123"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.True(result.Exposure.BehindProxy);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// K8sBoundaryExtractorTests.cs
|
||||
// Sprint: SPRINT_3800_0002_0002_boundary_k8s
|
||||
// Description: Unit tests for K8sBoundaryExtractor.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Boundary;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public class K8sBoundaryExtractorTests
|
||||
{
|
||||
private readonly K8sBoundaryExtractor _extractor;
|
||||
|
||||
public K8sBoundaryExtractorTests()
|
||||
{
|
||||
_extractor = new K8sBoundaryExtractor(
|
||||
NullLogger<K8sBoundaryExtractor>.Instance);
|
||||
}
|
||||
|
||||
#region Priority and CanHandle
|
||||
|
||||
[Fact]
|
||||
public void Priority_Returns200_HigherThanRichGraphExtractor()
|
||||
{
|
||||
Assert.Equal(200, _extractor.Priority);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("k8s", true)]
|
||||
[InlineData("K8S", true)]
|
||||
[InlineData("kubernetes", true)]
|
||||
[InlineData("Kubernetes", true)]
|
||||
[InlineData("static", false)]
|
||||
[InlineData("runtime", false)]
|
||||
public void CanHandle_WithSource_ReturnsExpected(string source, bool expected)
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with { Source = source };
|
||||
Assert.Equal(expected, _extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithK8sAnnotations_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kubernetes.io/ingress.class"] = "nginx"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithIngressAnnotation_ReturnsTrue()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/rewrite-target"] = "/"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanHandle_WithEmptyAnnotations_ReturnsFalse()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty;
|
||||
Assert.False(_extractor.CanHandle(context));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract - Exposure Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithInternetFacing_ReturnsPublicExposure()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
IsInternetFacing = true
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("public", result.Exposure.Level);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIngressClass_ReturnsInternetFacing()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kubernetes.io/ingress.class"] = "nginx"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.True(result.Exposure.InternetFacing);
|
||||
Assert.True(result.Exposure.BehindProxy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("LoadBalancer", "public", true)]
|
||||
[InlineData("NodePort", "internal", false)]
|
||||
[InlineData("ClusterIP", "private", false)]
|
||||
public void Extract_WithServiceType_ReturnsExpectedExposure(
|
||||
string serviceType, string expectedLevel, bool expectedInternetFacing)
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["service.type"] = serviceType
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal(expectedLevel, result.Exposure.Level);
|
||||
Assert.Equal(expectedInternetFacing, result.Exposure.InternetFacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithExternalPorts_ReturnsInternalLevel()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
PortBindings = new Dictionary<int, string> { [443] = "https" }
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("internal", result.Exposure.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithDmzZone_ReturnsInternalLevel()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
NetworkZone = "dmz"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Exposure);
|
||||
Assert.Equal("internal", result.Exposure.Level);
|
||||
Assert.Equal("dmz", result.Exposure.Zone);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract - Surface Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithServicePath_ReturnsSurfaceWithPath()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["service.path"] = "/api/v1"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("/api/v1", result.Surface.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithRewriteTarget_ReturnsSurfaceWithPath()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/rewrite-target"] = "/backend"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("/backend", result.Surface.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNamespace_ReturnsSurfaceWithNamespacePath()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Namespace = "production"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("/production", result.Surface.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithTlsAnnotation_ReturnsHttpsProtocol()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["cert-manager.io/cluster-issuer"] = "letsencrypt"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("https", result.Surface.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithGrpcAnnotation_ReturnsGrpcProtocol()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["grpc.service"] = "UserService"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("grpc", result.Surface.Protocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithPortBinding_ReturnsSurfaceWithPort()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
PortBindings = new Dictionary<int, string> { [8080] = "http" }
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal(8080, result.Surface.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIngressHost_ReturnsSurfaceWithHost()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["ingress.host"] = "api.example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Surface);
|
||||
Assert.Equal("api.example.com", result.Surface.Host);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract - Auth Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithBasicAuth_ReturnsBasicAuthType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/auth-secret"] = "basic-auth-secret"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("basic", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithOAuth_ReturnsOAuth2Type()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/oauth2-signin"] = "https://auth.example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("oauth2", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithMtls_ReturnsMtlsType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/auth-tls-secret"] = "client-certs"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("mtls", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithExplicitAuthType_ReturnsSpecifiedType()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/auth-type"] = "jwt"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.True(result.Auth.Required);
|
||||
Assert.Equal("jwt", result.Auth.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithAuthRoles_ReturnsRolesList()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/auth-type"] = "oauth2",
|
||||
["nginx.ingress.kubernetes.io/auth-roles"] = "admin,editor,viewer"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Auth);
|
||||
Assert.NotNull(result.Auth.Roles);
|
||||
Assert.Equal(3, result.Auth.Roles.Count);
|
||||
Assert.Contains("admin", result.Auth.Roles);
|
||||
Assert.Contains("editor", result.Auth.Roles);
|
||||
Assert.Contains("viewer", result.Auth.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoAuth_ReturnsNullAuth()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Auth);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract - Controls Detection
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNetworkPolicy_ReturnsNetworkPolicyControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Namespace = "production",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["network.policy.enabled"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
var control = Assert.Single(result.Controls);
|
||||
Assert.Equal("network_policy", control.Type);
|
||||
Assert.True(control.Active);
|
||||
Assert.Equal("production", control.Config);
|
||||
Assert.Equal("high", control.Effectiveness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithRateLimit_ReturnsRateLimitControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/rate-limit"] = "100"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
var control = Assert.Single(result.Controls);
|
||||
Assert.Equal("rate_limit", control.Type);
|
||||
Assert.True(control.Active);
|
||||
Assert.Equal("medium", control.Effectiveness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIpAllowlist_ReturnsIpAllowlistControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/whitelist-source-range"] = "10.0.0.0/8"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
var control = Assert.Single(result.Controls);
|
||||
Assert.Equal("ip_allowlist", control.Type);
|
||||
Assert.True(control.Active);
|
||||
Assert.Equal("high", control.Effectiveness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithWaf_ReturnsWafControl()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/enable-modsecurity"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
var control = Assert.Single(result.Controls);
|
||||
Assert.Equal("waf", control.Type);
|
||||
Assert.True(control.Active);
|
||||
Assert.Equal("high", control.Effectiveness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithMultipleControls_ReturnsAllControls()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["network.policy.enabled"] = "true",
|
||||
["nginx.ingress.kubernetes.io/rate-limit"] = "100",
|
||||
["nginx.ingress.kubernetes.io/enable-modsecurity"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Controls);
|
||||
Assert.Equal(3, result.Controls.Count);
|
||||
Assert.Contains(result.Controls, c => c.Type == "network_policy");
|
||||
Assert.Contains(result.Controls, c => c.Type == "rate_limit");
|
||||
Assert.Contains(result.Controls, c => c.Type == "waf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNoControls_ReturnsNullControls()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Controls);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract - Confidence and Metadata
|
||||
|
||||
[Fact]
|
||||
public void Extract_BaseConfidence_Returns0Point7()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.7, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithIngressAnnotation_IncreasesConfidence()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/rewrite-target"] = "/"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.85, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithServiceType_IncreasesConfidence()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["service.type"] = "ClusterIP"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0.8, result.Confidence, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_MaxConfidence_CapsAt0Point95()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kubernetes.io/ingress.class"] = "nginx",
|
||||
["service.type"] = "LoadBalancer"
|
||||
}
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Confidence <= 0.95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_ReturnsK8sSource()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("k8s", result.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_BuildsEvidenceRef_WithNamespaceAndEnvironment()
|
||||
{
|
||||
var root = new RichGraphRoot("root-123", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Namespace = "production",
|
||||
EnvironmentId = "env-456"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("k8s/production/env-456/root-123", result.EvidenceRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_ReturnsNetworkKind()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s"
|
||||
};
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("network", result.Kind);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExtractAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_ReturnsSameResultAsExtract()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "k8s", null);
|
||||
var context = BoundaryExtractionContext.Empty with
|
||||
{
|
||||
Source = "k8s",
|
||||
Namespace = "production",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["kubernetes.io/ingress.class"] = "nginx"
|
||||
}
|
||||
};
|
||||
|
||||
var syncResult = _extractor.Extract(root, null, context);
|
||||
var asyncResult = await _extractor.ExtractAsync(root, null, context);
|
||||
|
||||
Assert.NotNull(syncResult);
|
||||
Assert.NotNull(asyncResult);
|
||||
Assert.Equal(syncResult.Kind, asyncResult.Kind);
|
||||
Assert.Equal(syncResult.Source, asyncResult.Source);
|
||||
Assert.Equal(syncResult.Confidence, asyncResult.Confidence);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Extract_WithNullRoot_ThrowsArgumentNullException()
|
||||
{
|
||||
var context = BoundaryExtractionContext.Empty with { Source = "k8s" };
|
||||
Assert.Throws<ArgumentNullException>(() => _extractor.Extract(null!, null, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_WhenCannotHandle_ReturnsNull()
|
||||
{
|
||||
var root = new RichGraphRoot("root-1", "static", null);
|
||||
var context = BoundaryExtractionContext.Empty with { Source = "static" };
|
||||
|
||||
var result = _extractor.Extract(root, null, context);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SurfaceAwareReachabilityIntegrationTests.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-013)
|
||||
// Description: End-to-end integration tests for surface-aware reachability analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Surfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the surface-aware reachability analyzer.
|
||||
/// Tests the complete flow from vulnerability input through surface query to reachability result.
|
||||
/// </summary>
|
||||
public sealed class SurfaceAwareReachabilityIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly InMemorySurfaceRepository _surfaceRepo;
|
||||
private readonly InMemoryCallGraphAccessor _callGraphAccessor;
|
||||
private readonly InMemoryReachabilityGraphService _graphService;
|
||||
private readonly SurfaceQueryService _surfaceQueryService;
|
||||
private readonly SurfaceAwareReachabilityAnalyzer _analyzer;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public SurfaceAwareReachabilityIntegrationTests()
|
||||
{
|
||||
_surfaceRepo = new InMemorySurfaceRepository();
|
||||
_callGraphAccessor = new InMemoryCallGraphAccessor();
|
||||
_graphService = new InMemoryReachabilityGraphService();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_surfaceQueryService = new SurfaceQueryService(
|
||||
_surfaceRepo,
|
||||
_cache,
|
||||
NullLogger<SurfaceQueryService>.Instance,
|
||||
new SurfaceQueryOptions { EnableCaching = true });
|
||||
|
||||
_analyzer = new SurfaceAwareReachabilityAnalyzer(
|
||||
_surfaceQueryService,
|
||||
_graphService,
|
||||
NullLogger<SurfaceAwareReachabilityAnalyzer>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
#region Confirmed Reachable Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WhenTriggerMethodIsReachable_ReturnsConfirmedTier()
|
||||
{
|
||||
// Arrange: Create a call graph with path to vulnerable method
|
||||
// Entrypoint → Controller → Service → VulnerableLib.Deserialize()
|
||||
_callGraphAccessor.AddEntrypoint("API.UsersController::GetUser");
|
||||
_callGraphAccessor.AddEdge("API.UsersController::GetUser", "API.UserService::FetchUser");
|
||||
_callGraphAccessor.AddEdge("API.UserService::FetchUser", "Newtonsoft.Json.JsonConvert::DeserializeObject");
|
||||
|
||||
// Add surface with trigger method
|
||||
var surfaceId = Guid.NewGuid();
|
||||
_surfaceRepo.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId,
|
||||
CveId = "CVE-2023-1234",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
VulnVersion = "12.0.1",
|
||||
FixedVersion = "12.0.3",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TriggerCount = 1
|
||||
});
|
||||
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
||||
{
|
||||
new() { MethodKey = "Newtonsoft.Json.JsonConvert::DeserializeObject", MethodName = "DeserializeObject", DeclaringType = "JsonConvert" }
|
||||
});
|
||||
|
||||
// Configure graph service to find path
|
||||
_graphService.AddReachablePath(
|
||||
entrypoint: "API.UsersController::GetUser",
|
||||
sink: "Newtonsoft.Json.JsonConvert::DeserializeObject",
|
||||
pathMethods: new[] { "API.UsersController::GetUser", "API.UserService::FetchUser", "Newtonsoft.Json.JsonConvert::DeserializeObject" });
|
||||
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CveId = "CVE-2023-1234",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Newtonsoft.Json",
|
||||
Version = "12.0.1"
|
||||
}
|
||||
},
|
||||
CallGraph = _callGraphAccessor
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Findings.Should().HaveCount(1);
|
||||
var finding = result.Findings[0];
|
||||
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed);
|
||||
finding.SinkSource.Should().Be(SinkSource.Surface);
|
||||
finding.Witnesses.Should().NotBeEmpty();
|
||||
result.ConfirmedReachable.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WhenMultipleTriggerMethodsAreReachable_ReturnsMultipleWitnesses()
|
||||
{
|
||||
// Arrange: Create call graph with paths to multiple triggers
|
||||
_callGraphAccessor.AddEntrypoint("API.Controller::Action1");
|
||||
_callGraphAccessor.AddEntrypoint("API.Controller::Action2");
|
||||
_callGraphAccessor.AddEdge("API.Controller::Action1", "VulnLib::Method1");
|
||||
_callGraphAccessor.AddEdge("API.Controller::Action2", "VulnLib::Method2");
|
||||
|
||||
var surfaceId = Guid.NewGuid();
|
||||
_surfaceRepo.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId,
|
||||
CveId = "CVE-2024-5678",
|
||||
Ecosystem = "npm",
|
||||
PackageName = "vulnerable-lib",
|
||||
VulnVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TriggerCount = 2
|
||||
});
|
||||
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
||||
{
|
||||
new() { MethodKey = "VulnLib::Method1", MethodName = "Method1", DeclaringType = "VulnLib" },
|
||||
new() { MethodKey = "VulnLib::Method2", MethodName = "Method2", DeclaringType = "VulnLib" }
|
||||
});
|
||||
|
||||
_graphService.AddReachablePath("API.Controller::Action1", "VulnLib::Method1",
|
||||
new[] { "API.Controller::Action1", "VulnLib::Method1" });
|
||||
_graphService.AddReachablePath("API.Controller::Action2", "VulnLib::Method2",
|
||||
new[] { "API.Controller::Action2", "VulnLib::Method2" });
|
||||
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new() { CveId = "CVE-2024-5678", Ecosystem = "npm", PackageName = "vulnerable-lib", Version = "1.0.0" }
|
||||
},
|
||||
CallGraph = _callGraphAccessor
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Findings.Should().HaveCount(1);
|
||||
var finding = result.Findings[0];
|
||||
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed);
|
||||
finding.Witnesses.Should().HaveCountGreaterOrEqualTo(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unreachable Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WhenTriggerMethodNotReachable_ReturnsUnreachableTier()
|
||||
{
|
||||
// Arrange: Surface exists but no path to trigger
|
||||
_callGraphAccessor.AddEntrypoint("API.Controller::Action");
|
||||
_callGraphAccessor.AddEdge("API.Controller::Action", "SafeLib::SafeMethod");
|
||||
|
||||
var surfaceId = Guid.NewGuid();
|
||||
_surfaceRepo.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId,
|
||||
CveId = "CVE-2023-9999",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Vulnerable.Package",
|
||||
VulnVersion = "2.0.0",
|
||||
FixedVersion = "2.0.1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TriggerCount = 1
|
||||
});
|
||||
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
||||
{
|
||||
new() { MethodKey = "Vulnerable.Package::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Vulnerable.Package" }
|
||||
});
|
||||
|
||||
// No paths configured in graph service = unreachable
|
||||
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new() { CveId = "CVE-2023-9999", Ecosystem = "nuget", PackageName = "Vulnerable.Package", Version = "2.0.0" }
|
||||
},
|
||||
CallGraph = _callGraphAccessor
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Findings.Should().HaveCount(1);
|
||||
var finding = result.Findings[0];
|
||||
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Unreachable);
|
||||
finding.SinkSource.Should().Be(SinkSource.Surface);
|
||||
finding.Witnesses.Should().BeEmpty();
|
||||
result.Unreachable.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Likely Reachable (Fallback) Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WhenNoSurfaceButPackageApiCalled_ReturnsLikelyTier()
|
||||
{
|
||||
// Arrange: No surface exists, but package API is called
|
||||
_callGraphAccessor.AddEntrypoint("API.Controller::Action");
|
||||
_callGraphAccessor.AddEdge("API.Controller::Action", "UnknownLib.Client::DoSomething");
|
||||
|
||||
// Configure graph service for fallback path detection
|
||||
_graphService.AddReachablePath("API.Controller::Action", "UnknownLib.Client::DoSomething",
|
||||
new[] { "API.Controller::Action", "UnknownLib.Client::DoSomething" });
|
||||
|
||||
// No surface - will trigger fallback mode
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new() { CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "UnknownLib", Version = "1.0.0" }
|
||||
},
|
||||
CallGraph = _callGraphAccessor
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Findings.Should().HaveCount(1);
|
||||
var finding = result.Findings[0];
|
||||
// Without surface, should be either Likely or Present depending on fallback analysis
|
||||
finding.SinkSource.Should().BeOneOf(SinkSource.PackageApi, SinkSource.FallbackAll);
|
||||
finding.ConfidenceTier.Should().BeOneOf(
|
||||
ReachabilityConfidenceTier.Likely,
|
||||
ReachabilityConfidenceTier.Present);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Present Only Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WhenNoCallGraphData_ReturnsPresentTier()
|
||||
{
|
||||
// Arrange: No surface, no call graph paths
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new() { CveId = "CVE-2024-9999", Ecosystem = "npm", PackageName = "mystery-lib", Version = "0.0.1" }
|
||||
},
|
||||
CallGraph = null // No call graph available
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Findings.Should().HaveCount(1);
|
||||
var finding = result.Findings[0];
|
||||
finding.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Present);
|
||||
finding.SinkSource.Should().Be(SinkSource.FallbackAll);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Vulnerabilities Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithMultipleVulnerabilities_ReturnsCorrectTiersForEach()
|
||||
{
|
||||
// Arrange: Set up mixed scenario
|
||||
_callGraphAccessor.AddEntrypoint("API.Controller::Action");
|
||||
_callGraphAccessor.AddEdge("API.Controller::Action", "Lib1::Method");
|
||||
|
||||
// Vuln 1: Surface + path = Confirmed
|
||||
var surface1 = Guid.NewGuid();
|
||||
_surfaceRepo.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surface1,
|
||||
CveId = "CVE-2024-0001",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Lib1",
|
||||
VulnVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TriggerCount = 1
|
||||
});
|
||||
_surfaceRepo.AddTriggers(surface1, new List<TriggerMethodInfo>
|
||||
{
|
||||
new() { MethodKey = "Lib1::Method", MethodName = "Method", DeclaringType = "Lib1" }
|
||||
});
|
||||
_graphService.AddReachablePath("API.Controller::Action", "Lib1::Method",
|
||||
new[] { "API.Controller::Action", "Lib1::Method" });
|
||||
|
||||
// Vuln 2: Surface but no path = Unreachable
|
||||
var surface2 = Guid.NewGuid();
|
||||
_surfaceRepo.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surface2,
|
||||
CveId = "CVE-2024-0002",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Lib2",
|
||||
VulnVersion = "2.0.0",
|
||||
FixedVersion = "2.0.1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TriggerCount = 1
|
||||
});
|
||||
_surfaceRepo.AddTriggers(surface2, new List<TriggerMethodInfo>
|
||||
{
|
||||
new() { MethodKey = "Lib2::DangerousMethod", MethodName = "DangerousMethod", DeclaringType = "Lib2" }
|
||||
});
|
||||
// No path to Lib2 = unreachable
|
||||
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new() { CveId = "CVE-2024-0001", Ecosystem = "nuget", PackageName = "Lib1", Version = "1.0.0" },
|
||||
new() { CveId = "CVE-2024-0002", Ecosystem = "nuget", PackageName = "Lib2", Version = "2.0.0" }
|
||||
},
|
||||
CallGraph = _callGraphAccessor
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Findings.Should().HaveCount(2);
|
||||
result.ConfirmedReachable.Should().Be(1);
|
||||
result.Unreachable.Should().Be(1);
|
||||
|
||||
var confirmed = result.Findings.First(f => f.CveId == "CVE-2024-0001");
|
||||
confirmed.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Confirmed);
|
||||
|
||||
var unreachable = result.Findings.First(f => f.CveId == "CVE-2024-0002");
|
||||
unreachable.ConfidenceTier.Should().Be(ReachabilityConfidenceTier.Unreachable);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Surface Caching Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_CachesSurfaceQueries_DoesNotQueryTwice()
|
||||
{
|
||||
// Arrange
|
||||
var surfaceId = Guid.NewGuid();
|
||||
_surfaceRepo.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId,
|
||||
CveId = "CVE-2024-CACHED",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "CachedLib",
|
||||
VulnVersion = "1.0.0",
|
||||
FixedVersion = "1.0.1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
TriggerCount = 1
|
||||
});
|
||||
_surfaceRepo.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
||||
{
|
||||
new() { MethodKey = "CachedLib::Method", MethodName = "Method", DeclaringType = "CachedLib" }
|
||||
});
|
||||
|
||||
_callGraphAccessor.AddEntrypoint("App::Main");
|
||||
_callGraphAccessor.AddEdge("App::Main", "CachedLib::Method");
|
||||
_graphService.AddReachablePath("App::Main", "CachedLib::Method",
|
||||
new[] { "App::Main", "CachedLib::Method" });
|
||||
|
||||
var request = new SurfaceAwareReachabilityRequest
|
||||
{
|
||||
Vulnerabilities = new List<VulnerabilityInfo>
|
||||
{
|
||||
new() { CveId = "CVE-2024-CACHED", Ecosystem = "nuget", PackageName = "CachedLib", Version = "1.0.0" }
|
||||
},
|
||||
CallGraph = _callGraphAccessor
|
||||
};
|
||||
|
||||
// Act: Query twice
|
||||
await _analyzer.AnalyzeAsync(request);
|
||||
var initialQueryCount = _surfaceRepo.QueryCount;
|
||||
|
||||
await _analyzer.AnalyzeAsync(request);
|
||||
var finalQueryCount = _surfaceRepo.QueryCount;
|
||||
|
||||
// Assert: Should use cache, not query again
|
||||
finalQueryCount.Should().Be(initialQueryCount, "second analysis should use cached surface data");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ISurfaceRepository for testing.
|
||||
/// </summary>
|
||||
private sealed class InMemorySurfaceRepository : ISurfaceRepository
|
||||
{
|
||||
private readonly Dictionary<string, SurfaceInfo> _surfaces = new();
|
||||
private readonly Dictionary<Guid, List<TriggerMethodInfo>> _triggers = new();
|
||||
private readonly Dictionary<Guid, List<string>> _sinks = new();
|
||||
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public void AddSurface(SurfaceInfo surface)
|
||||
{
|
||||
var key = $"{surface.CveId}|{surface.Ecosystem}|{surface.PackageName}|{surface.VulnVersion}";
|
||||
_surfaces[key] = surface;
|
||||
}
|
||||
|
||||
public void AddTriggers(Guid surfaceId, List<TriggerMethodInfo> triggers)
|
||||
{
|
||||
_triggers[surfaceId] = triggers;
|
||||
}
|
||||
|
||||
public void AddSinks(Guid surfaceId, List<string> sinks)
|
||||
{
|
||||
_sinks[surfaceId] = sinks;
|
||||
}
|
||||
|
||||
public Task<SurfaceInfo?> GetSurfaceAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default)
|
||||
{
|
||||
QueryCount++;
|
||||
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
|
||||
_surfaces.TryGetValue(key, out var surface);
|
||||
return Task.FromResult(surface);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(Guid surfaceId, int maxCount, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(
|
||||
_triggers.TryGetValue(surfaceId, out var triggers) ? triggers : new List<TriggerMethodInfo>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetSinksAsync(Guid surfaceId, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(
|
||||
_sinks.TryGetValue(surfaceId, out var sinks) ? sinks : new List<string>());
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken ct = default)
|
||||
{
|
||||
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
|
||||
return Task.FromResult(_surfaces.ContainsKey(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ICallGraphAccessor for testing.
|
||||
/// </summary>
|
||||
private sealed class InMemoryCallGraphAccessor : ICallGraphAccessor
|
||||
{
|
||||
private readonly HashSet<string> _entrypoints = new();
|
||||
private readonly Dictionary<string, List<string>> _callees = new();
|
||||
private readonly HashSet<string> _methods = new();
|
||||
|
||||
public void AddEntrypoint(string methodKey)
|
||||
{
|
||||
_entrypoints.Add(methodKey);
|
||||
_methods.Add(methodKey);
|
||||
}
|
||||
|
||||
public void AddEdge(string caller, string callee)
|
||||
{
|
||||
if (!_callees.ContainsKey(caller))
|
||||
_callees[caller] = new List<string>();
|
||||
|
||||
_callees[caller].Add(callee);
|
||||
_methods.Add(caller);
|
||||
_methods.Add(callee);
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetEntrypoints() => _entrypoints.ToList();
|
||||
|
||||
public IReadOnlyList<string> GetCallees(string methodKey) =>
|
||||
_callees.TryGetValue(methodKey, out var callees) ? callees : new List<string>();
|
||||
|
||||
public bool ContainsMethod(string methodKey) => _methods.Contains(methodKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IReachabilityGraphService for testing.
|
||||
/// </summary>
|
||||
private sealed class InMemoryReachabilityGraphService : IReachabilityGraphService
|
||||
{
|
||||
private readonly List<ReachablePath> _paths = new();
|
||||
|
||||
public void AddReachablePath(string entrypoint, string sink, string[] pathMethods)
|
||||
{
|
||||
_paths.Add(new ReachablePath
|
||||
{
|
||||
EntrypointMethodKey = entrypoint,
|
||||
SinkMethodKey = sink,
|
||||
PathLength = pathMethods.Length,
|
||||
PathMethodKeys = pathMethods.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReachablePath>> FindPathsToSinksAsync(
|
||||
ICallGraphAccessor callGraph,
|
||||
IReadOnlyList<string> sinkMethodKeys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Return paths that match any of the requested sinks
|
||||
var matchingPaths = _paths
|
||||
.Where(p => sinkMethodKeys.Contains(p.SinkMethodKey))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReachablePath>>(matchingPaths);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user