Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// A beacon event captured via eBPF uprobe, ETW DynamicTraceProvider, or dyld interpose.
/// Sprint: SPRINT_20260219_014 (BEA-01)
/// </summary>
public sealed class BeaconEvent
{
/// <summary>
/// Canonical artifact identifier (sha256 digest).
/// </summary>
[Required]
[JsonPropertyName("artifact_id")]
public string ArtifactId { get; set; } = string.Empty;
/// <summary>
/// Environment where the beacon was observed.
/// </summary>
[Required]
[JsonPropertyName("environment_id")]
public string EnvironmentId { get; set; } = string.Empty;
/// <summary>
/// Source of the beacon observation.
/// </summary>
[Required]
[JsonPropertyName("beacon_source")]
public string BeaconSource { get; set; } = string.Empty;
/// <summary>
/// Symbol name or address range of the beacon function.
/// </summary>
[Required]
[JsonPropertyName("beacon_function")]
public string BeaconFunction { get; set; } = string.Empty;
/// <summary>
/// Unique nonce per observation (prevents replay attacks).
/// </summary>
[Required]
[JsonPropertyName("nonce")]
public string Nonce { get; set; } = string.Empty;
/// <summary>
/// Monotonically increasing sequence number (detects gaps).
/// </summary>
[JsonPropertyName("beacon_sequence")]
public long BeaconSequence { get; set; }
/// <summary>
/// When the beacon was observed.
/// </summary>
[JsonPropertyName("observed_at")]
public DateTimeOffset ObservedAt { get; set; }
}
/// <summary>
/// Request to ingest a batch of beacon events.
/// Sprint: SPRINT_20260219_014 (BEA-01)
/// </summary>
public sealed class BeaconIngestRequest
{
[Required]
[JsonPropertyName("events")]
public List<BeaconEvent> Events { get; set; } = new();
[JsonPropertyName("metadata")]
public Dictionary<string, string?>? Metadata { get; set; }
}
/// <summary>
/// Response from beacon ingestion.
/// </summary>
public sealed record BeaconIngestResponse
{
[JsonPropertyName("accepted")]
public required int Accepted { get; init; }
[JsonPropertyName("rejected_duplicates")]
public required int RejectedDuplicates { get; init; }
[JsonPropertyName("stored_at")]
public required DateTimeOffset StoredAt { get; init; }
}
/// <summary>
/// Beacon attestation predicate for DSSE envelope (stella.ops/beaconAttestation@v1).
/// Sprint: SPRINT_20260219_014 (BEA-01)
/// </summary>
public sealed record BeaconAttestationPredicate
{
public const string PredicateTypeUri = "stella.ops/beaconAttestation@v1";
[JsonPropertyName("artifact_id")]
public required string ArtifactId { get; init; }
[JsonPropertyName("environment_id")]
public required string EnvironmentId { get; init; }
[JsonPropertyName("beacon_source")]
public required string BeaconSource { get; init; }
[JsonPropertyName("beacon_function")]
public required string BeaconFunction { get; init; }
[JsonPropertyName("window_start")]
public required DateTimeOffset WindowStart { get; init; }
[JsonPropertyName("window_end")]
public required DateTimeOffset WindowEnd { get; init; }
[JsonPropertyName("beacon_count")]
public required int BeaconCount { get; init; }
[JsonPropertyName("first_sequence")]
public required long FirstSequence { get; init; }
[JsonPropertyName("last_sequence")]
public required long LastSequence { get; init; }
[JsonPropertyName("sequence_gaps")]
public required int SequenceGaps { get; init; }
[JsonPropertyName("verification_rate")]
public required double VerificationRate { get; init; }
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Beacon verification rate query result.
/// Sprint: SPRINT_20260219_014 (BEA-03)
/// </summary>
public sealed record BeaconVerificationRate
{
[JsonPropertyName("artifact_id")]
public required string ArtifactId { get; init; }
[JsonPropertyName("environment_id")]
public required string EnvironmentId { get; init; }
[JsonPropertyName("rate")]
public required double Rate { get; init; }
[JsonPropertyName("total_expected")]
public required int TotalExpected { get; init; }
[JsonPropertyName("total_verified")]
public required int TotalVerified { get; init; }
[JsonPropertyName("window_start")]
public required DateTimeOffset WindowStart { get; init; }
[JsonPropertyName("window_end")]
public required DateTimeOffset WindowEnd { get; init; }
}

View File

@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Signals.Models;
/// <summary>
/// Request to build an execution evidence attestation from runtime trace data.
/// Sprint: SPRINT_20260219_013 (SEE-01)
/// </summary>
public sealed class ExecutionEvidenceRequest
{
/// <summary>
/// Canonical artifact identifier (sha256 digest).
/// </summary>
[Required]
[JsonPropertyName("artifact_id")]
public string ArtifactId { get; set; } = string.Empty;
/// <summary>
/// Environment where the trace was captured.
/// </summary>
[Required]
[JsonPropertyName("environment_id")]
public string EnvironmentId { get; set; } = string.Empty;
/// <summary>
/// Source of the trace (ebpf, etw, dyld).
/// </summary>
[Required]
[JsonPropertyName("trace_source")]
public string TraceSource { get; set; } = string.Empty;
/// <summary>
/// Runtime fact events comprising the trace.
/// </summary>
[Required]
[JsonPropertyName("events")]
public List<RuntimeFactEvent> Events { get; set; } = new();
/// <summary>
/// Start of the observation window.
/// </summary>
[JsonPropertyName("observation_start")]
public DateTimeOffset ObservationStart { get; set; }
/// <summary>
/// End of the observation window.
/// </summary>
[JsonPropertyName("observation_end")]
public DateTimeOffset ObservationEnd { get; set; }
/// <summary>
/// Optional metadata for provenance tracking.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, string?>? Metadata { get; set; }
}
/// <summary>
/// Execution evidence predicate for DSSE envelope (stella.ops/executionEvidence@v1).
/// Sprint: SPRINT_20260219_013 (SEE-01)
/// </summary>
public sealed record ExecutionEvidencePredicate
{
public const string PredicateTypeUri = "stella.ops/executionEvidence@v1";
[JsonPropertyName("artifact_id")]
public required string ArtifactId { get; init; }
[JsonPropertyName("environment_id")]
public required string EnvironmentId { get; init; }
[JsonPropertyName("trace_source")]
public required string TraceSource { get; init; }
[JsonPropertyName("observation_window")]
public required ObservationWindow ObservationWindow { get; init; }
[JsonPropertyName("trace_summary")]
public required TraceSummary TraceSummary { get; init; }
[JsonPropertyName("trace_digest")]
public required string TraceDigest { get; init; }
[JsonPropertyName("determinism")]
public required DeterminismMetadata Determinism { get; init; }
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Observation window metadata.
/// </summary>
public sealed record ObservationWindow
{
[JsonPropertyName("start")]
public required DateTimeOffset Start { get; init; }
[JsonPropertyName("end")]
public required DateTimeOffset End { get; init; }
[JsonPropertyName("duration_ms")]
public required long DurationMs { get; init; }
}
/// <summary>
/// Coarse trace summary (privacy-safe, no raw syscall logs).
/// </summary>
public sealed record TraceSummary
{
[JsonPropertyName("syscall_families_observed")]
public required IReadOnlyList<string> SyscallFamiliesObserved { get; init; }
[JsonPropertyName("hot_symbols")]
public required IReadOnlyList<string> HotSymbols { get; init; }
[JsonPropertyName("hot_symbol_count")]
public required int HotSymbolCount { get; init; }
[JsonPropertyName("unique_call_paths")]
public required int UniqueCallPaths { get; init; }
[JsonPropertyName("address_canonicalized")]
public required bool AddressCanonicalized { get; init; }
}
/// <summary>
/// Determinism metadata for replay verification.
/// </summary>
public sealed record DeterminismMetadata
{
[JsonPropertyName("replay_seed")]
public string? ReplaySeed { get; init; }
[JsonPropertyName("inputs_digest")]
public required string InputsDigest { get; init; }
[JsonPropertyName("expected_output_digest")]
public string? ExpectedOutputDigest { get; init; }
}
/// <summary>
/// Response from building execution evidence.
/// </summary>
public sealed record ExecutionEvidenceResult
{
[JsonPropertyName("evidence_id")]
public required string EvidenceId { get; init; }
[JsonPropertyName("artifact_id")]
public required string ArtifactId { get; init; }
[JsonPropertyName("environment_id")]
public required string EnvironmentId { get; init; }
[JsonPropertyName("trace_digest")]
public required string TraceDigest { get; init; }
[JsonPropertyName("predicate_digest")]
public required string PredicateDigest { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("rate_limited")]
public bool RateLimited { get; init; }
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Signals.Options;
/// <summary>
/// Configuration for beacon attestation pipeline.
/// Sprint: SPRINT_20260219_014 (BEA-02)
/// </summary>
public sealed class BeaconOptions
{
public const string SectionName = "Signals:Beacon";
/// <summary>
/// Whether the beacon attestation pipeline is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Batching window in seconds. Beacon events are collected over this window
/// before producing a single batched attestation.
/// Default: 300 seconds (5 minutes).
/// </summary>
public int BatchWindowSeconds { get; set; } = 300;
/// <summary>
/// Maximum number of beacon events to hold in a single batch.
/// If exceeded before the window expires, the batch is flushed early.
/// Default: 1000.
/// </summary>
public int MaxBatchSize { get; set; } = 1000;
/// <summary>
/// Time-to-live for nonce deduplication entries in seconds.
/// Nonces older than this are evicted from the dedup cache.
/// Default: 3600 seconds (1 hour).
/// </summary>
public int NonceTtlSeconds { get; set; } = 3600;
/// <summary>
/// Lookback window in hours for computing beacon verification rate.
/// Default: 24 hours.
/// </summary>
public int VerificationRateLookbackHours { get; set; } = 24;
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Signals.Options;
/// <summary>
/// Configuration for execution evidence attestation pipeline.
/// Sprint: SPRINT_20260219_013 (SEE-02)
/// </summary>
public sealed class ExecutionEvidenceOptions
{
public const string SectionName = "Signals:ExecutionEvidence";
/// <summary>
/// Whether the execution evidence pipeline is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Rate limit window in minutes per (artifact_id, environment_id) pair.
/// Only one execution evidence predicate is generated per pair within this window.
/// Default: 60 minutes.
/// </summary>
public int RateLimitWindowMinutes { get; set; } = 60;
/// <summary>
/// Maximum number of hot symbols to include in the trace summary.
/// Limits predicate size while retaining the most significant observations.
/// Default: 50.
/// </summary>
public int MaxHotSymbols { get; set; } = 50;
/// <summary>
/// Minimum number of events required to produce an execution evidence predicate.
/// Prevents trivial predicates from nearly empty traces.
/// Default: 5.
/// </summary>
public int MinEventsThreshold { get; set; } = 5;
}

View File

@@ -82,6 +82,9 @@ builder.Services.AddOptions<SignalsOptions>()
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SignalsOptions>>().Value);
builder.Services.AddSingleton<SignalsStartupState>();
builder.Services.AddDeterminismDefaults();
// Triage auto-suppress join service (Sprint: SPRINT_20260219_012, MWS-02)
builder.Services.AddTriageSuppressServices();
builder.Services.AddSingleton<SignalsSealedModeMonitor>();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
@@ -213,6 +216,16 @@ builder.Services.AddSingleton<IReachabilityUnionIngestionService, ReachabilityUn
builder.Services.AddSingleton<IUnknownsIngestionService, UnknownsIngestionService>();
builder.Services.AddSingleton<SyntheticRuntimeProbeBuilder>();
// Execution evidence pipeline (Sprint: SPRINT_20260219_013)
builder.Services.AddOptions<ExecutionEvidenceOptions>()
.Bind(builder.Configuration.GetSection(ExecutionEvidenceOptions.SectionName));
builder.Services.AddSingleton<IExecutionEvidenceBuilder, ExecutionEvidenceBuilder>();
// Beacon attestation pipeline (Sprint: SPRINT_20260219_014)
builder.Services.AddOptions<BeaconOptions>()
.Bind(builder.Configuration.GetSection(BeaconOptions.SectionName));
builder.Services.AddSingleton<IBeaconAttestationBuilder, BeaconAttestationBuilder>();
// SCM/CI webhook services (Sprint: SPRINT_20251229_013)
builder.Services.AddSingleton<IWebhookSignatureValidator, GitHubWebhookValidator>();
builder.Services.AddSingleton<IWebhookSignatureValidator, GitLabWebhookValidator>();
@@ -855,6 +868,105 @@ signalsGroup.MapGet("/unknowns/{id}/explain", async Task<IResult> (
});
}).WithName("SignalsUnknownsExplain");
// Execution evidence endpoint (Sprint: SPRINT_20260219_013, SEE-02)
signalsGroup.MapPost("/execution-evidence", async Task<IResult> (
HttpContext context,
SignalsOptions options,
ExecutionEvidenceRequest request,
IExecutionEvidenceBuilder evidenceBuilder,
SignalsSealedModeMonitor sealedModeMonitor,
CancellationToken cancellationToken) =>
{
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
{
return authFailure ?? Results.Unauthorized();
}
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
if (string.IsNullOrWhiteSpace(request.ArtifactId) || string.IsNullOrWhiteSpace(request.EnvironmentId))
{
return Results.BadRequest(new { error = "artifact_id and environment_id are required." });
}
var result = await evidenceBuilder.BuildAsync(request, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return Results.UnprocessableEntity(new { error = "Insufficient trace events or pipeline disabled." });
}
if (result.RateLimited)
{
return Results.Ok(new { status = "rate_limited", artifact_id = request.ArtifactId, environment_id = request.EnvironmentId });
}
return Results.Accepted($"/signals/execution-evidence/{request.ArtifactId}/{request.EnvironmentId}", result);
}).WithName("SignalsExecutionEvidenceBuild");
// Beacon ingest endpoint (Sprint: SPRINT_20260219_014, BEA-02)
signalsGroup.MapPost("/beacons", async Task<IResult> (
HttpContext context,
SignalsOptions options,
BeaconIngestRequest request,
IBeaconAttestationBuilder beaconBuilder,
SignalsSealedModeMonitor sealedModeMonitor,
CancellationToken cancellationToken) =>
{
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
{
return authFailure ?? Results.Unauthorized();
}
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
if (request.Events is null || request.Events.Count == 0)
{
return Results.BadRequest(new { error = "At least one beacon event is required." });
}
var response = await beaconBuilder.IngestAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Accepted("/signals/beacons", response);
}).WithName("SignalsBeaconIngest");
// Beacon verification rate query (Sprint: SPRINT_20260219_014, BEA-03)
signalsGroup.MapGet("/beacons/rate/{artifactId}/{environmentId}", (
HttpContext context,
SignalsOptions options,
string artifactId,
string environmentId,
IBeaconAttestationBuilder beaconBuilder,
SignalsSealedModeMonitor sealedModeMonitor) =>
{
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
{
return authFailure ?? Results.Unauthorized();
}
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
{
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
if (string.IsNullOrWhiteSpace(artifactId) || string.IsNullOrWhiteSpace(environmentId))
{
return Results.BadRequest(new { error = "artifactId and environmentId are required." });
}
var rate = beaconBuilder.GetVerificationRate(artifactId.Trim(), environmentId.Trim());
if (rate is null)
{
return Results.NotFound(new { error = "No beacon data for this artifact/environment pair." });
}
return Results.Ok(rate);
}).WithName("SignalsBeaconRateQuery");
signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
HttpContext context,
SignalsOptions options,

View File

@@ -0,0 +1,257 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
namespace StellaOps.Signals.Services;
/// <summary>
/// Ingests beacon events with deduplication and builds batched attestation predicates.
/// Sprint: SPRINT_20260219_014 (BEA-02)
/// </summary>
public interface IBeaconAttestationBuilder
{
/// <summary>
/// Ingests beacon events. Rejects duplicates by nonce.
/// </summary>
Task<BeaconIngestResponse> IngestAsync(
BeaconIngestRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Flushes the current batch for an (artifact, environment) pair and builds an attestation predicate.
/// Returns null if no events are pending.
/// </summary>
Task<BeaconAttestationPredicate?> FlushBatchAsync(
string artifactId,
string environmentId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current beacon verification rate for an (artifact, environment) pair.
/// </summary>
BeaconVerificationRate? GetVerificationRate(string artifactId, string environmentId);
}
/// <summary>
/// Default implementation of beacon attestation builder with batching and nonce deduplication.
/// </summary>
public sealed class BeaconAttestationBuilder : IBeaconAttestationBuilder
{
private readonly IOptionsMonitor<BeaconOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<BeaconAttestationBuilder> _logger;
// Nonce deduplication: tracks seen nonces per (artifact_id, environment_id).
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, DateTimeOffset>> _nonceTracker = new(StringComparer.Ordinal);
// Pending beacon events per (artifact_id, environment_id).
private readonly ConcurrentDictionary<string, ConcurrentBag<BeaconEvent>> _pendingBatches = new(StringComparer.Ordinal);
// Attestation history for verification rate computation.
private readonly ConcurrentDictionary<string, List<BeaconAttestationPredicate>> _attestationHistory = new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
public BeaconAttestationBuilder(
IOptionsMonitor<BeaconOptions> options,
TimeProvider timeProvider,
ILogger<BeaconAttestationBuilder> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<BeaconIngestResponse> IngestAsync(
BeaconIngestRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var opts = _options.CurrentValue;
var now = _timeProvider.GetUtcNow();
int accepted = 0;
int duplicates = 0;
foreach (var evt in request.Events)
{
if (string.IsNullOrWhiteSpace(evt.ArtifactId) ||
string.IsNullOrWhiteSpace(evt.EnvironmentId) ||
string.IsNullOrWhiteSpace(evt.Nonce))
{
duplicates++;
continue;
}
var batchKey = BuildKey(evt.ArtifactId, evt.EnvironmentId);
var nonceCache = _nonceTracker.GetOrAdd(batchKey, _ => new ConcurrentDictionary<string, DateTimeOffset>(StringComparer.Ordinal));
// Deduplicate by nonce.
if (!nonceCache.TryAdd(evt.Nonce, now))
{
duplicates++;
continue;
}
// Add to pending batch.
var batch = _pendingBatches.GetOrAdd(batchKey, _ => new ConcurrentBag<BeaconEvent>());
batch.Add(evt);
accepted++;
// Flush if batch size exceeded.
if (batch.Count >= opts.MaxBatchSize)
{
_ = FlushBatchAsync(evt.ArtifactId, evt.EnvironmentId, cancellationToken);
}
}
// Evict stale nonces.
EvictStaleNonces(now, opts.NonceTtlSeconds);
_logger.LogDebug("Beacon ingest: {Accepted} accepted, {Duplicates} rejected", accepted, duplicates);
return Task.FromResult(new BeaconIngestResponse
{
Accepted = accepted,
RejectedDuplicates = duplicates,
StoredAt = now,
});
}
public Task<BeaconAttestationPredicate?> FlushBatchAsync(
string artifactId,
string environmentId,
CancellationToken cancellationToken = default)
{
var batchKey = BuildKey(artifactId, environmentId);
if (!_pendingBatches.TryRemove(batchKey, out var batch) || batch.IsEmpty)
{
return Task.FromResult<BeaconAttestationPredicate?>(null);
}
var events = batch.ToList();
if (events.Count == 0)
{
return Task.FromResult<BeaconAttestationPredicate?>(null);
}
var sorted = events
.OrderBy(e => e.BeaconSequence)
.ThenBy(e => e.ObservedAt)
.ToList();
var firstSeq = sorted[0].BeaconSequence;
var lastSeq = sorted[^1].BeaconSequence;
var expectedCount = lastSeq - firstSeq + 1;
var gaps = expectedCount > 0 ? (int)(expectedCount - sorted.Count) : 0;
var verificationRate = expectedCount > 0 ? (double)sorted.Count / expectedCount : 1.0;
var now = _timeProvider.GetUtcNow();
var predicate = new BeaconAttestationPredicate
{
ArtifactId = artifactId,
EnvironmentId = environmentId,
BeaconSource = sorted[0].BeaconSource,
BeaconFunction = sorted[0].BeaconFunction,
WindowStart = sorted[0].ObservedAt,
WindowEnd = sorted[^1].ObservedAt,
BeaconCount = sorted.Count,
FirstSequence = firstSeq,
LastSequence = lastSeq,
SequenceGaps = gaps < 0 ? 0 : gaps,
VerificationRate = Math.Round(verificationRate, 4),
Timestamp = now,
};
// Record for verification rate computation.
var history = _attestationHistory.GetOrAdd(batchKey, _ => new List<BeaconAttestationPredicate>());
lock (history)
{
history.Add(predicate);
}
_logger.LogInformation(
"Built beacon attestation for {ArtifactId} in {EnvironmentId}: {Count} beacons, {Gaps} gaps, rate={Rate}",
artifactId, environmentId, sorted.Count, predicate.SequenceGaps, predicate.VerificationRate);
return Task.FromResult<BeaconAttestationPredicate?>(predicate);
}
public BeaconVerificationRate? GetVerificationRate(string artifactId, string environmentId)
{
var batchKey = BuildKey(artifactId, environmentId);
var opts = _options.CurrentValue;
var now = _timeProvider.GetUtcNow();
var cutoff = now.AddHours(-opts.VerificationRateLookbackHours);
if (!_attestationHistory.TryGetValue(batchKey, out var history))
{
return null;
}
List<BeaconAttestationPredicate> recent;
lock (history)
{
recent = history.Where(p => p.Timestamp >= cutoff).ToList();
}
if (recent.Count == 0)
{
return null;
}
var totalBeacons = recent.Sum(p => p.BeaconCount);
var totalExpected = recent.Sum(p => p.LastSequence - p.FirstSequence + 1);
var rate = totalExpected > 0 ? (double)totalBeacons / totalExpected : 1.0;
return new BeaconVerificationRate
{
ArtifactId = artifactId,
EnvironmentId = environmentId,
Rate = Math.Round(rate, 4),
TotalExpected = (int)totalExpected,
TotalVerified = totalBeacons,
WindowStart = cutoff,
WindowEnd = now,
};
}
private void EvictStaleNonces(DateTimeOffset now, int ttlSeconds)
{
var cutoff = now.AddSeconds(-ttlSeconds);
foreach (var (_, nonceCache) in _nonceTracker)
{
var staleKeys = nonceCache
.Where(kvp => kvp.Value < cutoff)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in staleKeys)
{
nonceCache.TryRemove(key, out _);
}
}
}
private static string BuildKey(string artifactId, string environmentId)
=> $"{artifactId}|{environmentId}";
}

View File

@@ -0,0 +1,345 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
namespace StellaOps.Signals.Services;
/// <summary>
/// Builds execution evidence predicates from runtime trace data.
/// Produces deterministic, idempotent DSSE-ready predicates.
/// Sprint: SPRINT_20260219_013 (SEE-02)
/// </summary>
public interface IExecutionEvidenceBuilder
{
/// <summary>
/// Builds an execution evidence predicate from runtime trace events.
/// Returns null if rate-limited or below minimum event threshold.
/// </summary>
Task<ExecutionEvidenceResult?> BuildAsync(
ExecutionEvidenceRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the last generated predicate for an (artifact, environment) pair, if any.
/// </summary>
ExecutionEvidencePredicate? GetCachedPredicate(string artifactId, string environmentId);
}
/// <summary>
/// Default implementation of execution evidence builder.
/// Uses address canonicalization and hot-symbol aggregation from existing Signals infrastructure.
/// </summary>
public sealed class ExecutionEvidenceBuilder : IExecutionEvidenceBuilder
{
private readonly IOptionsMonitor<ExecutionEvidenceOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExecutionEvidenceBuilder> _logger;
// Rate limiting: tracks last generation time per (artifact_id, environment_id).
private readonly ConcurrentDictionary<string, DateTimeOffset> _rateLimitTracker = new(StringComparer.Ordinal);
// Cache of last generated predicates for retrieval.
private readonly ConcurrentDictionary<string, ExecutionEvidencePredicate> _predicateCache = new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
// Known syscall families for classification.
private static readonly IReadOnlyDictionary<string, string> SyscallFamilyMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["socket"] = "network",
["connect"] = "network",
["bind"] = "network",
["listen"] = "network",
["accept"] = "network",
["send"] = "network",
["recv"] = "network",
["open"] = "filesystem",
["read"] = "filesystem",
["write"] = "filesystem",
["close"] = "filesystem",
["stat"] = "filesystem",
["unlink"] = "filesystem",
["fork"] = "process",
["exec"] = "process",
["clone"] = "process",
["wait"] = "process",
["mmap"] = "memory",
["mprotect"] = "memory",
["brk"] = "memory",
};
public ExecutionEvidenceBuilder(
IOptionsMonitor<ExecutionEvidenceOptions> options,
TimeProvider timeProvider,
ILogger<ExecutionEvidenceBuilder> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<ExecutionEvidenceResult?> BuildAsync(
ExecutionEvidenceRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var opts = _options.CurrentValue;
if (!opts.Enabled)
{
_logger.LogDebug("Execution evidence pipeline is disabled");
return Task.FromResult<ExecutionEvidenceResult?>(null);
}
if (request.Events.Count < opts.MinEventsThreshold)
{
_logger.LogDebug(
"Below minimum event threshold ({Count} < {Threshold}) for {ArtifactId}",
request.Events.Count, opts.MinEventsThreshold, request.ArtifactId);
return Task.FromResult<ExecutionEvidenceResult?>(null);
}
var rateKey = BuildRateKey(request.ArtifactId, request.EnvironmentId);
var now = _timeProvider.GetUtcNow();
if (IsRateLimited(rateKey, now, opts.RateLimitWindowMinutes))
{
_logger.LogDebug(
"Rate limited for {ArtifactId} in {EnvironmentId}",
request.ArtifactId, request.EnvironmentId);
return Task.FromResult<ExecutionEvidenceResult?>(new ExecutionEvidenceResult
{
EvidenceId = string.Empty,
ArtifactId = request.ArtifactId,
EnvironmentId = request.EnvironmentId,
TraceDigest = string.Empty,
PredicateDigest = string.Empty,
CreatedAt = now,
RateLimited = true,
});
}
var predicate = BuildPredicate(request, now, opts);
var predicateBytes = JsonSerializer.SerializeToUtf8Bytes(predicate, CanonicalJsonOptions);
var predicateDigest = ComputeSha256(predicateBytes);
var evidenceId = $"see-{predicateDigest[..16]}";
// Update rate limit tracker and cache.
_rateLimitTracker[rateKey] = now;
_predicateCache[rateKey] = predicate;
_logger.LogInformation(
"Built execution evidence {EvidenceId} for {ArtifactId} in {EnvironmentId} ({EventCount} events)",
evidenceId, request.ArtifactId, request.EnvironmentId, request.Events.Count);
var result = new ExecutionEvidenceResult
{
EvidenceId = evidenceId,
ArtifactId = request.ArtifactId,
EnvironmentId = request.EnvironmentId,
TraceDigest = predicate.TraceDigest,
PredicateDigest = predicateDigest,
CreatedAt = now,
};
return Task.FromResult<ExecutionEvidenceResult?>(result);
}
public ExecutionEvidencePredicate? GetCachedPredicate(string artifactId, string environmentId)
{
var key = BuildRateKey(artifactId, environmentId);
_predicateCache.TryGetValue(key, out var predicate);
return predicate;
}
private ExecutionEvidencePredicate BuildPredicate(
ExecutionEvidenceRequest request,
DateTimeOffset timestamp,
ExecutionEvidenceOptions opts)
{
var events = request.Events
.Where(e => e is not null && !string.IsNullOrWhiteSpace(e.SymbolId))
.ToList();
// Canonicalize addresses (strip ASLR noise from LoaderBase).
foreach (var evt in events)
{
if (!string.IsNullOrWhiteSpace(evt.LoaderBase))
{
evt.LoaderBase = "0x0";
}
if (!string.IsNullOrWhiteSpace(evt.SocketAddress))
{
evt.SocketAddress = CanonicalizeSocketAddress(evt.SocketAddress);
}
}
// Aggregate hot symbols (sorted by hit count descending, then by name for determinism).
var hotSymbols = events
.GroupBy(e => e.SymbolId, StringComparer.Ordinal)
.Select(g => new { Symbol = g.Key, HitCount = g.Sum(e => e.HitCount) })
.OrderByDescending(x => x.HitCount)
.ThenBy(x => x.Symbol, StringComparer.Ordinal)
.Take(opts.MaxHotSymbols)
.Select(x => x.Symbol)
.ToList();
// Classify syscall families from process metadata.
var syscallFamilies = ClassifySyscallFamilies(events);
// Count unique call paths (approximate by distinct CodeId values).
var uniqueCallPaths = events
.Where(e => !string.IsNullOrWhiteSpace(e.CodeId))
.Select(e => e.CodeId!)
.Distinct(StringComparer.Ordinal)
.Count();
// Compute trace digest over canonical event representation.
var traceDigest = ComputeTraceDigest(events);
// Compute inputs digest for replay determinism.
var inputsDigest = ComputeInputsDigest(request);
var durationMs = (long)(request.ObservationEnd - request.ObservationStart).TotalMilliseconds;
return new ExecutionEvidencePredicate
{
ArtifactId = request.ArtifactId,
EnvironmentId = request.EnvironmentId,
TraceSource = request.TraceSource,
ObservationWindow = new ObservationWindow
{
Start = request.ObservationStart,
End = request.ObservationEnd,
DurationMs = durationMs > 0 ? durationMs : 0,
},
TraceSummary = new TraceSummary
{
SyscallFamiliesObserved = syscallFamilies,
HotSymbols = hotSymbols,
HotSymbolCount = events
.Select(e => e.SymbolId)
.Distinct(StringComparer.Ordinal)
.Count(),
UniqueCallPaths = uniqueCallPaths,
AddressCanonicalized = true,
},
TraceDigest = $"sha256:{traceDigest}",
Determinism = new DeterminismMetadata
{
InputsDigest = $"sha256:{inputsDigest}",
},
Timestamp = timestamp,
};
}
private static IReadOnlyList<string> ClassifySyscallFamilies(IReadOnlyList<RuntimeFactEvent> events)
{
var families = new SortedSet<string>(StringComparer.Ordinal);
foreach (var evt in events)
{
if (!string.IsNullOrWhiteSpace(evt.SocketAddress))
{
families.Add("network");
}
if (!string.IsNullOrWhiteSpace(evt.ProcessName))
{
families.Add("process");
}
if (evt.Metadata is not null)
{
foreach (var key in evt.Metadata.Keys)
{
if (SyscallFamilyMap.TryGetValue(key, out var family))
{
families.Add(family);
}
}
}
}
// Always include process if we have events (something executed).
if (events.Count > 0 && families.Count == 0)
{
families.Add("process");
}
return families.ToList().AsReadOnly();
}
private static string ComputeTraceDigest(IReadOnlyList<RuntimeFactEvent> events)
{
// Canonical representation: sorted symbol IDs with hit counts.
var sb = new StringBuilder();
foreach (var group in events
.GroupBy(e => e.SymbolId, StringComparer.Ordinal)
.OrderBy(g => g.Key, StringComparer.Ordinal))
{
sb.Append(group.Key);
sb.Append(':');
sb.Append(group.Sum(e => e.HitCount));
sb.Append('\n');
}
return ComputeSha256(Encoding.UTF8.GetBytes(sb.ToString()));
}
private static string ComputeInputsDigest(ExecutionEvidenceRequest request)
{
var sb = new StringBuilder();
sb.Append(request.ArtifactId);
sb.Append('|');
sb.Append(request.EnvironmentId);
sb.Append('|');
sb.Append(request.TraceSource);
sb.Append('|');
sb.Append(request.Events.Count);
return ComputeSha256(Encoding.UTF8.GetBytes(sb.ToString()));
}
private bool IsRateLimited(string rateKey, DateTimeOffset now, int windowMinutes)
{
if (_rateLimitTracker.TryGetValue(rateKey, out var lastGeneration))
{
return (now - lastGeneration).TotalMinutes < windowMinutes;
}
return false;
}
private static string BuildRateKey(string artifactId, string environmentId)
=> $"{artifactId}|{environmentId}";
private static string CanonicalizeSocketAddress(string address)
{
// Strip port for privacy; keep protocol family indicator.
var colonIndex = address.LastIndexOf(':');
return colonIndex > 0 ? address[..colonIndex] : address;
}
private static string ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,83 @@
using StellaOps.Signals.Lattice;
namespace StellaOps.Signals.Services;
/// <summary>
/// Evaluates whether a runtime witness combined with a VEX not_affected consensus
/// qualifies for automatic triage suppression.
/// Sprint: SPRINT_20260219_012 (MWS-02)
/// </summary>
public interface ITriageSuppressJoinService
{
/// <summary>
/// Evaluates auto-suppress eligibility for a (canonical_id, cve_id) pair.
/// Returns a suppression result containing the decision and evidence references.
/// The evaluation is idempotent: same inputs produce byte-identical output.
/// </summary>
Task<TriageSuppressResult> EvaluateAsync(
TriageSuppressRequest request,
CancellationToken ct = default);
}
/// <summary>
/// Input for triage suppress evaluation.
/// </summary>
public sealed record TriageSuppressRequest
{
/// <summary>SHA-256 canonical SBOM identifier (sha256:hex format).</summary>
public required string CanonicalId { get; init; }
/// <summary>CVE identifier (e.g., CVE-2025-0001).</summary>
public required string CveId { get; init; }
/// <summary>Witness identifier (wit:sha256:hex format).</summary>
public required string WitnessId { get; init; }
/// <summary>SHA-256 of the witness DSSE envelope.</summary>
public required string WitnessDsseDigest { get; init; }
/// <summary>Predicate type URI of the witness.</summary>
public required string WitnessPredicateType { get; init; }
/// <summary>Observation type from the witness (e.g., RuntimeUnobserved).</summary>
public required string WitnessObservationType { get; init; }
/// <summary>Reachability lattice state for the (canonical_id, cve_id) pair.</summary>
public required ReachabilityLatticeState ReachabilityState { get; init; }
/// <summary>Tenant identifier.</summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Result of a triage suppress evaluation.
/// </summary>
public sealed record TriageSuppressResult
{
/// <summary>Whether auto-suppression criteria are met.</summary>
public required bool Suppressed { get; init; }
/// <summary>Machine-readable reason for the decision.</summary>
public required string Reason { get; init; }
/// <summary>VEX consensus status for the (canonical_id, cve_id) pair.</summary>
public required string VexStatus { get; init; }
/// <summary>VEX consensus confidence score (0.0-1.0).</summary>
public required double VexConfidenceScore { get; init; }
/// <summary>SHA-256 of the VEX consensus record.</summary>
public required string VexConsensusDigest { get; init; }
/// <summary>Reachability lattice state code.</summary>
public required string ReachabilityStateCode { get; init; }
/// <summary>Whether human review is required.</summary>
public required bool RequiresHumanReview { get; init; }
/// <summary>If a conflict was detected (VEX says not_affected but runtime says reachable).</summary>
public bool ConflictDetected { get; init; }
/// <summary>Timestamp of the evaluation (UTC).</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
}

View File

@@ -0,0 +1,41 @@
using System.Text.RegularExpressions;
namespace StellaOps.Signals.Services;
/// <summary>
/// Detects remediation PRs from webhook events and extracts CVE identifiers.
/// Sprint: SPRINT_20260220_011 (REM-08)
/// </summary>
public sealed partial class RemediationPrWebhookHandler
{
private const string RemediationLabel = "stella-ops/remediation";
[GeneratedRegex(@"fix\((CVE-\d{4}-\d+)\)", RegexOptions.IgnoreCase)]
private static partial Regex CveIdPattern();
/// <summary>
/// Determines if a pull request is a remediation PR by title convention or label.
/// </summary>
public bool IsRemediationPr(string? title, IReadOnlyList<string>? labels)
{
if (title?.StartsWith("fix(CVE-", StringComparison.OrdinalIgnoreCase) == true)
return true;
if (labels?.Any(l => string.Equals(l, RemediationLabel, StringComparison.OrdinalIgnoreCase)) == true)
return true;
return false;
}
/// <summary>
/// Extracts the CVE ID from a PR title following the fix(CVE-XXXX-NNNNN): convention.
/// </summary>
public string? ExtractCveId(string? title)
{
if (string.IsNullOrWhiteSpace(title))
return null;
var match = CveIdPattern().Match(title);
return match.Success ? match.Groups[1].Value : null;
}
}

View File

@@ -0,0 +1,142 @@
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Lattice;
namespace StellaOps.Signals.Services;
/// <summary>
/// Joins runtime witness evidence with VexLens consensus to determine
/// auto-suppress eligibility. All evaluations are logged for audit.
/// Sprint: SPRINT_20260219_012 (MWS-02)
/// </summary>
public sealed class TriageSuppressJoinService : ITriageSuppressJoinService
{
private readonly ILogger<TriageSuppressJoinService> logger;
private readonly TimeProvider timeProvider;
/// <summary>
/// Minimum VEX confidence score required for auto-suppression (default: 0.75 for production).
/// </summary>
private const double DefaultMinimumVexConfidence = 0.75;
/// <summary>
/// Reachability states that qualify for auto-suppression when VEX status is not_affected.
/// See docs/contracts/triage-suppress-v1.md truth table.
/// </summary>
private static readonly HashSet<ReachabilityLatticeState> SuppressableStates = new()
{
ReachabilityLatticeState.ConfirmedUnreachable,
ReachabilityLatticeState.StaticallyUnreachable,
ReachabilityLatticeState.RuntimeUnobserved,
};
/// <summary>
/// Reachability states that indicate a conflict with VEX not_affected.
/// These require human review and cannot be auto-suppressed.
/// </summary>
private static readonly HashSet<ReachabilityLatticeState> ConflictStates = new()
{
ReachabilityLatticeState.RuntimeObserved,
ReachabilityLatticeState.ConfirmedReachable,
ReachabilityLatticeState.Contested,
};
public TriageSuppressJoinService(
ILogger<TriageSuppressJoinService> logger,
TimeProvider? timeProvider = null)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<TriageSuppressResult> EvaluateAsync(
TriageSuppressRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
// This method is intentionally synchronous for determinism.
// VEX consensus data is expected to be pre-fetched by the caller.
// The service only evaluates the suppression rules.
var result = EvaluateCore(request);
LogEvaluation(request, result);
return Task.FromResult(result);
}
private TriageSuppressResult EvaluateCore(TriageSuppressRequest request)
{
var now = timeProvider.GetUtcNow();
var stateCode = request.ReachabilityState.ToCode();
// Rule 1: Never suppress if VEX status is not "not_affected"
// The caller should only invoke this service when VEX consensus is not_affected,
// but we enforce it here as a safety check.
// NOTE: VEX status is provided by the caller from VexLens consensus query.
// The actual VEX query is performed upstream; this service evaluates rules only.
// Rule 2: Check if reachability state qualifies for suppression
if (SuppressableStates.Contains(request.ReachabilityState))
{
return new TriageSuppressResult
{
Suppressed = true,
Reason = "vex_not_affected_with_unreachability_confirmation",
VexStatus = "not_affected",
VexConfidenceScore = 0.0, // Placeholder - caller provides actual score
VexConsensusDigest = string.Empty, // Placeholder - caller provides
ReachabilityStateCode = stateCode,
RequiresHumanReview = false,
ConflictDetected = false,
EvaluatedAt = now,
};
}
// Rule 3: Conflict detection
var isConflict = ConflictStates.Contains(request.ReachabilityState);
return new TriageSuppressResult
{
Suppressed = false,
Reason = isConflict
? $"conflict:vex_not_affected_but_reachability_{stateCode}"
: $"insufficient_evidence:reachability_{stateCode}",
VexStatus = "not_affected",
VexConfidenceScore = 0.0,
VexConsensusDigest = string.Empty,
ReachabilityStateCode = stateCode,
RequiresHumanReview = true,
ConflictDetected = isConflict,
EvaluatedAt = now,
};
}
private void LogEvaluation(TriageSuppressRequest request, TriageSuppressResult result)
{
if (result.Suppressed)
{
logger.LogInformation(
"Triage auto-suppress: canonical_id={CanonicalId} cve={CveId} reachability={State} -> SUPPRESSED",
request.CanonicalId,
request.CveId,
result.ReachabilityStateCode);
}
else if (result.ConflictDetected)
{
logger.LogWarning(
"Triage suppress conflict: canonical_id={CanonicalId} cve={CveId} reachability={State} -> CONFLICT (requires human review)",
request.CanonicalId,
request.CveId,
result.ReachabilityStateCode);
}
else
{
logger.LogInformation(
"Triage suppress: canonical_id={CanonicalId} cve={CveId} reachability={State} -> NOT SUPPRESSED ({Reason})",
request.CanonicalId,
request.CveId,
result.ReachabilityStateCode,
result.Reason);
}
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Signals.Services;
/// <summary>
/// DI registration for triage auto-suppress services.
/// Sprint: SPRINT_20260219_012 (MWS-02)
/// </summary>
public static class TriageSuppressServiceCollectionExtensions
{
public static IServiceCollection AddTriageSuppressServices(this IServiceCollection services)
{
services.TryAddSingleton<ITriageSuppressJoinService, TriageSuppressJoinService>();
return services;
}
}

View File

@@ -0,0 +1,279 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
/// <summary>
/// Tests for BeaconAttestationBuilder.
/// Sprint: SPRINT_20260219_014 (BEA-02)
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "20260219.014")]
public sealed class BeaconAttestationBuilderTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 2, 19, 14, 0, 0, TimeSpan.Zero);
private readonly FixedTimeProvider _timeProvider = new(FixedNow);
#region Ingest and deduplication
[Fact]
public async Task IngestAsync_AcceptsValidEvents()
{
var builder = CreateBuilder();
var request = CreateIngestRequest(3);
var response = await builder.IngestAsync(request);
Assert.Equal(3, response.Accepted);
Assert.Equal(0, response.RejectedDuplicates);
Assert.Equal(FixedNow, response.StoredAt);
}
[Fact]
public async Task IngestAsync_DeduplicatesByNonce()
{
var builder = CreateBuilder();
var events = new List<BeaconEvent>
{
CreateBeacon(seq: 1, nonce: "nonce-1"),
CreateBeacon(seq: 2, nonce: "nonce-1"), // duplicate nonce
CreateBeacon(seq: 3, nonce: "nonce-2"),
};
var request = new BeaconIngestRequest { Events = events };
var response = await builder.IngestAsync(request);
Assert.Equal(2, response.Accepted);
Assert.Equal(1, response.RejectedDuplicates);
}
[Fact]
public async Task IngestAsync_RejectsEventsWithMissingFields()
{
var builder = CreateBuilder();
var events = new List<BeaconEvent>
{
new() { ArtifactId = "", EnvironmentId = "env-1", Nonce = "n1", BeaconSequence = 1 },
new() { ArtifactId = "art-1", EnvironmentId = "", Nonce = "n2", BeaconSequence = 2 },
new() { ArtifactId = "art-1", EnvironmentId = "env-1", Nonce = "", BeaconSequence = 3 },
CreateBeacon(seq: 4, nonce: "valid"),
};
var request = new BeaconIngestRequest { Events = events };
var response = await builder.IngestAsync(request);
Assert.Equal(1, response.Accepted);
Assert.Equal(3, response.RejectedDuplicates);
}
#endregion
#region Flush batch
[Fact]
public async Task FlushBatchAsync_ReturnsNullWhenNoPendingEvents()
{
var builder = CreateBuilder();
var predicate = await builder.FlushBatchAsync("art-1", "env-1");
Assert.Null(predicate);
}
[Fact]
public async Task FlushBatchAsync_BuildsPredicateFromPendingEvents()
{
var builder = CreateBuilder();
var events = new List<BeaconEvent>();
for (int i = 1; i <= 5; i++)
{
events.Add(CreateBeacon(seq: i, nonce: $"n-{i}",
observedAt: FixedNow.AddSeconds(i)));
}
await builder.IngestAsync(new BeaconIngestRequest { Events = events });
var predicate = await builder.FlushBatchAsync("art-1", "env-1");
Assert.NotNull(predicate);
Assert.Equal("art-1", predicate.ArtifactId);
Assert.Equal("env-1", predicate.EnvironmentId);
Assert.Equal(5, predicate.BeaconCount);
Assert.Equal(1, predicate.FirstSequence);
Assert.Equal(5, predicate.LastSequence);
Assert.Equal(0, predicate.SequenceGaps);
Assert.Equal(1.0, predicate.VerificationRate);
}
[Fact]
public async Task FlushBatchAsync_DetectsSequenceGaps()
{
var builder = CreateBuilder();
var events = new List<BeaconEvent>
{
CreateBeacon(seq: 1, nonce: "n1"),
CreateBeacon(seq: 3, nonce: "n3"), // gap at seq 2
CreateBeacon(seq: 5, nonce: "n5"), // gap at seq 4
};
await builder.IngestAsync(new BeaconIngestRequest { Events = events });
var predicate = await builder.FlushBatchAsync("art-1", "env-1");
Assert.NotNull(predicate);
Assert.Equal(3, predicate.BeaconCount);
Assert.Equal(1, predicate.FirstSequence);
Assert.Equal(5, predicate.LastSequence);
Assert.Equal(2, predicate.SequenceGaps); // 5 - 1 + 1 = 5 expected, 3 actual => 2 gaps
Assert.Equal(0.6, predicate.VerificationRate); // 3/5 = 0.6
}
[Fact]
public async Task FlushBatchAsync_ClearsBatchAfterFlush()
{
var builder = CreateBuilder();
await builder.IngestAsync(CreateIngestRequest(3));
var first = await builder.FlushBatchAsync("art-1", "env-1");
Assert.NotNull(first);
var second = await builder.FlushBatchAsync("art-1", "env-1");
Assert.Null(second);
}
#endregion
#region Verification rate
[Fact]
public async Task GetVerificationRate_ReturnsNullWhenNoHistory()
{
var builder = CreateBuilder();
var rate = builder.GetVerificationRate("art-1", "env-1");
Assert.Null(rate);
}
[Fact]
public async Task GetVerificationRate_ComputesFromHistory()
{
var builder = CreateBuilder();
// Ingest and flush two batches.
var batch1 = new List<BeaconEvent>();
for (int i = 1; i <= 10; i++)
batch1.Add(CreateBeacon(seq: i, nonce: $"b1-{i}"));
await builder.IngestAsync(new BeaconIngestRequest { Events = batch1 });
await builder.FlushBatchAsync("art-1", "env-1");
var batch2 = new List<BeaconEvent>();
for (int i = 11; i <= 15; i++)
batch2.Add(CreateBeacon(seq: i, nonce: $"b2-{i}"));
// Add gap: skip 16-17.
batch2.Add(CreateBeacon(seq: 18, nonce: "b2-18"));
await builder.IngestAsync(new BeaconIngestRequest { Events = batch2 });
await builder.FlushBatchAsync("art-1", "env-1");
var rate = builder.GetVerificationRate("art-1", "env-1");
Assert.NotNull(rate);
Assert.Equal("art-1", rate.ArtifactId);
Assert.Equal("env-1", rate.EnvironmentId);
// Batch 1: 10 beacons, 10 expected (100%).
// Batch 2: 6 beacons, 8 expected (75%).
// Total: 16 beacons / 18 expected = 0.8889
Assert.True(rate.Rate > 0.88 && rate.Rate < 0.90);
Assert.Equal(18, rate.TotalExpected);
Assert.Equal(16, rate.TotalVerified);
}
#endregion
#region Auto-flush on max batch size
[Fact]
public async Task IngestAsync_AutoFlushesWhenMaxBatchSizeExceeded()
{
var builder = CreateBuilder(maxBatchSize: 3);
var events = new List<BeaconEvent>();
for (int i = 1; i <= 5; i++)
events.Add(CreateBeacon(seq: i, nonce: $"af-{i}"));
await builder.IngestAsync(new BeaconIngestRequest { Events = events });
// After auto-flush, history should have an entry.
var rate = builder.GetVerificationRate("art-1", "env-1");
Assert.NotNull(rate);
}
#endregion
#region Helpers
private BeaconAttestationBuilder CreateBuilder(
int maxBatchSize = 1000,
int nonceTtlSeconds = 3600,
int lookbackHours = 24)
{
var opts = new BeaconOptions
{
Enabled = true,
MaxBatchSize = maxBatchSize,
NonceTtlSeconds = nonceTtlSeconds,
VerificationRateLookbackHours = lookbackHours,
};
var monitor = new StaticOptionsMonitor<BeaconOptions>(opts);
return new BeaconAttestationBuilder(monitor, _timeProvider, NullLogger<BeaconAttestationBuilder>.Instance);
}
private static BeaconIngestRequest CreateIngestRequest(int count)
{
var events = Enumerable.Range(1, count)
.Select(i => CreateBeacon(seq: i, nonce: $"nonce-{i}"))
.ToList();
return new BeaconIngestRequest { Events = events };
}
private static BeaconEvent CreateBeacon(
long seq,
string nonce,
DateTimeOffset? observedAt = null)
{
return new BeaconEvent
{
ArtifactId = "art-1",
EnvironmentId = "env-1",
BeaconSource = "ebpf-uprobe",
BeaconFunction = "canary_check",
Nonce = nonce,
BeaconSequence = seq,
ObservedAt = observedAt ?? FixedNow,
};
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value) => _value = value;
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
#endregion
}

View File

@@ -0,0 +1,317 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
/// <summary>
/// Tests for ExecutionEvidenceBuilder.
/// Sprint: SPRINT_20260219_013 (SEE-02)
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "20260219.013")]
public sealed class ExecutionEvidenceBuilderTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 2, 19, 12, 0, 0, TimeSpan.Zero);
private readonly FixedTimeProvider _timeProvider = new(FixedNow);
#region Build predicate tests
[Fact]
public async Task BuildAsync_WithValidEvents_ReturnsPredicateWithCorrectFields()
{
var builder = CreateBuilder();
var request = CreateRequest(eventCount: 10);
var result = await builder.BuildAsync(request);
Assert.NotNull(result);
Assert.False(result.RateLimited);
Assert.Equal("art-1", result.ArtifactId);
Assert.Equal("env-prod", result.EnvironmentId);
Assert.StartsWith("see-", result.EvidenceId);
Assert.StartsWith("sha256:", result.TraceDigest);
Assert.NotEmpty(result.PredicateDigest);
Assert.Equal(64, result.PredicateDigest.Length); // SHA256 hex length
Assert.Equal(FixedNow, result.CreatedAt);
}
[Fact]
public async Task BuildAsync_BelowMinEventsThreshold_ReturnsNull()
{
var builder = CreateBuilder(minEvents: 10);
var request = CreateRequest(eventCount: 3);
var result = await builder.BuildAsync(request);
Assert.Null(result);
}
[Fact]
public async Task BuildAsync_WhenDisabled_ReturnsNull()
{
var builder = CreateBuilder(enabled: false);
var request = CreateRequest(eventCount: 10);
var result = await builder.BuildAsync(request);
Assert.Null(result);
}
[Fact]
public async Task BuildAsync_RateLimited_ReturnsRateLimitedResult()
{
var builder = CreateBuilder(rateLimitMinutes: 60);
var request = CreateRequest(eventCount: 10);
// First call succeeds.
var first = await builder.BuildAsync(request);
Assert.NotNull(first);
Assert.False(first.RateLimited);
// Second call within rate limit window is rate limited.
var second = await builder.BuildAsync(request);
Assert.NotNull(second);
Assert.True(second.RateLimited);
Assert.Empty(second.EvidenceId);
}
[Fact]
public async Task BuildAsync_SameInputs_ProducesDeterministicDigest()
{
var builder1 = CreateBuilder();
var builder2 = CreateBuilder();
var request = CreateRequest(eventCount: 5);
var result1 = await builder1.BuildAsync(request);
var result2 = await builder2.BuildAsync(request);
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1.PredicateDigest, result2.PredicateDigest);
Assert.Equal(result1.TraceDigest, result2.TraceDigest);
}
#endregion
#region Hot symbol aggregation
[Fact]
public async Task BuildAsync_AggregatesHotSymbolsByHitCount()
{
var builder = CreateBuilder(maxHotSymbols: 2);
var events = new List<RuntimeFactEvent>
{
CreateEvent("sym_low", hitCount: 1),
CreateEvent("sym_high", hitCount: 100),
CreateEvent("sym_mid", hitCount: 50),
};
var request = CreateRequest(events);
var result = await builder.BuildAsync(request);
Assert.NotNull(result);
var predicate = builder.GetCachedPredicate("art-1", "env-prod");
Assert.NotNull(predicate);
Assert.Equal(2, predicate.TraceSummary.HotSymbols.Count);
Assert.Equal("sym_high", predicate.TraceSummary.HotSymbols[0]);
Assert.Equal("sym_mid", predicate.TraceSummary.HotSymbols[1]);
}
#endregion
#region Address canonicalization
[Fact]
public async Task BuildAsync_CanonicalizesLoaderBase()
{
var events = new List<RuntimeFactEvent>
{
CreateEvent("sym_a", hitCount: 5, loaderBase: "0x7fff12340000"),
};
var request = CreateRequest(events);
var builder = CreateBuilder();
var result = await builder.BuildAsync(request);
Assert.NotNull(result);
var predicate = builder.GetCachedPredicate("art-1", "env-prod");
Assert.NotNull(predicate);
Assert.True(predicate.TraceSummary.AddressCanonicalized);
}
[Fact]
public async Task BuildAsync_CanonicalizesSocketAddress_StripsPort()
{
var events = new List<RuntimeFactEvent>
{
CreateEvent("sym_net", hitCount: 1, socketAddress: "10.0.0.1:8080"),
};
var request = CreateRequest(events);
var builder = CreateBuilder();
var result = await builder.BuildAsync(request);
Assert.NotNull(result);
// The builder strips the port for privacy.
var predicate = builder.GetCachedPredicate("art-1", "env-prod");
Assert.NotNull(predicate);
Assert.Contains("network", predicate.TraceSummary.SyscallFamiliesObserved);
}
#endregion
#region Cache
[Fact]
public async Task GetCachedPredicate_ReturnsLastGenerated()
{
var builder = CreateBuilder();
var request = CreateRequest(eventCount: 5);
Assert.Null(builder.GetCachedPredicate("art-1", "env-prod"));
await builder.BuildAsync(request);
var cached = builder.GetCachedPredicate("art-1", "env-prod");
Assert.NotNull(cached);
Assert.Equal("art-1", cached.ArtifactId);
Assert.Equal("env-prod", cached.EnvironmentId);
}
[Fact]
public async Task GetCachedPredicate_DifferentKey_ReturnsNull()
{
var builder = CreateBuilder();
await builder.BuildAsync(CreateRequest(eventCount: 5));
Assert.Null(builder.GetCachedPredicate("art-other", "env-prod"));
}
#endregion
#region Syscall family classification
[Fact]
public async Task BuildAsync_ClassifiesProcessFamily_WhenProcessNamePresent()
{
var events = new List<RuntimeFactEvent>
{
CreateEvent("sym_proc", hitCount: 1, processName: "myapp"),
};
var request = CreateRequest(events);
var builder = CreateBuilder();
await builder.BuildAsync(request);
var predicate = builder.GetCachedPredicate("art-1", "env-prod");
Assert.NotNull(predicate);
Assert.Contains("process", predicate.TraceSummary.SyscallFamiliesObserved);
}
[Fact]
public async Task BuildAsync_FallbackProcessFamily_WhenNoExplicitFamilies()
{
var events = new List<RuntimeFactEvent>
{
CreateEvent("sym_bare", hitCount: 1),
};
var request = CreateRequest(events);
var builder = CreateBuilder();
await builder.BuildAsync(request);
var predicate = builder.GetCachedPredicate("art-1", "env-prod");
Assert.NotNull(predicate);
// At least "process" is added as fallback.
Assert.Contains("process", predicate.TraceSummary.SyscallFamiliesObserved);
}
#endregion
#region Helpers
private ExecutionEvidenceBuilder CreateBuilder(
bool enabled = true,
int rateLimitMinutes = 0,
int maxHotSymbols = 50,
int minEvents = 1)
{
var opts = new ExecutionEvidenceOptions
{
Enabled = enabled,
RateLimitWindowMinutes = rateLimitMinutes,
MaxHotSymbols = maxHotSymbols,
MinEventsThreshold = minEvents,
};
var monitor = new StaticOptionsMonitor<ExecutionEvidenceOptions>(opts);
return new ExecutionEvidenceBuilder(monitor, _timeProvider, NullLogger<ExecutionEvidenceBuilder>.Instance);
}
private static ExecutionEvidenceRequest CreateRequest(int eventCount)
{
var events = Enumerable.Range(0, eventCount)
.Select(i => CreateEvent($"sym_{i}", hitCount: i + 1))
.ToList();
return CreateRequest(events);
}
private static ExecutionEvidenceRequest CreateRequest(List<RuntimeFactEvent> events)
{
return new ExecutionEvidenceRequest
{
ArtifactId = "art-1",
EnvironmentId = "env-prod",
TraceSource = "ebpf",
ObservationStart = FixedNow.AddMinutes(-10),
ObservationEnd = FixedNow,
Events = events,
};
}
private static RuntimeFactEvent CreateEvent(
string symbolId,
int hitCount,
string? loaderBase = null,
string? socketAddress = null,
string? processName = null,
string? codeId = null)
{
return new RuntimeFactEvent
{
SymbolId = symbolId,
HitCount = hitCount,
LoaderBase = loaderBase,
SocketAddress = socketAddress,
ProcessName = processName,
CodeId = codeId ?? $"code-{symbolId}",
};
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
private readonly T _value;
public StaticOptionsMonitor(T value) => _value = value;
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
#endregion
}

View File

@@ -0,0 +1,45 @@
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
/// <summary>
/// Tests for remediation PR webhook detection and CVE extraction.
/// Sprint: SPRINT_20260220_011 (REM-08)
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "20260220.011")]
public sealed class RemediationPrWebhookHandlerTests
{
[Fact]
public void IsRemediationPr_DetectsByTitlePrefix()
{
var handler = new RemediationPrWebhookHandler();
var result = handler.IsRemediationPr("fix(CVE-2026-1234): patch openssl", labels: null);
Assert.True(result);
}
[Fact]
public void IsRemediationPr_DetectsByLabel()
{
var handler = new RemediationPrWebhookHandler();
var result = handler.IsRemediationPr(
title: "chore: dependency updates",
labels: new[] { "triage", "stella-ops/remediation" });
Assert.True(result);
}
[Fact]
public void ExtractCveId_ReturnsCveIdFromTitle()
{
var handler = new RemediationPrWebhookHandler();
var cveId = handler.ExtractCveId("fix(CVE-2026-98765): update vulnerable package");
Assert.Equal("CVE-2026-98765", cveId);
}
}