save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -190,10 +190,8 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
{
ArgumentNullException.ThrowIfNull(items);
// Sort items deterministically by digest
var sortedItems = items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ToList();
// Sort items deterministically by digest and stable tie-breakers
var sortedItems = TrustEvidenceOrdering.OrderItems(items).ToList();
if (sortedItems.Count == 0)
{
@@ -328,6 +326,21 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
}
}
internal static class TrustEvidenceOrdering
{
public static IOrderedEnumerable<TrustEvidenceItem> OrderItems(IEnumerable<TrustEvidenceItem> items)
{
ArgumentNullException.ThrowIfNull(items);
return 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());
}
}
/// <summary>
/// Extension methods for TrustEvidenceMerkleTree.
/// </summary>

View File

@@ -1,10 +1,9 @@
// JsonCanonicalizer - Deterministic JSON serialization for content addressing
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Buffers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.StandardPredicates;
namespace StellaOps.Attestor.TrustVerdict;
@@ -21,13 +20,11 @@ namespace StellaOps.Attestor.TrustVerdict;
/// </remarks>
public static class JsonCanonicalizer
{
private static readonly JsonSerializerOptions s_canonicalOptions = new()
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new SortedObjectConverter() }
WriteIndented = false
};
/// <summary>
@@ -35,12 +32,8 @@ public static class JsonCanonicalizer
/// </summary>
public static string Canonicalize<T>(T value)
{
// First serialize to JSON document to get raw structure
var json = JsonSerializer.Serialize(value, s_canonicalOptions);
// Re-parse and canonicalize
using var doc = JsonDocument.Parse(json);
return CanonicalizeElement(doc.RootElement);
var json = JsonSerializer.Serialize(value, CanonicalOptions);
return JsonCanonicalizer.Canonicalize(json);
}
/// <summary>
@@ -48,8 +41,7 @@ public static class JsonCanonicalizer
/// </summary>
public static string Canonicalize(string json)
{
using var doc = JsonDocument.Parse(json);
return CanonicalizeElement(doc.RootElement);
return StellaOps.Attestor.StandardPredicates.JsonCanonicalizer.Canonicalize(json);
}
/// <summary>
@@ -57,146 +49,7 @@ public static class JsonCanonicalizer
/// </summary>
public static string CanonicalizeElement(JsonElement element)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Indented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
WriteCanonical(writer, element);
writer.Flush();
return Encoding.UTF8.GetString(buffer.WrittenSpan);
}
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
WriteCanonicalObject(writer, element);
break;
case JsonValueKind.Array:
WriteCanonicalArray(writer, element);
break;
case JsonValueKind.String:
writer.WriteStringValue(element.GetString());
break;
case JsonValueKind.Number:
WriteCanonicalNumber(writer, element);
break;
case JsonValueKind.True:
writer.WriteBooleanValue(true);
break;
case JsonValueKind.False:
writer.WriteBooleanValue(false);
break;
case JsonValueKind.Null:
writer.WriteNullValue();
break;
default:
throw new ArgumentException($"Unsupported JSON value kind: {element.ValueKind}");
}
}
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element)
{
writer.WriteStartObject();
// Sort properties lexicographically by key
var properties = element.EnumerateObject()
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
foreach (var property in properties)
{
writer.WritePropertyName(property.Name);
WriteCanonical(writer, property.Value);
}
writer.WriteEndObject();
}
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element)
{
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonical(writer, item);
}
writer.WriteEndArray();
}
private static void WriteCanonicalNumber(Utf8JsonWriter writer, JsonElement element)
{
// RFC 8785: Numbers must be represented without exponent notation
// and with minimal significant digits
if (element.TryGetInt64(out var longValue))
{
writer.WriteNumberValue(longValue);
}
else if (element.TryGetDecimal(out var decimalValue))
{
// Normalize to remove trailing zeros
writer.WriteNumberValue(decimalValue);
}
else
{
writer.WriteRawValue(element.GetRawText());
}
}
/// <summary>
/// Custom converter that ensures object properties are sorted.
/// </summary>
private sealed class SortedObjectConverter : JsonConverter<object>
{
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
throw new NotSupportedException("Deserialization not supported");
}
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
var type = value.GetType();
// Get all public properties, sort by name
var properties = type.GetProperties()
.Where(p => p.CanRead)
.OrderBy(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name, StringComparer.Ordinal);
writer.WriteStartObject();
foreach (var property in properties)
{
var propValue = property.GetValue(value);
if (propValue is null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
{
continue;
}
var name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
writer.WritePropertyName(name);
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
}
writer.WriteEndObject();
}
var json = element.GetRawText();
return JsonCanonicalizer.Canonicalize(json);
}
}

View File

@@ -135,7 +135,7 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
try
{
// Parse reference
var parsed = ParseReference(imageReference);
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
if (parsed == null)
{
return new TrustVerdictOciAttachResult
@@ -154,18 +154,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
// 2. Create artifact manifest referencing the blob
// 3. Push manifest with subject pointing to original image
_logger.LogInformation(
"Would attach TrustVerdict {Digest} to {Reference} (implementation pending)",
verdictDigest, imageReference);
// Placeholder - full implementation requires OCI client
var mockDigest = $"sha256:{Guid.NewGuid():N}";
_logger.LogWarning(
"OCI attachment is enabled but not implemented for {Reference}",
imageReference);
return new TrustVerdictOciAttachResult
{
Success = true,
OciDigest = mockDigest,
ManifestDigest = mockDigest,
Success = false,
ErrorMessage = "OCI attachment is not implemented.",
Duration = _timeProvider.GetUtcNow() - startTime
};
}
@@ -195,19 +191,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
try
{
var parsed = ParseReference(imageReference);
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
if (parsed == null)
{
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
return null;
}
// Query referrers API
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
_logger.LogDebug("Would fetch TrustVerdict from {Reference} (implementation pending)", imageReference);
// Placeholder
_logger.LogWarning("OCI fetch is enabled but not implemented for {Reference}", imageReference);
return null;
}
catch (Exception ex)
@@ -230,15 +221,13 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
try
{
var parsed = ParseReference(imageReference);
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
if (parsed == null)
{
return [];
}
// Query referrers API and filter by artifact type
_logger.LogDebug("Would list TrustVerdicts for {Reference} (implementation pending)", imageReference);
_logger.LogWarning("OCI list is enabled but not implemented for {Reference}", imageReference);
return [];
}
catch (Exception ex)
@@ -262,10 +251,9 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
try
{
// DELETE the referrer manifest
_logger.LogDebug(
"Would detach TrustVerdict {Digest} from {Reference} (implementation pending)",
verdictDigest, imageReference);
_logger.LogWarning(
"OCI detach is enabled but not implemented for {Reference}",
imageReference);
return false;
}
@@ -276,38 +264,56 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
}
}
private static OciReference? ParseReference(string reference)
private static OciReference? ParseReference(string reference, string? defaultRegistry)
{
// Parse: registry/repo:tag or registry/repo@sha256:digest
// Parse: registry/repo:tag, registry/repo@sha256:digest, repo:tag, repo@sha256:digest
try
{
var atIdx = reference.IndexOf('@');
var colonIdx = reference.LastIndexOf(':');
var trimmed = reference.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
var atIdx = trimmed.LastIndexOf('@');
var digest = atIdx >= 0 ? trimmed[(atIdx + 1)..] : null;
var namePart = atIdx >= 0 ? trimmed[..atIdx] : trimmed;
if (string.IsNullOrWhiteSpace(namePart))
{
return null;
}
string? tag = null;
var lastSlash = namePart.LastIndexOf('/');
var lastColon = namePart.LastIndexOf(':');
if (lastColon > lastSlash)
{
tag = namePart[(lastColon + 1)..];
namePart = namePart[..lastColon];
}
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
{
return null;
}
string registry;
string repository;
string? tag = null;
string? digest = null;
if (atIdx > 0)
var slashIdx = namePart.IndexOf('/');
if (slashIdx > 0)
{
// Has digest
digest = reference[(atIdx + 1)..];
var beforeDigest = reference[..atIdx];
var slashIdx = beforeDigest.IndexOf('/');
registry = beforeDigest[..slashIdx];
repository = beforeDigest[(slashIdx + 1)..];
}
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
{
// Has tag
tag = reference[(colonIdx + 1)..];
var beforeTag = reference[..colonIdx];
var slashIdx = beforeTag.IndexOf('/');
registry = beforeTag[..slashIdx];
repository = beforeTag[(slashIdx + 1)..];
registry = namePart[..slashIdx];
repository = namePart[(slashIdx + 1)..];
}
else
{
repository = namePart;
registry = defaultRegistry ?? string.Empty;
}
if (string.IsNullOrWhiteSpace(repository) || string.IsNullOrWhiteSpace(registry))
{
return null;
}

View File

@@ -1,12 +1,13 @@
// TrustVerdictService - Service for generating signed TrustVerdict attestations
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.StandardPredicates;
using StellaOps.Attestor.TrustVerdict.Evidence;
using StellaOps.Attestor.TrustVerdict.Predicates;
namespace StellaOps.Attestor.TrustVerdict.Services;
@@ -266,6 +267,7 @@ public sealed record TrustVerdictResult
public sealed class TrustVerdictService : ITrustVerdictService
{
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
private readonly ITrustEvidenceMerkleBuilder _merkleBuilder;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TrustVerdictService> _logger;
@@ -275,10 +277,12 @@ public sealed class TrustVerdictService : ITrustVerdictService
public TrustVerdictService(
IOptionsMonitor<TrustVerdictServiceOptions> options,
ILogger<TrustVerdictService> logger,
ITrustEvidenceMerkleBuilder merkleBuilder,
TimeProvider? timeProvider = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
_timeProvider = timeProvider ?? TimeProvider.System;
}
@@ -441,7 +445,6 @@ public sealed class TrustVerdictService : ITrustVerdictService
// Build evidence chain
var evidenceItems = request.EvidenceItems
.OrderBy(e => e.Digest, StringComparer.Ordinal)
.Select(e => new TrustEvidenceItem
{
Type = e.Type,
@@ -452,12 +455,13 @@ public sealed class TrustVerdictService : ITrustVerdictService
})
.ToList();
var merkleRoot = ComputeMerkleRoot(evidenceItems);
var orderedEvidence = TrustEvidenceOrdering.OrderItems(evidenceItems).ToList();
var merkleTree = _merkleBuilder.Build(orderedEvidence);
var evidence = new TrustEvidenceChain
{
MerkleRoot = merkleRoot,
Items = evidenceItems
MerkleRoot = merkleTree.Root,
Items = orderedEvidence
};
// Build metadata
@@ -560,54 +564,17 @@ public sealed class TrustVerdictService : ITrustVerdictService
reasons.Add($"VEX freshness: {freshness.Status} ({freshness.AgeInDays} days old)");
// Reputation reason
reasons.Add($"Issuer reputation: {reputation.Composite:P0} ({reputation.SampleCount} samples)");
var reputationPercent = reputation.Composite.ToString("P0", CultureInfo.InvariantCulture);
reasons.Add($"Issuer reputation: {reputationPercent} ({reputation.SampleCount} samples)");
// Composite summary
var tier = TrustTiers.FromScore(compositeScore);
reasons.Add($"Overall trust: {tier} ({compositeScore:P0})");
var compositePercent = compositeScore.ToString("P0", CultureInfo.InvariantCulture);
reasons.Add($"Overall trust: {tier} ({compositePercent})");
return reasons;
}
private static string ComputeMerkleRoot(IReadOnlyList<TrustEvidenceItem> items)
{
if (items.Count == 0)
{
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData([]));
}
// Get leaf hashes
var hashes = items
.Select(i => SHA256.HashData(Encoding.UTF8.GetBytes(i.Digest)))
.ToList();
// Build tree bottom-up
while (hashes.Count > 1)
{
var newLevel = new List<byte[]>();
for (var i = 0; i < hashes.Count; i += 2)
{
if (i + 1 < hashes.Count)
{
// Combine two nodes
var combined = new byte[hashes[i].Length + hashes[i + 1].Length];
hashes[i].CopyTo(combined, 0);
hashes[i + 1].CopyTo(combined, hashes[i].Length);
newLevel.Add(SHA256.HashData(combined));
}
else
{
// Odd node, promote as-is
newLevel.Add(hashes[i]);
}
}
hashes = newLevel;
}
return $"sha256:{Convert.ToHexStringLower(hashes[0])}";
}
}
/// <summary>

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 | TODO | Pending approval for changes. |
| AUDIT-0067-A | DOING | Applying audit fixes for TrustVerdict library. |