doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,388 @@
// -----------------------------------------------------------------------------
// TetragonAgentCapability.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-004 - Extend existing agent framework for Tetragon
// Description: Agent capability for Tetragon eBPF event collection
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Agent.Tetragon;
/// <summary>
/// Agent capability for Tetragon eBPF event collection.
/// Extends the existing agent framework following established patterns.
/// </summary>
public sealed class TetragonAgentCapability : IAgentCapability
{
private readonly ITetragonGrpcClient _tetragonClient;
private readonly ITetragonEventAdapter _eventAdapter;
private readonly ITetragonHotSymbolBridge _hotSymbolBridge;
private readonly ITetragonWitnessBridge _witnessBridge;
private readonly ITetragonPrivacyFilter _privacyFilter;
private readonly TetragonAgentOptions _options;
private readonly ILogger<TetragonAgentCapability> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Agent.Tetragon");
private CancellationTokenSource? _collectionCts;
private Task? _collectionTask;
private bool _initialized;
/// <inheritdoc />
public string Name => "tetragon";
/// <inheritdoc />
public string Version => "1.0.0";
/// <inheritdoc />
public IReadOnlyList<string> SupportedTaskTypes =>
[
"tetragon.start-collection",
"tetragon.stop-collection",
"tetragon.get-status",
"tetragon.flush-observations"
];
/// <summary>
/// Creates a new Tetragon agent capability.
/// </summary>
public TetragonAgentCapability(
ITetragonGrpcClient tetragonClient,
ITetragonEventAdapter eventAdapter,
ITetragonHotSymbolBridge hotSymbolBridge,
ITetragonWitnessBridge witnessBridge,
ITetragonPrivacyFilter privacyFilter,
IOptions<TetragonAgentOptions> options,
ILogger<TetragonAgentCapability> logger)
{
_tetragonClient = tetragonClient ?? throw new ArgumentNullException(nameof(tetragonClient));
_eventAdapter = eventAdapter ?? throw new ArgumentNullException(nameof(eventAdapter));
_hotSymbolBridge = hotSymbolBridge ?? throw new ArgumentNullException(nameof(hotSymbolBridge));
_witnessBridge = witnessBridge ?? throw new ArgumentNullException(nameof(witnessBridge));
_privacyFilter = privacyFilter ?? throw new ArgumentNullException(nameof(privacyFilter));
_options = options?.Value ?? new TetragonAgentOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<bool> InitializeAsync(CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("Initialize");
try
{
_logger.LogInformation("Initializing Tetragon agent capability...");
// Verify Tetragon connection
var connected = await _tetragonClient.ConnectAsync(ct);
if (!connected)
{
_logger.LogError("Failed to connect to Tetragon gRPC endpoint");
return false;
}
// Verify Tetragon health
var health = await _tetragonClient.GetHealthAsync(ct);
if (!health.IsHealthy)
{
_logger.LogWarning("Tetragon is not healthy: {Status}", health.Status);
return false;
}
_logger.LogInformation(
"Connected to Tetragon v{Version}, policies: {PolicyCount}",
health.Version, health.ActivePolicyCount);
_initialized = true;
// Auto-start collection if configured
if (_options.AutoStartCollection)
{
await StartCollectionAsync(ct);
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error initializing Tetragon capability");
return false;
}
}
/// <inheritdoc />
public async Task<AgentTaskResult> ExecuteAsync(AgentTaskInfo task, CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("ExecuteTask");
activity?.SetTag("task_type", task.TaskType);
activity?.SetTag("task_id", task.TaskId);
return task.TaskType switch
{
"tetragon.start-collection" => await ExecuteStartCollectionAsync(task, ct),
"tetragon.stop-collection" => await ExecuteStopCollectionAsync(task, ct),
"tetragon.get-status" => await ExecuteGetStatusAsync(task, ct),
"tetragon.flush-observations" => await ExecuteFlushAsync(task, ct),
_ => AgentTaskResult.Failed(task.TaskId, $"Unknown task type: {task.TaskType}")
};
}
/// <inheritdoc />
public async Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default)
{
try
{
if (!_initialized)
{
return new CapabilityHealthStatus
{
IsHealthy = false,
Status = "Not initialized",
LastChecked = DateTimeOffset.UtcNow
};
}
var tetragonHealth = await _tetragonClient.GetHealthAsync(ct);
return new CapabilityHealthStatus
{
IsHealthy = tetragonHealth.IsHealthy && _collectionTask?.IsCompleted != true,
Status = _collectionTask != null ? "Collecting" : "Idle",
LastChecked = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["tetragon_version"] = tetragonHealth.Version ?? "unknown",
["active_policies"] = tetragonHealth.ActivePolicyCount,
["collection_running"] = _collectionTask != null && !_collectionTask.IsCompleted,
["privacy_mode"] = _privacyFilter.GetStatistics().SymbolIdOnlyMode
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Health check failed");
return new CapabilityHealthStatus
{
IsHealthy = false,
Status = $"Health check failed: {ex.Message}",
LastChecked = DateTimeOffset.UtcNow
};
}
}
private async Task<AgentTaskResult> ExecuteStartCollectionAsync(AgentTaskInfo task, CancellationToken ct)
{
if (_collectionTask != null && !_collectionTask.IsCompleted)
{
return AgentTaskResult.Failed(task.TaskId, "Collection already running");
}
await StartCollectionAsync(ct);
return AgentTaskResult.Succeeded(task.TaskId, new Dictionary<string, object>
{
["status"] = "started",
["started_at"] = DateTimeOffset.UtcNow
});
}
private async Task<AgentTaskResult> ExecuteStopCollectionAsync(AgentTaskInfo task, CancellationToken ct)
{
if (_collectionTask == null || _collectionTask.IsCompleted)
{
return AgentTaskResult.Failed(task.TaskId, "Collection not running");
}
await StopCollectionAsync();
return AgentTaskResult.Succeeded(task.TaskId, new Dictionary<string, object>
{
["status"] = "stopped",
["stopped_at"] = DateTimeOffset.UtcNow
});
}
private Task<AgentTaskResult> ExecuteGetStatusAsync(AgentTaskInfo task, CancellationToken ct)
{
var privacyStats = _privacyFilter.GetStatistics();
return Task.FromResult(AgentTaskResult.Succeeded(task.TaskId, new Dictionary<string, object>
{
["initialized"] = _initialized,
["collection_running"] = _collectionTask != null && !_collectionTask.IsCompleted,
["privacy_symbol_id_only"] = privacyStats.SymbolIdOnlyMode,
["privacy_allowed_namespaces"] = privacyStats.AllowedNamespacesCount
}));
}
private async Task<AgentTaskResult> ExecuteFlushAsync(AgentTaskInfo task, CancellationToken ct)
{
var flushed = await _hotSymbolBridge.FlushAsync(ct);
return AgentTaskResult.Succeeded(task.TaskId, new Dictionary<string, object>
{
["flushed_count"] = flushed,
["flushed_at"] = DateTimeOffset.UtcNow
});
}
private async Task StartCollectionAsync(CancellationToken ct)
{
_collectionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_collectionTask = RunCollectionLoopAsync(_collectionCts.Token);
_logger.LogInformation("Started Tetragon event collection");
await Task.CompletedTask;
}
private async Task StopCollectionAsync()
{
if (_collectionCts != null)
{
await _collectionCts.CancelAsync();
if (_collectionTask != null)
{
try
{
await _collectionTask;
}
catch (OperationCanceledException)
{
// Expected
}
}
_collectionCts.Dispose();
_collectionCts = null;
_collectionTask = null;
}
// Final flush
await _hotSymbolBridge.FlushAsync();
_logger.LogInformation("Stopped Tetragon event collection");
}
private async Task RunCollectionLoopAsync(CancellationToken ct)
{
_logger.LogDebug("Starting collection loop");
try
{
var eventStream = _tetragonClient.StreamEventsAsync(ct);
// Apply privacy filter
var filteredStream = _privacyFilter.FilterStreamAsync(eventStream, ct);
// Convert to RuntimeCallEvents
var runtimeEvents = _eventAdapter.AdaptStreamAsync(filteredStream, ct);
await foreach (var evt in runtimeEvents.WithCancellation(ct))
{
// Record to hot symbol index
if (!string.IsNullOrEmpty(evt.ContainerId))
{
var imageDigest = await ResolveImageDigestAsync(evt.ContainerId, ct);
if (!string.IsNullOrEmpty(imageDigest))
{
await _hotSymbolBridge.RecordObservationsAsync(imageDigest, new[] { evt }, ct);
}
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogDebug("Collection loop cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in collection loop");
throw;
}
}
private Task<string?> ResolveImageDigestAsync(string containerId, CancellationToken ct)
{
// In a real implementation, this would query the container runtime
// to resolve container ID -> image digest
// For now, return a placeholder
return Task.FromResult<string?>($"sha256:{containerId.GetHashCode():X64}".Substring(0, 71));
}
}
/// <summary>
/// Configuration for the Tetragon agent.
/// </summary>
public sealed record TetragonAgentOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon:Agent";
/// <summary>Tetragon gRPC address (default: localhost:54321).</summary>
public string TetragonGrpcAddress { get; init; } = "localhost:54321";
/// <summary>Whether to auto-start collection on initialization.</summary>
public bool AutoStartCollection { get; init; } = true;
/// <summary>Connection timeout.</summary>
public TimeSpan ConnectionTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>Reconnection delay on failure.</summary>
public TimeSpan ReconnectionDelay { get; init; } = TimeSpan.FromSeconds(5);
}
// Interface and model placeholders - should import from Agent.Core
/// <summary>
/// Agent capability interface.
/// </summary>
public interface IAgentCapability
{
string Name { get; }
string Version { get; }
IReadOnlyList<string> SupportedTaskTypes { get; }
Task<bool> InitializeAsync(CancellationToken ct = default);
Task<AgentTaskResult> ExecuteAsync(AgentTaskInfo task, CancellationToken ct = default);
Task<CapabilityHealthStatus> CheckHealthAsync(CancellationToken ct = default);
}
/// <summary>
/// Task information.
/// </summary>
public sealed record AgentTaskInfo
{
public required string TaskId { get; init; }
public required string TaskType { get; init; }
public IDictionary<string, object>? Parameters { get; init; }
}
/// <summary>
/// Task result.
/// </summary>
public sealed record AgentTaskResult
{
public required string TaskId { get; init; }
public required bool Success { get; init; }
public string? ErrorMessage { get; init; }
public IDictionary<string, object>? Output { get; init; }
public static AgentTaskResult Succeeded(string taskId, IDictionary<string, object>? output = null)
=> new() { TaskId = taskId, Success = true, Output = output };
public static AgentTaskResult Failed(string taskId, string error)
=> new() { TaskId = taskId, Success = false, ErrorMessage = error };
}
/// <summary>
/// Capability health status.
/// </summary>
public sealed record CapabilityHealthStatus
{
public required bool IsHealthy { get; init; }
public required string Status { get; init; }
public required DateTimeOffset LastChecked { get; init; }
public IDictionary<string, object>? Details { get; init; }
}

View File

@@ -0,0 +1,314 @@
// -----------------------------------------------------------------------------
// TetragonGrpcClient.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-004 - Extend existing agent framework for Tetragon
// Description: gRPC client for Tetragon export API
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Agent.Tetragon;
/// <summary>
/// gRPC client for Tetragon's export API.
/// Connects to the Tetragon daemon to stream eBPF events.
/// </summary>
public sealed class TetragonGrpcClient : ITetragonGrpcClient, IDisposable
{
private readonly TetragonGrpcClientOptions _options;
private readonly ILogger<TetragonGrpcClient> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Tetragon.GrpcClient");
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private HttpClient? _httpClient;
private bool _connected;
private bool _disposed;
/// <summary>
/// Creates a new Tetragon gRPC client.
/// </summary>
public TetragonGrpcClient(
IOptions<TetragonGrpcClientOptions> options,
ILogger<TetragonGrpcClient> logger)
{
_options = options?.Value ?? new TetragonGrpcClientOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<bool> ConnectAsync(CancellationToken ct = default)
{
await _connectionLock.WaitAsync(ct);
try
{
if (_connected)
{
return true;
}
_logger.LogDebug("Connecting to Tetragon at {Address}", _options.Address);
// Create HTTP client for gRPC-Web or REST fallback
_httpClient = new HttpClient
{
BaseAddress = new Uri(_options.Address),
Timeout = _options.ConnectionTimeout
};
// Verify connection with health check
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_options.ConnectionTimeout);
try
{
var health = await GetHealthAsync(cts.Token);
_connected = health.IsHealthy;
if (_connected)
{
_logger.LogInformation("Connected to Tetragon v{Version}", health.Version);
}
return _connected;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to connect to Tetragon");
return false;
}
}
finally
{
_connectionLock.Release();
}
}
/// <inheritdoc />
public async Task<TetragonHealthStatus> GetHealthAsync(CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("GetHealth");
try
{
if (_httpClient == null)
{
return new TetragonHealthStatus
{
IsHealthy = false,
Status = "Not connected"
};
}
var response = await _httpClient.GetAsync("/v1/health", ct);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(ct);
var healthResponse = JsonSerializer.Deserialize<TetragonHealthResponse>(content);
return new TetragonHealthStatus
{
IsHealthy = true,
Status = "healthy",
Version = healthResponse?.Version,
ActivePolicyCount = healthResponse?.ActivePolicies ?? 0
};
}
return new TetragonHealthStatus
{
IsHealthy = false,
Status = $"HTTP {(int)response.StatusCode}"
};
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Health check failed");
return new TetragonHealthStatus
{
IsHealthy = false,
Status = ex.Message
};
}
}
/// <inheritdoc />
public async IAsyncEnumerable<TetragonEvent> StreamEventsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("StreamEvents");
if (_httpClient == null || !_connected)
{
_logger.LogWarning("Not connected to Tetragon");
yield break;
}
_logger.LogDebug("Starting event stream from Tetragon");
var request = new HttpRequestMessage(HttpMethod.Get, "/v1/events");
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/x-ndjson"));
HttpResponseMessage? response = null;
Stream? stream = null;
StreamReader? reader = null;
try
{
response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
ct);
response.EnsureSuccessStatusCode();
stream = await response.Content.ReadAsStreamAsync(ct);
reader = new StreamReader(stream);
while (!ct.IsCancellationRequested && !reader.EndOfStream)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
TetragonEvent? evt = null;
try
{
evt = JsonSerializer.Deserialize<TetragonEvent>(line, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch (JsonException ex)
{
_logger.LogDebug(ex, "Failed to parse Tetragon event: {Line}", line);
continue;
}
if (evt != null)
{
yield return evt;
}
}
}
finally
{
reader?.Dispose();
stream?.Dispose();
response?.Dispose();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<TetragonPolicy>> GetPoliciesAsync(CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("GetPolicies");
if (_httpClient == null)
{
return [];
}
try
{
var response = await _httpClient.GetAsync("/v1/policies", ct);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<List<TetragonPolicy>>(content) ?? [];
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get policies");
return [];
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_httpClient?.Dispose();
_connectionLock.Dispose();
_activitySource.Dispose();
}
private sealed record TetragonHealthResponse
{
public string? Version { get; init; }
public int ActivePolicies { get; init; }
}
}
/// <summary>
/// Interface for Tetragon gRPC client.
/// </summary>
public interface ITetragonGrpcClient
{
/// <summary>
/// Connects to Tetragon.
/// </summary>
Task<bool> ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Gets Tetragon health status.
/// </summary>
Task<TetragonHealthStatus> GetHealthAsync(CancellationToken ct = default);
/// <summary>
/// Streams events from Tetragon.
/// </summary>
IAsyncEnumerable<TetragonEvent> StreamEventsAsync(CancellationToken ct = default);
/// <summary>
/// Gets active policies.
/// </summary>
Task<IReadOnlyList<TetragonPolicy>> GetPoliciesAsync(CancellationToken ct = default);
}
/// <summary>
/// Tetragon health status.
/// </summary>
public sealed record TetragonHealthStatus
{
public required bool IsHealthy { get; init; }
public required string Status { get; init; }
public string? Version { get; init; }
public int ActivePolicyCount { get; init; }
}
/// <summary>
/// Tetragon policy information.
/// </summary>
public sealed record TetragonPolicy
{
public string? Name { get; init; }
public string? Namespace { get; init; }
public bool Enabled { get; init; }
}
/// <summary>
/// Configuration for the Tetragon gRPC client.
/// </summary>
public sealed record TetragonGrpcClientOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon:GrpcClient";
/// <summary>Tetragon address (default: http://localhost:54321).</summary>
public string Address { get; init; } = "http://localhost:54321";
/// <summary>Connection timeout.</summary>
public TimeSpan ConnectionTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>Request timeout.</summary>
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(60);
}

View File

@@ -0,0 +1,625 @@
// -----------------------------------------------------------------------------
// TetragonPerformanceBenchmarks.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-009 - Create performance benchmarks
// Description: Performance benchmarks for Tetragon runtime instrumentation
// -----------------------------------------------------------------------------
using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Loggers;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace StellaOps.RuntimeInstrumentation.Tetragon.Tests.Benchmarks;
/// <summary>
/// Performance benchmarks for Tetragon runtime instrumentation.
///
/// Target KPIs (from runtime-agents-architecture.md):
/// - CPU overhead on monitored workloads: &lt;5%
/// - Memory overhead of agent: &lt;100MB
/// - Capture latency (P95): &lt;100ms
/// - Throughput: &gt;10,000 events/second
/// </summary>
[Config(typeof(TetragonBenchmarkConfig))]
[MemoryDiagnoser]
[RankColumn]
public class TetragonPerformanceBenchmarks
{
private TetragonPrivacyFilter _privacyFilter = null!;
private TetragonHotSymbolBridge _hotSymbolBridge = null!;
private TetragonFrameCanonicalizer _frameCanonicalizer = null!;
private Mock<IHotSymbolRepository> _mockRepository = null!;
private Mock<ISymbolResolver> _mockSymbolResolver = null!;
private Mock<IBuildIdResolver> _mockBuildIdResolver = null!;
private List<TetragonEvent> _testEvents = null!;
private List<RuntimeCallEvent> _testRuntimeEvents = null!;
private List<TetragonStackFrame> _testFrames = null!;
[GlobalSetup]
public void Setup()
{
_mockRepository = new Mock<IHotSymbolRepository>();
_mockRepository.Setup(r => r.IngestBatchAsync(It.IsAny<IEnumerable<SymbolObservation>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0);
_mockSymbolResolver = new Mock<ISymbolResolver>();
_mockSymbolResolver.Setup(r => r.ResolveAsync(It.IsAny<ulong>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResolvedSymbol { Name = "test_function", Confidence = 0.9 });
_mockBuildIdResolver = new Mock<IBuildIdResolver>();
_mockBuildIdResolver.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("abc123def456");
var privacyOptions = Options.Create(new TetragonPrivacyOptions
{
RedactArguments = true,
UseDefaultRedactionPatterns = true,
SymbolIdOnlyMode = false
});
_privacyFilter = new TetragonPrivacyFilter(privacyOptions, Mock.Of<ILogger<TetragonPrivacyFilter>>());
var bridgeOptions = Options.Create(new TetragonHotSymbolBridgeOptions
{
AggregationWindowSeconds = 60,
MinConfidenceThreshold = 0.2
});
_hotSymbolBridge = new TetragonHotSymbolBridge(_mockRepository.Object, bridgeOptions, Mock.Of<ILogger<TetragonHotSymbolBridge>>());
var canonicalizerOptions = Options.Create(new TetragonCanonicalizerOptions());
_frameCanonicalizer = new TetragonFrameCanonicalizer(
_mockSymbolResolver.Object,
_mockBuildIdResolver.Object,
canonicalizerOptions,
Mock.Of<ILogger<TetragonFrameCanonicalizer>>());
// Generate test data
_testEvents = GenerateTestEvents(10000);
_testRuntimeEvents = GenerateRuntimeCallEvents(10000);
_testFrames = GenerateTestFrames(1000);
}
/// <summary>
/// Measures privacy filter throughput.
/// Target: &gt;10,000 events/second
/// </summary>
[Benchmark(Baseline = true)]
public int PrivacyFilter_SingleEvent()
{
var count = 0;
foreach (var evt in _testEvents)
{
var filtered = _privacyFilter.Filter(evt);
if (filtered != null) count++;
}
return count;
}
/// <summary>
/// Measures privacy filter with argument redaction.
/// Target: &lt;10% overhead vs baseline
/// </summary>
[Benchmark]
public int PrivacyFilter_WithRedaction()
{
var count = 0;
foreach (var evt in _testEvents)
{
if (evt.Args != null)
{
// Ensure events have args for redaction testing
evt.Args = new List<object> { "password=secret123", "normal_arg" };
}
var filtered = _privacyFilter.Filter(evt);
if (filtered != null) count++;
}
return count;
}
/// <summary>
/// Measures hot symbol bridge recording throughput.
/// Target: &gt;10,000 events/second
/// </summary>
[Benchmark]
public async Task<int> HotSymbolBridge_RecordObservations()
{
await _hotSymbolBridge.RecordObservationsAsync("sha256:test123", _testRuntimeEvents);
return _testRuntimeEvents.Count;
}
/// <summary>
/// Measures frame canonicalization latency.
/// Target: &lt;1ms per frame
/// </summary>
[Benchmark]
public async Task<int> FrameCanonicalizer_CanonicalizeBatch()
{
var count = 0;
await foreach (var frame in _frameCanonicalizer.CanonicalizeBatchAsync(_testFrames, "/usr/bin/app"))
{
count++;
}
return count;
}
/// <summary>
/// Measures function ID computation speed.
/// Target: &lt;0.1ms per call
/// </summary>
[Benchmark]
public int FunctionIdComputation()
{
var count = 0;
foreach (var frame in _testFrames)
{
var id = _frameCanonicalizer.ComputeFunctionId("abc123", frame.Symbol ?? "func", frame.Offset);
if (!string.IsNullOrEmpty(id)) count++;
}
return count;
}
/// <summary>
/// Measures demangling throughput.
/// Target: &gt;100,000 symbols/second
/// </summary>
[Benchmark]
public int Demangling_Throughput()
{
var symbols = new[] { "_ZN4test8functionEv", "_RNvCs123_4test8function", "go.test.function", "normal_func" };
var count = 0;
for (var i = 0; i < 10000; i++)
{
foreach (var symbol in symbols)
{
var demangled = _frameCanonicalizer.Demangle(symbol);
if (!string.IsNullOrEmpty(demangled)) count++;
}
}
return count;
}
private static List<TetragonEvent> GenerateTestEvents(int count)
{
var events = new List<TetragonEvent>(count);
var random = new Random(42);
for (var i = 0; i < count; i++)
{
events.Add(new TetragonEvent
{
Type = (TetragonEventType)(random.Next(4)),
Time = DateTimeOffset.UtcNow.AddMilliseconds(-random.Next(10000)),
Process = new TetragonProcess
{
Pid = random.Next(1000, 50000),
Tid = random.Next(1000, 50000),
Pod = new TetragonPod { Namespace = "stella-ops" }
},
StackTrace = new TetragonStackTrace
{
Frames = Enumerable.Range(0, random.Next(3, 10))
.Select(_ => new TetragonStackFrame
{
Address = (ulong)random.Next(0x1000, int.MaxValue),
Offset = (ulong)random.Next(0, 1000),
Symbol = $"func_{random.Next(100)}",
Module = "/usr/lib/libtest.so"
})
.ToList()
}
});
}
return events;
}
private static List<RuntimeCallEvent> GenerateRuntimeCallEvents(int count)
{
var events = new List<RuntimeCallEvent>(count);
var random = new Random(42);
for (var i = 0; i < count; i++)
{
events.Add(new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow.AddMilliseconds(-random.Next(10000)),
Source = RuntimeEventSource.Tetragon,
ProcessId = random.Next(1000, 50000),
ContainerId = $"container-{random.Next(100):D4}",
Frames = Enumerable.Range(0, random.Next(3, 10))
.Select(_ => new CanonicalStackFrame
{
Address = (ulong)random.Next(0x1000, int.MaxValue),
Symbol = $"func_{random.Next(100)}",
Confidence = random.NextDouble() * 0.5 + 0.5
})
.ToList()
});
}
return events;
}
private static List<TetragonStackFrame> GenerateTestFrames(int count)
{
var frames = new List<TetragonStackFrame>(count);
var random = new Random(42);
for (var i = 0; i < count; i++)
{
frames.Add(new TetragonStackFrame
{
Address = (ulong)random.Next(0x1000, int.MaxValue),
Offset = (ulong)random.Next(0, 1000),
Symbol = $"func_{random.Next(100)}",
Module = "/usr/lib/libtest.so",
Flags = (StackFrameFlags)random.Next(4)
});
}
return frames;
}
}
/// <summary>
/// Unit tests for Tetragon performance thresholds.
/// These tests fail CI if benchmarks regress.
/// </summary>
public sealed class TetragonPerformanceTests
{
private readonly Mock<ILogger<TetragonPrivacyFilter>> _mockPrivacyLogger;
private readonly Mock<ILogger<TetragonHotSymbolBridge>> _mockBridgeLogger;
private readonly Mock<ILogger<TetragonFrameCanonicalizer>> _mockCanonicalizerLogger;
private readonly Mock<IHotSymbolRepository> _mockRepository;
private readonly Mock<ISymbolResolver> _mockSymbolResolver;
private readonly Mock<IBuildIdResolver> _mockBuildIdResolver;
public TetragonPerformanceTests()
{
_mockPrivacyLogger = new Mock<ILogger<TetragonPrivacyFilter>>();
_mockBridgeLogger = new Mock<ILogger<TetragonHotSymbolBridge>>();
_mockCanonicalizerLogger = new Mock<ILogger<TetragonFrameCanonicalizer>>();
_mockRepository = new Mock<IHotSymbolRepository>();
_mockRepository.Setup(r => r.IngestBatchAsync(It.IsAny<IEnumerable<SymbolObservation>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0);
_mockSymbolResolver = new Mock<ISymbolResolver>();
_mockSymbolResolver.Setup(r => r.ResolveAsync(It.IsAny<ulong>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResolvedSymbol { Name = "test", Confidence = 0.9 });
_mockBuildIdResolver = new Mock<IBuildIdResolver>();
_mockBuildIdResolver.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync("abc123");
}
[Fact]
public void PrivacyFilter_ShouldProcess10000EventsInUnder1Second()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions { RedactArguments = true, UseDefaultRedactionPatterns = true });
var filter = new TetragonPrivacyFilter(options, _mockPrivacyLogger.Object);
var events = GenerateTestEvents(10000);
// Act
var sw = Stopwatch.StartNew();
var count = 0;
foreach (var evt in events)
{
if (filter.Filter(evt) != null) count++;
}
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(1000,
"Privacy filter should process 10,000 events in under 1 second (target: >10,000 events/sec)");
count.Should().BeGreaterThan(0);
}
[Fact]
public void PrivacyFilter_SingleEventLatency_ShouldBeUnder1ms()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions { RedactArguments = true, UseDefaultRedactionPatterns = true });
var filter = new TetragonPrivacyFilter(options, _mockPrivacyLogger.Object);
var evt = GenerateTestEvents(1)[0];
// Warm up
filter.Filter(evt);
// Act - measure multiple iterations for accuracy
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++)
{
filter.Filter(evt);
}
sw.Stop();
var avgMicroseconds = sw.Elapsed.TotalMicroseconds / 1000;
// Assert - should average under 100 microseconds per event
avgMicroseconds.Should().BeLessThan(100,
"Privacy filter single event latency should be under 100 microseconds");
}
[Fact]
public async Task HotSymbolBridge_ShouldProcessBatchInUnder100ms()
{
// Arrange
var options = Options.Create(new TetragonHotSymbolBridgeOptions { AggregationWindowSeconds = 60 });
var bridge = new TetragonHotSymbolBridge(_mockRepository.Object, options, _mockBridgeLogger.Object);
var events = GenerateRuntimeCallEvents(1000);
// Act
var sw = Stopwatch.StartNew();
await bridge.RecordObservationsAsync("sha256:test", events);
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(100,
"Hot symbol bridge should process 1000 events batch in under 100ms");
}
[Fact]
public async Task FrameCanonicalizer_ShouldProcessFrameInUnder10ms()
{
// Arrange
var options = Options.Create(new TetragonCanonicalizerOptions());
var canonicalizer = new TetragonFrameCanonicalizer(
_mockSymbolResolver.Object,
_mockBuildIdResolver.Object,
options,
_mockCanonicalizerLogger.Object);
var frame = new TetragonStackFrame
{
Address = 0x7FFF12345678,
Offset = 0x100,
Symbol = "test_function",
Module = "/usr/lib/libtest.so"
};
// Warm up
await canonicalizer.CanonicalizeAsync(frame, null);
// Act
var sw = Stopwatch.StartNew();
for (var i = 0; i < 100; i++)
{
await canonicalizer.CanonicalizeAsync(frame, null);
}
sw.Stop();
var avgMs = sw.ElapsedMilliseconds / 100.0;
// Assert
avgMs.Should().BeLessThan(10,
"Frame canonicalization should complete in under 10ms per frame");
}
[Fact]
public void FunctionIdComputation_ShouldCompleteInUnder100Microseconds()
{
// Arrange
var options = Options.Create(new TetragonCanonicalizerOptions());
var canonicalizer = new TetragonFrameCanonicalizer(
_mockSymbolResolver.Object,
_mockBuildIdResolver.Object,
options,
_mockCanonicalizerLogger.Object);
// Act
var sw = Stopwatch.StartNew();
for (var i = 0; i < 10000; i++)
{
canonicalizer.ComputeFunctionId("abc123def456", "test_function", 0x100);
}
sw.Stop();
var avgMicroseconds = sw.Elapsed.TotalMicroseconds / 10000;
// Assert
avgMicroseconds.Should().BeLessThan(100,
"Function ID computation should complete in under 100 microseconds");
}
[Fact]
public void Demangling_ShouldProcess100000SymbolsInUnder1Second()
{
// Arrange
var options = Options.Create(new TetragonCanonicalizerOptions());
var canonicalizer = new TetragonFrameCanonicalizer(
_mockSymbolResolver.Object,
_mockBuildIdResolver.Object,
options,
_mockCanonicalizerLogger.Object);
var symbols = new[] { "_ZN4test8functionEv", "_RNvCs123_4test8function", "go.test.function", "normal" };
// Act
var sw = Stopwatch.StartNew();
for (var i = 0; i < 25000; i++)
{
foreach (var symbol in symbols)
{
canonicalizer.Demangle(symbol);
}
}
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(1000,
"Demangling should process 100,000 symbols in under 1 second");
}
[Fact]
public void MemoryOverhead_ShouldBeUnder100MB()
{
// Arrange - create components with large data sets
var options = Options.Create(new TetragonPrivacyOptions { RedactArguments = true });
var filter = new TetragonPrivacyFilter(options, _mockPrivacyLogger.Object);
var events = GenerateTestEvents(100000);
// Act - measure memory before and after processing
GC.Collect();
GC.WaitForPendingFinalizers();
var memoryBefore = GC.GetTotalMemory(true);
var results = events.Select(e => filter.Filter(e)).Where(e => e != null).ToList();
GC.Collect();
GC.WaitForPendingFinalizers();
var memoryAfter = GC.GetTotalMemory(true);
var memoryUsedMB = (memoryAfter - memoryBefore) / (1024.0 * 1024.0);
// Assert - allow for reasonable overhead during processing
memoryUsedMB.Should().BeLessThan(100,
"Memory overhead during processing should be under 100MB for 100K events");
results.Should().NotBeEmpty();
}
[Fact]
public void CaptureLatency_P95_ShouldBeUnder100ms()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions());
var filter = new TetragonPrivacyFilter(options, _mockPrivacyLogger.Object);
var events = GenerateTestEvents(1000);
var latencies = new List<double>(1000);
// Act - measure individual event processing latencies
foreach (var evt in events)
{
var sw = Stopwatch.StartNew();
filter.Filter(evt);
sw.Stop();
latencies.Add(sw.Elapsed.TotalMilliseconds);
}
// Calculate P95
latencies.Sort();
var p95Index = (int)(latencies.Count * 0.95);
var p95Latency = latencies[p95Index];
// Assert
p95Latency.Should().BeLessThan(100,
"Capture latency P95 should be under 100ms");
}
private static List<TetragonEvent> GenerateTestEvents(int count)
{
var events = new List<TetragonEvent>(count);
var random = new Random(42);
for (var i = 0; i < count; i++)
{
events.Add(new TetragonEvent
{
Type = (TetragonEventType)(random.Next(4)),
Time = DateTimeOffset.UtcNow.AddMilliseconds(-random.Next(10000)),
Process = new TetragonProcess
{
Pid = random.Next(1000, 50000),
Tid = random.Next(1000, 50000),
Pod = new TetragonPod { Namespace = "stella-ops" }
},
StackTrace = new TetragonStackTrace
{
Frames = Enumerable.Range(0, random.Next(3, 10))
.Select(_ => new TetragonStackFrame
{
Address = (ulong)random.Next(0x1000, int.MaxValue),
Offset = (ulong)random.Next(0, 1000),
Symbol = $"func_{random.Next(100)}",
Module = "/usr/lib/libtest.so"
})
.ToList()
}
});
}
return events;
}
private static List<RuntimeCallEvent> GenerateRuntimeCallEvents(int count)
{
var events = new List<RuntimeCallEvent>(count);
var random = new Random(42);
for (var i = 0; i < count; i++)
{
events.Add(new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow.AddMilliseconds(-random.Next(10000)),
Source = RuntimeEventSource.Tetragon,
ProcessId = random.Next(1000, 50000),
ContainerId = $"container-{random.Next(100):D4}",
Frames = Enumerable.Range(0, random.Next(3, 10))
.Select(_ => new CanonicalStackFrame
{
Address = (ulong)random.Next(0x1000, int.MaxValue),
Symbol = $"func_{random.Next(100)}",
Confidence = random.NextDouble() * 0.5 + 0.5
})
.ToList()
});
}
return events;
}
}
#region Benchmark Config
public sealed class TetragonBenchmarkConfig : ManualConfig
{
public TetragonBenchmarkConfig()
{
AddJob(Job.ShortRun
.WithWarmupCount(3)
.WithIterationCount(5));
AddLogger(ConsoleLogger.Default);
AddColumnProvider(DefaultColumnProviders.Instance);
}
}
#endregion
#region Mock Types for Benchmarks
public interface IHotSymbolRepository
{
Task<int> IngestBatchAsync(IEnumerable<SymbolObservation> observations, CancellationToken ct = default);
}
public sealed record SymbolObservation
{
public required string ImageDigest { get; init; }
public required string FunctionId { get; init; }
public required DateTimeOffset ObservedAt { get; init; }
public required double Confidence { get; init; }
}
public interface ISymbolResolver
{
Task<ResolvedSymbol?> ResolveAsync(ulong address, string? binaryPath, string? module, CancellationToken ct = default);
}
public sealed record ResolvedSymbol
{
public string? Name { get; init; }
public string? SourceFile { get; init; }
public int? SourceLine { get; init; }
public double Confidence { get; init; }
}
#endregion

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// TetragonHotSymbolBridgeTests.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-010 - Create integration tests
// Description: Unit tests for TetragonHotSymbolBridge
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.RuntimeInstrumentation.Tetragon;
using Xunit;
namespace StellaOps.RuntimeInstrumentation.Tetragon.Tests;
public class TetragonHotSymbolBridgeTests : IDisposable
{
private readonly Mock<IHotSymbolRepository> _mockRepository;
private readonly Mock<ILogger<TetragonHotSymbolBridge>> _mockLogger;
private readonly TetragonHotSymbolBridge _bridge;
public TetragonHotSymbolBridgeTests()
{
_mockRepository = new Mock<IHotSymbolRepository>();
_mockLogger = new Mock<ILogger<TetragonHotSymbolBridge>>();
var options = Options.Create(new TetragonBridgeOptions
{
AggregationWindow = TimeSpan.FromSeconds(5),
MaxBufferSize = 100,
MinConfidenceThreshold = 0.5
});
_bridge = new TetragonHotSymbolBridge(
_mockRepository.Object,
options,
_mockLogger.Object);
}
public void Dispose()
{
_bridge.Dispose();
}
[Fact]
public async Task RecordObservationsAsync_WithValidEvents_BuffersObservations()
{
// Arrange
var events = new List<RuntimeCallEvent>
{
CreateTestEvent("func1", "module1", 0.8),
CreateTestEvent("func2", "module1", 0.9)
};
// Act
await _bridge.RecordObservationsAsync("sha256:test123", events);
// Assert - observations are buffered, not immediately flushed
_mockRepository.Verify(
r => r.IngestBatchAsync(It.IsAny<HotSymbolIngestRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task FlushAsync_WithBufferedObservations_IngestsToRepository()
{
// Arrange
var events = new List<RuntimeCallEvent>
{
CreateTestEvent("func1", "module1", 0.8),
CreateTestEvent("func2", "module1", 0.9)
};
_mockRepository
.Setup(r => r.IngestBatchAsync(It.IsAny<HotSymbolIngestRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HotSymbolIngestResponse
{
IngestedCount = 2,
NewSymbolsCount = 2,
UpdatedSymbolsCount = 0,
ProcessingTime = TimeSpan.FromMilliseconds(10)
});
await _bridge.RecordObservationsAsync("sha256:test123", events);
// Act
var ingested = await _bridge.FlushAsync();
// Assert
Assert.Equal(2, ingested);
_mockRepository.Verify(
r => r.IngestBatchAsync(
It.Is<HotSymbolIngestRequest>(req =>
req.ImageDigest == "sha256:test123" &&
req.Source == "tetragon" &&
req.Observations.Count == 2),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordObservationsAsync_FiltersByConfidenceThreshold()
{
// Arrange - one event below threshold
var events = new List<RuntimeCallEvent>
{
CreateTestEvent("func1", "module1", 0.8), // Above threshold
CreateTestEvent("func2", "module1", 0.3) // Below threshold (0.5)
};
_mockRepository
.Setup(r => r.IngestBatchAsync(It.IsAny<HotSymbolIngestRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HotSymbolIngestResponse
{
IngestedCount = 1,
NewSymbolsCount = 1,
UpdatedSymbolsCount = 0,
ProcessingTime = TimeSpan.FromMilliseconds(10)
});
await _bridge.RecordObservationsAsync("sha256:test123", events);
// Act
await _bridge.FlushAsync();
// Assert - only one observation should be ingested
_mockRepository.Verify(
r => r.IngestBatchAsync(
It.Is<HotSymbolIngestRequest>(req => req.Observations.Count == 1),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RecordObservationsAsync_AggregatesDuplicateSymbols()
{
// Arrange - same function called multiple times
var events = new List<RuntimeCallEvent>
{
CreateTestEvent("func1", "module1", 0.8),
CreateTestEvent("func1", "module1", 0.8),
CreateTestEvent("func1", "module1", 0.8)
};
HotSymbolIngestRequest? capturedRequest = null;
_mockRepository
.Setup(r => r.IngestBatchAsync(It.IsAny<HotSymbolIngestRequest>(), It.IsAny<CancellationToken>()))
.Callback<HotSymbolIngestRequest, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HotSymbolIngestResponse
{
IngestedCount = 1,
NewSymbolsCount = 1,
UpdatedSymbolsCount = 0,
ProcessingTime = TimeSpan.FromMilliseconds(10)
});
await _bridge.RecordObservationsAsync("sha256:test123", events);
// Act
await _bridge.FlushAsync();
// Assert - should aggregate to single entry with count 3
Assert.NotNull(capturedRequest);
Assert.Single(capturedRequest.Observations);
Assert.Equal(3, capturedRequest.Observations[0].Count);
}
[Fact]
public async Task FlushAsync_WithEmptyBuffer_ReturnsZero()
{
// Act
var ingested = await _bridge.FlushAsync();
// Assert
Assert.Equal(0, ingested);
_mockRepository.Verify(
r => r.IngestBatchAsync(It.IsAny<HotSymbolIngestRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task RecordObservationsAsync_RequiresImageDigest()
{
// Arrange
var events = new List<RuntimeCallEvent> { CreateTestEvent("func1", "module1", 0.8) };
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => _bridge.RecordObservationsAsync("", events));
await Assert.ThrowsAsync<ArgumentException>(
() => _bridge.RecordObservationsAsync(null!, events));
}
[Fact]
public async Task GetStatisticsAsync_DelegatesToRepository()
{
// Arrange
var expectedStats = new HotSymbolStatistics
{
TotalSymbols = 100,
TotalObservations = 5000,
UniqueBuildIds = 10,
SecurityRelevantSymbols = 25,
SymbolsWithCves = 5,
EarliestObservation = DateTime.UtcNow.AddDays(-7),
LatestObservation = DateTime.UtcNow,
TopModules = new List<ModuleObservationSummary>()
};
_mockRepository
.Setup(r => r.GetStatisticsAsync("sha256:test", It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedStats);
// Act
var stats = await _bridge.GetStatisticsAsync("sha256:test");
// Assert
Assert.Equal(100, stats.TotalSymbols);
Assert.Equal(5000, stats.TotalObservations);
}
[Fact]
public async Task RecordObservationsAsync_ExcludesKernelFrames()
{
// Arrange - event with kernel frame
var evt = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Source = RuntimeEventSource.Syscall,
ProcessId = 1234,
ThreadId = 5678,
Frames = new List<CanonicalStackFrame>
{
new() { Symbol = "sys_read", Module = "vmlinux", IsKernel = true, Confidence = 1.0 },
new() { Symbol = "user_func", Module = "myapp", IsKernel = false, Confidence = 0.8 }
}
};
HotSymbolIngestRequest? capturedRequest = null;
_mockRepository
.Setup(r => r.IngestBatchAsync(It.IsAny<HotSymbolIngestRequest>(), It.IsAny<CancellationToken>()))
.Callback<HotSymbolIngestRequest, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HotSymbolIngestResponse
{
IngestedCount = 1,
NewSymbolsCount = 1,
UpdatedSymbolsCount = 0,
ProcessingTime = TimeSpan.FromMilliseconds(10)
});
await _bridge.RecordObservationsAsync("sha256:test123", new[] { evt });
// Act
await _bridge.FlushAsync();
// Assert - only user-space frame should be included
Assert.NotNull(capturedRequest);
Assert.Single(capturedRequest.Observations);
Assert.Equal("user_func", capturedRequest.Observations[0].FunctionName);
}
private static RuntimeCallEvent CreateTestEvent(string funcName, string module, double confidence)
{
return new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Source = RuntimeEventSource.Syscall,
ProcessId = 1234,
ThreadId = 5678,
Frames = new List<CanonicalStackFrame>
{
new()
{
Symbol = funcName,
Module = module,
IsKernel = false,
Confidence = confidence,
Address = 0x12345678
}
}
};
}
}

View File

@@ -0,0 +1,316 @@
// -----------------------------------------------------------------------------
// TetragonPrivacyFilterTests.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-010 - Create integration tests
// Description: Unit tests for TetragonPrivacyFilter
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.RuntimeInstrumentation.Tetragon;
using Xunit;
namespace StellaOps.RuntimeInstrumentation.Tetragon.Tests;
public class TetragonPrivacyFilterTests
{
private readonly Mock<ILogger<TetragonPrivacyFilter>> _mockLogger;
public TetragonPrivacyFilterTests()
{
_mockLogger = new Mock<ILogger<TetragonPrivacyFilter>>();
}
[Fact]
public void Filter_WithAllowedNamespace_ReturnsEvent()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
AllowedNamespaces = new[] { "stella-ops-workloads", "default" }
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var evt = CreateTestEvent("stella-ops-workloads");
// Act
var result = filter.Filter(evt);
// Assert
Assert.NotNull(result);
}
[Fact]
public void Filter_WithDisallowedNamespace_ReturnsNull()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
AllowedNamespaces = new[] { "stella-ops-workloads" }
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var evt = CreateTestEvent("kube-system");
// Act
var result = filter.Filter(evt);
// Assert
Assert.Null(result);
}
[Fact]
public void Filter_WithEmptyAllowlist_AllowsAllNamespaces()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
AllowedNamespaces = null // Empty = allow all
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var evt = CreateTestEvent("any-namespace");
// Act
var result = filter.Filter(evt);
// Assert
Assert.NotNull(result);
}
[Theory]
[InlineData("password=secret123", "[REDACTED]")]
[InlineData("API_KEY=abc123", "[REDACTED]")]
[InlineData("normal text here", "normal text here")]
[InlineData("user@example.com", "[REDACTED]")]
public void Filter_RedactsArguments(string input, string expected)
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
RedactArguments = true,
UseDefaultRedactionPatterns = true
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var evt = new TetragonEvent
{
Type = TetragonEventType.Kprobe,
Time = DateTimeOffset.UtcNow,
Process = new TetragonProcess
{
Pid = 1234,
Tid = 5678,
Pod = new TetragonPod { Namespace = "default" }
},
Args = new List<object> { input }
};
// Act
var result = filter.Filter(evt);
// Assert
Assert.NotNull(result);
Assert.Single(result.Args!);
Assert.Equal(expected, result.Args![0].ToString());
}
[Fact]
public void Filter_WithSymbolIdOnlyMode_StripsSymbolNames()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
SymbolIdOnlyMode = true
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var evt = new TetragonEvent
{
Type = TetragonEventType.Kprobe,
Time = DateTimeOffset.UtcNow,
Process = new TetragonProcess
{
Pid = 1234,
Tid = 5678,
Pod = new TetragonPod { Namespace = "default" }
},
StackTrace = new TetragonStackTrace
{
Frames = new List<TetragonStackFrame>
{
new()
{
Address = 0x7FFF12345678,
Symbol = "my_secret_function",
Module = "libsecret.so"
}
}
}
};
// Act
var result = filter.Filter(evt);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.StackTrace?.Frames);
Assert.Single(result.StackTrace!.Frames!);
Assert.DoesNotContain("my_secret_function", result.StackTrace!.Frames![0].Symbol!);
Assert.StartsWith("sym_", result.StackTrace!.Frames![0].Symbol!);
}
[Fact]
public void Filter_RuntimeCallEvent_FiltersNamespace()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
AllowedNamespaces = new[] { "allowed-ns" }
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var allowedEvt = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Source = RuntimeEventSource.Syscall,
Namespace = "allowed-ns",
Frames = new List<CanonicalStackFrame>()
};
var disallowedEvt = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Source = RuntimeEventSource.Syscall,
Namespace = "disallowed-ns",
Frames = new List<CanonicalStackFrame>()
};
// Act
var allowedResult = filter.Filter(allowedEvt);
var disallowedResult = filter.Filter(disallowedEvt);
// Assert
Assert.NotNull(allowedResult);
Assert.Null(disallowedResult);
}
[Fact]
public void Filter_RuntimeCallEvent_WithSymbolIdOnlyMode_StripsDemangled()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
SymbolIdOnlyMode = true
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var evt = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Source = RuntimeEventSource.Syscall,
Frames = new List<CanonicalStackFrame>
{
new()
{
Symbol = "func",
Demangled = "MyClass::myFunction(int)",
SourceFile = "/src/myfile.cpp",
SourceLine = 42,
Confidence = 0.9
}
}
};
// Act
var result = filter.Filter(evt);
// Assert
Assert.NotNull(result);
Assert.Single(result.Frames);
Assert.Equal("func", result.Frames[0].Symbol); // Symbol preserved
Assert.Null(result.Frames[0].Demangled); // Stripped
Assert.Null(result.Frames[0].SourceFile); // Stripped
Assert.Null(result.Frames[0].SourceLine); // Stripped
}
[Fact]
public void GetStatistics_ReturnsCorrectCounts()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
AllowedNamespaces = new[] { "ns1", "ns2", "ns3" },
AllowedLabels = new[] { "label1", "label2" },
UseDefaultRedactionPatterns = true,
AdditionalRedactionPatterns = new[] { @"custom\d+" },
SymbolIdOnlyMode = true
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
// Act
var stats = filter.GetStatistics();
// Assert
Assert.Equal(3, stats.AllowedNamespacesCount);
Assert.Equal(2, stats.AllowedLabelsCount);
Assert.True(stats.RedactionPatternsCount > 1); // Default + custom
Assert.True(stats.SymbolIdOnlyMode);
}
[Fact]
public async Task FilterStreamAsync_FiltersMultipleEvents()
{
// Arrange
var options = Options.Create(new TetragonPrivacyOptions
{
AllowedNamespaces = new[] { "allowed" }
});
var filter = new TetragonPrivacyFilter(options, _mockLogger.Object);
var events = new List<TetragonEvent>
{
CreateTestEvent("allowed"),
CreateTestEvent("disallowed"),
CreateTestEvent("allowed")
};
// Act
var results = new List<TetragonEvent>();
await foreach (var evt in filter.FilterStreamAsync(ToAsyncEnumerable(events)))
{
results.Add(evt);
}
// Assert
Assert.Equal(2, results.Count);
}
private static TetragonEvent CreateTestEvent(string ns)
{
return new TetragonEvent
{
Type = TetragonEventType.ProcessExec,
Time = DateTimeOffset.UtcNow,
Process = new TetragonProcess
{
Pid = 1234,
Tid = 5678,
Pod = new TetragonPod { Namespace = ns }
}
};
}
private static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<T> items)
{
foreach (var item in items)
{
await Task.Yield();
yield return item;
}
}
}

View File

@@ -0,0 +1,429 @@
// -----------------------------------------------------------------------------
// TetragonEventAdapter.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-002 - Create TetragonEventAdapter to existing RuntimeCallEvent
// Description: Adapts Tetragon events to existing Signals RuntimeCallEvent format
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.RuntimeInstrumentation.Tetragon;
/// <summary>
/// Adapts Tetragon eBPF events to the existing <see cref="RuntimeCallEvent"/> format
/// used by the Signals infrastructure. This is a bridge layer - NOT a parallel infrastructure.
/// </summary>
public sealed class TetragonEventAdapter : ITetragonEventAdapter
{
private readonly ISymbolResolver _symbolResolver;
private readonly IHotSymbolIndex _hotSymbolIndex;
private readonly ILogger<TetragonEventAdapter> _logger;
private readonly TetragonAdapterOptions _options;
/// <summary>
/// Creates a new Tetragon event adapter.
/// </summary>
public TetragonEventAdapter(
ISymbolResolver symbolResolver,
IHotSymbolIndex hotSymbolIndex,
IOptions<TetragonAdapterOptions> options,
ILogger<TetragonEventAdapter> logger)
{
_symbolResolver = symbolResolver ?? throw new ArgumentNullException(nameof(symbolResolver));
_hotSymbolIndex = hotSymbolIndex ?? throw new ArgumentNullException(nameof(hotSymbolIndex));
_options = options?.Value ?? new TetragonAdapterOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<RuntimeCallEvent?> AdaptAsync(
TetragonEvent tetragonEvent,
CancellationToken ct = default)
{
if (tetragonEvent == null)
{
return null;
}
// Map Tetragon event type to RuntimeCallEvent source
var source = tetragonEvent.Type switch
{
TetragonEventType.ProcessExec => RuntimeEventSource.ProcessExec,
TetragonEventType.ProcessExit => RuntimeEventSource.ProcessExit,
TetragonEventType.Kprobe => RuntimeEventSource.Syscall,
TetragonEventType.Uprobe => RuntimeEventSource.LibraryLoad,
TetragonEventType.Tracepoint => RuntimeEventSource.Tracepoint,
_ => RuntimeEventSource.Unknown
};
// Extract and canonicalize stack frames
var frames = await CanonicalizeStackFramesAsync(
tetragonEvent.StackTrace,
tetragonEvent.Process?.Binary,
ct);
// Build RuntimeCallEvent compatible with existing infrastructure
var runtimeEvent = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
Timestamp = tetragonEvent.Time ?? DateTimeOffset.UtcNow,
Source = source,
ProcessId = tetragonEvent.Process?.Pid ?? 0,
ThreadId = tetragonEvent.Process?.Tid ?? 0,
ContainerId = tetragonEvent.Process?.Docker ?? tetragonEvent.Process?.Pod?.Container?.Id,
PodName = tetragonEvent.Process?.Pod?.Name,
Namespace = tetragonEvent.Process?.Pod?.Namespace,
BinaryPath = tetragonEvent.Process?.Binary,
Frames = frames,
SyscallName = tetragonEvent.FunctionName,
SyscallArgs = tetragonEvent.Args?.Select(a => a.ToString()).ToList(),
ReturnValue = tetragonEvent.Return?.IntValue
};
// Update hot symbol index for reachability analysis
if (_options.UpdateHotSymbolIndex && frames.Count > 0)
{
await UpdateHotSymbolIndexAsync(runtimeEvent, ct);
}
return runtimeEvent;
}
/// <inheritdoc />
public async IAsyncEnumerable<RuntimeCallEvent> AdaptStreamAsync(
IAsyncEnumerable<TetragonEvent> eventStream,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var tetragonEvent in eventStream.WithCancellation(ct))
{
var runtimeEvent = await AdaptAsync(tetragonEvent, ct);
if (runtimeEvent != null)
{
yield return runtimeEvent;
}
}
}
private async Task<IReadOnlyList<CanonicalStackFrame>> CanonicalizeStackFramesAsync(
TetragonStackTrace? stackTrace,
string? binaryPath,
CancellationToken ct)
{
if (stackTrace?.Frames == null || stackTrace.Frames.Count == 0)
{
return [];
}
var canonicalFrames = new List<CanonicalStackFrame>();
foreach (var frame in stackTrace.Frames)
{
// Resolve symbol using existing infrastructure
var resolvedSymbol = await _symbolResolver.ResolveAsync(
frame.Address,
binaryPath,
frame.Module,
ct);
canonicalFrames.Add(new CanonicalStackFrame
{
Address = frame.Address,
Offset = frame.Offset,
Module = frame.Module,
Symbol = resolvedSymbol?.Name ?? $"sub_{frame.Address:X}",
Demangled = resolvedSymbol?.DemangledName,
SourceFile = resolvedSymbol?.SourceFile,
SourceLine = resolvedSymbol?.SourceLine,
IsKernel = frame.Flags.HasFlag(StackFrameFlags.Kernel),
Confidence = resolvedSymbol?.Confidence ?? 0.5
});
}
return canonicalFrames;
}
private async Task UpdateHotSymbolIndexAsync(RuntimeCallEvent runtimeEvent, CancellationToken ct)
{
foreach (var frame in runtimeEvent.Frames.Where(f => !f.IsKernel))
{
await _hotSymbolIndex.RecordObservationAsync(new SymbolObservation
{
Symbol = frame.Symbol,
Module = frame.Module,
ContainerId = runtimeEvent.ContainerId,
ObservedAt = runtimeEvent.Timestamp,
Source = runtimeEvent.Source.ToString()
}, ct);
}
}
}
/// <summary>
/// Interface for Tetragon event adaptation.
/// </summary>
public interface ITetragonEventAdapter
{
/// <summary>
/// Adapts a single Tetragon event to RuntimeCallEvent.
/// </summary>
Task<RuntimeCallEvent?> AdaptAsync(TetragonEvent tetragonEvent, CancellationToken ct = default);
/// <summary>
/// Adapts a stream of Tetragon events.
/// </summary>
IAsyncEnumerable<RuntimeCallEvent> AdaptStreamAsync(
IAsyncEnumerable<TetragonEvent> eventStream,
CancellationToken ct = default);
}
/// <summary>
/// Tetragon adapter configuration.
/// </summary>
public sealed record TetragonAdapterOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon";
/// <summary>Whether to update the hot symbol index on events.</summary>
public bool UpdateHotSymbolIndex { get; init; } = true;
/// <summary>Maximum stack depth to process.</summary>
public int MaxStackDepth { get; init; } = 64;
/// <summary>Whether to include kernel frames.</summary>
public bool IncludeKernelFrames { get; init; } = true;
}
// Models
/// <summary>
/// Tetragon event from eBPF.
/// </summary>
public sealed record TetragonEvent
{
[JsonPropertyName("type")]
public TetragonEventType Type { get; init; }
[JsonPropertyName("time")]
public DateTimeOffset? Time { get; init; }
[JsonPropertyName("process")]
public TetragonProcess? Process { get; init; }
[JsonPropertyName("function_name")]
public string? FunctionName { get; init; }
[JsonPropertyName("args")]
public IReadOnlyList<object>? Args { get; init; }
[JsonPropertyName("return")]
public TetragonReturn? Return { get; init; }
[JsonPropertyName("stack_trace")]
public TetragonStackTrace? StackTrace { get; init; }
}
/// <summary>
/// Tetragon event type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TetragonEventType
{
ProcessExec,
ProcessExit,
Kprobe,
Uprobe,
Tracepoint
}
/// <summary>
/// Tetragon process information.
/// </summary>
public sealed record TetragonProcess
{
[JsonPropertyName("pid")]
public int Pid { get; init; }
[JsonPropertyName("tid")]
public int Tid { get; init; }
[JsonPropertyName("binary")]
public string? Binary { get; init; }
[JsonPropertyName("docker")]
public string? Docker { get; init; }
[JsonPropertyName("pod")]
public TetragonPod? Pod { get; init; }
}
/// <summary>
/// Tetragon pod information.
/// </summary>
public sealed record TetragonPod
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
[JsonPropertyName("container")]
public TetragonContainer? Container { get; init; }
}
/// <summary>
/// Tetragon container information.
/// </summary>
public sealed record TetragonContainer
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// Tetragon return value.
/// </summary>
public sealed record TetragonReturn
{
[JsonPropertyName("int_value")]
public long? IntValue { get; init; }
}
/// <summary>
/// Tetragon stack trace.
/// </summary>
public sealed record TetragonStackTrace
{
[JsonPropertyName("frames")]
public IReadOnlyList<TetragonStackFrame>? Frames { get; init; }
}
/// <summary>
/// Tetragon stack frame.
/// </summary>
public sealed record TetragonStackFrame
{
[JsonPropertyName("address")]
public ulong Address { get; init; }
[JsonPropertyName("offset")]
public ulong Offset { get; init; }
[JsonPropertyName("module")]
public string? Module { get; init; }
[JsonPropertyName("symbol")]
public string? Symbol { get; init; }
[JsonPropertyName("flags")]
public StackFrameFlags Flags { get; init; }
}
/// <summary>
/// Stack frame flags.
/// </summary>
[Flags]
public enum StackFrameFlags
{
None = 0,
Kernel = 1,
User = 2,
Inline = 4
}
// Bridge models to existing infrastructure
/// <summary>
/// Runtime call event - compatible with existing Signals infrastructure.
/// </summary>
public sealed record RuntimeCallEvent
{
public Guid EventId { get; init; }
public DateTimeOffset Timestamp { get; init; }
public RuntimeEventSource Source { get; init; }
public int ProcessId { get; init; }
public int ThreadId { get; init; }
public string? ContainerId { get; init; }
public string? PodName { get; init; }
public string? Namespace { get; init; }
public string? BinaryPath { get; init; }
public IReadOnlyList<CanonicalStackFrame> Frames { get; init; } = [];
public string? SyscallName { get; init; }
public IReadOnlyList<string>? SyscallArgs { get; init; }
public long? ReturnValue { get; init; }
}
/// <summary>
/// Runtime event source.
/// </summary>
public enum RuntimeEventSource
{
Unknown,
ProcessExec,
ProcessExit,
Syscall,
LibraryLoad,
Tracepoint
}
/// <summary>
/// Canonical stack frame.
/// </summary>
public sealed record CanonicalStackFrame
{
public ulong Address { get; init; }
public ulong Offset { get; init; }
public string? Module { get; init; }
public required string Symbol { get; init; }
public string? Demangled { get; init; }
public string? SourceFile { get; init; }
public int? SourceLine { get; init; }
public bool IsKernel { get; init; }
public double Confidence { get; init; }
}
/// <summary>
/// Symbol observation for hot index.
/// </summary>
public sealed record SymbolObservation
{
public required string Symbol { get; init; }
public string? Module { get; init; }
public string? ContainerId { get; init; }
public DateTimeOffset ObservedAt { get; init; }
public string? Source { get; init; }
}
// Interface placeholders
public interface ISymbolResolver
{
Task<ResolvedSymbol?> ResolveAsync(ulong address, string? binaryPath, string? module, CancellationToken ct);
}
public sealed record ResolvedSymbol
{
public string? Name { get; init; }
public string? DemangledName { get; init; }
public string? SourceFile { get; init; }
public int? SourceLine { get; init; }
public double Confidence { get; init; }
}
public interface IHotSymbolIndex
{
Task RecordObservationAsync(SymbolObservation observation, CancellationToken ct);
}
public interface IOptions<T> where T : class
{
T Value { get; }
}
public interface ILogger<T> { }
public class EnumeratorCancellationAttribute : Attribute { }

View File

@@ -0,0 +1,348 @@
// -----------------------------------------------------------------------------
// TetragonFrameCanonicalizer.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-005 - Create frame canonicalization using existing symbol resolution
// Description: Canonicalizes Tetragon stack frames using existing Signals symbol resolution
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.RuntimeInstrumentation.Tetragon;
/// <summary>
/// Canonicalizes Tetragon stack frames to match the static analysis function ID namespace.
/// Uses existing Signals symbol resolution infrastructure.
/// </summary>
public sealed class TetragonFrameCanonicalizer : ITetragonFrameCanonicalizer
{
private readonly ISymbolResolver _symbolResolver;
private readonly IBuildIdResolver _buildIdResolver;
private readonly TetragonCanonicalizerOptions _options;
private readonly ILogger<TetragonFrameCanonicalizer> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Tetragon.FrameCanonicalizer");
// Demangling patterns
private static readonly Regex CppMangledPattern = new(@"^_Z[A-Za-z0-9_]+$", RegexOptions.Compiled);
private static readonly Regex RustMangledPattern = new(@"^_R[A-Za-z0-9_]+$", RegexOptions.Compiled);
private static readonly Regex GoMangledPattern = new(@"^go\..+$", RegexOptions.Compiled);
/// <summary>
/// Creates a new frame canonicalizer.
/// </summary>
public TetragonFrameCanonicalizer(
ISymbolResolver symbolResolver,
IBuildIdResolver buildIdResolver,
IOptions<TetragonCanonicalizerOptions> options,
ILogger<TetragonFrameCanonicalizer> logger)
{
_symbolResolver = symbolResolver ?? throw new ArgumentNullException(nameof(symbolResolver));
_buildIdResolver = buildIdResolver ?? throw new ArgumentNullException(nameof(buildIdResolver));
_options = options?.Value ?? new TetragonCanonicalizerOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<CanonicalStackFrame> CanonicalizeAsync(
TetragonStackFrame frame,
string? binaryPath,
CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("CanonicalizeFrame");
activity?.SetTag("address", $"0x{frame.Address:X}");
// Resolve Build-ID for the module
var buildId = await ResolveBuildIdAsync(frame.Module, binaryPath, ct);
// Resolve symbol using existing Signals infrastructure
var resolved = await _symbolResolver.ResolveAsync(
frame.Address,
binaryPath,
frame.Module,
ct);
// Demangle if needed
var symbolName = resolved?.Name ?? frame.Symbol ?? $"sub_{frame.Address:X}";
var demangledName = Demangle(symbolName);
// Compute canonical function ID matching static analysis namespace
var functionId = ComputeFunctionId(buildId, demangledName, frame.Offset);
return new CanonicalStackFrame
{
Address = frame.Address,
Offset = frame.Offset,
Module = frame.Module,
Symbol = symbolName,
Demangled = demangledName != symbolName ? demangledName : null,
SourceFile = resolved?.SourceFile,
SourceLine = resolved?.SourceLine,
IsKernel = frame.Flags.HasFlag(StackFrameFlags.Kernel),
Confidence = resolved?.Confidence ?? ComputeConfidence(frame, resolved != null),
FunctionId = functionId,
BuildId = buildId
};
}
/// <inheritdoc />
public async IAsyncEnumerable<CanonicalStackFrame> CanonicalizeBatchAsync(
IEnumerable<TetragonStackFrame> frames,
string? binaryPath,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var frame in frames)
{
yield return await CanonicalizeAsync(frame, binaryPath, ct);
}
}
/// <inheritdoc />
public string ComputeFunctionId(string? buildId, string functionName, ulong offset)
{
// Format: buildid:function+offset
// This matches the hot_symbols function_id format
var normalizedName = NormalizeFunctionName(functionName);
if (string.IsNullOrEmpty(buildId))
{
// Use hash of function name as pseudo-build-id
buildId = ComputeNameHash(normalizedName);
}
return $"{buildId}:{normalizedName}+{offset:X}";
}
/// <inheritdoc />
public string Demangle(string mangledName)
{
if (string.IsNullOrEmpty(mangledName))
{
return mangledName;
}
// C++ demangling (simplified - real implementation would use libcxxabi)
if (CppMangledPattern.IsMatch(mangledName))
{
return DemangleCpp(mangledName);
}
// Rust demangling
if (RustMangledPattern.IsMatch(mangledName))
{
return DemangleRust(mangledName);
}
// Go demangling (Go symbols aren't really mangled, but format them)
if (GoMangledPattern.IsMatch(mangledName))
{
return DemangleGo(mangledName);
}
return mangledName;
}
private async Task<string?> ResolveBuildIdAsync(
string? module,
string? binaryPath,
CancellationToken ct)
{
if (string.IsNullOrEmpty(module) && string.IsNullOrEmpty(binaryPath))
{
return null;
}
try
{
return await _buildIdResolver.ResolveAsync(binaryPath ?? module!, ct);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to resolve Build-ID for {Module}", module);
return null;
}
}
private static string NormalizeFunctionName(string name)
{
if (string.IsNullOrEmpty(name))
{
return "unknown";
}
// Remove template parameters for matching
var normalized = Regex.Replace(name, @"<[^>]+>", "<>");
// Remove parameter types
normalized = Regex.Replace(normalized, @"\([^)]*\)", "()");
// Normalize whitespace
normalized = Regex.Replace(normalized, @"\s+", " ").Trim();
return normalized;
}
private static string ComputeNameHash(string name)
{
var bytes = Encoding.UTF8.GetBytes(name);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).Substring(0, 16).ToLowerInvariant();
}
private static double ComputeConfidence(TetragonStackFrame frame, bool resolved)
{
// Base confidence
var confidence = resolved ? 0.8 : 0.5;
// Higher confidence for user space
if (!frame.Flags.HasFlag(StackFrameFlags.Kernel))
{
confidence += 0.1;
}
// Lower confidence for inline frames
if (frame.Flags.HasFlag(StackFrameFlags.Inline))
{
confidence -= 0.1;
}
// Lower confidence if no symbol name
if (string.IsNullOrEmpty(frame.Symbol))
{
confidence -= 0.2;
}
return Math.Clamp(confidence, 0.0, 1.0);
}
private static string DemangleCpp(string mangled)
{
// Simplified C++ demangling
// Real implementation would use __cxa_demangle or llvm::demangle
// For now, just mark it as C++
return $"C++:{mangled}";
}
private static string DemangleRust(string mangled)
{
// Simplified Rust demangling
// Real implementation would use rustc_demangle
return $"Rust:{mangled}";
}
private static string DemangleGo(string symbol)
{
// Go symbols are already readable, just clean up
return symbol.Replace("go.", "").Replace("·", "::");
}
}
/// <summary>
/// Interface for frame canonicalization.
/// </summary>
public interface ITetragonFrameCanonicalizer
{
/// <summary>
/// Canonicalizes a single frame.
/// </summary>
Task<CanonicalStackFrame> CanonicalizeAsync(
TetragonStackFrame frame,
string? binaryPath,
CancellationToken ct = default);
/// <summary>
/// Canonicalizes multiple frames.
/// </summary>
IAsyncEnumerable<CanonicalStackFrame> CanonicalizeBatchAsync(
IEnumerable<TetragonStackFrame> frames,
string? binaryPath,
CancellationToken ct = default);
/// <summary>
/// Computes the canonical function ID.
/// </summary>
string ComputeFunctionId(string? buildId, string functionName, ulong offset);
/// <summary>
/// Demangles a symbol name.
/// </summary>
string Demangle(string mangledName);
}
/// <summary>
/// Configuration for frame canonicalization.
/// </summary>
public sealed record TetragonCanonicalizerOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon:Canonicalizer";
/// <summary>Whether to cache symbol resolutions.</summary>
public bool EnableCache { get; init; } = true;
/// <summary>Cache size limit.</summary>
public int CacheSize { get; init; } = 10000;
/// <summary>Whether to include inline frames.</summary>
public bool IncludeInlineFrames { get; init; } = true;
}
/// <summary>
/// Interface for Build-ID resolution.
/// </summary>
public interface IBuildIdResolver
{
/// <summary>
/// Resolves the Build-ID for a binary.
/// </summary>
Task<string?> ResolveAsync(string binaryPath, CancellationToken ct = default);
}
/// <summary>
/// Default Build-ID resolver implementation.
/// </summary>
public sealed class DefaultBuildIdResolver : IBuildIdResolver
{
private readonly ILogger<DefaultBuildIdResolver> _logger;
public DefaultBuildIdResolver(ILogger<DefaultBuildIdResolver> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<string?> ResolveAsync(string binaryPath, CancellationToken ct = default)
{
// In a real implementation, this would:
// 1. Read ELF .note.gnu.build-id section
// 2. Or compute a hash of the binary
// For now, return a hash of the path
if (string.IsNullOrEmpty(binaryPath))
{
return Task.FromResult<string?>(null);
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(binaryPath));
return Task.FromResult<string?>(Convert.ToHexString(hash).Substring(0, 40).ToLowerInvariant());
}
}
/// <summary>
/// Extended canonical stack frame with function ID and Build-ID.
/// </summary>
public sealed record CanonicalStackFrame
{
public ulong Address { get; init; }
public ulong Offset { get; init; }
public string? Module { get; init; }
public required string Symbol { get; init; }
public string? Demangled { get; init; }
public string? SourceFile { get; init; }
public int? SourceLine { get; init; }
public bool IsKernel { get; init; }
public double Confidence { get; init; }
public string? FunctionId { get; init; }
public string? BuildId { get; init; }
}

View File

@@ -0,0 +1,383 @@
// -----------------------------------------------------------------------------
// TetragonHotSymbolBridge.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-003 - Integrate with existing Signals hot symbol index
// Description: Bridges Tetragon events to Signals hot_symbols PostgreSQL table
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.RuntimeInstrumentation.Tetragon;
/// <summary>
/// Bridges Tetragon runtime observations to the existing Signals hot_symbols
/// PostgreSQL infrastructure. Follows existing aggregation patterns.
/// </summary>
public sealed class TetragonHotSymbolBridge : ITetragonHotSymbolBridge, IDisposable
{
private readonly IHotSymbolRepository _repository;
private readonly TetragonBridgeOptions _options;
private readonly ILogger<TetragonHotSymbolBridge> _logger;
private readonly SemaphoreSlim _flushLock = new(1, 1);
private readonly Dictionary<string, SymbolAggregation> _aggregationBuffer = new();
private readonly Timer _flushTimer;
private readonly ActivitySource _activitySource = new("StellaOps.Tetragon.HotSymbolBridge");
private bool _disposed;
/// <summary>
/// Creates a new hot symbol bridge.
/// </summary>
public TetragonHotSymbolBridge(
IHotSymbolRepository repository,
IOptions<TetragonBridgeOptions> options,
ILogger<TetragonHotSymbolBridge> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_options = options?.Value ?? new TetragonBridgeOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Start periodic flush timer
_flushTimer = new Timer(
FlushTimerCallback,
null,
_options.AggregationWindow,
_options.AggregationWindow);
}
/// <inheritdoc />
public async Task RecordObservationsAsync(
string imageDigest,
IEnumerable<RuntimeCallEvent> events,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(imageDigest))
{
throw new ArgumentException("Image digest is required.", nameof(imageDigest));
}
using var activity = _activitySource.StartActivity("RecordObservations");
activity?.SetTag("image_digest", imageDigest);
var observations = new List<HotSymbolIngestRequest.SymbolObservation>();
var now = DateTime.UtcNow;
foreach (var evt in events)
{
foreach (var frame in evt.Frames.Where(f => !f.IsKernel && f.Confidence >= _options.MinConfidenceThreshold))
{
var key = BuildAggregationKey(imageDigest, frame);
await _flushLock.WaitAsync(ct);
try
{
if (!_aggregationBuffer.TryGetValue(key, out var agg))
{
agg = new SymbolAggregation
{
ImageDigest = imageDigest,
BuildId = frame.Module ?? "unknown",
FunctionName = frame.Symbol,
ModuleName = frame.Module,
FirstSeen = now,
LastSeen = now,
Count = 0
};
_aggregationBuffer[key] = agg;
}
agg.Count++;
agg.LastSeen = now;
}
finally
{
_flushLock.Release();
}
}
}
activity?.SetTag("aggregated_symbols", _aggregationBuffer.Count);
// Flush if buffer exceeds threshold
if (_aggregationBuffer.Count >= _options.MaxBufferSize)
{
await FlushAsync(ct);
}
}
/// <inheritdoc />
public async Task<int> FlushAsync(CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("FlushHotSymbols");
Dictionary<string, SymbolAggregation> toFlush;
await _flushLock.WaitAsync(ct);
try
{
if (_aggregationBuffer.Count == 0)
{
return 0;
}
toFlush = new Dictionary<string, SymbolAggregation>(_aggregationBuffer);
_aggregationBuffer.Clear();
}
finally
{
_flushLock.Release();
}
// Group by image digest for batch ingestion
var byImage = toFlush.Values.GroupBy(a => a.ImageDigest);
var totalIngested = 0;
foreach (var group in byImage)
{
var request = new HotSymbolIngestRequest
{
ImageDigest = group.Key,
Source = "tetragon",
Observations = group.Select(a => new HotSymbolIngestRequest.SymbolObservation
{
BuildId = a.BuildId,
FunctionName = a.FunctionName,
ModuleName = a.ModuleName,
Count = a.Count,
Timestamp = a.LastSeen
}).ToList()
};
try
{
var response = await _repository.IngestBatchAsync(request, ct);
totalIngested += response.IngestedCount;
_logger.LogDebug(
"Flushed {Count} symbol observations for image {Image}, created={New}, updated={Updated}",
response.IngestedCount, group.Key, response.NewSymbolsCount, response.UpdatedSymbolsCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to flush observations for image {Image}", group.Key);
throw;
}
}
activity?.SetTag("total_ingested", totalIngested);
return totalIngested;
}
/// <inheritdoc />
public async Task<HotSymbolStatistics> GetStatisticsAsync(
string imageDigest,
CancellationToken ct = default)
{
return await _repository.GetStatisticsAsync(imageDigest, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SymbolCorrelationResult>> CorrelateWithReachabilityAsync(
string imageDigest,
CancellationToken ct = default)
{
return await _repository.CorrelateWithReachabilityAsync(imageDigest, ct);
}
private static string BuildAggregationKey(string imageDigest, CanonicalStackFrame frame)
{
return $"{imageDigest}:{frame.Module}:{frame.Symbol}";
}
private void FlushTimerCallback(object? state)
{
try
{
// Fire-and-forget flush on timer
_ = FlushAsync(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in periodic flush timer");
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_flushTimer.Dispose();
_flushLock.Dispose();
_activitySource.Dispose();
// Final flush
try
{
FlushAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during final flush on dispose");
}
}
private sealed class SymbolAggregation
{
public required string ImageDigest { get; init; }
public required string BuildId { get; init; }
public required string FunctionName { get; init; }
public string? ModuleName { get; init; }
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
public long Count { get; set; }
}
}
/// <summary>
/// Interface for Tetragon to hot symbol index bridge.
/// </summary>
public interface ITetragonHotSymbolBridge
{
/// <summary>
/// Records runtime observations to the hot symbol index.
/// </summary>
Task RecordObservationsAsync(
string imageDigest,
IEnumerable<RuntimeCallEvent> events,
CancellationToken ct = default);
/// <summary>
/// Flushes buffered observations to the database.
/// </summary>
Task<int> FlushAsync(CancellationToken ct = default);
/// <summary>
/// Gets statistics for an image.
/// </summary>
Task<HotSymbolStatistics> GetStatisticsAsync(
string imageDigest,
CancellationToken ct = default);
/// <summary>
/// Correlates hot symbols with reachability data.
/// </summary>
Task<IReadOnlyList<SymbolCorrelationResult>> CorrelateWithReachabilityAsync(
string imageDigest,
CancellationToken ct = default);
}
/// <summary>
/// Configuration for the Tetragon hot symbol bridge.
/// </summary>
public sealed record TetragonBridgeOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon:HotSymbolBridge";
/// <summary>Aggregation window (default: 1 minute).</summary>
public TimeSpan AggregationWindow { get; init; } = TimeSpan.FromMinutes(1);
/// <summary>Maximum buffer size before flush (default: 10000).</summary>
public int MaxBufferSize { get; init; } = 10000;
/// <summary>Minimum confidence threshold for symbols (default: 0.5).</summary>
public double MinConfidenceThreshold { get; init; } = 0.5;
}
// Interface placeholders for Signals infrastructure
// These should be replaced with actual imports from StellaOps.Signals
/// <summary>
/// Repository for hot symbol persistence.
/// </summary>
public interface IHotSymbolRepository
{
Task<HotSymbolIngestResponse> IngestBatchAsync(HotSymbolIngestRequest request, CancellationToken ct);
Task<HotSymbolStatistics> GetStatisticsAsync(string imageDigest, CancellationToken ct);
Task<IReadOnlyList<SymbolCorrelationResult>> CorrelateWithReachabilityAsync(string imageDigest, CancellationToken ct);
}
/// <summary>
/// Request for ingesting hot symbols.
/// </summary>
public sealed record HotSymbolIngestRequest
{
public required string ImageDigest { get; init; }
public required IReadOnlyList<SymbolObservation> Observations { get; init; }
public Guid? TenantId { get; init; }
public string? Source { get; init; }
public sealed record SymbolObservation
{
public required string BuildId { get; init; }
public required string FunctionName { get; init; }
public string? ModuleName { get; init; }
public required long Count { get; init; }
public required DateTime Timestamp { get; init; }
}
}
/// <summary>
/// Response from hot symbol ingestion.
/// </summary>
public sealed record HotSymbolIngestResponse
{
public required int IngestedCount { get; init; }
public required int NewSymbolsCount { get; init; }
public required int UpdatedSymbolsCount { get; init; }
public required TimeSpan ProcessingTime { get; init; }
}
/// <summary>
/// Statistics for hot symbols.
/// </summary>
public sealed record HotSymbolStatistics
{
public required int TotalSymbols { get; init; }
public required long TotalObservations { get; init; }
public required int UniqueBuildIds { get; init; }
public required int SecurityRelevantSymbols { get; init; }
public required int SymbolsWithCves { get; init; }
public required DateTime EarliestObservation { get; init; }
public required DateTime LatestObservation { get; init; }
public required IReadOnlyList<ModuleObservationSummary> TopModules { get; init; }
}
/// <summary>
/// Module observation summary.
/// </summary>
public sealed record ModuleObservationSummary
{
public required string ModuleName { get; init; }
public required string BuildId { get; init; }
public required long ObservationCount { get; init; }
public required int SymbolCount { get; init; }
}
/// <summary>
/// Symbol correlation result.
/// </summary>
public sealed record SymbolCorrelationResult
{
public required object Symbol { get; init; }
public required bool InReachabilityModel { get; init; }
public string? ReachabilityState { get; init; }
public string? Purl { get; init; }
public IReadOnlyList<string>? Vulnerabilities { get; init; }
public double ConfidenceScore { get; init; }
public CorrelationMethod Method { get; init; }
}
/// <summary>
/// Correlation method.
/// </summary>
public enum CorrelationMethod
{
ExactMatch,
FunctionNameMatch,
PurlMatch,
Heuristic
}

View File

@@ -0,0 +1,339 @@
// -----------------------------------------------------------------------------
// TetragonPrivacyFilter.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-008 - Implement privacy controls following existing patterns
// Description: Privacy filtering for Tetragon events following Signals patterns
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.RuntimeInstrumentation.Tetragon;
/// <summary>
/// Privacy filter for Tetragon events following existing Signals patterns.
/// Implements argument redaction, symbol-ID-only mode, and namespace allowlisting.
/// </summary>
public sealed class TetragonPrivacyFilter : ITetragonPrivacyFilter
{
private readonly TetragonPrivacyOptions _options;
private readonly ILogger<TetragonPrivacyFilter> _logger;
private readonly Regex[] _argRedactionPatterns;
private readonly HashSet<string> _allowedNamespaces;
private readonly HashSet<string> _allowedLabels;
private readonly ActivitySource _activitySource = new("StellaOps.Tetragon.PrivacyFilter");
private static readonly string[] DefaultRedactionPatterns =
[
@"(?i)password\s*[:=]\s*\S+",
@"(?i)secret\s*[:=]\s*\S+",
@"(?i)token\s*[:=]\s*\S+",
@"(?i)api[_-]?key\s*[:=]\s*\S+",
@"(?i)bearer\s+\S+",
@"(?i)authorization\s*[:=]\s*\S+",
@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", // Email
@"\b\d{3}-\d{2}-\d{4}\b", // SSN
@"\b\d{16}\b" // Credit card (simplified)
];
/// <summary>
/// Creates a new privacy filter.
/// </summary>
public TetragonPrivacyFilter(
IOptions<TetragonPrivacyOptions> options,
ILogger<TetragonPrivacyFilter> logger)
{
_options = options?.Value ?? new TetragonPrivacyOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Compile redaction patterns
var patterns = _options.AdditionalRedactionPatterns?.ToList() ?? new List<string>();
if (_options.UseDefaultRedactionPatterns)
{
patterns.AddRange(DefaultRedactionPatterns);
}
_argRedactionPatterns = patterns
.Select(p => new Regex(p, RegexOptions.Compiled | RegexOptions.IgnoreCase))
.ToArray();
// Initialize allowlists
_allowedNamespaces = new HashSet<string>(_options.AllowedNamespaces ?? [], StringComparer.OrdinalIgnoreCase);
_allowedLabels = new HashSet<string>(_options.AllowedLabels ?? [], StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc />
public TetragonEvent? Filter(TetragonEvent evt)
{
using var activity = _activitySource.StartActivity("FilterEvent");
// Check namespace allowlist
if (!IsNamespaceAllowed(evt))
{
activity?.SetTag("filtered_reason", "namespace_not_allowed");
return null;
}
// Check label allowlist
if (!AreLabelsAllowed(evt))
{
activity?.SetTag("filtered_reason", "labels_not_allowed");
return null;
}
// Apply redactions
var filtered = evt with
{
Args = RedactArguments(evt.Args),
Process = RedactProcess(evt.Process)
};
// Apply symbol-ID-only mode if enabled
if (_options.SymbolIdOnlyMode)
{
filtered = filtered with
{
StackTrace = StripSymbolNames(filtered.StackTrace)
};
}
return filtered;
}
/// <inheritdoc />
public RuntimeCallEvent? Filter(RuntimeCallEvent evt)
{
using var activity = _activitySource.StartActivity("FilterRuntimeEvent");
// Check namespace allowlist
if (!string.IsNullOrEmpty(evt.Namespace) &&
_allowedNamespaces.Count > 0 &&
!_allowedNamespaces.Contains(evt.Namespace))
{
activity?.SetTag("filtered_reason", "namespace_not_allowed");
return null;
}
// Redact syscall arguments
var redactedArgs = evt.SyscallArgs?
.Select(arg => RedactString(arg))
.ToList();
// Apply symbol-ID-only mode
IReadOnlyList<CanonicalStackFrame> frames = evt.Frames;
if (_options.SymbolIdOnlyMode)
{
frames = evt.Frames.Select(f => f with
{
Demangled = null,
SourceFile = null,
SourceLine = null
}).ToList();
}
return evt with
{
SyscallArgs = redactedArgs,
Frames = frames
};
}
/// <inheritdoc />
public async IAsyncEnumerable<TetragonEvent> FilterStreamAsync(
IAsyncEnumerable<TetragonEvent> events,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var evt in events.WithCancellation(ct))
{
var filtered = Filter(evt);
if (filtered != null)
{
yield return filtered;
}
}
}
/// <inheritdoc />
public PrivacyStatistics GetStatistics()
{
return new PrivacyStatistics
{
AllowedNamespacesCount = _allowedNamespaces.Count,
AllowedLabelsCount = _allowedLabels.Count,
RedactionPatternsCount = _argRedactionPatterns.Length,
SymbolIdOnlyMode = _options.SymbolIdOnlyMode
};
}
private bool IsNamespaceAllowed(TetragonEvent evt)
{
if (_allowedNamespaces.Count == 0)
{
return true; // No allowlist = allow all
}
var ns = evt.Process?.Pod?.Namespace;
return !string.IsNullOrEmpty(ns) && _allowedNamespaces.Contains(ns);
}
private bool AreLabelsAllowed(TetragonEvent evt)
{
if (_allowedLabels.Count == 0)
{
return true; // No allowlist = allow all
}
// Events must have at least one allowed label (presence check)
// In a real implementation, this would check pod labels
return true; // Simplified for now
}
private IReadOnlyList<object>? RedactArguments(IReadOnlyList<object>? args)
{
if (args == null || args.Count == 0 || !_options.RedactArguments)
{
return args;
}
return args.Select(arg =>
{
if (arg is string strArg)
{
return (object)RedactString(strArg);
}
return arg;
}).ToList();
}
private string RedactString(string input)
{
if (string.IsNullOrEmpty(input))
{
return input;
}
var result = input;
foreach (var pattern in _argRedactionPatterns)
{
result = pattern.Replace(result, "[REDACTED]");
}
return result;
}
private TetragonProcess? RedactProcess(TetragonProcess? process)
{
if (process == null || !_options.RedactArguments)
{
return process;
}
return process with
{
Binary = _options.RedactBinaryPaths ? RedactPath(process.Binary) : process.Binary
};
}
private string? RedactPath(string? path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
// Keep only the filename, redact full path
return System.IO.Path.GetFileName(path);
}
private TetragonStackTrace? StripSymbolNames(TetragonStackTrace? stackTrace)
{
if (stackTrace?.Frames == null)
{
return stackTrace;
}
return stackTrace with
{
Frames = stackTrace.Frames.Select(f => f with
{
Symbol = $"sym_{f.Address:X16}" // Symbol ID only
}).ToList()
};
}
}
/// <summary>
/// Interface for Tetragon privacy filtering.
/// </summary>
public interface ITetragonPrivacyFilter
{
/// <summary>
/// Filters a single Tetragon event.
/// </summary>
TetragonEvent? Filter(TetragonEvent evt);
/// <summary>
/// Filters a runtime call event.
/// </summary>
RuntimeCallEvent? Filter(RuntimeCallEvent evt);
/// <summary>
/// Filters a stream of events.
/// </summary>
IAsyncEnumerable<TetragonEvent> FilterStreamAsync(
IAsyncEnumerable<TetragonEvent> events,
CancellationToken ct = default);
/// <summary>
/// Gets privacy filter statistics.
/// </summary>
PrivacyStatistics GetStatistics();
}
/// <summary>
/// Privacy filter configuration.
/// </summary>
public sealed record TetragonPrivacyOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon:Privacy";
/// <summary>Whether to redact arguments (default: true).</summary>
public bool RedactArguments { get; init; } = true;
/// <summary>Whether to redact binary paths (default: false).</summary>
public bool RedactBinaryPaths { get; init; } = false;
/// <summary>Whether to use default redaction patterns (default: true).</summary>
public bool UseDefaultRedactionPatterns { get; init; } = true;
/// <summary>Additional regex patterns for redaction.</summary>
public IReadOnlyList<string>? AdditionalRedactionPatterns { get; init; }
/// <summary>Symbol-ID-only mode strips human-readable symbol names.</summary>
public bool SymbolIdOnlyMode { get; init; } = false;
/// <summary>Allowed namespaces (empty = allow all).</summary>
public IReadOnlyList<string>? AllowedNamespaces { get; init; }
/// <summary>Allowed pod labels (empty = allow all).</summary>
public IReadOnlyList<string>? AllowedLabels { get; init; }
}
/// <summary>
/// Privacy filter statistics.
/// </summary>
public sealed record PrivacyStatistics
{
/// <summary>Number of allowed namespaces.</summary>
public int AllowedNamespacesCount { get; init; }
/// <summary>Number of allowed labels.</summary>
public int AllowedLabelsCount { get; init; }
/// <summary>Number of redaction patterns.</summary>
public int RedactionPatternsCount { get; init; }
/// <summary>Whether symbol-ID-only mode is enabled.</summary>
public bool SymbolIdOnlyMode { get; init; }
}

View File

@@ -0,0 +1,412 @@
// -----------------------------------------------------------------------------
// TetragonWitnessBridge.cs
// Sprint: SPRINT_20260118_019_Infra_tetragon_integration
// Task: TASK-019-006 - Create witness emission bridge to SPRINT_016 pipeline
// Description: Bridges Tetragon captures to RuntimeWitnessRequest for signing
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.RuntimeInstrumentation.Tetragon;
/// <summary>
/// Bridges Tetragon runtime observations to the witness signing pipeline.
/// Buffers captures by claim_id and emits to SignedWitnessGenerator.
/// </summary>
public sealed class TetragonWitnessBridge : ITetragonWitnessBridge, IDisposable
{
private readonly IRuntimeWitnessGenerator _witnessGenerator;
private readonly TetragonWitnessBridgeOptions _options;
private readonly ILogger<TetragonWitnessBridge> _logger;
private readonly ConcurrentDictionary<string, ClaimBuffer> _buffers = new();
private readonly SemaphoreSlim _backpressure;
private readonly Timer _flushTimer;
private readonly ActivitySource _activitySource = new("StellaOps.Tetragon.WitnessBridge");
private bool _disposed;
/// <summary>
/// Creates a new witness bridge.
/// </summary>
public TetragonWitnessBridge(
IRuntimeWitnessGenerator witnessGenerator,
IOptions<TetragonWitnessBridgeOptions> options,
ILogger<TetragonWitnessBridge> logger)
{
_witnessGenerator = witnessGenerator ?? throw new ArgumentNullException(nameof(witnessGenerator));
_options = options?.Value ?? new TetragonWitnessBridgeOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_backpressure = new SemaphoreSlim(_options.MaxConcurrentWitnesses);
_flushTimer = new Timer(
FlushTimerCallback,
null,
_options.BufferTimeout,
_options.BufferTimeout);
}
/// <inheritdoc />
public async Task BufferObservationAsync(
RuntimeObservation observation,
WitnessContext context,
CancellationToken ct = default)
{
using var activity = _activitySource.StartActivity("BufferObservation");
activity?.SetTag("claim_id", context.ClaimId);
var buffer = _buffers.GetOrAdd(context.ClaimId, _ => new ClaimBuffer
{
ClaimId = context.ClaimId,
Context = context,
CreatedAt = DateTimeOffset.UtcNow
});
buffer.Observations.Add(observation);
buffer.LastUpdated = DateTimeOffset.UtcNow;
activity?.SetTag("buffer_size", buffer.Observations.Count);
// Check if buffer should be emitted
if (buffer.Observations.Count >= _options.MinObservationsForWitness)
{
await TryEmitWitnessAsync(context.ClaimId, ct);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<RuntimeWitnessResult> ProcessStreamAsync(
IAsyncEnumerable<(RuntimeObservation observation, WitnessContext context)> stream,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var (observation, context) in stream.WithCancellation(ct))
{
await BufferObservationAsync(observation, context, ct);
// Yield any ready witnesses
foreach (var claimId in _buffers.Keys.ToList())
{
if (_buffers.TryGetValue(claimId, out var buffer) &&
buffer.Observations.Count >= _options.MinObservationsForWitness)
{
var result = await EmitWitnessAsync(claimId, ct);
if (result != null)
{
yield return result;
}
}
}
}
// Final flush of remaining buffers
await foreach (var result in FlushAllAsync(ct))
{
yield return result;
}
}
/// <inheritdoc />
public async Task<RuntimeWitnessResult?> TryEmitWitnessAsync(
string claimId,
CancellationToken ct = default)
{
if (!_buffers.TryGetValue(claimId, out var buffer))
{
return null;
}
if (buffer.Observations.Count < _options.MinObservationsForWitness)
{
return null;
}
return await EmitWitnessAsync(claimId, ct);
}
/// <inheritdoc />
public async IAsyncEnumerable<RuntimeWitnessResult> FlushAllAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var claimId in _buffers.Keys.ToList())
{
var result = await EmitWitnessAsync(claimId, ct);
if (result != null)
{
yield return result;
}
}
}
private async Task<RuntimeWitnessResult?> EmitWitnessAsync(
string claimId,
CancellationToken ct)
{
if (!_buffers.TryRemove(claimId, out var buffer))
{
return null;
}
if (buffer.Observations.Count == 0)
{
return null;
}
using var activity = _activitySource.StartActivity("EmitWitness");
activity?.SetTag("claim_id", claimId);
activity?.SetTag("observation_count", buffer.Observations.Count);
// Apply backpressure
await _backpressure.WaitAsync(ct);
try
{
var request = new RuntimeWitnessRequest
{
ClaimId = claimId,
ArtifactDigest = buffer.Context.ArtifactDigest,
ComponentPurl = buffer.Context.ComponentPurl,
VulnerabilityId = buffer.Context.VulnerabilityId,
Observations = buffer.Observations.ToList(),
PublishToRekor = _options.PublishToRekor,
SigningOptions = new RuntimeWitnessSigningOptions
{
UseKeyless = _options.UseKeylessSigning,
KeyId = _options.SigningKeyId,
Algorithm = _options.SigningAlgorithm
}
};
var result = await _witnessGenerator.GenerateAsync(request, ct);
if (result.Success)
{
_logger.LogInformation(
"Generated runtime witness for claim {ClaimId} with {Count} observations, Rekor index: {RekorIndex}",
claimId, buffer.Observations.Count, result.RekorLogIndex);
}
else
{
_logger.LogWarning(
"Failed to generate witness for claim {ClaimId}: {Error}",
claimId, result.ErrorMessage);
}
return result;
}
finally
{
_backpressure.Release();
}
}
private void FlushTimerCallback(object? state)
{
try
{
var cutoff = DateTimeOffset.UtcNow - _options.BufferTimeout;
foreach (var (claimId, buffer) in _buffers)
{
if (buffer.LastUpdated < cutoff && buffer.Observations.Count > 0)
{
_logger.LogDebug("Flushing stale buffer for claim {ClaimId}", claimId);
_ = EmitWitnessAsync(claimId, CancellationToken.None);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in flush timer callback");
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_flushTimer.Dispose();
_backpressure.Dispose();
_activitySource.Dispose();
}
private sealed class ClaimBuffer
{
public required string ClaimId { get; init; }
public required WitnessContext Context { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUpdated { get; set; }
public List<RuntimeObservation> Observations { get; } = new();
}
}
/// <summary>
/// Interface for the Tetragon witness bridge.
/// </summary>
public interface ITetragonWitnessBridge
{
/// <summary>
/// Buffers an observation for later witness emission.
/// </summary>
Task BufferObservationAsync(
RuntimeObservation observation,
WitnessContext context,
CancellationToken ct = default);
/// <summary>
/// Processes a stream of observations and emits witnesses.
/// </summary>
IAsyncEnumerable<RuntimeWitnessResult> ProcessStreamAsync(
IAsyncEnumerable<(RuntimeObservation observation, WitnessContext context)> stream,
CancellationToken ct = default);
/// <summary>
/// Attempts to emit a witness for a claim if ready.
/// </summary>
Task<RuntimeWitnessResult?> TryEmitWitnessAsync(
string claimId,
CancellationToken ct = default);
/// <summary>
/// Flushes all buffered observations as witnesses.
/// </summary>
IAsyncEnumerable<RuntimeWitnessResult> FlushAllAsync(CancellationToken ct = default);
}
/// <summary>
/// Context for witness generation.
/// </summary>
public sealed record WitnessContext
{
/// <summary>
/// Claim ID linking to static analysis claim.
/// </summary>
public required string ClaimId { get; init; }
/// <summary>
/// Artifact digest for context.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Component PURL being observed.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Optional vulnerability ID.
/// </summary>
public string? VulnerabilityId { get; init; }
}
/// <summary>
/// Configuration for the witness bridge.
/// </summary>
public sealed record TetragonWitnessBridgeOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Tetragon:WitnessBridge";
/// <summary>Minimum observations before emitting witness (default: 5).</summary>
public int MinObservationsForWitness { get; init; } = 5;
/// <summary>Maximum concurrent witness generations (default: 4).</summary>
public int MaxConcurrentWitnesses { get; init; } = 4;
/// <summary>Buffer timeout for stale observations (default: 5 minutes).</summary>
public TimeSpan BufferTimeout { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>Whether to publish to Rekor (default: true).</summary>
public bool PublishToRekor { get; init; } = true;
/// <summary>Whether to use keyless signing (default: true).</summary>
public bool UseKeylessSigning { get; init; } = true;
/// <summary>Signing key ID if not using keyless.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Signing algorithm (default: ECDSA_P256_SHA256).</summary>
public string SigningAlgorithm { get; init; } = "ECDSA_P256_SHA256";
}
// Interface placeholders - should import from Scanner module
/// <summary>
/// Generator for signed runtime witnesses.
/// </summary>
public interface IRuntimeWitnessGenerator
{
Task<RuntimeWitnessResult> GenerateAsync(RuntimeWitnessRequest request, CancellationToken ct = default);
}
/// <summary>
/// Request for runtime witness generation.
/// </summary>
public sealed record RuntimeWitnessRequest
{
public required string ClaimId { get; init; }
public required string ArtifactDigest { get; init; }
public required string ComponentPurl { get; init; }
public string? VulnerabilityId { get; init; }
public required IReadOnlyList<RuntimeObservation> Observations { get; init; }
public bool PublishToRekor { get; init; } = true;
public RuntimeWitnessSigningOptions SigningOptions { get; init; } = new();
}
/// <summary>
/// Signing options for runtime witnesses.
/// </summary>
public sealed record RuntimeWitnessSigningOptions
{
public string? KeyId { get; init; }
public bool UseKeyless { get; init; } = true;
public string Algorithm { get; init; } = "ECDSA_P256_SHA256";
}
/// <summary>
/// Result of runtime witness generation.
/// </summary>
public sealed record RuntimeWitnessResult
{
public required bool Success { get; init; }
public object? Witness { get; init; }
public byte[]? EnvelopeBytes { get; init; }
public long? RekorLogIndex { get; init; }
public string? RekorLogId { get; init; }
public string? CasUri { get; init; }
public string? ErrorMessage { get; init; }
public string? ClaimId { get; init; }
}
/// <summary>
/// Runtime observation for witness evidence.
/// </summary>
public sealed record RuntimeObservation
{
public required DateTimeOffset ObservedAt { get; init; }
public int ObservationCount { get; init; } = 1;
public string? StackSampleHash { get; init; }
public int? ProcessId { get; init; }
public string? ContainerId { get; init; }
public string? PodName { get; init; }
public string? Namespace { get; init; }
public required RuntimeObservationSourceType SourceType { get; init; }
public string? ObservationId { get; init; }
}
/// <summary>
/// Source type for observations.
/// </summary>
public enum RuntimeObservationSourceType
{
Tetragon,
OpenTelemetry,
Profiler,
Tracer,
Custom
}