tests fixes and sprints work

This commit is contained in:
master
2026-01-22 19:08:46 +02:00
parent c32fff8f86
commit 726d70dc7f
881 changed files with 134434 additions and 6228 deletions

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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
}
};
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -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
};
}
}

View File

@@ -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" />