up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client interface for fetching reachability facts from Signals service.
|
||||
/// </summary>
|
||||
public interface IReachabilityFactsSignalsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a reachability fact by subject key.
|
||||
/// </summary>
|
||||
/// <param name="subjectKey">Subject key (scan ID or component key).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The reachability fact document, or null if not found.</returns>
|
||||
Task<SignalsReachabilityFactResponse?> GetBySubjectAsync(
|
||||
string subjectKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple reachability facts by subject keys.
|
||||
/// </summary>
|
||||
/// <param name="subjectKeys">Subject keys to lookup.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Dictionary of subject key to fact.</returns>
|
||||
Task<IReadOnlyDictionary<string, SignalsReachabilityFactResponse>> GetBatchBySubjectsAsync(
|
||||
IReadOnlyList<string> subjectKeys,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Triggers recomputation of reachability for a subject.
|
||||
/// </summary>
|
||||
/// <param name="request">Recompute request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if recompute was triggered.</returns>
|
||||
Task<bool> TriggerRecomputeAsync(
|
||||
SignalsRecomputeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from Signals /facts/{subjectKey} endpoint.
|
||||
/// Maps to ReachabilityFactDocument in Signals module.
|
||||
/// </summary>
|
||||
public sealed record SignalsReachabilityFactResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Document ID.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Callgraph ID.
|
||||
/// </summary>
|
||||
public string CallgraphId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Subject information.
|
||||
/// </summary>
|
||||
public SignalsSubject? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry points.
|
||||
/// </summary>
|
||||
public List<string>? EntryPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability states.
|
||||
/// </summary>
|
||||
public List<SignalsReachabilityState>? States { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime facts.
|
||||
/// </summary>
|
||||
public List<SignalsRuntimeFact>? RuntimeFacts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CAS URI for runtime-facts batch artifact.
|
||||
/// </summary>
|
||||
public string? RuntimeFactsBatchUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of runtime-facts batch.
|
||||
/// </summary>
|
||||
public string? RuntimeFactsBatchHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string?>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context facts for provenance.
|
||||
/// </summary>
|
||||
public SignalsContextFacts? ContextFacts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Uncertainty information.
|
||||
/// </summary>
|
||||
public SignalsUncertainty? Uncertainty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge bundle references.
|
||||
/// </summary>
|
||||
public List<SignalsEdgeBundleReference>? EdgeBundles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether quarantined edges exist.
|
||||
/// </summary>
|
||||
public bool HasQuarantinedEdges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability score.
|
||||
/// </summary>
|
||||
public double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score.
|
||||
/// </summary>
|
||||
public double RiskScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of unknowns.
|
||||
/// </summary>
|
||||
public int UnknownsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns pressure.
|
||||
/// </summary>
|
||||
public double UnknownsPressure { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject key.
|
||||
/// </summary>
|
||||
public string SubjectKey { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject information from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsSubject
|
||||
{
|
||||
public string? ImageDigest { get; init; }
|
||||
public string? Component { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? ScanId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability state from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsReachabilityState
|
||||
{
|
||||
public string Target { get; init; } = string.Empty;
|
||||
public bool Reachable { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public string Bucket { get; init; } = "unknown";
|
||||
public string? LatticeState { get; init; }
|
||||
public string? PreviousLatticeState { get; init; }
|
||||
public double Weight { get; init; }
|
||||
public double Score { get; init; }
|
||||
public List<string>? Path { get; init; }
|
||||
public SignalsEvidence? Evidence { get; init; }
|
||||
public DateTimeOffset? LatticeTransitionAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsEvidence
|
||||
{
|
||||
public List<string>? RuntimeHits { get; init; }
|
||||
public List<string>? BlockedEdges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime fact from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsRuntimeFact
|
||||
{
|
||||
public string SymbolId { get; init; } = string.Empty;
|
||||
public string? CodeId { get; init; }
|
||||
public string? SymbolDigest { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public string? BuildId { get; init; }
|
||||
public int HitCount { get; init; }
|
||||
public DateTimeOffset? ObservedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context facts from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsContextFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Uncertainty information from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsUncertainty
|
||||
{
|
||||
public string? AggregateTier { get; init; }
|
||||
public double? RiskScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge bundle reference from Signals.
|
||||
/// </summary>
|
||||
public sealed record SignalsEdgeBundleReference
|
||||
{
|
||||
public string BundleId { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public int EdgeCount { get; init; }
|
||||
public string? CasUri { get; init; }
|
||||
public string? DsseDigest { get; init; }
|
||||
public bool HasRevokedEdges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to trigger reachability recomputation.
|
||||
/// </summary>
|
||||
public sealed record SignalsRecomputeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Subject key to recompute.
|
||||
/// </summary>
|
||||
public required string SubjectKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for fetching reachability facts from Signals service.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityFactsSignalsClient : IReachabilityFactsSignalsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ReachabilityFactsSignalsClientOptions _options;
|
||||
private readonly ILogger<ReachabilityFactsSignalsClient> _logger;
|
||||
|
||||
public ReachabilityFactsSignalsClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<ReachabilityFactsSignalsClientOptions> options,
|
||||
ILogger<ReachabilityFactsSignalsClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value;
|
||||
|
||||
if (_httpClient.BaseAddress is null && _options.BaseUri is not null)
|
||||
{
|
||||
_httpClient.BaseAddress = _options.BaseUri;
|
||||
}
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Clear();
|
||||
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SignalsReachabilityFactResponse?> GetBySubjectAsync(
|
||||
string subjectKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
|
||||
"signals_client.get_fact",
|
||||
ActivityKind.Client);
|
||||
activity?.SetTag("signals.subject_key", subjectKey);
|
||||
|
||||
var path = $"signals/facts/{Uri.EscapeDataString(subjectKey)}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Reachability fact not found for subject {SubjectKey}", subjectKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var fact = await response.Content
|
||||
.ReadFromJsonAsync<SignalsReachabilityFactResponse>(SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Retrieved reachability fact for subject {SubjectKey}: score={Score}, states={StateCount}",
|
||||
subjectKey,
|
||||
fact?.Score,
|
||||
fact?.States?.Count ?? 0);
|
||||
|
||||
return fact;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get reachability fact for subject {SubjectKey}", subjectKey);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, SignalsReachabilityFactResponse>> GetBatchBySubjectsAsync(
|
||||
IReadOnlyList<string> subjectKeys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subjectKeys);
|
||||
|
||||
if (subjectKeys.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, SignalsReachabilityFactResponse>();
|
||||
}
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
|
||||
"signals_client.get_facts_batch",
|
||||
ActivityKind.Client);
|
||||
activity?.SetTag("signals.batch_size", subjectKeys.Count);
|
||||
|
||||
var result = new Dictionary<string, SignalsReachabilityFactResponse>(StringComparer.Ordinal);
|
||||
|
||||
// Signals doesn't expose a batch endpoint, so we fetch in parallel with concurrency limit
|
||||
var semaphore = new SemaphoreSlim(_options.MaxConcurrentRequests);
|
||||
var tasks = subjectKeys.Select(async key =>
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var fact = await GetBySubjectAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
return (Key: key, Fact: fact);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
foreach (var (key, fact) in results)
|
||||
{
|
||||
if (fact is not null)
|
||||
{
|
||||
result[key] = fact;
|
||||
}
|
||||
}
|
||||
|
||||
activity?.SetTag("signals.found_count", result.Count);
|
||||
_logger.LogDebug(
|
||||
"Batch retrieved {FoundCount}/{TotalCount} reachability facts",
|
||||
result.Count,
|
||||
subjectKeys.Count);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TriggerRecomputeAsync(
|
||||
SignalsRecomputeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
|
||||
"signals_client.trigger_recompute",
|
||||
ActivityKind.Client);
|
||||
activity?.SetTag("signals.subject_key", request.SubjectKey);
|
||||
activity?.SetTag("signals.tenant_id", request.TenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"signals/reachability/recompute",
|
||||
new { subjectKey = request.SubjectKey, tenantId = request.TenantId },
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Triggered reachability recompute for subject {SubjectKey}",
|
||||
request.SubjectKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Failed to trigger reachability recompute for subject {SubjectKey}: {StatusCode}",
|
||||
request.SubjectKey,
|
||||
response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error triggering reachability recompute for subject {SubjectKey}",
|
||||
request.SubjectKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Signals reachability client.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityFactsSignalsClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "ReachabilitySignals";
|
||||
|
||||
/// <summary>
|
||||
/// Base URI for the Signals service.
|
||||
/// </summary>
|
||||
public Uri? BaseUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrent requests for batch operations.
|
||||
/// Default: 10.
|
||||
/// </summary>
|
||||
public int MaxConcurrentRequests { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Retry count for transient failures.
|
||||
/// Default: 3.
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IReachabilityFactsStore"/> that delegates to the Signals service.
|
||||
/// Maps between Signals' ReachabilityFactDocument and Policy's ReachabilityFact.
|
||||
/// </summary>
|
||||
public sealed class SignalsBackedReachabilityFactsStore : IReachabilityFactsStore
|
||||
{
|
||||
private readonly IReachabilityFactsSignalsClient _signalsClient;
|
||||
private readonly ILogger<SignalsBackedReachabilityFactsStore> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SignalsBackedReachabilityFactsStore(
|
||||
IReachabilityFactsSignalsClient signalsClient,
|
||||
ILogger<SignalsBackedReachabilityFactsStore> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_signalsClient = signalsClient ?? throw new ArgumentNullException(nameof(signalsClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachabilityFact?> GetAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Signals uses subjectKey which is typically a scan ID or component key
|
||||
// For Policy lookups, we construct a composite key
|
||||
var subjectKey = BuildSubjectKey(componentPurl, advisoryId);
|
||||
|
||||
var response = await _signalsClient.GetBySubjectAsync(subjectKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No reachability fact found for {TenantId}/{ComponentPurl}/{AdvisoryId}",
|
||||
tenantId, componentPurl, advisoryId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToReachabilityFact(tenantId, componentPurl, advisoryId, response);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(
|
||||
IReadOnlyList<ReachabilityFactKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
return new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
}
|
||||
|
||||
// Build subject keys for batch lookup
|
||||
var subjectKeyMap = keys.ToDictionary(
|
||||
k => BuildSubjectKey(k.ComponentPurl, k.AdvisoryId),
|
||||
k => k,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var responses = await _signalsClient.GetBatchBySubjectsAsync(
|
||||
subjectKeyMap.Keys.ToList(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
|
||||
foreach (var (subjectKey, response) in responses)
|
||||
{
|
||||
if (subjectKeyMap.TryGetValue(subjectKey, out var key))
|
||||
{
|
||||
var fact = MapToReachabilityFact(key.TenantId, key.ComponentPurl, key.AdvisoryId, response);
|
||||
result[key] = fact;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ReachabilityFact>> QueryAsync(
|
||||
ReachabilityFactsQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Signals service doesn't expose a direct query API
|
||||
// For now, return empty - callers should use batch lookups instead
|
||||
_logger.LogDebug(
|
||||
"Query not supported by Signals backend; use batch lookups instead. Tenant={TenantId}",
|
||||
query.TenantId);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReachabilityFact>>(Array.Empty<ReachabilityFact>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(ReachabilityFact fact, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Read-only store - facts are computed by Signals service
|
||||
_logger.LogWarning(
|
||||
"Save not supported by Signals backend. Facts are computed by Signals service.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveBatchAsync(IReadOnlyList<ReachabilityFact> facts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Read-only store - facts are computed by Signals service
|
||||
_logger.LogWarning(
|
||||
"SaveBatch not supported by Signals backend. Facts are computed by Signals service.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Read-only store - facts are managed by Signals service
|
||||
_logger.LogWarning(
|
||||
"Delete not supported by Signals backend. Facts are managed by Signals service.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long> CountAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Not available from Signals API
|
||||
return Task.FromResult(0L);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers recomputation of reachability for a subject.
|
||||
/// </summary>
|
||||
public Task<bool> TriggerRecomputeAsync(
|
||||
string tenantId,
|
||||
string subjectKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _signalsClient.TriggerRecomputeAsync(
|
||||
new SignalsRecomputeRequest { SubjectKey = subjectKey, TenantId = tenantId },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildSubjectKey(string componentPurl, string advisoryId)
|
||||
{
|
||||
// Build a deterministic subject key from component and advisory
|
||||
// This should match how Signals indexes facts
|
||||
return $"{componentPurl}|{advisoryId}";
|
||||
}
|
||||
|
||||
private ReachabilityFact MapToReachabilityFact(
|
||||
string tenantId,
|
||||
string componentPurl,
|
||||
string advisoryId,
|
||||
SignalsReachabilityFactResponse response)
|
||||
{
|
||||
// Determine overall state from lattice states
|
||||
var (state, confidence, hasRuntimeEvidence) = DetermineOverallState(response);
|
||||
|
||||
// Determine analysis method
|
||||
var method = DetermineAnalysisMethod(response);
|
||||
|
||||
// Build evidence reference
|
||||
var evidenceRef = response.RuntimeFactsBatchUri ?? response.CallgraphId;
|
||||
var evidenceHash = response.RuntimeFactsBatchHash;
|
||||
|
||||
// Build metadata
|
||||
var metadata = BuildMetadata(response);
|
||||
|
||||
return new ReachabilityFact
|
||||
{
|
||||
Id = response.Id,
|
||||
TenantId = tenantId,
|
||||
ComponentPurl = componentPurl,
|
||||
AdvisoryId = advisoryId,
|
||||
State = state,
|
||||
Confidence = (decimal)confidence,
|
||||
Score = (decimal)response.Score,
|
||||
HasRuntimeEvidence = hasRuntimeEvidence,
|
||||
Source = "signals",
|
||||
Method = method,
|
||||
EvidenceRef = evidenceRef,
|
||||
EvidenceHash = evidenceHash,
|
||||
ComputedAt = response.ComputedAt,
|
||||
ExpiresAt = null, // Signals doesn't expose expiry; rely on cache TTL
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private static (ReachabilityState State, double Confidence, bool HasRuntimeEvidence) DetermineOverallState(
|
||||
SignalsReachabilityFactResponse response)
|
||||
{
|
||||
if (response.States is null || response.States.Count == 0)
|
||||
{
|
||||
return (ReachabilityState.Unknown, 0, false);
|
||||
}
|
||||
|
||||
// Aggregate states - worst case wins for reachability
|
||||
var hasReachable = false;
|
||||
var hasUnreachable = false;
|
||||
var hasRuntimeEvidence = false;
|
||||
var maxConfidence = 0.0;
|
||||
var totalConfidence = 0.0;
|
||||
|
||||
foreach (var state in response.States)
|
||||
{
|
||||
if (state.Reachable)
|
||||
{
|
||||
hasReachable = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
hasUnreachable = true;
|
||||
}
|
||||
|
||||
if (state.Evidence?.RuntimeHits?.Count > 0)
|
||||
{
|
||||
hasRuntimeEvidence = true;
|
||||
}
|
||||
|
||||
maxConfidence = Math.Max(maxConfidence, state.Confidence);
|
||||
totalConfidence += state.Confidence;
|
||||
}
|
||||
|
||||
// Also check runtime facts
|
||||
if (response.RuntimeFacts?.Count > 0)
|
||||
{
|
||||
hasRuntimeEvidence = true;
|
||||
}
|
||||
|
||||
var avgConfidence = totalConfidence / response.States.Count;
|
||||
|
||||
// Determine overall state
|
||||
ReachabilityState overallState;
|
||||
if (hasReachable && hasRuntimeEvidence)
|
||||
{
|
||||
overallState = ReachabilityState.Reachable; // Confirmed reachable
|
||||
}
|
||||
else if (hasReachable)
|
||||
{
|
||||
overallState = ReachabilityState.Reachable; // Statically reachable
|
||||
}
|
||||
else if (hasUnreachable && avgConfidence >= 0.7)
|
||||
{
|
||||
overallState = ReachabilityState.Unreachable;
|
||||
}
|
||||
else if (hasUnreachable)
|
||||
{
|
||||
overallState = ReachabilityState.UnderInvestigation; // Low confidence
|
||||
}
|
||||
else
|
||||
{
|
||||
overallState = ReachabilityState.Unknown;
|
||||
}
|
||||
|
||||
return (overallState, avgConfidence, hasRuntimeEvidence);
|
||||
}
|
||||
|
||||
private static AnalysisMethod DetermineAnalysisMethod(SignalsReachabilityFactResponse response)
|
||||
{
|
||||
var hasStaticAnalysis = response.States?.Count > 0;
|
||||
var hasRuntimeAnalysis = response.RuntimeFacts?.Count > 0 ||
|
||||
response.States?.Any(s => s.Evidence?.RuntimeHits?.Count > 0) == true;
|
||||
|
||||
if (hasStaticAnalysis && hasRuntimeAnalysis)
|
||||
{
|
||||
return AnalysisMethod.Hybrid;
|
||||
}
|
||||
|
||||
if (hasRuntimeAnalysis)
|
||||
{
|
||||
return AnalysisMethod.Dynamic;
|
||||
}
|
||||
|
||||
if (hasStaticAnalysis)
|
||||
{
|
||||
return AnalysisMethod.Static;
|
||||
}
|
||||
|
||||
return AnalysisMethod.Manual;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? BuildMetadata(SignalsReachabilityFactResponse response)
|
||||
{
|
||||
var metadata = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
|
||||
if (!string.IsNullOrEmpty(response.CallgraphId))
|
||||
{
|
||||
metadata["callgraph_id"] = response.CallgraphId;
|
||||
}
|
||||
|
||||
if (response.Subject is not null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(response.Subject.ScanId))
|
||||
{
|
||||
metadata["scan_id"] = response.Subject.ScanId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(response.Subject.ImageDigest))
|
||||
{
|
||||
metadata["image_digest"] = response.Subject.ImageDigest;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.EntryPoints?.Count > 0)
|
||||
{
|
||||
metadata["entry_points"] = response.EntryPoints;
|
||||
}
|
||||
|
||||
if (response.Uncertainty is not null)
|
||||
{
|
||||
metadata["uncertainty_tier"] = response.Uncertainty.AggregateTier;
|
||||
metadata["uncertainty_risk_score"] = response.Uncertainty.RiskScore;
|
||||
}
|
||||
|
||||
if (response.EdgeBundles?.Count > 0)
|
||||
{
|
||||
metadata["edge_bundle_count"] = response.EdgeBundles.Count;
|
||||
metadata["has_revoked_edges"] = response.EdgeBundles.Any(b => b.HasRevokedEdges);
|
||||
}
|
||||
|
||||
if (response.HasQuarantinedEdges)
|
||||
{
|
||||
metadata["has_quarantined_edges"] = true;
|
||||
}
|
||||
|
||||
metadata["unknowns_count"] = response.UnknownsCount;
|
||||
metadata["unknowns_pressure"] = response.UnknownsPressure;
|
||||
metadata["risk_score"] = response.RiskScore;
|
||||
|
||||
if (!string.IsNullOrEmpty(response.RuntimeFactsBatchUri))
|
||||
{
|
||||
metadata["runtime_facts_cas_uri"] = response.RuntimeFactsBatchUri;
|
||||
}
|
||||
|
||||
// Extract call paths from states for evidence
|
||||
var callPaths = response.States?
|
||||
.Where(s => s.Path?.Count > 0)
|
||||
.Select(s => s.Path!)
|
||||
.ToList();
|
||||
|
||||
if (callPaths?.Count > 0)
|
||||
{
|
||||
metadata["call_paths"] = callPaths;
|
||||
}
|
||||
|
||||
// Extract runtime hits from states
|
||||
var runtimeHits = response.States?
|
||||
.Where(s => s.Evidence?.RuntimeHits?.Count > 0)
|
||||
.SelectMany(s => s.Evidence!.RuntimeHits!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (runtimeHits?.Count > 0)
|
||||
{
|
||||
metadata["runtime_hits"] = runtimeHits;
|
||||
}
|
||||
|
||||
// Extract lattice states
|
||||
var latticeStates = response.States?
|
||||
.Where(s => !string.IsNullOrEmpty(s.LatticeState))
|
||||
.Select(s => new { s.Target, s.LatticeState, s.Confidence })
|
||||
.ToList();
|
||||
|
||||
if (latticeStates?.Count > 0)
|
||||
{
|
||||
metadata["lattice_states"] = latticeStates;
|
||||
}
|
||||
|
||||
return metadata.Count > 0 ? metadata : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user