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.
475 lines
17 KiB
C#
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'");
|
|
}
|