Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,230 @@
// -----------------------------------------------------------------------------
// CanonicalizationBoundaryAnalyzerTests.cs
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-18
// Description: Unit tests for STELLA0100 canonicalization analyzer.
// -----------------------------------------------------------------------------
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using StellaOps.Determinism.Analyzers;
using Xunit;
namespace StellaOps.Determinism.Analyzers.Tests;
public class CanonicalizationBoundaryAnalyzerTests
{
[Fact]
public async Task JsonSerialize_InDigestMethod_ReportsDiagnostic()
{
var testCode = """
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
public class TestClass
{
public string ComputeDigest(object data)
{
var json = JsonSerializer.Serialize(data);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
}
}
""";
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.DiagnosticId, DiagnosticSeverity.Warning)
.WithSpan(9, 24, 9, 54)
.WithArguments("ComputeDigest");
await VerifyAsync(testCode, expected);
}
[Fact]
public async Task JsonSerialize_InRegularMethod_NoDiagnostic()
{
var testCode = """
using System.Text.Json;
public class TestClass
{
public string GetJson(object data)
{
return JsonSerializer.Serialize(data);
}
}
""";
await VerifyAsync(testCode);
}
[Fact]
public async Task JsonSerialize_WithCanonicalizerField_NoDiagnostic()
{
var testCode = """
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
public class Rfc8785JsonCanonicalizer
{
public string Canonicalize(object data) => "";
}
public class TestClass
{
private readonly Rfc8785JsonCanonicalizer _canonicalizer = new();
public string ComputeDigest(object data)
{
var json = _canonicalizer.Canonicalize(data);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
}
}
""";
await VerifyAsync(testCode);
}
[Fact]
public async Task DictionaryForeach_InDigestMethod_ReportsDiagnostic()
{
var testCode = """
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
public class TestClass
{
public string ComputeHash(Dictionary<string, string> items)
{
var sb = new StringBuilder();
foreach (var item in items)
{
sb.Append(item.Key);
sb.Append(item.Value);
}
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
}
}
""";
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.CollectionDiagnosticId, DiagnosticSeverity.Warning)
.WithSpan(10, 30, 10, 35)
.WithArguments("ComputeHash");
await VerifyAsync(testCode, expected);
}
[Fact]
public async Task DictionaryForeach_WithOrderBy_NoDiagnostic()
{
var testCode = """
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
public class TestClass
{
public string ComputeHash(Dictionary<string, string> items)
{
var sb = new StringBuilder();
foreach (var item in items.OrderBy(x => x.Key, StringComparer.Ordinal))
{
sb.Append(item.Key);
sb.Append(item.Value);
}
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
}
}
""";
await VerifyAsync(testCode);
}
[Fact]
public async Task FrozenDictionaryForeach_NoDiagnostic()
{
var testCode = """
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
public class TestClass
{
public string ComputeHash(FrozenDictionary<string, string> items)
{
var sb = new StringBuilder();
foreach (var item in items)
{
sb.Append(item.Key);
sb.Append(item.Value);
}
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
}
}
""";
await VerifyAsync(testCode);
}
[Fact]
public async Task JsonSerialize_InResolverClass_ReportsDiagnostic()
{
var testCode = """
using System.Text.Json;
public class VerdictResolver
{
public string Resolve(object data)
{
return JsonSerializer.Serialize(data);
}
}
""";
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.DiagnosticId, DiagnosticSeverity.Warning)
.WithSpan(7, 16, 7, 46)
.WithArguments("Resolve");
await VerifyAsync(testCode, expected);
}
[Fact]
public async Task JsonSerialize_InAttestorClass_ReportsDiagnostic()
{
var testCode = """
using System.Text.Json;
public class SigningAttestor
{
public string CreatePayload(object data)
{
return JsonSerializer.Serialize(data);
}
}
""";
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.DiagnosticId, DiagnosticSeverity.Warning)
.WithSpan(7, 16, 7, 46)
.WithArguments("CreatePayload");
await VerifyAsync(testCode, expected);
}
private static Task VerifyAsync(string source, params DiagnosticResult[] expected)
{
var test = new CSharpAnalyzerTest<CanonicalizationBoundaryAnalyzer, DefaultVerifier>
{
TestCode = source,
ReferenceAssemblies = ReferenceAssemblies.Net.Net80
};
test.ExpectedDiagnostics.AddRange(expected);
return test.RunAsync();
}
}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Determinism.Analyzers.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Determinism.Analyzers\StellaOps.Determinism.Analyzers.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1 @@
; No releases shipped yet

View File

@@ -0,0 +1,9 @@
## Release 1.0
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
STELLA0100 | Determinism | Warning | Non-canonical JSON serialization at resolver boundary
STELLA0101 | Determinism | Info | Unicode string not NFC normalized before hashing
STELLA0102 | Determinism | Warning | Non-deterministic collection iteration in digest context

View File

@@ -0,0 +1,317 @@
// -----------------------------------------------------------------------------
// CanonicalizationBoundaryAnalyzer.cs
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-18
// Description: Roslyn analyzer enforcing canonicalization at resolver boundaries.
// Diagnostic ID: STELLA0100
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
namespace StellaOps.Determinism.Analyzers;
/// <summary>
/// Roslyn analyzer that detects non-canonical JSON serialization at resolver boundaries.
/// Reports STELLA0100 when JsonSerializer.Serialize is used without RFC 8785 canonicalization
/// in methods that participate in digest computation or attestation signing.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class CanonicalizationBoundaryAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// Diagnostic ID for canonicalization boundary violations.
/// </summary>
public const string DiagnosticId = "STELLA0100";
/// <summary>
/// Diagnostic ID for missing NFC normalization.
/// </summary>
public const string NfcDiagnosticId = "STELLA0101";
/// <summary>
/// Diagnostic ID for non-deterministic collection iteration.
/// </summary>
public const string CollectionDiagnosticId = "STELLA0102";
// Type names to detect
private const string JsonSerializerTypeName = "System.Text.Json.JsonSerializer";
private const string Sha256TypeName = "System.Security.Cryptography.SHA256";
private const string Sha384TypeName = "System.Security.Cryptography.SHA384";
private const string Sha512TypeName = "System.Security.Cryptography.SHA512";
private const string CanonicalizerTypeName = "StellaOps.Attestor.ProofChain.Json.Rfc8785JsonCanonicalizer";
private const string CanonicalJsonTypeName = "StellaOps.Canonical.Json.CanonicalJsonSerializer";
// Method names indicating digest computation
private static readonly string[] DigestMethodPatterns =
[
"ComputeDigest",
"ComputeHash",
"HashData",
"CreateDigest",
"CalculateHash",
"Sign",
"CreateAttestation",
"CreateProof",
"SerializeForSigning"
];
// Attribute names marking resolver boundaries
private static readonly string[] ResolverBoundaryAttributes =
[
"ResolverBoundary",
"RequiresCanonicalization",
"DeterministicOutput"
];
private static readonly DiagnosticDescriptor CanonicalizationRule = new(
DiagnosticId,
title: "Non-canonical JSON serialization at resolver boundary",
messageFormat: "Use Rfc8785JsonCanonicalizer instead of JsonSerializer.Serialize for deterministic digest computation in '{0}'",
category: "Determinism",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "JSON serialization at resolver boundaries must use RFC 8785 JCS canonicalization to ensure deterministic digests across platforms. Use Rfc8785JsonCanonicalizer.Canonicalize() instead of JsonSerializer.Serialize().",
helpLinkUri: "https://stella-ops.org/docs/contributing/canonicalization-determinism");
private static readonly DiagnosticDescriptor NfcRule = new(
NfcDiagnosticId,
title: "Unicode string not NFC normalized before hashing",
messageFormat: "String '{0}' should be NFC normalized before digest computation to ensure cross-platform consistency",
category: "Determinism",
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: "Strings from external sources should be NFC normalized before participating in digest computation to avoid platform-specific Unicode representation differences.",
helpLinkUri: "https://stella-ops.org/docs/contributing/canonicalization-determinism#unicode-nfc-normalization");
private static readonly DiagnosticDescriptor CollectionRule = new(
CollectionDiagnosticId,
title: "Non-deterministic collection iteration in digest context",
messageFormat: "Collection iteration in '{0}' should use OrderBy or FrozenDictionary for deterministic ordering",
category: "Determinism",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Collections participating in digest computation must be iterated in a deterministic order. Use OrderBy() with StringComparer.Ordinal or FrozenDictionary for stable iteration.",
helpLinkUri: "https://stella-ops.org/docs/contributing/canonicalization-determinism#collection-ordering");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(CanonicalizationRule, NfcRule, CollectionRule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
context.RegisterSyntaxNodeAction(AnalyzeForEach, SyntaxKind.ForEachStatement);
}
private static void AnalyzeInvocation(OperationAnalysisContext context)
{
if (context.Operation is not IInvocationOperation invocation)
{
return;
}
var method = invocation.TargetMethod;
var containingMethod = context.ContainingSymbol as IMethodSymbol;
// Check if we're in a resolver boundary context
if (!IsInResolverBoundaryContext(containingMethod, context.Compilation))
{
return;
}
// Check for JsonSerializer.Serialize usage
if (IsJsonSerializerSerialize(method))
{
// Check if there's a canonicalizer call nearby (simple heuristic)
if (!HasCanonicalizerInScope(containingMethod, context.Compilation))
{
var diagnostic = Diagnostic.Create(
CanonicalizationRule,
invocation.Syntax.GetLocation(),
containingMethod?.Name ?? "unknown");
context.ReportDiagnostic(diagnostic);
}
}
// Check for Dictionary/HashSet iteration in digest methods
if (IsHashComputationMethod(method) && HasDictionaryArgumentWithoutOrdering(invocation))
{
var diagnostic = Diagnostic.Create(
CollectionRule,
invocation.Syntax.GetLocation(),
containingMethod?.Name ?? "unknown");
context.ReportDiagnostic(diagnostic);
}
}
private static void AnalyzeForEach(SyntaxNodeAnalysisContext context)
{
var forEachStatement = (ForEachStatementSyntax)context.Node;
var containingMethod = context.ContainingSymbol as IMethodSymbol;
// Check if we're in a resolver boundary context
if (!IsInResolverBoundaryContext(containingMethod, context.Compilation))
{
return;
}
// Get the type of the collection being iterated
var typeInfo = context.SemanticModel.GetTypeInfo(forEachStatement.Expression, context.CancellationToken);
var collectionType = typeInfo.Type;
if (collectionType is null)
{
return;
}
// Check if it's a Dictionary or HashSet (non-deterministic iteration order)
if (IsNonDeterministicCollection(collectionType) && !HasOrderByInExpression(forEachStatement.Expression))
{
var diagnostic = Diagnostic.Create(
CollectionRule,
forEachStatement.Expression.GetLocation(),
containingMethod?.Name ?? "unknown");
context.ReportDiagnostic(diagnostic);
}
}
private static bool IsInResolverBoundaryContext(IMethodSymbol? method, Compilation compilation)
{
if (method is null)
{
return false;
}
// Check for resolver boundary attributes
foreach (var attr in method.GetAttributes())
{
var attrName = attr.AttributeClass?.Name;
if (attrName is not null && ResolverBoundaryAttributes.Any(a => attrName.Contains(a)))
{
return true;
}
}
// Check method name patterns
foreach (var pattern in DigestMethodPatterns)
{
if (method.Name.Contains(pattern))
{
return true;
}
}
// Check containing type for resolver patterns
var containingType = method.ContainingType;
if (containingType?.Name.Contains("Resolver") == true ||
containingType?.Name.Contains("Attestor") == true ||
containingType?.Name.Contains("Proof") == true ||
containingType?.Name.Contains("Canonicalizer") == true)
{
return true;
}
return false;
}
private static bool IsJsonSerializerSerialize(IMethodSymbol method)
{
if (method.Name != "Serialize")
{
return false;
}
var containingType = method.ContainingType;
return containingType?.ToDisplayString() == JsonSerializerTypeName;
}
private static bool IsHashComputationMethod(IMethodSymbol method)
{
var containingType = method.ContainingType?.ToDisplayString();
return containingType == Sha256TypeName ||
containingType == Sha384TypeName ||
containingType == Sha512TypeName;
}
private static bool HasCanonicalizerInScope(IMethodSymbol? method, Compilation compilation)
{
if (method is null)
{
return false;
}
// Check if the containing type has a field or local of type Rfc8785JsonCanonicalizer
var containingType = method.ContainingType;
if (containingType is null)
{
return false;
}
foreach (var member in containingType.GetMembers())
{
if (member is IFieldSymbol field)
{
var fieldTypeName = field.Type.ToDisplayString();
if (fieldTypeName.Contains("Canonicalizer") || fieldTypeName.Contains("CanonicalJson"))
{
return true;
}
}
}
return false;
}
private static bool HasDictionaryArgumentWithoutOrdering(IInvocationOperation invocation)
{
foreach (var arg in invocation.Arguments)
{
var argType = arg.Value.Type;
if (argType is null)
{
continue;
}
var typeName = argType.ToDisplayString();
if ((typeName.Contains("Dictionary") || typeName.Contains("HashSet")) &&
!typeName.Contains("Frozen") &&
!typeName.Contains("Sorted") &&
!typeName.Contains("Immutable"))
{
return true;
}
}
return false;
}
private static bool IsNonDeterministicCollection(ITypeSymbol type)
{
var typeName = type.ToDisplayString();
// FrozenDictionary, SortedDictionary, ImmutableSortedDictionary are deterministic
if (typeName.Contains("Frozen") ||
typeName.Contains("Sorted") ||
typeName.Contains("ImmutableSorted"))
{
return false;
}
// Regular Dictionary and HashSet are non-deterministic
return typeName.Contains("Dictionary") || typeName.Contains("HashSet");
}
private static bool HasOrderByInExpression(ExpressionSyntax expression)
{
// Simple check: look for OrderBy in the expression text
var text = expression.ToString();
return text.Contains("OrderBy") || text.Contains("Order(");
}
}

View File

@@ -0,0 +1,93 @@
# StellaOps.Determinism.Analyzers
Roslyn analyzers enforcing determinism patterns in StellaOps codebase.
## Diagnostics
| ID | Severity | Description |
|----|----------|-------------|
| STELLA0100 | Warning | Non-canonical JSON serialization at resolver boundary |
| STELLA0101 | Info | Unicode string not NFC normalized before hashing |
| STELLA0102 | Warning | Non-deterministic collection iteration in digest context |
## STELLA0100: Canonicalization Boundary Violation
**Triggers when:** `JsonSerializer.Serialize()` is used in methods that compute digests, create attestations, or are marked with resolver boundary attributes.
**Why it matters:** Non-canonical JSON produces different byte representations on different platforms, breaking signature verification and replay guarantees.
**Fix:** Use `Rfc8785JsonCanonicalizer.Canonicalize()` instead of `JsonSerializer.Serialize()`.
### Example
```csharp
// ❌ STELLA0100: Non-canonical JSON serialization
public string ComputeDigest(object data)
{
var json = JsonSerializer.Serialize(data); // Warning here
return SHA256.HashData(Encoding.UTF8.GetBytes(json)).ToHexString();
}
// ✅ Correct: Use canonicalizer
public string ComputeDigest(object data)
{
var canonicalizer = new Rfc8785JsonCanonicalizer();
var canonical = canonicalizer.Canonicalize(data);
return SHA256.HashData(Encoding.UTF8.GetBytes(canonical)).ToHexString();
}
```
## STELLA0102: Non-Deterministic Collection Iteration
**Triggers when:** `foreach` iterates over `Dictionary` or `HashSet` in resolver boundary methods without explicit ordering.
**Why it matters:** Dictionary/HashSet iteration order is not guaranteed across runs or platforms.
**Fix:** Use `OrderBy()` before iteration, or use `FrozenDictionary`/`SortedDictionary`.
### Example
```csharp
// ❌ STELLA0102: Non-deterministic iteration
public void ProcessItems(Dictionary<string, Item> items)
{
foreach (var item in items) // Warning here
{
AppendToDigest(item.Key);
}
}
// ✅ Correct: Order before iteration
public void ProcessItems(Dictionary<string, Item> items)
{
foreach (var item in items.OrderBy(x => x.Key, StringComparer.Ordinal))
{
AppendToDigest(item.Key);
}
}
```
## Configuration
Add the analyzer to your project:
```xml
<ProjectReference Include="path/to/StellaOps.Determinism.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
```
## Suppression
When intentionally using non-canonical serialization (e.g., for human-readable output):
```csharp
#pragma warning disable STELLA0100 // Intentional: human-readable log output
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
#pragma warning restore STELLA0100
```
## Related Documentation
- [Canonicalization & Determinism Patterns](../../docs/contributing/canonicalization-determinism.md)
- [RFC 8785 - JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785)

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IncludeBuildOutput>false</IncludeBuildOutput>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<RootNamespace>StellaOps.Determinism.Analyzers</RootNamespace>
<Description>Roslyn analyzer enforcing canonicalization at resolver boundaries (STELLA0100).</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Visible="false" />
<None Include="AnalyzerReleases.Shipped.md" Visible="false" />
<None Include="AnalyzerReleases.Unshipped.md" Visible="false" />
</ItemGroup>
</Project>