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.
499 lines
20 KiB
C#
499 lines
20 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using StellaOps.Findings.Ledger.Domain;
|
|
using StellaOps.Findings.Ledger.Infrastructure;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
|
using StellaOps.Findings.Ledger.Services;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Findings.Ledger.Tests.Attestation;
|
|
|
|
public class AttestationPointerServiceTests
|
|
{
|
|
private readonly Mock<ILedgerEventRepository> _ledgerEventRepository;
|
|
private readonly Mock<ILedgerEventWriteService> _writeService;
|
|
private readonly InMemoryAttestationPointerRepository _repository;
|
|
private readonly FakeTimeProvider _timeProvider;
|
|
private readonly AttestationPointerService _service;
|
|
|
|
public AttestationPointerServiceTests()
|
|
{
|
|
_ledgerEventRepository = new Mock<ILedgerEventRepository>();
|
|
_writeService = new Mock<ILedgerEventWriteService>();
|
|
_repository = new InMemoryAttestationPointerRepository();
|
|
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
|
|
|
_writeService.Setup(w => w.AppendAsync(It.IsAny<LedgerEventDraft>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((LedgerEventDraft draft, CancellationToken _) =>
|
|
{
|
|
var record = new LedgerEventRecord(
|
|
draft.TenantId,
|
|
draft.ChainId,
|
|
draft.SequenceNumber,
|
|
draft.EventId,
|
|
draft.EventType,
|
|
draft.PolicyVersion,
|
|
draft.FindingId,
|
|
draft.ArtifactId,
|
|
draft.SourceRunId,
|
|
draft.ActorId,
|
|
draft.ActorType,
|
|
draft.OccurredAt,
|
|
draft.RecordedAt,
|
|
draft.Payload,
|
|
"event-hash",
|
|
draft.ProvidedPreviousHash ?? LedgerEventConstants.EmptyHash,
|
|
"merkle-leaf-hash",
|
|
draft.CanonicalEnvelope.ToJsonString());
|
|
return LedgerWriteResult.Success(record);
|
|
});
|
|
|
|
_service = new AttestationPointerService(
|
|
_ledgerEventRepository.Object,
|
|
_writeService.Object,
|
|
_repository,
|
|
_timeProvider,
|
|
NullLogger<AttestationPointerService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePointer_CreatesNewPointer()
|
|
{
|
|
var input = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-123",
|
|
AttestationType: AttestationType.DsseEnvelope,
|
|
Relationship: AttestationRelationship.VerifiedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
|
|
AttestationId: Guid.NewGuid(),
|
|
StorageUri: "s3://attestations/test.json",
|
|
PayloadType: "application/vnd.in-toto+json",
|
|
PredicateType: "https://slsa.dev/provenance/v1"),
|
|
VerificationResult: new VerificationResult(
|
|
Verified: true,
|
|
VerifiedAt: _timeProvider.GetUtcNow(),
|
|
Verifier: "stellaops-attestor",
|
|
VerifierVersion: "2025.01.0"),
|
|
CreatedBy: "test-system");
|
|
|
|
var result = await _service.CreatePointerAsync(input);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.PointerId);
|
|
Assert.NotNull(result.LedgerEventId);
|
|
Assert.Null(result.Error);
|
|
|
|
var saved = await _repository.GetByIdAsync("tenant-1", result.PointerId!.Value, CancellationToken.None);
|
|
Assert.NotNull(saved);
|
|
Assert.Equal(input.FindingId, saved!.FindingId);
|
|
Assert.Equal(input.AttestationType, saved.AttestationType);
|
|
Assert.Equal(input.AttestationRef.Digest, saved.AttestationRef.Digest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePointer_IsIdempotent()
|
|
{
|
|
var input = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-456",
|
|
AttestationType: AttestationType.SlsaProvenance,
|
|
Relationship: AttestationRelationship.AttestedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:def456789012345678901234567890123456789012345678901234567890abcd"),
|
|
CreatedBy: "test-system");
|
|
|
|
var result1 = await _service.CreatePointerAsync(input);
|
|
var result2 = await _service.CreatePointerAsync(input);
|
|
|
|
Assert.True(result1.Success);
|
|
Assert.True(result2.Success);
|
|
Assert.Equal(result1.PointerId, result2.PointerId);
|
|
|
|
var pointers = await _repository.GetByFindingIdAsync("tenant-1", "finding-456", CancellationToken.None);
|
|
Assert.Single(pointers);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPointers_ReturnsAllPointersForFinding()
|
|
{
|
|
var input1 = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-multi",
|
|
AttestationType: AttestationType.DsseEnvelope,
|
|
Relationship: AttestationRelationship.VerifiedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:aaa111222333444555666777888999000111222333444555666777888999000a"));
|
|
|
|
var input2 = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-multi",
|
|
AttestationType: AttestationType.VexAttestation,
|
|
Relationship: AttestationRelationship.DerivedFrom,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:bbb111222333444555666777888999000111222333444555666777888999000b"));
|
|
|
|
await _service.CreatePointerAsync(input1);
|
|
await _service.CreatePointerAsync(input2);
|
|
|
|
var pointers = await _service.GetPointersAsync("tenant-1", "finding-multi");
|
|
|
|
Assert.Equal(2, pointers.Count);
|
|
Assert.Contains(pointers, p => p.AttestationType == AttestationType.DsseEnvelope);
|
|
Assert.Contains(pointers, p => p.AttestationType == AttestationType.VexAttestation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSummary_CalculatesCorrectCounts()
|
|
{
|
|
var verified = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-summary",
|
|
AttestationType: AttestationType.DsseEnvelope,
|
|
Relationship: AttestationRelationship.VerifiedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:ver111222333444555666777888999000111222333444555666777888999000a"),
|
|
VerificationResult: new VerificationResult(Verified: true, VerifiedAt: _timeProvider.GetUtcNow()));
|
|
|
|
var unverified = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-summary",
|
|
AttestationType: AttestationType.SbomAttestation,
|
|
Relationship: AttestationRelationship.DerivedFrom,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:unv111222333444555666777888999000111222333444555666777888999000b"));
|
|
|
|
await _service.CreatePointerAsync(verified);
|
|
await _service.CreatePointerAsync(unverified);
|
|
|
|
var summary = await _service.GetSummaryAsync("tenant-1", "finding-summary");
|
|
|
|
Assert.Equal("finding-summary", summary.FindingId);
|
|
Assert.Equal(2, summary.AttestationCount);
|
|
Assert.Equal(1, summary.VerifiedCount);
|
|
Assert.Equal(OverallVerificationStatus.PartiallyVerified, summary.OverallVerificationStatus);
|
|
Assert.Contains(AttestationType.DsseEnvelope, summary.AttestationTypes);
|
|
Assert.Contains(AttestationType.SbomAttestation, summary.AttestationTypes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Search_FiltersByAttestationType()
|
|
{
|
|
var input1 = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-search-1",
|
|
AttestationType: AttestationType.DsseEnvelope,
|
|
Relationship: AttestationRelationship.VerifiedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:sea111222333444555666777888999000111222333444555666777888999000a"));
|
|
|
|
var input2 = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-search-2",
|
|
AttestationType: AttestationType.SlsaProvenance,
|
|
Relationship: AttestationRelationship.AttestedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:sea222333444555666777888999000111222333444555666777888999000111b"));
|
|
|
|
await _service.CreatePointerAsync(input1);
|
|
await _service.CreatePointerAsync(input2);
|
|
|
|
var query = new AttestationPointerQuery(
|
|
TenantId: "tenant-1",
|
|
AttestationTypes: new[] { AttestationType.DsseEnvelope });
|
|
|
|
var results = await _service.SearchAsync(query);
|
|
|
|
Assert.Single(results);
|
|
Assert.Equal(AttestationType.DsseEnvelope, results[0].AttestationType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateVerificationResult_UpdatesExistingPointer()
|
|
{
|
|
var input = new AttestationPointerInput(
|
|
TenantId: "tenant-1",
|
|
FindingId: "finding-update",
|
|
AttestationType: AttestationType.DsseEnvelope,
|
|
Relationship: AttestationRelationship.VerifiedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:upd111222333444555666777888999000111222333444555666777888999000a"));
|
|
|
|
var createResult = await _service.CreatePointerAsync(input);
|
|
Assert.True(createResult.Success);
|
|
|
|
var verificationResult = new VerificationResult(
|
|
Verified: true,
|
|
VerifiedAt: _timeProvider.GetUtcNow(),
|
|
Verifier: "external-verifier",
|
|
VerifierVersion: "1.0.0",
|
|
Checks: new[]
|
|
{
|
|
new VerificationCheck(VerificationCheckType.SignatureValid, true, "ECDSA verified"),
|
|
new VerificationCheck(VerificationCheckType.CertificateValid, true, "Chain verified")
|
|
});
|
|
|
|
var success = await _service.UpdateVerificationResultAsync(
|
|
"tenant-1", createResult.PointerId!.Value, verificationResult);
|
|
|
|
Assert.True(success);
|
|
|
|
var updated = await _repository.GetByIdAsync("tenant-1", createResult.PointerId!.Value, CancellationToken.None);
|
|
Assert.NotNull(updated?.VerificationResult);
|
|
Assert.True(updated.VerificationResult!.Verified);
|
|
Assert.Equal("external-verifier", updated.VerificationResult.Verifier);
|
|
Assert.Equal(2, updated.VerificationResult.Checks!.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TenantIsolation_PreventsAccessAcrossTenants()
|
|
{
|
|
var input = new AttestationPointerInput(
|
|
TenantId: "tenant-a",
|
|
FindingId: "finding-isolated",
|
|
AttestationType: AttestationType.DsseEnvelope,
|
|
Relationship: AttestationRelationship.VerifiedBy,
|
|
AttestationRef: new AttestationRef(
|
|
Digest: "sha256:iso111222333444555666777888999000111222333444555666777888999000a"));
|
|
|
|
var result = await _service.CreatePointerAsync(input);
|
|
Assert.True(result.Success);
|
|
|
|
var fromTenantA = await _service.GetPointersAsync("tenant-a", "finding-isolated");
|
|
var fromTenantB = await _service.GetPointersAsync("tenant-b", "finding-isolated");
|
|
|
|
Assert.Single(fromTenantA);
|
|
Assert.Empty(fromTenantB);
|
|
}
|
|
|
|
private sealed class FakeTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _now;
|
|
|
|
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation for testing.
|
|
/// </summary>
|
|
internal sealed class InMemoryAttestationPointerRepository : IAttestationPointerRepository
|
|
{
|
|
private readonly List<AttestationPointerRecord> _records = new();
|
|
private readonly object _lock = new();
|
|
|
|
public Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_records.Add(record);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<AttestationPointerRecord?> GetByIdAsync(string tenantId, Guid pointerId, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var result = _records.FirstOrDefault(r => r.TenantId == tenantId && r.PointerId == pointerId);
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var results = _records
|
|
.Where(r => r.TenantId == tenantId && r.FindingId == findingId)
|
|
.OrderByDescending(r => r.CreatedAt)
|
|
.ToList();
|
|
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(results);
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(string tenantId, string digest, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var results = _records
|
|
.Where(r => r.TenantId == tenantId && r.AttestationRef.Digest == digest)
|
|
.OrderByDescending(r => r.CreatedAt)
|
|
.ToList();
|
|
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(results);
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(AttestationPointerQuery query, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var results = _records.Where(r => r.TenantId == query.TenantId);
|
|
|
|
if (query.FindingIds is { Count: > 0 })
|
|
{
|
|
results = results.Where(r => query.FindingIds.Contains(r.FindingId));
|
|
}
|
|
|
|
if (query.AttestationTypes is { Count: > 0 })
|
|
{
|
|
results = results.Where(r => query.AttestationTypes.Contains(r.AttestationType));
|
|
}
|
|
|
|
if (query.VerificationStatus.HasValue)
|
|
{
|
|
results = query.VerificationStatus.Value switch
|
|
{
|
|
AttestationVerificationFilter.Verified =>
|
|
results.Where(r => r.VerificationResult?.Verified == true),
|
|
AttestationVerificationFilter.Unverified =>
|
|
results.Where(r => r.VerificationResult is null),
|
|
AttestationVerificationFilter.Failed =>
|
|
results.Where(r => r.VerificationResult?.Verified == false),
|
|
_ => results
|
|
};
|
|
}
|
|
|
|
if (query.CreatedAfter.HasValue)
|
|
{
|
|
results = results.Where(r => r.CreatedAt >= query.CreatedAfter.Value);
|
|
}
|
|
|
|
if (query.CreatedBefore.HasValue)
|
|
{
|
|
results = results.Where(r => r.CreatedAt <= query.CreatedBefore.Value);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
|
|
{
|
|
results = results.Where(r => r.AttestationRef.SignerInfo?.Subject == query.SignerIdentity);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.PredicateType))
|
|
{
|
|
results = results.Where(r => r.AttestationRef.PredicateType == query.PredicateType);
|
|
}
|
|
|
|
var list = results
|
|
.OrderByDescending(r => r.CreatedAt)
|
|
.Skip(query.Offset)
|
|
.Take(query.Limit)
|
|
.ToList();
|
|
|
|
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(list);
|
|
}
|
|
}
|
|
|
|
public Task<FindingAttestationSummary> GetSummaryAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var pointers = _records.Where(r => r.TenantId == tenantId && r.FindingId == findingId).ToList();
|
|
|
|
if (pointers.Count == 0)
|
|
{
|
|
return Task.FromResult(new FindingAttestationSummary(
|
|
findingId, 0, 0, null, Array.Empty<AttestationType>(), OverallVerificationStatus.NoAttestations));
|
|
}
|
|
|
|
var verifiedCount = pointers.Count(p => p.VerificationResult?.Verified == true);
|
|
var latest = pointers.Max(p => p.CreatedAt);
|
|
var types = pointers.Select(p => p.AttestationType).Distinct().ToList();
|
|
|
|
var status = pointers.Count switch
|
|
{
|
|
0 => OverallVerificationStatus.NoAttestations,
|
|
_ when verifiedCount == pointers.Count => OverallVerificationStatus.AllVerified,
|
|
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
|
_ => OverallVerificationStatus.NoneVerified
|
|
};
|
|
|
|
return Task.FromResult(new FindingAttestationSummary(
|
|
findingId, pointers.Count, verifiedCount, latest, types, status));
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(string tenantId, IReadOnlyList<string> findingIds, CancellationToken cancellationToken)
|
|
{
|
|
var tasks = findingIds.Select(fid => GetSummaryAsync(tenantId, fid, cancellationToken));
|
|
return Task.WhenAll(tasks).ContinueWith(t => (IReadOnlyList<FindingAttestationSummary>)t.Result.ToList());
|
|
}
|
|
|
|
public Task<bool> ExistsAsync(string tenantId, string findingId, string digest, AttestationType attestationType, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var exists = _records.Any(r =>
|
|
r.TenantId == tenantId &&
|
|
r.FindingId == findingId &&
|
|
r.AttestationRef.Digest == digest &&
|
|
r.AttestationType == attestationType);
|
|
return Task.FromResult(exists);
|
|
}
|
|
}
|
|
|
|
public Task UpdateVerificationResultAsync(string tenantId, Guid pointerId, VerificationResult verificationResult, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var idx = _records.FindIndex(r => r.TenantId == tenantId && r.PointerId == pointerId);
|
|
if (idx >= 0)
|
|
{
|
|
var old = _records[idx];
|
|
_records[idx] = old with { VerificationResult = verificationResult };
|
|
}
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<int> GetCountAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var count = _records.Count(r => r.TenantId == tenantId && r.FindingId == findingId);
|
|
return Task.FromResult(count);
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(string tenantId, AttestationVerificationFilter? verificationFilter, IReadOnlyList<AttestationType>? attestationTypes, int limit, int offset, CancellationToken cancellationToken)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var results = _records.Where(r => r.TenantId == tenantId);
|
|
|
|
if (attestationTypes is { Count: > 0 })
|
|
{
|
|
results = results.Where(r => attestationTypes.Contains(r.AttestationType));
|
|
}
|
|
|
|
if (verificationFilter.HasValue)
|
|
{
|
|
results = verificationFilter.Value switch
|
|
{
|
|
AttestationVerificationFilter.Verified =>
|
|
results.Where(r => r.VerificationResult?.Verified == true),
|
|
AttestationVerificationFilter.Unverified =>
|
|
results.Where(r => r.VerificationResult is null),
|
|
AttestationVerificationFilter.Failed =>
|
|
results.Where(r => r.VerificationResult?.Verified == false),
|
|
_ => results
|
|
};
|
|
}
|
|
|
|
var list = results
|
|
.Select(r => r.FindingId)
|
|
.Distinct()
|
|
.OrderBy(f => f)
|
|
.Skip(offset)
|
|
.Take(limit)
|
|
.ToList();
|
|
|
|
return Task.FromResult<IReadOnlyList<string>>(list);
|
|
}
|
|
}
|
|
}
|