compose and authority fixes. finish sprints.

This commit is contained in:
master
2026-02-17 21:59:47 +02:00
parent fb46a927ad
commit 49cdebe2f1
187 changed files with 23189 additions and 1439 deletions

View File

@@ -406,20 +406,27 @@ if (bootstrapOptions.Authority.Enabled)
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrapOptions.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrapOptions.Authority.TokenClockSkewSeconds);
// Read collections directly from IConfiguration to work around
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
var authoritySection = builder.Configuration.GetSection("scanner:Authority");
var audiences = authoritySection.GetSection("Audiences").Get<string[]>() ?? [];
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrapOptions.Authority.Audiences)
foreach (var audience in audiences)
{
resourceOptions.Audiences.Add(audience);
}
var requiredScopes = authoritySection.GetSection("RequiredScopes").Get<string[]>() ?? [];
resourceOptions.RequiredScopes.Clear();
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
foreach (var scope in requiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
var bypassNetworks = authoritySection.GetSection("BypassNetworks").Get<string[]>() ?? [];
resourceOptions.BypassNetworks.Clear();
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
foreach (var network in bypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}

View File

@@ -4,6 +4,8 @@
namespace StellaOps.Scanner.Reachability.Jobs;
using StellaOps.Scanner.Reachability.Runtime;
/// <summary>
/// A job request for reachability evidence analysis.
/// </summary>
@@ -135,6 +137,11 @@ public sealed record ReachabilityJobOptions
/// </summary>
public bool UseHistoricalRuntimeData { get; init; } = true;
/// <summary>
/// Optional runtime witness emission configuration for Layer 3 observations.
/// </summary>
public RuntimeWitnessEmissionRequest? RuntimeWitnessEmission { get; init; }
/// <summary>
/// Default options for standard analysis.
/// </summary>

View File

@@ -378,7 +378,8 @@ public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobEx
ImageDigest = job.ImageDigest,
TargetSymbols = targetSymbols,
Duration = job.Options.RuntimeObservationDuration ?? TimeSpan.FromMinutes(5),
UseHistoricalData = true
UseHistoricalData = job.Options.UseHistoricalRuntimeData,
WitnessEmission = job.Options.RuntimeWitnessEmission
};
var result = await _runtimeCollector.ObserveAsync(request, ct);

View File

@@ -6,10 +6,13 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.Signals.Ebpf.Schema;
using StellaOps.Signals.Ebpf.Services;
using System.Collections.Immutable;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.Reachability.Runtime;
@@ -20,6 +23,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
{
private readonly IRuntimeSignalCollector _signalCollector;
private readonly IRuntimeObservationStore _observationStore;
private readonly IRuntimeWitnessGenerator? _runtimeWitnessGenerator;
private readonly ILogger<EbpfRuntimeReachabilityCollector> _logger;
private readonly TimeProvider _timeProvider;
@@ -27,12 +31,14 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
IRuntimeSignalCollector signalCollector,
IRuntimeObservationStore observationStore,
ILogger<EbpfRuntimeReachabilityCollector> logger,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IRuntimeWitnessGenerator? runtimeWitnessGenerator = null)
{
_signalCollector = signalCollector ?? throw new ArgumentNullException(nameof(signalCollector));
_observationStore = observationStore ?? throw new ArgumentNullException(nameof(observationStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_runtimeWitnessGenerator = runtimeWitnessGenerator;
}
/// <inheritdoc />
@@ -47,6 +53,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
request.WitnessEmission?.Validate();
var startTime = _timeProvider.GetUtcNow();
@@ -114,8 +121,14 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
return null;
}
var anyObserved = observations.Any(o => o.WasObserved);
var layer3 = BuildLayer3FromObservations(observations, ObservationSource.Historical);
var btfSelection = _signalCollector.GetBtfSelection();
var witness = await TryGenerateRuntimeWitnessAsync(
request,
observations,
ObservationSource.Historical,
observedAt: _timeProvider.GetUtcNow(),
ct).ConfigureAwait(false);
return new RuntimeReachabilityResult
{
@@ -124,7 +137,9 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
Observations = observations,
ObservedAt = _timeProvider.GetUtcNow(),
Duration = TimeSpan.Zero,
Source = ObservationSource.Historical
Source = ObservationSource.Historical,
BtfSelection = btfSelection,
Witness = witness,
};
}
@@ -141,6 +156,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
ResolveSymbols = true,
MaxEventsPerSecond = 5000
};
RuntimeSignalSummary? summary = null;
var handle = await _signalCollector.StartCollectionAsync(
request.ContainerId, options, ct);
@@ -167,7 +183,7 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
finally
{
// Always stop collection
var summary = await _signalCollector.StopCollectionAsync(handle, ct);
summary = await _signalCollector.StopCollectionAsync(handle, ct);
_logger.LogInformation(
"Stopped eBPF signal collection: {TotalEvents} events, {UniqueSymbols} symbols observed",
@@ -182,6 +198,12 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
var layer3 = BuildLayer3FromObservations(observations, ObservationSource.Live);
var duration = _timeProvider.GetUtcNow() - startTime;
var witness = await TryGenerateRuntimeWitnessAsync(
request,
observations,
ObservationSource.Live,
observedAt: startTime,
ct).ConfigureAwait(false);
return new RuntimeReachabilityResult
{
@@ -190,7 +212,9 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
Observations = observations,
ObservedAt = startTime,
Duration = duration,
Source = ObservationSource.Live
Source = ObservationSource.Live,
BtfSelection = summary?.BtfSelection ?? _signalCollector.GetBtfSelection(),
Witness = witness,
};
}
@@ -252,7 +276,8 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
ObservedAt = startTime,
Duration = TimeSpan.Zero,
Error = reason,
Source = ObservationSource.None
Source = ObservationSource.None,
BtfSelection = _signalCollector.GetBtfSelection(),
};
}
@@ -274,9 +299,170 @@ public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityColle
ObservedAt = startTime,
Duration = _timeProvider.GetUtcNow() - startTime,
Error = error,
Source = ObservationSource.None
Source = ObservationSource.None,
BtfSelection = _signalCollector.GetBtfSelection(),
};
}
private async Task<RuntimeWitnessResult?> TryGenerateRuntimeWitnessAsync(
RuntimeObservationRequest request,
IReadOnlyList<SymbolObservation> observations,
ObservationSource source,
DateTimeOffset observedAt,
CancellationToken ct)
{
if (_runtimeWitnessGenerator is null)
{
return null;
}
var emission = request.WitnessEmission;
if (emission is null || !emission.Enabled)
{
return null;
}
try
{
var runtimeObservations = ToRuntimeObservations(
observations,
request.ContainerId,
source,
observedAt);
if (runtimeObservations.Count == 0)
{
return RuntimeWitnessResult.Failed(
emission.ClaimId ?? string.Empty,
"No runtime observations available for witness generation.");
}
var claimId = string.IsNullOrWhiteSpace(emission.ClaimId)
? ClaimIdGenerator.Generate(
request.ImageDigest,
ComputeRuntimePathHash(runtimeObservations))
: emission.ClaimId!;
var witnessRequest = new RuntimeWitnessRequest
{
ClaimId = claimId,
ArtifactDigest = request.ImageDigest,
ComponentPurl = emission.ComponentPurl,
VulnerabilityId = emission.VulnerabilityId,
Observations = runtimeObservations,
Symbolization = emission.Symbolization,
PublishToRekor = emission.PublishToRekor,
SigningOptions = emission.SigningOptions
};
var result = await _runtimeWitnessGenerator.GenerateAsync(witnessRequest, ct).ConfigureAwait(false);
if (!result.Success)
{
_logger.LogWarning(
"Runtime witness generation failed for container {ContainerId}: {Error}",
request.ContainerId,
result.ErrorMessage);
}
return result;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or FormatException)
{
_logger.LogWarning(
ex,
"Runtime witness generation configuration invalid for container {ContainerId}",
request.ContainerId);
return RuntimeWitnessResult.Failed(
emission.ClaimId ?? string.Empty,
ex.Message);
}
}
private static IReadOnlyList<RuntimeObservation> ToRuntimeObservations(
IReadOnlyList<SymbolObservation> observations,
string containerId,
ObservationSource source,
DateTimeOffset fallbackObservedAt)
{
return observations
.Where(static observation => observation.WasObserved && observation.ObservationCount > 0)
.OrderBy(static observation => observation.Symbol, StringComparer.Ordinal)
.ThenBy(static observation => observation.FirstObservedAt ?? DateTimeOffset.MinValue)
.ThenBy(static observation => observation.LastObservedAt ?? DateTimeOffset.MinValue)
.Select(observation =>
{
var observedAt = observation.LastObservedAt
?? observation.FirstObservedAt
?? fallbackObservedAt;
var durationMicros = observation.FirstObservedAt.HasValue
&& observation.LastObservedAt.HasValue
&& observation.LastObservedAt >= observation.FirstObservedAt
? (long?)(observation.LastObservedAt.Value - observation.FirstObservedAt.Value).TotalMilliseconds * 1000L
: null;
return new RuntimeObservation
{
ObservedAt = observedAt,
ObservationCount = Math.Max(1, observation.ObservationCount),
StackSampleHash = ComputeStackSampleHash(observation),
ProcessId = null,
ContainerId = containerId,
PodName = null,
Namespace = null,
SourceType = source == ObservationSource.Live
? RuntimeObservationSourceType.Tetragon
: RuntimeObservationSourceType.Custom,
ObservationId = ComputeObservationId(observation),
DurationMicroseconds = durationMicros
};
})
.ToList();
}
private static string ComputeRuntimePathHash(IReadOnlyList<RuntimeObservation> observations)
{
var seed = string.Join(
"\n",
observations
.OrderBy(static observation => observation.ObservedAt)
.ThenBy(static observation => observation.ObservationId ?? string.Empty, StringComparer.Ordinal)
.Select(static observation => $"{observation.StackSampleHash}|{observation.ObservationCount}|{observation.ObservedAt:O}"));
return ComputeSha256Hex(seed);
}
private static string ComputeObservationId(SymbolObservation observation)
{
var input = string.Join("|",
observation.Symbol,
observation.FirstObservedAt?.ToUniversalTime().ToString("O") ?? string.Empty,
observation.LastObservedAt?.ToUniversalTime().ToString("O") ?? string.Empty,
observation.ObservationCount.ToString(),
string.Join(";", observation.Paths
.OrderBy(static path => string.Join(">", path.Symbols), StringComparer.Ordinal)
.Select(static path => $"{string.Join(">", path.Symbols)}:{path.Count}")));
return $"obs:{ComputeSha256Hex(input)}";
}
private static string ComputeStackSampleHash(SymbolObservation observation)
{
var seed = string.Join("|",
observation.Symbol,
observation.ObservationCount.ToString(),
string.Join(";", observation.Paths
.OrderBy(static path => string.Join(">", path.Symbols), StringComparer.Ordinal)
.Select(static path => $"{string.Join(">", path.Symbols)}:{path.Count}")));
return $"sha256:{ComputeSha256Hex(seed)}";
}
private static string ComputeSha256Hex(string input)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
/// <summary>

View File

@@ -3,6 +3,8 @@
// Sprint: EVID-001-004 - Runtime Reachability Collection
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.Signals.Ebpf.Schema;
namespace StellaOps.Scanner.Reachability.Runtime;
@@ -84,6 +86,72 @@ public sealed record RuntimeObservationRequest
/// Time window for historical data lookup.
/// </summary>
public TimeSpan HistoricalWindow { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Optional runtime witness emission settings.
/// When provided and enabled, collector attempts witness generation from observed symbols.
/// </summary>
public RuntimeWitnessEmissionRequest? WitnessEmission { get; init; }
}
/// <summary>
/// Optional witness emission settings for runtime observation requests.
/// </summary>
public sealed record RuntimeWitnessEmissionRequest
{
/// <summary>
/// Enables runtime witness generation for this observation request.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Optional claim ID linking runtime witness to static claim.
/// When omitted, the collector generates a deterministic claim ID from runtime observations.
/// </summary>
public string? ClaimId { get; init; }
/// <summary>
/// Component PURL associated with observed runtime symbols.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Optional vulnerability identifier for witness context.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Deterministic symbolization tuple required for runtime witnesses.
/// </summary>
public required WitnessSymbolization Symbolization { get; init; }
/// <summary>
/// Whether witness generation should publish to Rekor when configured.
/// </summary>
public bool PublishToRekor { get; init; } = true;
/// <summary>
/// Signing options for runtime witness generation.
/// </summary>
public RuntimeWitnessSigningOptions SigningOptions { get; init; } = new();
/// <summary>
/// Validates witness emission configuration.
/// </summary>
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ComponentPurl))
{
throw new ArgumentException("ComponentPurl is required for runtime witness emission.", nameof(ComponentPurl));
}
Symbolization.Validate();
}
}
/// <summary>
@@ -125,6 +193,16 @@ public sealed record RuntimeReachabilityResult
/// Source of the data (live, historical, none).
/// </summary>
public ObservationSource Source { get; init; }
/// <summary>
/// Deterministic BTF source metadata used by runtime collector.
/// </summary>
public RuntimeBtfSelection? BtfSelection { get; init; }
/// <summary>
/// Runtime witness generation result, if witness emission was requested.
/// </summary>
public RuntimeWitnessResult? Witness { get; init; }
}
/// <summary>

View File

@@ -5,11 +5,13 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.Reachability.Binary;
using StellaOps.Scanner.Reachability.Jobs;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Services;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.Scanner.Reachability.Vex;
namespace StellaOps.Scanner.Reachability;
@@ -56,6 +58,18 @@ public static class ServiceCollectionExtensions
// Runtime Collection (optional - requires eBPF infrastructure; null default for environments without eBPF)
services.TryAddSingleton<IRuntimeReachabilityCollector, NullRuntimeReachabilityCollector>();
// Runtime Witness Generation (deterministic runtime witness profile)
services.TryAddSingleton<IWitnessDsseSigner, WitnessDsseSigner>();
services.TryAddSingleton<IRuntimeWitnessSigningKeyProvider, NullRuntimeWitnessSigningKeyProvider>();
services.TryAddSingleton<IRuntimeWitnessStorage>(sp =>
{
var cas = sp.GetService<IFileContentAddressableStore>();
return cas is null
? new NullRuntimeWitnessStorage()
: new CasRuntimeWitnessStorage(cas);
});
services.TryAddSingleton<IRuntimeWitnessGenerator, RuntimeWitnessGenerator>();
// Binary Patch Verification (requires Ghidra infrastructure; null default for environments without Ghidra)
services.TryAddSingleton<IBinaryPatchVerifier, NullBinaryPatchVerifier>();
@@ -82,6 +96,16 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<IEvidenceStorageService, NullEvidenceStorageService>();
services.TryAddScoped<IReachabilityEvidenceJobExecutor, ReachabilityEvidenceJobExecutor>();
services.TryAddSingleton<IRuntimeReachabilityCollector, NullRuntimeReachabilityCollector>();
services.TryAddSingleton<IWitnessDsseSigner, WitnessDsseSigner>();
services.TryAddSingleton<IRuntimeWitnessSigningKeyProvider, NullRuntimeWitnessSigningKeyProvider>();
services.TryAddSingleton<IRuntimeWitnessStorage>(sp =>
{
var cas = sp.GetService<IFileContentAddressableStore>();
return cas is null
? new NullRuntimeWitnessStorage()
: new CasRuntimeWitnessStorage(cas);
});
services.TryAddSingleton<IRuntimeWitnessGenerator, RuntimeWitnessGenerator>();
services.TryAddSingleton<IBinaryPatchVerifier, NullBinaryPatchVerifier>();
return services;

View File

@@ -151,6 +151,11 @@ public interface IRuntimeWitnessContextProvider
/// </summary>
string? GetVulnerabilityId(RuntimeObservation observation);
/// <summary>
/// Gets deterministic symbolization metadata for the observation.
/// </summary>
WitnessSymbolization? GetSymbolization(RuntimeObservation observation);
/// <summary>
/// Gets signing options.
/// </summary>

View File

@@ -0,0 +1,34 @@
using StellaOps.Attestor.Envelope;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Resolves signing keys for runtime witness generation.
/// </summary>
public interface IRuntimeWitnessSigningKeyProvider
{
/// <summary>
/// Attempts to resolve a signing key for the provided options.
/// </summary>
bool TryResolveSigningKey(
RuntimeWitnessSigningOptions options,
out EnvelopeKey? signingKey,
out string? errorMessage);
}
/// <summary>
/// Default signing key provider used when no signing key source is configured.
/// </summary>
public sealed class NullRuntimeWitnessSigningKeyProvider : IRuntimeWitnessSigningKeyProvider
{
/// <inheritdoc />
public bool TryResolveSigningKey(
RuntimeWitnessSigningOptions options,
out EnvelopeKey? signingKey,
out string? errorMessage)
{
signingKey = null;
errorMessage = "Runtime witness signing key is not configured.";
return false;
}
}

View File

@@ -0,0 +1,88 @@
using StellaOps.Scanner.Cache.Abstractions;
using System.Security.Cryptography;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Persists runtime witness artifacts for replay and audit.
/// </summary>
public interface IRuntimeWitnessStorage
{
/// <summary>
/// Stores runtime witness artifacts and returns a primary artifact URI.
/// </summary>
Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default);
}
/// <summary>
/// Storage request containing canonical payload and envelope artifacts.
/// </summary>
/// <param name="Witness">Runtime witness document.</param>
/// <param name="PayloadBytes">Canonical witness JSON bytes.</param>
/// <param name="EnvelopeBytes">Serialized DSSE envelope bytes.</param>
public sealed record RuntimeWitnessStorageRequest(
PathWitness Witness,
byte[] PayloadBytes,
byte[] EnvelopeBytes);
/// <summary>
/// No-op witness storage implementation.
/// </summary>
public sealed class NullRuntimeWitnessStorage : IRuntimeWitnessStorage
{
/// <inheritdoc />
public Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
return Task.FromResult<string?>(null);
}
}
/// <summary>
/// Stores runtime witness artifacts in CAS using deterministic SHA-256 keys.
/// </summary>
public sealed class CasRuntimeWitnessStorage : IRuntimeWitnessStorage
{
private readonly IFileContentAddressableStore _cas;
/// <summary>
/// Creates a CAS-backed runtime witness storage.
/// </summary>
public CasRuntimeWitnessStorage(IFileContentAddressableStore cas)
{
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
}
/// <inheritdoc />
public async Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
var witnessSha = ComputeSha256Hex(request.PayloadBytes);
var envelopeSha = ComputeSha256Hex(request.EnvelopeBytes);
await PutIfMissingAsync(witnessSha, request.PayloadBytes, ct).ConfigureAwait(false);
await PutIfMissingAsync(envelopeSha, request.EnvelopeBytes, ct).ConfigureAwait(false);
return $"cas://runtime-witness/dsse/{envelopeSha}";
}
private async Task PutIfMissingAsync(string sha256, byte[] content, CancellationToken ct)
{
var existing = await _cas.TryGetAsync(sha256, ct).ConfigureAwait(false);
if (existing is not null)
{
return;
}
await using var stream = new MemoryStream(content, writable: false);
await _cas.PutAsync(new FileCasPutRequest(sha256, stream, leaveOpen: false), ct).ConfigureAwait(false);
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -150,6 +150,36 @@ public sealed record PathWitness
/// </summary>
[JsonPropertyName("observations")]
public IReadOnlyList<RuntimeObservation>? Observations { get; init; }
/// <summary>
/// Deterministic symbolization tuple used to reproduce runtime frames.
/// Required for runtime/confirmed witnesses.
/// </summary>
[JsonPropertyName("symbolization")]
public WitnessSymbolization? Symbolization { get; init; }
/// <summary>
/// Validates deterministic symbolization requirements for runtime witnesses.
/// </summary>
/// <exception cref="InvalidOperationException">If required symbolization inputs are missing.</exception>
public void ValidateDeterministicSymbolization()
{
var requiresRuntimeSymbolization = ObservationType != ObservationType.Static
|| (Observations is not null && Observations.Count > 0);
if (!requiresRuntimeSymbolization)
{
return;
}
if (Symbolization is null)
{
throw new InvalidOperationException(
"Runtime witness is missing required symbolization tuple.");
}
Symbolization.Validate();
}
}
/// <summary>
@@ -337,3 +367,127 @@ public sealed record WitnessEvidence
[JsonPropertyName("build_id")]
public string? BuildId { get; init; }
}
/// <summary>
/// Deterministic symbolization tuple required for runtime witness replay.
/// </summary>
public sealed record WitnessSymbolization
{
/// <summary>
/// Build ID of the observed userspace binary.
/// </summary>
[JsonPropertyName("build_id")]
public required string BuildId { get; init; }
/// <summary>
/// URI to debug artifact for symbolization.
/// </summary>
[JsonPropertyName("debug_artifact_uri")]
public string? DebugArtifactUri { get; init; }
/// <summary>
/// URI to symbol table material for symbolization.
/// </summary>
[JsonPropertyName("symbol_table_uri")]
public string? SymbolTableUri { get; init; }
/// <summary>
/// Symbolizer identity, version, and digest.
/// </summary>
[JsonPropertyName("symbolizer")]
public required WitnessSymbolizer Symbolizer { get; init; }
/// <summary>
/// libc variant used during symbolization (for example glibc or musl).
/// </summary>
[JsonPropertyName("libc_variant")]
public required string LibcVariant { get; init; }
/// <summary>
/// Digest of the sysroot used by symbolization.
/// </summary>
[JsonPropertyName("sysroot_digest")]
public required string SysrootDigest { get; init; }
/// <summary>
/// Validates required deterministic symbolization fields.
/// </summary>
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(BuildId))
{
throw new InvalidOperationException("Symbolization requires non-empty build_id.");
}
if (string.IsNullOrWhiteSpace(DebugArtifactUri)
&& string.IsNullOrWhiteSpace(SymbolTableUri))
{
throw new InvalidOperationException(
"Symbolization requires at least one of debug_artifact_uri or symbol_table_uri.");
}
if (Symbolizer is null)
{
throw new InvalidOperationException("Symbolization requires symbolizer metadata.");
}
Symbolizer.Validate();
if (string.IsNullOrWhiteSpace(LibcVariant))
{
throw new InvalidOperationException("Symbolization requires non-empty libc_variant.");
}
if (string.IsNullOrWhiteSpace(SysrootDigest))
{
throw new InvalidOperationException("Symbolization requires non-empty sysroot_digest.");
}
}
}
/// <summary>
/// Identity details for the symbolizer tool used to resolve frames.
/// </summary>
public sealed record WitnessSymbolizer
{
/// <summary>
/// Symbolizer tool name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Symbolizer tool version.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Digest of the symbolizer binary or package.
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// Validates required symbolizer identity fields.
/// </summary>
/// <exception cref="InvalidOperationException">If required fields are missing.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Name))
{
throw new InvalidOperationException("Symbolization requires symbolizer.name.");
}
if (string.IsNullOrWhiteSpace(Version))
{
throw new InvalidOperationException("Symbolization requires symbolizer.version.");
}
if (string.IsNullOrWhiteSpace(Digest))
{
throw new InvalidOperationException("Symbolization requires symbolizer.digest.");
}
}
}

View File

@@ -0,0 +1,444 @@
using StellaOps.Attestor.Envelope;
using StellaOps.Canonical.Json;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Generates DSSE-signed runtime witnesses from runtime observations.
/// </summary>
public sealed class RuntimeWitnessGenerator : IRuntimeWitnessGenerator
{
private const int MaxPathSteps = 32;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.Default
};
private readonly IWitnessDsseSigner _signer;
private readonly IRuntimeWitnessSigningKeyProvider _signingKeyProvider;
private readonly IRuntimeWitnessStorage _storage;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Creates a runtime witness generator.
/// </summary>
public RuntimeWitnessGenerator(
IWitnessDsseSigner signer,
IRuntimeWitnessSigningKeyProvider signingKeyProvider,
IRuntimeWitnessStorage storage,
TimeProvider? timeProvider = null)
{
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_signingKeyProvider = signingKeyProvider ?? throw new ArgumentNullException(nameof(signingKeyProvider));
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<RuntimeWitnessResult> GenerateAsync(
RuntimeWitnessRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
ct.ThrowIfCancellationRequested();
try
{
request.Validate();
if (!_signingKeyProvider.TryResolveSigningKey(
request.SigningOptions,
out var signingKey,
out var keyError)
|| signingKey is null)
{
return RuntimeWitnessResult.Failed(
request.ClaimId,
keyError ?? "No runtime witness signing key was resolved.");
}
var canonicalObservations = CanonicalizeObservations(request.Observations);
var witness = BuildRuntimeWitness(request, canonicalObservations);
var signResult = _signer.SignWitness(witness, signingKey, ct);
if (!signResult.IsSuccess || signResult.Envelope is null || signResult.PayloadBytes is null)
{
return RuntimeWitnessResult.Failed(
request.ClaimId,
signResult.Error ?? "Runtime witness signing failed.");
}
var envelopeBytes = DsseEnvelopeSerializer.Serialize(
signResult.Envelope,
new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true,
EmitExpandedJson = false
})
.CompactJson ?? Array.Empty<byte>();
var casUri = await _storage.StoreAsync(
new RuntimeWitnessStorageRequest(witness, signResult.PayloadBytes, envelopeBytes),
ct)
.ConfigureAwait(false);
return RuntimeWitnessResult.Successful(
witness,
envelopeBytes,
casUri: casUri);
}
catch (Exception ex) when (ex is ArgumentException or FormatException or InvalidOperationException)
{
return RuntimeWitnessResult.Failed(request.ClaimId, ex.Message);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateBatchAsync(
BatchRuntimeWitnessRequest request,
[EnumeratorCancellation] CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
request.Validate();
var ordered = request.Requests
.OrderBy(static r => r.ClaimId, StringComparer.Ordinal)
.ToList();
foreach (var item in ordered)
{
ct.ThrowIfCancellationRequested();
var result = await GenerateAsync(item, ct).ConfigureAwait(false);
yield return result;
if (!result.Success && !request.ContinueOnError)
{
yield break;
}
}
}
/// <inheritdoc />
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateFromStreamAsync(
IAsyncEnumerable<RuntimeObservation> observations,
IRuntimeWitnessContextProvider contextProvider,
[EnumeratorCancellation] CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(observations);
ArgumentNullException.ThrowIfNull(contextProvider);
var byClaim = new Dictionary<string, List<RuntimeObservation>>(StringComparer.Ordinal);
await foreach (var observation in observations.WithCancellation(ct).ConfigureAwait(false))
{
var claimId = contextProvider.GetClaimId(observation);
if (!byClaim.TryGetValue(claimId, out var buffer))
{
buffer = new List<RuntimeObservation>();
byClaim[claimId] = buffer;
}
buffer.Add(observation);
}
foreach (var claim in byClaim.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
{
ct.ThrowIfCancellationRequested();
var first = claim.Value[0];
var request = new RuntimeWitnessRequest
{
ClaimId = claim.Key,
ArtifactDigest = contextProvider.GetArtifactDigest(),
ComponentPurl = contextProvider.GetComponentPurl(first),
VulnerabilityId = contextProvider.GetVulnerabilityId(first),
Observations = claim.Value,
Symbolization = contextProvider.GetSymbolization(first),
SigningOptions = contextProvider.GetSigningOptions()
};
yield return await GenerateAsync(request, ct).ConfigureAwait(false);
}
}
private PathWitness BuildRuntimeWitness(
RuntimeWitnessRequest request,
IReadOnlyList<RuntimeObservation> canonicalObservations)
{
var (claimArtifactDigest, claimPathHash) = ClaimIdGenerator.Parse(request.ClaimId);
var observedAt = canonicalObservations.Count > 0
? canonicalObservations[0].ObservedAt
: _timeProvider.GetUtcNow();
var pathSteps = BuildPathSteps(canonicalObservations);
var sink = pathSteps[^1];
var entrypoint = pathSteps[0];
var nodeHashes = BuildNodeHashes(canonicalObservations);
var runtimeDigest = ComputeRuntimeDigest(canonicalObservations);
var witness = new PathWitness
{
WitnessId = string.Empty,
Artifact = new WitnessArtifact
{
SbomDigest = request.ArtifactDigest,
ComponentPurl = request.ComponentPurl
},
Vuln = new WitnessVuln
{
Id = request.VulnerabilityId ?? "runtime-observation",
Source = request.VulnerabilityId is null ? "runtime" : "scanner",
AffectedRange = "unknown"
},
Entrypoint = new WitnessEntrypoint
{
Kind = "runtime",
Name = entrypoint.Symbol,
SymbolId = entrypoint.SymbolId
},
Path = pathSteps,
Sink = new WitnessSink
{
Symbol = sink.Symbol,
SymbolId = sink.SymbolId,
SinkType = "runtime-observed"
},
Evidence = new WitnessEvidence
{
CallgraphDigest = $"sha256:{runtimeDigest}",
AnalysisConfigDigest = $"sha256:{ComputeSigningOptionsDigest(request.SigningOptions)}",
BuildId = request.Symbolization?.BuildId
},
ObservedAt = observedAt,
PredicateType = RuntimeWitnessPredicateTypes.RuntimeWitnessCanonical,
ObservationType = ObservationType.Runtime,
ClaimId = request.ClaimId,
Observations = canonicalObservations,
Symbolization = request.Symbolization,
PathHash = claimPathHash,
NodeHashes = nodeHashes,
EvidenceUris = BuildEvidenceUris(request, claimArtifactDigest)
};
var witnessId = ComputeWitnessId(witness);
return witness with { WitnessId = witnessId };
}
private static IReadOnlyList<PathStep> BuildPathSteps(IReadOnlyList<RuntimeObservation> canonicalObservations)
{
var list = new List<PathStep>(Math.Min(MaxPathSteps, canonicalObservations.Count));
for (var i = 0; i < canonicalObservations.Count && i < MaxPathSteps; i++)
{
var observation = canonicalObservations[i];
var symbolId = BuildStepSymbolId(observation, i);
list.Add(new PathStep
{
Symbol = BuildStepSymbol(observation, i),
SymbolId = symbolId
});
}
if (list.Count == 0)
{
list.Add(new PathStep
{
Symbol = "runtime-observation",
SymbolId = "runtime:observation:0000"
});
}
return list;
}
private static IReadOnlyList<string> BuildNodeHashes(IReadOnlyList<RuntimeObservation> observations)
{
var hashes = observations
.Select(static o => o.StackSampleHash)
.Where(static h => !string.IsNullOrWhiteSpace(h))
.Select(static h => h!)
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToList();
if (hashes.Count > 0)
{
return hashes;
}
return [$"sha256:{ComputeRuntimeDigest(observations)}"];
}
private static IReadOnlyList<string> BuildEvidenceUris(RuntimeWitnessRequest request, string claimArtifactDigest)
{
var uris = new List<string>
{
$"claim:{request.ClaimId}",
$"artifact:{claimArtifactDigest}"
};
if (!string.IsNullOrWhiteSpace(request.Symbolization?.DebugArtifactUri))
{
uris.Add(request.Symbolization.DebugArtifactUri!);
}
if (!string.IsNullOrWhiteSpace(request.Symbolization?.SymbolTableUri))
{
uris.Add(request.Symbolization.SymbolTableUri!);
}
return uris
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToList();
}
private static string BuildStepSymbol(RuntimeObservation observation, int index)
{
var id = observation.ObservationId;
if (!string.IsNullOrWhiteSpace(id))
{
return $"runtime:{id}";
}
var stack = observation.StackSampleHash;
if (!string.IsNullOrWhiteSpace(stack))
{
return $"runtime:{stack}";
}
return $"runtime:frame:{index:D4}";
}
private static string BuildStepSymbolId(RuntimeObservation observation, int index)
{
if (!string.IsNullOrWhiteSpace(observation.ObservationId))
{
return $"runtime:obs:{observation.ObservationId!.ToLowerInvariant()}";
}
if (!string.IsNullOrWhiteSpace(observation.StackSampleHash))
{
return $"runtime:stack:{observation.StackSampleHash!.ToLowerInvariant()}";
}
return $"runtime:observation:{index:D4}";
}
private static string ComputeWitnessId(PathWitness witness)
{
var canonical = new
{
witness.WitnessSchema,
witness.Artifact,
witness.Vuln,
witness.Entrypoint,
witness.Path,
witness.Sink,
witness.Evidence,
witness.ObservedAt,
witness.PathHash,
witness.NodeHashes,
witness.EvidenceUris,
witness.PredicateType,
witness.ObservationType,
witness.ClaimId,
witness.Observations,
witness.Symbolization
};
var canonicalBytes = CanonJson.Canonicalize(canonical, CanonicalJsonOptions);
var hash = SHA256.HashData(canonicalBytes);
var hex = Convert.ToHexString(hash).ToLowerInvariant();
return $"{WitnessSchema.WitnessIdPrefix}sha256:{hex}";
}
private static IReadOnlyList<RuntimeObservation> CanonicalizeObservations(
IReadOnlyList<RuntimeObservation> observations)
{
return observations
.Select(static observation => observation with
{
ObservedAt = observation.ObservedAt.ToUniversalTime(),
ObservationCount = Math.Max(1, observation.ObservationCount),
StackSampleHash = NormalizeOptionalHash(observation.StackSampleHash),
ContainerId = NormalizeOptionalText(observation.ContainerId),
PodName = NormalizeOptionalText(observation.PodName),
Namespace = NormalizeOptionalText(observation.Namespace),
ObservationId = NormalizeOptionalText(observation.ObservationId)
})
.OrderBy(static o => o.ObservedAt)
.ThenBy(static o => o.ObservationId ?? string.Empty, StringComparer.Ordinal)
.ThenBy(static o => o.StackSampleHash ?? string.Empty, StringComparer.Ordinal)
.ThenBy(static o => o.ProcessId ?? int.MinValue)
.ThenBy(static o => o.ContainerId ?? string.Empty, StringComparer.Ordinal)
.ThenBy(static o => o.Namespace ?? string.Empty, StringComparer.Ordinal)
.ThenBy(static o => o.PodName ?? string.Empty, StringComparer.Ordinal)
.ThenBy(static o => o.SourceType)
.ThenBy(static o => o.DurationMicroseconds ?? long.MinValue)
.ThenBy(static o => o.ObservationCount)
.ToList();
}
private static string NormalizeOptionalText(string? value)
{
return value is null ? string.Empty : value.Trim();
}
private static string? NormalizeOptionalHash(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant();
}
private static string ComputeRuntimeDigest(IReadOnlyList<RuntimeObservation> canonicalObservations)
{
var lines = canonicalObservations
.Select(static observation => string.Join("|",
observation.ObservedAt.ToUniversalTime().ToString("O"),
observation.ObservationId ?? string.Empty,
observation.StackSampleHash ?? string.Empty,
observation.ObservationCount.ToString(),
observation.ProcessId?.ToString() ?? string.Empty,
observation.ContainerId ?? string.Empty,
observation.Namespace ?? string.Empty,
observation.PodName ?? string.Empty,
((int)observation.SourceType).ToString(),
observation.DurationMicroseconds?.ToString() ?? string.Empty))
.ToList();
var bytes = Encoding.UTF8.GetBytes(string.Join('\n', lines));
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputeSigningOptionsDigest(RuntimeWitnessSigningOptions options)
{
var canonical = string.Join('|',
options.KeyId ?? string.Empty,
options.UseKeyless.ToString(),
options.Algorithm,
options.Timeout.TotalMilliseconds.ToString("F0"));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -35,6 +35,11 @@ public sealed record RuntimeWitnessRequest
/// </summary>
public required IReadOnlyList<RuntimeObservation> Observations { get; init; }
/// <summary>
/// Deterministic symbolization tuple required for replayable runtime witnesses.
/// </summary>
public WitnessSymbolization? Symbolization { get; init; }
/// <summary>
/// Vulnerability ID if observing a vulnerable path.
/// </summary>
@@ -70,6 +75,11 @@ public sealed record RuntimeWitnessRequest
if (Observations == null || Observations.Count == 0)
throw new ArgumentException("At least one observation is required.", nameof(Observations));
if (Symbolization is null)
throw new ArgumentException("Symbolization is required for runtime witness generation.", nameof(Symbolization));
Symbolization.Validate();
}
}

View File

@@ -47,6 +47,9 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
try
{
// Enforce deterministic runtime symbolization requirements before signing.
witness.ValidateDeterministicSymbolization();
// Serialize witness to canonical JSON bytes
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
@@ -106,6 +109,9 @@ public sealed class WitnessDsseSigner : IWitnessDsseSigner
return WitnessVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
}
// Runtime witnesses must carry deterministic symbolization inputs.
witness.ValidateDeterministicSymbolization();
// Find signature matching the public key
var matchingSignature = envelope.Signatures.FirstOrDefault(
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));

View File

@@ -5,10 +5,12 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Signals.Ebpf.Schema;
using StellaOps.Signals.Ebpf.Services;
using StellaOps.TestKit;
using System.Text;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Evidence;
@@ -88,6 +90,8 @@ public sealed class RuntimeReachabilityCollectorTests
Assert.Equal(ObservationSource.Historical, result.Source);
Assert.Single(result.Observations);
Assert.True(result.Observations[0].WasObserved);
Assert.NotNull(result.BtfSelection);
Assert.Equal("kernel", result.BtfSelection!.SourceKind);
}
[Trait("Category", TestCategories.Unit)]
@@ -217,11 +221,88 @@ public sealed class RuntimeReachabilityCollectorTests
Assert.NotNull(result.Error);
Assert.Equal(GatingOutcome.Unknown, result.Layer3.Outcome);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ObserveAsync_WithWitnessEmission_InvokesRuntimeWitnessGenerator()
{
var observations = new List<SymbolObservation>
{
new()
{
Symbol = "target_sink",
WasObserved = true,
ObservationCount = 2,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-3),
LastObservedAt = DateTimeOffset.UtcNow.AddMinutes(-1)
}
};
_observationStore.SetObservations("container-wit", observations);
var witnessGenerator = new MockRuntimeWitnessGenerator();
var collector = new EbpfRuntimeReachabilityCollector(
_signalCollector,
_observationStore,
NullLogger<EbpfRuntimeReachabilityCollector>.Instance,
_timeProvider,
witnessGenerator);
var request = new RuntimeObservationRequest
{
ContainerId = "container-wit",
ImageDigest = "sha256:img123",
TargetSymbols = ["target_sink"],
UseHistoricalData = true,
WitnessEmission = new RuntimeWitnessEmissionRequest
{
Enabled = true,
ComponentPurl = "pkg:oci/demo@sha256:img123",
VulnerabilityId = "CVE-2026-0001",
Symbolization = new WitnessSymbolization
{
BuildId = "gnu-build-id:test",
DebugArtifactUri = "cas://symbols/test.debug",
Symbolizer = new WitnessSymbolizer
{
Name = "llvm-symbolizer",
Version = "18.1.7",
Digest = "sha256:symbolizer"
},
LibcVariant = "glibc",
SysrootDigest = "sha256:sysroot"
},
SigningOptions = new RuntimeWitnessSigningOptions
{
KeyId = "runtime-signing-key",
UseKeyless = false
}
}
};
var result = await collector.ObserveAsync(request, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Witness);
Assert.True(result.Witness!.Success);
Assert.NotNull(witnessGenerator.LastRequest);
Assert.Equal(request.ImageDigest, witnessGenerator.LastRequest!.ArtifactDigest);
Assert.Equal(request.WitnessEmission!.ComponentPurl, witnessGenerator.LastRequest.ComponentPurl);
}
}
internal sealed class MockSignalCollector : IRuntimeSignalCollector
{
private bool _isSupported = true;
private readonly RuntimeBtfSelection _btfSelection = new()
{
SourceKind = "kernel",
SourcePath = "/sys/kernel/btf/vmlinux",
SourceDigest = "sha256:test",
SelectionReason = "kernel_btf_present",
KernelRelease = "6.8.0-test",
KernelArch = "x86_64",
};
private readonly RuntimeSignalOptions _defaultOptions = new()
{
TargetSymbols = [],
@@ -232,6 +313,8 @@ internal sealed class MockSignalCollector : IRuntimeSignalCollector
public bool IsSupported() => _isSupported;
public RuntimeBtfSelection GetBtfSelection() => _btfSelection;
public IReadOnlyList<ProbeType> GetSupportedProbeTypes() => [ProbeType.Uprobe, ProbeType.Uretprobe];
public Task<SignalCollectionHandle> StartCollectionAsync(
@@ -309,3 +392,96 @@ internal sealed class MockObservationStore : IRuntimeObservationStore
return Task.CompletedTask;
}
}
internal sealed class MockRuntimeWitnessGenerator : IRuntimeWitnessGenerator
{
public RuntimeWitnessRequest? LastRequest { get; private set; }
public Task<RuntimeWitnessResult> GenerateAsync(RuntimeWitnessRequest request, CancellationToken ct = default)
{
LastRequest = request;
var witness = new PathWitness
{
WitnessId = "wit:runtime:test",
Artifact = new WitnessArtifact
{
SbomDigest = request.ArtifactDigest,
ComponentPurl = request.ComponentPurl
},
Vuln = new WitnessVuln
{
Id = request.VulnerabilityId ?? "runtime",
Source = "runtime",
AffectedRange = "unknown"
},
Entrypoint = new WitnessEntrypoint
{
Kind = "runtime",
Name = "runtime-entry",
SymbolId = "runtime:entry"
},
Path =
[
new PathStep
{
Symbol = "runtime-entry",
SymbolId = "runtime:entry"
}
],
Sink = new WitnessSink
{
Symbol = "runtime-sink",
SymbolId = "runtime:sink",
SinkType = "runtime-observed"
},
Evidence = new WitnessEvidence
{
CallgraphDigest = "sha256:test",
BuildId = request.Symbolization?.BuildId
},
ObservedAt = request.Observations[0].ObservedAt,
ObservationType = ObservationType.Runtime,
PredicateType = RuntimeWitnessPredicateTypes.RuntimeWitnessCanonical,
ClaimId = request.ClaimId,
Observations = request.Observations,
Symbolization = request.Symbolization
};
return Task.FromResult(RuntimeWitnessResult.Successful(
witness,
Encoding.UTF8.GetBytes("{}"),
casUri: "cas://runtime-witness/dsse/mock"));
}
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateBatchAsync(
BatchRuntimeWitnessRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var item in request.Requests)
{
yield return await GenerateAsync(item, ct);
}
}
public async IAsyncEnumerable<RuntimeWitnessResult> GenerateFromStreamAsync(
IAsyncEnumerable<RuntimeObservation> observations,
IRuntimeWitnessContextProvider contextProvider,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var observation in observations.WithCancellation(ct))
{
var request = new RuntimeWitnessRequest
{
ClaimId = contextProvider.GetClaimId(observation),
ArtifactDigest = contextProvider.GetArtifactDigest(),
ComponentPurl = contextProvider.GetComponentPurl(observation),
VulnerabilityId = contextProvider.GetVulnerabilityId(observation),
Observations = [observation],
Symbolization = contextProvider.GetSymbolization(observation) ?? throw new InvalidOperationException(),
SigningOptions = contextProvider.GetSigningOptions()
};
yield return await GenerateAsync(request, ct);
}
}
}

View File

@@ -0,0 +1,287 @@
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Attestor.Envelope;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
/// <summary>
/// Tests for deterministic runtime witness generation.
/// </summary>
public sealed class RuntimeWitnessGeneratorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_WithValidRequest_ReturnsSignedWitnessAndStoresArtifacts()
{
var signingKey = CreateTestKey();
var signer = new WitnessDsseSigner();
var keyProvider = new StaticSigningKeyProvider(signingKey);
var storage = new RecordingStorage("cas://runtime-witness/dsse/test-envelope");
var sut = new RuntimeWitnessGenerator(signer, keyProvider, storage);
var request = CreateRequest(
claimId: "claim:artifact123:pathabcdef123456",
observations:
[
CreateObservation("obs-b", "sha256:bbb", DateTimeOffset.Parse("2026-02-16T10:00:02Z")),
CreateObservation("obs-a", "sha256:aaa", DateTimeOffset.Parse("2026-02-16T10:00:01Z"))
]);
var result = await sut.GenerateAsync(request, TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.NotNull(result.Witness);
Assert.NotNull(result.EnvelopeBytes);
Assert.Equal("cas://runtime-witness/dsse/test-envelope", result.CasUri);
Assert.Equal("pathabcdef123456", result.Witness!.PathHash);
Assert.NotNull(storage.LastRequest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateAsync_WithEquivalentObservationSets_ProducesStableEnvelopeBytes()
{
var signingKey = CreateTestKey();
var signer = new WitnessDsseSigner();
var keyProvider = new StaticSigningKeyProvider(signingKey);
var sut = new RuntimeWitnessGenerator(signer, keyProvider, new NullRuntimeWitnessStorage());
var ordered = new[]
{
CreateObservation("obs-a", "sha256:aaa", DateTimeOffset.Parse("2026-02-16T10:00:01Z")),
CreateObservation("obs-b", "sha256:bbb", DateTimeOffset.Parse("2026-02-16T10:00:02Z"))
};
var reversed = new[] { ordered[1], ordered[0] };
var requestA = CreateRequest("claim:artifact123:pathabcdef123456", ordered);
var requestB = CreateRequest("claim:artifact123:pathabcdef123456", reversed);
var resultA = await sut.GenerateAsync(requestA, TestContext.Current.CancellationToken);
var resultB = await sut.GenerateAsync(requestB, TestContext.Current.CancellationToken);
Assert.True(resultA.Success);
Assert.True(resultB.Success);
Assert.NotNull(resultA.EnvelopeBytes);
Assert.NotNull(resultB.EnvelopeBytes);
Assert.Equal(resultA.Witness!.WitnessId, resultB.Witness!.WitnessId);
Assert.Equal(resultA.EnvelopeBytes, resultB.EnvelopeBytes);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GenerateFromStreamAsync_GroupsByClaimIdAndEmitsDeterministicOrder()
{
var signingKey = CreateTestKey();
var signer = new WitnessDsseSigner();
var keyProvider = new StaticSigningKeyProvider(signingKey);
var sut = new RuntimeWitnessGenerator(signer, keyProvider, new NullRuntimeWitnessStorage());
var observations = StreamObservations(
CreateObservation("obs-1", "sha256:111", DateTimeOffset.Parse("2026-02-16T10:01:00Z"), containerId: "c2"),
CreateObservation("obs-2", "sha256:222", DateTimeOffset.Parse("2026-02-16T10:02:00Z"), containerId: "c1"));
var provider = new TestContextProvider();
var results = new List<RuntimeWitnessResult>();
await foreach (var result in sut.GenerateFromStreamAsync(observations, provider, TestContext.Current.CancellationToken))
{
results.Add(result);
}
Assert.Equal(2, results.Count);
Assert.All(results, static result => Assert.True(result.Success));
Assert.Equal("claim:artifact123:c1", results[0].ClaimId);
Assert.Equal("claim:artifact123:c2", results[1].ClaimId);
}
private static RuntimeWitnessRequest CreateRequest(
string claimId,
IReadOnlyList<RuntimeObservation> observations)
{
return new RuntimeWitnessRequest
{
ClaimId = claimId,
ArtifactDigest = "sha256:artifact123",
ComponentPurl = "pkg:oci/demo@sha256:artifact123",
VulnerabilityId = "CVE-2026-0001",
Observations = observations,
Symbolization = CreateSymbolization(),
SigningOptions = new RuntimeWitnessSigningOptions
{
KeyId = "runtime-signing-key",
UseKeyless = false
}
};
}
private static RuntimeObservation CreateObservation(
string observationId,
string stackHash,
DateTimeOffset observedAt,
string containerId = "container-a")
{
return new RuntimeObservation
{
ObservedAt = observedAt,
ObservationCount = 1,
StackSampleHash = stackHash,
ProcessId = 100,
ContainerId = containerId,
PodName = "pod-a",
Namespace = "default",
SourceType = RuntimeObservationSourceType.Tetragon,
ObservationId = observationId
};
}
private static WitnessSymbolization CreateSymbolization()
{
return new WitnessSymbolization
{
BuildId = "gnu-build-id:runtime-test",
DebugArtifactUri = "cas://symbols/runtime-test.debug",
Symbolizer = new WitnessSymbolizer
{
Name = "llvm-symbolizer",
Version = "18.1.7",
Digest = "sha256:symbolizer"
},
LibcVariant = "glibc",
SysrootDigest = "sha256:sysroot"
};
}
private static async IAsyncEnumerable<RuntimeObservation> StreamObservations(params RuntimeObservation[] items)
{
foreach (var item in items)
{
yield return item;
await Task.Yield();
}
}
private static EnvelopeKey CreateTestKey()
{
var generator = new Ed25519KeyPairGenerator();
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
var keyPair = generator.GenerateKeyPair();
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
var privateKey = new byte[64];
privateParams.Encode(privateKey, 0);
var publicKey = publicParams.GetEncoded();
Array.Copy(publicKey, 0, privateKey, 32, 32);
return EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "runtime-signing-key");
}
private sealed class StaticSigningKeyProvider : IRuntimeWitnessSigningKeyProvider
{
private readonly EnvelopeKey _key;
public StaticSigningKeyProvider(EnvelopeKey key)
{
_key = key;
}
public bool TryResolveSigningKey(
RuntimeWitnessSigningOptions options,
out EnvelopeKey? signingKey,
out string? errorMessage)
{
signingKey = _key;
errorMessage = null;
return true;
}
}
private sealed class RecordingStorage : IRuntimeWitnessStorage
{
private readonly string _uri;
public RecordingStorage(string uri)
{
_uri = uri;
}
public RuntimeWitnessStorageRequest? LastRequest { get; private set; }
public Task<string?> StoreAsync(RuntimeWitnessStorageRequest request, CancellationToken ct = default)
{
LastRequest = request;
return Task.FromResult<string?>(_uri);
}
}
private sealed class TestContextProvider : IRuntimeWitnessContextProvider
{
public string GetClaimId(RuntimeObservation observation)
{
return $"claim:artifact123:{observation.ContainerId}";
}
public string GetArtifactDigest()
{
return "sha256:artifact123";
}
public string GetComponentPurl(RuntimeObservation observation)
{
return "pkg:oci/demo@sha256:artifact123";
}
public string? GetVulnerabilityId(RuntimeObservation observation)
{
return "CVE-2026-0001";
}
public WitnessSymbolization? GetSymbolization(RuntimeObservation observation)
{
return CreateSymbolization();
}
public RuntimeWitnessSigningOptions GetSigningOptions()
{
return new RuntimeWitnessSigningOptions
{
KeyId = "runtime-signing-key",
UseKeyless = false
};
}
}
/// <summary>
/// Fixed random generator for deterministic key generation in tests.
/// </summary>
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
{
private byte _value = 0x42;
public void AddSeedMaterial(byte[] seed) { }
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
public void AddSeedMaterial(long seed) { }
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
public void NextBytes(byte[] bytes, int start, int len)
{
for (var i = start; i < start + len; i++)
{
bytes[i] = _value++;
}
}
public void NextBytes(Span<byte> bytes)
{
for (var i = 0; i < bytes.Length; i++)
{
bytes[i] = _value++;
}
}
}
}

View File

@@ -0,0 +1,103 @@
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
/// <summary>
/// Validation tests for runtime witness requests.
/// </summary>
public sealed class RuntimeWitnessRequestTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_WithoutSymbolization_ThrowsArgumentException()
{
var request = CreateValidRequest() with
{
Symbolization = null
};
var ex = Assert.Throws<ArgumentException>(() => request.Validate());
Assert.Contains("Symbolization", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_WithoutDebugArtifactAndSymbolTable_ThrowsInvalidOperationException()
{
var request = CreateValidRequest() with
{
Symbolization = new WitnessSymbolization
{
BuildId = "gnu-build-id:abc123",
DebugArtifactUri = null,
SymbolTableUri = null,
Symbolizer = new WitnessSymbolizer
{
Name = "llvm-symbolizer",
Version = "18.1.7",
Digest = "sha256:symdigest"
},
LibcVariant = "glibc",
SysrootDigest = "sha256:sysroot"
}
};
var ex = Assert.Throws<InvalidOperationException>(() => request.Validate());
Assert.Contains("debug_artifact_uri", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_WithValidSymbolization_DoesNotThrow()
{
var request = CreateValidRequest();
request.Validate();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BatchValidate_ValidRequest_DoesNotThrow()
{
var batch = new BatchRuntimeWitnessRequest
{
Requests = [CreateValidRequest()]
};
batch.Validate();
}
private static RuntimeWitnessRequest CreateValidRequest()
{
return new RuntimeWitnessRequest
{
ClaimId = "claim:artifact:path",
ArtifactDigest = "sha256:artifact",
ComponentPurl = "pkg:docker/example/app@1.0.0",
Observations =
[
new RuntimeObservation
{
ObservedAt = new DateTimeOffset(2026, 2, 16, 12, 0, 0, TimeSpan.Zero),
ObservationCount = 2,
SourceType = RuntimeObservationSourceType.Tetragon
}
],
Symbolization = new WitnessSymbolization
{
BuildId = "gnu-build-id:abc123",
DebugArtifactUri = "cas://symbols/by-build-id/gnu-build-id:abc123/artifact.debug",
SymbolTableUri = null,
Symbolizer = new WitnessSymbolizer
{
Name = "llvm-symbolizer",
Version = "18.1.7",
Digest = "sha256:symdigest"
},
LibcVariant = "glibc",
SysrootDigest = "sha256:sysroot"
}
};
}
}

View File

@@ -65,6 +65,42 @@ public class WitnessDsseSignerTests
Assert.NotEmpty(result.PayloadBytes!);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_RuntimeWitnessWithoutSymbolization_ReturnsFails()
{
// Arrange
var witness = CreateRuntimeWitness(includeSymbolization: false);
var (privateKey, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
// Act
var result = signer.SignWitness(witness, key, TestCancellationToken);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains("symbolization", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_RuntimeWitnessWithSymbolization_ReturnsSuccess()
{
// Arrange
var witness = CreateRuntimeWitness(includeSymbolization: true);
var (privateKey, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new WitnessDsseSigner();
// Act
var result = signer.SignWitness(witness, key, TestCancellationToken);
// Assert
Assert.True(result.IsSuccess, result.Error);
Assert.NotNull(result.Envelope);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
@@ -304,6 +340,46 @@ public class WitnessDsseSignerTests
};
}
private static PathWitness CreateRuntimeWitness(bool includeSymbolization)
{
var witness = CreateTestWitness() with
{
ObservationType = ObservationType.Runtime,
Observations =
[
new RuntimeObservation
{
ObservedAt = new DateTimeOffset(2025, 12, 19, 12, 30, 0, TimeSpan.Zero),
ObservationCount = 3,
SourceType = RuntimeObservationSourceType.Tetragon
}
]
};
if (!includeSymbolization)
{
return witness;
}
return witness with
{
Symbolization = new WitnessSymbolization
{
BuildId = "gnu-build-id:abcd1234",
DebugArtifactUri = "cas://symbols/by-build-id/gnu-build-id:abcd1234/artifact.debug",
SymbolTableUri = null,
Symbolizer = new WitnessSymbolizer
{
Name = "llvm-symbolizer",
Version = "18.1.7",
Digest = "sha256:symbolizer123"
},
LibcVariant = "glibc",
SysrootDigest = "sha256:sysroot123"
}
};
}
/// <summary>
/// Fixed random generator for deterministic key generation in tests.
/// </summary>