// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors. using Microsoft.Extensions.Logging; using System.Collections.Immutable; namespace StellaOps.BinaryIndex.Analysis; /// /// Adapter that implements using ReachGraph module. /// /// /// /// This adapter bridges the BinaryIndex.Analysis module to the ReachGraph service layer. /// It can be configured to use either: /// /// /// Direct service injection (when running in same process as ReachGraph) /// HTTP client (when ReachGraph runs as separate service) /// /// /// To use this adapter with direct injection, register it in DI after registering /// the ReachGraph services: /// /// services.AddReachGraphSliceService(); // From ReachGraph.WebService /// services.AddBinaryReachabilityService<ReachGraphBinaryReachabilityService>(); /// /// /// /// To use this adapter with HTTP client, implement a custom adapter that uses /// IHttpClientFactory to call the ReachGraph API endpoints. /// /// public sealed class ReachGraphBinaryReachabilityService : IBinaryReachabilityService { private readonly IReachGraphSliceClient _sliceClient; private readonly ILogger _logger; /// /// Creates a new ReachGraph-backed reachability service. /// /// ReachGraph slice client. /// Logger. public ReachGraphBinaryReachabilityService( IReachGraphSliceClient sliceClient, ILogger logger) { _sliceClient = sliceClient; _logger = logger; } /// public async Task AnalyzeCveReachabilityAsync( string artifactDigest, string cveId, string tenantId, BinaryReachabilityOptions? options = null, CancellationToken ct = default) { options ??= BinaryReachabilityOptions.Default; _logger.LogDebug("Analyzing CVE {CveId} reachability in artifact {Digest}", cveId, TruncateDigest(artifactDigest)); try { var response = await _sliceClient.SliceByCveAsync( artifactDigest, cveId, tenantId, options.MaxPaths, ct); if (response is null) { _logger.LogDebug("No reachability data found for CVE {CveId}", cveId); return BinaryReachabilityResult.NotReachable(); } // Map ReachGraph paths to our model var paths = response.Paths .Select(p => new ReachabilityPath { EntryPoint = p.Entrypoint, Sink = p.Sink, Nodes = p.Hops.ToImmutableArray() }) .ToImmutableArray(); return new BinaryReachabilityResult { IsReachable = paths.Length > 0, ReachableSinks = response.Sinks.ToImmutableArray(), Paths = paths, Confidence = 0.95m }; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Failed to analyze CVE {CveId} reachability", cveId); return BinaryReachabilityResult.NotReachable(); } } /// public async Task> FindPathsAsync( string artifactDigest, ImmutableArray entryPoints, ImmutableArray sinks, string tenantId, int maxDepth = 20, CancellationToken ct = default) { _logger.LogDebug("Finding paths in artifact {Digest} from {EntryCount} entries to {SinkCount} sinks", TruncateDigest(artifactDigest), entryPoints.Length, sinks.Length); var allPaths = new List(); try { // Query for each entry point pattern foreach (var entryPoint in entryPoints) { var response = await _sliceClient.SliceByEntrypointAsync( artifactDigest, entryPoint, tenantId, maxDepth, ct); if (response is null) continue; // Check if any sink is reachable from this slice // The slice contains all nodes reachable from the entry point var reachableNodeIds = response.Nodes .Select(n => n.Ref) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var sink in sinks) { if (reachableNodeIds.Contains(sink)) { // Sink is reachable - construct path // Note: This is simplified; real implementation would trace actual path allPaths.Add(new ReachabilityPath { EntryPoint = entryPoint, Sink = sink, Nodes = [entryPoint, sink] // Simplified }); } } } return allPaths.ToImmutableArray(); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Failed to find paths"); return ImmutableArray.Empty; } } private static string TruncateDigest(string digest) => digest.Length > 20 ? digest[..20] + "..." : digest; } /// /// Client interface for ReachGraph slice operations. /// /// /// This interface abstracts the ReachGraph slice service to enable /// different implementations (direct injection, HTTP client, gRPC). /// public interface IReachGraphSliceClient { /// /// Slices by CVE to get reachability paths. /// Task SliceByCveAsync( string digest, string cveId, string tenantId, int maxPaths = 5, CancellationToken ct = default); /// /// Slices by entry point pattern. /// Task SliceByEntrypointAsync( string digest, string entrypointPattern, string tenantId, int maxDepth = 10, CancellationToken ct = default); } /// /// Result of a CVE slice query. /// public sealed record CveSliceResult { /// Sinks that are reachable. public required IReadOnlyList Sinks { get; init; } /// Paths from entries to sinks. public required IReadOnlyList Paths { get; init; } } /// /// A path in a CVE slice result. /// public sealed record CveSlicePath { /// Entry point function. public required string Entrypoint { get; init; } /// Sink function. public required string Sink { get; init; } /// Intermediate nodes. public required IReadOnlyList Hops { get; init; } } /// /// Result of a slice query. /// public sealed record SliceResult { /// Nodes in the slice. public required IReadOnlyList Nodes { get; init; } /// Edges in the slice. public required IReadOnlyList Edges { get; init; } } /// /// A node in a slice result. /// public sealed record SliceNode { /// Node ID. public required string Id { get; init; } /// Reference (function name, PURL, etc.). public required string Ref { get; init; } /// Node kind. public string Kind { get; init; } = "Function"; } /// /// An edge in a slice result. /// public sealed record SliceEdge { /// Source node ID. public required string From { get; init; } /// Target node ID. public required string To { get; init; } } /// /// HTTP implementation of IReachGraphSliceClient that calls the ReachGraph service API. /// public sealed class HttpReachGraphSliceClient : IReachGraphSliceClient { private readonly HttpClient _httpClient; private readonly ILogger _logger; /// /// Creates a new HTTP-backed ReachGraph slice client. /// /// Pre-configured HttpClient targeting ReachGraph base URL. /// Logger. public HttpReachGraphSliceClient( HttpClient httpClient, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task SliceByCveAsync( string digest, string cveId, string tenantId, int maxPaths = 5, CancellationToken ct = default) { _logger.LogDebug("Querying ReachGraph slice-by-CVE: {CveId} for {Digest}", cveId, digest); try { var url = $"api/v1/slice/cve?digest={Uri.EscapeDataString(digest)}&cveId={Uri.EscapeDataString(cveId)}&tenantId={Uri.EscapeDataString(tenantId)}&maxPaths={maxPaths}"; var response = await _httpClient.GetAsync(url, ct); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { _logger.LogDebug("No slice data found for CVE {CveId}", cveId); return null; } response.EnsureSuccessStatusCode(); return await System.Text.Json.JsonSerializer.DeserializeAsync( await response.Content.ReadAsStreamAsync(ct), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Failed to query ReachGraph for CVE {CveId}", cveId); return null; } } /// public async Task SliceByEntrypointAsync( string digest, string entrypointPattern, string tenantId, int maxDepth = 10, CancellationToken ct = default) { _logger.LogDebug("Querying ReachGraph slice-by-entrypoint: {Pattern} for {Digest}", entrypointPattern, digest); try { var url = $"api/v1/slice/entrypoint?digest={Uri.EscapeDataString(digest)}&pattern={Uri.EscapeDataString(entrypointPattern)}&tenantId={Uri.EscapeDataString(tenantId)}&maxDepth={maxDepth}"; var response = await _httpClient.GetAsync(url, ct); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } response.EnsureSuccessStatusCode(); return await System.Text.Json.JsonSerializer.DeserializeAsync( await response.Content.ReadAsStreamAsync(ct), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct); } catch (HttpRequestException ex) { _logger.LogWarning(ex, "Failed to query ReachGraph for entrypoint {Pattern}", entrypointPattern); return null; } } } /// /// Null implementation of IReachGraphSliceClient for testing. /// public sealed class NullReachGraphSliceClient : IReachGraphSliceClient { /// public Task SliceByCveAsync( string digest, string cveId, string tenantId, int maxPaths = 5, CancellationToken ct = default) { return Task.FromResult(null); } /// public Task SliceByEntrypointAsync( string digest, string entrypointPattern, string tenantId, int maxDepth = 10, CancellationToken ct = default) { return Task.FromResult(null); } }