Add Canonical JSON serialization library with tests and documentation

- Implemented CanonJson class for deterministic JSON serialization and hashing.
- Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters.
- Created project files for the Canonical JSON library and its tests, including necessary package references.
- Added README.md for library usage and API reference.
- Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -0,0 +1,86 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Configuration options for <see cref="ReachabilityAnalyzer"/>.
/// Defines limits and ordering rules for deterministic path output.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B)
/// Contract: ReachabilityAnalyzer → PathWitnessBuilder output contract
///
/// Determinism guarantees:
/// - Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC)
/// - Node IDs within paths are ordered from entrypoint to sink (caller → callee)
/// - Maximum caps prevent unbounded output
/// </remarks>
public sealed record ReachabilityAnalysisOptions
{
/// <summary>
/// Default options with sensible limits.
/// </summary>
public static ReachabilityAnalysisOptions Default { get; } = new();
/// <summary>
/// Maximum depth for BFS traversal (0 = unlimited, default = 256).
/// Prevents infinite loops in cyclic graphs.
/// </summary>
public int MaxDepth { get; init; } = 256;
/// <summary>
/// Maximum number of paths to return per sink (default = 10).
/// Limits witness explosion when many entrypoints reach the same sink.
/// </summary>
public int MaxPathsPerSink { get; init; } = 10;
/// <summary>
/// Maximum total paths to return (default = 100).
/// Hard cap to prevent memory issues with highly connected graphs.
/// </summary>
public int MaxTotalPaths { get; init; } = 100;
/// <summary>
/// Whether to include node metadata in path reconstruction (default = true).
/// When false, paths only contain node IDs without additional context.
/// </summary>
public bool IncludeNodeMetadata { get; init; } = true;
/// <summary>
/// Explicit list of sink node IDs to target (default = null, meaning use snapshot.SinkIds).
/// When set, analysis will only find paths to these specific sinks.
/// This enables targeted witness generation for specific vulnerabilities.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_3700_0001_0001 (WIT-007B)
/// Enables: PathWitnessBuilder can request paths to specific trigger methods.
/// </remarks>
public ImmutableArray<string>? ExplicitSinks { get; init; }
/// <summary>
/// Validates options and returns sanitized values.
/// </summary>
public ReachabilityAnalysisOptions Validated()
{
// Normalize explicit sinks: trim, dedupe, order
ImmutableArray<string>? normalizedSinks = null;
if (ExplicitSinks.HasValue && !ExplicitSinks.Value.IsDefaultOrEmpty)
{
normalizedSinks = ExplicitSinks.Value
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(s => s, StringComparer.Ordinal)
.ToImmutableArray();
}
return new ReachabilityAnalysisOptions
{
MaxDepth = MaxDepth <= 0 ? 256 : Math.Min(MaxDepth, 1024),
MaxPathsPerSink = MaxPathsPerSink <= 0 ? 10 : Math.Min(MaxPathsPerSink, 100),
MaxTotalPaths = MaxTotalPaths <= 0 ? 100 : Math.Min(MaxTotalPaths, 1000),
IncludeNodeMetadata = IncludeNodeMetadata,
ExplicitSinks = normalizedSinks
};
}
}

View File

@@ -2,20 +2,53 @@ using System.Collections.Immutable;
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Analyzes call graph reachability from entrypoints to sinks using BFS traversal.
/// Provides deterministically-ordered paths suitable for witness generation.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B)
/// Contract: Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC).
/// Node IDs within paths are ordered from entrypoint to sink (caller → callee).
/// </remarks>
public sealed class ReachabilityAnalyzer
{
private readonly TimeProvider _timeProvider;
private readonly int _maxDepth;
private readonly ReachabilityAnalysisOptions _options;
/// <summary>
/// Creates a new ReachabilityAnalyzer with default options.
/// </summary>
public ReachabilityAnalyzer(TimeProvider? timeProvider = null, int maxDepth = 256)
: this(timeProvider, new ReachabilityAnalysisOptions { MaxDepth = maxDepth })
{
_timeProvider = timeProvider ?? TimeProvider.System;
_maxDepth = maxDepth <= 0 ? 256 : maxDepth;
}
/// <summary>
/// Creates a new ReachabilityAnalyzer with specified options.
/// </summary>
public ReachabilityAnalyzer(TimeProvider? timeProvider, ReachabilityAnalysisOptions options)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_options = (options ?? ReachabilityAnalysisOptions.Default).Validated();
}
/// <summary>
/// Analyzes reachability using default options.
/// </summary>
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot)
=> Analyze(snapshot, _options);
/// <summary>
/// Analyzes reachability with explicit options for this invocation.
/// </summary>
/// <param name="snapshot">The call graph snapshot to analyze.</param>
/// <param name="options">Options controlling limits and output format.</param>
/// <returns>Analysis result with deterministically-ordered paths.</returns>
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot, ReachabilityAnalysisOptions options)
{
ArgumentNullException.ThrowIfNull(snapshot);
var opts = (options ?? _options).Validated();
var trimmed = snapshot.Trimmed();
var adjacency = BuildAdjacency(trimmed);
@@ -47,7 +80,7 @@ public sealed class ReachabilityAnalyzer
continue;
}
if (depth >= _maxDepth)
if (depth >= opts.MaxDepth)
{
continue;
}
@@ -72,12 +105,18 @@ public sealed class ReachabilityAnalyzer
}
var reachableNodes = origins.Keys.OrderBy(id => id, StringComparer.Ordinal).ToImmutableArray();
var reachableSinks = trimmed.SinkIds
// WIT-007B: Use explicit sinks if specified, otherwise use snapshot sinks
var targetSinks = opts.ExplicitSinks.HasValue && !opts.ExplicitSinks.Value.IsDefaultOrEmpty
? opts.ExplicitSinks.Value
: trimmed.SinkIds;
var reachableSinks = targetSinks
.Where(origins.ContainsKey)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var paths = BuildPaths(reachableSinks, origins, parents);
var paths = BuildPaths(reachableSinks, origins, parents, opts);
var computedAt = _timeProvider.GetUtcNow();
var provisional = new ReachabilityAnalysisResult(
@@ -136,9 +175,12 @@ public sealed class ReachabilityAnalyzer
private static ImmutableArray<ReachabilityPath> BuildPaths(
ImmutableArray<string> reachableSinks,
Dictionary<string, string> origins,
Dictionary<string, string?> parents)
Dictionary<string, string?> parents,
ReachabilityAnalysisOptions options)
{
var paths = new List<ReachabilityPath>(reachableSinks.Length);
var pathCountPerSink = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var sinkId in reachableSinks)
{
if (!origins.TryGetValue(sinkId, out var origin))
@@ -146,13 +188,29 @@ public sealed class ReachabilityAnalyzer
continue;
}
// Enforce per-sink limit
pathCountPerSink.TryGetValue(sinkId, out var currentCount);
if (currentCount >= options.MaxPathsPerSink)
{
continue;
}
pathCountPerSink[sinkId] = currentCount + 1;
var nodeIds = ReconstructPathNodeIds(sinkId, parents);
paths.Add(new ReachabilityPath(origin, sinkId, nodeIds));
// Enforce total path limit
if (paths.Count >= options.MaxTotalPaths)
{
break;
}
}
// Deterministic ordering: SinkId ASC, EntrypointId ASC, PathLength ASC
return paths
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
.ThenBy(p => p.NodeIds.Length)
.ToImmutableArray();
}