new two advisories and sprints work on them

This commit is contained in:
master
2026-01-16 18:39:36 +02:00
parent 9daf619954
commit c3a6269d55
72 changed files with 15540 additions and 18 deletions

View File

@@ -0,0 +1,497 @@
// -----------------------------------------------------------------------------
// VexRekorAttestationFlowTests.cs
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
// Task: VRL-010 - Integration tests for VEX-Rekor attestation flow
// Description: End-to-end tests for VEX observation attestation and verification
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Excititor.Core.Observations;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Excititor.Attestation.Tests;
[Trait("Category", TestCategories.Integration)]
public sealed class VexRekorAttestationFlowTests
{
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryVexObservationStore _observationStore;
private readonly MockRekorClient _rekorClient;
public VexRekorAttestationFlowTests()
{
_timeProvider = new FakeTimeProvider(FixedTimestamp);
_observationStore = new InMemoryVexObservationStore();
_rekorClient = new MockRekorClient();
}
[Fact]
public async Task AttestObservation_CreatesRekorEntry_UpdatesLinkage()
{
// Arrange
var observation = CreateTestObservation("obs-001");
await _observationStore.InsertAsync(observation, CancellationToken.None);
var service = CreateService();
// Act
var result = await service.AttestAsync("default", "obs-001", CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.RekorEntryId.Should().NotBeNullOrEmpty();
result.LogIndex.Should().BeGreaterThan(0);
// Verify linkage was updated
var updated = await _observationStore.GetByIdAsync("default", "obs-001", CancellationToken.None);
updated.Should().NotBeNull();
updated!.RekorUuid.Should().Be(result.RekorEntryId);
updated.RekorLogIndex.Should().Be(result.LogIndex);
}
[Fact]
public async Task AttestObservation_AlreadyAttested_ReturnsExisting()
{
// Arrange
var observation = CreateTestObservation("obs-002") with
{
RekorUuid = "existing-uuid-12345678",
RekorLogIndex = 999
};
await _observationStore.UpsertAsync(observation, CancellationToken.None);
var service = CreateService();
// Act
var result = await service.AttestAsync("default", "obs-002", CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
result.AlreadyAttested.Should().BeTrue();
result.RekorEntryId.Should().Be("existing-uuid-12345678");
}
[Fact]
public async Task AttestObservation_NotFound_ReturnsFailure()
{
// Arrange
var service = CreateService();
// Act
var result = await service.AttestAsync("default", "nonexistent", CancellationToken.None);
// Assert
result.Success.Should().BeFalse();
result.ErrorCode.Should().Be("OBSERVATION_NOT_FOUND");
}
[Fact]
public async Task VerifyRekorLinkage_ValidLinkage_ReturnsSuccess()
{
// Arrange
var observation = CreateTestObservation("obs-003") with
{
RekorUuid = "valid-uuid-12345678",
RekorLogIndex = 12345,
RekorIntegratedTime = FixedTimestamp.AddMinutes(-5),
RekorInclusionProof = CreateTestInclusionProof()
};
await _observationStore.UpsertAsync(observation, CancellationToken.None);
_rekorClient.SetupValidEntry("valid-uuid-12345678", 12345);
var service = CreateService();
// Act
var result = await service.VerifyRekorLinkageAsync("default", "obs-003", CancellationToken.None);
// Assert
result.IsVerified.Should().BeTrue();
result.InclusionProofValid.Should().BeTrue();
result.SignatureValid.Should().BeTrue();
}
[Fact]
public async Task VerifyRekorLinkage_NoLinkage_ReturnsNotLinked()
{
// Arrange
var observation = CreateTestObservation("obs-004");
await _observationStore.InsertAsync(observation, CancellationToken.None);
var service = CreateService();
// Act
var result = await service.VerifyRekorLinkageAsync("default", "obs-004", CancellationToken.None);
// Assert
result.IsVerified.Should().BeFalse();
result.FailureReason.Should().Contain("not linked");
}
[Fact]
public async Task VerifyRekorLinkage_Offline_UsesStoredProof()
{
// Arrange
var observation = CreateTestObservation("obs-005") with
{
RekorUuid = "offline-uuid-12345678",
RekorLogIndex = 12346,
RekorIntegratedTime = FixedTimestamp.AddMinutes(-10),
RekorInclusionProof = CreateTestInclusionProof()
};
await _observationStore.UpsertAsync(observation, CancellationToken.None);
// Disconnect Rekor (simulate offline)
_rekorClient.SetOffline(true);
var service = CreateService();
// Act
var result = await service.VerifyRekorLinkageAsync(
"default", "obs-005",
verifyOnline: false,
CancellationToken.None);
// Assert
result.IsVerified.Should().BeTrue();
result.VerificationMode.Should().Be("offline");
}
[Fact]
public async Task AttestBatch_MultipleObservations_AttestsAll()
{
// Arrange
var observations = Enumerable.Range(1, 5)
.Select(i => CreateTestObservation($"batch-obs-{i:D3}"))
.ToList();
foreach (var obs in observations)
{
await _observationStore.InsertAsync(obs, CancellationToken.None);
}
var service = CreateService();
var ids = observations.Select(o => o.ObservationId).ToList();
// Act
var results = await service.AttestBatchAsync("default", ids, CancellationToken.None);
// Assert
results.TotalCount.Should().Be(5);
results.SuccessCount.Should().Be(5);
results.FailureCount.Should().Be(0);
}
[Fact]
public async Task GetPendingAttestations_ReturnsUnlinkedObservations()
{
// Arrange
var linkedObs = CreateTestObservation("linked-001") with
{
RekorUuid = "already-linked",
RekorLogIndex = 100
};
var unlinkedObs1 = CreateTestObservation("unlinked-001");
var unlinkedObs2 = CreateTestObservation("unlinked-002");
await _observationStore.UpsertAsync(linkedObs, CancellationToken.None);
await _observationStore.InsertAsync(unlinkedObs1, CancellationToken.None);
await _observationStore.InsertAsync(unlinkedObs2, CancellationToken.None);
var service = CreateService();
// Act
var pending = await service.GetPendingAttestationsAsync("default", 10, CancellationToken.None);
// Assert
pending.Should().HaveCount(2);
pending.Select(p => p.ObservationId).Should().Contain("unlinked-001");
pending.Select(p => p.ObservationId).Should().Contain("unlinked-002");
pending.Select(p => p.ObservationId).Should().NotContain("linked-001");
}
[Fact]
public async Task AttestObservation_StoresInclusionProof()
{
// Arrange
var observation = CreateTestObservation("obs-proof-001");
await _observationStore.InsertAsync(observation, CancellationToken.None);
var service = CreateService(storeInclusionProof: true);
// Act
var result = await service.AttestAsync("default", "obs-proof-001", CancellationToken.None);
// Assert
result.Success.Should().BeTrue();
var updated = await _observationStore.GetByIdAsync("default", "obs-proof-001", CancellationToken.None);
updated!.RekorInclusionProof.Should().NotBeNull();
updated.RekorInclusionProof!.Hashes.Should().NotBeEmpty();
}
[Fact]
public async Task VerifyRekorLinkage_TamperedEntry_DetectsInconsistency()
{
// Arrange
var observation = CreateTestObservation("obs-tampered") with
{
RekorUuid = "tampered-uuid",
RekorLogIndex = 12347,
RekorIntegratedTime = FixedTimestamp.AddMinutes(-5)
};
await _observationStore.UpsertAsync(observation, CancellationToken.None);
// Setup Rekor to return different data than what was stored
_rekorClient.SetupTamperedEntry("tampered-uuid", 12347);
var service = CreateService();
// Act
var result = await service.VerifyRekorLinkageAsync("default", "obs-tampered", CancellationToken.None);
// Assert
result.IsVerified.Should().BeFalse();
result.FailureReason.Should().Contain("mismatch");
}
// Helper methods
private IVexObservationAttestationService CreateService(bool storeInclusionProof = false)
{
return new VexObservationAttestationService(
_observationStore,
_rekorClient,
Options.Create(new VexAttestationOptions
{
StoreInclusionProof = storeInclusionProof,
RekorUrl = "https://rekor.sigstore.dev"
}),
_timeProvider,
NullLogger<VexObservationAttestationService>.Instance);
}
private VexObservation CreateTestObservation(string id)
{
return new VexObservation(
observationId: id,
tenant: "default",
providerId: "test-provider",
streamId: "test-stream",
upstream: new VexObservationUpstream(
url: "https://example.com/vex",
etag: "etag-123",
lastModified: FixedTimestamp.AddDays(-1),
format: "csaf",
fetchedAt: FixedTimestamp),
statements: ImmutableArray.Create(
new VexObservationStatement(
vulnerabilityId: "CVE-2026-0001",
productKey: "pkg:example/test@1.0",
status: "not_affected",
justification: "code_not_present",
actionStatement: null,
impact: null,
timestamp: FixedTimestamp.AddDays(-1))),
content: new VexObservationContent(
raw: """{"test": "content"}""",
mediaType: "application/json",
encoding: "utf-8",
signature: null),
linkset: new VexObservationLinkset(
advisoryLinks: ImmutableArray<VexObservationReference>.Empty,
productLinks: ImmutableArray<VexObservationReference>.Empty,
vulnerabilityLinks: ImmutableArray<VexObservationReference>.Empty),
createdAt: FixedTimestamp);
}
private static VexInclusionProof CreateTestInclusionProof()
{
return new VexInclusionProof(
TreeSize: 100000,
RootHash: "dGVzdC1yb290LWhhc2g=",
LogIndex: 12345,
Hashes: ImmutableArray.Create(
"aGFzaDE=",
"aGFzaDI=",
"aGFzaDM="));
}
}
// Supporting types for tests
public record VexInclusionProof(
long TreeSize,
string RootHash,
long LogIndex,
ImmutableArray<string> Hashes);
public sealed class InMemoryVexObservationStore : IVexObservationStore
{
private readonly Dictionary<(string Tenant, string Id), VexObservation> _store = new();
public ValueTask<bool> InsertAsync(VexObservation observation, CancellationToken ct)
{
var key = (observation.Tenant, observation.ObservationId);
if (_store.ContainsKey(key)) return ValueTask.FromResult(false);
_store[key] = observation;
return ValueTask.FromResult(true);
}
public ValueTask<bool> UpsertAsync(VexObservation observation, CancellationToken ct)
{
var key = (observation.Tenant, observation.ObservationId);
_store[key] = observation;
return ValueTask.FromResult(true);
}
public ValueTask<int> InsertManyAsync(string tenant, IEnumerable<VexObservation> observations, CancellationToken ct)
{
var count = 0;
foreach (var obs in observations.Where(o => o.Tenant == tenant))
{
var key = (obs.Tenant, obs.ObservationId);
if (!_store.ContainsKey(key))
{
_store[key] = obs;
count++;
}
}
return ValueTask.FromResult(count);
}
public ValueTask<VexObservation?> GetByIdAsync(string tenant, string observationId, CancellationToken ct)
{
_store.TryGetValue((tenant, observationId), out var obs);
return ValueTask.FromResult(obs);
}
public ValueTask<IReadOnlyList<VexObservation>> FindByVulnerabilityAndProductAsync(
string tenant, string vulnerabilityId, string productKey, CancellationToken ct)
{
var results = _store.Values
.Where(o => o.Tenant == tenant)
.Where(o => o.Statements.Any(s => s.VulnerabilityId == vulnerabilityId && s.ProductKey == productKey))
.ToList();
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
}
public ValueTask<IReadOnlyList<VexObservation>> FindByProviderAsync(
string tenant, string providerId, int limit, CancellationToken ct)
{
var results = _store.Values
.Where(o => o.Tenant == tenant && o.ProviderId == providerId)
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
}
public ValueTask<bool> DeleteAsync(string tenant, string observationId, CancellationToken ct)
{
return ValueTask.FromResult(_store.Remove((tenant, observationId)));
}
public ValueTask<long> CountAsync(string tenant, CancellationToken ct)
{
var count = _store.Values.Count(o => o.Tenant == tenant);
return ValueTask.FromResult((long)count);
}
public ValueTask<bool> UpdateRekorLinkageAsync(
string tenant, string observationId, RekorLinkage linkage, CancellationToken ct)
{
if (!_store.TryGetValue((tenant, observationId), out var obs))
return ValueTask.FromResult(false);
_store[(tenant, observationId)] = obs with
{
RekorUuid = linkage.EntryUuid,
RekorLogIndex = linkage.LogIndex,
RekorIntegratedTime = linkage.IntegratedTime,
RekorLogUrl = linkage.LogUrl
};
return ValueTask.FromResult(true);
}
public ValueTask<IReadOnlyList<VexObservation>> GetPendingRekorAttestationAsync(
string tenant, int limit, CancellationToken ct)
{
var results = _store.Values
.Where(o => o.Tenant == tenant && string.IsNullOrEmpty(o.RekorUuid))
.Take(limit)
.ToList();
return ValueTask.FromResult<IReadOnlyList<VexObservation>>(results);
}
public ValueTask<VexObservation?> GetByRekorUuidAsync(string tenant, string rekorUuid, CancellationToken ct)
{
var obs = _store.Values.FirstOrDefault(o => o.Tenant == tenant && o.RekorUuid == rekorUuid);
return ValueTask.FromResult(obs);
}
}
public sealed class MockRekorClient
{
private readonly Dictionary<string, (long LogIndex, bool Valid, bool Tampered)> _entries = new();
private bool _offline;
private long _nextLogIndex = 10000;
public void SetupValidEntry(string uuid, long logIndex)
{
_entries[uuid] = (logIndex, true, false);
}
public void SetupTamperedEntry(string uuid, long logIndex)
{
_entries[uuid] = (logIndex, false, true);
}
public void SetOffline(bool offline)
{
_offline = offline;
}
public Task<RekorSubmitResult> SubmitAsync(byte[] payload, CancellationToken ct)
{
if (_offline)
{
return Task.FromResult(new RekorSubmitResult(false, null, 0, "offline"));
}
var uuid = Guid.NewGuid().ToString("N");
var logIndex = _nextLogIndex++;
_entries[uuid] = (logIndex, true, false);
return Task.FromResult(new RekorSubmitResult(true, uuid, logIndex, null));
}
public Task<RekorVerifyResult> VerifyAsync(string uuid, CancellationToken ct)
{
if (_offline)
{
return Task.FromResult(new RekorVerifyResult(false, "offline", null, null));
}
if (_entries.TryGetValue(uuid, out var entry))
{
if (entry.Tampered)
{
return Task.FromResult(new RekorVerifyResult(false, "hash mismatch", null, null));
}
return Task.FromResult(new RekorVerifyResult(true, null, true, true));
}
return Task.FromResult(new RekorVerifyResult(false, "entry not found", null, null));
}
}
public record RekorSubmitResult(bool Success, string? EntryId, long LogIndex, string? Error);
public record RekorVerifyResult(bool IsVerified, string? FailureReason, bool? SignatureValid, bool? InclusionProofValid);