Files
git.stella-ops.org/src/RuntimeInstrumentation/StellaOps.RuntimeInstrumentation.Tetragon/TetragonWitnessBridge.cs

413 lines
13 KiB
C#

// -----------------------------------------------------------------------------
// 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
}