tests fixes and sprints work
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Conditional reachability analysis that tags paths gated by optional scopes or SBOM conditions.
|
||||
/// </summary>
|
||||
public sealed class ConditionalReachabilityAnalyzer
|
||||
{
|
||||
private static readonly string[] ConditionPropertyKeys =
|
||||
[
|
||||
"stellaops.reachability.condition",
|
||||
"stellaops.reachability.conditions"
|
||||
];
|
||||
|
||||
private static readonly char[] ConditionSeparators = [',', ';'];
|
||||
private const string OptionalDependencyCondition = "dependency.scope.optional";
|
||||
private const string OptionalComponentCondition = "component.scope.optional";
|
||||
|
||||
public ReachabilityReport Analyze(
|
||||
DependencyGraph graph,
|
||||
ParsedSbom sbom,
|
||||
ImmutableArray<string> entryPoints,
|
||||
ReachabilityPolicy? policy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
|
||||
var resolvedPolicy = policy ?? new ReachabilityPolicy();
|
||||
var optionalHandling = resolvedPolicy.ScopeHandling.IncludeOptional;
|
||||
var includeOptionalCondition =
|
||||
optionalHandling == OptionalDependencyHandling.AsPotentiallyReachable;
|
||||
var componentConditions = BuildComponentConditions(sbom, includeOptionalCondition);
|
||||
|
||||
var normalizedEntryPoints = NormalizeEntryPoints(entryPoints);
|
||||
var results = new Dictionary<string, ReachabilityStatus>(StringComparer.Ordinal);
|
||||
var findings = new List<ReachabilityFinding>();
|
||||
|
||||
if (normalizedEntryPoints.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
results[node] = ReachabilityStatus.Unknown;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.Unknown,
|
||||
Reason = "no-entrypoints"
|
||||
});
|
||||
}
|
||||
|
||||
return ReachabilityReportBuilder.Build(graph, results, findings);
|
||||
}
|
||||
|
||||
var predecessor = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var pathKind = new Dictionary<string, PathKind>(StringComparer.Ordinal);
|
||||
var pathConditions = new Dictionary<string, ImmutableArray<string>>(StringComparer.Ordinal);
|
||||
var queue = new Queue<TraversalState>();
|
||||
|
||||
foreach (var entryPoint in normalizedEntryPoints)
|
||||
{
|
||||
var entryConditions = componentConditions.TryGetValue(entryPoint, out var conditions)
|
||||
? conditions
|
||||
: ImmutableArray<string>.Empty;
|
||||
var kind = entryConditions.IsDefaultOrEmpty ? PathKind.Required : PathKind.Conditional;
|
||||
|
||||
if (!pathKind.TryAdd(entryPoint, kind))
|
||||
{
|
||||
if (pathKind[entryPoint] == PathKind.Conditional && kind == PathKind.Required)
|
||||
{
|
||||
pathKind[entryPoint] = PathKind.Required;
|
||||
pathConditions.Remove(entryPoint);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
predecessor[entryPoint] = null;
|
||||
if (!entryConditions.IsDefaultOrEmpty)
|
||||
{
|
||||
pathConditions[entryPoint] = entryConditions;
|
||||
}
|
||||
|
||||
queue.Enqueue(new TraversalState(entryPoint, kind, entryConditions));
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var state = queue.Dequeue();
|
||||
if (!graph.Edges.TryGetValue(state.Node, out var edges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!IsScopeIncluded(edge.Scope, resolvedPolicy.ScopeHandling))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var edgeConditions = BuildEdgeConditions(
|
||||
edge,
|
||||
componentConditions,
|
||||
optionalHandling);
|
||||
var mergedConditions = MergeConditions(state.Conditions, edgeConditions);
|
||||
var nextKind = mergedConditions.IsDefaultOrEmpty
|
||||
? PathKind.Required
|
||||
: PathKind.Conditional;
|
||||
var target = edge.To;
|
||||
|
||||
if (nextKind == PathKind.Required)
|
||||
{
|
||||
if (!pathKind.TryGetValue(target, out var existingKind))
|
||||
{
|
||||
predecessor[target] = state.Node;
|
||||
pathKind[target] = PathKind.Required;
|
||||
queue.Enqueue(new TraversalState(target, PathKind.Required, []));
|
||||
}
|
||||
else if (existingKind == PathKind.Conditional)
|
||||
{
|
||||
predecessor[target] = state.Node;
|
||||
pathKind[target] = PathKind.Required;
|
||||
pathConditions.Remove(target);
|
||||
queue.Enqueue(new TraversalState(target, PathKind.Required, []));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pathKind.ContainsKey(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
predecessor[target] = state.Node;
|
||||
pathKind[target] = PathKind.Conditional;
|
||||
pathConditions[target] = mergedConditions;
|
||||
queue.Enqueue(new TraversalState(target, PathKind.Conditional, mergedConditions));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (pathKind.TryGetValue(node, out var kind))
|
||||
{
|
||||
if (kind == PathKind.Required)
|
||||
{
|
||||
results[node] = ReachabilityStatus.Reachable;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.Reachable,
|
||||
Path = BuildPath(node, predecessor)
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
results[node] = ReachabilityStatus.PotentiallyReachable;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.PotentiallyReachable,
|
||||
Path = BuildPath(node, predecessor),
|
||||
Conditions = pathConditions.TryGetValue(node, out var conditions)
|
||||
? conditions
|
||||
: [],
|
||||
Reason = "conditional-path"
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
results[node] = ReachabilityStatus.Unreachable;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.Unreachable,
|
||||
Reason = "no-path"
|
||||
});
|
||||
}
|
||||
|
||||
return ReachabilityReportBuilder.Build(graph, results, findings);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeEntryPoints(ImmutableArray<string> entryPoints)
|
||||
{
|
||||
if (entryPoints.IsDefaultOrEmpty)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return entryPoints
|
||||
.Select(NormalizeRef)
|
||||
.Where(value => value is not null)
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, ImmutableArray<string>> BuildComponentConditions(
|
||||
ParsedSbom sbom,
|
||||
bool includeOptionalCondition)
|
||||
{
|
||||
if (sbom.Components.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableDictionary<string, ImmutableArray<string>>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, ImmutableArray<string>>(
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
var bomRef = NormalizeRef(component.BomRef);
|
||||
if (bomRef is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var conditions = new List<string>();
|
||||
if (includeOptionalCondition && component.Scope == ComponentScope.Optional)
|
||||
{
|
||||
conditions.Add(OptionalComponentCondition);
|
||||
}
|
||||
|
||||
AppendPropertyConditions(conditions, component.Properties);
|
||||
|
||||
if (conditions.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[bomRef] = NormalizeConditions(conditions);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static void AppendPropertyConditions(
|
||||
List<string> conditions,
|
||||
ImmutableDictionary<string, string> properties)
|
||||
{
|
||||
if (properties is null || properties.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var key in ConditionPropertyKeys)
|
||||
{
|
||||
if (!properties.TryGetValue(key, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AppendConditions(conditions, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendConditions(List<string> conditions, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tokens = value.Split(
|
||||
ConditionSeparators,
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
conditions.Add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildEdgeConditions(
|
||||
DependencyEdge edge,
|
||||
ImmutableDictionary<string, ImmutableArray<string>> componentConditions,
|
||||
OptionalDependencyHandling optionalHandling)
|
||||
{
|
||||
var conditions = new List<string>();
|
||||
|
||||
if (edge.Scope == DependencyScope.Optional &&
|
||||
optionalHandling == OptionalDependencyHandling.AsPotentiallyReachable)
|
||||
{
|
||||
conditions.Add(OptionalDependencyCondition);
|
||||
}
|
||||
|
||||
if (componentConditions.TryGetValue(edge.To, out var targetConditions))
|
||||
{
|
||||
conditions.AddRange(targetConditions);
|
||||
}
|
||||
|
||||
return NormalizeConditions(conditions);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> MergeConditions(
|
||||
ImmutableArray<string> current,
|
||||
ImmutableArray<string> next)
|
||||
{
|
||||
if (current.IsDefaultOrEmpty)
|
||||
{
|
||||
return next;
|
||||
}
|
||||
|
||||
if (next.IsDefaultOrEmpty)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return NormalizeConditions(current.Concat(next));
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeConditions(IEnumerable<string> conditions)
|
||||
{
|
||||
return conditions
|
||||
.Select(value => value?.Trim())
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildPath(
|
||||
string target,
|
||||
IReadOnlyDictionary<string, string?> predecessor)
|
||||
{
|
||||
var path = new List<string>();
|
||||
var current = target;
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (!seen.Add(current))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
path.Add(current);
|
||||
|
||||
if (!predecessor.TryGetValue(current, out var previous) ||
|
||||
string.IsNullOrWhiteSpace(previous))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = previous;
|
||||
}
|
||||
|
||||
path.Reverse();
|
||||
return path.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsScopeIncluded(DependencyScope scope, ReachabilityScopePolicy policy)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
DependencyScope.Runtime => policy.IncludeRuntime,
|
||||
DependencyScope.Development => policy.IncludeDevelopment,
|
||||
DependencyScope.Test => policy.IncludeTest,
|
||||
DependencyScope.Optional => policy.IncludeOptional != OptionalDependencyHandling.Exclude,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeRef(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private sealed record TraversalState(
|
||||
string Node,
|
||||
PathKind Kind,
|
||||
ImmutableArray<string> Conditions);
|
||||
|
||||
private enum PathKind
|
||||
{
|
||||
Required,
|
||||
Conditional
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Builds adjacency-list dependency graphs from ParsedSbom dependencies.
|
||||
/// </summary>
|
||||
public sealed class DependencyGraphBuilder
|
||||
{
|
||||
public DependencyGraph Build(ParsedSbom sbom)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
|
||||
var nodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var incoming = new HashSet<string>(StringComparer.Ordinal);
|
||||
var edges = new Dictionary<string, List<DependencyEdge>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
AddNode(nodes, component.BomRef);
|
||||
}
|
||||
|
||||
foreach (var dependency in sbom.Dependencies)
|
||||
{
|
||||
var source = NormalizeRef(dependency.SourceRef);
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddNode(nodes, source);
|
||||
|
||||
if (dependency.DependsOn.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var targetRef in dependency.DependsOn)
|
||||
{
|
||||
var target = NormalizeRef(targetRef);
|
||||
if (target is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddNode(nodes, target);
|
||||
incoming.Add(target);
|
||||
|
||||
if (!edges.TryGetValue(source, out var list))
|
||||
{
|
||||
list = new List<DependencyEdge>();
|
||||
edges[source] = list;
|
||||
}
|
||||
|
||||
list.Add(new DependencyEdge
|
||||
{
|
||||
From = source,
|
||||
To = target,
|
||||
Scope = dependency.Scope
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var roots = new HashSet<string>(StringComparer.Ordinal);
|
||||
var metadataRoot = NormalizeRef(sbom.Metadata.RootComponentRef);
|
||||
if (metadataRoot is not null)
|
||||
{
|
||||
AddNode(nodes, metadataRoot);
|
||||
roots.Add(metadataRoot);
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (!incoming.Contains(node))
|
||||
{
|
||||
roots.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
var edgeMap = edges
|
||||
.ToImmutableDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value
|
||||
.Distinct()
|
||||
.OrderBy(edge => edge.To, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.Scope)
|
||||
.ToImmutableArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return new DependencyGraph
|
||||
{
|
||||
Nodes = nodes
|
||||
.OrderBy(node => node, StringComparer.Ordinal)
|
||||
.ToImmutableArray(),
|
||||
Edges = edgeMap,
|
||||
Roots = roots
|
||||
.OrderBy(root => root, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddNode(HashSet<string> nodes, string? value)
|
||||
{
|
||||
var normalized = NormalizeRef(value);
|
||||
if (normalized is not null)
|
||||
{
|
||||
nodes.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeRef(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Infers component reachability using SBOM dependency graphs.
|
||||
/// </summary>
|
||||
public interface IReachabilityInferrer
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes reachability for every component in the SBOM.
|
||||
/// </summary>
|
||||
Task<ReachabilityReport> InferAsync(
|
||||
ParsedSbom sbom,
|
||||
ReachabilityPolicy policy,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Computes reachability for a single component PURL.
|
||||
/// </summary>
|
||||
Task<ComponentReachability> CheckComponentReachabilityAsync(
|
||||
string componentPurl,
|
||||
ParsedSbom sbom,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability inference result.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityReport
|
||||
{
|
||||
public required DependencyGraph Graph { get; init; }
|
||||
public ImmutableDictionary<string, ReachabilityStatus> ComponentReachability { get; init; } =
|
||||
ImmutableDictionary<string, ReachabilityStatus>.Empty;
|
||||
public ImmutableArray<ReachabilityFinding> Findings { get; init; } = [];
|
||||
public required ReachabilityStatistics Statistics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state for a component.
|
||||
/// </summary>
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
Reachable,
|
||||
PotentiallyReachable,
|
||||
Unreachable,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate reachability statistics for a scan.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityStatistics
|
||||
{
|
||||
public int TotalComponents { get; init; }
|
||||
public int ReachableComponents { get; init; }
|
||||
public int UnreachableComponents { get; init; }
|
||||
public int UnknownComponents { get; init; }
|
||||
public double VulnerabilityReductionPercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability finding for a component.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFinding
|
||||
{
|
||||
public required string ComponentRef { get; init; }
|
||||
public required ReachabilityStatus Status { get; init; }
|
||||
public ImmutableArray<string> Path { get; init; } = [];
|
||||
public ImmutableArray<string> Conditions { get; init; } = [];
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status for a single component lookup.
|
||||
/// </summary>
|
||||
public sealed record ComponentReachability
|
||||
{
|
||||
public required string ComponentRef { get; init; }
|
||||
public ReachabilityStatus Status { get; init; }
|
||||
public ImmutableArray<string> Path { get; init; } = [];
|
||||
public ImmutableArray<string> Conditions { get; init; } = [];
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dependency graph with adjacency list edges.
|
||||
/// </summary>
|
||||
public sealed record DependencyGraph
|
||||
{
|
||||
public ImmutableArray<string> Nodes { get; init; } = [];
|
||||
public ImmutableDictionary<string, ImmutableArray<DependencyEdge>> Edges { get; init; } =
|
||||
ImmutableDictionary<string, ImmutableArray<DependencyEdge>>.Empty;
|
||||
public ImmutableArray<string> Roots { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directed dependency edge between components.
|
||||
/// </summary>
|
||||
public sealed record DependencyEdge
|
||||
{
|
||||
public required string From { get; init; }
|
||||
public required string To { get; init; }
|
||||
public DependencyScope Scope { get; init; } = DependencyScope.Runtime;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Determines entry points from ParsedSbom metadata and component types.
|
||||
/// </summary>
|
||||
public sealed class EntryPointDetector
|
||||
{
|
||||
public ImmutableArray<string> DetectEntryPoints(
|
||||
ParsedSbom sbom,
|
||||
ReachabilityPolicy? policy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
|
||||
var entryPoints = new HashSet<string>(StringComparer.Ordinal);
|
||||
var entryPolicy = policy?.EntryPoints ?? new ReachabilityEntryPointPolicy();
|
||||
|
||||
foreach (var explicitEntry in entryPolicy.Additional)
|
||||
{
|
||||
AddEntry(entryPoints, explicitEntry);
|
||||
}
|
||||
|
||||
if (entryPolicy.DetectFromSbom)
|
||||
{
|
||||
AddEntry(entryPoints, sbom.Metadata.RootComponentRef);
|
||||
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
if (IsApplicationComponent(component))
|
||||
{
|
||||
AddEntry(entryPoints, component.BomRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entryPoints.Count == 0)
|
||||
{
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
AddEntry(entryPoints, component.BomRef);
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints
|
||||
.OrderBy(entry => entry, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static void AddEntry(HashSet<string> entryPoints, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entryPoints.Add(value.Trim());
|
||||
}
|
||||
|
||||
private static bool IsApplicationComponent(ParsedComponent component)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component.Type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return component.Type.Contains("application", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Combines SBOM dependency reachability with reachgraph call analysis.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphReachabilityCombiner
|
||||
{
|
||||
private readonly DependencyGraphBuilder _graphBuilder = new();
|
||||
private readonly EntryPointDetector _entryPointDetector = new();
|
||||
private readonly ConditionalReachabilityAnalyzer _conditionalAnalyzer = new();
|
||||
private readonly CallGraphReachabilityAnalyzer _callGraphAnalyzer = new();
|
||||
|
||||
public ReachabilityReport Analyze(
|
||||
ParsedSbom sbom,
|
||||
RichGraph? callGraph,
|
||||
ReachabilityPolicy? policy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
|
||||
var resolvedPolicy = policy ?? new ReachabilityPolicy();
|
||||
var dependencyGraph = _graphBuilder.Build(sbom);
|
||||
var entryPoints = _entryPointDetector.DetectEntryPoints(sbom, resolvedPolicy);
|
||||
var sbomReport = _conditionalAnalyzer.Analyze(
|
||||
dependencyGraph,
|
||||
sbom,
|
||||
entryPoints,
|
||||
resolvedPolicy);
|
||||
|
||||
return Combine(sbomReport, sbom, callGraph, resolvedPolicy);
|
||||
}
|
||||
|
||||
public ReachabilityReport Combine(
|
||||
ReachabilityReport sbomReport,
|
||||
ParsedSbom sbom,
|
||||
RichGraph? callGraph,
|
||||
ReachabilityPolicy? policy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbomReport);
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
|
||||
var resolvedPolicy = policy ?? new ReachabilityPolicy();
|
||||
if (resolvedPolicy.AnalysisMode == ReachabilityAnalysisMode.SbomOnly || callGraph is null)
|
||||
{
|
||||
return sbomReport;
|
||||
}
|
||||
|
||||
var callGraphResult = _callGraphAnalyzer.Analyze(callGraph);
|
||||
if (!callGraphResult.HasEntrypoints || callGraphResult.PurlReachability.Count == 0)
|
||||
{
|
||||
return sbomReport;
|
||||
}
|
||||
|
||||
var componentPurls = BuildComponentPurlLookup(sbom);
|
||||
var sbomFindings = sbomReport.Findings.ToDictionary(
|
||||
finding => finding.ComponentRef,
|
||||
StringComparer.Ordinal);
|
||||
var combinedResults = new Dictionary<string, ReachabilityStatus>(StringComparer.Ordinal);
|
||||
var combinedFindings = new List<ReachabilityFinding>(sbomReport.ComponentReachability.Count);
|
||||
|
||||
foreach (var entry in sbomReport.ComponentReachability)
|
||||
{
|
||||
var componentRef = entry.Key;
|
||||
var sbomStatus = entry.Value;
|
||||
var callStatus = ResolveCallGraphStatus(
|
||||
componentRef,
|
||||
componentPurls,
|
||||
callGraphResult);
|
||||
var combinedStatus = CombineStatus(
|
||||
sbomStatus,
|
||||
callStatus,
|
||||
resolvedPolicy.AnalysisMode);
|
||||
|
||||
combinedResults[componentRef] = combinedStatus;
|
||||
combinedFindings.Add(BuildFinding(
|
||||
componentRef,
|
||||
sbomStatus,
|
||||
callStatus,
|
||||
combinedStatus,
|
||||
sbomFindings.TryGetValue(componentRef, out var baseFinding) ? baseFinding : null,
|
||||
resolvedPolicy.AnalysisMode,
|
||||
resolvedPolicy.Reporting));
|
||||
}
|
||||
|
||||
return ReachabilityReportBuilder.Build(sbomReport.Graph, combinedResults, combinedFindings);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildComponentPurlLookup(ParsedSbom sbom)
|
||||
{
|
||||
var lookup = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component.BomRef) ||
|
||||
string.IsNullOrWhiteSpace(component.Purl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lookup[component.BomRef] = component.Purl!;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static ReachabilityStatus ResolveCallGraphStatus(
|
||||
string componentRef,
|
||||
IReadOnlyDictionary<string, string> componentPurls,
|
||||
CallGraphReachabilityResult callGraphResult)
|
||||
{
|
||||
if (!componentPurls.TryGetValue(componentRef, out var purl))
|
||||
{
|
||||
return ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
return callGraphResult.PurlReachability.TryGetValue(purl, out var status)
|
||||
? status
|
||||
: ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
private static ReachabilityStatus CombineStatus(
|
||||
ReachabilityStatus sbomStatus,
|
||||
ReachabilityStatus callStatus,
|
||||
ReachabilityAnalysisMode mode)
|
||||
{
|
||||
if (mode == ReachabilityAnalysisMode.CallGraph)
|
||||
{
|
||||
return callStatus;
|
||||
}
|
||||
|
||||
if (sbomStatus == ReachabilityStatus.Unreachable)
|
||||
{
|
||||
return ReachabilityStatus.Unreachable;
|
||||
}
|
||||
|
||||
return callStatus switch
|
||||
{
|
||||
ReachabilityStatus.Reachable => ReachabilityStatus.Reachable,
|
||||
ReachabilityStatus.Unreachable => ReachabilityStatus.Unreachable,
|
||||
_ => sbomStatus
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityFinding BuildFinding(
|
||||
string componentRef,
|
||||
ReachabilityStatus sbomStatus,
|
||||
ReachabilityStatus callStatus,
|
||||
ReachabilityStatus combinedStatus,
|
||||
ReachabilityFinding? baseFinding,
|
||||
ReachabilityAnalysisMode mode,
|
||||
ReachabilityReportingPolicy reporting)
|
||||
{
|
||||
var reason = baseFinding?.Reason;
|
||||
|
||||
if (mode != ReachabilityAnalysisMode.SbomOnly)
|
||||
{
|
||||
if (callStatus == ReachabilityStatus.Unknown)
|
||||
{
|
||||
if (mode == ReachabilityAnalysisMode.CallGraph)
|
||||
{
|
||||
reason = MergeReason(reason, "call-graph-missing");
|
||||
}
|
||||
}
|
||||
else if (mode == ReachabilityAnalysisMode.CallGraph || combinedStatus != sbomStatus)
|
||||
{
|
||||
reason = MergeReason(
|
||||
reason,
|
||||
$"call-graph-{combinedStatus.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = componentRef,
|
||||
Status = combinedStatus,
|
||||
Path = reporting.IncludeReachabilityPaths
|
||||
? baseFinding?.Path ?? ImmutableArray<string>.Empty
|
||||
: ImmutableArray<string>.Empty,
|
||||
Conditions = baseFinding?.Conditions ?? ImmutableArray<string>.Empty,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
private static string? MergeReason(string? existing, string addition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(addition))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
return addition;
|
||||
}
|
||||
|
||||
var parts = existing.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Contains(addition, StringComparer.Ordinal))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
return $"{existing};{addition}";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CallGraphReachabilityAnalyzer
|
||||
{
|
||||
public CallGraphReachabilityResult Analyze(RichGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var entrypoints = ResolveEntrypoints(graph);
|
||||
if (entrypoints.Length == 0)
|
||||
{
|
||||
return new CallGraphReachabilityResult
|
||||
{
|
||||
PurlReachability = ImmutableDictionary<string, ReachabilityStatus>.Empty,
|
||||
Entrypoints = ImmutableArray<string>.Empty,
|
||||
HasEntrypoints = false
|
||||
};
|
||||
}
|
||||
|
||||
var reachableNodes = Traverse(graph, entrypoints);
|
||||
var purlStates = new Dictionary<string, PurlAccumulator>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
var purl = ResolvePurl(node);
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!purlStates.TryGetValue(purl, out var state))
|
||||
{
|
||||
state = new PurlAccumulator();
|
||||
purlStates[purl] = state;
|
||||
}
|
||||
|
||||
state.HasNode = true;
|
||||
if (reachableNodes.Contains(node.Id))
|
||||
{
|
||||
state.IsReachable = true;
|
||||
}
|
||||
}
|
||||
|
||||
var reachability = purlStates
|
||||
.ToImmutableDictionary(
|
||||
entry => entry.Key,
|
||||
entry => entry.Value.IsReachable
|
||||
? ReachabilityStatus.Reachable
|
||||
: ReachabilityStatus.Unreachable,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new CallGraphReachabilityResult
|
||||
{
|
||||
PurlReachability = reachability,
|
||||
Entrypoints = entrypoints,
|
||||
HasEntrypoints = true
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ResolveEntrypoints(RichGraph graph)
|
||||
{
|
||||
var entrypoints = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (graph.Roots is not null)
|
||||
{
|
||||
foreach (var root in graph.Roots)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(root.Id))
|
||||
{
|
||||
entrypoints.Add(root.Id.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (IsEntrypoint(node))
|
||||
{
|
||||
entrypoints.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return entrypoints.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsEntrypoint(RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.IsEntrypoint, out var value) == true &&
|
||||
bool.TryParse(value, out var isEntrypoint) &&
|
||||
isEntrypoint)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return node.Kind.Equals("entrypoint", StringComparison.OrdinalIgnoreCase) ||
|
||||
node.Kind.Equals("export", StringComparison.OrdinalIgnoreCase) ||
|
||||
node.Kind.Equals("main", StringComparison.OrdinalIgnoreCase) ||
|
||||
node.Kind.Equals("handler", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ResolvePurl(RichGraphNode node)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(node.Purl))
|
||||
{
|
||||
return node.Purl.Trim();
|
||||
}
|
||||
|
||||
if (node.Attributes?.TryGetValue("purl", out var purl) == true &&
|
||||
!string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return purl.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static HashSet<string> Traverse(RichGraph graph, ImmutableArray<string> entrypoints)
|
||||
{
|
||||
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.From) || string.IsNullOrWhiteSpace(edge.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(edge.From, out var targets))
|
||||
{
|
||||
targets = [];
|
||||
adjacency[edge.From] = targets;
|
||||
}
|
||||
|
||||
targets.Add(edge.To);
|
||||
}
|
||||
|
||||
var reachable = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
|
||||
foreach (var entry in entrypoints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = entry.Trim();
|
||||
if (reachable.Add(normalized))
|
||||
{
|
||||
queue.Enqueue(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!adjacency.TryGetValue(current, out var targets))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
if (reachable.Add(target))
|
||||
{
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reachable;
|
||||
}
|
||||
|
||||
private sealed class PurlAccumulator
|
||||
{
|
||||
public bool HasNode { get; set; }
|
||||
public bool IsReachable { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CallGraphReachabilityResult
|
||||
{
|
||||
public required ImmutableDictionary<string, ReachabilityStatus> PurlReachability { get; init; }
|
||||
public ImmutableArray<string> Entrypoints { get; init; } = [];
|
||||
public bool HasEntrypoints { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Policy options for SBOM dependency reachability inference.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityPolicy
|
||||
{
|
||||
public ReachabilityAnalysisMode AnalysisMode { get; init; } =
|
||||
ReachabilityAnalysisMode.SbomOnly;
|
||||
public ReachabilityScopePolicy ScopeHandling { get; init; } = new();
|
||||
public ReachabilityEntryPointPolicy EntryPoints { get; init; } = new();
|
||||
public ReachabilityVulnerabilityFilteringPolicy VulnerabilityFiltering { get; init; }
|
||||
= new();
|
||||
public ReachabilityReportingPolicy Reporting { get; init; } = new();
|
||||
public ReachabilityConfidencePolicy Confidence { get; init; } = new();
|
||||
}
|
||||
|
||||
public enum ReachabilityAnalysisMode
|
||||
{
|
||||
SbomOnly,
|
||||
CallGraph,
|
||||
Combined
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope handling rules for dependency edges.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityScopePolicy
|
||||
{
|
||||
public bool IncludeRuntime { get; init; } = true;
|
||||
public OptionalDependencyHandling IncludeOptional { get; init; } =
|
||||
OptionalDependencyHandling.AsPotentiallyReachable;
|
||||
public bool IncludeDevelopment { get; init; }
|
||||
public bool IncludeTest { get; init; }
|
||||
}
|
||||
|
||||
public enum OptionalDependencyHandling
|
||||
{
|
||||
Exclude,
|
||||
AsPotentiallyReachable,
|
||||
Reachable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry point detection configuration.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityEntryPointPolicy
|
||||
{
|
||||
public bool DetectFromSbom { get; init; } = true;
|
||||
public ImmutableArray<string> Additional { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability filtering and severity adjustment options.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityVulnerabilityFilteringPolicy
|
||||
{
|
||||
public bool FilterUnreachable { get; init; } = true;
|
||||
public ReachabilitySeverityAdjustmentPolicy SeverityAdjustment { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record ReachabilitySeverityAdjustmentPolicy
|
||||
{
|
||||
public ReachabilitySeverityAdjustment PotentiallyReachable { get; init; } =
|
||||
ReachabilitySeverityAdjustment.ReduceBySeverityLevel;
|
||||
public ReachabilitySeverityAdjustment Unreachable { get; init; } =
|
||||
ReachabilitySeverityAdjustment.InformationalOnly;
|
||||
public double ReduceByPercentage { get; init; } = 0.5;
|
||||
}
|
||||
|
||||
public enum ReachabilitySeverityAdjustment
|
||||
{
|
||||
None,
|
||||
ReduceBySeverityLevel,
|
||||
ReduceByPercentage,
|
||||
InformationalOnly
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reporting options for reachability outputs.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityReportingPolicy
|
||||
{
|
||||
public bool ShowFilteredVulnerabilities { get; init; } = true;
|
||||
public bool IncludeReachabilityPaths { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence thresholds for reachability inference.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityConfidencePolicy
|
||||
{
|
||||
public double MinimumConfidence { get; init; } = 0.8;
|
||||
public ReachabilityStatus MarkUnknownAs { get; init; } =
|
||||
ReachabilityStatus.PotentiallyReachable;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
public interface IReachabilityPolicyLoader
|
||||
{
|
||||
Task<ReachabilityPolicy> LoadAsync(string? path, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public static class ReachabilityPolicyDefaults
|
||||
{
|
||||
public static ReachabilityPolicy Default { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReachabilityPolicyLoader : IReachabilityPolicyLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
private readonly IDeserializer _yamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
public async Task<ReachabilityPolicy> LoadAsync(string? path, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return ReachabilityPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||
await using var stream = File.OpenRead(path);
|
||||
|
||||
return extension switch
|
||||
{
|
||||
".yaml" or ".yml" => LoadFromYaml(stream),
|
||||
_ => await LoadFromJsonAsync(stream, ct).ConfigureAwait(false)
|
||||
};
|
||||
}
|
||||
|
||||
private ReachabilityPolicy LoadFromYaml(Stream stream)
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
var yamlObject = _yamlDeserializer.Deserialize(reader);
|
||||
if (yamlObject is null)
|
||||
{
|
||||
return ReachabilityPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(yamlObject);
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
return ExtractPolicy(document.RootElement);
|
||||
}
|
||||
|
||||
private static async Task<ReachabilityPolicy> LoadFromJsonAsync(
|
||||
Stream stream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(
|
||||
stream,
|
||||
cancellationToken: ct)
|
||||
.ConfigureAwait(false);
|
||||
return ExtractPolicy(document.RootElement);
|
||||
}
|
||||
|
||||
private static ReachabilityPolicy ExtractPolicy(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("reachabilityPolicy", out var policyElement))
|
||||
{
|
||||
return JsonSerializer.Deserialize<ReachabilityPolicy>(policyElement, JsonOptions)
|
||||
?? ReachabilityPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<ReachabilityPolicy>(root, JsonOptions)
|
||||
?? ReachabilityPolicyDefaults.Default;
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
options.Converters.Add(new FlexibleBooleanConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class FlexibleBooleanConverter : JsonConverter<bool>
|
||||
{
|
||||
public override bool Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.True => true,
|
||||
JsonTokenType.False => false,
|
||||
JsonTokenType.String when bool.TryParse(reader.GetString(), out var value) => value,
|
||||
_ => throw new JsonException(
|
||||
$"Expected boolean value or boolean string, got {reader.TokenType}.")
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteBooleanValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
internal static class ReachabilityReportBuilder
|
||||
{
|
||||
public static ReachabilityReport Build(
|
||||
DependencyGraph graph,
|
||||
Dictionary<string, ReachabilityStatus> results,
|
||||
List<ReachabilityFinding> findings)
|
||||
{
|
||||
var total = results.Count;
|
||||
var reachableCount = results.Values.Count(status =>
|
||||
status == ReachabilityStatus.Reachable);
|
||||
var unknownCount = results.Values.Count(status =>
|
||||
status == ReachabilityStatus.Unknown);
|
||||
var unreachableCount = results.Values.Count(status =>
|
||||
status == ReachabilityStatus.Unreachable);
|
||||
|
||||
var reductionPercent = total == 0
|
||||
? 0
|
||||
: (double)unreachableCount / total * 100.0;
|
||||
|
||||
return new ReachabilityReport
|
||||
{
|
||||
Graph = graph,
|
||||
ComponentReachability = results.ToImmutableDictionary(StringComparer.Ordinal),
|
||||
Findings = findings
|
||||
.OrderBy(finding => finding.ComponentRef, StringComparer.Ordinal)
|
||||
.ToImmutableArray(),
|
||||
Statistics = new ReachabilityStatistics
|
||||
{
|
||||
TotalComponents = total,
|
||||
ReachableComponents = reachableCount,
|
||||
UnreachableComponents = unreachableCount,
|
||||
UnknownComponents = unknownCount,
|
||||
VulnerabilityReductionPercent = reductionPercent
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies.Reporting;
|
||||
|
||||
public sealed record DependencyReachabilityReport
|
||||
{
|
||||
public required DependencyReachabilitySummary Summary { get; init; }
|
||||
public ImmutableArray<DependencyReachabilityComponent> Components { get; init; } = [];
|
||||
public ImmutableArray<DependencyReachabilityVulnerabilityFinding> Vulnerabilities { get; init; } = [];
|
||||
public ImmutableArray<DependencyReachabilityVulnerabilityFinding> FilteredVulnerabilities { get; init; } = [];
|
||||
public ReachabilityAnalysisMode AnalysisMode { get; init; } = ReachabilityAnalysisMode.SbomOnly;
|
||||
}
|
||||
|
||||
public sealed record DependencyReachabilitySummary
|
||||
{
|
||||
public required ReachabilityStatistics ComponentStatistics { get; init; }
|
||||
public required VulnerabilityReachabilityStatistics VulnerabilityStatistics { get; init; }
|
||||
public double FalsePositiveReductionPercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DependencyReachabilityComponent
|
||||
{
|
||||
public required string ComponentRef { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public required ReachabilityStatus Status { get; init; }
|
||||
public ImmutableArray<string> Path { get; init; } = [];
|
||||
public ImmutableArray<string> Conditions { get; init; } = [];
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DependencyReachabilityVulnerabilityFinding
|
||||
{
|
||||
public required Guid CanonicalId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public string? ComponentRef { get; init; }
|
||||
public ReachabilityStatus Status { get; init; }
|
||||
public ReachabilityStatus RawStatus { get; init; }
|
||||
public bool IsReachable { get; init; }
|
||||
public bool IsFiltered { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public string? OriginalSeverity { get; init; }
|
||||
public string? AdjustedSeverity { get; init; }
|
||||
public string? AffectedVersions { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public ImmutableArray<string> ReachabilityPath { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record DependencyReachabilityAdvisorySummary
|
||||
{
|
||||
public required Guid CanonicalId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public string? AffectedVersions { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Scanner.Sarif;
|
||||
using StellaOps.Scanner.Sarif.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies.Reporting;
|
||||
|
||||
public sealed class DependencyReachabilityReporter
|
||||
{
|
||||
private readonly ISarifExportService _sarifExporter;
|
||||
|
||||
public DependencyReachabilityReporter(ISarifExportService sarifExporter)
|
||||
{
|
||||
_sarifExporter = sarifExporter ?? throw new ArgumentNullException(nameof(sarifExporter));
|
||||
}
|
||||
|
||||
public DependencyReachabilityReport BuildReport(
|
||||
ParsedSbom sbom,
|
||||
ReachabilityReport reachabilityReport,
|
||||
VulnerabilityReachabilityFilterResult filterResult,
|
||||
IReadOnlyDictionary<Guid, DependencyReachabilityAdvisorySummary>? advisories,
|
||||
ReachabilityPolicy policy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sbom);
|
||||
ArgumentNullException.ThrowIfNull(reachabilityReport);
|
||||
ArgumentNullException.ThrowIfNull(filterResult);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var componentPurls = BuildComponentPurlLookup(sbom);
|
||||
var componentRefsByPurl = BuildPurlComponentLookup(sbom);
|
||||
var findingsByComponent = reachabilityReport.Findings.ToDictionary(
|
||||
finding => finding.ComponentRef,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var components = new List<DependencyReachabilityComponent>(
|
||||
reachabilityReport.ComponentReachability.Count);
|
||||
foreach (var entry in reachabilityReport.ComponentReachability
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
findingsByComponent.TryGetValue(entry.Key, out var finding);
|
||||
|
||||
components.Add(new DependencyReachabilityComponent
|
||||
{
|
||||
ComponentRef = entry.Key,
|
||||
Purl = componentPurls.TryGetValue(entry.Key, out var purl) ? purl : null,
|
||||
Status = entry.Value,
|
||||
Path = finding?.Path ?? ImmutableArray<string>.Empty,
|
||||
Conditions = finding?.Conditions ?? ImmutableArray<string>.Empty,
|
||||
Reason = finding?.Reason
|
||||
});
|
||||
}
|
||||
|
||||
var vulnerabilities = new List<DependencyReachabilityVulnerabilityFinding>();
|
||||
var filtered = new List<DependencyReachabilityVulnerabilityFinding>();
|
||||
|
||||
var advisoryLookup = advisories ?? new Dictionary<Guid, DependencyReachabilityAdvisorySummary>();
|
||||
foreach (var adjustment in filterResult.Adjustments
|
||||
.OrderBy(adj => adj.Match.Purl, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(adj => adj.Match.CanonicalId))
|
||||
{
|
||||
advisoryLookup.TryGetValue(adjustment.Match.CanonicalId, out var advisory);
|
||||
|
||||
var componentRef = componentRefsByPurl.TryGetValue(adjustment.Match.Purl, out var refs)
|
||||
? refs.FirstOrDefault()
|
||||
: null;
|
||||
var reachabilityPath = componentRef is not null &&
|
||||
findingsByComponent.TryGetValue(componentRef, out var compFinding)
|
||||
? compFinding.Path
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
var finding = new DependencyReachabilityVulnerabilityFinding
|
||||
{
|
||||
CanonicalId = adjustment.Match.CanonicalId,
|
||||
VulnerabilityId = advisory?.VulnerabilityId
|
||||
?? adjustment.Match.CanonicalId.ToString(),
|
||||
Purl = adjustment.Match.Purl,
|
||||
ComponentRef = componentRef,
|
||||
Status = adjustment.EffectiveStatus,
|
||||
RawStatus = adjustment.Status,
|
||||
IsReachable = adjustment.Match.IsReachable,
|
||||
IsFiltered = adjustment.IsFiltered,
|
||||
Confidence = adjustment.Match.Confidence,
|
||||
OriginalSeverity = adjustment.OriginalSeverity,
|
||||
AdjustedSeverity = adjustment.AdjustedSeverity ?? advisory?.Severity,
|
||||
AffectedVersions = advisory?.AffectedVersions,
|
||||
Title = advisory?.Title,
|
||||
Summary = advisory?.Summary,
|
||||
ReachabilityPath = reachabilityPath
|
||||
};
|
||||
|
||||
if (adjustment.IsFiltered)
|
||||
{
|
||||
filtered.Add(finding);
|
||||
}
|
||||
else
|
||||
{
|
||||
vulnerabilities.Add(finding);
|
||||
}
|
||||
}
|
||||
|
||||
if (!policy.Reporting.ShowFilteredVulnerabilities)
|
||||
{
|
||||
filtered.Clear();
|
||||
}
|
||||
|
||||
return new DependencyReachabilityReport
|
||||
{
|
||||
Summary = new DependencyReachabilitySummary
|
||||
{
|
||||
ComponentStatistics = reachabilityReport.Statistics,
|
||||
VulnerabilityStatistics = filterResult.Statistics,
|
||||
FalsePositiveReductionPercent = filterResult.Statistics.ReductionPercent
|
||||
},
|
||||
Components = components.ToImmutableArray(),
|
||||
Vulnerabilities = vulnerabilities.ToImmutableArray(),
|
||||
FilteredVulnerabilities = filtered.ToImmutableArray(),
|
||||
AnalysisMode = policy.AnalysisMode
|
||||
};
|
||||
}
|
||||
|
||||
public string ExportGraphViz(
|
||||
DependencyGraph graph,
|
||||
IReadOnlyDictionary<string, ReachabilityStatus> componentStatus,
|
||||
IReadOnlyDictionary<string, string?> purlByComponent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("digraph \"sbom-reachability\" {");
|
||||
builder.AppendLine(" rankdir=LR;");
|
||||
builder.AppendLine(" node [shape=box];");
|
||||
|
||||
foreach (var node in graph.Nodes.OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
var status = componentStatus.TryGetValue(node, out var value)
|
||||
? value
|
||||
: ReachabilityStatus.Unknown;
|
||||
var label = purlByComponent.TryGetValue(node, out var purl) && !string.IsNullOrWhiteSpace(purl)
|
||||
? $"{purl}\\n{status.ToString().ToLowerInvariant()}"
|
||||
: $"{node}\\n{status.ToString().ToLowerInvariant()}";
|
||||
builder.AppendLine($" \"{Escape(node)}\" [label=\"{Escape(label)}\"];");
|
||||
}
|
||||
|
||||
foreach (var edge in graph.Edges
|
||||
.SelectMany(pair => pair.Value)
|
||||
.OrderBy(edge => edge.From, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.To, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.Scope))
|
||||
{
|
||||
var scope = edge.Scope.ToString().ToLowerInvariant();
|
||||
builder.AppendLine(
|
||||
$" \"{Escape(edge.From)}\" -> \"{Escape(edge.To)}\" [label=\"{Escape(scope)}\"];");
|
||||
}
|
||||
|
||||
builder.AppendLine("}");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public async Task<SarifLog> ExportSarifAsync(
|
||||
DependencyReachabilityReport report,
|
||||
string toolVersion,
|
||||
bool includeFiltered,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var findings = new List<FindingInput>();
|
||||
AppendFindings(findings, report.Vulnerabilities, includeFiltered: false);
|
||||
if (includeFiltered)
|
||||
{
|
||||
AppendFindings(findings, report.FilteredVulnerabilities, includeFiltered: true);
|
||||
}
|
||||
|
||||
var options = new SarifExportOptions
|
||||
{
|
||||
ToolName = "StellaOps Scanner",
|
||||
ToolVersion = string.IsNullOrWhiteSpace(toolVersion) ? "unknown" : toolVersion,
|
||||
IncludeEvidenceUris = true,
|
||||
IncludeReachability = true,
|
||||
IncludeVexStatus = false,
|
||||
FingerprintStrategy = FingerprintStrategy.Standard
|
||||
};
|
||||
|
||||
return await _sarifExporter.ExportAsync(findings, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendFindings(
|
||||
List<FindingInput> target,
|
||||
IEnumerable<DependencyReachabilityVulnerabilityFinding> findings,
|
||||
bool includeFiltered)
|
||||
{
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
if (finding.IsFiltered && !includeFiltered)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
target.Add(new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
VulnerabilityId = finding.VulnerabilityId,
|
||||
ComponentPurl = finding.Purl,
|
||||
ComponentName = ExtractComponentName(finding.Purl),
|
||||
ComponentVersion = ExtractComponentVersion(finding.Purl),
|
||||
Severity = ParseSeverity(finding.AdjustedSeverity ?? finding.OriginalSeverity),
|
||||
Title = finding.Title ?? $"Vulnerability {finding.VulnerabilityId} in {finding.Purl}",
|
||||
Description = finding.Summary,
|
||||
Reachability = MapReachability(finding.Status),
|
||||
EvidenceUris = BuildEvidenceUris(finding.VulnerabilityId, finding.Purl),
|
||||
Properties = new Dictionary<string, object>
|
||||
{
|
||||
["canonicalId"] = finding.CanonicalId.ToString(),
|
||||
["reachabilityRaw"] = finding.RawStatus.ToString(),
|
||||
["reachabilityFiltered"] = finding.IsFiltered,
|
||||
["reachabilityConfidence"] = finding.Confidence
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractComponentName(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var atIndex = purl.LastIndexOf('@');
|
||||
var slashIndex = purl.LastIndexOf('/');
|
||||
if (slashIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endIndex = atIndex > slashIndex ? atIndex : purl.Length;
|
||||
return purl.Substring(slashIndex + 1, endIndex - slashIndex - 1);
|
||||
}
|
||||
|
||||
private static string? ExtractComponentVersion(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var atIndex = purl.LastIndexOf('@');
|
||||
return atIndex < 0 ? null : purl[(atIndex + 1)..];
|
||||
}
|
||||
|
||||
private static Severity ParseSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
return Severity.Unknown;
|
||||
}
|
||||
|
||||
return severity.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"CRITICAL" => Severity.Critical,
|
||||
"HIGH" => Severity.High,
|
||||
"MEDIUM" => Severity.Medium,
|
||||
"LOW" => Severity.Low,
|
||||
_ => Severity.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static Sarif.ReachabilityStatus MapReachability(ReachabilityStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
ReachabilityStatus.Reachable => Sarif.ReachabilityStatus.StaticReachable,
|
||||
ReachabilityStatus.Unreachable => Sarif.ReachabilityStatus.StaticUnreachable,
|
||||
ReachabilityStatus.PotentiallyReachable => Sarif.ReachabilityStatus.Contested,
|
||||
_ => Sarif.ReachabilityStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildEvidenceUris(string vulnId, string purl)
|
||||
{
|
||||
var uris = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(vulnId))
|
||||
{
|
||||
uris.Add($"stella://vuln/{vulnId}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
uris.Add($"stella://component/{Uri.EscapeDataString(purl)}");
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildComponentPurlLookup(ParsedSbom sbom)
|
||||
{
|
||||
var lookup = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component.BomRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lookup[component.BomRef] = component.Purl;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<string>> BuildPurlComponentLookup(ParsedSbom sbom)
|
||||
{
|
||||
var lookup = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component.Purl) || string.IsNullOrWhiteSpace(component.BomRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lookup.TryGetValue(component.Purl, out var list))
|
||||
{
|
||||
list = [];
|
||||
lookup[component.Purl] = list;
|
||||
}
|
||||
|
||||
list.Add(component.BomRef);
|
||||
}
|
||||
|
||||
foreach (var pair in lookup)
|
||||
{
|
||||
pair.Value.Sort(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Static graph traversal reachability for dependency graphs.
|
||||
/// </summary>
|
||||
public sealed class StaticReachabilityAnalyzer
|
||||
{
|
||||
public ReachabilityReport Analyze(
|
||||
DependencyGraph graph,
|
||||
ImmutableArray<string> entryPoints,
|
||||
ReachabilityPolicy? policy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var resolvedPolicy = policy ?? new ReachabilityPolicy();
|
||||
var results = new Dictionary<string, ReachabilityStatus>(StringComparer.Ordinal);
|
||||
var findings = new List<ReachabilityFinding>();
|
||||
|
||||
if (entryPoints.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
results[node] = ReachabilityStatus.Unknown;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.Unknown,
|
||||
Reason = "no-entrypoints"
|
||||
});
|
||||
}
|
||||
|
||||
return ReachabilityReportBuilder.Build(graph, results, findings);
|
||||
}
|
||||
|
||||
var reachable = new HashSet<string>(StringComparer.Ordinal);
|
||||
var potential = new HashSet<string>(StringComparer.Ordinal);
|
||||
var predecessor = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var pathKind = new Dictionary<string, PathKind>(StringComparer.Ordinal);
|
||||
var queue = new Queue<(string Node, PathKind Kind)>();
|
||||
|
||||
foreach (var entry in entryPoints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = entry.Trim();
|
||||
if (reachable.Add(normalized))
|
||||
{
|
||||
predecessor[normalized] = null;
|
||||
pathKind[normalized] = PathKind.Required;
|
||||
queue.Enqueue((normalized, PathKind.Required));
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (node, kind) = queue.Dequeue();
|
||||
if (!graph.Edges.TryGetValue(node, out var edges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!IsScopeIncluded(edge.Scope, resolvedPolicy.ScopeHandling))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nextKind = Combine(kind, edge.Scope, resolvedPolicy.ScopeHandling);
|
||||
var target = edge.To;
|
||||
if (nextKind == PathKind.Required)
|
||||
{
|
||||
if (reachable.Add(target))
|
||||
{
|
||||
predecessor[target] = node;
|
||||
pathKind[target] = PathKind.Required;
|
||||
queue.Enqueue((target, PathKind.Required));
|
||||
}
|
||||
else if (pathKind.TryGetValue(target, out var existing) &&
|
||||
existing == PathKind.Optional)
|
||||
{
|
||||
predecessor[target] = node;
|
||||
pathKind[target] = PathKind.Required;
|
||||
queue.Enqueue((target, PathKind.Required));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (reachable.Contains(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (potential.Add(target))
|
||||
{
|
||||
predecessor[target] = node;
|
||||
pathKind[target] = PathKind.Optional;
|
||||
queue.Enqueue((target, PathKind.Optional));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (reachable.Contains(node))
|
||||
{
|
||||
results[node] = ReachabilityStatus.Reachable;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.Reachable,
|
||||
Path = BuildPath(node, predecessor)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (potential.Contains(node))
|
||||
{
|
||||
results[node] = ReachabilityStatus.PotentiallyReachable;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.PotentiallyReachable,
|
||||
Path = BuildPath(node, predecessor),
|
||||
Reason = "optional-dependency"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results[node] = ReachabilityStatus.Unreachable;
|
||||
findings.Add(new ReachabilityFinding
|
||||
{
|
||||
ComponentRef = node,
|
||||
Status = ReachabilityStatus.Unreachable,
|
||||
Reason = "no-path"
|
||||
});
|
||||
}
|
||||
|
||||
return ReachabilityReportBuilder.Build(graph, results, findings);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildPath(
|
||||
string target,
|
||||
IReadOnlyDictionary<string, string?> predecessor)
|
||||
{
|
||||
var path = new List<string>();
|
||||
var current = target;
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
while (true)
|
||||
{
|
||||
if (!seen.Add(current))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
path.Add(current);
|
||||
|
||||
if (!predecessor.TryGetValue(current, out var previous) ||
|
||||
string.IsNullOrWhiteSpace(previous))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = previous;
|
||||
}
|
||||
|
||||
path.Reverse();
|
||||
return path.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsScopeIncluded(DependencyScope scope, ReachabilityScopePolicy policy)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
DependencyScope.Runtime => policy.IncludeRuntime,
|
||||
DependencyScope.Development => policy.IncludeDevelopment,
|
||||
DependencyScope.Test => policy.IncludeTest,
|
||||
DependencyScope.Optional => policy.IncludeOptional != OptionalDependencyHandling.Exclude,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static PathKind Combine(
|
||||
PathKind current,
|
||||
DependencyScope scope,
|
||||
ReachabilityScopePolicy policy)
|
||||
{
|
||||
if (scope != DependencyScope.Optional)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return policy.IncludeOptional switch
|
||||
{
|
||||
OptionalDependencyHandling.Reachable => current,
|
||||
OptionalDependencyHandling.AsPotentiallyReachable => PathKind.Optional,
|
||||
_ => current
|
||||
};
|
||||
}
|
||||
|
||||
private enum PathKind
|
||||
{
|
||||
Required,
|
||||
Optional
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Filters SBOM advisory matches using component reachability signals.
|
||||
/// </summary>
|
||||
public sealed class VulnerabilityReachabilityFilter
|
||||
{
|
||||
public VulnerabilityReachabilityFilterResult Apply(
|
||||
IReadOnlyList<SbomAdvisoryMatch> matches,
|
||||
IReadOnlyDictionary<string, ReachabilityStatus>? reachabilityMap,
|
||||
ReachabilityPolicy? policy = null,
|
||||
IReadOnlyDictionary<Guid, string?>? severityByCanonicalId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(matches);
|
||||
|
||||
var resolvedPolicy = policy ?? new ReachabilityPolicy();
|
||||
var adjustments = new List<VulnerabilityReachabilityAdjustment>(matches.Count);
|
||||
var filtered = new List<VulnerabilityReachabilityAdjustment>();
|
||||
var retainedMatches = new List<SbomAdvisoryMatch>(matches.Count);
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var rawStatus = ResolveStatus(match, reachabilityMap, resolvedPolicy);
|
||||
var effectiveStatus = ApplyUnknownPolicy(rawStatus, resolvedPolicy.Confidence);
|
||||
var adjustedMatch = match with
|
||||
{
|
||||
IsReachable = effectiveStatus is ReachabilityStatus.Reachable or
|
||||
ReachabilityStatus.PotentiallyReachable
|
||||
};
|
||||
|
||||
var originalSeverity = TryResolveSeverity(match, severityByCanonicalId);
|
||||
var adjustedSeverity = AdjustSeverity(
|
||||
originalSeverity,
|
||||
effectiveStatus,
|
||||
resolvedPolicy.VulnerabilityFiltering.SeverityAdjustment);
|
||||
|
||||
var isFiltered = resolvedPolicy.VulnerabilityFiltering.FilterUnreachable &&
|
||||
effectiveStatus == ReachabilityStatus.Unreachable;
|
||||
|
||||
var adjustment = new VulnerabilityReachabilityAdjustment
|
||||
{
|
||||
Match = adjustedMatch,
|
||||
Status = rawStatus,
|
||||
EffectiveStatus = effectiveStatus,
|
||||
OriginalSeverity = originalSeverity,
|
||||
AdjustedSeverity = adjustedSeverity,
|
||||
IsFiltered = isFiltered
|
||||
};
|
||||
|
||||
adjustments.Add(adjustment);
|
||||
|
||||
if (isFiltered)
|
||||
{
|
||||
filtered.Add(adjustment);
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedMatches.Add(adjustedMatch);
|
||||
}
|
||||
|
||||
return new VulnerabilityReachabilityFilterResult
|
||||
{
|
||||
Matches = retainedMatches.ToImmutableArray(),
|
||||
Adjustments = adjustments.ToImmutableArray(),
|
||||
Filtered = filtered.ToImmutableArray(),
|
||||
Statistics = VulnerabilityReachabilityStatistics.Build(adjustments, filtered.Count)
|
||||
};
|
||||
}
|
||||
|
||||
public VulnerabilityReachabilityFilterResult Apply(
|
||||
IReadOnlyList<SbomAdvisoryMatch> matches,
|
||||
IReadOnlyDictionary<string, bool>? reachabilityMap,
|
||||
ReachabilityPolicy? policy = null,
|
||||
IReadOnlyDictionary<Guid, string?>? severityByCanonicalId = null)
|
||||
{
|
||||
if (reachabilityMap is null)
|
||||
{
|
||||
return Apply(
|
||||
matches,
|
||||
(IReadOnlyDictionary<string, ReachabilityStatus>?)null,
|
||||
policy,
|
||||
severityByCanonicalId);
|
||||
}
|
||||
|
||||
var statusMap = reachabilityMap.ToDictionary(
|
||||
entry => entry.Key,
|
||||
entry => entry.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.Unreachable,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return Apply(matches, statusMap, policy, severityByCanonicalId);
|
||||
}
|
||||
|
||||
private static ReachabilityStatus ResolveStatus(
|
||||
SbomAdvisoryMatch match,
|
||||
IReadOnlyDictionary<string, ReachabilityStatus>? reachabilityMap,
|
||||
ReachabilityPolicy policy)
|
||||
{
|
||||
if (match.Confidence < policy.Confidence.MinimumConfidence)
|
||||
{
|
||||
return ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
if (reachabilityMap is not null &&
|
||||
reachabilityMap.TryGetValue(match.Purl, out var status))
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
return ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
private static ReachabilityStatus ApplyUnknownPolicy(
|
||||
ReachabilityStatus status,
|
||||
ReachabilityConfidencePolicy policy)
|
||||
=> status == ReachabilityStatus.Unknown ? policy.MarkUnknownAs : status;
|
||||
|
||||
private static string? TryResolveSeverity(
|
||||
SbomAdvisoryMatch match,
|
||||
IReadOnlyDictionary<Guid, string?>? severityByCanonicalId)
|
||||
{
|
||||
if (severityByCanonicalId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return severityByCanonicalId.TryGetValue(match.CanonicalId, out var severity)
|
||||
? NormalizeSeverity(severity)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? AdjustSeverity(
|
||||
string? originalSeverity,
|
||||
ReachabilityStatus status,
|
||||
ReachabilitySeverityAdjustmentPolicy policy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(originalSeverity))
|
||||
{
|
||||
return status == ReachabilityStatus.Unreachable &&
|
||||
policy.Unreachable == ReachabilitySeverityAdjustment.InformationalOnly
|
||||
? "informational"
|
||||
: originalSeverity;
|
||||
}
|
||||
|
||||
return status switch
|
||||
{
|
||||
ReachabilityStatus.Reachable => originalSeverity,
|
||||
ReachabilityStatus.PotentiallyReachable => ApplyAdjustment(
|
||||
originalSeverity,
|
||||
policy.PotentiallyReachable,
|
||||
policy),
|
||||
ReachabilityStatus.Unreachable => ApplyAdjustment(
|
||||
originalSeverity,
|
||||
policy.Unreachable,
|
||||
policy),
|
||||
_ => originalSeverity
|
||||
};
|
||||
}
|
||||
|
||||
private static string ApplyAdjustment(
|
||||
string original,
|
||||
ReachabilitySeverityAdjustment adjustment,
|
||||
ReachabilitySeverityAdjustmentPolicy policy)
|
||||
{
|
||||
return adjustment switch
|
||||
{
|
||||
ReachabilitySeverityAdjustment.None => original,
|
||||
ReachabilitySeverityAdjustment.InformationalOnly => "informational",
|
||||
ReachabilitySeverityAdjustment.ReduceBySeverityLevel => ReduceByLevel(original),
|
||||
ReachabilitySeverityAdjustment.ReduceByPercentage => ReduceByPercentage(
|
||||
original,
|
||||
policy.ReduceByPercentage),
|
||||
_ => original
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReduceByLevel(string severity)
|
||||
{
|
||||
return ParseSeverityTier(severity) switch
|
||||
{
|
||||
SeverityTier.Critical => "high",
|
||||
SeverityTier.High => "medium",
|
||||
SeverityTier.Medium => "low",
|
||||
SeverityTier.Low => "informational",
|
||||
SeverityTier.None => "informational",
|
||||
SeverityTier.Informational => "informational",
|
||||
_ => severity
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReduceByPercentage(string severity, double percent)
|
||||
{
|
||||
if (percent <= 0)
|
||||
{
|
||||
return severity;
|
||||
}
|
||||
|
||||
var tier = ParseSeverityTier(severity);
|
||||
var score = TierToScore(tier);
|
||||
if (score is null)
|
||||
{
|
||||
return severity;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(percent, 0, 1);
|
||||
var adjusted = score.Value * (1.0 - clamped);
|
||||
return TierToString(ScoreToTier(adjusted));
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return severity.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"info" or "informational" => "informational",
|
||||
"med" => "medium",
|
||||
_ => severity.Trim().ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static SeverityTier ParseSeverityTier(string severity)
|
||||
{
|
||||
return NormalizeSeverity(severity) switch
|
||||
{
|
||||
"critical" => SeverityTier.Critical,
|
||||
"high" => SeverityTier.High,
|
||||
"medium" => SeverityTier.Medium,
|
||||
"low" => SeverityTier.Low,
|
||||
"none" => SeverityTier.None,
|
||||
"informational" => SeverityTier.Informational,
|
||||
_ => SeverityTier.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static double? TierToScore(SeverityTier tier)
|
||||
=> tier switch
|
||||
{
|
||||
SeverityTier.Critical => 9.0,
|
||||
SeverityTier.High => 7.0,
|
||||
SeverityTier.Medium => 5.0,
|
||||
SeverityTier.Low => 3.0,
|
||||
SeverityTier.None => 0.0,
|
||||
SeverityTier.Informational => 0.1,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static SeverityTier ScoreToTier(double score)
|
||||
{
|
||||
if (score >= 8.5) return SeverityTier.Critical;
|
||||
if (score >= 7.0) return SeverityTier.High;
|
||||
if (score >= 4.0) return SeverityTier.Medium;
|
||||
if (score >= 1.0) return SeverityTier.Low;
|
||||
return SeverityTier.Informational;
|
||||
}
|
||||
|
||||
private static string TierToString(SeverityTier tier)
|
||||
=> tier switch
|
||||
{
|
||||
SeverityTier.Critical => "critical",
|
||||
SeverityTier.High => "high",
|
||||
SeverityTier.Medium => "medium",
|
||||
SeverityTier.Low => "low",
|
||||
SeverityTier.None => "none",
|
||||
SeverityTier.Informational => "informational",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private enum SeverityTier
|
||||
{
|
||||
Unknown,
|
||||
Informational,
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VulnerabilityReachabilityFilterResult
|
||||
{
|
||||
public ImmutableArray<SbomAdvisoryMatch> Matches { get; init; } = [];
|
||||
public ImmutableArray<VulnerabilityReachabilityAdjustment> Adjustments { get; init; } = [];
|
||||
public ImmutableArray<VulnerabilityReachabilityAdjustment> Filtered { get; init; } = [];
|
||||
public required VulnerabilityReachabilityStatistics Statistics { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VulnerabilityReachabilityAdjustment
|
||||
{
|
||||
public required SbomAdvisoryMatch Match { get; init; }
|
||||
public required ReachabilityStatus Status { get; init; }
|
||||
public ReachabilityStatus EffectiveStatus { get; init; }
|
||||
public string? OriginalSeverity { get; init; }
|
||||
public string? AdjustedSeverity { get; init; }
|
||||
public bool IsFiltered { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VulnerabilityReachabilityStatistics
|
||||
{
|
||||
public int TotalVulnerabilities { get; init; }
|
||||
public int ReachableVulnerabilities { get; init; }
|
||||
public int PotentiallyReachableVulnerabilities { get; init; }
|
||||
public int UnreachableVulnerabilities { get; init; }
|
||||
public int UnknownVulnerabilities { get; init; }
|
||||
public int FilteredVulnerabilities { get; init; }
|
||||
public double ReductionPercent { get; init; }
|
||||
|
||||
internal static VulnerabilityReachabilityStatistics Build(
|
||||
IReadOnlyList<VulnerabilityReachabilityAdjustment> adjustments,
|
||||
int filteredCount)
|
||||
{
|
||||
if (adjustments.Count == 0)
|
||||
{
|
||||
return new VulnerabilityReachabilityStatistics();
|
||||
}
|
||||
|
||||
var reachable = 0;
|
||||
var potential = 0;
|
||||
var unreachable = 0;
|
||||
var unknown = 0;
|
||||
|
||||
foreach (var adjustment in adjustments)
|
||||
{
|
||||
switch (adjustment.EffectiveStatus)
|
||||
{
|
||||
case ReachabilityStatus.Reachable:
|
||||
reachable++;
|
||||
break;
|
||||
case ReachabilityStatus.PotentiallyReachable:
|
||||
potential++;
|
||||
break;
|
||||
case ReachabilityStatus.Unreachable:
|
||||
unreachable++;
|
||||
break;
|
||||
default:
|
||||
unknown++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new VulnerabilityReachabilityStatistics
|
||||
{
|
||||
TotalVulnerabilities = adjustments.Count,
|
||||
ReachableVulnerabilities = reachable,
|
||||
PotentiallyReachableVulnerabilities = potential,
|
||||
UnreachableVulnerabilities = unreachable,
|
||||
UnknownVulnerabilities = unknown,
|
||||
FilteredVulnerabilities = filteredCount,
|
||||
ReductionPercent = adjustments.Count == 0
|
||||
? 0
|
||||
: filteredCount * 100.0 / adjustments.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
|
||||
@@ -19,11 +20,13 @@
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.CallGraph\StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Sarif\StellaOps.Scanner.Sarif.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.SbomIntegration\StellaOps.Concelier.SbomIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user