413 lines
13 KiB
C#
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
|
|
}
|