save progress
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user