348 lines
12 KiB
C#
348 lines
12 KiB
C#
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Scanner.Core;
|
|
using StellaOps.Scanner.Reachability.Slices;
|
|
|
|
namespace StellaOps.Scanner.Reachability.Runtime;
|
|
|
|
/// <summary>
|
|
/// Configuration for runtime-static graph merging.
|
|
/// </summary>
|
|
public sealed record RuntimeStaticMergeOptions
|
|
{
|
|
/// <summary>
|
|
/// Confidence boost for edges observed at runtime. Default: 1.0 (max).
|
|
/// </summary>
|
|
public double ObservedConfidenceBoost { get; init; } = 1.0;
|
|
|
|
/// <summary>
|
|
/// Base confidence for runtime-only edges (not in static graph). Default: 0.9.
|
|
/// </summary>
|
|
public double RuntimeOnlyConfidence { get; init; } = 0.9;
|
|
|
|
/// <summary>
|
|
/// Minimum observation count to include a runtime-only edge. Default: 1.
|
|
/// </summary>
|
|
public int MinObservationCount { get; init; } = 1;
|
|
|
|
/// <summary>
|
|
/// Maximum age of observations to consider fresh. Default: 7 days.
|
|
/// </summary>
|
|
public TimeSpan FreshnessWindow { get; init; } = TimeSpan.FromDays(7);
|
|
|
|
/// <summary>
|
|
/// Whether to add edges from runtime that don't exist in static graph.
|
|
/// </summary>
|
|
public bool AddRuntimeOnlyEdges { get; init; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of merging runtime traces with static call graph.
|
|
/// </summary>
|
|
public sealed record RuntimeStaticMergeResult
|
|
{
|
|
/// <summary>
|
|
/// Merged graph with runtime annotations.
|
|
/// </summary>
|
|
public required CallGraph MergedGraph { get; init; }
|
|
|
|
/// <summary>
|
|
/// Statistics about the merge operation.
|
|
/// </summary>
|
|
public required MergeStatistics Statistics { get; init; }
|
|
|
|
/// <summary>
|
|
/// Edges that were observed at runtime.
|
|
/// </summary>
|
|
public ImmutableArray<ObservedEdge> ObservedEdges { get; init; } = ImmutableArray<ObservedEdge>.Empty;
|
|
|
|
/// <summary>
|
|
/// Edges added from runtime that weren't in static graph.
|
|
/// </summary>
|
|
public ImmutableArray<RuntimeOnlyEdge> RuntimeOnlyEdges { get; init; } = ImmutableArray<RuntimeOnlyEdge>.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Statistics from the merge operation.
|
|
/// </summary>
|
|
public sealed record MergeStatistics
|
|
{
|
|
public int StaticEdgeCount { get; init; }
|
|
public int RuntimeEventCount { get; init; }
|
|
public int MatchedEdgeCount { get; init; }
|
|
public int RuntimeOnlyEdgeCount { get; init; }
|
|
public int UnmatchedStaticEdgeCount { get; init; }
|
|
public double CoverageRatio => StaticEdgeCount > 0
|
|
? (double)MatchedEdgeCount / StaticEdgeCount
|
|
: 0.0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An edge that was observed at runtime.
|
|
/// </summary>
|
|
public sealed record ObservedEdge
|
|
{
|
|
public required string From { get; init; }
|
|
public required string To { get; init; }
|
|
public required DateTimeOffset FirstObserved { get; init; }
|
|
public required DateTimeOffset LastObserved { get; init; }
|
|
public required int ObservationCount { get; init; }
|
|
public string? TraceDigest { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// An edge that only exists in runtime observations (dynamic dispatch, etc).
|
|
/// </summary>
|
|
public sealed record RuntimeOnlyEdge
|
|
{
|
|
public required string From { get; init; }
|
|
public required string To { get; init; }
|
|
public required DateTimeOffset FirstObserved { get; init; }
|
|
public required DateTimeOffset LastObserved { get; init; }
|
|
public required int ObservationCount { get; init; }
|
|
public required string Origin { get; init; } // "runtime", "dynamic_dispatch", etc.
|
|
public string? TraceDigest { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a runtime call event from eBPF/ETW collectors.
|
|
/// </summary>
|
|
public sealed record RuntimeCallEvent
|
|
{
|
|
public required ulong Timestamp { get; init; }
|
|
public required uint Pid { get; init; }
|
|
public required uint Tid { get; init; }
|
|
public required string CallerSymbol { get; init; }
|
|
public required string CalleeSymbol { get; init; }
|
|
public required string BinaryPath { get; init; }
|
|
public string? TraceDigest { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Merges runtime trace observations with static call graphs.
|
|
/// </summary>
|
|
public sealed class RuntimeStaticMerger
|
|
{
|
|
private readonly RuntimeStaticMergeOptions _options;
|
|
private readonly ILogger<RuntimeStaticMerger> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public RuntimeStaticMerger(
|
|
RuntimeStaticMergeOptions? options = null,
|
|
ILogger<RuntimeStaticMerger>? logger = null,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_options = options ?? new RuntimeStaticMergeOptions();
|
|
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RuntimeStaticMerger>.Instance;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Merge runtime events into a static call graph.
|
|
/// </summary>
|
|
public RuntimeStaticMergeResult Merge(
|
|
CallGraph staticGraph,
|
|
IEnumerable<RuntimeCallEvent> runtimeEvents)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(staticGraph);
|
|
ArgumentNullException.ThrowIfNull(runtimeEvents);
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
var freshnessThreshold = now - _options.FreshnessWindow;
|
|
|
|
// Index static edges for fast lookup
|
|
var staticEdgeIndex = BuildStaticEdgeIndex(staticGraph);
|
|
|
|
// Aggregate runtime events by edge
|
|
var runtimeEdgeAggregates = AggregateRuntimeEvents(runtimeEvents);
|
|
|
|
var observedEdges = new List<ObservedEdge>();
|
|
var runtimeOnlyEdges = new List<RuntimeOnlyEdge>();
|
|
var modifiedEdges = new List<CallEdge>();
|
|
var matchedEdgeKeys = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
foreach (var (edgeKey, aggregate) in runtimeEdgeAggregates)
|
|
{
|
|
// Skip stale observations
|
|
if (aggregate.LastObserved < freshnessThreshold)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Skip low observation counts
|
|
if (aggregate.ObservationCount < _options.MinObservationCount)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (staticEdgeIndex.TryGetValue(edgeKey, out var staticEdge))
|
|
{
|
|
// Edge exists in static graph - mark as observed
|
|
matchedEdgeKeys.Add(edgeKey);
|
|
|
|
var observedMetadata = new ObservedEdgeMetadata
|
|
{
|
|
FirstObserved = aggregate.FirstObserved,
|
|
LastObserved = aggregate.LastObserved,
|
|
ObservationCount = aggregate.ObservationCount,
|
|
TraceDigest = aggregate.TraceDigest
|
|
};
|
|
|
|
var boostedEdge = staticEdge with
|
|
{
|
|
Confidence = _options.ObservedConfidenceBoost,
|
|
Observed = observedMetadata
|
|
};
|
|
|
|
modifiedEdges.Add(boostedEdge);
|
|
observedEdges.Add(new ObservedEdge
|
|
{
|
|
From = aggregate.From,
|
|
To = aggregate.To,
|
|
FirstObserved = aggregate.FirstObserved,
|
|
LastObserved = aggregate.LastObserved,
|
|
ObservationCount = aggregate.ObservationCount,
|
|
TraceDigest = aggregate.TraceDigest
|
|
});
|
|
}
|
|
else if (_options.AddRuntimeOnlyEdges)
|
|
{
|
|
// Edge only exists in runtime - add it
|
|
var runtimeEdge = new CallEdge
|
|
{
|
|
From = aggregate.From,
|
|
To = aggregate.To,
|
|
Kind = CallEdgeKind.Dynamic,
|
|
Confidence = ComputeRuntimeOnlyConfidence(aggregate),
|
|
Evidence = "runtime_observation",
|
|
Observed = new ObservedEdgeMetadata
|
|
{
|
|
FirstObserved = aggregate.FirstObserved,
|
|
LastObserved = aggregate.LastObserved,
|
|
ObservationCount = aggregate.ObservationCount,
|
|
TraceDigest = aggregate.TraceDigest
|
|
}
|
|
};
|
|
|
|
modifiedEdges.Add(runtimeEdge);
|
|
runtimeOnlyEdges.Add(new RuntimeOnlyEdge
|
|
{
|
|
From = aggregate.From,
|
|
To = aggregate.To,
|
|
FirstObserved = aggregate.FirstObserved,
|
|
LastObserved = aggregate.LastObserved,
|
|
ObservationCount = aggregate.ObservationCount,
|
|
Origin = "runtime",
|
|
TraceDigest = aggregate.TraceDigest
|
|
});
|
|
}
|
|
}
|
|
|
|
// Build merged edge list: unmatched static + modified
|
|
var mergedEdges = new List<CallEdge>();
|
|
foreach (var edge in staticGraph.Edges)
|
|
{
|
|
var key = BuildEdgeKey(edge.From, edge.To);
|
|
if (!matchedEdgeKeys.Contains(key))
|
|
{
|
|
mergedEdges.Add(edge);
|
|
}
|
|
}
|
|
mergedEdges.AddRange(modifiedEdges);
|
|
|
|
var mergedGraph = staticGraph with
|
|
{
|
|
Edges = mergedEdges.ToImmutableArray()
|
|
};
|
|
|
|
var statistics = new MergeStatistics
|
|
{
|
|
StaticEdgeCount = staticGraph.Edges.Length,
|
|
RuntimeEventCount = runtimeEdgeAggregates.Count,
|
|
MatchedEdgeCount = matchedEdgeKeys.Count,
|
|
RuntimeOnlyEdgeCount = runtimeOnlyEdges.Count,
|
|
UnmatchedStaticEdgeCount = staticGraph.Edges.Length - matchedEdgeKeys.Count
|
|
};
|
|
|
|
_logger.LogInformation(
|
|
"Merged runtime traces: {Matched}/{Static} edges observed ({Coverage:P1}), {RuntimeOnly} runtime-only edges added",
|
|
statistics.MatchedEdgeCount,
|
|
statistics.StaticEdgeCount,
|
|
statistics.CoverageRatio,
|
|
statistics.RuntimeOnlyEdgeCount);
|
|
|
|
return new RuntimeStaticMergeResult
|
|
{
|
|
MergedGraph = mergedGraph,
|
|
Statistics = statistics,
|
|
ObservedEdges = observedEdges.ToImmutableArray(),
|
|
RuntimeOnlyEdges = runtimeOnlyEdges.ToImmutableArray()
|
|
};
|
|
}
|
|
|
|
private static Dictionary<string, CallEdge> BuildStaticEdgeIndex(CallGraph graph)
|
|
{
|
|
var index = new Dictionary<string, CallEdge>(StringComparer.Ordinal);
|
|
foreach (var edge in graph.Edges)
|
|
{
|
|
var key = BuildEdgeKey(edge.From, edge.To);
|
|
index.TryAdd(key, edge);
|
|
}
|
|
return index;
|
|
}
|
|
|
|
private static Dictionary<string, RuntimeEdgeAggregate> AggregateRuntimeEvents(
|
|
IEnumerable<RuntimeCallEvent> events)
|
|
{
|
|
var aggregates = new Dictionary<string, RuntimeEdgeAggregate>(StringComparer.Ordinal);
|
|
|
|
foreach (var evt in events)
|
|
{
|
|
var key = BuildEdgeKey(evt.CallerSymbol, evt.CalleeSymbol);
|
|
|
|
if (aggregates.TryGetValue(key, out var existing))
|
|
{
|
|
aggregates[key] = existing with
|
|
{
|
|
ObservationCount = existing.ObservationCount + 1,
|
|
LastObserved = DateTimeOffset.FromUnixTimeMilliseconds((long)(evt.Timestamp / 1_000_000))
|
|
};
|
|
}
|
|
else
|
|
{
|
|
var timestamp = DateTimeOffset.FromUnixTimeMilliseconds((long)(evt.Timestamp / 1_000_000));
|
|
aggregates[key] = new RuntimeEdgeAggregate
|
|
{
|
|
From = evt.CallerSymbol,
|
|
To = evt.CalleeSymbol,
|
|
FirstObserved = timestamp,
|
|
LastObserved = timestamp,
|
|
ObservationCount = 1,
|
|
TraceDigest = evt.TraceDigest
|
|
};
|
|
}
|
|
}
|
|
|
|
return aggregates;
|
|
}
|
|
|
|
private double ComputeRuntimeOnlyConfidence(RuntimeEdgeAggregate aggregate)
|
|
{
|
|
// Higher observation count = higher confidence, capped at runtime-only max
|
|
var countFactor = Math.Min(1.0, aggregate.ObservationCount / 10.0);
|
|
return _options.RuntimeOnlyConfidence * (0.5 + 0.5 * countFactor);
|
|
}
|
|
|
|
private static string BuildEdgeKey(string from, string to) => $"{from}->{to}";
|
|
|
|
private sealed record RuntimeEdgeAggregate
|
|
{
|
|
public required string From { get; init; }
|
|
public required string To { get; init; }
|
|
public required DateTimeOffset FirstObserved { get; init; }
|
|
public required DateTimeOffset LastObserved { get; init; }
|
|
public required int ObservationCount { get; init; }
|
|
public string? TraceDigest { get; init; }
|
|
}
|
|
}
|