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