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