634 lines
20 KiB
C#
634 lines
20 KiB
C#
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
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", CultureInfo.InvariantCulture)
|
|
};
|
|
}
|
|
|
|
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", CultureInfo.InvariantCulture)
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|