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

@@ -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));