|
|
|
|
@@ -0,0 +1,516 @@
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// LedgerReplayDeterminismTests.cs
|
|
|
|
|
// Sprint: SPRINT_5100_0010_0001_evidencelocker_tests
|
|
|
|
|
// Tasks: FINDINGS-5100-001, FINDINGS-5100-002, FINDINGS-5100-003
|
|
|
|
|
// Description: Model L0+S1 determinism tests for Findings Ledger replay
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using StellaOps.Findings.Ledger.Domain;
|
|
|
|
|
using StellaOps.Findings.Ledger.Hashing;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure.InMemory;
|
|
|
|
|
using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
|
|
|
|
using StellaOps.Findings.Ledger.Services;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Findings.Ledger.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Determinism tests for Findings Ledger replay.
|
|
|
|
|
/// Implements Model L0+S1 test requirements:
|
|
|
|
|
/// - Replay events → identical final state (FINDINGS-5100-001)
|
|
|
|
|
/// - Events ordered by timestamp + sequence → deterministic replay (FINDINGS-5100-002)
|
|
|
|
|
/// - Ledger state at specific point-in-time → canonical JSON snapshot (FINDINGS-5100-003)
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Trait("Category", "Unit")]
|
|
|
|
|
[Trait("Category", "LedgerDeterminism")]
|
|
|
|
|
public sealed class LedgerReplayDeterminismTests
|
|
|
|
|
{
|
|
|
|
|
private readonly InMemoryLedgerEventRepository _repository;
|
|
|
|
|
|
|
|
|
|
public LedgerReplayDeterminismTests()
|
|
|
|
|
{
|
|
|
|
|
_repository = new InMemoryLedgerEventRepository();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FINDINGS-5100-001: Replay events → identical final state
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplayEvents_SameOrder_ProducesIdenticalProjection()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Act - Replay twice
|
|
|
|
|
var projection1 = ReplayEvents(events);
|
|
|
|
|
var projection2 = ReplayEvents(events);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
projection1.Should().NotBeNull();
|
|
|
|
|
projection2.Should().NotBeNull();
|
|
|
|
|
projection1!.Status.Should().Be(projection2!.Status);
|
|
|
|
|
projection1.Severity.Should().Be(projection2.Severity);
|
|
|
|
|
projection1.CycleHash.Should().Be(projection2.CycleHash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplayEvents_MultipleRuns_ProducesDeterministicCycleHash()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Act - Replay 5 times
|
|
|
|
|
var hashes = new List<string>();
|
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
|
|
|
{
|
|
|
|
|
var projection = ReplayEvents(events);
|
|
|
|
|
hashes.Add(projection!.CycleHash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Assert - All hashes should be identical
|
|
|
|
|
hashes.Distinct().Should().HaveCount(1, "replay should produce deterministic cycle hash");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplayEvents_WithLabels_ProducesIdenticalLabels()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateEventsWithLabels(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Act - Replay twice
|
|
|
|
|
var projection1 = ReplayEvents(events);
|
|
|
|
|
var projection2 = ReplayEvents(events);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
var labels1Json = projection1!.Labels.ToJsonString();
|
|
|
|
|
var labels2Json = projection2!.Labels.ToJsonString();
|
|
|
|
|
labels1Json.Should().Be(labels2Json);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FINDINGS-5100-002: Events ordered by timestamp + sequence → deterministic replay
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplayEvents_DifferentOrder_ProducesDifferentProjection()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
var reversedEvents = events.Reverse().ToList();
|
|
|
|
|
|
|
|
|
|
// Act - Replay in forward and reverse order
|
|
|
|
|
var projectionForward = ReplayEvents(events);
|
|
|
|
|
var projectionReverse = ReplayEvents(reversedEvents);
|
|
|
|
|
|
|
|
|
|
// Assert - Different order may produce different final state
|
|
|
|
|
// (depending on event semantics, but status/hash should differ)
|
|
|
|
|
projectionForward.Should().NotBeNull();
|
|
|
|
|
projectionReverse.Should().NotBeNull();
|
|
|
|
|
|
|
|
|
|
// At minimum, cycle hashes should differ if order matters
|
|
|
|
|
if (!projectionForward!.CycleHash.Equals(projectionReverse!.CycleHash))
|
|
|
|
|
{
|
|
|
|
|
projectionForward.CycleHash.Should().NotBe(projectionReverse.CycleHash,
|
|
|
|
|
"different event order should produce different cycle hash");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplayEvents_OrderedBySequence_ProducesDeterministicState()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Sort events by sequence number (deterministic ordering)
|
|
|
|
|
var orderedEvents = events.OrderBy(e => e.SequenceNumber).ToList();
|
|
|
|
|
|
|
|
|
|
// Act - Replay multiple times with same ordering
|
|
|
|
|
var projection1 = ReplayEvents(orderedEvents);
|
|
|
|
|
var projection2 = ReplayEvents(orderedEvents);
|
|
|
|
|
var projection3 = ReplayEvents(orderedEvents);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
projection1!.CycleHash.Should().Be(projection2!.CycleHash);
|
|
|
|
|
projection2.CycleHash.Should().Be(projection3!.CycleHash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void ReplayEvents_SameTimestampDifferentSequence_UsesSequenceForOrder()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var sameTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
// Create events with same timestamp but different sequence numbers
|
|
|
|
|
var events = new List<LedgerEventRecord>
|
|
|
|
|
{
|
|
|
|
|
CreateEvent(tenantId, findingId, chainId, 1, sameTime,
|
|
|
|
|
LedgerEventConstants.EventFindingCreated, CreateCreatedPayload(7.5m)),
|
|
|
|
|
CreateEvent(tenantId, findingId, chainId, 2, sameTime,
|
|
|
|
|
LedgerEventConstants.EventFindingStatusChanged, CreateStatusPayload("under_review")),
|
|
|
|
|
CreateEvent(tenantId, findingId, chainId, 3, sameTime,
|
|
|
|
|
LedgerEventConstants.EventFindingStatusChanged, CreateStatusPayload("closed"))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Act - Replay with different sort strategies
|
|
|
|
|
var bySequence = events.OrderBy(e => e.SequenceNumber).ToList();
|
|
|
|
|
var bySequenceDesc = events.OrderByDescending(e => e.SequenceNumber).ToList();
|
|
|
|
|
|
|
|
|
|
var projection1 = ReplayEvents(bySequence);
|
|
|
|
|
var projection2 = ReplayEvents(bySequenceDesc);
|
|
|
|
|
|
|
|
|
|
// Assert - Sequence-based ordering should produce "closed" status
|
|
|
|
|
projection1!.Status.Should().Be("closed");
|
|
|
|
|
// Reverse order should produce different result (created → first, closed → last != under_review → last)
|
|
|
|
|
projection2!.Status.Should().NotBe("closed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FINDINGS-5100-003: Ledger state at specific point-in-time → canonical JSON snapshot
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void LedgerState_AtPointInTime_ProducesCanonicalSnapshot()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Act - Get projection at specific point in time
|
|
|
|
|
var projection = ReplayEvents(events);
|
|
|
|
|
|
|
|
|
|
// Convert to canonical JSON
|
|
|
|
|
var canonicalJson = CreateCanonicalProjectionJson(projection!);
|
|
|
|
|
|
|
|
|
|
// Assert - Multiple calls should produce identical JSON
|
|
|
|
|
var canonicalJson2 = CreateCanonicalProjectionJson(projection);
|
|
|
|
|
canonicalJson.Should().Be(canonicalJson2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CycleHash_ComputedDeterministically()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var projection1 = new FindingProjection(
|
|
|
|
|
TenantId: "t1",
|
|
|
|
|
FindingId: "f1",
|
|
|
|
|
PolicyVersion: "v1",
|
|
|
|
|
Status: "affected",
|
|
|
|
|
Severity: 7.5m,
|
|
|
|
|
RiskScore: 5.5m,
|
|
|
|
|
RiskSeverity: "high",
|
|
|
|
|
RiskProfileVersion: "profile-1",
|
|
|
|
|
RiskExplanationId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
|
|
|
|
RiskEventSequence: 1,
|
|
|
|
|
Labels: new JsonObject { ["env"] = "prod", ["team"] = "security" },
|
|
|
|
|
CurrentEventId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
|
|
|
|
ExplainRef: "ref-1",
|
|
|
|
|
PolicyRationale: new JsonArray("rationale-1"),
|
|
|
|
|
UpdatedAt: new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero),
|
|
|
|
|
CycleHash: string.Empty);
|
|
|
|
|
|
|
|
|
|
var projection2 = projection1 with { CycleHash = string.Empty };
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
var hash1 = ProjectionHashing.ComputeCycleHash(projection1);
|
|
|
|
|
var hash2 = ProjectionHashing.ComputeCycleHash(projection2);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
hash1.Should().Be(hash2);
|
|
|
|
|
hash1.Should().NotBeNullOrEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void CycleHash_ChangesWhenStatusChanges()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var baseProjection = new FindingProjection(
|
|
|
|
|
TenantId: "t1",
|
|
|
|
|
FindingId: "f1",
|
|
|
|
|
PolicyVersion: "v1",
|
|
|
|
|
Status: "affected",
|
|
|
|
|
Severity: 7.5m,
|
|
|
|
|
RiskScore: null,
|
|
|
|
|
RiskSeverity: null,
|
|
|
|
|
RiskProfileVersion: null,
|
|
|
|
|
RiskExplanationId: null,
|
|
|
|
|
RiskEventSequence: 1,
|
|
|
|
|
Labels: new JsonObject(),
|
|
|
|
|
CurrentEventId: Guid.NewGuid(),
|
|
|
|
|
ExplainRef: null,
|
|
|
|
|
PolicyRationale: new JsonArray(),
|
|
|
|
|
UpdatedAt: DateTimeOffset.UtcNow,
|
|
|
|
|
CycleHash: string.Empty);
|
|
|
|
|
|
|
|
|
|
var changedStatus = baseProjection with { Status = "closed" };
|
|
|
|
|
|
|
|
|
|
// Act
|
|
|
|
|
var hash1 = ProjectionHashing.ComputeCycleHash(baseProjection);
|
|
|
|
|
var hash2 = ProjectionHashing.ComputeCycleHash(changedStatus);
|
|
|
|
|
|
|
|
|
|
// Assert
|
|
|
|
|
hash1.Should().NotBe(hash2, "different status should produce different hash");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void EventHash_ChainedDeterministically()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Act - Verify hash chain
|
|
|
|
|
string previousHash = LedgerEventConstants.EmptyHash;
|
|
|
|
|
foreach (var evt in events.OrderBy(e => e.SequenceNumber))
|
|
|
|
|
{
|
|
|
|
|
evt.PreviousHash.Should().Be(previousHash,
|
|
|
|
|
$"event {evt.SequenceNumber} should reference previous hash");
|
|
|
|
|
previousHash = evt.EventHash;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Assert - Each hash should be unique
|
|
|
|
|
var hashes = events.Select(e => e.EventHash).ToList();
|
|
|
|
|
hashes.Distinct().Should().HaveCount(events.Count, "each event should have unique hash");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void MerkleLeafHash_ComputedFromEventBody()
|
|
|
|
|
{
|
|
|
|
|
// Arrange
|
|
|
|
|
var tenantId = "tenant-1";
|
|
|
|
|
var findingId = $"finding-{Guid.NewGuid():N}";
|
|
|
|
|
var chainId = Guid.NewGuid();
|
|
|
|
|
var baseTime = DateTimeOffset.UtcNow;
|
|
|
|
|
|
|
|
|
|
var events = CreateFindingEventSequence(tenantId, findingId, chainId, baseTime);
|
|
|
|
|
|
|
|
|
|
// Assert - Each event should have a non-empty merkle leaf hash
|
|
|
|
|
foreach (var evt in events)
|
|
|
|
|
{
|
|
|
|
|
evt.MerkleLeafHash.Should().NotBeNullOrEmpty($"event {evt.SequenceNumber} should have merkle hash");
|
|
|
|
|
evt.MerkleLeafHash.Length.Should().Be(64, "merkle hash should be SHA-256 hex");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper methods
|
|
|
|
|
|
|
|
|
|
private List<LedgerEventRecord> CreateFindingEventSequence(
|
|
|
|
|
string tenantId,
|
|
|
|
|
string findingId,
|
|
|
|
|
Guid chainId,
|
|
|
|
|
DateTimeOffset baseTime)
|
|
|
|
|
{
|
|
|
|
|
var events = new List<LedgerEventRecord>();
|
|
|
|
|
string previousHash = LedgerEventConstants.EmptyHash;
|
|
|
|
|
|
|
|
|
|
// Event 1: Finding created
|
|
|
|
|
var event1 = CreateEvent(tenantId, findingId, chainId, 1, baseTime,
|
|
|
|
|
LedgerEventConstants.EventFindingCreated, CreateCreatedPayload(7.5m), previousHash);
|
|
|
|
|
events.Add(event1);
|
|
|
|
|
previousHash = event1.EventHash;
|
|
|
|
|
|
|
|
|
|
// Event 2: Status changed
|
|
|
|
|
var event2 = CreateEvent(tenantId, findingId, chainId, 2, baseTime.AddMinutes(1),
|
|
|
|
|
LedgerEventConstants.EventFindingStatusChanged, CreateStatusPayload("under_review"), previousHash);
|
|
|
|
|
events.Add(event2);
|
|
|
|
|
previousHash = event2.EventHash;
|
|
|
|
|
|
|
|
|
|
// Event 3: Comment added
|
|
|
|
|
var event3 = CreateEvent(tenantId, findingId, chainId, 3, baseTime.AddMinutes(2),
|
|
|
|
|
LedgerEventConstants.EventFindingCommentAdded, CreateCommentPayload("Investigating"), previousHash);
|
|
|
|
|
events.Add(event3);
|
|
|
|
|
previousHash = event3.EventHash;
|
|
|
|
|
|
|
|
|
|
// Event 4: Finding closed
|
|
|
|
|
var event4 = CreateEvent(tenantId, findingId, chainId, 4, baseTime.AddMinutes(3),
|
|
|
|
|
LedgerEventConstants.EventFindingClosed, CreateStatusPayload("closed"), previousHash);
|
|
|
|
|
events.Add(event4);
|
|
|
|
|
|
|
|
|
|
return events;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<LedgerEventRecord> CreateEventsWithLabels(
|
|
|
|
|
string tenantId,
|
|
|
|
|
string findingId,
|
|
|
|
|
Guid chainId,
|
|
|
|
|
DateTimeOffset baseTime)
|
|
|
|
|
{
|
|
|
|
|
var events = new List<LedgerEventRecord>();
|
|
|
|
|
string previousHash = LedgerEventConstants.EmptyHash;
|
|
|
|
|
|
|
|
|
|
// Event 1: Finding created with labels
|
|
|
|
|
var payload1 = CreateCreatedPayload(5.0m);
|
|
|
|
|
payload1["labels"] = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["env"] = "prod",
|
|
|
|
|
["team"] = "security"
|
|
|
|
|
};
|
|
|
|
|
var event1 = CreateEvent(tenantId, findingId, chainId, 1, baseTime,
|
|
|
|
|
LedgerEventConstants.EventFindingCreated, payload1, previousHash);
|
|
|
|
|
events.Add(event1);
|
|
|
|
|
previousHash = event1.EventHash;
|
|
|
|
|
|
|
|
|
|
// Event 2: Tag updated (add label)
|
|
|
|
|
var payload2 = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["labels"] = new JsonObject { ["priority"] = "high" }
|
|
|
|
|
};
|
|
|
|
|
var event2 = CreateEvent(tenantId, findingId, chainId, 2, baseTime.AddMinutes(1),
|
|
|
|
|
LedgerEventConstants.EventFindingTagUpdated, payload2, previousHash);
|
|
|
|
|
events.Add(event2);
|
|
|
|
|
|
|
|
|
|
return events;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private LedgerEventRecord CreateEvent(
|
|
|
|
|
string tenantId,
|
|
|
|
|
string findingId,
|
|
|
|
|
Guid chainId,
|
|
|
|
|
long sequence,
|
|
|
|
|
DateTimeOffset occurredAt,
|
|
|
|
|
string eventType,
|
|
|
|
|
JsonObject payload,
|
|
|
|
|
string? previousHash = null)
|
|
|
|
|
{
|
|
|
|
|
var eventId = Guid.NewGuid();
|
|
|
|
|
previousHash ??= LedgerEventConstants.EmptyHash;
|
|
|
|
|
|
|
|
|
|
var eventBody = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["event"] = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["type"] = eventType,
|
|
|
|
|
["payload"] = payload.DeepClone()
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(eventBody);
|
|
|
|
|
var eventHash = LedgerHashing.ComputeEventHash(canonicalJson, previousHash);
|
|
|
|
|
var merkleLeaf = LedgerHashing.ComputeMerkleLeaf(eventBody);
|
|
|
|
|
|
|
|
|
|
return new LedgerEventRecord(
|
|
|
|
|
TenantId: tenantId,
|
|
|
|
|
ChainId: chainId,
|
|
|
|
|
SequenceNumber: sequence,
|
|
|
|
|
EventId: eventId,
|
|
|
|
|
EventType: eventType,
|
|
|
|
|
PolicyVersion: "v1",
|
|
|
|
|
FindingId: findingId,
|
|
|
|
|
ArtifactId: $"artifact-{sequence}",
|
|
|
|
|
SourceRunId: null,
|
|
|
|
|
ActorId: "system",
|
|
|
|
|
ActorType: "system",
|
|
|
|
|
OccurredAt: occurredAt,
|
|
|
|
|
RecordedAt: occurredAt.AddSeconds(1),
|
|
|
|
|
EventBody: eventBody,
|
|
|
|
|
EventHash: eventHash,
|
|
|
|
|
PreviousHash: previousHash,
|
|
|
|
|
MerkleLeafHash: merkleLeaf,
|
|
|
|
|
CanonicalJson: canonicalJson);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private JsonObject CreateCreatedPayload(decimal severity)
|
|
|
|
|
{
|
|
|
|
|
return new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["status"] = "affected",
|
|
|
|
|
["severity"] = severity
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private JsonObject CreateStatusPayload(string status)
|
|
|
|
|
{
|
|
|
|
|
return new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["status"] = status
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private JsonObject CreateCommentPayload(string comment)
|
|
|
|
|
{
|
|
|
|
|
return new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["comment"] = comment
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private FindingProjection? ReplayEvents(IEnumerable<LedgerEventRecord> events)
|
|
|
|
|
{
|
|
|
|
|
FindingProjection? current = null;
|
|
|
|
|
|
|
|
|
|
foreach (var record in events)
|
|
|
|
|
{
|
|
|
|
|
var evaluation = new PolicyEvaluationResult(
|
|
|
|
|
Status: null,
|
|
|
|
|
Severity: null,
|
|
|
|
|
RiskScore: null,
|
|
|
|
|
RiskSeverity: null,
|
|
|
|
|
RiskProfileVersion: null,
|
|
|
|
|
RiskExplanationId: null,
|
|
|
|
|
RiskEventSequence: null,
|
|
|
|
|
Labels: new JsonObject(),
|
|
|
|
|
ExplainRef: null,
|
|
|
|
|
Rationale: new JsonArray());
|
|
|
|
|
|
|
|
|
|
var result = LedgerProjectionReducer.Reduce(record, current, evaluation);
|
|
|
|
|
current = result.Projection;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string CreateCanonicalProjectionJson(FindingProjection projection)
|
|
|
|
|
{
|
|
|
|
|
// Create a deterministic JSON representation
|
|
|
|
|
var obj = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["tenantId"] = projection.TenantId,
|
|
|
|
|
["findingId"] = projection.FindingId,
|
|
|
|
|
["policyVersion"] = projection.PolicyVersion,
|
|
|
|
|
["status"] = projection.Status,
|
|
|
|
|
["severity"] = projection.Severity.HasValue ? JsonValue.Create(projection.Severity.Value) : null,
|
|
|
|
|
["riskScore"] = projection.RiskScore.HasValue ? JsonValue.Create(projection.RiskScore.Value) : null,
|
|
|
|
|
["riskSeverity"] = projection.RiskSeverity,
|
|
|
|
|
["cycleHash"] = projection.CycleHash
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return JsonSerializer.Serialize(obj, new JsonSerializerOptions
|
|
|
|
|
{
|
|
|
|
|
WriteIndented = false,
|
|
|
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|