using Microsoft.Extensions.Logging; namespace StellaOps.Policy.Engine.ReachabilityFacts; /// /// Implementation of that delegates to the Signals service. /// Maps between Signals' ReachabilityFactDocument and Policy's ReachabilityFact. /// public sealed class SignalsBackedReachabilityFactsStore : IReachabilityFactsStore { private readonly IReachabilityFactsSignalsClient _signalsClient; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public SignalsBackedReachabilityFactsStore( IReachabilityFactsSignalsClient signalsClient, ILogger logger, TimeProvider? timeProvider = null) { _signalsClient = signalsClient ?? throw new ArgumentNullException(nameof(signalsClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } /// public async Task 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); } /// public async Task> GetBatchAsync( IReadOnlyList keys, CancellationToken cancellationToken = default) { if (keys.Count == 0) { return new Dictionary(); } // 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(); 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; } /// public Task> 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>(Array.Empty()); } /// 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; } /// public Task SaveBatchAsync(IReadOnlyList 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; } /// 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; } /// public Task CountAsync(string tenantId, CancellationToken cancellationToken = default) { // Not available from Signals API return Task.FromResult(0L); } /// /// Triggers recomputation of reachability for a subject. /// public Task 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? BuildMetadata(SignalsReachabilityFactResponse response) { var metadata = new Dictionary(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; } }