Files
git.stella-ops.org/src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.Analysis/ReachGraphBinaryReachabilityService.cs
2026-02-16 07:32:38 +02:00

386 lines
12 KiB
C#

// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
namespace StellaOps.BinaryIndex.Analysis;
/// <summary>
/// Adapter that implements <see cref="IBinaryReachabilityService"/> using ReachGraph module.
/// </summary>
/// <remarks>
/// <para>
/// This adapter bridges the BinaryIndex.Analysis module to the ReachGraph service layer.
/// It can be configured to use either:
/// </para>
/// <list type="bullet">
/// <item>Direct service injection (when running in same process as ReachGraph)</item>
/// <item>HTTP client (when ReachGraph runs as separate service)</item>
/// </list>
/// <para>
/// To use this adapter with direct injection, register it in DI after registering
/// the ReachGraph services:
/// <code>
/// services.AddReachGraphSliceService(); // From ReachGraph.WebService
/// services.AddBinaryReachabilityService&lt;ReachGraphBinaryReachabilityService&gt;();
/// </code>
/// </para>
/// <para>
/// To use this adapter with HTTP client, implement a custom adapter that uses
/// <c>IHttpClientFactory</c> to call the ReachGraph API endpoints.
/// </para>
/// </remarks>
public sealed class ReachGraphBinaryReachabilityService : IBinaryReachabilityService
{
private readonly IReachGraphSliceClient _sliceClient;
private readonly ILogger<ReachGraphBinaryReachabilityService> _logger;
/// <summary>
/// Creates a new ReachGraph-backed reachability service.
/// </summary>
/// <param name="sliceClient">ReachGraph slice client.</param>
/// <param name="logger">Logger.</param>
public ReachGraphBinaryReachabilityService(
IReachGraphSliceClient sliceClient,
ILogger<ReachGraphBinaryReachabilityService> logger)
{
_sliceClient = sliceClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<BinaryReachabilityResult> 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();
}
}
/// <inheritdoc />
public async Task<ImmutableArray<ReachabilityPath>> FindPathsAsync(
string artifactDigest,
ImmutableArray<string> entryPoints,
ImmutableArray<string> 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<ReachabilityPath>();
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<ReachabilityPath>.Empty;
}
}
private static string TruncateDigest(string digest) =>
digest.Length > 20 ? digest[..20] + "..." : digest;
}
/// <summary>
/// Client interface for ReachGraph slice operations.
/// </summary>
/// <remarks>
/// This interface abstracts the ReachGraph slice service to enable
/// different implementations (direct injection, HTTP client, gRPC).
/// </remarks>
public interface IReachGraphSliceClient
{
/// <summary>
/// Slices by CVE to get reachability paths.
/// </summary>
Task<CveSliceResult?> SliceByCveAsync(
string digest,
string cveId,
string tenantId,
int maxPaths = 5,
CancellationToken ct = default);
/// <summary>
/// Slices by entry point pattern.
/// </summary>
Task<SliceResult?> SliceByEntrypointAsync(
string digest,
string entrypointPattern,
string tenantId,
int maxDepth = 10,
CancellationToken ct = default);
}
/// <summary>
/// Result of a CVE slice query.
/// </summary>
public sealed record CveSliceResult
{
/// <summary>Sinks that are reachable.</summary>
public required IReadOnlyList<string> Sinks { get; init; }
/// <summary>Paths from entries to sinks.</summary>
public required IReadOnlyList<CveSlicePath> Paths { get; init; }
}
/// <summary>
/// A path in a CVE slice result.
/// </summary>
public sealed record CveSlicePath
{
/// <summary>Entry point function.</summary>
public required string Entrypoint { get; init; }
/// <summary>Sink function.</summary>
public required string Sink { get; init; }
/// <summary>Intermediate nodes.</summary>
public required IReadOnlyList<string> Hops { get; init; }
}
/// <summary>
/// Result of a slice query.
/// </summary>
public sealed record SliceResult
{
/// <summary>Nodes in the slice.</summary>
public required IReadOnlyList<SliceNode> Nodes { get; init; }
/// <summary>Edges in the slice.</summary>
public required IReadOnlyList<SliceEdge> Edges { get; init; }
}
/// <summary>
/// A node in a slice result.
/// </summary>
public sealed record SliceNode
{
/// <summary>Node ID.</summary>
public required string Id { get; init; }
/// <summary>Reference (function name, PURL, etc.).</summary>
public required string Ref { get; init; }
/// <summary>Node kind.</summary>
public string Kind { get; init; } = "Function";
}
/// <summary>
/// An edge in a slice result.
/// </summary>
public sealed record SliceEdge
{
/// <summary>Source node ID.</summary>
public required string From { get; init; }
/// <summary>Target node ID.</summary>
public required string To { get; init; }
}
/// <summary>
/// HTTP implementation of IReachGraphSliceClient that calls the ReachGraph service API.
/// </summary>
public sealed class HttpReachGraphSliceClient : IReachGraphSliceClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<HttpReachGraphSliceClient> _logger;
/// <summary>
/// Creates a new HTTP-backed ReachGraph slice client.
/// </summary>
/// <param name="httpClient">Pre-configured HttpClient targeting ReachGraph base URL.</param>
/// <param name="logger">Logger.</param>
public HttpReachGraphSliceClient(
HttpClient httpClient,
ILogger<HttpReachGraphSliceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<CveSliceResult?> 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<CveSliceResult>(
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;
}
}
/// <inheritdoc />
public async Task<SliceResult?> 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<SliceResult>(
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;
}
}
}
/// <summary>
/// Null implementation of IReachGraphSliceClient for testing.
/// </summary>
public sealed class NullReachGraphSliceClient : IReachGraphSliceClient
{
/// <inheritdoc />
public Task<CveSliceResult?> SliceByCveAsync(
string digest,
string cveId,
string tenantId,
int maxPaths = 5,
CancellationToken ct = default)
{
return Task.FromResult<CveSliceResult?>(null);
}
/// <inheritdoc />
public Task<SliceResult?> SliceByEntrypointAsync(
string digest,
string entrypointPattern,
string tenantId,
int maxDepth = 10,
CancellationToken ct = default)
{
return Task.FromResult<SliceResult?>(null);
}
}