287 lines
9.2 KiB
C#
287 lines
9.2 KiB
C#
// <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);
|
|
}
|
|
}
|