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
378 lines
12 KiB
C#
378 lines
12 KiB
C#
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;
|
|
}
|
|
}
|