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:
@@ -19,10 +19,22 @@ public static class BoundaryServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBoundaryExtractors(this IServiceCollection services)
|
||||
{
|
||||
// Register base extractor
|
||||
// Register base extractor (Priority 100 - fallback)
|
||||
services.TryAddSingleton<RichGraphBoundaryExtractor>();
|
||||
services.TryAddSingleton<IBoundaryProofExtractor, RichGraphBoundaryExtractor>();
|
||||
|
||||
// Register IaC extractor (Priority 150 - for Terraform/CloudFormation/Pulumi/Helm sources)
|
||||
services.TryAddSingleton<IacBoundaryExtractor>();
|
||||
services.AddSingleton<IBoundaryProofExtractor, IacBoundaryExtractor>();
|
||||
|
||||
// Register K8s extractor (Priority 200 - higher precedence for K8s sources)
|
||||
services.TryAddSingleton<K8sBoundaryExtractor>();
|
||||
services.AddSingleton<IBoundaryProofExtractor, K8sBoundaryExtractor>();
|
||||
|
||||
// Register Gateway extractor (Priority 250 - higher precedence for API gateway sources)
|
||||
services.TryAddSingleton<GatewayBoundaryExtractor>();
|
||||
services.AddSingleton<IBoundaryProofExtractor, GatewayBoundaryExtractor>();
|
||||
|
||||
// Register composite extractor that uses all available extractors
|
||||
services.TryAddSingleton<CompositeBoundaryExtractor>();
|
||||
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GatewayBoundaryExtractor.cs
|
||||
// Sprint: SPRINT_3800_0002_0003_boundary_gateway
|
||||
// Description: Extracts boundary proof from API Gateway metadata.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Boundary;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts boundary proof from API Gateway deployment metadata.
|
||||
/// Parses Kong, Envoy/Istio, AWS API Gateway, and Traefik configurations.
|
||||
/// </summary>
|
||||
public sealed class GatewayBoundaryExtractor : IBoundaryProofExtractor
|
||||
{
|
||||
private readonly ILogger<GatewayBoundaryExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Gateway source identifiers
|
||||
private static readonly string[] GatewaySources =
|
||||
[
|
||||
"gateway",
|
||||
"kong",
|
||||
"envoy",
|
||||
"istio",
|
||||
"apigateway",
|
||||
"traefik"
|
||||
];
|
||||
|
||||
// Gateway annotation prefixes
|
||||
private static readonly string[] GatewayAnnotationPrefixes =
|
||||
[
|
||||
"kong.",
|
||||
"konghq.com/",
|
||||
"envoy.",
|
||||
"istio.io/",
|
||||
"apigateway.",
|
||||
"traefik.",
|
||||
"getambassador.io/"
|
||||
];
|
||||
|
||||
public GatewayBoundaryExtractor(
|
||||
ILogger<GatewayBoundaryExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Priority => 250; // Higher than K8sBoundaryExtractor (200)
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(BoundaryExtractionContext context)
|
||||
{
|
||||
// Handle when source is a known gateway
|
||||
if (GatewaySources.Any(s =>
|
||||
string.Equals(context.Source, s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also handle if annotations contain gateway-specific keys
|
||||
return context.Annotations.Keys.Any(k =>
|
||||
GatewayAnnotationPrefixes.Any(prefix =>
|
||||
k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BoundaryProof?> ExtractAsync(
|
||||
RichGraphRoot root,
|
||||
RichGraphNode? rootNode,
|
||||
BoundaryExtractionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Extract(root, rootNode, context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BoundaryProof? Extract(
|
||||
RichGraphRoot root,
|
||||
RichGraphNode? rootNode,
|
||||
BoundaryExtractionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
if (!CanHandle(context))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var annotations = context.Annotations;
|
||||
var gatewayType = DetectGatewayType(context);
|
||||
var exposure = DetermineExposure(context, gatewayType);
|
||||
var surface = DetermineSurface(context, annotations, gatewayType);
|
||||
var auth = DetectAuth(annotations, gatewayType);
|
||||
var controls = DetectControls(annotations, gatewayType);
|
||||
var confidence = CalculateConfidence(annotations, gatewayType);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Gateway boundary extraction: gateway={Gateway}, exposure={ExposureLevel}, confidence={Confidence:F2}",
|
||||
gatewayType,
|
||||
exposure.Level,
|
||||
confidence);
|
||||
|
||||
return new BoundaryProof
|
||||
{
|
||||
Kind = "network",
|
||||
Surface = surface,
|
||||
Exposure = exposure,
|
||||
Auth = auth,
|
||||
Controls = controls.Count > 0 ? controls : null,
|
||||
LastSeen = _timeProvider.GetUtcNow(),
|
||||
Confidence = confidence,
|
||||
Source = $"gateway:{gatewayType}",
|
||||
EvidenceRef = BuildEvidenceRef(context, root.Id, gatewayType)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Gateway boundary extraction failed for root {RootId}", root.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string DetectGatewayType(BoundaryExtractionContext context)
|
||||
{
|
||||
var source = context.Source?.ToLowerInvariant() ?? string.Empty;
|
||||
var annotations = context.Annotations;
|
||||
|
||||
// Check source first
|
||||
if (source.Contains("kong"))
|
||||
return "kong";
|
||||
if (source.Contains("envoy") || source.Contains("istio"))
|
||||
return "envoy";
|
||||
if (source.Contains("apigateway"))
|
||||
return "aws-apigw";
|
||||
if (source.Contains("traefik"))
|
||||
return "traefik";
|
||||
|
||||
// Check annotations
|
||||
if (annotations.Keys.Any(k => k.StartsWith("kong.", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith("konghq.com/", StringComparison.OrdinalIgnoreCase)))
|
||||
return "kong";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("istio.io/", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith("envoy.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "envoy";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("apigateway.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "aws-apigw";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("traefik.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "traefik";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("getambassador.io/", StringComparison.OrdinalIgnoreCase)))
|
||||
return "ambassador";
|
||||
|
||||
return "generic";
|
||||
}
|
||||
|
||||
private BoundaryExposure DetermineExposure(BoundaryExtractionContext context, string gatewayType)
|
||||
{
|
||||
var annotations = context.Annotations;
|
||||
var level = "public"; // API gateways are typically internet-facing
|
||||
var internetFacing = true;
|
||||
var behindProxy = true; // Gateway is the proxy
|
||||
List<string>? clientTypes = ["browser", "api_client", "mobile"];
|
||||
|
||||
// Check for internal-only configurations
|
||||
if (annotations.TryGetValue($"{gatewayType}.internal", out var isInternal) ||
|
||||
annotations.TryGetValue("internal", out isInternal))
|
||||
{
|
||||
if (bool.TryParse(isInternal, out var internalFlag) && internalFlag)
|
||||
{
|
||||
level = "internal";
|
||||
internetFacing = false;
|
||||
clientTypes = ["service"];
|
||||
}
|
||||
}
|
||||
|
||||
// Istio mesh internal
|
||||
if (gatewayType == "envoy" &&
|
||||
annotations.Keys.Any(k => k.Contains("mesh", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
level = "internal";
|
||||
internetFacing = false;
|
||||
clientTypes = ["service"];
|
||||
}
|
||||
|
||||
// AWS internal API
|
||||
if (gatewayType == "aws-apigw" &&
|
||||
annotations.TryGetValue("apigateway.endpoint-type", out var endpointType))
|
||||
{
|
||||
if (endpointType.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
level = "internal";
|
||||
internetFacing = false;
|
||||
clientTypes = ["service"];
|
||||
}
|
||||
}
|
||||
|
||||
return new BoundaryExposure
|
||||
{
|
||||
Level = level,
|
||||
InternetFacing = internetFacing,
|
||||
Zone = context.NetworkZone,
|
||||
BehindProxy = behindProxy,
|
||||
ClientTypes = clientTypes
|
||||
};
|
||||
}
|
||||
|
||||
private BoundarySurface DetermineSurface(
|
||||
BoundaryExtractionContext context,
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string gatewayType)
|
||||
{
|
||||
string? path = null;
|
||||
string protocol = "https";
|
||||
int? port = null;
|
||||
string? host = null;
|
||||
|
||||
// Gateway-specific path extraction
|
||||
path = gatewayType switch
|
||||
{
|
||||
"kong" => TryGetAnnotation(annotations, "kong.route.path", "kong.path", "konghq.com/path"),
|
||||
"envoy" => TryGetAnnotation(annotations, "envoy.route.prefix", "istio.io/path"),
|
||||
"aws-apigw" => TryGetAnnotation(annotations, "apigateway.path", "apigateway.resource-path"),
|
||||
"traefik" => TryGetAnnotation(annotations, "traefik.http.routers.path", "traefik.path"),
|
||||
_ => TryGetAnnotation(annotations, "path", "route.path")
|
||||
};
|
||||
|
||||
// Default path from namespace
|
||||
path ??= !string.IsNullOrEmpty(context.Namespace) ? $"/{context.Namespace}" : "/";
|
||||
|
||||
// Host extraction
|
||||
host = gatewayType switch
|
||||
{
|
||||
"kong" => TryGetAnnotation(annotations, "kong.route.host", "konghq.com/host"),
|
||||
"envoy" => TryGetAnnotation(annotations, "istio.io/host", "envoy.virtual-host"),
|
||||
"aws-apigw" => TryGetAnnotation(annotations, "apigateway.domain-name"),
|
||||
"traefik" => TryGetAnnotation(annotations, "traefik.http.routers.host"),
|
||||
_ => TryGetAnnotation(annotations, "host")
|
||||
};
|
||||
|
||||
// Protocol - gateways typically use HTTPS
|
||||
if (annotations.Keys.Any(k => k.Contains("grpc", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
protocol = "grpc";
|
||||
}
|
||||
else if (annotations.Keys.Any(k => k.Contains("websocket", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
protocol = "wss";
|
||||
}
|
||||
|
||||
// Port from bindings
|
||||
if (context.PortBindings.Count > 0)
|
||||
{
|
||||
port = context.PortBindings.Keys.FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default gateway ports
|
||||
port = protocol switch
|
||||
{
|
||||
"https" => 443,
|
||||
"grpc" => 443,
|
||||
"wss" => 443,
|
||||
_ => 80
|
||||
};
|
||||
}
|
||||
|
||||
return new BoundarySurface
|
||||
{
|
||||
Type = "api",
|
||||
Protocol = protocol,
|
||||
Port = port,
|
||||
Host = host,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private BoundaryAuth? DetectAuth(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string gatewayType)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
bool? mfaRequired = null;
|
||||
|
||||
switch (gatewayType)
|
||||
{
|
||||
case "kong":
|
||||
(authType, required, roles, provider) = DetectKongAuth(annotations);
|
||||
break;
|
||||
case "envoy":
|
||||
(authType, required, roles, provider) = DetectEnvoyAuth(annotations);
|
||||
break;
|
||||
case "aws-apigw":
|
||||
(authType, required, roles, provider) = DetectAwsApigwAuth(annotations);
|
||||
break;
|
||||
case "traefik":
|
||||
(authType, required, roles, provider) = DetectTraefikAuth(annotations);
|
||||
break;
|
||||
default:
|
||||
(authType, required, roles, provider) = DetectGenericAuth(annotations);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!required)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BoundaryAuth
|
||||
{
|
||||
Required = required,
|
||||
Type = authType,
|
||||
Roles = roles,
|
||||
Provider = provider,
|
||||
MfaRequired = mfaRequired
|
||||
};
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, List<string>? roles, string? provider) DetectKongAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// JWT plugin
|
||||
if (key.Contains("jwt", StringComparison.OrdinalIgnoreCase) &&
|
||||
(key.Contains("plugin", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("kong", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authType = "jwt";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// OAuth2 plugin
|
||||
if (key.Contains("oauth2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Key-auth plugin
|
||||
if (key.Contains("key-auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "api_key";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Basic auth plugin
|
||||
if (key.Contains("basic-auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "basic";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// ACL plugin for roles
|
||||
if (key.Contains("acl", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("allow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
roles = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, roles, provider);
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, List<string>? roles, string? provider) DetectEnvoyAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Istio JWT policy
|
||||
if (key.Contains("jwt", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("requestauthentication", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "jwt";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Istio AuthorizationPolicy
|
||||
if (key.Contains("authorizationpolicy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType ??= "rbac";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// mTLS
|
||||
if (key.Contains("mtls", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("peerauthentication", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "mtls";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// OIDC filter
|
||||
if (key.Contains("oidc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
provider = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, roles, provider);
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, List<string>? roles, string? provider) DetectAwsApigwAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Cognito authorizer
|
||||
if (key.Contains("cognito", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
provider = "cognito";
|
||||
}
|
||||
|
||||
// Lambda authorizer
|
||||
if (key.Contains("lambda-authorizer", StringComparison.OrdinalIgnoreCase) ||
|
||||
(key.Contains("authorizer", StringComparison.OrdinalIgnoreCase) &&
|
||||
value.Contains("lambda", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authType = "custom";
|
||||
required = true;
|
||||
provider = "lambda";
|
||||
}
|
||||
|
||||
// API key required
|
||||
if (key.Contains("api-key-required", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (bool.TryParse(value, out var keyRequired) && keyRequired)
|
||||
{
|
||||
authType = "api_key";
|
||||
required = true;
|
||||
}
|
||||
}
|
||||
|
||||
// IAM authorizer
|
||||
if (key.Contains("iam", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("authorizer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "iam";
|
||||
required = true;
|
||||
provider = "aws-iam";
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, roles, provider);
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, List<string>? roles, string? provider) DetectTraefikAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Basic auth middleware
|
||||
if (key.Contains("basicauth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "basic";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Digest auth middleware
|
||||
if (key.Contains("digestauth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "digest";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Forward auth middleware (external auth)
|
||||
if (key.Contains("forwardauth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "custom";
|
||||
required = true;
|
||||
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
provider = value;
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth middleware plugin
|
||||
if (key.Contains("oauth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, roles, provider);
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, List<string>? roles, string? provider) DetectGenericAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
if (key.Contains("auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (key.Contains("jwt", StringComparison.OrdinalIgnoreCase))
|
||||
authType = "jwt";
|
||||
else if (key.Contains("oauth", StringComparison.OrdinalIgnoreCase))
|
||||
authType = "oauth2";
|
||||
else if (key.Contains("basic", StringComparison.OrdinalIgnoreCase))
|
||||
authType = "basic";
|
||||
else if (key.Contains("api-key", StringComparison.OrdinalIgnoreCase))
|
||||
authType = "api_key";
|
||||
else
|
||||
authType = "custom";
|
||||
|
||||
required = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, roles, provider);
|
||||
}
|
||||
|
||||
private List<BoundaryControl> DetectControls(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string gatewayType)
|
||||
{
|
||||
var controls = new List<BoundaryControl>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Rate limiting
|
||||
var hasRateLimit = annotations.Keys.Any(k =>
|
||||
k.Contains("rate-limit", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("ratelimit", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("throttle", StringComparison.OrdinalIgnoreCase) ||
|
||||
// Kong
|
||||
k.Contains("kong.plugin.rate-limiting", StringComparison.OrdinalIgnoreCase) ||
|
||||
// Istio
|
||||
k.Contains("ratelimit.config", StringComparison.OrdinalIgnoreCase) ||
|
||||
// AWS
|
||||
k.Contains("apigateway.throttle", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasRateLimit)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "rate_limit",
|
||||
Active = true,
|
||||
Config = gatewayType,
|
||||
Effectiveness = "medium",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// IP restrictions
|
||||
var hasIpRestriction = annotations.Keys.Any(k =>
|
||||
k.Contains("ip-restriction", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("whitelist", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("allowlist", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("blacklist", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("denylist", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasIpRestriction)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "ip_allowlist",
|
||||
Active = true,
|
||||
Config = gatewayType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// CORS
|
||||
var hasCors = annotations.Keys.Any(k =>
|
||||
k.Contains("cors", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasCors)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "cors",
|
||||
Active = true,
|
||||
Config = gatewayType,
|
||||
Effectiveness = "low",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Request size limit
|
||||
var hasSizeLimit = annotations.Keys.Any(k =>
|
||||
k.Contains("request-size", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("body-limit", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("max-body", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasSizeLimit)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "request_size_limit",
|
||||
Active = true,
|
||||
Config = gatewayType,
|
||||
Effectiveness = "low",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// WAF / Bot protection
|
||||
var hasWaf = annotations.Keys.Any(k =>
|
||||
k.Contains("waf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("bot", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("modsecurity", StringComparison.OrdinalIgnoreCase) ||
|
||||
// Kong
|
||||
k.Contains("kong.plugin.bot-detection", StringComparison.OrdinalIgnoreCase) ||
|
||||
// AWS
|
||||
k.Contains("apigateway.waf", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasWaf)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "waf",
|
||||
Active = true,
|
||||
Config = gatewayType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Request transformation / validation
|
||||
var hasValidation = annotations.Keys.Any(k =>
|
||||
k.Contains("request-validation", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("request-transformer", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("validate", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasValidation)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "input_validation",
|
||||
Active = true,
|
||||
Config = gatewayType,
|
||||
Effectiveness = "medium",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string gatewayType)
|
||||
{
|
||||
// Base confidence from gateway source
|
||||
var confidence = 0.75;
|
||||
|
||||
// Higher confidence if we have specific gateway annotations
|
||||
if (gatewayType != "generic")
|
||||
{
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
// Higher confidence if we have auth information
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("auth", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("jwt", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("oauth", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
confidence += 0.05;
|
||||
}
|
||||
|
||||
// Higher confidence if we have routing information
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("route", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("path", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("host", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
confidence += 0.05;
|
||||
}
|
||||
|
||||
// Cap at 0.95
|
||||
return Math.Min(confidence, 0.95);
|
||||
}
|
||||
|
||||
private static string? TryGetAnnotation(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (annotations.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Also try case-insensitive match
|
||||
var match = annotations.FirstOrDefault(kv =>
|
||||
kv.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(match.Value))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildEvidenceRef(
|
||||
BoundaryExtractionContext context,
|
||||
string rootId,
|
||||
string gatewayType)
|
||||
{
|
||||
var parts = new List<string> { "gateway", gatewayType };
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Namespace))
|
||||
{
|
||||
parts.Add(context.Namespace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.EnvironmentId))
|
||||
{
|
||||
parts.Add(context.EnvironmentId);
|
||||
}
|
||||
|
||||
parts.Add(rootId);
|
||||
|
||||
return string.Join("/", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IacBoundaryExtractor.cs
|
||||
// Sprint: SPRINT_3800_0002_0004_boundary_iac
|
||||
// Description: Extracts boundary proof from Infrastructure-as-Code metadata.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Boundary;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts boundary proof from Infrastructure-as-Code configurations.
|
||||
/// Parses Terraform, CloudFormation, Pulumi, and Helm chart configurations.
|
||||
/// </summary>
|
||||
public sealed class IacBoundaryExtractor : IBoundaryProofExtractor
|
||||
{
|
||||
private readonly ILogger<IacBoundaryExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// IaC source identifiers
|
||||
private static readonly string[] IacSources =
|
||||
[
|
||||
"terraform",
|
||||
"cloudformation",
|
||||
"cfn",
|
||||
"pulumi",
|
||||
"helm",
|
||||
"iac",
|
||||
"infrastructure"
|
||||
];
|
||||
|
||||
// IaC annotation prefixes
|
||||
private static readonly string[] IacAnnotationPrefixes =
|
||||
[
|
||||
"terraform.",
|
||||
"cloudformation.",
|
||||
"cfn.",
|
||||
"pulumi.",
|
||||
"helm.",
|
||||
"aws::",
|
||||
"azure.",
|
||||
"gcp."
|
||||
];
|
||||
|
||||
public IacBoundaryExtractor(
|
||||
ILogger<IacBoundaryExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Priority => 150; // Between base (100) and K8s (200) - IaC is declarative intent
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(BoundaryExtractionContext context)
|
||||
{
|
||||
// Handle when source is a known IaC tool
|
||||
if (IacSources.Any(s =>
|
||||
string.Equals(context.Source, s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also handle if annotations contain IaC-specific keys
|
||||
return context.Annotations.Keys.Any(k =>
|
||||
IacAnnotationPrefixes.Any(prefix =>
|
||||
k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BoundaryProof?> ExtractAsync(
|
||||
RichGraphRoot root,
|
||||
RichGraphNode? rootNode,
|
||||
BoundaryExtractionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Extract(root, rootNode, context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BoundaryProof? Extract(
|
||||
RichGraphRoot root,
|
||||
RichGraphNode? rootNode,
|
||||
BoundaryExtractionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
if (!CanHandle(context))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var annotations = context.Annotations;
|
||||
var iacType = DetectIacType(context);
|
||||
var exposure = DetermineExposure(context, annotations, iacType);
|
||||
var surface = DetermineSurface(context, annotations, iacType);
|
||||
var auth = DetectAuth(annotations, iacType);
|
||||
var controls = DetectControls(annotations, iacType);
|
||||
var confidence = CalculateConfidence(annotations, iacType);
|
||||
|
||||
_logger.LogDebug(
|
||||
"IaC boundary extraction: iac={IacType}, exposure={ExposureLevel}, confidence={Confidence:F2}",
|
||||
iacType,
|
||||
exposure.Level,
|
||||
confidence);
|
||||
|
||||
return new BoundaryProof
|
||||
{
|
||||
Kind = "network",
|
||||
Surface = surface,
|
||||
Exposure = exposure,
|
||||
Auth = auth,
|
||||
Controls = controls.Count > 0 ? controls : null,
|
||||
LastSeen = _timeProvider.GetUtcNow(),
|
||||
Confidence = confidence,
|
||||
Source = $"iac:{iacType}",
|
||||
EvidenceRef = BuildEvidenceRef(context, root.Id, iacType)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "IaC boundary extraction failed for root {RootId}", root.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string DetectIacType(BoundaryExtractionContext context)
|
||||
{
|
||||
var source = context.Source?.ToLowerInvariant() ?? string.Empty;
|
||||
var annotations = context.Annotations;
|
||||
|
||||
// Check source first
|
||||
if (source.Contains("terraform"))
|
||||
return "terraform";
|
||||
if (source.Contains("cloudformation") || source.Contains("cfn"))
|
||||
return "cloudformation";
|
||||
if (source.Contains("pulumi"))
|
||||
return "pulumi";
|
||||
if (source.Contains("helm"))
|
||||
return "helm";
|
||||
|
||||
// Check annotations
|
||||
if (annotations.Keys.Any(k => k.StartsWith("terraform.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "terraform";
|
||||
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.StartsWith("cloudformation.", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith("cfn.", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("AWS::", StringComparison.Ordinal)))
|
||||
return "cloudformation";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("pulumi.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "pulumi";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("helm.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "helm";
|
||||
|
||||
// Check for cloud provider patterns
|
||||
if (annotations.Keys.Any(k => k.StartsWith("aws:", StringComparison.OrdinalIgnoreCase)))
|
||||
return "terraform"; // Assume Terraform for AWS resources
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("azure.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "terraform";
|
||||
|
||||
if (annotations.Keys.Any(k => k.StartsWith("gcp.", StringComparison.OrdinalIgnoreCase)))
|
||||
return "terraform";
|
||||
|
||||
return "generic";
|
||||
}
|
||||
|
||||
private BoundaryExposure DetermineExposure(
|
||||
BoundaryExtractionContext context,
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string iacType)
|
||||
{
|
||||
var level = "private";
|
||||
var internetFacing = false;
|
||||
var behindProxy = false;
|
||||
List<string>? clientTypes = ["service"];
|
||||
|
||||
// Check for public internet exposure indicators
|
||||
var hasPublicExposure = false;
|
||||
|
||||
switch (iacType)
|
||||
{
|
||||
case "terraform":
|
||||
hasPublicExposure = DetectTerraformPublicExposure(annotations);
|
||||
break;
|
||||
case "cloudformation":
|
||||
hasPublicExposure = DetectCloudFormationPublicExposure(annotations);
|
||||
break;
|
||||
case "pulumi":
|
||||
hasPublicExposure = DetectPulumiPublicExposure(annotations);
|
||||
break;
|
||||
case "helm":
|
||||
hasPublicExposure = DetectHelmPublicExposure(annotations);
|
||||
break;
|
||||
default:
|
||||
hasPublicExposure = DetectGenericPublicExposure(annotations);
|
||||
break;
|
||||
}
|
||||
|
||||
if (hasPublicExposure || context.IsInternetFacing == true)
|
||||
{
|
||||
level = "public";
|
||||
internetFacing = true;
|
||||
clientTypes = ["browser", "api_client"];
|
||||
}
|
||||
else if (annotations.Keys.Any(k =>
|
||||
k.Contains("internal", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("private", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
level = "internal";
|
||||
clientTypes = ["service"];
|
||||
}
|
||||
|
||||
// Check for load balancer (implies behind proxy)
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("load_balancer", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("loadbalancer", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("alb", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("elb", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
behindProxy = true;
|
||||
}
|
||||
|
||||
return new BoundaryExposure
|
||||
{
|
||||
Level = level,
|
||||
InternetFacing = internetFacing,
|
||||
Zone = context.NetworkZone,
|
||||
BehindProxy = behindProxy,
|
||||
ClientTypes = clientTypes
|
||||
};
|
||||
}
|
||||
|
||||
private static bool DetectTerraformPublicExposure(IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
// Check for internet-facing resources
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Security group with 0.0.0.0/0
|
||||
if (key.Contains("security_group", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("ingress", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Contains("0.0.0.0/0") || value.Contains("::/0"))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Internet-facing ALB
|
||||
if (key.Contains("aws_lb", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("internal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public subnet
|
||||
if (key.Contains("map_public_ip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public IP association
|
||||
if (key.Contains("public_ip", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("eip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool DetectCloudFormationPublicExposure(IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Security group with public CIDR
|
||||
if (key.Contains("SecurityGroup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Contains("0.0.0.0/0") || value.Contains("::/0"))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Internet-facing ELB/ALB
|
||||
if ((key.Contains("LoadBalancer", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("ELB", StringComparison.OrdinalIgnoreCase)) &&
|
||||
key.Contains("Scheme", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("internet-facing", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// API Gateway
|
||||
if (key.Contains("ApiGateway", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// CloudFront distribution
|
||||
if (key.Contains("CloudFront", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool DetectPulumiPublicExposure(IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Public security group rule
|
||||
if (key.Contains("SecurityGroup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Contains("0.0.0.0/0") || value.Contains("::/0"))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Internet-facing load balancer
|
||||
if (key.Contains("LoadBalancer", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("internal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public tags
|
||||
if (key.Contains("tags", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("public", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool DetectHelmPublicExposure(IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Ingress enabled
|
||||
if (key.Contains("ingress.enabled", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// LoadBalancer service
|
||||
if (key.Contains("service.type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("LoadBalancer", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// NodePort service
|
||||
if (key.Contains("service.type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("NodePort", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool DetectGenericPublicExposure(IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Generic public indicators
|
||||
if (key.Contains("public", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("internet", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("external", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// CIDR 0.0.0.0/0
|
||||
if (value.Contains("0.0.0.0/0"))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static BoundarySurface DetermineSurface(
|
||||
BoundaryExtractionContext context,
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string iacType)
|
||||
{
|
||||
string? path = null;
|
||||
string protocol = "https";
|
||||
int? port = null;
|
||||
string? host = null;
|
||||
|
||||
// IaC-specific path/host extraction
|
||||
path = iacType switch
|
||||
{
|
||||
"terraform" => TryGetAnnotation(annotations, "terraform.resource.path", "path"),
|
||||
"cloudformation" => TryGetAnnotation(annotations, "cloudformation.path", "path"),
|
||||
"pulumi" => TryGetAnnotation(annotations, "pulumi.path", "path"),
|
||||
"helm" => TryGetAnnotation(annotations, "helm.values.ingress.path", "ingress.path"),
|
||||
_ => TryGetAnnotation(annotations, "path")
|
||||
};
|
||||
|
||||
// Default path
|
||||
path ??= !string.IsNullOrEmpty(context.Namespace) ? $"/{context.Namespace}" : "/";
|
||||
|
||||
// Host extraction
|
||||
host = iacType switch
|
||||
{
|
||||
"terraform" => TryGetAnnotation(annotations, "terraform.resource.domain", "domain"),
|
||||
"cloudformation" => TryGetAnnotation(annotations, "cloudformation.domain", "domain"),
|
||||
"pulumi" => TryGetAnnotation(annotations, "pulumi.domain", "domain"),
|
||||
"helm" => TryGetAnnotation(annotations, "helm.values.ingress.host", "ingress.host"),
|
||||
_ => TryGetAnnotation(annotations, "domain", "host")
|
||||
};
|
||||
|
||||
// Port extraction
|
||||
var portStr = TryGetAnnotation(annotations, "port", "listener.port", "service.port");
|
||||
if (portStr != null && int.TryParse(portStr, out var parsedPort))
|
||||
{
|
||||
port = parsedPort;
|
||||
}
|
||||
else if (context.PortBindings.Count > 0)
|
||||
{
|
||||
port = context.PortBindings.Keys.FirstOrDefault();
|
||||
}
|
||||
|
||||
// Determine protocol from annotations
|
||||
if (annotations.Keys.Any(k => k.Contains("grpc", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
protocol = "grpc";
|
||||
}
|
||||
else if (annotations.Keys.Any(k =>
|
||||
k.Contains("tcp", StringComparison.OrdinalIgnoreCase) &&
|
||||
!k.Contains("https", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
protocol = "tcp";
|
||||
}
|
||||
|
||||
return new BoundarySurface
|
||||
{
|
||||
Type = "infrastructure",
|
||||
Protocol = protocol,
|
||||
Port = port,
|
||||
Host = host,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private static BoundaryAuth? DetectAuth(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string iacType)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
|
||||
switch (iacType)
|
||||
{
|
||||
case "terraform":
|
||||
case "cloudformation":
|
||||
case "pulumi":
|
||||
(authType, required, provider) = DetectCloudAuth(annotations);
|
||||
break;
|
||||
case "helm":
|
||||
(authType, required, provider) = DetectHelmAuth(annotations);
|
||||
break;
|
||||
default:
|
||||
(authType, required, provider) = DetectGenericAuth(annotations);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!required)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BoundaryAuth
|
||||
{
|
||||
Required = required,
|
||||
Type = authType,
|
||||
Roles = roles,
|
||||
Provider = provider,
|
||||
MfaRequired = null
|
||||
};
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, string? provider) DetectCloudAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// IAM authentication
|
||||
if (key.Contains("iam", StringComparison.OrdinalIgnoreCase) &&
|
||||
(key.Contains("auth", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("policy", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authType = "iam";
|
||||
required = true;
|
||||
provider = "aws-iam";
|
||||
}
|
||||
|
||||
// Cognito authentication
|
||||
if (key.Contains("cognito", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
provider = "cognito";
|
||||
}
|
||||
|
||||
// Azure AD authentication
|
||||
if (key.Contains("azure_ad", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("aad", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
provider = "azure-ad";
|
||||
}
|
||||
|
||||
// GCP IAM
|
||||
if (key.Contains("gcp", StringComparison.OrdinalIgnoreCase) &&
|
||||
key.Contains("iam", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "iam";
|
||||
required = true;
|
||||
provider = "gcp-iam";
|
||||
}
|
||||
|
||||
// mTLS
|
||||
if (key.Contains("mtls", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("client_certificate", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "mtls";
|
||||
required = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, provider);
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, string? provider) DetectHelmAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// OAuth2 proxy
|
||||
if (key.Contains("oauth2-proxy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Basic auth
|
||||
if (key.Contains("auth.enabled", StringComparison.OrdinalIgnoreCase) &&
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType ??= "basic";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// TLS/mTLS
|
||||
if (key.Contains("tls.enabled", StringComparison.OrdinalIgnoreCase) &&
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (key.Contains("mtls", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "mtls";
|
||||
required = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, provider);
|
||||
}
|
||||
|
||||
private static (string? authType, bool required, string? provider) DetectGenericAuth(
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
string? provider = null;
|
||||
|
||||
foreach (var (key, _) in annotations)
|
||||
{
|
||||
if (key.Contains("auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "custom";
|
||||
required = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (authType, required, provider);
|
||||
}
|
||||
|
||||
private List<BoundaryControl> DetectControls(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string iacType)
|
||||
{
|
||||
var controls = new List<BoundaryControl>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Security Groups / Firewall Rules
|
||||
var hasSecurityGroup = annotations.Keys.Any(k =>
|
||||
k.Contains("security_group", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("SecurityGroup", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("firewall", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("nsg", StringComparison.OrdinalIgnoreCase)); // Azure NSG
|
||||
|
||||
if (hasSecurityGroup)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "security_group",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// WAF
|
||||
var hasWaf = annotations.Keys.Any(k =>
|
||||
k.Contains("waf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("WebACL", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("ApplicationGateway", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasWaf)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "waf",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// VPC / Network isolation
|
||||
var hasVpc = annotations.Keys.Any(k =>
|
||||
k.Contains("vpc", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("vnet", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("subnet", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasVpc)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "network_isolation",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "medium",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// NACL / Network ACL
|
||||
var hasNacl = annotations.Keys.Any(k =>
|
||||
k.Contains("nacl", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("network_acl", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("NetworkAcl", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasNacl)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "network_acl",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// DDoS Protection
|
||||
var hasDdos = annotations.Keys.Any(k =>
|
||||
k.Contains("ddos", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("shield", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasDdos)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "ddos_protection",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Encryption in transit
|
||||
var hasEncryption = annotations.Keys.Any(k =>
|
||||
k.Contains("ssl", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("tls", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("https_only", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasEncryption)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "encryption_in_transit",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Private endpoints
|
||||
var hasPrivateEndpoint = annotations.Keys.Any(k =>
|
||||
k.Contains("private_endpoint", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("PrivateLink", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("vpc_endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasPrivateEndpoint)
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "private_endpoint",
|
||||
Active = true,
|
||||
Config = iacType,
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
string iacType)
|
||||
{
|
||||
// Base confidence - IaC is declarative intent, lower than runtime
|
||||
var confidence = 0.6;
|
||||
|
||||
// Higher confidence for known IaC tools
|
||||
if (iacType != "generic")
|
||||
{
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
// Higher confidence if we have security-related resources
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("security", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("firewall", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("waf", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
// Higher confidence if we have network configuration
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("vpc", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("subnet", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("network", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
confidence += 0.05;
|
||||
}
|
||||
|
||||
// Cap at 0.85 - IaC is not runtime state
|
||||
return Math.Min(confidence, 0.85);
|
||||
}
|
||||
|
||||
private static string? TryGetAnnotation(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (annotations.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Also try case-insensitive match
|
||||
var match = annotations.FirstOrDefault(kv =>
|
||||
kv.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(match.Value))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildEvidenceRef(
|
||||
BoundaryExtractionContext context,
|
||||
string rootId,
|
||||
string iacType)
|
||||
{
|
||||
var parts = new List<string> { "iac", iacType };
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Namespace))
|
||||
{
|
||||
parts.Add(context.Namespace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.EnvironmentId))
|
||||
{
|
||||
parts.Add(context.EnvironmentId);
|
||||
}
|
||||
|
||||
parts.Add(rootId);
|
||||
|
||||
return string.Join("/", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// K8sBoundaryExtractor.cs
|
||||
// Sprint: SPRINT_3800_0002_0002_boundary_k8s
|
||||
// Description: Extracts boundary proof from Kubernetes metadata.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.SmartDiff.Detection;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Boundary;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts boundary proof from Kubernetes deployment metadata.
|
||||
/// Parses Ingress, Service, and NetworkPolicy resources to determine exposure.
|
||||
/// </summary>
|
||||
public sealed class K8sBoundaryExtractor : IBoundaryProofExtractor
|
||||
{
|
||||
private readonly ILogger<K8sBoundaryExtractor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Well-known annotations for TLS
|
||||
private static readonly string[] TlsAnnotations =
|
||||
[
|
||||
"nginx.ingress.kubernetes.io/ssl-redirect",
|
||||
"nginx.ingress.kubernetes.io/force-ssl-redirect",
|
||||
"cert-manager.io/cluster-issuer",
|
||||
"cert-manager.io/issuer",
|
||||
"kubernetes.io/tls-acme"
|
||||
];
|
||||
|
||||
public K8sBoundaryExtractor(
|
||||
ILogger<K8sBoundaryExtractor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Priority => 200; // Higher than RichGraphBoundaryExtractor (100)
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(BoundaryExtractionContext context)
|
||||
{
|
||||
// Handle when source is K8s or when we have K8s-specific annotations
|
||||
if (string.Equals(context.Source, "k8s", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(context.Source, "kubernetes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also handle if annotations contain K8s-specific keys
|
||||
return context.Annotations.Keys.Any(k =>
|
||||
k.Contains("kubernetes.io", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("ingress", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("k8s", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BoundaryProof?> ExtractAsync(
|
||||
RichGraphRoot root,
|
||||
RichGraphNode? rootNode,
|
||||
BoundaryExtractionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Extract(root, rootNode, context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BoundaryProof? Extract(
|
||||
RichGraphRoot root,
|
||||
RichGraphNode? rootNode,
|
||||
BoundaryExtractionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
if (!CanHandle(context))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var annotations = context.Annotations;
|
||||
var exposure = DetermineExposure(context);
|
||||
var surface = DetermineSurface(context, annotations, exposure);
|
||||
var auth = DetectAuth(annotations);
|
||||
var controls = DetectControls(annotations, context);
|
||||
var confidence = CalculateConfidence(exposure, annotations);
|
||||
|
||||
_logger.LogDebug(
|
||||
"K8s boundary extraction: exposure={ExposureLevel}, surface={SurfaceType}, confidence={Confidence:F2}",
|
||||
exposure.Level,
|
||||
surface.Type,
|
||||
confidence);
|
||||
|
||||
return new BoundaryProof
|
||||
{
|
||||
Kind = DetermineKind(exposure),
|
||||
Surface = surface,
|
||||
Exposure = exposure,
|
||||
Auth = auth,
|
||||
Controls = controls.Count > 0 ? controls : null,
|
||||
LastSeen = _timeProvider.GetUtcNow(),
|
||||
Confidence = confidence,
|
||||
Source = "k8s",
|
||||
EvidenceRef = BuildEvidenceRef(context, root.Id)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "K8s boundary extraction failed for root {RootId}", root.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private BoundaryExposure DetermineExposure(BoundaryExtractionContext context)
|
||||
{
|
||||
var annotations = context.Annotations;
|
||||
var level = "private";
|
||||
var internetFacing = false;
|
||||
var behindProxy = false;
|
||||
List<string>? clientTypes = null;
|
||||
|
||||
// Check explicit internet-facing flag
|
||||
if (context.IsInternetFacing == true)
|
||||
{
|
||||
level = "public";
|
||||
internetFacing = true;
|
||||
clientTypes = ["browser", "api_client"];
|
||||
}
|
||||
// Ingress class indicates external exposure
|
||||
else if (annotations.ContainsKey("kubernetes.io/ingress.class") ||
|
||||
annotations.Keys.Any(k => k.Contains("ingress", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
level = "public";
|
||||
internetFacing = true;
|
||||
behindProxy = true; // ingress controller acts as proxy
|
||||
clientTypes = ["browser", "api_client"];
|
||||
}
|
||||
// Check for LoadBalancer service type
|
||||
else if (annotations.TryGetValue("service.type", out var serviceType))
|
||||
{
|
||||
(level, internetFacing, clientTypes) = serviceType.ToLowerInvariant() switch
|
||||
{
|
||||
"loadbalancer" => ("public", true, new List<string> { "api_client", "service" }),
|
||||
"nodeport" => ("internal", false, new List<string> { "service" }),
|
||||
"clusterip" => ("private", false, new List<string> { "service" }),
|
||||
_ => ("private", false, new List<string> { "service" })
|
||||
};
|
||||
}
|
||||
// Check port bindings for common external ports
|
||||
else if (context.PortBindings.Count > 0)
|
||||
{
|
||||
var externalPorts = new HashSet<int> { 80, 443, 8080, 8443 };
|
||||
if (context.PortBindings.Keys.Any(p => externalPorts.Contains(p)))
|
||||
{
|
||||
level = "internal";
|
||||
clientTypes = ["service"];
|
||||
}
|
||||
}
|
||||
// Default based on network zone
|
||||
else
|
||||
{
|
||||
level = context.NetworkZone switch
|
||||
{
|
||||
"dmz" => "internal",
|
||||
"trusted" or "internal" => "private",
|
||||
_ => "private"
|
||||
};
|
||||
clientTypes = ["service"];
|
||||
}
|
||||
|
||||
return new BoundaryExposure
|
||||
{
|
||||
Level = level,
|
||||
InternetFacing = internetFacing,
|
||||
Zone = context.NetworkZone,
|
||||
BehindProxy = behindProxy,
|
||||
ClientTypes = clientTypes
|
||||
};
|
||||
}
|
||||
|
||||
private static BoundarySurface DetermineSurface(
|
||||
BoundaryExtractionContext context,
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
BoundaryExposure exposure)
|
||||
{
|
||||
string? path = null;
|
||||
string protocol = "https";
|
||||
int? port = null;
|
||||
string? host = null;
|
||||
|
||||
// Try to extract path from annotations
|
||||
if (annotations.TryGetValue("service.path", out var servicePath))
|
||||
{
|
||||
path = servicePath;
|
||||
}
|
||||
else if (annotations.TryGetValue("nginx.ingress.kubernetes.io/rewrite-target", out var rewrite))
|
||||
{
|
||||
path = rewrite;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(context.Namespace))
|
||||
{
|
||||
path = $"/{context.Namespace}";
|
||||
}
|
||||
|
||||
// Determine protocol
|
||||
var hasTls = TlsAnnotations.Any(ta =>
|
||||
annotations.ContainsKey(ta) ||
|
||||
annotations.Keys.Any(k => k.Contains("tls", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
protocol = hasTls || exposure.InternetFacing ? "https" : "http";
|
||||
|
||||
// Check for grpc
|
||||
if (annotations.Keys.Any(k => k.Contains("grpc", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
protocol = "grpc";
|
||||
}
|
||||
|
||||
// Get port from bindings
|
||||
if (context.PortBindings.Count > 0)
|
||||
{
|
||||
port = context.PortBindings.Keys.FirstOrDefault();
|
||||
}
|
||||
|
||||
// Get host from annotations
|
||||
if (annotations.TryGetValue("ingress.host", out var ingressHost))
|
||||
{
|
||||
host = ingressHost;
|
||||
}
|
||||
|
||||
return new BoundarySurface
|
||||
{
|
||||
Type = exposure.InternetFacing ? "api" : "service",
|
||||
Protocol = protocol,
|
||||
Port = port,
|
||||
Host = host,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
private BoundaryAuth? DetectAuth(IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
string? authType = null;
|
||||
var required = false;
|
||||
List<string>? roles = null;
|
||||
string? provider = null;
|
||||
bool? mfaRequired = null;
|
||||
|
||||
// Check for auth annotations
|
||||
foreach (var (key, value) in annotations)
|
||||
{
|
||||
// Check auth type annotations
|
||||
if (key.Contains("auth-type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = value.ToLowerInvariant();
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Check for basic auth
|
||||
if (key.Contains("auth-secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("basic-auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType ??= "basic";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Check for OAuth/OIDC
|
||||
if (key.Contains("oauth", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("oidc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "oauth2";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Check for client cert auth
|
||||
if (key.Contains("client-certificate", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("auth-tls", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "mtls";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Check for API key auth
|
||||
if (key.Contains("api-key", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authType = "api_key";
|
||||
required = true;
|
||||
}
|
||||
|
||||
// Check for auth provider
|
||||
if (key.Contains("auth-url", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
provider = value;
|
||||
}
|
||||
|
||||
// Check for role requirements
|
||||
if (key.Contains("auth-roles", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
roles = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
}
|
||||
|
||||
// Check for MFA requirement
|
||||
if (key.Contains("mfa", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mfaRequired = bool.TryParse(value, out var mfa) ? mfa : true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!required)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BoundaryAuth
|
||||
{
|
||||
Required = required,
|
||||
Type = authType,
|
||||
Roles = roles,
|
||||
Provider = provider,
|
||||
MfaRequired = mfaRequired
|
||||
};
|
||||
}
|
||||
|
||||
private List<BoundaryControl> DetectControls(
|
||||
IReadOnlyDictionary<string, string> annotations,
|
||||
BoundaryExtractionContext context)
|
||||
{
|
||||
var controls = new List<BoundaryControl>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for NetworkPolicy
|
||||
if (annotations.ContainsKey("network.policy.enabled") ||
|
||||
annotations.Keys.Any(k => k.Contains("networkpolicy", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "network_policy",
|
||||
Active = true,
|
||||
Config = context.Namespace ?? "default",
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("rate-limit", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("ratelimit", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var rateValue = annotations.FirstOrDefault(kv =>
|
||||
kv.Key.Contains("rate", StringComparison.OrdinalIgnoreCase)).Value ?? "default";
|
||||
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "rate_limit",
|
||||
Active = true,
|
||||
Config = rateValue,
|
||||
Effectiveness = "medium",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check for IP whitelist
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("whitelist", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("allowlist", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "ip_allowlist",
|
||||
Active = true,
|
||||
Config = "ingress",
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check for WAF
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("waf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains("modsecurity", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "waf",
|
||||
Active = true,
|
||||
Config = "ingress",
|
||||
Effectiveness = "high",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
// Check for input validation
|
||||
if (annotations.Keys.Any(k =>
|
||||
k.Contains("validation", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
controls.Add(new BoundaryControl
|
||||
{
|
||||
Type = "input_validation",
|
||||
Active = true,
|
||||
Effectiveness = "medium",
|
||||
VerifiedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
private static string DetermineKind(BoundaryExposure exposure)
|
||||
{
|
||||
return exposure.InternetFacing ? "network" : "network";
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(
|
||||
BoundaryExposure exposure,
|
||||
IReadOnlyDictionary<string, string> annotations)
|
||||
{
|
||||
// Base confidence from K8s source
|
||||
var confidence = 0.7;
|
||||
|
||||
// Higher confidence if we have explicit ingress annotations
|
||||
if (annotations.Keys.Any(k => k.Contains("ingress", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
confidence += 0.15;
|
||||
}
|
||||
|
||||
// Higher confidence if we have service type
|
||||
if (annotations.ContainsKey("service.type"))
|
||||
{
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
// Cap at 0.95 - K8s extraction is high confidence but not runtime-verified
|
||||
return Math.Min(confidence, 0.95);
|
||||
}
|
||||
|
||||
private static string BuildEvidenceRef(BoundaryExtractionContext context, string rootId)
|
||||
{
|
||||
var parts = new List<string> { "k8s" };
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Namespace))
|
||||
{
|
||||
parts.Add(context.Namespace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.EnvironmentId))
|
||||
{
|
||||
parts.Add(context.EnvironmentId);
|
||||
}
|
||||
|
||||
parts.Add(rootId);
|
||||
|
||||
return string.Join("/", parts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user