save progress
This commit is contained in:
@@ -8,6 +8,9 @@ Resolve container `ENTRYPOINT`/`CMD` chains into deterministic call graphs that
|
||||
- Walk layered root filesystems to resolve PATH lookups, interpreter hand-offs (Python/Node/Java), and record evidence.
|
||||
- Surface explainable diagnostics for unresolved branches (env indirection, missing files, unsupported syntax) and emit metrics.
|
||||
- Package analyzers as signed plug-ins under `plugins/scanner/entrytrace/`, guarded by restart-only policy.
|
||||
- **Semantic analysis**: Classify entrypoints by application intent (ApiEndpoint, Worker, CronJob, etc.), capability class (NetworkListener, FileSystemAccess, etc.), and threat vectors.
|
||||
- **Temporal tracking**: Track entrypoint evolution across image versions, detecting drift categories (intent changes, capability expansion, attack surface growth).
|
||||
- **Mesh analysis**: Parse multi-container orchestration manifests (K8s, Docker Compose) to build cross-container reachability graphs and identify vulnerable paths.
|
||||
|
||||
## Out of Scope
|
||||
- SBOM emission/diffing (owned by `Scanner.Emit`/`Scanner.Diff`).
|
||||
@@ -15,11 +18,43 @@ Resolve container `ENTRYPOINT`/`CMD` chains into deterministic call graphs that
|
||||
- Registry/network fetchers beyond file lookups inside extracted layers.
|
||||
|
||||
## Interfaces & Contracts
|
||||
|
||||
### Core EntryTrace
|
||||
- Primary entry point: `IEntryTraceAnalyzer.ResolveAsync` returning a deterministic `EntryTraceGraph`.
|
||||
- Graph nodes must include file path, line span, interpreter classification, evidence source, and follow `Scanner.Core` timestamp/ID helpers when emitting events.
|
||||
- Diagnostics must enumerate unknown reasons from fixed enum; metrics tagged `entrytrace.*`.
|
||||
- Plug-ins register via `IEntryTraceAnalyzerFactory` and must validate against `IPluginCatalogGuard`.
|
||||
|
||||
### Semantic Entrypoints (Sprint 0411)
|
||||
Located in `Semantic/`:
|
||||
- `SemanticEntrypoint`: Classifies entrypoints with intent, capabilities, threat vectors, and confidence scores.
|
||||
- `ApplicationIntent`: Enum for high-level purpose (ApiEndpoint, Worker, CronJob, CliTool, etc.).
|
||||
- `CapabilityClass`: Enum for functional capabilities (NetworkListener, FileSystemAccess, ProcessSpawner, etc.).
|
||||
- `ThreatVector`: Enum for security-relevant classifications (NetworkExposure, FilePathTraversal, CommandInjection, etc.).
|
||||
- `DataFlowBoundary`: Record for trust boundaries in data flow.
|
||||
- `SemanticConfidence`: Confidence scores for classification results.
|
||||
|
||||
### Temporal Entrypoints (Sprint 0412)
|
||||
Located in `Temporal/`:
|
||||
- `TemporalEntrypointGraph`: Tracks entrypoints across image versions with snapshots and deltas.
|
||||
- `EntrypointSnapshot`: Point-in-time entrypoint state with content hash for comparison.
|
||||
- `EntrypointDelta`: Version-to-version changes (added/removed/modified entrypoints).
|
||||
- `EntrypointDrift`: Flags enum for drift categories (IntentChanged, CapabilitiesExpanded, AttackSurfaceGrew, PrivilegeEscalation, PortsAdded, etc.).
|
||||
- `ITemporalEntrypointStore`: Interface for storing and querying temporal graphs.
|
||||
- `InMemoryTemporalEntrypointStore`: Reference implementation with delta computation.
|
||||
|
||||
### Mesh Entrypoints (Sprint 0412)
|
||||
Located in `Mesh/`:
|
||||
- `MeshEntrypointGraph`: Multi-container service mesh with services, edges, and ingress paths.
|
||||
- `ServiceNode`: Container in the mesh with entrypoints, exposed ports, and labels.
|
||||
- `CrossContainerEdge`: Inter-service communication link.
|
||||
- `CrossContainerPath`: Reachability path across services with vulnerability tracking.
|
||||
- `IngressPath`: External exposure via ingress/load balancer.
|
||||
- `IManifestParser`: Interface for parsing orchestration manifests.
|
||||
- `KubernetesManifestParser`: Parser for K8s Deployment, Service, Ingress, StatefulSet, DaemonSet, Pod.
|
||||
- `DockerComposeParser`: Parser for Docker Compose v2/v3 files.
|
||||
- `MeshEntrypointAnalyzer`: Orchestrator for mesh analysis with security metrics and blast radius analysis.
|
||||
|
||||
## Observability & Security
|
||||
- No dynamic assembly loading beyond restart-time plug-in catalog.
|
||||
- Structured logs include `scanId`, `imageDigest`, `layerDigest`, `command`, `reason`.
|
||||
@@ -30,10 +65,14 @@ Resolve container `ENTRYPOINT`/`CMD` chains into deterministic call graphs that
|
||||
- Unit tests live in `../StellaOps.Scanner.EntryTrace.Tests` with golden fixtures under `Fixtures/`.
|
||||
- Determinism harness: same inputs produce byte-identical serialized graphs.
|
||||
- Parser fuzz seeds captured for regression; interpreter tracers validated with sample scripts for Python, Node, Java launchers.
|
||||
- **Temporal tests**: `Temporal/TemporalEntrypointGraphTests.cs`, `Temporal/InMemoryTemporalEntrypointStoreTests.cs`.
|
||||
- **Mesh tests**: `Mesh/MeshEntrypointGraphTests.cs`, `Mesh/KubernetesManifestParserTests.cs`, `Mesh/DockerComposeParserTests.cs`, `Mesh/MeshEntrypointAnalyzerTests.cs`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/operations/entrypoint-problem.md`
|
||||
- `docs/reachability/function-level-evidence.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for Docker Compose files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-007).
|
||||
/// Supports docker-compose.yaml v2.x and v3.x formats.
|
||||
/// </remarks>
|
||||
public sealed partial class DockerComposeParser : IManifestParser
|
||||
{
|
||||
public MeshType MeshType => MeshType.DockerCompose;
|
||||
|
||||
public bool CanParse(string manifestPath, string? content = null)
|
||||
{
|
||||
var fileName = Path.GetFileName(manifestPath).ToLowerInvariant();
|
||||
|
||||
// Check for docker-compose naming patterns
|
||||
if (fileName is "docker-compose.yaml" or "docker-compose.yml" or
|
||||
"compose.yaml" or "compose.yml")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for docker-compose.*.yaml pattern
|
||||
if (fileName.StartsWith("docker-compose.", StringComparison.OrdinalIgnoreCase) &&
|
||||
(fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// If content is provided, check for Compose markers
|
||||
if (content is not null)
|
||||
{
|
||||
return content.Contains("services:") &&
|
||||
!content.Contains("apiVersion:"); // Exclude K8s
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<MeshEntrypointGraph> ParseAsync(
|
||||
string manifestPath,
|
||||
string content,
|
||||
ManifestParseOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ParseMultipleAsync(
|
||||
new Dictionary<string, string> { [manifestPath] = content },
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MeshEntrypointGraph> ParseMultipleAsync(
|
||||
IReadOnlyDictionary<string, string> manifests,
|
||||
ManifestParseOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= ManifestParseOptions.Default;
|
||||
|
||||
var services = new List<ServiceNode>();
|
||||
var edges = new List<CrossContainerEdge>();
|
||||
var networks = new Dictionary<string, ComposeNetwork>();
|
||||
var volumes = new Dictionary<string, ComposeVolume>();
|
||||
var serviceInfos = new Dictionary<string, ComposeServiceInfo>();
|
||||
|
||||
foreach (var (path, content) in manifests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ParseComposeFile(content, options, services, serviceInfos, networks, volumes);
|
||||
}
|
||||
|
||||
// Build edges from depends_on and links
|
||||
BuildExplicitEdges(serviceInfos, edges);
|
||||
|
||||
// Infer edges from environment variables if enabled
|
||||
if (options.InferEdgesFromEnv)
|
||||
{
|
||||
InferEdgesFromEnvironment(serviceInfos, edges);
|
||||
}
|
||||
|
||||
// Build ingress paths from port mappings
|
||||
var ingressPaths = BuildIngressPaths(serviceInfos);
|
||||
|
||||
var meshId = options.MeshId ?? options.Namespace ?? "compose";
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = meshId,
|
||||
Type = MeshType.DockerCompose,
|
||||
Namespace = options.Namespace,
|
||||
Services = services.ToImmutableArray(),
|
||||
Edges = edges.ToImmutableArray(),
|
||||
IngressPaths = ingressPaths,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
private void ParseComposeFile(
|
||||
string content,
|
||||
ManifestParseOptions options,
|
||||
List<ServiceNode> services,
|
||||
Dictionary<string, ComposeServiceInfo> serviceInfos,
|
||||
Dictionary<string, ComposeNetwork> networks,
|
||||
Dictionary<string, ComposeVolume> volumes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var yaml = new YamlStream();
|
||||
using var reader = new StringReader(content);
|
||||
yaml.Load(reader);
|
||||
|
||||
if (yaml.Documents.Count == 0)
|
||||
return;
|
||||
|
||||
var root = yaml.Documents[0].RootNode as YamlMappingNode;
|
||||
if (root is null)
|
||||
return;
|
||||
|
||||
// Parse version (optional in v3+)
|
||||
var version = GetScalarValue(root, "version");
|
||||
|
||||
// Parse networks
|
||||
var networksNode = GetMappingNode(root, "networks");
|
||||
if (networksNode is not null)
|
||||
{
|
||||
ParseNetworks(networksNode, networks);
|
||||
}
|
||||
|
||||
// Parse volumes
|
||||
var volumesNode = GetMappingNode(root, "volumes");
|
||||
if (volumesNode is not null)
|
||||
{
|
||||
ParseVolumes(volumesNode, volumes);
|
||||
}
|
||||
|
||||
// Parse services
|
||||
var servicesNode = GetMappingNode(root, "services");
|
||||
if (servicesNode is null)
|
||||
return;
|
||||
|
||||
foreach (var (keyNode, valueNode) in servicesNode.Children)
|
||||
{
|
||||
if (keyNode is not YamlScalarNode keyScalar ||
|
||||
valueNode is not YamlMappingNode serviceNode)
|
||||
continue;
|
||||
|
||||
var serviceName = keyScalar.Value ?? "unknown";
|
||||
ParseService(serviceName, serviceNode, options, services, serviceInfos);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Skip malformed files
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseService(
|
||||
string serviceName,
|
||||
YamlMappingNode serviceNode,
|
||||
ManifestParseOptions options,
|
||||
List<ServiceNode> services,
|
||||
Dictionary<string, ComposeServiceInfo> serviceInfos)
|
||||
{
|
||||
var image = GetScalarValue(serviceNode, "image");
|
||||
var build = GetMappingNode(serviceNode, "build") ??
|
||||
(serviceNode.Children.TryGetValue(new YamlScalarNode("build"), out var buildVal) &&
|
||||
buildVal is YamlScalarNode buildScalar ? null : null);
|
||||
|
||||
// Handle build context as string
|
||||
string? buildContext = null;
|
||||
if (serviceNode.Children.TryGetValue(new YamlScalarNode("build"), out var buildNode))
|
||||
{
|
||||
if (buildNode is YamlScalarNode buildScalarNode)
|
||||
{
|
||||
buildContext = buildScalarNode.Value;
|
||||
}
|
||||
else if (buildNode is YamlMappingNode buildMappingNode)
|
||||
{
|
||||
buildContext = GetScalarValue(buildMappingNode, "context");
|
||||
}
|
||||
}
|
||||
|
||||
var containerName = GetScalarValue(serviceNode, "container_name") ?? serviceName;
|
||||
var ports = ParsePorts(serviceNode);
|
||||
var expose = ParseExpose(serviceNode);
|
||||
var environment = ParseEnvironment(serviceNode);
|
||||
var dependsOn = ParseDependsOn(serviceNode);
|
||||
var links = ParseLinks(serviceNode);
|
||||
var labels = ParseLabels(serviceNode);
|
||||
var networksList = ParseNetworksList(serviceNode);
|
||||
var volumes = ParseVolumesList(serviceNode);
|
||||
var replicas = ParseReplicas(serviceNode);
|
||||
|
||||
var allExposedPorts = expose.Concat(ports.Select(p => p.ContainerPort)).Distinct().ToImmutableArray();
|
||||
|
||||
var node = new ServiceNode
|
||||
{
|
||||
ServiceId = serviceName,
|
||||
ContainerName = containerName,
|
||||
ImageDigest = image is not null ? ExtractDigestFromImage(image) : $"build:{buildContext ?? "."}",
|
||||
ImageReference = image,
|
||||
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
|
||||
ExposedPorts = allExposedPorts,
|
||||
PortMappings = ports.ToImmutableDictionary(p => p.HostPort, p => p.ContainerPort),
|
||||
InternalDns = [serviceName], // Docker Compose uses service name as DNS
|
||||
Labels = labels.ToImmutableDictionary(),
|
||||
Replicas = replicas
|
||||
};
|
||||
|
||||
services.Add(node);
|
||||
|
||||
serviceInfos[serviceName] = new ComposeServiceInfo
|
||||
{
|
||||
Name = serviceName,
|
||||
Node = node,
|
||||
Environment = environment,
|
||||
DependsOn = dependsOn,
|
||||
Links = links,
|
||||
Networks = networksList,
|
||||
Ports = ports
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildExplicitEdges(
|
||||
Dictionary<string, ComposeServiceInfo> serviceInfos,
|
||||
List<CrossContainerEdge> edges)
|
||||
{
|
||||
foreach (var (serviceName, info) in serviceInfos)
|
||||
{
|
||||
// Create edges from depends_on
|
||||
foreach (var dep in info.DependsOn)
|
||||
{
|
||||
if (serviceInfos.TryGetValue(dep, out var depInfo))
|
||||
{
|
||||
// Find the best port to use
|
||||
var targetPort = depInfo.Node.ExposedPorts.Length > 0
|
||||
? depInfo.Node.ExposedPorts[0]
|
||||
: 0;
|
||||
|
||||
edges.Add(new CrossContainerEdge
|
||||
{
|
||||
FromServiceId = serviceName,
|
||||
ToServiceId = dep,
|
||||
Port = targetPort,
|
||||
Protocol = "tcp"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create edges from links
|
||||
foreach (var link in info.Links)
|
||||
{
|
||||
var linkTarget = link.Contains(':') ? link.Split(':')[0] : link;
|
||||
if (serviceInfos.TryGetValue(linkTarget, out var linkInfo))
|
||||
{
|
||||
var targetPort = linkInfo.Node.ExposedPorts.Length > 0
|
||||
? linkInfo.Node.ExposedPorts[0]
|
||||
: 0;
|
||||
|
||||
// Check if edge already exists
|
||||
if (!edges.Any(e => e.FromServiceId == serviceName && e.ToServiceId == linkTarget))
|
||||
{
|
||||
edges.Add(new CrossContainerEdge
|
||||
{
|
||||
FromServiceId = serviceName,
|
||||
ToServiceId = linkTarget,
|
||||
Port = targetPort,
|
||||
Protocol = "tcp"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InferEdgesFromEnvironment(
|
||||
Dictionary<string, ComposeServiceInfo> serviceInfos,
|
||||
List<CrossContainerEdge> edges)
|
||||
{
|
||||
var serviceNames = serviceInfos.Keys.ToHashSet();
|
||||
|
||||
foreach (var (serviceName, info) in serviceInfos)
|
||||
{
|
||||
foreach (var (envName, envValue) in info.Environment)
|
||||
{
|
||||
// Look for references to other services in environment values
|
||||
foreach (var otherService in serviceNames)
|
||||
{
|
||||
if (otherService == serviceName)
|
||||
continue;
|
||||
|
||||
// Check if env value contains the service name
|
||||
// Common patterns: SERVICE_HOST, SERVICE_URL, etc.
|
||||
if (envValue.Contains(otherService, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!edges.Any(e => e.FromServiceId == serviceName && e.ToServiceId == otherService))
|
||||
{
|
||||
var targetPort = ExtractPortFromEnvValue(envValue) ??
|
||||
(serviceInfos.TryGetValue(otherService, out var target) &&
|
||||
target.Node.ExposedPorts.Length > 0
|
||||
? target.Node.ExposedPorts[0]
|
||||
: 0);
|
||||
|
||||
edges.Add(new CrossContainerEdge
|
||||
{
|
||||
FromServiceId = serviceName,
|
||||
ToServiceId = otherService,
|
||||
Port = targetPort,
|
||||
Protocol = "tcp",
|
||||
Source = EdgeSource.EnvironmentInferred
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ExtractPortFromEnvValue(string value)
|
||||
{
|
||||
// Match :PORT patterns
|
||||
var match = PortPatternRegex().Match(value);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var port))
|
||||
{
|
||||
return port;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@":(\d{2,5})(?:[/\s]|$)")]
|
||||
private static partial Regex PortPatternRegex();
|
||||
|
||||
private ImmutableArray<IngressPath> BuildIngressPaths(
|
||||
Dictionary<string, ComposeServiceInfo> serviceInfos)
|
||||
{
|
||||
var paths = new List<IngressPath>();
|
||||
|
||||
foreach (var (serviceName, info) in serviceInfos)
|
||||
{
|
||||
foreach (var (hostPort, containerPort) in info.Ports)
|
||||
{
|
||||
paths.Add(new IngressPath
|
||||
{
|
||||
IngressName = $"compose-port-{hostPort}",
|
||||
Host = "localhost",
|
||||
Path = "/",
|
||||
TargetServiceId = serviceName,
|
||||
TargetPort = containerPort,
|
||||
TlsEnabled = false // Compose doesn't define TLS at service level
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths.ToImmutableArray();
|
||||
}
|
||||
|
||||
#region Parsing Helpers
|
||||
|
||||
private static void ParseNetworks(YamlMappingNode networksNode, Dictionary<string, ComposeNetwork> networks)
|
||||
{
|
||||
foreach (var (keyNode, valueNode) in networksNode.Children)
|
||||
{
|
||||
if (keyNode is not YamlScalarNode keyScalar)
|
||||
continue;
|
||||
|
||||
var networkName = keyScalar.Value ?? "default";
|
||||
var driver = "bridge";
|
||||
var external = false;
|
||||
|
||||
if (valueNode is YamlMappingNode networkConfig)
|
||||
{
|
||||
driver = GetScalarValue(networkConfig, "driver") ?? "bridge";
|
||||
external = GetScalarValue(networkConfig, "external") == "true";
|
||||
}
|
||||
|
||||
networks[networkName] = new ComposeNetwork
|
||||
{
|
||||
Name = networkName,
|
||||
Driver = driver,
|
||||
External = external
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseVolumes(YamlMappingNode volumesNode, Dictionary<string, ComposeVolume> volumes)
|
||||
{
|
||||
foreach (var (keyNode, valueNode) in volumesNode.Children)
|
||||
{
|
||||
if (keyNode is not YamlScalarNode keyScalar)
|
||||
continue;
|
||||
|
||||
var volumeName = keyScalar.Value ?? "default";
|
||||
var driver = "local";
|
||||
var external = false;
|
||||
|
||||
if (valueNode is YamlMappingNode volumeConfig)
|
||||
{
|
||||
driver = GetScalarValue(volumeConfig, "driver") ?? "local";
|
||||
external = GetScalarValue(volumeConfig, "external") == "true";
|
||||
}
|
||||
|
||||
volumes[volumeName] = new ComposeVolume
|
||||
{
|
||||
Name = volumeName,
|
||||
Driver = driver,
|
||||
External = external
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static List<(int HostPort, int ContainerPort)> ParsePorts(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new List<(int, int)>();
|
||||
var portsNode = GetSequenceNode(serviceNode, "ports");
|
||||
if (portsNode is null)
|
||||
return result;
|
||||
|
||||
foreach (var portNode in portsNode.Children)
|
||||
{
|
||||
if (portNode is YamlScalarNode scalarPort)
|
||||
{
|
||||
var portStr = scalarPort.Value ?? "";
|
||||
var parsed = ParsePortString(portStr);
|
||||
if (parsed.HasValue)
|
||||
result.Add(parsed.Value);
|
||||
}
|
||||
else if (portNode is YamlMappingNode mappingPort)
|
||||
{
|
||||
// Long syntax
|
||||
var targetStr = GetScalarValue(mappingPort, "target");
|
||||
var publishedStr = GetScalarValue(mappingPort, "published");
|
||||
|
||||
if (int.TryParse(targetStr, out var target))
|
||||
{
|
||||
var published = int.TryParse(publishedStr, out var p) ? p : target;
|
||||
result.Add((published, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (int HostPort, int ContainerPort)? ParsePortString(string portStr)
|
||||
{
|
||||
// Remove protocol suffix
|
||||
var colonIndex = portStr.LastIndexOf(':');
|
||||
if (colonIndex == -1)
|
||||
{
|
||||
// Just a port number
|
||||
if (int.TryParse(portStr.Split('/')[0], out var p))
|
||||
return (p, p);
|
||||
return null;
|
||||
}
|
||||
|
||||
// HOST:CONTAINER or HOST:CONTAINER/PROTOCOL
|
||||
var hostPart = portStr[..colonIndex];
|
||||
var containerPart = portStr[(colonIndex + 1)..].Split('/')[0];
|
||||
|
||||
// Handle IP:HOST:CONTAINER format
|
||||
var lastColonInHost = hostPart.LastIndexOf(':');
|
||||
if (lastColonInHost >= 0)
|
||||
{
|
||||
hostPart = hostPart[(lastColonInHost + 1)..];
|
||||
}
|
||||
|
||||
if (int.TryParse(hostPart, out var host) && int.TryParse(containerPart, out var container))
|
||||
{
|
||||
return (host, container);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableArray<int> ParseExpose(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new List<int>();
|
||||
var exposeNode = GetSequenceNode(serviceNode, "expose");
|
||||
if (exposeNode is null)
|
||||
return result.ToImmutableArray();
|
||||
|
||||
foreach (var node in exposeNode.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar)
|
||||
{
|
||||
var portStr = scalar.Value?.Split('/')[0];
|
||||
if (int.TryParse(portStr, out var port))
|
||||
result.Add(port);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseEnvironment(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
|
||||
// Try mapping syntax first
|
||||
var envNode = GetMappingNode(serviceNode, "environment");
|
||||
if (envNode is not null)
|
||||
{
|
||||
foreach (var (key, value) in envNode.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar && value is YamlScalarNode valueScalar)
|
||||
{
|
||||
result[keyScalar.Value ?? ""] = valueScalar.Value ?? "";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try list syntax
|
||||
var envList = GetSequenceNode(serviceNode, "environment");
|
||||
if (envList is not null)
|
||||
{
|
||||
foreach (var node in envList.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar && scalar.Value is not null)
|
||||
{
|
||||
var eqIndex = scalar.Value.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = scalar.Value[..eqIndex];
|
||||
var value = scalar.Value[(eqIndex + 1)..];
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ParseDependsOn(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
// Try list syntax
|
||||
var depsList = GetSequenceNode(serviceNode, "depends_on");
|
||||
if (depsList is not null)
|
||||
{
|
||||
foreach (var node in depsList.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar)
|
||||
{
|
||||
result.Add(scalar.Value ?? "");
|
||||
}
|
||||
else if (node is YamlMappingNode mapping)
|
||||
{
|
||||
// v3 extended syntax: depends_on: service: condition: ...
|
||||
foreach (var (key, _) in mapping.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar)
|
||||
{
|
||||
result.Add(keyScalar.Value ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try mapping syntax (v3 extended)
|
||||
var depsMap = GetMappingNode(serviceNode, "depends_on");
|
||||
if (depsMap is not null)
|
||||
{
|
||||
foreach (var (key, _) in depsMap.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar)
|
||||
{
|
||||
result.Add(keyScalar.Value ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ParseLinks(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var linksNode = GetSequenceNode(serviceNode, "links");
|
||||
if (linksNode is null)
|
||||
return result;
|
||||
|
||||
foreach (var node in linksNode.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar)
|
||||
{
|
||||
result.Add(scalar.Value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseLabels(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
|
||||
// Try mapping syntax
|
||||
var labelsNode = GetMappingNode(serviceNode, "labels");
|
||||
if (labelsNode is not null)
|
||||
{
|
||||
foreach (var (key, value) in labelsNode.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar && value is YamlScalarNode valueScalar)
|
||||
{
|
||||
result[keyScalar.Value ?? ""] = valueScalar.Value ?? "";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try list syntax
|
||||
var labelsList = GetSequenceNode(serviceNode, "labels");
|
||||
if (labelsList is not null)
|
||||
{
|
||||
foreach (var node in labelsList.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar && scalar.Value is not null)
|
||||
{
|
||||
var eqIndex = scalar.Value.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = scalar.Value[..eqIndex];
|
||||
var value = scalar.Value[(eqIndex + 1)..];
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ParseNetworksList(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
// Try list syntax
|
||||
var networksList = GetSequenceNode(serviceNode, "networks");
|
||||
if (networksList is not null)
|
||||
{
|
||||
foreach (var node in networksList.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar)
|
||||
{
|
||||
result.Add(scalar.Value ?? "");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try mapping syntax
|
||||
var networksMap = GetMappingNode(serviceNode, "networks");
|
||||
if (networksMap is not null)
|
||||
{
|
||||
foreach (var (key, _) in networksMap.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar)
|
||||
{
|
||||
result.Add(keyScalar.Value ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ParseVolumesList(YamlMappingNode serviceNode)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var volumesList = GetSequenceNode(serviceNode, "volumes");
|
||||
if (volumesList is null)
|
||||
return result;
|
||||
|
||||
foreach (var node in volumesList.Children)
|
||||
{
|
||||
if (node is YamlScalarNode scalar)
|
||||
{
|
||||
result.Add(scalar.Value ?? "");
|
||||
}
|
||||
else if (node is YamlMappingNode mapping)
|
||||
{
|
||||
// Long syntax
|
||||
var source = GetScalarValue(mapping, "source");
|
||||
var target = GetScalarValue(mapping, "target");
|
||||
if (source is not null && target is not null)
|
||||
{
|
||||
result.Add($"{source}:{target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int ParseReplicas(YamlMappingNode serviceNode)
|
||||
{
|
||||
var deploy = GetMappingNode(serviceNode, "deploy");
|
||||
if (deploy is null)
|
||||
return 1;
|
||||
|
||||
var replicasStr = GetScalarValue(deploy, "replicas");
|
||||
return int.TryParse(replicasStr, out var replicas) ? replicas : 1;
|
||||
}
|
||||
|
||||
private static string? GetScalarValue(YamlMappingNode node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(new YamlScalarNode(key), out var value) &&
|
||||
value is YamlScalarNode scalar)
|
||||
{
|
||||
return scalar.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static YamlMappingNode? GetMappingNode(YamlMappingNode node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(new YamlScalarNode(key), out var value) &&
|
||||
value is YamlMappingNode mapping)
|
||||
{
|
||||
return mapping;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static YamlSequenceNode? GetSequenceNode(YamlMappingNode node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(new YamlScalarNode(key), out var value) &&
|
||||
value is YamlSequenceNode sequence)
|
||||
{
|
||||
return sequence;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractDigestFromImage(string image)
|
||||
{
|
||||
var atIndex = image.IndexOf('@');
|
||||
if (atIndex >= 0 && image.Length > atIndex + 1)
|
||||
{
|
||||
return image[(atIndex + 1)..];
|
||||
}
|
||||
return $"unresolved:{image}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Types
|
||||
|
||||
private sealed class ComposeServiceInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required ServiceNode Node { get; init; }
|
||||
public Dictionary<string, string> Environment { get; init; } = [];
|
||||
public List<string> DependsOn { get; init; } = [];
|
||||
public List<string> Links { get; init; } = [];
|
||||
public List<string> Networks { get; init; } = [];
|
||||
public List<(int HostPort, int ContainerPort)> Ports { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed record ComposeNetwork
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string Driver { get; init; } = "bridge";
|
||||
public bool External { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ComposeVolume
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string Driver { get; init; } = "local";
|
||||
public bool External { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace StellaOps.Scanner.EntryTrace.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for parsing orchestration manifests into mesh graphs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-005).
|
||||
/// </remarks>
|
||||
public interface IManifestParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the mesh type this parser handles.
|
||||
/// </summary>
|
||||
MeshType MeshType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this parser can handle the given manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifestPath">Path to the manifest file.</param>
|
||||
/// <param name="content">Optional content of the manifest.</param>
|
||||
/// <returns>True if this parser can handle the manifest.</returns>
|
||||
bool CanParse(string manifestPath, string? content = null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a manifest file into a mesh graph.
|
||||
/// </summary>
|
||||
/// <param name="manifestPath">Path to the manifest file.</param>
|
||||
/// <param name="content">Content of the manifest.</param>
|
||||
/// <param name="options">Parsing options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The parsed mesh graph.</returns>
|
||||
Task<MeshEntrypointGraph> ParseAsync(
|
||||
string manifestPath,
|
||||
string content,
|
||||
ManifestParseOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Parses multiple manifest files into a combined mesh graph.
|
||||
/// </summary>
|
||||
/// <param name="manifests">Dictionary of path to content.</param>
|
||||
/// <param name="options">Parsing options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The combined mesh graph.</returns>
|
||||
Task<MeshEntrypointGraph> ParseMultipleAsync(
|
||||
IReadOnlyDictionary<string, string> manifests,
|
||||
ManifestParseOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for manifest parsing.
|
||||
/// </summary>
|
||||
public sealed record ManifestParseOptions
|
||||
{
|
||||
/// <summary>Mesh identifier override.</summary>
|
||||
public string? MeshId { get; init; }
|
||||
|
||||
/// <summary>Namespace filter (for K8s).</summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>Whether to resolve image digests.</summary>
|
||||
public bool ResolveDigests { get; init; }
|
||||
|
||||
/// <summary>Whether to infer edges from environment variables.</summary>
|
||||
public bool InferEdgesFromEnv { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to include init containers.</summary>
|
||||
public bool IncludeInitContainers { get; init; }
|
||||
|
||||
/// <summary>Whether to include sidecar containers.</summary>
|
||||
public bool IncludeSidecars { get; init; } = true;
|
||||
|
||||
/// <summary>Label selector for filtering resources.</summary>
|
||||
public IReadOnlyDictionary<string, string>? LabelSelector { get; init; }
|
||||
|
||||
/// <summary>Default options.</summary>
|
||||
public static ManifestParseOptions Default { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for Kubernetes manifests (Deployment, Service, Ingress).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-006).
|
||||
/// </remarks>
|
||||
public sealed partial class KubernetesManifestParser : IManifestParser
|
||||
{
|
||||
public MeshType MeshType => MeshType.Kubernetes;
|
||||
|
||||
public bool CanParse(string manifestPath, string? content = null)
|
||||
{
|
||||
// Check file extension
|
||||
if (manifestPath.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
manifestPath.EndsWith(".yml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If content provided, check for K8s markers
|
||||
if (content is not null)
|
||||
{
|
||||
return content.Contains("apiVersion:") &&
|
||||
(content.Contains("kind: Deployment") ||
|
||||
content.Contains("kind: Service") ||
|
||||
content.Contains("kind: Ingress") ||
|
||||
content.Contains("kind: Pod") ||
|
||||
content.Contains("kind: StatefulSet") ||
|
||||
content.Contains("kind: DaemonSet"));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<MeshEntrypointGraph> ParseAsync(
|
||||
string manifestPath,
|
||||
string content,
|
||||
ManifestParseOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ParseMultipleAsync(
|
||||
new Dictionary<string, string> { [manifestPath] = content },
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MeshEntrypointGraph> ParseMultipleAsync(
|
||||
IReadOnlyDictionary<string, string> manifests,
|
||||
ManifestParseOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= ManifestParseOptions.Default;
|
||||
|
||||
var services = new List<ServiceNode>();
|
||||
var edges = new List<CrossContainerEdge>();
|
||||
var ingressPaths = new List<IngressPath>();
|
||||
var k8sServices = new Dictionary<string, K8sServiceInfo>();
|
||||
var deployments = new Dictionary<string, K8sDeploymentInfo>();
|
||||
|
||||
foreach (var (path, content) in manifests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ParseManifestContent(content, options, services, k8sServices, deployments, ingressPaths);
|
||||
}
|
||||
|
||||
// Build edges from K8s Service → Deployment mappings
|
||||
BuildEdgesFromServices(k8sServices, deployments, edges);
|
||||
|
||||
// Infer edges from environment variables if enabled
|
||||
if (options.InferEdgesFromEnv)
|
||||
{
|
||||
InferEdgesFromEnvironment(services, k8sServices, edges);
|
||||
}
|
||||
|
||||
var meshId = options.MeshId ?? options.Namespace ?? "default";
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = meshId,
|
||||
Type = MeshType.Kubernetes,
|
||||
Namespace = options.Namespace,
|
||||
Services = services.ToImmutableArray(),
|
||||
Edges = edges.ToImmutableArray(),
|
||||
IngressPaths = ingressPaths.ToImmutableArray(),
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
private void ParseManifestContent(
|
||||
string content,
|
||||
ManifestParseOptions options,
|
||||
List<ServiceNode> services,
|
||||
Dictionary<string, K8sServiceInfo> k8sServices,
|
||||
Dictionary<string, K8sDeploymentInfo> deployments,
|
||||
List<IngressPath> ingressPaths)
|
||||
{
|
||||
// Handle multi-document YAML
|
||||
var documents = content.Split(["---"], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var doc in documents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(doc))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var yaml = new YamlStream();
|
||||
using var reader = new StringReader(doc.Trim());
|
||||
yaml.Load(reader);
|
||||
|
||||
if (yaml.Documents.Count == 0)
|
||||
continue;
|
||||
|
||||
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
|
||||
var kind = GetScalarValue(root, "kind");
|
||||
var apiVersion = GetScalarValue(root, "apiVersion");
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case "Deployment":
|
||||
case "StatefulSet":
|
||||
case "DaemonSet":
|
||||
ParseDeployment(root, options, services, deployments);
|
||||
break;
|
||||
case "Service":
|
||||
ParseService(root, options, k8sServices);
|
||||
break;
|
||||
case "Ingress":
|
||||
ParseIngress(root, options, ingressPaths);
|
||||
break;
|
||||
case "Pod":
|
||||
ParsePod(root, options, services);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Skip malformed documents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseDeployment(
|
||||
YamlMappingNode root,
|
||||
ManifestParseOptions options,
|
||||
List<ServiceNode> services,
|
||||
Dictionary<string, K8sDeploymentInfo> deployments)
|
||||
{
|
||||
var metadata = GetMappingNode(root, "metadata");
|
||||
var spec = GetMappingNode(root, "spec");
|
||||
if (metadata is null || spec is null)
|
||||
return;
|
||||
|
||||
var name = GetScalarValue(metadata, "name") ?? "unknown";
|
||||
var ns = GetScalarValue(metadata, "namespace") ?? options.Namespace ?? "default";
|
||||
|
||||
// Apply namespace filter
|
||||
if (options.Namespace is not null && ns != options.Namespace)
|
||||
return;
|
||||
|
||||
var labels = GetLabels(metadata);
|
||||
var replicas = int.TryParse(GetScalarValue(spec, "replicas"), out var r) ? r : 1;
|
||||
|
||||
var template = GetMappingNode(spec, "template");
|
||||
var podSpec = template is not null ? GetMappingNode(template, "spec") : null;
|
||||
if (podSpec is null)
|
||||
return;
|
||||
|
||||
var selectorLabels = GetSelectorLabels(spec);
|
||||
|
||||
var deploymentInfo = new K8sDeploymentInfo
|
||||
{
|
||||
Name = name,
|
||||
Namespace = ns,
|
||||
SelectorLabels = selectorLabels
|
||||
};
|
||||
deployments[$"{ns}/{name}"] = deploymentInfo;
|
||||
|
||||
var containers = GetSequenceNode(podSpec, "containers");
|
||||
if (containers is null)
|
||||
return;
|
||||
|
||||
foreach (YamlMappingNode container in containers)
|
||||
{
|
||||
var containerName = GetScalarValue(container, "name") ?? "main";
|
||||
var image = GetScalarValue(container, "image") ?? "unknown";
|
||||
var ports = ParseContainerPorts(container);
|
||||
var env = ParseEnvironment(container);
|
||||
|
||||
var serviceNode = new ServiceNode
|
||||
{
|
||||
ServiceId = $"{ns}/{name}/{containerName}",
|
||||
ContainerName = containerName,
|
||||
ImageDigest = ExtractDigestFromImage(image),
|
||||
ImageReference = image,
|
||||
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty, // Filled by EntryTrace analysis
|
||||
ExposedPorts = ports.Select(p => p.ContainerPort).ToImmutableArray(),
|
||||
PortMappings = ports.ToImmutableDictionary(p => p.ContainerPort, p => p.ContainerPort),
|
||||
InternalDns = ImmutableArray<string>.Empty, // Filled from Service
|
||||
Labels = labels.ToImmutableDictionary(),
|
||||
Replicas = replicas,
|
||||
IsSidecar = containerName != "main" && containers.Children.Count > 1
|
||||
};
|
||||
|
||||
deploymentInfo.Containers.Add((containerName, serviceNode, env));
|
||||
services.Add(serviceNode);
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseService(
|
||||
YamlMappingNode root,
|
||||
ManifestParseOptions options,
|
||||
Dictionary<string, K8sServiceInfo> k8sServices)
|
||||
{
|
||||
var metadata = GetMappingNode(root, "metadata");
|
||||
var spec = GetMappingNode(root, "spec");
|
||||
if (metadata is null || spec is null)
|
||||
return;
|
||||
|
||||
var name = GetScalarValue(metadata, "name") ?? "unknown";
|
||||
var ns = GetScalarValue(metadata, "namespace") ?? options.Namespace ?? "default";
|
||||
|
||||
if (options.Namespace is not null && ns != options.Namespace)
|
||||
return;
|
||||
|
||||
var selectorLabels = GetSelector(spec);
|
||||
var ports = ParseServicePorts(spec);
|
||||
|
||||
k8sServices[$"{ns}/{name}"] = new K8sServiceInfo
|
||||
{
|
||||
Name = name,
|
||||
Namespace = ns,
|
||||
SelectorLabels = selectorLabels,
|
||||
Ports = ports,
|
||||
DnsName = $"{name}.{ns}.svc.cluster.local"
|
||||
};
|
||||
}
|
||||
|
||||
private void ParseIngress(
|
||||
YamlMappingNode root,
|
||||
ManifestParseOptions options,
|
||||
List<IngressPath> ingressPaths)
|
||||
{
|
||||
var metadata = GetMappingNode(root, "metadata");
|
||||
var spec = GetMappingNode(root, "spec");
|
||||
if (metadata is null || spec is null)
|
||||
return;
|
||||
|
||||
var name = GetScalarValue(metadata, "name") ?? "unknown";
|
||||
var ns = GetScalarValue(metadata, "namespace") ?? options.Namespace ?? "default";
|
||||
var annotations = GetAnnotations(metadata);
|
||||
|
||||
if (options.Namespace is not null && ns != options.Namespace)
|
||||
return;
|
||||
|
||||
// Check for TLS
|
||||
var tls = GetSequenceNode(spec, "tls");
|
||||
var tlsEnabled = tls is not null && tls.Children.Count > 0;
|
||||
string? tlsSecretName = null;
|
||||
if (tlsEnabled && tls!.Children.Count > 0)
|
||||
{
|
||||
var tlsEntry = tls.Children[0] as YamlMappingNode;
|
||||
tlsSecretName = GetScalarValue(tlsEntry!, "secretName");
|
||||
}
|
||||
|
||||
var rules = GetSequenceNode(spec, "rules");
|
||||
if (rules is null)
|
||||
return;
|
||||
|
||||
foreach (YamlMappingNode rule in rules)
|
||||
{
|
||||
var host = GetScalarValue(rule, "host") ?? "*";
|
||||
var http = GetMappingNode(rule, "http");
|
||||
if (http is null)
|
||||
continue;
|
||||
|
||||
var paths = GetSequenceNode(http, "paths");
|
||||
if (paths is null)
|
||||
continue;
|
||||
|
||||
foreach (YamlMappingNode pathEntry in paths)
|
||||
{
|
||||
var path = GetScalarValue(pathEntry, "path") ?? "/";
|
||||
var backend = GetMappingNode(pathEntry, "backend");
|
||||
if (backend is null)
|
||||
continue;
|
||||
|
||||
// Handle both v1 and networking.k8s.io/v1 formats
|
||||
string? serviceName = null;
|
||||
int servicePort = 80;
|
||||
|
||||
// networking.k8s.io/v1 format
|
||||
var service = GetMappingNode(backend, "service");
|
||||
if (service is not null)
|
||||
{
|
||||
serviceName = GetScalarValue(service, "name");
|
||||
var port = GetMappingNode(service, "port");
|
||||
if (port is not null)
|
||||
{
|
||||
var portNumber = GetScalarValue(port, "number");
|
||||
if (int.TryParse(portNumber, out var pn))
|
||||
servicePort = pn;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// v1beta1 format
|
||||
serviceName = GetScalarValue(backend, "serviceName");
|
||||
var portStr = GetScalarValue(backend, "servicePort");
|
||||
if (int.TryParse(portStr, out var pn))
|
||||
servicePort = pn;
|
||||
}
|
||||
|
||||
if (serviceName is null)
|
||||
continue;
|
||||
|
||||
ingressPaths.Add(new IngressPath
|
||||
{
|
||||
IngressName = name,
|
||||
Host = host,
|
||||
Path = path,
|
||||
TargetServiceId = $"{ns}/{serviceName}",
|
||||
TargetPort = servicePort,
|
||||
TlsEnabled = tlsEnabled,
|
||||
TlsSecretName = tlsSecretName,
|
||||
Annotations = annotations.Count > 0 ? annotations.ToImmutableDictionary() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ParsePod(
|
||||
YamlMappingNode root,
|
||||
ManifestParseOptions options,
|
||||
List<ServiceNode> services)
|
||||
{
|
||||
var metadata = GetMappingNode(root, "metadata");
|
||||
var spec = GetMappingNode(root, "spec");
|
||||
if (metadata is null || spec is null)
|
||||
return;
|
||||
|
||||
var name = GetScalarValue(metadata, "name") ?? "unknown";
|
||||
var ns = GetScalarValue(metadata, "namespace") ?? options.Namespace ?? "default";
|
||||
var labels = GetLabels(metadata);
|
||||
|
||||
if (options.Namespace is not null && ns != options.Namespace)
|
||||
return;
|
||||
|
||||
var containers = GetSequenceNode(spec, "containers");
|
||||
if (containers is null)
|
||||
return;
|
||||
|
||||
foreach (YamlMappingNode container in containers)
|
||||
{
|
||||
var containerName = GetScalarValue(container, "name") ?? "main";
|
||||
var image = GetScalarValue(container, "image") ?? "unknown";
|
||||
var ports = ParseContainerPorts(container);
|
||||
|
||||
services.Add(new ServiceNode
|
||||
{
|
||||
ServiceId = $"{ns}/{name}/{containerName}",
|
||||
ContainerName = containerName,
|
||||
ImageDigest = ExtractDigestFromImage(image),
|
||||
ImageReference = image,
|
||||
Entrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
|
||||
ExposedPorts = ports.Select(p => p.ContainerPort).ToImmutableArray(),
|
||||
Labels = labels.ToImmutableDictionary()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildEdgesFromServices(
|
||||
Dictionary<string, K8sServiceInfo> k8sServices,
|
||||
Dictionary<string, K8sDeploymentInfo> deployments,
|
||||
List<CrossContainerEdge> edges)
|
||||
{
|
||||
foreach (var (_, svc) in k8sServices)
|
||||
{
|
||||
// Find deployments matching this service's selector
|
||||
foreach (var (_, deployment) in deployments)
|
||||
{
|
||||
if (deployment.Namespace != svc.Namespace)
|
||||
continue;
|
||||
|
||||
if (!LabelsMatch(deployment.SelectorLabels, svc.SelectorLabels))
|
||||
continue;
|
||||
|
||||
// Create edges from service to deployment containers
|
||||
foreach (var (containerName, node, _) in deployment.Containers)
|
||||
{
|
||||
foreach (var port in svc.Ports)
|
||||
{
|
||||
if (node.ExposedPorts.Contains(port.TargetPort))
|
||||
{
|
||||
// This is a "receive" edge - external → container
|
||||
// Mark the service node with DNS names
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InferEdgesFromEnvironment(
|
||||
List<ServiceNode> services,
|
||||
Dictionary<string, K8sServiceInfo> k8sServices,
|
||||
List<CrossContainerEdge> edges)
|
||||
{
|
||||
// Pattern to match K8s service DNS names in environment variables
|
||||
var dnsPattern = DnsPatternRegex();
|
||||
|
||||
// This would require access to environment variables from containers
|
||||
// For now, we'll match based on known patterns
|
||||
foreach (var service in services)
|
||||
{
|
||||
foreach (var k8sSvc in k8sServices.Values)
|
||||
{
|
||||
// Check if any service references another via environment
|
||||
// This is a simplified version - full implementation would parse actual env vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"([a-z0-9-]+)\.([a-z0-9-]+)\.svc\.cluster\.local")]
|
||||
private static partial Regex DnsPatternRegex();
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string? GetScalarValue(YamlMappingNode node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(new YamlScalarNode(key), out var value) &&
|
||||
value is YamlScalarNode scalar)
|
||||
{
|
||||
return scalar.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static YamlMappingNode? GetMappingNode(YamlMappingNode node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(new YamlScalarNode(key), out var value) &&
|
||||
value is YamlMappingNode mapping)
|
||||
{
|
||||
return mapping;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static YamlSequenceNode? GetSequenceNode(YamlMappingNode node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(new YamlScalarNode(key), out var value) &&
|
||||
value is YamlSequenceNode sequence)
|
||||
{
|
||||
return sequence;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetLabels(YamlMappingNode metadata)
|
||||
{
|
||||
var labels = new Dictionary<string, string>();
|
||||
var labelsNode = GetMappingNode(metadata, "labels");
|
||||
if (labelsNode is null)
|
||||
return labels;
|
||||
|
||||
foreach (var (key, value) in labelsNode.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar && value is YamlScalarNode valueScalar)
|
||||
{
|
||||
labels[keyScalar.Value ?? ""] = valueScalar.Value ?? "";
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetAnnotations(YamlMappingNode metadata)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>();
|
||||
var node = GetMappingNode(metadata, "annotations");
|
||||
if (node is null)
|
||||
return annotations;
|
||||
|
||||
foreach (var (key, value) in node.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar && value is YamlScalarNode valueScalar)
|
||||
{
|
||||
annotations[keyScalar.Value ?? ""] = valueScalar.Value ?? "";
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetSelectorLabels(YamlMappingNode spec)
|
||||
{
|
||||
var selector = GetMappingNode(spec, "selector");
|
||||
if (selector is null)
|
||||
return [];
|
||||
|
||||
var matchLabels = GetMappingNode(selector, "matchLabels");
|
||||
if (matchLabels is null)
|
||||
return [];
|
||||
|
||||
var labels = new Dictionary<string, string>();
|
||||
foreach (var (key, value) in matchLabels.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar && value is YamlScalarNode valueScalar)
|
||||
{
|
||||
labels[keyScalar.Value ?? ""] = valueScalar.Value ?? "";
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GetSelector(YamlMappingNode spec)
|
||||
{
|
||||
var selector = GetMappingNode(spec, "selector");
|
||||
if (selector is null)
|
||||
return [];
|
||||
|
||||
var labels = new Dictionary<string, string>();
|
||||
foreach (var (key, value) in selector.Children)
|
||||
{
|
||||
if (key is YamlScalarNode keyScalar && value is YamlScalarNode valueScalar)
|
||||
{
|
||||
labels[keyScalar.Value ?? ""] = valueScalar.Value ?? "";
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static List<(int ContainerPort, string? Name, string Protocol)> ParseContainerPorts(YamlMappingNode container)
|
||||
{
|
||||
var result = new List<(int, string?, string)>();
|
||||
var ports = GetSequenceNode(container, "ports");
|
||||
if (ports is null)
|
||||
return result;
|
||||
|
||||
foreach (YamlMappingNode port in ports)
|
||||
{
|
||||
var containerPort = int.TryParse(GetScalarValue(port, "containerPort"), out var cp) ? cp : 0;
|
||||
var name = GetScalarValue(port, "name");
|
||||
var protocol = GetScalarValue(port, "protocol") ?? "TCP";
|
||||
|
||||
if (containerPort > 0)
|
||||
result.Add((containerPort, name, protocol));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<(int Port, int TargetPort, string? Name, string Protocol)> ParseServicePorts(YamlMappingNode spec)
|
||||
{
|
||||
var result = new List<(int, int, string?, string)>();
|
||||
var ports = GetSequenceNode(spec, "ports");
|
||||
if (ports is null)
|
||||
return result;
|
||||
|
||||
foreach (YamlMappingNode port in ports)
|
||||
{
|
||||
var servicePort = int.TryParse(GetScalarValue(port, "port"), out var sp) ? sp : 0;
|
||||
var targetPort = int.TryParse(GetScalarValue(port, "targetPort"), out var tp) ? tp : servicePort;
|
||||
var name = GetScalarValue(port, "name");
|
||||
var protocol = GetScalarValue(port, "protocol") ?? "TCP";
|
||||
|
||||
if (servicePort > 0)
|
||||
result.Add((servicePort, targetPort, name, protocol));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseEnvironment(YamlMappingNode container)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
var env = GetSequenceNode(container, "env");
|
||||
if (env is null)
|
||||
return result;
|
||||
|
||||
foreach (YamlMappingNode envVar in env)
|
||||
{
|
||||
var name = GetScalarValue(envVar, "name");
|
||||
var value = GetScalarValue(envVar, "value");
|
||||
if (name is not null && value is not null)
|
||||
{
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ExtractDigestFromImage(string image)
|
||||
{
|
||||
// Check if image contains @sha256:
|
||||
var atIndex = image.IndexOf('@');
|
||||
if (atIndex >= 0 && image.Length > atIndex + 1)
|
||||
{
|
||||
return image[(atIndex + 1)..];
|
||||
}
|
||||
// Return placeholder for tag-based images
|
||||
return $"unresolved:{image}";
|
||||
}
|
||||
|
||||
private static bool LabelsMatch(Dictionary<string, string> podLabels, Dictionary<string, string> selectorLabels)
|
||||
{
|
||||
foreach (var (key, value) in selectorLabels)
|
||||
{
|
||||
if (!podLabels.TryGetValue(key, out var podValue) || podValue != value)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Internal Types
|
||||
|
||||
private sealed class K8sServiceInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Namespace { get; init; }
|
||||
public Dictionary<string, string> SelectorLabels { get; init; } = [];
|
||||
public List<(int Port, int TargetPort, string? Name, string Protocol)> Ports { get; init; } = [];
|
||||
public string DnsName { get; init; } = "";
|
||||
}
|
||||
|
||||
private sealed class K8sDeploymentInfo
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Namespace { get; init; }
|
||||
public Dictionary<string, string> SelectorLabels { get; init; } = [];
|
||||
public List<(string Name, ServiceNode Node, Dictionary<string, string> Env)> Containers { get; } = [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator for mesh entrypoint analysis.
|
||||
/// Coordinates manifest parsers with semantic entrypoint analysis
|
||||
/// to produce a complete mesh entrypoint graph.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-008).
|
||||
/// </remarks>
|
||||
public sealed class MeshEntrypointAnalyzer
|
||||
{
|
||||
private readonly IReadOnlyList<IManifestParser> _parsers;
|
||||
private readonly ISemanticEntrypointAnalyzer? _semanticAnalyzer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new mesh entrypoint analyzer with the default parsers.
|
||||
/// </summary>
|
||||
public MeshEntrypointAnalyzer()
|
||||
: this([new KubernetesManifestParser(), new DockerComposeParser()], null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new mesh entrypoint analyzer with custom parsers.
|
||||
/// </summary>
|
||||
public MeshEntrypointAnalyzer(
|
||||
IReadOnlyList<IManifestParser> parsers,
|
||||
ISemanticEntrypointAnalyzer? semanticAnalyzer = null)
|
||||
{
|
||||
_parsers = parsers ?? throw new ArgumentNullException(nameof(parsers));
|
||||
_semanticAnalyzer = semanticAnalyzer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a single manifest file.
|
||||
/// </summary>
|
||||
public async Task<MeshAnalysisResult> AnalyzeAsync(
|
||||
string manifestPath,
|
||||
string content,
|
||||
MeshAnalysisOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AnalyzeMultipleAsync(
|
||||
new Dictionary<string, string> { [manifestPath] = content },
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes multiple manifest files and produces a combined mesh graph.
|
||||
/// </summary>
|
||||
public async Task<MeshAnalysisResult> AnalyzeMultipleAsync(
|
||||
IReadOnlyDictionary<string, string> manifests,
|
||||
MeshAnalysisOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= MeshAnalysisOptions.Default;
|
||||
|
||||
var errors = new List<MeshAnalysisError>();
|
||||
var graphs = new List<MeshEntrypointGraph>();
|
||||
|
||||
// Group manifests by parser type
|
||||
var manifestsByParser = new Dictionary<IManifestParser, Dictionary<string, string>>();
|
||||
|
||||
foreach (var (path, content) in manifests)
|
||||
{
|
||||
var parser = FindParser(path, content);
|
||||
if (parser is null)
|
||||
{
|
||||
errors.Add(new MeshAnalysisError
|
||||
{
|
||||
FilePath = path,
|
||||
ErrorCode = "MESH001",
|
||||
Message = "No suitable parser found for manifest format"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!manifestsByParser.TryGetValue(parser, out var parserManifests))
|
||||
{
|
||||
parserManifests = [];
|
||||
manifestsByParser[parser] = parserManifests;
|
||||
}
|
||||
parserManifests[path] = content;
|
||||
}
|
||||
|
||||
// Parse each group
|
||||
foreach (var (parser, parserManifests) in manifestsByParser)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var parseOptions = new ManifestParseOptions
|
||||
{
|
||||
Namespace = options.Namespace,
|
||||
MeshId = options.MeshId,
|
||||
InferEdgesFromEnv = options.InferEdgesFromEnv,
|
||||
IncludeSidecars = options.IncludeSidecars
|
||||
};
|
||||
|
||||
var graph = await parser.ParseMultipleAsync(
|
||||
parserManifests,
|
||||
parseOptions,
|
||||
cancellationToken);
|
||||
|
||||
graphs.Add(graph);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
foreach (var path in parserManifests.Keys)
|
||||
{
|
||||
errors.Add(new MeshAnalysisError
|
||||
{
|
||||
FilePath = path,
|
||||
ErrorCode = "MESH002",
|
||||
Message = $"Failed to parse manifest: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge graphs
|
||||
var mergedGraph = MergeGraphs(graphs, options);
|
||||
|
||||
// Enrich with semantic analysis if available
|
||||
if (_semanticAnalyzer is not null && options.PerformSemanticAnalysis)
|
||||
{
|
||||
mergedGraph = await EnrichWithSemanticAnalysisAsync(
|
||||
mergedGraph,
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Calculate security metrics
|
||||
var metrics = CalculateSecurityMetrics(mergedGraph);
|
||||
|
||||
return new MeshAnalysisResult
|
||||
{
|
||||
Graph = mergedGraph,
|
||||
Metrics = metrics,
|
||||
Errors = errors.ToImmutableArray(),
|
||||
AnalyzedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the most vulnerable paths from ingress to target services.
|
||||
/// </summary>
|
||||
public ImmutableArray<CrossContainerPath> FindVulnerablePaths(
|
||||
MeshEntrypointGraph graph,
|
||||
string targetServiceId,
|
||||
VulnerablePathCriteria? criteria = null)
|
||||
{
|
||||
criteria ??= VulnerablePathCriteria.Default;
|
||||
|
||||
var allPaths = graph.FindPathsToService(targetServiceId);
|
||||
|
||||
// Filter and score paths
|
||||
var scoredPaths = allPaths
|
||||
.Select(path => (Path: path, Score: ScorePath(path, graph, criteria)))
|
||||
.Where(x => x.Score >= criteria.MinimumScore)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(criteria.MaxResults)
|
||||
.Select(x => x.Path);
|
||||
|
||||
return scoredPaths.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies blast radius for a compromised service.
|
||||
/// </summary>
|
||||
public BlastRadiusAnalysis AnalyzeBlastRadius(
|
||||
MeshEntrypointGraph graph,
|
||||
string compromisedServiceId)
|
||||
{
|
||||
var directlyReachable = new HashSet<string>();
|
||||
var transitivelyReachable = new HashSet<string>();
|
||||
var ingressExposed = new List<IngressPath>();
|
||||
|
||||
// Find all services reachable from compromised service
|
||||
var toVisit = new Queue<(string ServiceId, int Depth)>();
|
||||
var visited = new HashSet<string>();
|
||||
|
||||
toVisit.Enqueue((compromisedServiceId, 0));
|
||||
visited.Add(compromisedServiceId);
|
||||
|
||||
while (toVisit.Count > 0)
|
||||
{
|
||||
var (currentId, depth) = toVisit.Dequeue();
|
||||
|
||||
var outboundEdges = graph.Edges
|
||||
.Where(e => e.FromServiceId == currentId);
|
||||
|
||||
foreach (var edge in outboundEdges)
|
||||
{
|
||||
if (depth == 0)
|
||||
{
|
||||
directlyReachable.Add(edge.ToServiceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
transitivelyReachable.Add(edge.ToServiceId);
|
||||
}
|
||||
|
||||
if (visited.Add(edge.ToServiceId))
|
||||
{
|
||||
toVisit.Enqueue((edge.ToServiceId, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if compromised service is ingress-exposed
|
||||
ingressExposed.AddRange(
|
||||
graph.IngressPaths.Where(p => p.TargetServiceId == compromisedServiceId));
|
||||
|
||||
// Calculate severity based on reach
|
||||
var severity = CalculateBlastRadiusSeverity(
|
||||
directlyReachable.Count,
|
||||
transitivelyReachable.Count,
|
||||
ingressExposed.Count,
|
||||
graph.Services.Length);
|
||||
|
||||
return new BlastRadiusAnalysis
|
||||
{
|
||||
CompromisedServiceId = compromisedServiceId,
|
||||
DirectlyReachableServices = directlyReachable.ToImmutableArray(),
|
||||
TransitivelyReachableServices = transitivelyReachable.ToImmutableArray(),
|
||||
IngressExposure = ingressExposed.ToImmutableArray(),
|
||||
TotalReach = directlyReachable.Count + transitivelyReachable.Count,
|
||||
TotalServices = graph.Services.Length,
|
||||
Severity = severity
|
||||
};
|
||||
}
|
||||
|
||||
private IManifestParser? FindParser(string path, string content)
|
||||
{
|
||||
foreach (var parser in _parsers)
|
||||
{
|
||||
if (parser.CanParse(path, content))
|
||||
return parser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private MeshEntrypointGraph MergeGraphs(
|
||||
IReadOnlyList<MeshEntrypointGraph> graphs,
|
||||
MeshAnalysisOptions options)
|
||||
{
|
||||
if (graphs.Count == 0)
|
||||
{
|
||||
return new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = options.MeshId ?? "empty",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = ImmutableArray<ServiceNode>.Empty,
|
||||
Edges = ImmutableArray<CrossContainerEdge>.Empty,
|
||||
IngressPaths = ImmutableArray<IngressPath>.Empty,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
if (graphs.Count == 1)
|
||||
return graphs[0];
|
||||
|
||||
// Merge all graphs
|
||||
var services = new List<ServiceNode>();
|
||||
var edges = new List<CrossContainerEdge>();
|
||||
var ingressPaths = new List<IngressPath>();
|
||||
|
||||
foreach (var graph in graphs)
|
||||
{
|
||||
services.AddRange(graph.Services);
|
||||
edges.AddRange(graph.Edges);
|
||||
ingressPaths.AddRange(graph.IngressPaths);
|
||||
}
|
||||
|
||||
// Deduplicate by ID
|
||||
var uniqueServices = services
|
||||
.GroupBy(s => s.ServiceId)
|
||||
.Select(g => g.First())
|
||||
.ToImmutableArray();
|
||||
|
||||
var uniqueEdges = edges
|
||||
.GroupBy(e => $"{e.FromServiceId}:{e.ToServiceId}:{e.Port}")
|
||||
.Select(g => g.First())
|
||||
.ToImmutableArray();
|
||||
|
||||
var uniqueIngress = ingressPaths
|
||||
.GroupBy(i => $"{i.Host}{i.Path}{i.TargetServiceId}")
|
||||
.Select(g => g.First())
|
||||
.ToImmutableArray();
|
||||
|
||||
return new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = options.MeshId ?? graphs[0].MeshId,
|
||||
Type = graphs[0].Type,
|
||||
Namespace = options.Namespace ?? graphs[0].Namespace,
|
||||
Services = uniqueServices,
|
||||
Edges = uniqueEdges,
|
||||
IngressPaths = uniqueIngress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<MeshEntrypointGraph> EnrichWithSemanticAnalysisAsync(
|
||||
MeshEntrypointGraph graph,
|
||||
MeshAnalysisOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_semanticAnalyzer is null)
|
||||
return graph;
|
||||
|
||||
var enrichedServices = new List<ServiceNode>();
|
||||
|
||||
foreach (var service in graph.Services)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var entrypoints = await _semanticAnalyzer.AnalyzeContainerAsync(
|
||||
service.ImageDigest ?? service.ImageReference ?? "",
|
||||
cancellationToken);
|
||||
|
||||
var enriched = service with
|
||||
{
|
||||
Entrypoints = entrypoints.ToImmutableArray(),
|
||||
VulnerableComponents = await _semanticAnalyzer.GetVulnerableComponentsAsync(
|
||||
service.ImageDigest ?? "",
|
||||
cancellationToken)
|
||||
};
|
||||
enrichedServices.Add(enriched);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep original service on analysis failure
|
||||
enrichedServices.Add(service);
|
||||
}
|
||||
}
|
||||
|
||||
return graph with { Services = enrichedServices.ToImmutableArray() };
|
||||
}
|
||||
|
||||
private static MeshSecurityMetrics CalculateSecurityMetrics(MeshEntrypointGraph graph)
|
||||
{
|
||||
var totalServices = graph.Services.Length;
|
||||
var totalEdges = graph.Edges.Length;
|
||||
var ingressCount = graph.IngressPaths.Length;
|
||||
|
||||
// Calculate exposure score
|
||||
var exposedServices = graph.Services
|
||||
.Where(s => graph.IngressPaths.Any(p => p.TargetServiceId == s.ServiceId))
|
||||
.Count();
|
||||
|
||||
var exposureRatio = totalServices > 0
|
||||
? (double)exposedServices / totalServices
|
||||
: 0;
|
||||
|
||||
// Calculate connectivity density
|
||||
var maxEdges = totalServices * (totalServices - 1);
|
||||
var connectivityDensity = maxEdges > 0
|
||||
? (double)totalEdges / maxEdges
|
||||
: 0;
|
||||
|
||||
// Calculate vulnerable service ratio
|
||||
var vulnerableServices = graph.Services
|
||||
.Where(s => s.VulnerableComponents.Length > 0)
|
||||
.Count();
|
||||
|
||||
var vulnerableRatio = totalServices > 0
|
||||
? (double)vulnerableServices / totalServices
|
||||
: 0;
|
||||
|
||||
// Calculate critical path count (paths from ingress to vulnerable services)
|
||||
var criticalPathCount = 0;
|
||||
foreach (var vulnerable in graph.Services.Where(s => s.VulnerableComponents.Length > 0))
|
||||
{
|
||||
var paths = graph.FindPathsToService(vulnerable.ServiceId);
|
||||
criticalPathCount += paths.Length;
|
||||
}
|
||||
|
||||
// Overall risk score (0-100)
|
||||
var riskScore = CalculateOverallRiskScore(
|
||||
exposureRatio,
|
||||
connectivityDensity,
|
||||
vulnerableRatio,
|
||||
criticalPathCount,
|
||||
totalServices);
|
||||
|
||||
return new MeshSecurityMetrics
|
||||
{
|
||||
TotalServices = totalServices,
|
||||
TotalEdges = totalEdges,
|
||||
IngressPointCount = ingressCount,
|
||||
ExposedServiceCount = exposedServices,
|
||||
VulnerableServiceCount = vulnerableServices,
|
||||
CriticalPathCount = criticalPathCount,
|
||||
ExposureRatio = exposureRatio,
|
||||
ConnectivityDensity = connectivityDensity,
|
||||
VulnerableRatio = vulnerableRatio,
|
||||
OverallRiskScore = riskScore
|
||||
};
|
||||
}
|
||||
|
||||
private static double CalculateOverallRiskScore(
|
||||
double exposureRatio,
|
||||
double connectivityDensity,
|
||||
double vulnerableRatio,
|
||||
int criticalPathCount,
|
||||
int totalServices)
|
||||
{
|
||||
// Weighted scoring
|
||||
var score = 0.0;
|
||||
|
||||
// Exposure (25% weight)
|
||||
score += exposureRatio * 25;
|
||||
|
||||
// Vulnerability (30% weight)
|
||||
score += vulnerableRatio * 30;
|
||||
|
||||
// Connectivity (15% weight) - higher connectivity = more lateral movement risk
|
||||
score += connectivityDensity * 15;
|
||||
|
||||
// Critical paths (30% weight) - normalized
|
||||
var criticalPathNormalized = totalServices > 0
|
||||
? Math.Min(1.0, criticalPathCount / (totalServices * 2.0))
|
||||
: 0;
|
||||
score += criticalPathNormalized * 30;
|
||||
|
||||
return Math.Min(100, score);
|
||||
}
|
||||
|
||||
private static double ScorePath(
|
||||
CrossContainerPath path,
|
||||
MeshEntrypointGraph graph,
|
||||
VulnerablePathCriteria criteria)
|
||||
{
|
||||
var score = 0.0;
|
||||
|
||||
// Base score for path existence
|
||||
score += 10;
|
||||
|
||||
// Shorter paths are more critical
|
||||
var lengthFactor = Math.Max(0, criteria.MaxDepth - path.Hops.Length + 1);
|
||||
score += lengthFactor * 5;
|
||||
|
||||
// Check for vulnerable components along path
|
||||
foreach (var hop in path.Hops)
|
||||
{
|
||||
var service = graph.Services.FirstOrDefault(s => s.ServiceId == hop.ToServiceId);
|
||||
if (service?.VulnerableComponents.Length > 0)
|
||||
{
|
||||
score += 20;
|
||||
}
|
||||
}
|
||||
|
||||
// External ingress exposure
|
||||
if (path.IsIngressExposed)
|
||||
{
|
||||
score += 25;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static BlastRadiusSeverity CalculateBlastRadiusSeverity(
|
||||
int directCount,
|
||||
int transitiveCount,
|
||||
int ingressCount,
|
||||
int totalServices)
|
||||
{
|
||||
if (totalServices == 0)
|
||||
return BlastRadiusSeverity.None;
|
||||
|
||||
var reachRatio = (double)(directCount + transitiveCount) / totalServices;
|
||||
|
||||
return (reachRatio, ingressCount) switch
|
||||
{
|
||||
( >= 0.5, > 0) => BlastRadiusSeverity.Critical,
|
||||
( >= 0.3, > 0) => BlastRadiusSeverity.High,
|
||||
( >= 0.3, 0) => BlastRadiusSeverity.Medium,
|
||||
( >= 0.1, _) => BlastRadiusSeverity.Medium,
|
||||
( > 0, _) => BlastRadiusSeverity.Low,
|
||||
_ => BlastRadiusSeverity.None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for mesh entrypoint analysis.
|
||||
/// </summary>
|
||||
public sealed record MeshAnalysisOptions
|
||||
{
|
||||
public static readonly MeshAnalysisOptions Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional namespace filter.
|
||||
/// </summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional mesh identifier.
|
||||
/// </summary>
|
||||
public string? MeshId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to infer edges from environment variables.
|
||||
/// </summary>
|
||||
public bool InferEdgesFromEnv { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include sidecar containers.
|
||||
/// </summary>
|
||||
public bool IncludeSidecars { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to perform semantic entrypoint analysis.
|
||||
/// </summary>
|
||||
public bool PerformSemanticAnalysis { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of mesh entrypoint analysis.
|
||||
/// </summary>
|
||||
public sealed record MeshAnalysisResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The analyzed mesh graph.
|
||||
/// </summary>
|
||||
public required MeshEntrypointGraph Graph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security metrics for the mesh.
|
||||
/// </summary>
|
||||
public required MeshSecurityMetrics Metrics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Errors encountered during analysis.
|
||||
/// </summary>
|
||||
public ImmutableArray<MeshAnalysisError> Errors { get; init; } = ImmutableArray<MeshAnalysisError>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the analysis was performed.
|
||||
/// </summary>
|
||||
public DateTime AnalyzedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Security metrics for a mesh.
|
||||
/// </summary>
|
||||
public sealed record MeshSecurityMetrics
|
||||
{
|
||||
public int TotalServices { get; init; }
|
||||
public int TotalEdges { get; init; }
|
||||
public int IngressPointCount { get; init; }
|
||||
public int ExposedServiceCount { get; init; }
|
||||
public int VulnerableServiceCount { get; init; }
|
||||
public int CriticalPathCount { get; init; }
|
||||
public double ExposureRatio { get; init; }
|
||||
public double ConnectivityDensity { get; init; }
|
||||
public double VulnerableRatio { get; init; }
|
||||
public double OverallRiskScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error encountered during mesh analysis.
|
||||
/// </summary>
|
||||
public sealed record MeshAnalysisError
|
||||
{
|
||||
public required string FilePath { get; init; }
|
||||
public required string ErrorCode { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public int? Line { get; init; }
|
||||
public int? Column { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Criteria for finding vulnerable paths.
|
||||
/// </summary>
|
||||
public sealed record VulnerablePathCriteria
|
||||
{
|
||||
public static readonly VulnerablePathCriteria Default = new();
|
||||
|
||||
public int MaxDepth { get; init; } = 5;
|
||||
public int MaxResults { get; init; } = 10;
|
||||
public double MinimumScore { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analysis of blast radius for a compromised service.
|
||||
/// </summary>
|
||||
public sealed record BlastRadiusAnalysis
|
||||
{
|
||||
public required string CompromisedServiceId { get; init; }
|
||||
public ImmutableArray<string> DirectlyReachableServices { get; init; }
|
||||
public ImmutableArray<string> TransitivelyReachableServices { get; init; }
|
||||
public ImmutableArray<IngressPath> IngressExposure { get; init; }
|
||||
public int TotalReach { get; init; }
|
||||
public int TotalServices { get; init; }
|
||||
public BlastRadiusSeverity Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for blast radius.
|
||||
/// </summary>
|
||||
public enum BlastRadiusSeverity
|
||||
{
|
||||
None = 0,
|
||||
Low = 1,
|
||||
Medium = 2,
|
||||
High = 3,
|
||||
Critical = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for semantic entrypoint analysis (to be implemented by Semantic module integration).
|
||||
/// </summary>
|
||||
public interface ISemanticEntrypointAnalyzer
|
||||
{
|
||||
Task<IReadOnlyList<SemanticEntrypoint>> AnalyzeContainerAsync(
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ImmutableArray<string>> GetVulnerableComponentsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a multi-container service mesh with cross-container reachability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-001).
|
||||
/// Enables analysis of vulnerable paths that cross service boundaries.
|
||||
/// </remarks>
|
||||
public sealed record MeshEntrypointGraph
|
||||
{
|
||||
/// <summary>Mesh identifier (namespace, compose project, or cluster name).</summary>
|
||||
public required string MeshId { get; init; }
|
||||
|
||||
/// <summary>All service nodes in the mesh.</summary>
|
||||
public required ImmutableArray<ServiceNode> Services { get; init; }
|
||||
|
||||
/// <summary>All edges representing inter-service communication.</summary>
|
||||
public required ImmutableArray<CrossContainerEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>Ingress paths from external sources.</summary>
|
||||
public required ImmutableArray<IngressPath> IngressPaths { get; init; }
|
||||
|
||||
/// <summary>Mesh type (Kubernetes, DockerCompose, etc.).</summary>
|
||||
public required MeshType Type { get; init; }
|
||||
|
||||
/// <summary>When the mesh was analyzed (UTC ISO-8601).</summary>
|
||||
public required string AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>Namespace within the cluster (for K8s).</summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Finds all paths from an ingress to a specific service.
|
||||
/// </summary>
|
||||
public ImmutableArray<CrossContainerPath> FindPathsToService(string serviceId)
|
||||
{
|
||||
var paths = new List<CrossContainerPath>();
|
||||
var targetService = Services.FirstOrDefault(s => s.ServiceId == serviceId);
|
||||
|
||||
if (targetService is null)
|
||||
return ImmutableArray<CrossContainerPath>.Empty;
|
||||
|
||||
// Find ingress paths that lead to target
|
||||
foreach (var ingress in IngressPaths)
|
||||
{
|
||||
var path = FindPath(ingress.TargetServiceId, serviceId);
|
||||
if (path is not null)
|
||||
{
|
||||
paths.Add(path with
|
||||
{
|
||||
IsIngressExposed = true,
|
||||
IngressPath = ingress
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a path between two services using BFS.
|
||||
/// </summary>
|
||||
public CrossContainerPath? FindPath(string fromServiceId, string toServiceId)
|
||||
{
|
||||
if (fromServiceId == toServiceId)
|
||||
return null;
|
||||
|
||||
var fromService = Services.FirstOrDefault(s => s.ServiceId == fromServiceId);
|
||||
var toService = Services.FirstOrDefault(s => s.ServiceId == toServiceId);
|
||||
|
||||
if (fromService is null || toService is null)
|
||||
return null;
|
||||
|
||||
// BFS to find shortest path
|
||||
var visited = new HashSet<string>();
|
||||
var queue = new Queue<(string ServiceId, List<CrossContainerEdge> Path)>();
|
||||
queue.Enqueue((fromServiceId, []));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (currentId, currentPath) = queue.Dequeue();
|
||||
|
||||
if (currentId == toServiceId)
|
||||
{
|
||||
return new CrossContainerPath
|
||||
{
|
||||
Source = fromService,
|
||||
Target = toService,
|
||||
Hops = currentPath.ToImmutableArray(),
|
||||
HopCount = currentPath.Count,
|
||||
IsIngressExposed = false,
|
||||
ReachabilityConfidence = ComputePathConfidence(currentPath)
|
||||
};
|
||||
}
|
||||
|
||||
if (!visited.Add(currentId))
|
||||
continue;
|
||||
|
||||
var outgoingEdges = Edges.Where(e => e.FromServiceId == currentId);
|
||||
foreach (var edge in outgoingEdges)
|
||||
{
|
||||
if (!visited.Contains(edge.ToServiceId))
|
||||
{
|
||||
var newPath = new List<CrossContainerEdge>(currentPath) { edge };
|
||||
queue.Enqueue((edge.ToServiceId, newPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all services that are internet-exposed via ingress.
|
||||
/// </summary>
|
||||
public ImmutableArray<ServiceNode> GetExposedServices()
|
||||
{
|
||||
var exposedIds = IngressPaths
|
||||
.Select(i => i.TargetServiceId)
|
||||
.ToHashSet();
|
||||
|
||||
return Services
|
||||
.Where(s => exposedIds.Contains(s.ServiceId))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static float ComputePathConfidence(List<CrossContainerEdge> path)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
return 1.0f;
|
||||
|
||||
// Confidence decreases with each hop and edge confidence
|
||||
var confidence = 1.0f;
|
||||
foreach (var edge in path)
|
||||
{
|
||||
confidence *= edge.Confidence;
|
||||
}
|
||||
return confidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a builder for constructing a MeshEntrypointGraph.
|
||||
/// </summary>
|
||||
public static MeshEntrypointGraphBuilder CreateBuilder() => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single container/service in the mesh.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-002).
|
||||
/// </remarks>
|
||||
public sealed record ServiceNode
|
||||
{
|
||||
/// <summary>Service identifier (deployment name, service name).</summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>Container name within the pod/service.</summary>
|
||||
public string? ContainerName { get; init; }
|
||||
|
||||
/// <summary>Image digest (sha256:...).</summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Image reference (registry/repo:tag).</summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>Semantic entrypoints discovered in this container.</summary>
|
||||
public required ImmutableArray<SemanticEntrypoint> Entrypoints { get; init; }
|
||||
|
||||
/// <summary>Ports exposed by the container.</summary>
|
||||
public required ImmutableArray<int> ExposedPorts { get; init; }
|
||||
|
||||
/// <summary>Container ports (port → target port mapping).</summary>
|
||||
public ImmutableDictionary<int, int>? PortMappings { get; init; }
|
||||
|
||||
/// <summary>Internal DNS names (K8s service names, compose aliases).</summary>
|
||||
public ImmutableArray<string> InternalDns { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Labels from the container/pod.</summary>
|
||||
public ImmutableDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
/// <summary>Annotations from the pod.</summary>
|
||||
public ImmutableDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>Replicas/instances of this service.</summary>
|
||||
public int Replicas { get; init; } = 1;
|
||||
|
||||
/// <summary>Whether this service is a sidecar/init container.</summary>
|
||||
public bool IsSidecar { get; init; }
|
||||
|
||||
/// <summary>Primary intent of the service.</summary>
|
||||
public ApplicationIntent? PrimaryIntent { get; init; }
|
||||
|
||||
/// <summary>Combined capabilities.</summary>
|
||||
public CapabilityClass CombinedCapabilities { get; init; }
|
||||
|
||||
/// <summary>Vulnerable components (PURLs) in this service.</summary>
|
||||
public ImmutableArray<string> VulnerableComponents { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an edge connecting two services in the mesh.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-003).
|
||||
/// </remarks>
|
||||
public sealed record CrossContainerEdge
|
||||
{
|
||||
/// <summary>Source service ID.</summary>
|
||||
public required string FromServiceId { get; init; }
|
||||
|
||||
/// <summary>Target service ID.</summary>
|
||||
public required string ToServiceId { get; init; }
|
||||
|
||||
/// <summary>Target port on the destination service.</summary>
|
||||
public required int Port { get; init; }
|
||||
|
||||
/// <summary>Protocol (TCP, UDP, HTTP, gRPC, etc.).</summary>
|
||||
public required string Protocol { get; init; }
|
||||
|
||||
/// <summary>Whether this edge is exposed via ingress.</summary>
|
||||
public bool IsExternal { get; init; }
|
||||
|
||||
/// <summary>Confidence in this edge (0.0-1.0).</summary>
|
||||
public float Confidence { get; init; } = 1.0f;
|
||||
|
||||
/// <summary>Source of edge detection (manifest, runtime, inferred).</summary>
|
||||
public EdgeSource Source { get; init; } = EdgeSource.Manifest;
|
||||
|
||||
/// <summary>Named port if applicable.</summary>
|
||||
public string? NamedPort { get; init; }
|
||||
|
||||
/// <summary>Additional metadata about the edge.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a path across multiple services in the mesh.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task MESH-004).
|
||||
/// </remarks>
|
||||
public sealed record CrossContainerPath
|
||||
{
|
||||
/// <summary>Source service at the start of the path.</summary>
|
||||
public required ServiceNode Source { get; init; }
|
||||
|
||||
/// <summary>Target service at the end of the path.</summary>
|
||||
public required ServiceNode Target { get; init; }
|
||||
|
||||
/// <summary>Edges traversed in order.</summary>
|
||||
public required ImmutableArray<CrossContainerEdge> Hops { get; init; }
|
||||
|
||||
/// <summary>Number of hops in the path.</summary>
|
||||
public required int HopCount { get; init; }
|
||||
|
||||
/// <summary>Whether this path is exposed via ingress.</summary>
|
||||
public required bool IsIngressExposed { get; init; }
|
||||
|
||||
/// <summary>Ingress path details if exposed.</summary>
|
||||
public IngressPath? IngressPath { get; init; }
|
||||
|
||||
/// <summary>Confidence in path reachability (0.0-1.0).</summary>
|
||||
public required float ReachabilityConfidence { get; init; }
|
||||
|
||||
/// <summary>Vulnerable components along the path (PURLs).</summary>
|
||||
public ImmutableArray<string> VulnerableComponents { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Combined vulnerable PURLs from source and target.</summary>
|
||||
public ImmutableArray<string> GetAllVulnerableComponents()
|
||||
{
|
||||
return Source.VulnerableComponents
|
||||
.Concat(Target.VulnerableComponents)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an ingress path from external sources.
|
||||
/// </summary>
|
||||
public sealed record IngressPath
|
||||
{
|
||||
/// <summary>Ingress resource name.</summary>
|
||||
public required string IngressName { get; init; }
|
||||
|
||||
/// <summary>External hostname.</summary>
|
||||
public required string Host { get; init; }
|
||||
|
||||
/// <summary>Path pattern (e.g., "/api/*").</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Target service ID.</summary>
|
||||
public required string TargetServiceId { get; init; }
|
||||
|
||||
/// <summary>Target port.</summary>
|
||||
public required int TargetPort { get; init; }
|
||||
|
||||
/// <summary>Whether TLS is enabled.</summary>
|
||||
public bool TlsEnabled { get; init; }
|
||||
|
||||
/// <summary>TLS secret name if TLS is enabled.</summary>
|
||||
public string? TlsSecretName { get; init; }
|
||||
|
||||
/// <summary>Annotations from the ingress resource.</summary>
|
||||
public ImmutableDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of service mesh/orchestration.
|
||||
/// </summary>
|
||||
public enum MeshType
|
||||
{
|
||||
/// <summary>Unknown mesh type.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Kubernetes cluster.</summary>
|
||||
Kubernetes = 1,
|
||||
|
||||
/// <summary>Docker Compose.</summary>
|
||||
DockerCompose = 2,
|
||||
|
||||
/// <summary>Docker Swarm.</summary>
|
||||
DockerSwarm = 3,
|
||||
|
||||
/// <summary>AWS ECS.</summary>
|
||||
AwsEcs = 4,
|
||||
|
||||
/// <summary>Nomad.</summary>
|
||||
Nomad = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of edge detection.
|
||||
/// </summary>
|
||||
public enum EdgeSource
|
||||
{
|
||||
/// <summary>Parsed from manifest (K8s, Compose).</summary>
|
||||
Manifest = 0,
|
||||
|
||||
/// <summary>Observed at runtime.</summary>
|
||||
Runtime = 1,
|
||||
|
||||
/// <summary>Inferred from code analysis.</summary>
|
||||
CodeAnalysis = 2,
|
||||
|
||||
/// <summary>Inferred from environment variables.</summary>
|
||||
EnvironmentInferred = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing MeshEntrypointGraph instances.
|
||||
/// </summary>
|
||||
public sealed class MeshEntrypointGraphBuilder
|
||||
{
|
||||
private string? _meshId;
|
||||
private MeshType _type = MeshType.Unknown;
|
||||
private string? _namespace;
|
||||
private readonly List<ServiceNode> _services = [];
|
||||
private readonly List<CrossContainerEdge> _edges = [];
|
||||
private readonly List<IngressPath> _ingressPaths = [];
|
||||
private readonly Dictionary<string, string> _metadata = [];
|
||||
|
||||
public MeshEntrypointGraphBuilder WithMeshId(string meshId)
|
||||
{
|
||||
_meshId = meshId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraphBuilder WithType(MeshType type)
|
||||
{
|
||||
_type = type;
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraphBuilder WithNamespace(string? ns)
|
||||
{
|
||||
_namespace = ns;
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraphBuilder AddService(ServiceNode service)
|
||||
{
|
||||
_services.Add(service);
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraphBuilder AddEdge(CrossContainerEdge edge)
|
||||
{
|
||||
_edges.Add(edge);
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraphBuilder AddIngressPath(IngressPath ingress)
|
||||
{
|
||||
_ingressPaths.Add(ingress);
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraphBuilder AddMetadata(string key, string value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public MeshEntrypointGraph Build()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_meshId))
|
||||
throw new InvalidOperationException("MeshId is required");
|
||||
|
||||
return new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = _meshId,
|
||||
Type = _type,
|
||||
Namespace = _namespace,
|
||||
Services = _services.ToImmutableArray(),
|
||||
Edges = _edges.ToImmutableArray(),
|
||||
IngressPaths = _ingressPaths.ToImmutableArray(),
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Metadata = _metadata.Count > 0
|
||||
? _metadata.ToImmutableDictionary()
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
namespace StellaOps.Scanner.EntryTrace.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Categories of drift detected between entrypoint versions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task TEMP-004).
|
||||
/// Drift categories enable alerting and policy decisions based on entrypoint changes.
|
||||
/// </remarks>
|
||||
[Flags]
|
||||
public enum EntrypointDrift
|
||||
{
|
||||
/// <summary>No drift detected.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Application intent changed (e.g., WebServer → Worker).</summary>
|
||||
IntentChanged = 1 << 0,
|
||||
|
||||
/// <summary>New capabilities added to the entrypoint.</summary>
|
||||
CapabilitiesExpanded = 1 << 1,
|
||||
|
||||
/// <summary>Capabilities removed from the entrypoint.</summary>
|
||||
CapabilitiesReduced = 1 << 2,
|
||||
|
||||
/// <summary>New threat vectors identified (attack surface grew).</summary>
|
||||
AttackSurfaceGrew = 1 << 3,
|
||||
|
||||
/// <summary>Threat vectors removed (attack surface shrank).</summary>
|
||||
AttackSurfaceShrank = 1 << 4,
|
||||
|
||||
/// <summary>Framework changed (e.g., Express → Fastify).</summary>
|
||||
FrameworkChanged = 1 << 5,
|
||||
|
||||
/// <summary>Framework version changed.</summary>
|
||||
FrameworkVersionChanged = 1 << 6,
|
||||
|
||||
/// <summary>Exposed ports changed.</summary>
|
||||
PortsChanged = 1 << 7,
|
||||
|
||||
/// <summary>Added new exposed ports.</summary>
|
||||
PortsAdded = 1 << 8,
|
||||
|
||||
/// <summary>Removed exposed ports.</summary>
|
||||
PortsRemoved = 1 << 9,
|
||||
|
||||
/// <summary>User context changed to more privileged (e.g., user → root).</summary>
|
||||
PrivilegeEscalation = 1 << 10,
|
||||
|
||||
/// <summary>User context changed to less privileged (e.g., root → user).</summary>
|
||||
PrivilegeReduction = 1 << 11,
|
||||
|
||||
/// <summary>Working directory changed.</summary>
|
||||
WorkingDirectoryChanged = 1 << 12,
|
||||
|
||||
/// <summary>Environment variables changed.</summary>
|
||||
EnvironmentChanged = 1 << 13,
|
||||
|
||||
/// <summary>Shell or interpreter changed.</summary>
|
||||
ShellChanged = 1 << 14,
|
||||
|
||||
/// <summary>Entrypoint command changed.</summary>
|
||||
CommandChanged = 1 << 15,
|
||||
|
||||
/// <summary>New entrypoint added.</summary>
|
||||
EntrypointAdded = 1 << 16,
|
||||
|
||||
/// <summary>Entrypoint removed.</summary>
|
||||
EntrypointRemoved = 1 << 17,
|
||||
|
||||
/// <summary>Data flow boundaries changed.</summary>
|
||||
DataBoundariesChanged = 1 << 18,
|
||||
|
||||
/// <summary>Confidence in analysis changed significantly.</summary>
|
||||
ConfidenceChanged = 1 << 19,
|
||||
|
||||
/// <summary>Runtime version changed.</summary>
|
||||
RuntimeVersionChanged = 1 << 20,
|
||||
|
||||
/// <summary>Labels changed.</summary>
|
||||
LabelsChanged = 1 << 21,
|
||||
|
||||
/// <summary>Volumes changed.</summary>
|
||||
VolumesChanged = 1 << 22,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for EntrypointDrift.
|
||||
/// </summary>
|
||||
public static class EntrypointDriftExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if the drift represents an increase in risk.
|
||||
/// </summary>
|
||||
public static bool IsRiskIncrease(this EntrypointDrift drift)
|
||||
{
|
||||
return drift.HasFlag(EntrypointDrift.CapabilitiesExpanded) ||
|
||||
drift.HasFlag(EntrypointDrift.AttackSurfaceGrew) ||
|
||||
drift.HasFlag(EntrypointDrift.PrivilegeEscalation) ||
|
||||
drift.HasFlag(EntrypointDrift.PortsAdded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the drift represents a decrease in risk.
|
||||
/// </summary>
|
||||
public static bool IsRiskDecrease(this EntrypointDrift drift)
|
||||
{
|
||||
return drift.HasFlag(EntrypointDrift.CapabilitiesReduced) ||
|
||||
drift.HasFlag(EntrypointDrift.AttackSurfaceShrank) ||
|
||||
drift.HasFlag(EntrypointDrift.PrivilegeReduction) ||
|
||||
drift.HasFlag(EntrypointDrift.PortsRemoved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the drift is a material change requiring review.
|
||||
/// </summary>
|
||||
public static bool IsMaterialChange(this EntrypointDrift drift)
|
||||
{
|
||||
return drift.HasFlag(EntrypointDrift.IntentChanged) ||
|
||||
drift.HasFlag(EntrypointDrift.FrameworkChanged) ||
|
||||
drift.IsRiskIncrease();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable description of the drift.
|
||||
/// </summary>
|
||||
public static string ToDescription(this EntrypointDrift drift)
|
||||
{
|
||||
if (drift == EntrypointDrift.None)
|
||||
return "No changes detected";
|
||||
|
||||
var descriptions = new List<string>();
|
||||
|
||||
if (drift.HasFlag(EntrypointDrift.IntentChanged))
|
||||
descriptions.Add("Application intent changed");
|
||||
if (drift.HasFlag(EntrypointDrift.CapabilitiesExpanded))
|
||||
descriptions.Add("Capabilities expanded");
|
||||
if (drift.HasFlag(EntrypointDrift.CapabilitiesReduced))
|
||||
descriptions.Add("Capabilities reduced");
|
||||
if (drift.HasFlag(EntrypointDrift.AttackSurfaceGrew))
|
||||
descriptions.Add("Attack surface increased");
|
||||
if (drift.HasFlag(EntrypointDrift.AttackSurfaceShrank))
|
||||
descriptions.Add("Attack surface decreased");
|
||||
if (drift.HasFlag(EntrypointDrift.FrameworkChanged))
|
||||
descriptions.Add("Framework changed");
|
||||
if (drift.HasFlag(EntrypointDrift.PrivilegeEscalation))
|
||||
descriptions.Add("Privilege escalation detected");
|
||||
if (drift.HasFlag(EntrypointDrift.PrivilegeReduction))
|
||||
descriptions.Add("Privilege reduced");
|
||||
if (drift.HasFlag(EntrypointDrift.PortsAdded))
|
||||
descriptions.Add("New ports exposed");
|
||||
if (drift.HasFlag(EntrypointDrift.PortsRemoved))
|
||||
descriptions.Add("Ports removed");
|
||||
if (drift.HasFlag(EntrypointDrift.EntrypointAdded))
|
||||
descriptions.Add("New entrypoint added");
|
||||
if (drift.HasFlag(EntrypointDrift.EntrypointRemoved))
|
||||
descriptions.Add("Entrypoint removed");
|
||||
|
||||
return string.Join("; ", descriptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace StellaOps.Scanner.EntryTrace.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for storing and retrieving temporal entrypoint graphs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task TEMP-005).
|
||||
/// </remarks>
|
||||
public interface ITemporalEntrypointStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the temporal graph for a service.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">The service identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The temporal graph, or null if not found.</returns>
|
||||
Task<TemporalEntrypointGraph?> GetGraphAsync(
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific snapshot for a service.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">The service identifier.</param>
|
||||
/// <param name="version">Version or image digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The snapshot, or null if not found.</returns>
|
||||
Task<EntrypointSnapshot?> GetSnapshotAsync(
|
||||
string serviceId,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a new snapshot for a service, updating the temporal graph.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">The service identifier.</param>
|
||||
/// <param name="snapshot">The snapshot to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated temporal graph with computed delta.</returns>
|
||||
Task<TemporalEntrypointGraph> StoreSnapshotAsync(
|
||||
string serviceId,
|
||||
EntrypointSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the delta between two versions.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">The service identifier.</param>
|
||||
/// <param name="fromVersion">The base version.</param>
|
||||
/// <param name="toVersion">The target version.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The delta, or null if versions not found.</returns>
|
||||
Task<EntrypointDelta?> ComputeDeltaAsync(
|
||||
string serviceId,
|
||||
string fromVersion,
|
||||
string toVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all services with temporal graphs.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Service identifiers.</returns>
|
||||
Task<IReadOnlyList<string>> ListServicesAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes old snapshots beyond retention limit.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">The service identifier.</param>
|
||||
/// <param name="keepCount">Number of recent snapshots to keep.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of snapshots deleted.</returns>
|
||||
Task<int> PruneSnapshotsAsync(
|
||||
string serviceId,
|
||||
int keepCount,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of temporal entrypoint store for testing and development.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task TEMP-006).
|
||||
/// </remarks>
|
||||
public sealed class InMemoryTemporalEntrypointStore : ITemporalEntrypointStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TemporalEntrypointGraph> _graphs = new();
|
||||
private readonly int _maxSnapshotsPerService;
|
||||
|
||||
public InMemoryTemporalEntrypointStore(int maxSnapshotsPerService = 100)
|
||||
{
|
||||
_maxSnapshotsPerService = maxSnapshotsPerService;
|
||||
}
|
||||
|
||||
public Task<TemporalEntrypointGraph?> GetGraphAsync(
|
||||
string serviceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_graphs.TryGetValue(serviceId, out var graph);
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<EntrypointSnapshot?> GetSnapshotAsync(
|
||||
string serviceId,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_graphs.TryGetValue(serviceId, out var graph))
|
||||
return Task.FromResult<EntrypointSnapshot?>(null);
|
||||
|
||||
var snapshot = graph.GetSnapshot(version);
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
public Task<TemporalEntrypointGraph> StoreSnapshotAsync(
|
||||
string serviceId,
|
||||
EntrypointSnapshot snapshot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var graph = _graphs.AddOrUpdate(
|
||||
serviceId,
|
||||
_ => CreateNewGraph(serviceId, snapshot),
|
||||
(_, existing) => UpdateGraph(existing, snapshot));
|
||||
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<EntrypointDelta?> ComputeDeltaAsync(
|
||||
string serviceId,
|
||||
string fromVersion,
|
||||
string toVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_graphs.TryGetValue(serviceId, out var graph))
|
||||
return Task.FromResult<EntrypointDelta?>(null);
|
||||
|
||||
var fromSnapshot = graph.GetSnapshot(fromVersion);
|
||||
var toSnapshot = graph.GetSnapshot(toVersion);
|
||||
|
||||
if (fromSnapshot is null || toSnapshot is null)
|
||||
return Task.FromResult<EntrypointDelta?>(null);
|
||||
|
||||
var delta = ComputeDelta(fromSnapshot, toSnapshot);
|
||||
return Task.FromResult<EntrypointDelta?>(delta);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> ListServicesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var services = _graphs.Keys.OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(services);
|
||||
}
|
||||
|
||||
public Task<int> PruneSnapshotsAsync(
|
||||
string serviceId,
|
||||
int keepCount,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_graphs.TryGetValue(serviceId, out var graph))
|
||||
return Task.FromResult(0);
|
||||
|
||||
if (graph.Snapshots.Length <= keepCount)
|
||||
return Task.FromResult(0);
|
||||
|
||||
var toRemove = graph.Snapshots.Length - keepCount;
|
||||
var prunedSnapshots = graph.Snapshots
|
||||
.Skip(toRemove)
|
||||
.ToImmutableArray();
|
||||
|
||||
var prunedGraph = graph with
|
||||
{
|
||||
Snapshots = prunedSnapshots,
|
||||
UpdatedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
_graphs[serviceId] = prunedGraph;
|
||||
return Task.FromResult(toRemove);
|
||||
}
|
||||
|
||||
private TemporalEntrypointGraph CreateNewGraph(string serviceId, EntrypointSnapshot snapshot)
|
||||
{
|
||||
return new TemporalEntrypointGraph
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
Snapshots = [snapshot],
|
||||
CurrentVersion = snapshot.Version,
|
||||
PreviousVersion = null,
|
||||
Delta = null,
|
||||
UpdatedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private TemporalEntrypointGraph UpdateGraph(
|
||||
TemporalEntrypointGraph existing,
|
||||
EntrypointSnapshot newSnapshot)
|
||||
{
|
||||
// Check if this version already exists
|
||||
var existingSnapshot = existing.GetSnapshot(newSnapshot.Version);
|
||||
if (existingSnapshot is not null)
|
||||
{
|
||||
// If content hash matches, no update needed
|
||||
if (existingSnapshot.ContentHash == newSnapshot.ContentHash)
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Find previous snapshot (the one before the new one)
|
||||
var previousSnapshot = existing.Snapshots.LastOrDefault();
|
||||
EntrypointDelta? delta = null;
|
||||
|
||||
if (previousSnapshot is not null)
|
||||
{
|
||||
delta = ComputeDelta(previousSnapshot, newSnapshot);
|
||||
}
|
||||
|
||||
// Add new snapshot, maintaining order
|
||||
var newSnapshots = existing.Snapshots
|
||||
.Where(s => s.ImageDigest != newSnapshot.ImageDigest)
|
||||
.Append(newSnapshot)
|
||||
.OrderBy(s => s.AnalyzedAt, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
// Prune if exceeds max
|
||||
if (newSnapshots.Length > _maxSnapshotsPerService)
|
||||
{
|
||||
newSnapshots = newSnapshots
|
||||
.Skip(newSnapshots.Length - _maxSnapshotsPerService)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
return existing with
|
||||
{
|
||||
Snapshots = newSnapshots,
|
||||
CurrentVersion = newSnapshot.Version,
|
||||
PreviousVersion = previousSnapshot?.Version,
|
||||
Delta = delta,
|
||||
UpdatedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointDelta ComputeDelta(
|
||||
EntrypointSnapshot from,
|
||||
EntrypointSnapshot to)
|
||||
{
|
||||
var fromIds = from.Entrypoints.Select(e => e.Id).ToHashSet();
|
||||
var toIds = to.Entrypoints.Select(e => e.Id).ToHashSet();
|
||||
|
||||
var addedIds = toIds.Except(fromIds);
|
||||
var removedIds = fromIds.Except(toIds);
|
||||
var commonIds = fromIds.Intersect(toIds);
|
||||
|
||||
var added = to.Entrypoints
|
||||
.Where(e => addedIds.Contains(e.Id))
|
||||
.ToImmutableArray();
|
||||
|
||||
var removed = from.Entrypoints
|
||||
.Where(e => removedIds.Contains(e.Id))
|
||||
.ToImmutableArray();
|
||||
|
||||
var modifications = new List<EntrypointModification>();
|
||||
var driftFlags = EntrypointDrift.None;
|
||||
|
||||
// Check for modifications in common entrypoints
|
||||
foreach (var id in commonIds)
|
||||
{
|
||||
var fromEntry = from.Entrypoints.First(e => e.Id == id);
|
||||
var toEntry = to.Entrypoints.First(e => e.Id == id);
|
||||
|
||||
var changes = DetectChanges(fromEntry, toEntry);
|
||||
if (changes != EntrypointDrift.None)
|
||||
{
|
||||
modifications.Add(new EntrypointModification
|
||||
{
|
||||
Before = fromEntry,
|
||||
After = toEntry,
|
||||
Changes = [changes],
|
||||
Description = changes.ToDescription()
|
||||
});
|
||||
driftFlags |= changes;
|
||||
}
|
||||
}
|
||||
|
||||
// Add drift for added/removed entrypoints
|
||||
if (added.Length > 0)
|
||||
driftFlags |= EntrypointDrift.EntrypointAdded;
|
||||
if (removed.Length > 0)
|
||||
driftFlags |= EntrypointDrift.EntrypointRemoved;
|
||||
|
||||
return new EntrypointDelta
|
||||
{
|
||||
FromVersion = from.Version,
|
||||
ToVersion = to.Version,
|
||||
FromDigest = from.ImageDigest,
|
||||
ToDigest = to.ImageDigest,
|
||||
AddedEntrypoints = added,
|
||||
RemovedEntrypoints = removed,
|
||||
ModifiedEntrypoints = modifications.ToImmutableArray(),
|
||||
DriftCategories = [driftFlags]
|
||||
};
|
||||
}
|
||||
|
||||
private static EntrypointDrift DetectChanges(
|
||||
SemanticEntrypoint from,
|
||||
SemanticEntrypoint to)
|
||||
{
|
||||
var drift = EntrypointDrift.None;
|
||||
|
||||
// Intent changed
|
||||
if (from.Intent != to.Intent)
|
||||
drift |= EntrypointDrift.IntentChanged;
|
||||
|
||||
// Capabilities changed
|
||||
if (from.Capabilities != to.Capabilities)
|
||||
{
|
||||
var added = to.Capabilities & ~from.Capabilities;
|
||||
var removed = from.Capabilities & ~to.Capabilities;
|
||||
|
||||
if (added != 0)
|
||||
drift |= EntrypointDrift.CapabilitiesExpanded;
|
||||
if (removed != 0)
|
||||
drift |= EntrypointDrift.CapabilitiesReduced;
|
||||
}
|
||||
|
||||
// Attack surface changed
|
||||
var fromVectors = from.AttackSurface.Select(v => v.Type).ToHashSet();
|
||||
var toVectors = to.AttackSurface.Select(v => v.Type).ToHashSet();
|
||||
|
||||
if (!toVectors.SetEquals(fromVectors))
|
||||
{
|
||||
if (toVectors.Except(fromVectors).Any())
|
||||
drift |= EntrypointDrift.AttackSurfaceGrew;
|
||||
if (fromVectors.Except(toVectors).Any())
|
||||
drift |= EntrypointDrift.AttackSurfaceShrank;
|
||||
}
|
||||
|
||||
// Framework changed
|
||||
if (from.Framework != to.Framework)
|
||||
drift |= EntrypointDrift.FrameworkChanged;
|
||||
|
||||
if (from.FrameworkVersion != to.FrameworkVersion)
|
||||
drift |= EntrypointDrift.FrameworkVersionChanged;
|
||||
|
||||
// Ports changed
|
||||
var fromPorts = from.Specification.ExposedPorts.ToHashSet();
|
||||
var toPorts = to.Specification.ExposedPorts.ToHashSet();
|
||||
|
||||
if (!toPorts.SetEquals(fromPorts))
|
||||
{
|
||||
drift |= EntrypointDrift.PortsChanged;
|
||||
if (toPorts.Except(fromPorts).Any())
|
||||
drift |= EntrypointDrift.PortsAdded;
|
||||
if (fromPorts.Except(toPorts).Any())
|
||||
drift |= EntrypointDrift.PortsRemoved;
|
||||
}
|
||||
|
||||
// Privilege changed
|
||||
var fromUser = from.Specification.User ?? "root";
|
||||
var toUser = to.Specification.User ?? "root";
|
||||
|
||||
if (fromUser != toUser)
|
||||
{
|
||||
var wasRoot = fromUser == "root" || fromUser == "0";
|
||||
var isRoot = toUser == "root" || toUser == "0";
|
||||
|
||||
if (!wasRoot && isRoot)
|
||||
drift |= EntrypointDrift.PrivilegeEscalation;
|
||||
if (wasRoot && !isRoot)
|
||||
drift |= EntrypointDrift.PrivilegeReduction;
|
||||
}
|
||||
|
||||
// Runtime version changed
|
||||
if (from.RuntimeVersion != to.RuntimeVersion)
|
||||
drift |= EntrypointDrift.RuntimeVersionChanged;
|
||||
|
||||
return drift;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content hash for a snapshot.
|
||||
/// </summary>
|
||||
public static string ComputeContentHash(EntrypointSnapshot snapshot)
|
||||
{
|
||||
// Deterministic serialization
|
||||
var content = new
|
||||
{
|
||||
snapshot.Version,
|
||||
snapshot.ImageDigest,
|
||||
Entrypoints = snapshot.Entrypoints
|
||||
.OrderBy(e => e.Id, StringComparer.Ordinal)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
Intent = e.Intent.ToString(),
|
||||
Capabilities = e.Capabilities.ToString(),
|
||||
e.Framework,
|
||||
e.FrameworkVersion,
|
||||
e.Language
|
||||
})
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks entrypoint evolution across image versions for a single service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task TEMP-001).
|
||||
/// Enables drift detection and version-to-version comparison of entrypoints.
|
||||
/// </remarks>
|
||||
public sealed record TemporalEntrypointGraph
|
||||
{
|
||||
/// <summary>Stable service identifier (e.g., "myapp-api").</summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>Ordered snapshots from oldest to newest.</summary>
|
||||
public required ImmutableArray<EntrypointSnapshot> Snapshots { get; init; }
|
||||
|
||||
/// <summary>Current version identifier (tag or digest short form).</summary>
|
||||
public required string CurrentVersion { get; init; }
|
||||
|
||||
/// <summary>Previous version identifier, if any.</summary>
|
||||
public string? PreviousVersion { get; init; }
|
||||
|
||||
/// <summary>Delta between current and previous version, if both exist.</summary>
|
||||
public EntrypointDelta? Delta { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the graph was last updated (UTC ISO-8601).</summary>
|
||||
public required string UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Additional metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes drift categories between current and previous version.
|
||||
/// </summary>
|
||||
public ImmutableArray<EntrypointDrift> ComputeDrift()
|
||||
{
|
||||
if (Delta is null)
|
||||
return ImmutableArray<EntrypointDrift>.Empty;
|
||||
|
||||
return Delta.DriftCategories;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the snapshot for a specific version.
|
||||
/// </summary>
|
||||
public EntrypointSnapshot? GetSnapshot(string version)
|
||||
{
|
||||
foreach (var snapshot in Snapshots)
|
||||
{
|
||||
if (snapshot.Version == version || snapshot.ImageDigest == version)
|
||||
return snapshot;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a builder for constructing a TemporalEntrypointGraph.
|
||||
/// </summary>
|
||||
public static TemporalEntrypointGraphBuilder CreateBuilder() => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time snapshot of entrypoints for a specific image version.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task TEMP-002).
|
||||
/// </remarks>
|
||||
public sealed record EntrypointSnapshot
|
||||
{
|
||||
/// <summary>Version identifier (image tag or short digest).</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Full image digest (sha256:...).</summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Image reference (registry/repo:tag).</summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>When this snapshot was analyzed (UTC ISO-8601).</summary>
|
||||
public required string AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>All semantic entrypoints discovered in this version.</summary>
|
||||
public required ImmutableArray<SemanticEntrypoint> Entrypoints { get; init; }
|
||||
|
||||
/// <summary>Content hash for fast comparison (BLAKE3 or SHA256).</summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>Total count of entrypoints.</summary>
|
||||
public int EntrypointCount => Entrypoints.Length;
|
||||
|
||||
/// <summary>Primary intent of the service (most common or primary entrypoint).</summary>
|
||||
public ApplicationIntent? PrimaryIntent { get; init; }
|
||||
|
||||
/// <summary>Combined capabilities across all entrypoints.</summary>
|
||||
public CapabilityClass CombinedCapabilities { get; init; }
|
||||
|
||||
/// <summary>Exposed ports across all entrypoints.</summary>
|
||||
public ImmutableArray<int> ExposedPorts { get; init; } = ImmutableArray<int>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the difference between two entrypoint snapshots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0412 - Temporal & Mesh Entrypoint (Task TEMP-003).
|
||||
/// </remarks>
|
||||
public sealed record EntrypointDelta
|
||||
{
|
||||
/// <summary>Version we're comparing from.</summary>
|
||||
public required string FromVersion { get; init; }
|
||||
|
||||
/// <summary>Version we're comparing to.</summary>
|
||||
public required string ToVersion { get; init; }
|
||||
|
||||
/// <summary>Image digest of the from version.</summary>
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>Image digest of the to version.</summary>
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>Entrypoints added in the new version.</summary>
|
||||
public required ImmutableArray<SemanticEntrypoint> AddedEntrypoints { get; init; }
|
||||
|
||||
/// <summary>Entrypoints removed in the new version.</summary>
|
||||
public required ImmutableArray<SemanticEntrypoint> RemovedEntrypoints { get; init; }
|
||||
|
||||
/// <summary>Entrypoints that changed between versions.</summary>
|
||||
public required ImmutableArray<EntrypointModification> ModifiedEntrypoints { get; init; }
|
||||
|
||||
/// <summary>Detected drift categories.</summary>
|
||||
public required ImmutableArray<EntrypointDrift> DriftCategories { get; init; }
|
||||
|
||||
/// <summary>Whether any material change was detected.</summary>
|
||||
public bool HasChanges => AddedEntrypoints.Length > 0 ||
|
||||
RemovedEntrypoints.Length > 0 ||
|
||||
ModifiedEntrypoints.Length > 0;
|
||||
|
||||
/// <summary>Whether the drift represents increased risk.</summary>
|
||||
public bool IsRiskIncrease => DriftCategories.Any(d =>
|
||||
d == EntrypointDrift.CapabilitiesExpanded ||
|
||||
d == EntrypointDrift.AttackSurfaceGrew ||
|
||||
d == EntrypointDrift.PrivilegeEscalation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes how a specific entrypoint changed between versions.
|
||||
/// </summary>
|
||||
public sealed record EntrypointModification
|
||||
{
|
||||
/// <summary>The entrypoint before modification.</summary>
|
||||
public required SemanticEntrypoint Before { get; init; }
|
||||
|
||||
/// <summary>The entrypoint after modification.</summary>
|
||||
public required SemanticEntrypoint After { get; init; }
|
||||
|
||||
/// <summary>Specific changes detected.</summary>
|
||||
public required ImmutableArray<EntrypointDrift> Changes { get; init; }
|
||||
|
||||
/// <summary>Human-readable description of the modification.</summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating TemporalEntrypointGraph instances.
|
||||
/// </summary>
|
||||
public sealed class TemporalEntrypointGraphBuilder
|
||||
{
|
||||
private string? _serviceId;
|
||||
private readonly List<EntrypointSnapshot> _snapshots = [];
|
||||
private string? _currentVersion;
|
||||
private string? _previousVersion;
|
||||
private EntrypointDelta? _delta;
|
||||
private readonly Dictionary<string, string> _metadata = [];
|
||||
|
||||
public TemporalEntrypointGraphBuilder WithServiceId(string serviceId)
|
||||
{
|
||||
_serviceId = serviceId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporalEntrypointGraphBuilder AddSnapshot(EntrypointSnapshot snapshot)
|
||||
{
|
||||
_snapshots.Add(snapshot);
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporalEntrypointGraphBuilder WithCurrentVersion(string version)
|
||||
{
|
||||
_currentVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporalEntrypointGraphBuilder WithPreviousVersion(string? version)
|
||||
{
|
||||
_previousVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporalEntrypointGraphBuilder WithDelta(EntrypointDelta? delta)
|
||||
{
|
||||
_delta = delta;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporalEntrypointGraphBuilder AddMetadata(string key, string value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TemporalEntrypointGraph Build()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_serviceId))
|
||||
throw new InvalidOperationException("ServiceId is required");
|
||||
if (string.IsNullOrEmpty(_currentVersion))
|
||||
throw new InvalidOperationException("CurrentVersion is required");
|
||||
|
||||
// Sort snapshots by AnalyzedAt for deterministic ordering
|
||||
var orderedSnapshots = _snapshots
|
||||
.OrderBy(s => s.AnalyzedAt, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new TemporalEntrypointGraph
|
||||
{
|
||||
ServiceId = _serviceId,
|
||||
Snapshots = orderedSnapshots,
|
||||
CurrentVersion = _currentVersion,
|
||||
PreviousVersion = _previousVersion,
|
||||
Delta = _delta,
|
||||
UpdatedAt = DateTime.UtcNow.ToString("O"),
|
||||
Metadata = _metadata.Count > 0
|
||||
? _metadata.ToImmutableDictionary()
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -221,8 +221,7 @@ public sealed class FileSurfaceManifestStore :
|
||||
}
|
||||
|
||||
var sorted = artifact.Metadata
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
|
||||
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
|
||||
|
||||
return NormalizeAttestations(artifact with { Metadata = sorted });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DockerComposeParser.
|
||||
/// Part of Sprint 0412 - Task TEST-003.
|
||||
/// </summary>
|
||||
public sealed class DockerComposeParserTests
|
||||
{
|
||||
private readonly DockerComposeParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void CanParse_DockerComposeYaml_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
Assert.True(_parser.CanParse("docker-compose.yaml"));
|
||||
Assert.True(_parser.CanParse("docker-compose.yml"));
|
||||
Assert.True(_parser.CanParse("compose.yaml"));
|
||||
Assert.True(_parser.CanParse("compose.yml"));
|
||||
Assert.True(_parser.CanParse("docker-compose.prod.yaml"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanParse_NonComposeYaml_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
""";
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(_parser.CanParse("deployment.yaml", content));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SimpleService_ExtractsService()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(MeshType.DockerCompose, graph.Type);
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("web", graph.Services[0].ServiceId);
|
||||
Assert.Equal("web", graph.Services[0].ContainerName);
|
||||
Assert.Single(graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(80, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MultipleServices_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
api:
|
||||
image: myapi:v1
|
||||
ports:
|
||||
- "8080:8080"
|
||||
db:
|
||||
image: postgres:15
|
||||
expose:
|
||||
- "5432"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, graph.Services.Length);
|
||||
Assert.Contains(graph.Services, s => s.ServiceId == "web");
|
||||
Assert.Contains(graph.Services, s => s.ServiceId == "api");
|
||||
Assert.Contains(graph.Services, s => s.ServiceId == "db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DependsOn_CreatesEdges()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
depends_on:
|
||||
- api
|
||||
api:
|
||||
image: myapi
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: postgres
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Edges.Length);
|
||||
Assert.Contains(graph.Edges, e => e.SourceServiceId == "web" && e.TargetServiceId == "api");
|
||||
Assert.Contains(graph.Edges, e => e.SourceServiceId == "api" && e.TargetServiceId == "db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Links_CreatesEdges()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
links:
|
||||
- api:backend
|
||||
api:
|
||||
image: myapi
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Edges);
|
||||
Assert.Equal("web", graph.Edges[0].SourceServiceId);
|
||||
Assert.Equal("api", graph.Edges[0].TargetServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_PortMappings_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
ports:
|
||||
- "80:8080"
|
||||
- "443:8443"
|
||||
- "9090:9090"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal(3, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Equal(3, graph.Services[0].PortMappings.Count);
|
||||
Assert.Equal(8080, graph.Services[0].PortMappings[80]);
|
||||
Assert.Equal(8443, graph.Services[0].PortMappings[443]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Expose_AddsToExposedPorts()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
expose:
|
||||
- "5432"
|
||||
- "5433"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Contains(5432, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(5433, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ContainerName_OverridesServiceName()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
container_name: my-web-container
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("web", graph.Services[0].ServiceId);
|
||||
Assert.Equal("my-web-container", graph.Services[0].ContainerName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_BuildContext_SetsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
build: ./app
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.StartsWith("build:", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_BuildWithContext_SetsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./myapp
|
||||
dockerfile: Dockerfile.prod
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.StartsWith("build:", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Labels_ExtractsLabels()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
labels:
|
||||
app: web
|
||||
env: production
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services[0].Labels.Count);
|
||||
Assert.Equal("web", graph.Services[0].Labels["app"]);
|
||||
Assert.Equal("production", graph.Services[0].Labels["env"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_LabelsListSyntax_ExtractsLabels()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
labels:
|
||||
- "app=web"
|
||||
- "env=production"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services[0].Labels.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Replicas_ExtractsReplicaCount()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
deploy:
|
||||
replicas: 5
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, graph.Services[0].Replicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_InferEdgesFromEnv_FindsServiceReferences()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
environment:
|
||||
- API_URL=http://api:8080
|
||||
api:
|
||||
image: myapi
|
||||
ports:
|
||||
- "8080:8080"
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { InferEdgesFromEnv = true };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(graph.Edges, e =>
|
||||
e.SourceServiceId == "web" &&
|
||||
e.TargetServiceId == "api" &&
|
||||
e.TargetPort == 8080);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_EnvironmentMappingSyntax_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
""";
|
||||
|
||||
// Act - Should not throw
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DependsOnExtendedSyntax_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
api:
|
||||
image: myapi
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Edges);
|
||||
Assert.Equal("api", graph.Edges[0].TargetServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_PortWithProtocol_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
dns:
|
||||
image: coredns
|
||||
ports:
|
||||
- "53:53/udp"
|
||||
- "53:53/tcp"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(53, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_LongPortSyntax_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- target: 80
|
||||
published: 8080
|
||||
protocol: tcp
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(80, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(8080, graph.Services[0].PortMappings.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Networks_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
frontend:
|
||||
driver: bridge
|
||||
backend:
|
||||
driver: bridge
|
||||
""";
|
||||
|
||||
// Act - Should not throw
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Volumes_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
""";
|
||||
|
||||
// Act - Should not throw
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_IngressPaths_CreatedFromPorts()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.IngressPaths.Length);
|
||||
Assert.All(graph.IngressPaths, p => Assert.Equal("localhost", p.Host));
|
||||
Assert.All(graph.IngressPaths, p => Assert.Equal("web", p.TargetServiceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ImageWithDigest_ExtractsDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
app:
|
||||
image: myapp@sha256:abcdef123456
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("sha256:abcdef123456", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_InternalDns_SetsServiceName()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
my-service:
|
||||
image: app
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services[0].InternalDns);
|
||||
Assert.Contains("my-service", graph.Services[0].InternalDns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseMultipleAsync_CombinesFiles()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>
|
||||
{
|
||||
["docker-compose.yaml"] = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
""",
|
||||
["docker-compose.override.yaml"] = """
|
||||
version: "3.8"
|
||||
services:
|
||||
api:
|
||||
image: myapi
|
||||
"""
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshType_IsDockerCompose()
|
||||
{
|
||||
Assert.Equal(MeshType.DockerCompose, _parser.MeshType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for KubernetesManifestParser.
|
||||
/// Part of Sprint 0412 - Task TEST-003.
|
||||
/// </summary>
|
||||
public sealed class KubernetesManifestParserTests
|
||||
{
|
||||
private readonly KubernetesManifestParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void CanParse_KubernetesYaml_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.CanParse("deployment.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanParse_NonKubernetesYaml_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.CanParse("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SimpleDeployment_ExtractsServices()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: default
|
||||
labels:
|
||||
app: my-app
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: myapp:v1.0.0@sha256:abc123def456
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- containerPort: 8443
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("deployment.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("default/my-app/app", graph.Services[0].ServiceId);
|
||||
Assert.Equal("sha256:abc123def456", graph.Services[0].ImageDigest);
|
||||
Assert.Equal(2, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Contains(8080, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(8443, graph.Services[0].ExposedPorts);
|
||||
Assert.Equal(3, graph.Services[0].Replicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Service_ExtractsServiceInfo()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: my-service
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
app: my-app
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("service.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(MeshType.Kubernetes, graph.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_IngressNetworkingV1_ExtractsIngress()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-ingress
|
||||
namespace: default
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
tls:
|
||||
- secretName: my-tls-secret
|
||||
rules:
|
||||
- host: api.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api-service
|
||||
port:
|
||||
number: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("ingress.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.IngressPaths);
|
||||
Assert.Equal("my-ingress", graph.IngressPaths[0].IngressName);
|
||||
Assert.Equal("api.example.com", graph.IngressPaths[0].Host);
|
||||
Assert.Equal("/api", graph.IngressPaths[0].Path);
|
||||
Assert.Equal("default/api-service", graph.IngressPaths[0].TargetServiceId);
|
||||
Assert.Equal(8080, graph.IngressPaths[0].TargetPort);
|
||||
Assert.True(graph.IngressPaths[0].TlsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MultiDocumentYaml_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: frontend:v1
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: backend:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("multi.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_NamespaceFilter_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-a
|
||||
namespace: production
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: a
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-b
|
||||
namespace: staging
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: b
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { Namespace = "production" };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("namespaced.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Contains("production", graph.Services[0].ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MultiplePorts_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: multi-port-app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: multi
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: server
|
||||
image: server:v1
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: http
|
||||
- containerPort: 443
|
||||
name: https
|
||||
- containerPort: 9090
|
||||
name: metrics
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("ports.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal(3, graph.Services[0].ExposedPorts.Length);
|
||||
Assert.Contains(80, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(443, graph.Services[0].ExposedPorts);
|
||||
Assert.Contains(9090, graph.Services[0].ExposedPorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_SidecarContainers_IncludesAll()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-with-sidecar
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: main:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- name: envoy-proxy
|
||||
image: envoy:v1
|
||||
ports:
|
||||
- containerPort: 15000
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { IncludeSidecars = true };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("sidecar.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
Assert.Contains(graph.Services, s => s.ContainerName == "main");
|
||||
Assert.Contains(graph.Services, s => s.ContainerName == "envoy-proxy");
|
||||
Assert.Contains(graph.Services, s => s.IsSidecar);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_StatefulSet_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: database
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: db
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:15
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("statefulset.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("default/database/postgres", graph.Services[0].ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_DaemonSet_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: log-collector
|
||||
namespace: kube-system
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: logs
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: fluentd
|
||||
image: fluentd:v1
|
||||
ports:
|
||||
- containerPort: 24224
|
||||
""";
|
||||
|
||||
var options = new ManifestParseOptions { Namespace = "kube-system" };
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("daemonset.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_Pod_Parses()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: debug-pod
|
||||
namespace: default
|
||||
labels:
|
||||
purpose: debug
|
||||
spec:
|
||||
containers:
|
||||
- name: shell
|
||||
image: busybox
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("pod.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Equal("default/debug-pod/shell", graph.Services[0].ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ImageWithoutDigest_UsesUnresolvedDigest()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: myapp:latest
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("tagonly.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.StartsWith("unresolved:", graph.Services[0].ImageDigest);
|
||||
Assert.Contains("myapp:latest", graph.Services[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseMultipleAsync_CombinesFiles()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>
|
||||
{
|
||||
["deploy.yaml"] = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""",
|
||||
["ingress.yaml"] = """
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: main
|
||||
namespace: default
|
||||
spec:
|
||||
rules:
|
||||
- host: app.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: app
|
||||
port:
|
||||
number: 8080
|
||||
"""
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
Assert.Single(graph.IngressPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_MalformedYaml_SkipsDocument()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
this is: [not valid: yaml
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: valid-app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: valid
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: valid:v1
|
||||
""";
|
||||
|
||||
// Act
|
||||
var graph = await _parser.ParseAsync("mixed.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(graph.Services);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MeshEntrypointAnalyzer.
|
||||
/// Part of Sprint 0412 - Task TEST-003.
|
||||
/// </summary>
|
||||
public sealed class MeshEntrypointAnalyzerTests
|
||||
{
|
||||
private readonly MeshEntrypointAnalyzer _analyzer = new();
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_KubernetesManifest_ProducesResult()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: webapp:v1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("deployment.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Graph);
|
||||
Assert.NotNull(result.Metrics);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.Single(result.Graph.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DockerCompose_ProducesResult()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
api:
|
||||
image: myapi
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: postgres
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Graph.Services.Length);
|
||||
Assert.Single(result.Graph.Edges);
|
||||
Assert.Equal(MeshType.DockerCompose, result.Graph.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_UnrecognizedFormat_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var content = "this is just plain text";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("unknown.txt", content);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Errors);
|
||||
Assert.Equal("MESH001", result.Errors[0].ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeMultipleAsync_MixedFormats_CombinesResults()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>
|
||||
{
|
||||
["k8s.yaml"] = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: k8s-app
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: k8s
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: k8sapp:v1
|
||||
""",
|
||||
["docker-compose.yaml"] = """
|
||||
version: "3.8"
|
||||
services:
|
||||
compose-app:
|
||||
image: composeapp:v1
|
||||
"""
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Graph.Services.Length);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_CalculatesSecurityMetrics()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
api:
|
||||
image: myapi
|
||||
depends_on:
|
||||
- web
|
||||
db:
|
||||
image: postgres
|
||||
depends_on:
|
||||
- api
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("docker-compose.yaml", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.Metrics.TotalServices);
|
||||
Assert.Equal(2, result.Metrics.TotalEdges);
|
||||
Assert.True(result.Metrics.ExposedServiceCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindVulnerablePaths_FindsPathsToTarget()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph();
|
||||
|
||||
// Act
|
||||
var paths = _analyzer.FindVulnerablePaths(graph, "db");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(paths);
|
||||
Assert.All(paths, p => Assert.Equal("db", p.TargetServiceId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindVulnerablePaths_RespectsMaxResults()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph();
|
||||
var criteria = new VulnerablePathCriteria { MaxResults = 1 };
|
||||
|
||||
// Act
|
||||
var paths = _analyzer.FindVulnerablePaths(graph, "db", criteria);
|
||||
|
||||
// Assert
|
||||
Assert.True(paths.Length <= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeBlastRadius_CalculatesReach()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph();
|
||||
|
||||
// Act
|
||||
var analysis = _analyzer.AnalyzeBlastRadius(graph, "api");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("api", analysis.CompromisedServiceId);
|
||||
Assert.Contains("db", analysis.DirectlyReachableServices);
|
||||
Assert.True(analysis.TotalReach >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeBlastRadius_DetectsIngressExposure()
|
||||
{
|
||||
// Arrange
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("web"),
|
||||
CreateServiceNode("api"),
|
||||
CreateServiceNode("db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("web", "api"),
|
||||
CreateEdge("api", "db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingress = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main",
|
||||
Host = "example.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "web",
|
||||
TargetPort = 80
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var analysis = _analyzer.AnalyzeBlastRadius(graph, "web");
|
||||
|
||||
// Assert
|
||||
Assert.Single(analysis.IngressExposure);
|
||||
Assert.True(analysis.Severity >= BlastRadiusSeverity.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeBlastRadius_IsolatedService_HasNoReach()
|
||||
{
|
||||
// Arrange
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("isolated"),
|
||||
CreateServiceNode("other")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.DockerCompose,
|
||||
Services = services,
|
||||
Edges = [],
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var analysis = _analyzer.AnalyzeBlastRadius(graph, "isolated");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, analysis.TotalReach);
|
||||
Assert.Equal(BlastRadiusSeverity.None, analysis.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithOptions_AppliesFilters()
|
||||
{
|
||||
// Arrange
|
||||
var content = """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app
|
||||
namespace: production
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: main
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: main
|
||||
image: app:v1
|
||||
""";
|
||||
|
||||
var options = new MeshAnalysisOptions
|
||||
{
|
||||
Namespace = "production",
|
||||
MeshId = "prod-mesh"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync("deploy.yaml", content, options);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("prod-mesh", result.Graph.MeshId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_EmptyManifests_ReturnsEmptyGraph()
|
||||
{
|
||||
// Arrange
|
||||
var manifests = new Dictionary<string, string>();
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeMultipleAsync(manifests);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Graph.Services);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlastRadiusSeverity_AllValuesDistinct()
|
||||
{
|
||||
// Assert
|
||||
var values = Enum.GetValues<BlastRadiusSeverity>();
|
||||
var distinctCount = values.Distinct().Count();
|
||||
Assert.Equal(values.Length, distinctCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshSecurityMetrics_CalculatesRatios()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new MeshSecurityMetrics
|
||||
{
|
||||
TotalServices = 10,
|
||||
TotalEdges = 15,
|
||||
ExposedServiceCount = 3,
|
||||
VulnerableServiceCount = 2,
|
||||
ExposureRatio = 0.3,
|
||||
VulnerableRatio = 0.2,
|
||||
OverallRiskScore = 45.0
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.3, metrics.ExposureRatio);
|
||||
Assert.Equal(0.2, metrics.VulnerableRatio);
|
||||
Assert.Equal(45.0, metrics.OverallRiskScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnerablePathCriteria_DefaultValues()
|
||||
{
|
||||
// Arrange
|
||||
var criteria = VulnerablePathCriteria.Default;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, criteria.MaxDepth);
|
||||
Assert.Equal(10, criteria.MaxResults);
|
||||
Assert.Equal(10, criteria.MinimumScore);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static MeshEntrypointGraph CreateTestGraph()
|
||||
{
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("web"),
|
||||
CreateServiceNode("api"),
|
||||
CreateServiceNode("db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("web", "api"),
|
||||
CreateEdge("api", "db")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingress = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main",
|
||||
Host = "example.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "web",
|
||||
TargetPort = 80
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
return new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
|
||||
private static ServiceNode CreateServiceNode(string serviceId)
|
||||
{
|
||||
return new ServiceNode
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
ContainerName = serviceId,
|
||||
ImageDigest = $"sha256:{serviceId}",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [8080]
|
||||
};
|
||||
}
|
||||
|
||||
private static CrossContainerEdge CreateEdge(string from, string to)
|
||||
{
|
||||
return new CrossContainerEdge
|
||||
{
|
||||
EdgeId = $"{from}->{to}",
|
||||
SourceServiceId = from,
|
||||
TargetServiceId = to,
|
||||
TargetPort = 8080
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Mesh;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Mesh;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MeshEntrypointGraph and related types.
|
||||
/// Part of Sprint 0412 - Task TEST-002.
|
||||
/// </summary>
|
||||
public sealed class MeshEntrypointGraphTests
|
||||
{
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test-mesh",
|
||||
Type = MeshType.Kubernetes,
|
||||
Namespace = "default",
|
||||
Services = CreateServiceNodes(3),
|
||||
Edges = [],
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-mesh", graph.MeshId);
|
||||
Assert.Equal(MeshType.Kubernetes, graph.Type);
|
||||
Assert.Equal("default", graph.Namespace);
|
||||
Assert.Equal(3, graph.Services.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_FindPathsToService_FindsDirectPath()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServiceNodes(3);
|
||||
var edges = new[]
|
||||
{
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "a->b",
|
||||
SourceServiceId = "svc-0",
|
||||
TargetServiceId = "svc-1",
|
||||
TargetPort = 8080
|
||||
},
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "b->c",
|
||||
SourceServiceId = "svc-1",
|
||||
TargetServiceId = "svc-2",
|
||||
TargetPort = 8080
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingressPaths = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main-ingress",
|
||||
Host = "example.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "svc-0",
|
||||
TargetPort = 8080
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingressPaths,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var paths = graph.FindPathsToService("svc-2", maxDepth: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Single(paths);
|
||||
Assert.Equal(2, paths[0].Hops.Length);
|
||||
Assert.True(paths[0].IsExternallyExposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_FindPathsToService_RespectsMaxDepth()
|
||||
{
|
||||
// Arrange - Long chain of services
|
||||
var services = CreateServiceNodes(10);
|
||||
var edges = new List<CrossContainerEdge>();
|
||||
for (var i = 0; i < 9; i++)
|
||||
{
|
||||
edges.Add(new CrossContainerEdge
|
||||
{
|
||||
EdgeId = $"svc-{i}->svc-{i + 1}",
|
||||
SourceServiceId = $"svc-{i}",
|
||||
TargetServiceId = $"svc-{i + 1}",
|
||||
TargetPort = 8080
|
||||
});
|
||||
}
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges.ToImmutableArray(),
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act - Limit depth to 3
|
||||
var paths = graph.FindPathsToService("svc-9", maxDepth: 3);
|
||||
|
||||
// Assert - Should not find path since it requires 9 hops
|
||||
Assert.Empty(paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_FindPathsToService_NoPathExists()
|
||||
{
|
||||
// Arrange - Disconnected services
|
||||
var services = CreateServiceNodes(2);
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "test",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = [],
|
||||
IngressPaths = [],
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var paths = graph.FindPathsToService("svc-1", maxDepth: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceNode_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var node = new ServiceNode
|
||||
{
|
||||
ServiceId = "my-service",
|
||||
ContainerName = "app",
|
||||
ImageDigest = "sha256:abc123",
|
||||
ImageReference = "myapp:v1.0.0",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [8080, 8443],
|
||||
InternalDns = ["my-service.default.svc.cluster.local"],
|
||||
Labels = new Dictionary<string, string> { ["app"] = "my-app" }.ToImmutableDictionary(),
|
||||
Replicas = 3
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-service", node.ServiceId);
|
||||
Assert.Equal("app", node.ContainerName);
|
||||
Assert.Equal(2, node.ExposedPorts.Length);
|
||||
Assert.Equal(3, node.Replicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossContainerEdge_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var edge = new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "frontend->backend",
|
||||
SourceServiceId = "frontend",
|
||||
TargetServiceId = "backend",
|
||||
SourcePort = 0,
|
||||
TargetPort = 8080,
|
||||
Protocol = "http",
|
||||
IsExplicit = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("frontend->backend", edge.EdgeId);
|
||||
Assert.Equal("frontend", edge.SourceServiceId);
|
||||
Assert.Equal("backend", edge.TargetServiceId);
|
||||
Assert.Equal(8080, edge.TargetPort);
|
||||
Assert.True(edge.IsExplicit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossContainerPath_TracksHops()
|
||||
{
|
||||
// Arrange
|
||||
var hops = new[]
|
||||
{
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "a->b",
|
||||
SourceServiceId = "a",
|
||||
TargetServiceId = "b",
|
||||
TargetPort = 8080
|
||||
},
|
||||
new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "b->c",
|
||||
SourceServiceId = "b",
|
||||
TargetServiceId = "c",
|
||||
TargetPort = 9090
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
// Act
|
||||
var path = new CrossContainerPath
|
||||
{
|
||||
PathId = "path-1",
|
||||
SourceServiceId = "a",
|
||||
TargetServiceId = "c",
|
||||
Hops = hops,
|
||||
IsExternallyExposed = true,
|
||||
VulnerableComponents = ["pkg:npm/lodash@4.17.20"],
|
||||
TotalLatencyEstimateMs = 10
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, path.Hops.Length);
|
||||
Assert.True(path.IsExternallyExposed);
|
||||
Assert.Single(path.VulnerableComponents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngressPath_TracksExternalExposure()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ingress = new IngressPath
|
||||
{
|
||||
IngressName = "main-ingress",
|
||||
Host = "api.example.com",
|
||||
Path = "/v1",
|
||||
TargetServiceId = "api-gateway",
|
||||
TargetPort = 8080,
|
||||
TlsEnabled = true,
|
||||
TlsSecretName = "api-tls-secret",
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["nginx.ingress.kubernetes.io/rewrite-target"] = "/"
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("main-ingress", ingress.IngressName);
|
||||
Assert.Equal("api.example.com", ingress.Host);
|
||||
Assert.True(ingress.TlsEnabled);
|
||||
Assert.NotNull(ingress.TlsSecretName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraphBuilder_BuildsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new MeshEntrypointGraphBuilder("test-mesh", MeshType.DockerCompose);
|
||||
|
||||
// Act
|
||||
var graph = builder
|
||||
.WithNamespace("my-project")
|
||||
.WithService(new ServiceNode
|
||||
{
|
||||
ServiceId = "web",
|
||||
ContainerName = "web",
|
||||
ImageDigest = "sha256:abc",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [80]
|
||||
})
|
||||
.WithService(new ServiceNode
|
||||
{
|
||||
ServiceId = "db",
|
||||
ContainerName = "db",
|
||||
ImageDigest = "sha256:def",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [5432]
|
||||
})
|
||||
.WithEdge(new CrossContainerEdge
|
||||
{
|
||||
EdgeId = "web->db",
|
||||
SourceServiceId = "web",
|
||||
TargetServiceId = "db",
|
||||
TargetPort = 5432
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-mesh", graph.MeshId);
|
||||
Assert.Equal(MeshType.DockerCompose, graph.Type);
|
||||
Assert.Equal(2, graph.Services.Length);
|
||||
Assert.Single(graph.Edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshType_AllValuesAreDistinct()
|
||||
{
|
||||
// Assert
|
||||
var values = Enum.GetValues<MeshType>();
|
||||
var distinctCount = values.Distinct().Count();
|
||||
Assert.Equal(values.Length, distinctCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeshEntrypointGraph_MultiplePaths_FindsAll()
|
||||
{
|
||||
// Arrange - Diamond pattern: A -> B -> D, A -> C -> D
|
||||
var services = new[]
|
||||
{
|
||||
CreateServiceNode("A"),
|
||||
CreateServiceNode("B"),
|
||||
CreateServiceNode("C"),
|
||||
CreateServiceNode("D")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
CreateEdge("A", "B"),
|
||||
CreateEdge("A", "C"),
|
||||
CreateEdge("B", "D"),
|
||||
CreateEdge("C", "D")
|
||||
}.ToImmutableArray();
|
||||
|
||||
var ingress = new[]
|
||||
{
|
||||
new IngressPath
|
||||
{
|
||||
IngressName = "main",
|
||||
Host = "test.com",
|
||||
Path = "/",
|
||||
TargetServiceId = "A",
|
||||
TargetPort = 80
|
||||
}
|
||||
}.ToImmutableArray();
|
||||
|
||||
var graph = new MeshEntrypointGraph
|
||||
{
|
||||
MeshId = "diamond",
|
||||
Type = MeshType.Kubernetes,
|
||||
Services = services,
|
||||
Edges = edges,
|
||||
IngressPaths = ingress,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
// Act
|
||||
var paths = graph.FindPathsToService("D", maxDepth: 5);
|
||||
|
||||
// Assert - Should find both paths: A->B->D and A->C->D
|
||||
Assert.Equal(2, paths.Length);
|
||||
Assert.All(paths, p => Assert.True(p.IsExternallyExposed));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ImmutableArray<ServiceNode> CreateServiceNodes(int count)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<ServiceNode>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
builder.Add(CreateServiceNode($"svc-{i}"));
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ServiceNode CreateServiceNode(string serviceId)
|
||||
{
|
||||
return new ServiceNode
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
ContainerName = serviceId,
|
||||
ImageDigest = $"sha256:{serviceId}",
|
||||
ImageReference = $"{serviceId}:latest",
|
||||
Entrypoints = [],
|
||||
ExposedPorts = [8080]
|
||||
};
|
||||
}
|
||||
|
||||
private static CrossContainerEdge CreateEdge(string from, string to)
|
||||
{
|
||||
return new CrossContainerEdge
|
||||
{
|
||||
EdgeId = $"{from}->{to}",
|
||||
SourceServiceId = from,
|
||||
TargetServiceId = to,
|
||||
TargetPort = 8080
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Temporal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for InMemoryTemporalEntrypointStore.
|
||||
/// Part of Sprint 0412 - Task TEST-001.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTemporalEntrypointStoreTests
|
||||
{
|
||||
private readonly InMemoryTemporalEntrypointStore _store = new();
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_StoresAndReturnsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateSnapshot("v1.0.0", "sha256:abc123", 2);
|
||||
|
||||
// Act
|
||||
var graph = await _store.StoreSnapshotAsync("my-service", snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal("my-service", graph.ServiceId);
|
||||
Assert.Single(graph.Snapshots);
|
||||
Assert.Equal("v1.0.0", graph.CurrentVersion);
|
||||
Assert.Null(graph.PreviousVersion);
|
||||
Assert.Null(graph.Delta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_MultipleVersions_CreatesDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc", 2);
|
||||
var snapshot2 = CreateSnapshot("v2.0.0", "sha256:def", 3);
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("my-service", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("my-service", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal(2, graph.Snapshots.Length);
|
||||
Assert.Equal("v2.0.0", graph.CurrentVersion);
|
||||
Assert.Equal("v1.0.0", graph.PreviousVersion);
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.Equal("v1.0.0", graph.Delta.FromVersion);
|
||||
Assert.Equal("v2.0.0", graph.Delta.ToVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGraphAsync_ReturnsStoredGraph()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateSnapshot("v1.0.0", "sha256:abc", 2);
|
||||
await _store.StoreSnapshotAsync("my-service", snapshot);
|
||||
|
||||
// Act
|
||||
var graph = await _store.GetGraphAsync("my-service");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal("my-service", graph.ServiceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGraphAsync_NonExistentService_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var graph = await _store.GetGraphAsync("non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(graph);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_CalculatesDifferences()
|
||||
{
|
||||
// Arrange
|
||||
var oldEntrypoints = CreateEntrypoints(2);
|
||||
var newEntrypoints = CreateEntrypoints(3);
|
||||
|
||||
var oldSnapshot = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.AddDays(-1).ToString("O"),
|
||||
Entrypoints = oldEntrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(oldEntrypoints)
|
||||
};
|
||||
|
||||
var newSnapshot = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = newEntrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(newEntrypoints)
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await _store.ComputeDeltaAsync(oldSnapshot, newSnapshot);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(delta);
|
||||
Assert.Equal("v1.0.0", delta.FromVersion);
|
||||
Assert.Equal("v2.0.0", delta.ToVersion);
|
||||
// Since we use different entrypoint IDs, all new ones are "added" and old ones "removed"
|
||||
Assert.True(delta.AddedEntrypoints.Length > 0 || delta.RemovedEntrypoints.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_SameContent_ReturnsNoDrift()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoints = CreateEntrypoints(2);
|
||||
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:same",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.1",
|
||||
ImageDigest = "sha256:same2",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = await _store.ComputeDeltaAsync(snapshot1, snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(delta);
|
||||
Assert.Empty(delta.AddedEntrypoints);
|
||||
Assert.Empty(delta.RemovedEntrypoints);
|
||||
Assert.Empty(delta.ModifiedEntrypoints);
|
||||
Assert.Equal(EntrypointDrift.None, delta.DriftCategories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneSnapshotsAsync_RemovesOldSnapshots()
|
||||
{
|
||||
// Arrange
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
var snapshot = CreateSnapshot($"v{i}.0.0", $"sha256:hash{i}", 2);
|
||||
await _store.StoreSnapshotAsync("my-service", snapshot);
|
||||
}
|
||||
|
||||
// Act - Keep only last 5
|
||||
var prunedCount = await _store.PruneSnapshotsAsync("my-service", keepCount: 5);
|
||||
var graph = await _store.GetGraphAsync("my-service");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, prunedCount);
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal(5, graph.Snapshots.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneSnapshotsAsync_NonExistentService_ReturnsZero()
|
||||
{
|
||||
// Act
|
||||
var prunedCount = await _store.PruneSnapshotsAsync("non-existent", keepCount: 5);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, prunedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_DetectsIntentChange()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash1"
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.Worker, // Changed!
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("svc", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.IntentChanged));
|
||||
Assert.Single(graph.Delta.ModifiedEntrypoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_DetectsCapabilitiesExpanded()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash1"
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener, CapabilityClass.FileSystemAccess], // Added!
|
||||
ThreatVectors = [],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("svc", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.CapabilitiesExpanded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSnapshotAsync_DetectsAttackSurfaceGrew()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:old",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure],
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash1"
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v2.0.0",
|
||||
ImageDigest = "sha256:new",
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints =
|
||||
[
|
||||
new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
FilePath = "/app/main.py",
|
||||
FunctionName = "handle",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure, ThreatVector.FilePathTraversal], // Added!
|
||||
Confidence = new SemanticConfidence { Overall = 0.9 }
|
||||
}
|
||||
],
|
||||
ContentHash = "hash2"
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.StoreSnapshotAsync("svc", snapshot1);
|
||||
var graph = await _store.StoreSnapshotAsync("svc", snapshot2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.AttackSurfaceGrew));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EntrypointSnapshot CreateSnapshot(string version, string digest, int entrypointCount)
|
||||
{
|
||||
var entrypoints = CreateEntrypoints(entrypointCount);
|
||||
return new EntrypointSnapshot
|
||||
{
|
||||
Version = version,
|
||||
ImageDigest = digest,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<SemanticEntrypoint> CreateEntrypoints(int count)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SemanticEntrypoint>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
builder.Add(new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = $"ep-{Guid.NewGuid():N}",
|
||||
FilePath = $"/app/handler{i}.py",
|
||||
FunctionName = $"handle_{i}",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure],
|
||||
Confidence = new SemanticConfidence
|
||||
{
|
||||
Overall = 0.9,
|
||||
IntentConfidence = 0.95,
|
||||
CapabilityConfidence = 0.85
|
||||
}
|
||||
});
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Temporal;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Temporal;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for TemporalEntrypointGraph and related types.
|
||||
/// Part of Sprint 0412 - Task TEST-001.
|
||||
/// </summary>
|
||||
public sealed class TemporalEntrypointGraphTests
|
||||
{
|
||||
[Fact]
|
||||
public void TemporalEntrypointGraph_Creation_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc123", 2);
|
||||
var snapshot2 = CreateSnapshot("v1.1.0", "sha256:def456", 3);
|
||||
|
||||
// Act
|
||||
var graph = new TemporalEntrypointGraph
|
||||
{
|
||||
ServiceId = "my-service",
|
||||
Snapshots = [snapshot1, snapshot2],
|
||||
CurrentVersion = "v1.1.0",
|
||||
PreviousVersion = "v1.0.0"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-service", graph.ServiceId);
|
||||
Assert.Equal(2, graph.Snapshots.Length);
|
||||
Assert.Equal("v1.1.0", graph.CurrentVersion);
|
||||
Assert.Equal("v1.0.0", graph.PreviousVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointSnapshot_ContentHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoints = CreateEntrypoints(3);
|
||||
|
||||
// Act
|
||||
var snapshot1 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:abc123",
|
||||
AnalyzedAt = "2025-01-01T00:00:00Z",
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
var snapshot2 = new EntrypointSnapshot
|
||||
{
|
||||
Version = "v1.0.0",
|
||||
ImageDigest = "sha256:abc123",
|
||||
AnalyzedAt = "2025-01-01T12:00:00Z", // Different time
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
|
||||
// Assert - Same content should produce same hash
|
||||
Assert.Equal(snapshot1.ContentHash, snapshot2.ContentHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointSnapshot_ContentHash_DiffersForDifferentContent()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoints1 = CreateEntrypoints(2);
|
||||
var entrypoints2 = CreateEntrypoints(3);
|
||||
|
||||
// Act
|
||||
var hash1 = EntrypointSnapshot.ComputeHash(entrypoints1);
|
||||
var hash2 = EntrypointSnapshot.ComputeHash(entrypoints2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDelta_TracksChanges()
|
||||
{
|
||||
// Arrange
|
||||
var added = CreateEntrypoints(1);
|
||||
var removed = CreateEntrypoints(1);
|
||||
var modified = new EntrypointModification
|
||||
{
|
||||
EntrypointId = "ep-1",
|
||||
OldIntent = ApplicationIntent.ApiEndpoint,
|
||||
NewIntent = ApplicationIntent.Worker,
|
||||
OldCapabilities = ImmutableArray<CapabilityClass>.Empty,
|
||||
NewCapabilities = [CapabilityClass.NetworkListener],
|
||||
Drift = EntrypointDrift.IntentChanged
|
||||
};
|
||||
|
||||
// Act
|
||||
var delta = new EntrypointDelta
|
||||
{
|
||||
FromVersion = "v1.0.0",
|
||||
ToVersion = "v2.0.0",
|
||||
FromDigest = "sha256:old",
|
||||
ToDigest = "sha256:new",
|
||||
AddedEntrypoints = added,
|
||||
RemovedEntrypoints = removed,
|
||||
ModifiedEntrypoints = [modified],
|
||||
DriftCategories = EntrypointDrift.IntentChanged
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, delta.AddedEntrypoints.Length);
|
||||
Assert.Equal(1, delta.RemovedEntrypoints.Length);
|
||||
Assert.Equal(1, delta.ModifiedEntrypoints.Length);
|
||||
Assert.True(delta.DriftCategories.HasFlag(EntrypointDrift.IntentChanged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalEntrypointGraphBuilder_BuildsGraph()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new TemporalEntrypointGraphBuilder("test-service");
|
||||
|
||||
var snapshot1 = CreateSnapshot("v1.0.0", "sha256:abc", 2);
|
||||
var snapshot2 = CreateSnapshot("v2.0.0", "sha256:def", 3);
|
||||
|
||||
// Act
|
||||
var graph = builder
|
||||
.WithSnapshot(snapshot1)
|
||||
.WithSnapshot(snapshot2)
|
||||
.WithCurrentVersion("v2.0.0")
|
||||
.WithPreviousVersion("v1.0.0")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-service", graph.ServiceId);
|
||||
Assert.Equal(2, graph.Snapshots.Length);
|
||||
Assert.Equal("v2.0.0", graph.CurrentVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_IsRiskIncrease_DetectsRiskyChanges()
|
||||
{
|
||||
// Arrange
|
||||
var riskIncrease = EntrypointDrift.AttackSurfaceGrew |
|
||||
EntrypointDrift.PrivilegeEscalation;
|
||||
|
||||
var riskDecrease = EntrypointDrift.AttackSurfaceShrank |
|
||||
EntrypointDrift.CapabilitiesReduced;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(riskIncrease.IsRiskIncrease());
|
||||
Assert.False(riskDecrease.IsRiskIncrease());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_IsMaterialChange_DetectsMaterialChanges()
|
||||
{
|
||||
// Arrange
|
||||
var material = EntrypointDrift.IntentChanged;
|
||||
var nonMaterial = EntrypointDrift.None;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(material.IsMaterialChange());
|
||||
Assert.False(nonMaterial.IsMaterialChange());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_ToDescription_FormatsCategories()
|
||||
{
|
||||
// Arrange
|
||||
var drift = EntrypointDrift.IntentChanged | EntrypointDrift.PortsAdded;
|
||||
|
||||
// Act
|
||||
var description = drift.ToDescription();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("IntentChanged", description);
|
||||
Assert.Contains("PortsAdded", description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointDrift_AllRiskFlags_AreConsistent()
|
||||
{
|
||||
// Arrange
|
||||
var allRisks = EntrypointDrift.AttackSurfaceGrew |
|
||||
EntrypointDrift.CapabilitiesExpanded |
|
||||
EntrypointDrift.PrivilegeEscalation |
|
||||
EntrypointDrift.PortsAdded |
|
||||
EntrypointDrift.SecurityContextWeakened |
|
||||
EntrypointDrift.NewVulnerableComponent |
|
||||
EntrypointDrift.ExposedToIngress;
|
||||
|
||||
// Act
|
||||
var isRisk = allRisks.IsRiskIncrease();
|
||||
|
||||
// Assert
|
||||
Assert.True(isRisk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointSnapshot_EmptyEntrypoints_ProducesValidHash()
|
||||
{
|
||||
// Arrange
|
||||
var emptyEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty;
|
||||
|
||||
// Act
|
||||
var hash = EntrypointSnapshot.ComputeHash(emptyEntrypoints);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(hash);
|
||||
Assert.NotEmpty(hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemporalEntrypointGraph_WithDelta_TracksVersionDiff()
|
||||
{
|
||||
// Arrange
|
||||
var oldEntrypoints = CreateEntrypoints(2);
|
||||
var newEntrypoints = CreateEntrypoints(3);
|
||||
|
||||
var delta = new EntrypointDelta
|
||||
{
|
||||
FromVersion = "v1",
|
||||
ToVersion = "v2",
|
||||
FromDigest = "sha256:old",
|
||||
ToDigest = "sha256:new",
|
||||
AddedEntrypoints = newEntrypoints.Skip(2).ToImmutableArray(),
|
||||
RemovedEntrypoints = ImmutableArray<SemanticEntrypoint>.Empty,
|
||||
ModifiedEntrypoints = ImmutableArray<EntrypointModification>.Empty,
|
||||
DriftCategories = EntrypointDrift.AttackSurfaceGrew
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = new TemporalEntrypointGraph
|
||||
{
|
||||
ServiceId = "svc",
|
||||
Snapshots = [],
|
||||
CurrentVersion = "v2",
|
||||
PreviousVersion = "v1",
|
||||
Delta = delta
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(graph.Delta);
|
||||
Assert.Equal("v1", graph.Delta.FromVersion);
|
||||
Assert.Equal("v2", graph.Delta.ToVersion);
|
||||
Assert.True(graph.Delta.DriftCategories.HasFlag(EntrypointDrift.AttackSurfaceGrew));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EntrypointSnapshot CreateSnapshot(string version, string digest, int entrypointCount)
|
||||
{
|
||||
var entrypoints = CreateEntrypoints(entrypointCount);
|
||||
return new EntrypointSnapshot
|
||||
{
|
||||
Version = version,
|
||||
ImageDigest = digest,
|
||||
AnalyzedAt = DateTime.UtcNow.ToString("O"),
|
||||
Entrypoints = entrypoints,
|
||||
ContentHash = EntrypointSnapshot.ComputeHash(entrypoints)
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<SemanticEntrypoint> CreateEntrypoints(int count)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SemanticEntrypoint>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
builder.Add(new SemanticEntrypoint
|
||||
{
|
||||
EntrypointId = $"ep-{i}",
|
||||
FilePath = $"/app/handler{i}.py",
|
||||
FunctionName = $"handle_{i}",
|
||||
Intent = ApplicationIntent.ApiEndpoint,
|
||||
Capabilities = [CapabilityClass.NetworkListener],
|
||||
ThreatVectors = [ThreatVector.NetworkExposure],
|
||||
Confidence = new SemanticConfidence
|
||||
{
|
||||
Overall = 0.9,
|
||||
IntentConfidence = 0.95,
|
||||
CapabilityConfidence = 0.85
|
||||
}
|
||||
});
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -228,6 +228,29 @@ public sealed class FileSurfaceManifestStoreTests : IAsyncDisposable
|
||||
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Purpose-based methods (delegate to algorithm-based methods for test purposes)
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHash(data);
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashHex(data);
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashBase64(data);
|
||||
|
||||
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashAsync(stream, null, cancellationToken);
|
||||
|
||||
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashHexAsync(stream, null, cancellationToken);
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose) => "SHA-256";
|
||||
|
||||
public string GetHashPrefix(string purpose) => "sha256:";
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> $"{GetHashPrefix(purpose)}{ComputeHashHex(data)}";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
Reference in New Issue
Block a user