compose and authority fixes. finish sprints.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user