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:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: <5%
|
||||
/// - Memory overhead of agent: <100MB
|
||||
/// - Capture latency (P95): <100ms
|
||||
/// - Throughput: >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: >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: <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: >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: <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: <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: >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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user