save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -0,0 +1,31 @@
// JsonCanonicalizerTests - RFC 8785 canonicalization tests for TrustVerdict
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using FluentAssertions;
using StellaOps.Attestor.TrustVerdict;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
public class JsonCanonicalizerTests
{
[Fact]
public void Canonicalize_OrdersKeysAndRemovesWhitespace()
{
var input = "{ \"b\": 1, \"a\": 2 }";
var canonical = JsonCanonicalizer.Canonicalize(input);
canonical.Should().Be("{\"a\":2,\"b\":1}");
}
[Fact]
public void Canonicalize_NormalizesExponentNumbers()
{
var input = "{\"n\":1e0}";
var canonical = JsonCanonicalizer.Canonicalize(input);
canonical.Should().Be("{\"n\":1}");
}
}

View File

@@ -63,14 +63,17 @@ public class TrustEvidenceMerkleBuilderTests
}
[Fact]
public void Build_SortsItemsByDigest()
public void Build_SortsItemsByDigestWithTieBreakers()
{
// Arrange - Items in reverse order
// Arrange - Items with matching digests but different tie-breakers
var items = new[]
{
CreateEvidenceItem("sha256:zzz"),
CreateEvidenceItem("sha256:aaa"),
CreateEvidenceItem("sha256:mmm")
CreateEvidenceItem("sha256:dup", TrustEvidenceTypes.Signature, "https://example.com/b", "b",
new DateTimeOffset(2025, 1, 15, 12, 2, 0, TimeSpan.Zero)),
CreateEvidenceItem("sha256:dup", TrustEvidenceTypes.Certificate, "https://example.com/a", "a",
new DateTimeOffset(2025, 1, 15, 12, 1, 0, TimeSpan.Zero)),
CreateEvidenceItem("sha256:dup", TrustEvidenceTypes.VexDocument, "https://example.com/a", "c",
new DateTimeOffset(2025, 1, 15, 12, 3, 0, TimeSpan.Zero))
};
// Act
@@ -78,7 +81,16 @@ public class TrustEvidenceMerkleBuilderTests
// Assert
tree.LeafCount.Should().Be(3);
// First leaf should correspond to "sha256:aaa"
var expectedOrder = items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.Select(i => ToDigestString(_builder.ComputeLeafHash(i)))
.ToList();
tree.LeafHashes.Should().BeEquivalentTo(expectedOrder, opts => opts.WithStrictOrdering());
}
[Fact]
@@ -174,7 +186,13 @@ public class TrustEvidenceMerkleBuilderTests
var proof = tree.GenerateProof(1);
// Get the item at sorted index 1 (should be "sha256:bbb")
var sortedItems = items.OrderBy(i => i.Digest).ToList();
var sortedItems = items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.ToList();
var item = sortedItems[1];
// Act
@@ -259,7 +277,13 @@ public class TrustEvidenceMerkleBuilderTests
CreateEvidenceItem("sha256:bbb")
};
var tree = _builder.Build(items);
var chain = tree.ToEvidenceChain(items.OrderBy(i => i.Digest).ToList());
var chain = tree.ToEvidenceChain(items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.ToList());
// Act
var valid = _builder.ValidateChain(chain);
@@ -314,7 +338,13 @@ public class TrustEvidenceMerkleBuilderTests
for (var i = 0; i < count; i++)
{
var proof = tree.GenerateProof(i);
var sortedItems = items.OrderBy(x => x.Digest).ToList();
var sortedItems = items
.OrderBy(x => x.Digest, StringComparer.Ordinal)
.ThenBy(x => x.Type, StringComparer.Ordinal)
.ThenBy(x => x.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(x => x.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(x => x.CollectedAt?.ToUniversalTime())
.ToList();
var valid = _builder.VerifyProof(sortedItems[i], proof, tree.Root);
valid.Should().BeTrue($"proof for index {i} should be valid");
}
@@ -342,14 +372,19 @@ public class TrustEvidenceMerkleBuilderTests
private static TrustEvidenceItem CreateEvidenceItem(
string digest,
string type = TrustEvidenceTypes.VexDocument,
string? uri = null)
string? uri = null,
string? description = null,
DateTimeOffset? collectedAt = null)
{
return new TrustEvidenceItem
{
Type = type,
Digest = digest,
Uri = uri,
CollectedAt = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)
Description = description,
CollectedAt = collectedAt ?? new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)
};
}
private static string ToDigestString(byte[] hash) => $"sha256:{Convert.ToHexStringLower(hash)}";
}

View File

@@ -2,10 +2,12 @@
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.TrustVerdict.Caching;
using StellaOps.Attestor.TrustVerdict.Predicates;
using System.Reflection;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
@@ -101,6 +103,47 @@ public class TrustVerdictCacheTests
result.Should().BeNull();
}
[Fact]
public async Task Get_UpdatesHitCountInCache()
{
// Arrange
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
await _cache.SetAsync(entry);
// Act
var first = await _cache.GetAsync("sha256:verdict1");
var second = await _cache.GetAsync("sha256:verdict1");
// Assert
first.Should().NotBeNull();
second.Should().NotBeNull();
first!.HitCount.Should().Be(1);
second!.HitCount.Should().Be(2);
}
[Fact]
public async Task GetByVexDigest_RemovesExpiredIndex()
{
// Arrange
var entry = CreateCacheEntry(
"sha256:verdict1",
"sha256:vex1",
"tenant1",
expiresAt: _timeProvider.GetUtcNow().AddMinutes(1));
await _cache.SetAsync(entry);
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant1");
// Assert
result.Should().BeNull();
var index = GetVexIndex(_cache);
index.Should().BeEmpty("expired entries should evict the VEX index mapping");
}
[Fact]
public async Task Invalidate_RemovesEntry()
{
@@ -151,6 +194,27 @@ public class TrustVerdictCacheTests
results.Should().NotContainKey("sha256:vex4");
}
[Fact]
public async Task GetBatch_RemovesExpiredEntries()
{
// Arrange
await _cache.SetAsync(CreateCacheEntry(
"sha256:v1",
"sha256:vex1",
"tenant1",
expiresAt: _timeProvider.GetUtcNow().AddMinutes(1)));
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var results = await _cache.GetBatchAsync(["sha256:vex1"], "tenant1");
// Assert
results.Should().BeEmpty();
var index = GetVexIndex(_cache);
index.Should().BeEmpty();
}
[Fact]
public async Task Set_EvictsOldestWhenFull()
{
@@ -230,6 +294,21 @@ public class TrustVerdictCacheTests
result!.Predicate.Composite.Score.Should().Be(0.99m);
}
[Fact]
public async Task ValkeyCache_WhenEnabled_ThrowsNotSupported()
{
// Arrange
var options = CreateOptions(new TrustVerdictCacheOptions { UseValkey = true });
var cache = new ValkeyTrustVerdictCache(options, NullLogger<ValkeyTrustVerdictCache>.Instance, _timeProvider);
// Act
Func<Task> act = () => cache.GetAsync("sha256:verdict1");
// Assert
await act.Should().ThrowAsync<NotSupportedException>()
.WithMessage("*TrustVerdictCache:UseValkey*");
}
private TrustVerdictCacheEntry CreateCacheEntry(
string verdictDigest,
string vexDigest,
@@ -297,4 +376,12 @@ public class TrustVerdictCacheTests
monitor.Setup(m => m.CurrentValue).Returns(options);
return monitor.Object;
}
private static Dictionary<string, string> GetVexIndex(InMemoryTrustVerdictCache cache)
{
var field = typeof(InMemoryTrustVerdictCache)
.GetField("_vexToVerdictIndex", BindingFlags.NonPublic | BindingFlags.Instance);
field.Should().NotBeNull("vex index should exist");
return (Dictionary<string, string>)field!.GetValue(cache)!;
}
}

View File

@@ -0,0 +1,92 @@
// TrustVerdictOciAttacherTests - Tests for OCI attacher stub behavior
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.TrustVerdict.Oci;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
public class TrustVerdictOciAttacherTests
{
[Fact]
public async Task Attach_WhenDisabled_ReturnsFailure()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = false });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.AttachAsync("registry.example/repo:tag", "ZXhhbXBsZQ==", "sha256:verdict");
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Be("OCI attachment is disabled");
}
[Fact]
public async Task Attach_InvalidReference_ReturnsFailure()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.AttachAsync("not-a-ref", "ZXhhbXBsZQ==", "sha256:verdict");
result.Success.Should().BeFalse();
result.ErrorMessage.Should().StartWith("Invalid OCI reference:");
}
[Fact]
public async Task Attach_WhenEnabled_ReturnsNotImplemented()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var timeProvider = new FakeTimeProvider();
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance, timeProvider: timeProvider);
var result = await attacher.AttachAsync("repo:tag", "ZXhhbXBsZQ==", "sha256:verdict");
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Be("OCI attachment is not implemented.");
result.Duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero);
}
[Fact]
public async Task Fetch_InvalidReference_ReturnsNull()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.FetchAsync("not-a-ref");
result.Should().BeNull();
}
[Fact]
public async Task List_InvalidReference_ReturnsEmpty()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.ListAsync("not-a-ref");
result.Should().BeEmpty();
}
[Fact]
public async Task Detach_WhenEnabled_ReturnsFalse()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.DetachAsync("repo:tag", "sha256:verdict");
result.Should().BeFalse();
}
private static IOptionsMonitor<TrustVerdictOciOptions> CreateOptions(TrustVerdictOciOptions options)
{
var monitor = new Moq.Mock<IOptionsMonitor<TrustVerdictOciOptions>>();
monitor.Setup(m => m.CurrentValue).Returns(options);
return monitor.Object;
}
}

View File

@@ -0,0 +1,144 @@
// TrustVerdictRepositoryMappingTests - Repository mapping tests
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Data;
using System.Data.Common;
using FluentAssertions;
using StellaOps.Attestor.TrustVerdict.Persistence;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
public class TrustVerdictRepositoryMappingTests
{
[Fact]
public void ReadEntity_PreservesDateTimeOffset()
{
var issuedAt = new DateTimeOffset(2025, 2, 10, 8, 30, 0, TimeSpan.FromHours(2));
var evaluatedAt = new DateTimeOffset(2025, 2, 11, 9, 45, 0, TimeSpan.FromHours(-5));
var createdAt = new DateTimeOffset(2025, 2, 12, 10, 15, 0, TimeSpan.FromHours(1));
var expiresAt = new DateTimeOffset(2025, 2, 13, 11, 0, 0, TimeSpan.Zero);
using var reader = CreateReader(issuedAt, evaluatedAt, createdAt, expiresAt);
reader.Read();
var entity = PostgresTrustVerdictRepository.ReadEntity(reader);
entity.FreshnessIssuedAt.Should().Be(issuedAt);
entity.EvaluatedAt.Should().Be(evaluatedAt);
entity.CreatedAt.Should().Be(createdAt);
entity.ExpiresAt.Should().Be(expiresAt);
}
private static DbDataReader CreateReader(
DateTimeOffset issuedAt,
DateTimeOffset evaluatedAt,
DateTimeOffset createdAt,
DateTimeOffset expiresAt)
{
var table = new DataTable();
table.Columns.Add("verdict_id", typeof(string));
table.Columns.Add("tenant_id", typeof(Guid));
table.Columns.Add("vex_digest", typeof(string));
table.Columns.Add("vex_format", typeof(string));
table.Columns.Add("provider_id", typeof(string));
table.Columns.Add("statement_id", typeof(string));
table.Columns.Add("vulnerability_id", typeof(string));
table.Columns.Add("product_key", typeof(string));
table.Columns.Add("vex_status", typeof(string));
table.Columns.Add("origin_valid", typeof(bool));
table.Columns.Add("origin_method", typeof(string));
table.Columns.Add("origin_key_id", typeof(string));
table.Columns.Add("origin_issuer_id", typeof(string));
table.Columns.Add("origin_issuer_name", typeof(string));
table.Columns.Add("origin_rekor_log_index", typeof(long));
table.Columns.Add("origin_score", typeof(decimal));
table.Columns.Add("freshness_status", typeof(string));
table.Columns.Add("freshness_issued_at", typeof(DateTimeOffset));
table.Columns.Add("freshness_expires_at", typeof(DateTimeOffset));
table.Columns.Add("freshness_superseded_by", typeof(string));
table.Columns.Add("freshness_age_days", typeof(int));
table.Columns.Add("freshness_score", typeof(decimal));
table.Columns.Add("reputation_composite", typeof(decimal));
table.Columns.Add("reputation_authority", typeof(decimal));
table.Columns.Add("reputation_accuracy", typeof(decimal));
table.Columns.Add("reputation_timeliness", typeof(decimal));
table.Columns.Add("reputation_coverage", typeof(decimal));
table.Columns.Add("reputation_verification", typeof(decimal));
table.Columns.Add("reputation_sample_count", typeof(int));
table.Columns.Add("trust_score", typeof(decimal));
table.Columns.Add("trust_tier", typeof(string));
table.Columns.Add("trust_formula", typeof(string));
table.Columns.Add("trust_reasons", typeof(string[]));
table.Columns.Add("meets_policy_threshold", typeof(bool));
table.Columns.Add("policy_threshold", typeof(decimal));
table.Columns.Add("evidence_merkle_root", typeof(string));
table.Columns.Add("evidence_items_json", typeof(string));
table.Columns.Add("envelope_base64", typeof(string));
table.Columns.Add("verdict_digest", typeof(string));
table.Columns.Add("evaluated_at", typeof(DateTimeOffset));
table.Columns.Add("evaluator_version", typeof(string));
table.Columns.Add("crypto_profile", typeof(string));
table.Columns.Add("policy_digest", typeof(string));
table.Columns.Add("environment", typeof(string));
table.Columns.Add("correlation_id", typeof(string));
table.Columns.Add("oci_digest", typeof(string));
table.Columns.Add("rekor_log_index", typeof(long));
table.Columns.Add("created_at", typeof(DateTimeOffset));
table.Columns.Add("expires_at", typeof(DateTimeOffset));
var row = table.NewRow();
row["verdict_id"] = "verdict-1";
row["tenant_id"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
row["vex_digest"] = "sha256:vex";
row["vex_format"] = "openvex";
row["provider_id"] = "provider";
row["statement_id"] = "statement";
row["vulnerability_id"] = "CVE-2025-1234";
row["product_key"] = "pkg:npm/test@1.0.0";
row["vex_status"] = DBNull.Value;
row["origin_valid"] = true;
row["origin_method"] = "dsse";
row["origin_key_id"] = DBNull.Value;
row["origin_issuer_id"] = DBNull.Value;
row["origin_issuer_name"] = DBNull.Value;
row["origin_rekor_log_index"] = DBNull.Value;
row["origin_score"] = 0.9m;
row["freshness_status"] = "fresh";
row["freshness_issued_at"] = issuedAt;
row["freshness_expires_at"] = DBNull.Value;
row["freshness_superseded_by"] = DBNull.Value;
row["freshness_age_days"] = 0;
row["freshness_score"] = 1.0m;
row["reputation_composite"] = 0.8m;
row["reputation_authority"] = 0.8m;
row["reputation_accuracy"] = 0.8m;
row["reputation_timeliness"] = 0.8m;
row["reputation_coverage"] = 0.8m;
row["reputation_verification"] = 0.8m;
row["reputation_sample_count"] = 10;
row["trust_score"] = 0.9m;
row["trust_tier"] = "High";
row["trust_formula"] = "test";
row["trust_reasons"] = new[] { "reason" };
row["meets_policy_threshold"] = DBNull.Value;
row["policy_threshold"] = DBNull.Value;
row["evidence_merkle_root"] = "sha256:root";
row["evidence_items_json"] = "[]";
row["envelope_base64"] = DBNull.Value;
row["verdict_digest"] = "sha256:verdict";
row["evaluated_at"] = evaluatedAt;
row["evaluator_version"] = "1.0.0";
row["crypto_profile"] = "world";
row["policy_digest"] = DBNull.Value;
row["environment"] = DBNull.Value;
row["correlation_id"] = DBNull.Value;
row["oci_digest"] = DBNull.Value;
row["rekor_log_index"] = DBNull.Value;
row["created_at"] = createdAt;
row["expires_at"] = expiresAt;
table.Rows.Add(row);
return table.CreateDataReader();
}
}

View File

@@ -1,10 +1,12 @@
// TrustVerdictServiceTests - Unit tests for TrustVerdictService
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Globalization;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.TrustVerdict.Evidence;
using StellaOps.Attestor.TrustVerdict.Predicates;
using StellaOps.Attestor.TrustVerdict.Services;
using Xunit;
@@ -16,12 +18,14 @@ public class TrustVerdictServiceTests
private readonly FakeTimeProvider _timeProvider;
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
private readonly TrustVerdictService _service;
private readonly ITrustEvidenceMerkleBuilder _merkleBuilder;
public TrustVerdictServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_options = CreateOptions(new TrustVerdictServiceOptions { EvaluatorVersion = "1.0.0-test" });
_service = new TrustVerdictService(_options, NullLogger<TrustVerdictService>.Instance, _timeProvider);
_merkleBuilder = new TrustEvidenceMerkleBuilder();
_service = new TrustVerdictService(_options, NullLogger<TrustVerdictService>.Instance, _merkleBuilder, _timeProvider);
}
[Fact]
@@ -327,6 +331,131 @@ public class TrustVerdictServiceTests
digests.Should().BeInAscendingOrder();
}
[Fact]
public async Task GenerateVerdictAsync_EvidenceOrderingUsesTieBreakers()
{
// Arrange
var request = CreateValidRequest() with
{
EvidenceItems =
[
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.Signature,
Digest = "sha256:dup",
Uri = "https://example.com/b",
Description = "b",
CollectedAt = _timeProvider.GetUtcNow().AddMinutes(2)
},
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.Certificate,
Digest = "sha256:dup",
Uri = "https://example.com/a",
Description = "a",
CollectedAt = _timeProvider.GetUtcNow().AddMinutes(1)
},
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.VexDocument,
Digest = "sha256:dup",
Uri = "https://example.com/a",
Description = "c",
CollectedAt = _timeProvider.GetUtcNow().AddMinutes(3)
}
]
};
// Act
var result = await _service.GenerateVerdictAsync(request);
// Assert
var ordered = result.Predicate!.Evidence.Items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.ToList();
result.Predicate.Evidence.Items.Should().BeEquivalentTo(ordered, opts => opts.WithStrictOrdering());
}
[Fact]
public async Task GenerateVerdictAsync_EvidenceMerkleRootMatchesBuilder()
{
// Arrange
var request = CreateValidRequest() with
{
EvidenceItems =
[
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.VexDocument,
Digest = "sha256:vex123",
Uri = "https://example.com/vex/123",
Description = "vex"
},
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.Signature,
Digest = "sha256:sig456",
Description = "signature"
}
]
};
// Act
var result = await _service.GenerateVerdictAsync(request);
// Assert
var tree = _merkleBuilder.Build(result.Predicate!.Evidence.Items);
result.Predicate.Evidence.MerkleRoot.Should().Be(tree.Root);
}
[Fact]
public async Task GenerateVerdictAsync_UsesInvariantCultureForReasons()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
CultureInfo.CurrentUICulture = new CultureInfo("fr-FR");
var request = CreateValidRequest() with
{
Origin = new TrustVerdictOriginInput { Valid = true, Method = VerificationMethods.Dsse },
Freshness = new TrustVerdictFreshnessInput
{
Status = FreshnessStatuses.Fresh,
IssuedAt = _timeProvider.GetUtcNow()
},
Reputation = new TrustVerdictReputationInput
{
Authority = 1.0m,
Accuracy = 1.0m,
Timeliness = 1.0m,
Coverage = 1.0m,
Verification = 1.0m,
ComputedAt = _timeProvider.GetUtcNow(),
SampleCount = 10
}
};
var result = await _service.GenerateVerdictAsync(request);
var reasons = result.Predicate!.Composite.Reasons;
reasons.Should().Contain(r => r.Contains("100%", StringComparison.Ordinal));
reasons.Should().NotContain(r => r.Contains("100 %", StringComparison.Ordinal));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
[Fact]
public async Task GenerateVerdictAsync_ChecksPolicyThreshold()
{

View File

@@ -1,7 +1,6 @@
// TrustVerdictCache - Valkey-backed cache for TrustVerdict lookups
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.TrustVerdict.Predicates;
@@ -164,11 +163,13 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
if (_timeProvider.GetUtcNow() < entry.ExpiresAt)
{
Interlocked.Increment(ref _hitCount);
return Task.FromResult<TrustVerdictCacheEntry?>(entry with { HitCount = entry.HitCount + 1 });
var updated = entry with { HitCount = entry.HitCount + 1 };
_byVerdictDigest[verdictDigest] = updated;
return Task.FromResult<TrustVerdictCacheEntry?>(updated);
}
// Expired, remove
_byVerdictDigest.Remove(verdictDigest);
RemoveEntryLocked(entry);
Interlocked.Increment(ref _evictionCount);
}
@@ -188,7 +189,25 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
{
if (_vexToVerdictIndex.TryGetValue(key, out var verdictDigest))
{
return GetAsync(verdictDigest, ct);
if (!_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
{
_vexToVerdictIndex.Remove(key);
Interlocked.Increment(ref _missCount);
return Task.FromResult<TrustVerdictCacheEntry?>(null);
}
if (_timeProvider.GetUtcNow() < entry.ExpiresAt)
{
Interlocked.Increment(ref _hitCount);
var updated = entry with { HitCount = entry.HitCount + 1 };
_byVerdictDigest[verdictDigest] = updated;
return Task.FromResult<TrustVerdictCacheEntry?>(updated);
}
RemoveEntryLocked(entry);
Interlocked.Increment(ref _evictionCount);
Interlocked.Increment(ref _missCount);
return Task.FromResult<TrustVerdictCacheEntry?>(null);
}
}
@@ -267,15 +286,30 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
{
var vexKey = BuildVexKey(vexDigest, tenantId);
if (_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest) &&
_byVerdictDigest.TryGetValue(verdictDigest, out var entry) &&
now < entry.ExpiresAt)
if (!_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest))
{
Interlocked.Increment(ref _missCount);
continue;
}
if (!_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
{
_vexToVerdictIndex.Remove(vexKey);
Interlocked.Increment(ref _missCount);
continue;
}
if (now < entry.ExpiresAt)
{
results[vexDigest] = entry;
Interlocked.Increment(ref _hitCount);
var updated = entry with { HitCount = entry.HitCount + 1 };
_byVerdictDigest[verdictDigest] = updated;
results[vexDigest] = updated;
}
else
{
RemoveEntryLocked(entry);
Interlocked.Increment(ref _evictionCount);
Interlocked.Increment(ref _missCount);
}
}
@@ -312,13 +346,18 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
if (oldest != null)
{
_byVerdictDigest.Remove(oldest.VerdictDigest);
var vexKey = BuildVexKey(oldest.VexDigest, oldest.TenantId);
_vexToVerdictIndex.Remove(vexKey);
RemoveEntryLocked(oldest);
Interlocked.Increment(ref _evictionCount);
}
}
private void RemoveEntryLocked(TrustVerdictCacheEntry entry)
{
_byVerdictDigest.Remove(entry.VerdictDigest);
var vexKey = BuildVexKey(entry.VexDigest, entry.TenantId);
_vexToVerdictIndex.Remove(vexKey);
}
private long EstimateMemoryUsage()
{
// Rough estimate: ~1KB per entry average
@@ -334,7 +373,6 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ValkeyTrustVerdictCache> _logger;
private readonly JsonSerializerOptions _jsonOptions;
// Note: In production, this would use StackExchange.Redis or similar Valkey client
// For now, we delegate to in-memory as a fallback
@@ -349,11 +387,6 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
_fallback = new InMemoryTrustVerdictCache(options, timeProvider);
}
@@ -366,21 +399,8 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
return await _fallback.GetAsync(verdictDigest, ct);
}
try
{
// TODO: Implement Valkey lookup
// var key = BuildKey(opts.KeyPrefix, "verdict", verdictDigest);
// var value = await _valkeyClient.GetAsync(key);
// if (value != null)
// return JsonSerializer.Deserialize<TrustVerdictCacheEntry>(value, _jsonOptions);
return await _fallback.GetAsync(verdictDigest, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Valkey lookup failed for {Digest}, falling back to in-memory", verdictDigest);
return await _fallback.GetAsync(verdictDigest, ct);
}
ThrowValkeyNotImplemented();
return null;
}
public async Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
@@ -395,89 +415,45 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
}
try
{
// TODO: Implement Valkey lookup via secondary index
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Valkey lookup failed for VEX {Digest}, falling back", vexDigest);
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
}
ThrowValkeyNotImplemented();
return null;
}
public async Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default)
{
var opts = _options.CurrentValue;
// Always set in fallback for local consistency
await _fallback.SetAsync(entry, ct);
if (!opts.UseValkey)
{
await _fallback.SetAsync(entry, ct);
return;
}
try
{
// TODO: Implement Valkey SET with TTL
// var key = BuildKey(opts.KeyPrefix, "verdict", entry.VerdictDigest);
// var value = JsonSerializer.Serialize(entry, _jsonOptions);
// await _valkeyClient.SetAsync(key, value, opts.DefaultTtl);
// Also set secondary index
// var vexKey = BuildKey(opts.KeyPrefix, "vex", entry.TenantId, entry.VexDigest);
// await _valkeyClient.SetAsync(vexKey, entry.VerdictDigest, opts.DefaultTtl);
_logger.LogDebug("Cached verdict {Digest} in Valkey", entry.VerdictDigest);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache verdict {Digest} in Valkey", entry.VerdictDigest);
}
ThrowValkeyNotImplemented();
}
public async Task InvalidateAsync(string verdictDigest, CancellationToken ct = default)
{
await _fallback.InvalidateAsync(verdictDigest, ct);
var opts = _options.CurrentValue;
if (!opts.UseValkey)
{
await _fallback.InvalidateAsync(verdictDigest, ct);
return;
}
try
{
// TODO: Implement Valkey DEL
_logger.LogDebug("Invalidated verdict {Digest} in Valkey", verdictDigest);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate verdict {Digest} in Valkey", verdictDigest);
}
ThrowValkeyNotImplemented();
}
public async Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default)
{
await _fallback.InvalidateByVexDigestAsync(vexDigest, tenantId, ct);
var opts = _options.CurrentValue;
if (!opts.UseValkey)
{
await _fallback.InvalidateByVexDigestAsync(vexDigest, tenantId, ct);
return;
}
try
{
// TODO: Implement Valkey DEL via secondary index
_logger.LogDebug("Invalidated verdicts for VEX {Digest} in Valkey", vexDigest);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate VEX {Digest} in Valkey", vexDigest);
}
ThrowValkeyNotImplemented();
}
public async Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
@@ -492,21 +468,20 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
}
try
{
// TODO: Implement Valkey MGET for batch lookup
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Valkey batch lookup failed, falling back");
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
}
ThrowValkeyNotImplemented();
return new Dictionary<string, TrustVerdictCacheEntry>(StringComparer.Ordinal);
}
public Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default)
{
// TODO: Combine Valkey INFO stats with fallback stats
var opts = _options.CurrentValue;
if (!opts.UseValkey)
{
return _fallback.GetStatsAsync(ct);
}
ThrowValkeyNotImplemented();
return _fallback.GetStatsAsync(ct);
}
@@ -515,6 +490,15 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
// TODO: Dispose Valkey client when implemented
return ValueTask.CompletedTask;
}
private void ThrowValkeyNotImplemented()
{
_logger.LogError(
"Valkey TrustVerdict cache is not implemented. Set {SectionKey}:UseValkey=false.",
TrustVerdictCacheOptions.SectionKey);
throw new NotSupportedException(
"Valkey TrustVerdict cache is not implemented. Set TrustVerdictCache:UseValkey=false.");
}
}
/// <summary>

View File

@@ -3,6 +3,7 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.StandardPredicates;
namespace StellaOps.Attestor.TrustVerdict;

View File

@@ -47,7 +47,7 @@ CREATE TABLE vex.trust_verdicts (
-- Trust composite
trust_score DECIMAL(5,4) NOT NULL,
trust_tier TEXT NOT NULL, -- verified, high, medium, low, untrusted
trust_tier TEXT NOT NULL, -- VeryHigh, High, Medium, Low, VeryLow
trust_formula TEXT NOT NULL,
trust_reasons TEXT[] NOT NULL,
meets_policy_threshold BOOLEAN,

View File

@@ -1,6 +1,7 @@
// TrustVerdictRepository - PostgreSQL persistence for TrustVerdict attestations
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Data.Common;
using System.Text.Json;
using Npgsql;
using NpgsqlTypes;
@@ -182,12 +183,12 @@ public sealed record TrustVerdictStats
public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly JsonSerializerOptions _jsonOptions;
private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public PostgresTrustVerdictRepository(NpgsqlDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
public async Task<string> StoreAsync(TrustVerdictEntity entity, CancellationToken ct = default)
@@ -412,8 +413,8 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
ActiveCount = reader.GetInt64(1),
ExpiredCount = reader.GetInt64(2),
AverageScore = reader.GetDecimal(3),
OldestEvaluation = reader.IsDBNull(4) ? null : reader.GetDateTime(4),
NewestEvaluation = reader.IsDBNull(5) ? null : reader.GetDateTime(5),
OldestEvaluation = reader.IsDBNull(4) ? null : reader.GetFieldValue<DateTimeOffset>(4),
NewestEvaluation = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
CountByTier = await GetCountByTierAsync(tenantId, ct),
CountByProvider = await GetCountByProviderAsync(tenantId, ct)
};
@@ -532,7 +533,7 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
cmd.Parameters.AddWithValue("policy_threshold", entity.PolicyThreshold ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("evidence_merkle_root", entity.EvidenceMerkleRoot);
cmd.Parameters.AddWithValue("evidence_items_json", JsonSerializer.Serialize(entity.EvidenceItems, _jsonOptions));
cmd.Parameters.AddWithValue("evidence_items_json", JsonSerializer.Serialize(entity.EvidenceItems, JsonOptions));
cmd.Parameters.AddWithValue("envelope_base64", entity.EnvelopeBase64 ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("verdict_digest", entity.VerdictDigest);
@@ -551,10 +552,10 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
cmd.Parameters.AddWithValue("expires_at", entity.ExpiresAt ?? (object)DBNull.Value);
}
private TrustVerdictEntity ReadEntity(NpgsqlDataReader reader)
internal static TrustVerdictEntity ReadEntity(DbDataReader reader)
{
var evidenceJson = reader.GetString(reader.GetOrdinal("evidence_items_json"));
var evidenceItems = JsonSerializer.Deserialize<List<TrustEvidenceItem>>(evidenceJson, _jsonOptions) ?? [];
var evidenceItems = JsonSerializer.Deserialize<List<TrustEvidenceItem>>(evidenceJson, JsonOptions) ?? [];
return new TrustVerdictEntity
{
@@ -578,8 +579,10 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
OriginScore = reader.GetDecimal(reader.GetOrdinal("origin_score")),
FreshnessStatus = reader.GetString(reader.GetOrdinal("freshness_status")),
FreshnessIssuedAt = reader.GetDateTime(reader.GetOrdinal("freshness_issued_at")),
FreshnessExpiresAt = reader.IsDBNull(reader.GetOrdinal("freshness_expires_at")) ? null : reader.GetDateTime(reader.GetOrdinal("freshness_expires_at")),
FreshnessIssuedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("freshness_issued_at")),
FreshnessExpiresAt = reader.IsDBNull(reader.GetOrdinal("freshness_expires_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("freshness_expires_at")),
FreshnessSupersededBy = reader.IsDBNull(reader.GetOrdinal("freshness_superseded_by")) ? null : reader.GetString(reader.GetOrdinal("freshness_superseded_by")),
FreshnessAgeDays = reader.GetInt32(reader.GetOrdinal("freshness_age_days")),
FreshnessScore = reader.GetDecimal(reader.GetOrdinal("freshness_score")),
@@ -605,7 +608,7 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
EnvelopeBase64 = reader.IsDBNull(reader.GetOrdinal("envelope_base64")) ? null : reader.GetString(reader.GetOrdinal("envelope_base64")),
VerdictDigest = reader.GetString(reader.GetOrdinal("verdict_digest")),
EvaluatedAt = reader.GetDateTime(reader.GetOrdinal("evaluated_at")),
EvaluatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("evaluated_at")),
EvaluatorVersion = reader.GetString(reader.GetOrdinal("evaluator_version")),
CryptoProfile = reader.GetString(reader.GetOrdinal("crypto_profile")),
PolicyDigest = reader.IsDBNull(reader.GetOrdinal("policy_digest")) ? null : reader.GetString(reader.GetOrdinal("policy_digest")),
@@ -615,8 +618,10 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
OciDigest = reader.IsDBNull(reader.GetOrdinal("oci_digest")) ? null : reader.GetString(reader.GetOrdinal("oci_digest")),
RekorLogIndex = reader.IsDBNull(reader.GetOrdinal("rekor_log_index")) ? null : reader.GetInt64(reader.GetOrdinal("rekor_log_index")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
ExpiresAt = reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetDateTime(reader.GetOrdinal("expires_at"))
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
ExpiresAt = reader.IsDBNull(reader.GetOrdinal("expires_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at"))
};
}
}

View File

@@ -162,6 +162,7 @@ public sealed record TrustVerdictEvidenceInput
public required string Digest { get; init; }
public string? Uri { get; init; }
public string? Description { get; init; }
public DateTimeOffset? CollectedAt { get; init; }
}
/// <summary>
@@ -451,7 +452,7 @@ public sealed class TrustVerdictService : ITrustVerdictService
Digest = e.Digest,
Uri = e.Uri,
Description = e.Description,
CollectedAt = evaluatedAt
CollectedAt = e.CollectedAt ?? evaluatedAt
})
.ToList();

View File

@@ -24,4 +24,8 @@
<ProjectReference Include="..\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Attestor.TrustVerdict.Tests" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0067-M | DONE | Maintainability audit for StellaOps.Attestor.TrustVerdict. |
| AUDIT-0067-T | DONE | Test coverage audit for StellaOps.Attestor.TrustVerdict. |
| AUDIT-0067-A | DOING | Applying audit fixes for TrustVerdict library. |
| AUDIT-0067-A | DONE | Applied audit fixes for TrustVerdict library. |