Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger/Services/AttestationPointerService.cs
StellaOps Bot 965cbf9574
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
Add unit tests for PhpFrameworkSurface and PhpPharScanner
- 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.
2025-12-07 13:44:13 +02:00

475 lines
17 KiB
C#

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'");
}