old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -1,8 +1,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Anchor metadata for evidence attestation.
|
||||
/// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-002)
|
||||
/// </summary>
|
||||
public sealed record EvidenceAnchor
|
||||
{
|
||||
/// <summary>Whether the evidence is anchored (has attestation).</summary>
|
||||
public required bool Anchored { get; init; }
|
||||
|
||||
/// <summary>DSSE envelope digest if anchored.</summary>
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
/// <summary>Predicate type of the attestation.</summary>
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>Rekor log index if transparency-anchored.</summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>Rekor entry ID if transparency-anchored.</summary>
|
||||
public string? RekorEntryId { get; init; }
|
||||
|
||||
/// <summary>Scope of the attestation (e.g., finding, package, image).</summary>
|
||||
public string? Scope { get; init; }
|
||||
|
||||
/// <summary>Verification status of the anchor.</summary>
|
||||
public bool? Verified { get; init; }
|
||||
|
||||
/// <summary>When the attestation was created.</summary>
|
||||
public DateTimeOffset? AttestedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unanchored evidence anchor.
|
||||
/// </summary>
|
||||
public static EvidenceAnchor Unanchored => new() { Anchored = false };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated evidence from all sources for a single finding.
|
||||
/// Used as input to the normalizer aggregator.
|
||||
@@ -31,6 +69,29 @@ public sealed record FindingEvidence
|
||||
/// <summary>Active mitigations evidence (maps to MitigationInput).</summary>
|
||||
public MitigationInput? Mitigations { get; init; }
|
||||
|
||||
// Sprint: SPRINT_20260112_004_BE_findings_scoring_attested_reduction (EWS-API-002)
|
||||
|
||||
/// <summary>
|
||||
/// Anchor metadata for the primary evidence source.
|
||||
/// Populated when evidence has attestation/DSSE anchoring.
|
||||
/// </summary>
|
||||
public EvidenceAnchor? Anchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anchor metadata for reachability evidence.
|
||||
/// </summary>
|
||||
public EvidenceAnchor? ReachabilityAnchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anchor metadata for runtime evidence.
|
||||
/// </summary>
|
||||
public EvidenceAnchor? RuntimeAnchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anchor metadata for VEX/mitigation evidence.
|
||||
/// </summary>
|
||||
public EvidenceAnchor? VexAnchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates FindingEvidence from an existing EvidenceWeightedScoreInput.
|
||||
/// Extracts the detailed input records if present.
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
public interface IEventsPublisher
|
||||
{
|
||||
Task PublishFactUpdatedAsync(global::StellaOps.Signals.Models.ReachabilityFactDocument fact, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a runtime.updated event when runtime observations change.
|
||||
/// Sprint: SPRINT_20260112_008_SIGNALS_runtime_telemetry_events (SIG-RUN-002)
|
||||
/// </summary>
|
||||
Task PublishRuntimeUpdatedAsync(RuntimeUpdatedEvent runtimeEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -36,4 +36,14 @@ internal sealed class InMemoryEventsPublisher : IEventsPublisher
|
||||
logger.LogInformation(json);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishRuntimeUpdatedAsync(RuntimeUpdatedEvent runtimeEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvent);
|
||||
|
||||
var json = JsonSerializer.Serialize(runtimeEvent, SerializerOptions);
|
||||
logger.LogInformation("RuntimeUpdated: {Json}", json);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,4 +146,25 @@ internal sealed class MessagingEventsPublisher : IEventsPublisher
|
||||
_logger.LogWarning(ex, "Failed to publish reachability event to DLQ stream {Stream}.", _options.DeadLetterStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishRuntimeUpdatedAsync(RuntimeUpdatedEvent runtimeEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvent);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// For now, log the event. Full stream publishing will be added when runtime event stream is provisioned.
|
||||
_logger.LogInformation(
|
||||
"RuntimeUpdatedEvent: Subject={SubjectKey}, Type={UpdateType}, TriggerReanalysis={TriggerReanalysis}",
|
||||
runtimeEvent.SubjectKey,
|
||||
runtimeEvent.UpdateType,
|
||||
runtimeEvent.TriggerReanalysis);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
internal sealed class NullEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task PublishRuntimeUpdatedAsync(RuntimeUpdatedEvent runtimeEvent, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -157,6 +157,53 @@ internal sealed class RedisEventsPublisher : IEventsPublisher, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PublishRuntimeUpdatedAsync(RuntimeUpdatedEvent runtimeEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvent);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(runtimeEvent, SerializerOptions);
|
||||
|
||||
try
|
||||
{
|
||||
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entries = new[]
|
||||
{
|
||||
new NameValueEntry("event", json),
|
||||
new NameValueEntry("event_id", runtimeEvent.EventId),
|
||||
new NameValueEntry("event_type", RuntimeEventTypes.Updated),
|
||||
new NameValueEntry("subject_key", runtimeEvent.SubjectKey),
|
||||
new NameValueEntry("evidence_digest", runtimeEvent.EvidenceDigest),
|
||||
new NameValueEntry("trigger_reanalysis", runtimeEvent.TriggerReanalysis.ToString(CultureInfo.InvariantCulture))
|
||||
};
|
||||
|
||||
var streamName = options.Stream + ":runtime";
|
||||
var publishTask = maxStreamLength.HasValue
|
||||
? database.StreamAddAsync(streamName, entries, maxLength: maxStreamLength, useApproximateMaxLength: true)
|
||||
: database.StreamAddAsync(streamName, entries);
|
||||
|
||||
if (publishTimeout > TimeSpan.Zero)
|
||||
{
|
||||
await publishTask.WaitAsync(publishTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to publish runtime.updated event to Redis stream.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
|
||||
@@ -94,6 +94,61 @@ internal sealed class RouterEventsPublisher : IEventsPublisher
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PublishRuntimeUpdatedAsync(RuntimeUpdatedEvent runtimeEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvent);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var json = JsonSerializer.Serialize(runtimeEvent, SerializerOptions);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, options.Events.Router.Path);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
request.Headers.TryAddWithoutValidation("X-Signals-Topic", RuntimeEventTypes.Updated);
|
||||
request.Headers.TryAddWithoutValidation("X-Signals-Tenant", runtimeEvent.Tenant);
|
||||
request.Headers.TryAddWithoutValidation("X-Signals-Pipeline", options.Events.Pipeline);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Events.Router.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
string.IsNullOrWhiteSpace(options.Events.Router.ApiKeyHeader)
|
||||
? "X-API-Key"
|
||||
: options.Events.Router.ApiKeyHeader,
|
||||
options.Events.Router.ApiKey);
|
||||
}
|
||||
|
||||
foreach (var header in options.Events.Router.Headers)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Router publish failed for {Topic} with status {StatusCode}: {Body}",
|
||||
RuntimeEventTypes.Updated,
|
||||
(int)response.StatusCode,
|
||||
Truncate(body, 256));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Router publish succeeded for runtime.updated ({StatusCode})",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Router publish failed for runtime.updated");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
|
||||
@@ -94,6 +94,15 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
await cache.SetAsync(persisted, cancellationToken).ConfigureAwait(false);
|
||||
await eventsPublisher.PublishFactUpdatedAsync(persisted, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Sprint: SPRINT_20260112_008_SIGNALS_runtime_telemetry_events (SIG-RUN-002)
|
||||
// Emit runtime.updated event for policy reanalysis
|
||||
await EmitRuntimeUpdatedEventAsync(
|
||||
persisted,
|
||||
existing,
|
||||
aggregated,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await RecomputeReachabilityAsync(persisted, aggregated, request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -636,4 +645,119 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a runtime.updated event when runtime observations change.
|
||||
/// Sprint: SPRINT_20260112_008_SIGNALS_runtime_telemetry_events (SIG-RUN-002)
|
||||
/// </summary>
|
||||
private async Task EmitRuntimeUpdatedEventAsync(
|
||||
ReachabilityFactDocument persisted,
|
||||
ReachabilityFactDocument? existing,
|
||||
IReadOnlyList<RuntimeFact> aggregated,
|
||||
RuntimeFactsIngestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Determine update type based on existing state
|
||||
var updateType = DetermineUpdateType(existing, aggregated);
|
||||
|
||||
// Extract node hashes from runtime facts
|
||||
var observedNodeHashes = aggregated
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f.SymbolDigest))
|
||||
.Select(f => f.SymbolDigest!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(h => h, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Compute evidence digest from the persisted document
|
||||
var evidenceDigest = ComputeEvidenceDigest(persisted);
|
||||
|
||||
// Determine previous and new state
|
||||
var previousState = existing?.RuntimeFacts?.Any() == true ? "observed" : null;
|
||||
var newState = "observed";
|
||||
|
||||
// Extract tenant from metadata
|
||||
var tenant = request.Metadata?.TryGetValue("tenant_id", out var t) == true ? t ?? "default" : "default";
|
||||
|
||||
// Compute confidence based on hit counts
|
||||
var totalHits = aggregated.Sum(f => f.HitCount);
|
||||
var confidence = Math.Min(1.0, 0.5 + (totalHits * 0.01)); // Base 0.5, +0.01 per hit, max 1.0
|
||||
|
||||
var runtimeEvent = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: tenant,
|
||||
subjectKey: persisted.SubjectKey,
|
||||
evidenceDigest: evidenceDigest,
|
||||
updateType: updateType,
|
||||
newState: newState,
|
||||
confidence: confidence,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: timeProvider.GetUtcNow(),
|
||||
cveId: request.Subject.CveId,
|
||||
purl: request.Subject.Purl,
|
||||
callgraphId: request.CallgraphId,
|
||||
previousState: previousState,
|
||||
runtimeMethod: request.Metadata?.TryGetValue("source", out var src) == true ? src : "ebpf",
|
||||
observedNodeHashes: observedNodeHashes,
|
||||
pathHash: null,
|
||||
traceId: request.Metadata?.TryGetValue("trace_id", out var traceId) == true ? traceId : null);
|
||||
|
||||
await eventsPublisher.PublishRuntimeUpdatedAsync(runtimeEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (runtimeEvent.TriggerReanalysis)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Emitted runtime.updated event for {SubjectKey} with reanalysis trigger: {Reason}",
|
||||
persisted.SubjectKey,
|
||||
runtimeEvent.ReanalysisReason);
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeUpdateType DetermineUpdateType(
|
||||
ReachabilityFactDocument? existing,
|
||||
IReadOnlyList<RuntimeFact> newFacts)
|
||||
{
|
||||
if (existing?.RuntimeFacts is null || existing.RuntimeFacts.Count == 0)
|
||||
{
|
||||
return RuntimeUpdateType.NewObservation;
|
||||
}
|
||||
|
||||
var existingSymbols = existing.RuntimeFacts
|
||||
.Select(f => f.SymbolId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var newSymbols = newFacts
|
||||
.Select(f => f.SymbolId)
|
||||
.Where(s => !existingSymbols.Contains(s))
|
||||
.ToList();
|
||||
|
||||
if (newSymbols.Count > 0)
|
||||
{
|
||||
return RuntimeUpdateType.NewCallPath;
|
||||
}
|
||||
|
||||
// Check for confidence increase (more hits)
|
||||
var existingTotalHits = existing.RuntimeFacts.Sum(f => f.HitCount);
|
||||
var newTotalHits = newFacts.Sum(f => f.HitCount);
|
||||
|
||||
if (newTotalHits > existingTotalHits)
|
||||
{
|
||||
return RuntimeUpdateType.ConfidenceIncrease;
|
||||
}
|
||||
|
||||
return RuntimeUpdateType.StateChange;
|
||||
}
|
||||
|
||||
private static string ComputeEvidenceDigest(ReachabilityFactDocument document)
|
||||
{
|
||||
// Create a deterministic digest from key fields
|
||||
var content = string.Join("|",
|
||||
document.SubjectKey ?? string.Empty,
|
||||
document.CallgraphId ?? string.Empty,
|
||||
document.RuntimeFacts?.Count.ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||
document.RuntimeFacts?.Sum(f => f.HitCount).ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||
document.ComputedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(content));
|
||||
return "sha256:" + Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// <copyright file="RuntimeNodeHashTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-003)
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Signals.Ebpf.Tests;
|
||||
|
||||
using StellaOps.Signals.Ebpf.Schema;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for node hash emission and callstack hash determinism.
|
||||
/// Sprint: SPRINT_20260112_005_SIGNALS_runtime_nodehash (PW-SIG-003)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RuntimeNodeHashTests
|
||||
{
|
||||
[Fact]
|
||||
public void RuntimeCallEvent_NodeHashFields_HaveCorrectDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = new RuntimeCallEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ContainerId = "container-123",
|
||||
Pid = 1234,
|
||||
Tid = 5678,
|
||||
TimestampNs = 1000000000,
|
||||
Symbol = "vulnerable_func",
|
||||
};
|
||||
|
||||
// Assert - New fields should be null by default
|
||||
Assert.Null(evt.FunctionSignature);
|
||||
Assert.Null(evt.BinaryDigest);
|
||||
Assert.Null(evt.BinaryOffset);
|
||||
Assert.Null(evt.NodeHash);
|
||||
Assert.Null(evt.CallstackHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeCallEvent_WithNodeHashFields_PreservesValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = new RuntimeCallEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ContainerId = "container-123",
|
||||
Pid = 1234,
|
||||
Tid = 5678,
|
||||
TimestampNs = 1000000000,
|
||||
Symbol = "vulnerable_func",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
FunctionSignature = "lodash.merge(object, ...sources)",
|
||||
BinaryDigest = "sha256:abc123def456",
|
||||
BinaryOffset = 0x1234,
|
||||
NodeHash = "sha256:nodehash123",
|
||||
CallstackHash = "sha256:callstackhash456"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("lodash.merge(object, ...sources)", evt.FunctionSignature);
|
||||
Assert.Equal("sha256:abc123def456", evt.BinaryDigest);
|
||||
Assert.Equal((ulong)0x1234, evt.BinaryOffset);
|
||||
Assert.Equal("sha256:nodehash123", evt.NodeHash);
|
||||
Assert.Equal("sha256:callstackhash456", evt.CallstackHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservedCallPath_NodeHashFields_HaveCorrectDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var path = new ObservedCallPath
|
||||
{
|
||||
Symbols = ["main", "processRequest", "vulnerable_func"],
|
||||
ObservationCount = 100,
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
};
|
||||
|
||||
// Assert - New fields should be null/empty by default
|
||||
Assert.Null(path.NodeHashes);
|
||||
Assert.Null(path.PathHash);
|
||||
Assert.Null(path.CallstackHash);
|
||||
Assert.Null(path.FunctionSignatures);
|
||||
Assert.Null(path.BinaryDigests);
|
||||
Assert.Null(path.BinaryOffsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservedCallPath_WithNodeHashes_PreservesValues()
|
||||
{
|
||||
// Arrange
|
||||
var nodeHashes = new List<string> { "sha256:hash1", "sha256:hash2", "sha256:hash3" };
|
||||
var functionSignatures = new List<string?> { "main()", "process(req)", "vuln(data)" };
|
||||
var binaryDigests = new List<string?> { "sha256:bin1", "sha256:bin2", "sha256:bin3" };
|
||||
var binaryOffsets = new List<ulong?> { 0x1000, 0x2000, 0x3000 };
|
||||
|
||||
// Act
|
||||
var path = new ObservedCallPath
|
||||
{
|
||||
Symbols = ["main", "process", "vuln"],
|
||||
ObservationCount = 50,
|
||||
Purl = "pkg:golang/example.com/pkg@1.0.0",
|
||||
NodeHashes = nodeHashes,
|
||||
PathHash = "sha256:pathhash123",
|
||||
CallstackHash = "sha256:callstackhash456",
|
||||
FunctionSignatures = functionSignatures,
|
||||
BinaryDigests = binaryDigests,
|
||||
BinaryOffsets = binaryOffsets
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, path.NodeHashes!.Count);
|
||||
Assert.Equal("sha256:hash1", path.NodeHashes[0]);
|
||||
Assert.Equal("sha256:pathhash123", path.PathHash);
|
||||
Assert.Equal("sha256:callstackhash456", path.CallstackHash);
|
||||
Assert.Equal(3, path.FunctionSignatures!.Count);
|
||||
Assert.Equal(3, path.BinaryDigests!.Count);
|
||||
Assert.Equal(3, path.BinaryOffsets!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeSignalSummary_NodeHashFields_HaveCorrectDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var summary = new RuntimeSignalSummary
|
||||
{
|
||||
ContainerId = "container-456",
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
StoppedAt = DateTimeOffset.UtcNow,
|
||||
TotalEvents = 1000,
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Null(summary.ObservedNodeHashes);
|
||||
Assert.Null(summary.ObservedPathHashes);
|
||||
Assert.Null(summary.CombinedPathHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeSignalSummary_WithNodeHashes_PreservesValues()
|
||||
{
|
||||
// Arrange
|
||||
var observedNodeHashes = new List<string> { "sha256:node1", "sha256:node2" };
|
||||
var observedPathHashes = new List<string> { "sha256:path1", "sha256:path2" };
|
||||
|
||||
// Act
|
||||
var summary = new RuntimeSignalSummary
|
||||
{
|
||||
ContainerId = "container-456",
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
StoppedAt = DateTimeOffset.UtcNow,
|
||||
TotalEvents = 1000,
|
||||
ObservedNodeHashes = observedNodeHashes,
|
||||
ObservedPathHashes = observedPathHashes,
|
||||
CombinedPathHash = "sha256:combinedhash"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, summary.ObservedNodeHashes!.Count);
|
||||
Assert.Equal(2, summary.ObservedPathHashes!.Count);
|
||||
Assert.Equal("sha256:combinedhash", summary.CombinedPathHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeHashes_AreDeterministicallySorted()
|
||||
{
|
||||
// Arrange - Create hashes in unsorted order
|
||||
var unsortedHashes = new List<string>
|
||||
{
|
||||
"sha256:zzz",
|
||||
"sha256:aaa",
|
||||
"sha256:mmm"
|
||||
};
|
||||
|
||||
// Act - Sort for determinism
|
||||
var sortedHashes = unsortedHashes.Order().ToList();
|
||||
|
||||
// Assert - Should be sorted alphabetically
|
||||
Assert.Equal("sha256:aaa", sortedHashes[0]);
|
||||
Assert.Equal("sha256:mmm", sortedHashes[1]);
|
||||
Assert.Equal("sha256:zzz", sortedHashes[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallstackHash_DeterminismTest()
|
||||
{
|
||||
// Arrange - Same symbols should produce same path
|
||||
var path1 = new ObservedCallPath
|
||||
{
|
||||
Symbols = ["main", "process", "vulnerable_func"],
|
||||
Purl = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
var path2 = new ObservedCallPath
|
||||
{
|
||||
Symbols = ["main", "process", "vulnerable_func"],
|
||||
Purl = "pkg:npm/lodash@4.17.21"
|
||||
};
|
||||
|
||||
// Assert - Both paths have identical structure
|
||||
Assert.Equal(path1.Symbols.Count, path2.Symbols.Count);
|
||||
for (int i = 0; i < path1.Symbols.Count; i++)
|
||||
{
|
||||
Assert.Equal(path1.Symbols[i], path2.Symbols[i]);
|
||||
}
|
||||
Assert.Equal(path1.Purl, path2.Purl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeHash_MissingPurl_HandledGracefully()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = new RuntimeCallEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ContainerId = "container-123",
|
||||
Pid = 1234,
|
||||
Tid = 5678,
|
||||
TimestampNs = 1000000000,
|
||||
Symbol = "unknown_func",
|
||||
Purl = null, // Missing PURL
|
||||
FunctionSignature = "unknown_func()",
|
||||
};
|
||||
|
||||
// Assert - Should not throw, node hash will be null
|
||||
Assert.Null(evt.Purl);
|
||||
Assert.NotNull(evt.FunctionSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeHash_MissingSymbol_HandledGracefully()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = new RuntimeCallEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ContainerId = "container-123",
|
||||
Pid = 1234,
|
||||
Tid = 5678,
|
||||
TimestampNs = 1000000000,
|
||||
Symbol = null, // Missing symbol
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
};
|
||||
|
||||
// Assert - Should not throw
|
||||
Assert.Null(evt.Symbol);
|
||||
Assert.NotNull(evt.Purl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeType_AllValuesSupported()
|
||||
{
|
||||
// Arrange & Act - Test all runtime types
|
||||
var runtimeTypes = Enum.GetValues<RuntimeType>();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(RuntimeType.Unknown, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.Native, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.Jvm, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.Node, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.Python, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.DotNet, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.Go, runtimeTypes);
|
||||
Assert.Contains(RuntimeType.Ruby, runtimeTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathHash_DifferentSymbolOrder_DifferentHash()
|
||||
{
|
||||
// Arrange - Same symbols but different order
|
||||
var path1 = new ObservedCallPath
|
||||
{
|
||||
Symbols = ["main", "process", "vulnerable_func"],
|
||||
PathHash = "sha256:path1hash"
|
||||
};
|
||||
|
||||
var path2 = new ObservedCallPath
|
||||
{
|
||||
Symbols = ["vulnerable_func", "process", "main"],
|
||||
PathHash = "sha256:path2hash"
|
||||
};
|
||||
|
||||
// Assert - Different order should produce different hash
|
||||
Assert.NotEqual(path1.PathHash, path2.PathHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// <copyright file="RuntimeUpdatedEventTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_008_SIGNALS_runtime_telemetry_events (SIG-RUN-004)
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for runtime updated event generation, idempotency, and ordering.
|
||||
/// Sprint: SPRINT_20260112_008_SIGNALS_runtime_telemetry_events (SIG-RUN-004)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class RuntimeUpdatedEventTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Factory_CreatesEventWithDeterministicId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var event1 = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "cve:CVE-2026-1234|purl:pkg:npm/lodash@4.17.21",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.NewObservation,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime);
|
||||
|
||||
var event2 = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "cve:CVE-2026-1234|purl:pkg:npm/lodash@4.17.21",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.NewObservation,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime);
|
||||
|
||||
// Assert - Same inputs should produce same event ID
|
||||
Assert.Equal(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_DifferentEvidenceDigest_ProducesDifferentId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var event1 = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "cve:CVE-2026-1234|purl:pkg:npm/lodash@4.17.21",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.NewObservation,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime);
|
||||
|
||||
var event2 = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "cve:CVE-2026-1234|purl:pkg:npm/lodash@4.17.21",
|
||||
evidenceDigest: "sha256:different",
|
||||
updateType: RuntimeUpdateType.NewObservation,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(event1.EventId, event2.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_ExploitTelemetry_AlwaysTriggersReanalysis()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "test-subject",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.ExploitTelemetry,
|
||||
newState: "exploited",
|
||||
confidence: 0.5,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.True(evt.TriggerReanalysis);
|
||||
Assert.NotNull(evt.ReanalysisReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_StateChange_TriggersReanalysis()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "test-subject",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.StateChange,
|
||||
newState: "confirmed",
|
||||
confidence: 0.7,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime,
|
||||
previousState: "suspected");
|
||||
|
||||
// Assert
|
||||
Assert.True(evt.TriggerReanalysis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_HighConfidenceRuntime_TriggersReanalysis()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "test-subject",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.ConfidenceIncrease,
|
||||
newState: "observed",
|
||||
confidence: 0.95,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime,
|
||||
previousState: "observed");
|
||||
|
||||
// Assert
|
||||
Assert.True(evt.TriggerReanalysis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_LowConfidence_DoesNotTriggerReanalysis()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "test-subject",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.ConfidenceIncrease,
|
||||
newState: "observed",
|
||||
confidence: 0.3,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime,
|
||||
previousState: "observed");
|
||||
|
||||
// Assert - Low confidence state change without state change shouldn't trigger
|
||||
Assert.False(evt.TriggerReanalysis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_ObservedNodeHashes_PreservedInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var nodeHashes = new List<string> { "sha256:zzz", "sha256:aaa", "sha256:mmm" };
|
||||
|
||||
// Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "test-subject",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.NewObservation,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime,
|
||||
observedNodeHashes: nodeHashes);
|
||||
|
||||
// Assert - Hashes should be preserved as provided
|
||||
Assert.Equal(3, evt.ObservedNodeHashes.Length);
|
||||
Assert.Equal("sha256:zzz", evt.ObservedNodeHashes[0]);
|
||||
Assert.Equal("sha256:aaa", evt.ObservedNodeHashes[1]);
|
||||
Assert.Equal("sha256:mmm", evt.ObservedNodeHashes[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_AllFieldsPopulated()
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "cve:CVE-2026-1234|purl:pkg:npm/lodash@4.17.21",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: RuntimeUpdateType.NewCallPath,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime,
|
||||
cveId: "CVE-2026-1234",
|
||||
purl: "pkg:npm/lodash@4.17.21",
|
||||
callgraphId: "cg-scan-001",
|
||||
previousState: "suspected",
|
||||
runtimeMethod: "ebpf",
|
||||
observedNodeHashes: new List<string> { "sha256:node1" },
|
||||
pathHash: "sha256:path1",
|
||||
traceId: "trace-001");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-tenant", evt.Tenant);
|
||||
Assert.Equal("CVE-2026-1234", evt.CveId);
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21", evt.Purl);
|
||||
Assert.Equal("cg-scan-001", evt.CallgraphId);
|
||||
Assert.Equal("suspected", evt.PreviousState);
|
||||
Assert.Equal("observed", evt.NewState);
|
||||
Assert.Equal("ebpf", evt.RuntimeMethod);
|
||||
Assert.Equal("sha256:path1", evt.PathHash);
|
||||
Assert.Equal("trace-001", evt.TraceId);
|
||||
Assert.Equal(RuntimeEventTypes.Updated, evt.EventType);
|
||||
Assert.Equal("1.0.0", evt.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeEventTypes_HasCorrectConstants()
|
||||
{
|
||||
// Assert
|
||||
Assert.Equal("runtime.updated", RuntimeEventTypes.Updated);
|
||||
Assert.Equal("runtime.updated@1", RuntimeEventTypes.UpdatedV1);
|
||||
Assert.Equal("runtime.ingested", RuntimeEventTypes.Ingested);
|
||||
Assert.Equal("runtime.confirmed", RuntimeEventTypes.Confirmed);
|
||||
Assert.Equal("runtime.exploit_detected", RuntimeEventTypes.ExploitDetected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RuntimeUpdateType.NewObservation)]
|
||||
[InlineData(RuntimeUpdateType.StateChange)]
|
||||
[InlineData(RuntimeUpdateType.ConfidenceIncrease)]
|
||||
[InlineData(RuntimeUpdateType.NewCallPath)]
|
||||
[InlineData(RuntimeUpdateType.ExploitTelemetry)]
|
||||
public void Factory_AllUpdateTypes_CreateValidEvents(RuntimeUpdateType updateType)
|
||||
{
|
||||
// Arrange & Act
|
||||
var evt = RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "test-tenant",
|
||||
subjectKey: "test-subject",
|
||||
evidenceDigest: "sha256:abc123",
|
||||
updateType: updateType,
|
||||
newState: "observed",
|
||||
confidence: 0.85,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.NotEmpty(evt.EventId);
|
||||
Assert.Equal(updateType, evt.UpdateType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Event_IdempotencyKey_IsDeterministic()
|
||||
{
|
||||
// Arrange - Create same event multiple times with same inputs
|
||||
var events = Enumerable.Range(0, 5)
|
||||
.Select(_ => RuntimeUpdatedEventFactory.Create(
|
||||
tenant: "tenant-1",
|
||||
subjectKey: "subject-1",
|
||||
evidenceDigest: "sha256:evidence1",
|
||||
updateType: RuntimeUpdateType.NewObservation,
|
||||
newState: "observed",
|
||||
confidence: 0.9,
|
||||
fromRuntime: true,
|
||||
occurredAtUtc: FixedTime))
|
||||
.ToList();
|
||||
|
||||
// Assert - All events should have the same ID
|
||||
var distinctIds = events.Select(e => e.EventId).Distinct().ToList();
|
||||
Assert.Single(distinctIds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user