Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled

- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners.
- Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns.
- Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures.
- Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
StellaOps Bot
2025-12-07 13:44:13 +02:00
parent af30fc322f
commit 965cbf9574
49 changed files with 11935 additions and 152 deletions

View File

@@ -0,0 +1,474 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
using StellaOps.Findings.Ledger.Observability;
namespace StellaOps.Findings.Ledger.Services;
/// <summary>
/// Service for managing attestation pointers linking findings to verification reports and attestation envelopes.
/// </summary>
public sealed class AttestationPointerService
{
private readonly ILedgerEventRepository _ledgerEventRepository;
private readonly ILedgerEventWriteService _writeService;
private readonly IAttestationPointerRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AttestationPointerService> _logger;
public AttestationPointerService(
ILedgerEventRepository ledgerEventRepository,
ILedgerEventWriteService writeService,
IAttestationPointerRepository repository,
TimeProvider timeProvider,
ILogger<AttestationPointerService> logger)
{
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Creates an attestation pointer linking a finding to a verification report or attestation envelope.
/// </summary>
public async Task<AttestationPointerResult> CreatePointerAsync(
AttestationPointerInput input,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId);
ArgumentException.ThrowIfNullOrWhiteSpace(input.AttestationRef.Digest);
var now = _timeProvider.GetUtcNow();
var createdBy = input.CreatedBy ?? "attestation-linker";
// Check for idempotency
var exists = await _repository.ExistsAsync(
input.TenantId,
input.FindingId,
input.AttestationRef.Digest,
input.AttestationType,
cancellationToken).ConfigureAwait(false);
if (exists)
{
_logger.LogDebug(
"Attestation pointer already exists for finding {FindingId} with digest {Digest}",
input.FindingId, input.AttestationRef.Digest);
// Find and return the existing pointer
var existing = await _repository.GetByDigestAsync(
input.TenantId,
input.AttestationRef.Digest,
cancellationToken).ConfigureAwait(false);
var match = existing.FirstOrDefault(p =>
p.FindingId == input.FindingId && p.AttestationType == input.AttestationType);
return new AttestationPointerResult(true, match?.PointerId, match?.LedgerEventId, null);
}
var pointerId = Guid.NewGuid();
// Create ledger event for the attestation pointer
var chainId = LedgerChainIdGenerator.FromTenantSubject(
input.TenantId, $"attestation::{input.FindingId}");
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(
input.TenantId, chainId, cancellationToken).ConfigureAwait(false);
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
var eventId = Guid.NewGuid();
var attestationPayload = BuildAttestationPayload(input, pointerId);
var envelope = BuildEnvelope(eventId, input, chainId, sequence, now, attestationPayload);
var draft = new LedgerEventDraft(
input.TenantId,
chainId,
sequence,
eventId,
LedgerEventConstants.EventAttestationPointerLinked,
"attestation-pointer",
input.FindingId,
input.FindingId,
SourceRunId: null,
ActorId: createdBy,
ActorType: "system",
OccurredAt: now,
RecordedAt: now,
Payload: attestationPayload,
CanonicalEnvelope: envelope,
ProvidedPreviousHash: previousHash);
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
{
var error = string.Join(";", writeResult.Errors);
_logger.LogWarning(
"Failed to write ledger event for attestation pointer {PointerId}: {Error}",
pointerId, error);
return new AttestationPointerResult(false, null, null, error);
}
var ledgerEventId = writeResult.Record?.EventId;
var record = new AttestationPointerRecord(
input.TenantId,
pointerId,
input.FindingId,
input.AttestationType,
input.Relationship,
input.AttestationRef,
input.VerificationResult,
now,
createdBy,
input.Metadata,
ledgerEventId);
await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false);
LedgerTimeline.EmitAttestationPointerLinked(
_logger,
input.TenantId,
input.FindingId,
pointerId,
input.AttestationType.ToString(),
input.AttestationRef.Digest);
return new AttestationPointerResult(true, pointerId, ledgerEventId, null);
}
/// <summary>
/// Gets attestation pointers for a finding.
/// </summary>
public async Task<IReadOnlyList<AttestationPointerRecord>> GetPointersAsync(
string tenantId,
string findingId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
return await _repository.GetByFindingIdAsync(tenantId, findingId, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Gets an attestation pointer by ID.
/// </summary>
public async Task<AttestationPointerRecord?> GetPointerAsync(
string tenantId,
Guid pointerId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Searches attestation pointers.
/// </summary>
public async Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
AttestationPointerQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
return await _repository.SearchAsync(query, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets attestation summary for a finding.
/// </summary>
public async Task<FindingAttestationSummary> GetSummaryAsync(
string tenantId,
string findingId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
return await _repository.GetSummaryAsync(tenantId, findingId, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Gets attestation summaries for multiple findings.
/// </summary>
public async Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
string tenantId,
IReadOnlyList<string> findingIds,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(findingIds);
return await _repository.GetSummariesAsync(tenantId, findingIds, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Updates the verification result for an attestation pointer.
/// </summary>
public async Task<bool> UpdateVerificationResultAsync(
string tenantId,
Guid pointerId,
VerificationResult verificationResult,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(verificationResult);
var existing = await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
_logger.LogWarning(
"Attestation pointer {PointerId} not found for tenant {TenantId}",
pointerId, tenantId);
return false;
}
await _repository.UpdateVerificationResultAsync(
tenantId, pointerId, verificationResult, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Updated verification result for attestation pointer {PointerId}, verified={Verified}",
pointerId, verificationResult.Verified);
return true;
}
/// <summary>
/// Gets findings that have attestations matching the criteria.
/// </summary>
public async Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
string tenantId,
AttestationVerificationFilter? verificationFilter = null,
IReadOnlyList<AttestationType>? attestationTypes = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return await _repository.GetFindingIdsWithAttestationsAsync(
tenantId, verificationFilter, attestationTypes, limit, offset, cancellationToken)
.ConfigureAwait(false);
}
private static JsonObject BuildAttestationPayload(AttestationPointerInput input, Guid pointerId)
{
var attestationRefNode = new JsonObject
{
["digest"] = input.AttestationRef.Digest
};
if (input.AttestationRef.AttestationId.HasValue)
{
attestationRefNode["attestation_id"] = input.AttestationRef.AttestationId.Value.ToString();
}
if (!string.IsNullOrEmpty(input.AttestationRef.StorageUri))
{
attestationRefNode["storage_uri"] = input.AttestationRef.StorageUri;
}
if (!string.IsNullOrEmpty(input.AttestationRef.PayloadType))
{
attestationRefNode["payload_type"] = input.AttestationRef.PayloadType;
}
if (!string.IsNullOrEmpty(input.AttestationRef.PredicateType))
{
attestationRefNode["predicate_type"] = input.AttestationRef.PredicateType;
}
if (input.AttestationRef.SubjectDigests is { Count: > 0 })
{
var subjectsArray = new JsonArray();
foreach (var subject in input.AttestationRef.SubjectDigests)
{
subjectsArray.Add(subject);
}
attestationRefNode["subject_digests"] = subjectsArray;
}
if (input.AttestationRef.SignerInfo is not null)
{
var signerNode = new JsonObject();
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.KeyId))
{
signerNode["key_id"] = input.AttestationRef.SignerInfo.KeyId;
}
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Issuer))
{
signerNode["issuer"] = input.AttestationRef.SignerInfo.Issuer;
}
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Subject))
{
signerNode["subject"] = input.AttestationRef.SignerInfo.Subject;
}
if (input.AttestationRef.SignerInfo.SignedAt.HasValue)
{
signerNode["signed_at"] = FormatTimestamp(input.AttestationRef.SignerInfo.SignedAt.Value);
}
attestationRefNode["signer_info"] = signerNode;
}
if (input.AttestationRef.RekorEntry is not null)
{
var rekorNode = new JsonObject();
if (input.AttestationRef.RekorEntry.LogIndex.HasValue)
{
rekorNode["log_index"] = input.AttestationRef.RekorEntry.LogIndex.Value;
}
if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.LogId))
{
rekorNode["log_id"] = input.AttestationRef.RekorEntry.LogId;
}
if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.Uuid))
{
rekorNode["uuid"] = input.AttestationRef.RekorEntry.Uuid;
}
if (input.AttestationRef.RekorEntry.IntegratedTime.HasValue)
{
rekorNode["integrated_time"] = input.AttestationRef.RekorEntry.IntegratedTime.Value;
}
attestationRefNode["rekor_entry"] = rekorNode;
}
var pointerNode = new JsonObject
{
["pointer_id"] = pointerId.ToString(),
["attestation_type"] = input.AttestationType.ToString(),
["relationship"] = input.Relationship.ToString(),
["attestation_ref"] = attestationRefNode
};
if (input.VerificationResult is not null)
{
var verificationNode = new JsonObject
{
["verified"] = input.VerificationResult.Verified,
["verified_at"] = FormatTimestamp(input.VerificationResult.VerifiedAt)
};
if (!string.IsNullOrEmpty(input.VerificationResult.Verifier))
{
verificationNode["verifier"] = input.VerificationResult.Verifier;
}
if (!string.IsNullOrEmpty(input.VerificationResult.VerifierVersion))
{
verificationNode["verifier_version"] = input.VerificationResult.VerifierVersion;
}
if (!string.IsNullOrEmpty(input.VerificationResult.PolicyRef))
{
verificationNode["policy_ref"] = input.VerificationResult.PolicyRef;
}
if (input.VerificationResult.Checks is { Count: > 0 })
{
var checksArray = new JsonArray();
foreach (var check in input.VerificationResult.Checks)
{
var checkNode = new JsonObject
{
["check_type"] = check.CheckType.ToString(),
["passed"] = check.Passed
};
if (!string.IsNullOrEmpty(check.Details))
{
checkNode["details"] = check.Details;
}
checksArray.Add(checkNode);
}
verificationNode["checks"] = checksArray;
}
if (input.VerificationResult.Warnings is { Count: > 0 })
{
var warningsArray = new JsonArray();
foreach (var warning in input.VerificationResult.Warnings)
{
warningsArray.Add(warning);
}
verificationNode["warnings"] = warningsArray;
}
if (input.VerificationResult.Errors is { Count: > 0 })
{
var errorsArray = new JsonArray();
foreach (var error in input.VerificationResult.Errors)
{
errorsArray.Add(error);
}
verificationNode["errors"] = errorsArray;
}
pointerNode["verification_result"] = verificationNode;
}
return new JsonObject
{
["attestation"] = new JsonObject
{
["pointer"] = pointerNode
}
};
}
private static JsonObject BuildEnvelope(
Guid eventId,
AttestationPointerInput input,
Guid chainId,
long sequence,
DateTimeOffset now,
JsonObject payload)
{
return new JsonObject
{
["event"] = new JsonObject
{
["id"] = eventId.ToString(),
["type"] = LedgerEventConstants.EventAttestationPointerLinked,
["tenant"] = input.TenantId,
["chainId"] = chainId.ToString(),
["sequence"] = sequence,
["policyVersion"] = "attestation-pointer",
["artifactId"] = input.FindingId,
["finding"] = new JsonObject
{
["id"] = input.FindingId,
["artifactId"] = input.FindingId,
["vulnId"] = "attestation-pointer"
},
["actor"] = new JsonObject
{
["id"] = input.CreatedBy ?? "attestation-linker",
["type"] = "system"
},
["occurredAt"] = FormatTimestamp(now),
["recordedAt"] = FormatTimestamp(now),
["payload"] = payload.DeepClone()
}
};
}
private static string FormatTimestamp(DateTimeOffset value)
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
}

View File

@@ -0,0 +1,370 @@
namespace StellaOps.Findings.Ledger.Services;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
using StellaOps.Findings.Ledger.Observability;
/// <summary>
/// Service for managing ledger snapshots and time-travel queries.
/// </summary>
public sealed class SnapshotService
{
private readonly ISnapshotRepository _snapshotRepository;
private readonly ITimeTravelRepository _timeTravelRepository;
private readonly ILogger<SnapshotService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public SnapshotService(
ISnapshotRepository snapshotRepository,
ITimeTravelRepository timeTravelRepository,
ILogger<SnapshotService> logger)
{
_snapshotRepository = snapshotRepository;
_timeTravelRepository = timeTravelRepository;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <summary>
/// Creates a new snapshot of the ledger at the specified point.
/// </summary>
public async Task<CreateSnapshotResult> CreateSnapshotAsync(
CreateSnapshotInput input,
CancellationToken ct = default)
{
try
{
_logger.LogInformation(
"Creating snapshot for tenant {TenantId} at sequence {Sequence} / timestamp {Timestamp}",
input.TenantId,
input.AtSequence,
input.AtTimestamp);
// Get current ledger state
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(input.TenantId, ct);
// Create the snapshot record
var snapshot = await _snapshotRepository.CreateAsync(
input.TenantId,
input,
currentPoint.SequenceNumber,
currentPoint.Timestamp,
ct);
// Compute statistics asynchronously
var statistics = await ComputeStatisticsAsync(
input.TenantId,
snapshot.SequenceNumber,
input.IncludeEntityTypes,
ct);
await _snapshotRepository.UpdateStatisticsAsync(
input.TenantId,
snapshot.SnapshotId,
statistics,
ct);
// Compute Merkle root if signing is requested
string? merkleRoot = null;
string? dsseDigest = null;
if (input.Sign)
{
merkleRoot = await ComputeMerkleRootAsync(
input.TenantId,
snapshot.SequenceNumber,
ct);
await _snapshotRepository.SetMerkleRootAsync(
input.TenantId,
snapshot.SnapshotId,
merkleRoot,
dsseDigest,
ct);
}
// Mark as available
await _snapshotRepository.UpdateStatusAsync(
input.TenantId,
snapshot.SnapshotId,
SnapshotStatus.Available,
ct);
// Retrieve updated snapshot
var finalSnapshot = await _snapshotRepository.GetByIdAsync(
input.TenantId,
snapshot.SnapshotId,
ct);
LedgerTimeline.EmitSnapshotCreated(
_logger,
input.TenantId,
snapshot.SnapshotId,
snapshot.SequenceNumber,
statistics.FindingsCount);
return new CreateSnapshotResult(true, finalSnapshot, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create snapshot for tenant {TenantId}", input.TenantId);
return new CreateSnapshotResult(false, null, ex.Message);
}
}
/// <summary>
/// Gets a snapshot by ID.
/// </summary>
public async Task<LedgerSnapshot?> GetSnapshotAsync(
string tenantId,
Guid snapshotId,
CancellationToken ct = default)
{
return await _snapshotRepository.GetByIdAsync(tenantId, snapshotId, ct);
}
/// <summary>
/// Lists snapshots for a tenant.
/// </summary>
public async Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListSnapshotsAsync(
SnapshotListQuery query,
CancellationToken ct = default)
{
return await _snapshotRepository.ListAsync(query, ct);
}
/// <summary>
/// Deletes a snapshot.
/// </summary>
public async Task<bool> DeleteSnapshotAsync(
string tenantId,
Guid snapshotId,
CancellationToken ct = default)
{
var deleted = await _snapshotRepository.DeleteAsync(tenantId, snapshotId, ct);
if (deleted)
{
LedgerTimeline.EmitSnapshotDeleted(_logger, tenantId, snapshotId);
}
return deleted;
}
/// <summary>
/// Queries historical findings at a specific point in time.
/// </summary>
public async Task<HistoricalQueryResponse<FindingHistoryItem>> QueryHistoricalFindingsAsync(
HistoricalQueryRequest request,
CancellationToken ct = default)
{
return await _timeTravelRepository.QueryFindingsAsync(request, ct);
}
/// <summary>
/// Queries historical VEX statements at a specific point in time.
/// </summary>
public async Task<HistoricalQueryResponse<VexHistoryItem>> QueryHistoricalVexAsync(
HistoricalQueryRequest request,
CancellationToken ct = default)
{
return await _timeTravelRepository.QueryVexAsync(request, ct);
}
/// <summary>
/// Queries historical advisories at a specific point in time.
/// </summary>
public async Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryHistoricalAdvisoriesAsync(
HistoricalQueryRequest request,
CancellationToken ct = default)
{
return await _timeTravelRepository.QueryAdvisoriesAsync(request, ct);
}
/// <summary>
/// Replays events within a specified range.
/// </summary>
public async Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
ReplayRequest request,
CancellationToken ct = default)
{
return await _timeTravelRepository.ReplayEventsAsync(request, ct);
}
/// <summary>
/// Computes diff between two points in time.
/// </summary>
public async Task<DiffResponse> ComputeDiffAsync(
DiffRequest request,
CancellationToken ct = default)
{
return await _timeTravelRepository.ComputeDiffAsync(request, ct);
}
/// <summary>
/// Gets changelog for an entity.
/// </summary>
public async Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
string tenantId,
EntityType entityType,
string entityId,
int limit = 100,
CancellationToken ct = default)
{
return await _timeTravelRepository.GetChangelogAsync(tenantId, entityType, entityId, limit, ct);
}
/// <summary>
/// Checks staleness of ledger data.
/// </summary>
public async Task<StalenessResult> CheckStalenessAsync(
string tenantId,
TimeSpan threshold,
CancellationToken ct = default)
{
return await _timeTravelRepository.CheckStalenessAsync(tenantId, threshold, ct);
}
/// <summary>
/// Gets the current query point (latest sequence and timestamp).
/// </summary>
public async Task<QueryPoint> GetCurrentPointAsync(
string tenantId,
CancellationToken ct = default)
{
return await _timeTravelRepository.GetCurrentPointAsync(tenantId, ct);
}
/// <summary>
/// Expires old snapshots.
/// </summary>
public async Task<int> ExpireOldSnapshotsAsync(CancellationToken ct = default)
{
var cutoff = DateTimeOffset.UtcNow;
var count = await _snapshotRepository.ExpireSnapshotsAsync(cutoff, ct);
if (count > 0)
{
_logger.LogInformation("Expired {Count} snapshots", count);
}
return count;
}
private async Task<SnapshotStatistics> ComputeStatisticsAsync(
string tenantId,
long atSequence,
IReadOnlyList<EntityType>? entityTypes,
CancellationToken ct)
{
// Query counts from time-travel repository
var findingsResult = await _timeTravelRepository.QueryFindingsAsync(
new HistoricalQueryRequest(
tenantId,
null,
atSequence,
null,
EntityType.Finding,
null,
1),
ct);
var vexResult = await _timeTravelRepository.QueryVexAsync(
new HistoricalQueryRequest(
tenantId,
null,
atSequence,
null,
EntityType.Vex,
null,
1),
ct);
var advisoryResult = await _timeTravelRepository.QueryAdvisoriesAsync(
new HistoricalQueryRequest(
tenantId,
null,
atSequence,
null,
EntityType.Advisory,
null,
1),
ct);
// Get event count
var (events, _) = await _timeTravelRepository.ReplayEventsAsync(
new ReplayRequest(tenantId, ToSequence: atSequence, IncludePayload: false, PageSize: 1),
ct);
// Note: These are approximations; actual counting would need dedicated queries
return new SnapshotStatistics(
FindingsCount: findingsResult.TotalCount,
VexStatementsCount: vexResult.TotalCount,
AdvisoriesCount: advisoryResult.TotalCount,
SbomsCount: 0, // Would need separate SBOM tracking
EventsCount: atSequence,
SizeBytes: 0); // Would need to compute actual storage size
}
private async Task<string> ComputeMerkleRootAsync(
string tenantId,
long atSequence,
CancellationToken ct)
{
// Get all event hashes up to the sequence
var (events, _) = await _timeTravelRepository.ReplayEventsAsync(
new ReplayRequest(
tenantId,
ToSequence: atSequence,
IncludePayload: false,
PageSize: 10000),
ct);
if (events.Count == 0)
{
return ComputeHash("empty");
}
// Build Merkle tree from event hashes
var hashes = events.Select(e => e.EventHash).ToList();
return ComputeMerkleRoot(hashes);
}
private static string ComputeMerkleRoot(List<string> hashes)
{
if (hashes.Count == 0)
return ComputeHash("empty");
if (hashes.Count == 1)
return hashes[0];
var nextLevel = new List<string>();
for (int i = 0; i < hashes.Count; i += 2)
{
if (i + 1 < hashes.Count)
{
nextLevel.Add(ComputeHash(hashes[i] + hashes[i + 1]));
}
else
{
nextLevel.Add(hashes[i]);
}
}
return ComputeMerkleRoot(nextLevel);
}
private static string ComputeHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
}