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,232 @@
// -----------------------------------------------------------------------------
// CallGraphExtractorRegistryTests.cs
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-02)
// Description: Tests for the call graph extractor registry.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.CallGraph.DotNet;
using StellaOps.Scanner.CallGraph.Go;
using StellaOps.Scanner.CallGraph.Java;
using StellaOps.Scanner.CallGraph.Node;
using StellaOps.Scanner.CallGraph.Python;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Scanner.CallGraph.Tests;
/// <summary>
/// Tests for <see cref="CallGraphExtractorRegistry"/> ensuring proper registration
/// and deterministic behavior across all language extractors.
/// </summary>
[Trait("Category", "Determinism")]
public class CallGraphExtractorRegistryTests
{
private readonly ITestOutputHelper _output;
private readonly FixedTimeProvider _timeProvider;
public CallGraphExtractorRegistryTests(ITestOutputHelper output)
{
_output = output;
_timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 12, 26, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public void Registry_ContainsAllExpectedLanguages()
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var languages = registry.SupportedLanguages;
// Assert
Assert.Contains("dotnet", languages);
Assert.Contains("go", languages);
Assert.Contains("java", languages);
Assert.Contains("node", languages);
Assert.Contains("python", languages);
Assert.Equal(5, languages.Count);
}
[Fact]
public void Registry_LanguagesAreOrderedDeterministically()
{
// Arrange - Create registries with extractors in different orders
var extractors1 = CreateAllExtractors().ToList();
var extractors2 = CreateAllExtractors().Reverse().ToList();
var registry1 = new CallGraphExtractorRegistry(extractors1);
var registry2 = new CallGraphExtractorRegistry(extractors2);
// Act
var languages1 = registry1.SupportedLanguages;
var languages2 = registry2.SupportedLanguages;
// Assert - Same order regardless of input order
Assert.Equal(languages1.Count, languages2.Count);
for (int i = 0; i < languages1.Count; i++)
{
Assert.Equal(languages1[i], languages2[i]);
}
// Verify alphabetical ordering
var sorted = languages1.OrderBy(l => l, StringComparer.OrdinalIgnoreCase).ToList();
Assert.Equal(sorted, languages1.ToList());
}
[Theory]
[InlineData("dotnet")]
[InlineData("go")]
[InlineData("java")]
[InlineData("node")]
[InlineData("python")]
public void Registry_GetExtractor_ReturnsCorrectExtractor(string language)
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var extractor = registry.GetExtractor(language);
// Assert
Assert.NotNull(extractor);
Assert.Equal(language, extractor.Language, StringComparer.OrdinalIgnoreCase);
}
[Theory]
[InlineData("JAVA")]
[InlineData("Java")]
[InlineData("PYTHON")]
[InlineData("Python")]
[InlineData("NODE")]
[InlineData("Node")]
public void Registry_GetExtractor_IsCaseInsensitive(string language)
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var extractor = registry.GetExtractor(language);
// Assert
Assert.NotNull(extractor);
}
[Theory]
[InlineData("rust")]
[InlineData("ruby")]
[InlineData("php")]
[InlineData("unknown")]
[InlineData("")]
[InlineData(null)]
public void Registry_GetExtractor_ReturnsNullForUnsupported(string? language)
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var extractor = registry.GetExtractor(language!);
// Assert
Assert.Null(extractor);
}
[Fact]
public void Registry_IsLanguageSupported_ReturnsCorrectValues()
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Assert - Supported languages
Assert.True(registry.IsLanguageSupported("java"));
Assert.True(registry.IsLanguageSupported("python"));
Assert.True(registry.IsLanguageSupported("node"));
Assert.True(registry.IsLanguageSupported("go"));
Assert.True(registry.IsLanguageSupported("dotnet"));
// Assert - Unsupported languages
Assert.False(registry.IsLanguageSupported("rust"));
Assert.False(registry.IsLanguageSupported(""));
Assert.False(registry.IsLanguageSupported(null!));
}
[Fact]
public void Registry_DuplicateRegistration_KeepsFirst()
{
// Arrange - Two extractors for same language
var extractor1 = new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance, _timeProvider);
var extractor2 = new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance, _timeProvider);
var extractors = new ICallGraphExtractor[] { extractor1, extractor2 };
// Act
var registry = new CallGraphExtractorRegistry(extractors);
// Assert - Only one Java extractor should be registered
var retrieved = registry.GetExtractor("java");
Assert.NotNull(retrieved);
Assert.Same(extractor1, retrieved);
}
[Fact]
public void Registry_EmptyExtractors_HandledGracefully()
{
// Arrange & Act
var registry = new CallGraphExtractorRegistry(Array.Empty<ICallGraphExtractor>());
// Assert
Assert.Empty(registry.SupportedLanguages);
Assert.Empty(registry.Extractors);
Assert.Null(registry.GetExtractor("java"));
Assert.False(registry.IsLanguageSupported("java"));
}
[Fact]
public void Registry_ExtractorsAreDeterministicallyOrdered()
{
// Arrange - Create registry multiple times with shuffled input
var random = new Random(42); // Fixed seed for reproducibility
var results = new List<IReadOnlyList<ICallGraphExtractor>>();
for (int i = 0; i < 5; i++)
{
var extractors = CreateAllExtractors()
.OrderBy(_ => random.Next())
.ToList();
var registry = new CallGraphExtractorRegistry(extractors);
results.Add(registry.Extractors);
}
// Assert - All registries have same extractor order
var first = results[0];
foreach (var result in results.Skip(1))
{
Assert.Equal(first.Count, result.Count);
for (int i = 0; i < first.Count; i++)
{
Assert.Equal(first[i].Language, result[i].Language);
}
}
}
private IEnumerable<ICallGraphExtractor> CreateAllExtractors()
{
yield return new DotNetCallGraphExtractor(
NullLogger<DotNetCallGraphExtractor>.Instance, _timeProvider);
yield return new GoCallGraphExtractor(
NullLogger<GoCallGraphExtractor>.Instance, _timeProvider);
yield return new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance, _timeProvider);
yield return new NodeCallGraphExtractor(_timeProvider);
yield return new PythonCallGraphExtractor(
NullLogger<PythonCallGraphExtractor>.Instance, _timeProvider);
}
}

View File

@@ -0,0 +1,325 @@
// -----------------------------------------------------------------------------
// FuncProofBuilderTests.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-18
// Description: Unit tests for FuncProofBuilder.
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Scanner.Evidence.Models;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class FuncProofBuilderTests
{
[Fact]
public void Build_WithBinaryIdentity_SetsFileProperties()
{
// Arrange
var fileHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc1";
var buildId = "build-12345";
var fileSize = 1024L;
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity(fileHash, buildId, fileSize)
.Build();
// Assert
proof.FileSha256.Should().Be(fileHash);
proof.BuildId.Should().Be(buildId);
proof.FileSize.Should().Be(fileSize);
proof.SchemaVersion.Should().Be(FuncProofConstants.SchemaVersion);
}
[Fact]
public void Build_WithSection_AddsSectionToProof()
{
// Arrange
var sectionName = ".text";
var sectionOffset = 0x1000UL;
var sectionSize = 0x5000UL;
var sectionHash = "section_hash_12345";
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddSection(sectionName, sectionOffset, sectionSize, sectionHash)
.Build();
// Assert
proof.Sections.Should().HaveCount(1);
proof.Sections![0].Name.Should().Be(sectionName);
proof.Sections![0].Offset.Should().Be(sectionOffset);
proof.Sections![0].Size.Should().Be(sectionSize);
proof.Sections![0].Hash.Should().Be(sectionHash);
}
[Fact]
public void Build_WithMultipleSections_AddsAllSections()
{
// Arrange & Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddSection(".text", 0x1000, 0x5000, "hash1")
.AddSection(".rodata", 0x6000, 0x2000, "hash2")
.AddSection(".data", 0x8000, 0x1000, "hash3")
.Build();
// Assert
proof.Sections.Should().HaveCount(3);
proof.Sections![0].Name.Should().Be(".text");
proof.Sections![1].Name.Should().Be(".rodata");
proof.Sections![2].Name.Should().Be(".data");
}
[Fact]
public void Build_WithFunction_AddsFunctionToProof()
{
// Arrange
var funcName = "main";
var funcOffset = 0x1100UL;
var funcSize = 256UL;
var symbolDigest = "symbol_digest_abc123";
var functionHash = "function_hash_def456";
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction(funcName, funcOffset, funcSize, symbolDigest, functionHash)
.Build();
// Assert
proof.Functions.Should().HaveCount(1);
proof.Functions![0].Name.Should().Be(funcName);
proof.Functions![0].Offset.Should().Be(funcOffset);
proof.Functions![0].Size.Should().Be(funcSize);
proof.Functions![0].SymbolDigest.Should().Be(symbolDigest);
proof.Functions![0].Hash.Should().Be(functionHash);
}
[Fact]
public void Build_WithFunctionCallers_SetsCallersOnFunction()
{
// Arrange
var callers = new List<string> { "caller1", "caller2", "caller3" };
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("main", 0x1100, 256, "sym", "hash", callers: callers)
.Build();
// Assert
proof.Functions![0].Callers.Should().BeEquivalentTo(callers);
}
[Fact]
public void Build_WithTrace_AddsTraceToProof()
{
// Arrange
var entryFunc = "vulnerable_func";
var hops = new List<string> { "main", "process_input", "parse_data", "vulnerable_func" };
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddTrace(entryFunc, hops, truncated: false)
.Build();
// Assert
proof.Traces.Should().HaveCount(1);
proof.Traces![0].EntryFunction.Should().Be(entryFunc);
proof.Traces![0].Hops.Should().BeEquivalentTo(hops);
proof.Traces![0].Truncated.Should().BeFalse();
}
[Fact]
public void Build_WithTruncatedTrace_SetsTruncatedFlag()
{
// Arrange
var hops = Enumerable.Range(0, 15).Select(i => $"func_{i}").ToList();
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddTrace("target", hops, truncated: true)
.Build();
// Assert
proof.Traces![0].Truncated.Should().BeTrue();
}
[Fact]
public void Build_WithMetadata_SetsMetadataProperties()
{
// Arrange
var tool = "test-tool";
var version = "1.0.0";
var timestamp = "2024-01-01T00:00:00Z";
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.WithMetadata(tool, version, timestamp)
.Build();
// Assert
proof.Metadata.Should().NotBeNull();
proof.Metadata!.Tool.Should().Be(tool);
proof.Metadata.ToolVersion.Should().Be(version);
proof.Metadata.CreatedAt.Should().Be(timestamp);
}
[Fact]
public void Build_GeneratesProofId()
{
// Arrange & Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym", "hash")
.Build();
// Assert
proof.ProofId.Should().NotBeNullOrEmpty();
proof.ProofId.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public void Build_SameInput_GeneratesSameProofId()
{
// Arrange
FuncProof BuildProof() => new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddSection(".text", 0x1000, 0x5000, "section_hash")
.AddFunction("main", 0x1100, 256, "sym", "hash")
.WithMetadata("tool", "1.0", "2024-01-01T00:00:00Z")
.Build();
// Act
var proof1 = BuildProof();
var proof2 = BuildProof();
// Assert
proof1.ProofId.Should().Be(proof2.ProofId);
}
[Fact]
public void Build_DifferentInput_GeneratesDifferentProofId()
{
// Arrange & Act
var proof1 = new FuncProofBuilder()
.WithBinaryIdentity("filehash1", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym1", "hash1")
.Build();
var proof2 = new FuncProofBuilder()
.WithBinaryIdentity("filehash2", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym2", "hash2")
.Build();
// Assert
proof1.ProofId.Should().NotBe(proof2.ProofId);
}
[Fact]
public void ComputeSymbolDigest_DeterministicForSameInput()
{
// Arrange
var name = "main";
var offset = 0x1000UL;
// Act
var digest1 = FuncProofBuilder.ComputeSymbolDigest(name, offset);
var digest2 = FuncProofBuilder.ComputeSymbolDigest(name, offset);
// Assert
digest1.Should().Be(digest2);
}
[Fact]
public void ComputeSymbolDigest_DifferentForDifferentOffset()
{
// Arrange
var name = "main";
// Act
var digest1 = FuncProofBuilder.ComputeSymbolDigest(name, 0x1000);
var digest2 = FuncProofBuilder.ComputeSymbolDigest(name, 0x2000);
// Assert
digest1.Should().NotBe(digest2);
}
[Fact]
public void ComputeFunctionHash_DeterministicForSameInput()
{
// Arrange
var bytes = new byte[] { 0x55, 0x48, 0x89, 0xe5, 0xc3 }; // push rbp; mov rbp, rsp; ret
// Act
var hash1 = FuncProofBuilder.ComputeFunctionHash(bytes);
var hash2 = FuncProofBuilder.ComputeFunctionHash(bytes);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeFunctionHash_DifferentForDifferentInput()
{
// Arrange
var bytes1 = new byte[] { 0x55, 0x48, 0x89, 0xe5, 0xc3 };
var bytes2 = new byte[] { 0x55, 0x48, 0x89, 0xe5, 0xc9, 0xc3 }; // includes leave
// Act
var hash1 = FuncProofBuilder.ComputeFunctionHash(bytes1);
var hash2 = FuncProofBuilder.ComputeFunctionHash(bytes2);
// Assert
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeProofId_DeterministicForSameProof()
{
// Arrange
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym", "hash")
.Build();
// Act
var id1 = FuncProofBuilder.ComputeProofId(proof);
var id2 = FuncProofBuilder.ComputeProofId(proof);
// Assert
id1.Should().Be(id2);
}
[Fact]
public void Build_FunctionOrdering_IsDeterministic()
{
// Arrange & Act
var proof1 = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("func_c", 0x3000, 100, "sym_c", "hash_c")
.AddFunction("func_a", 0x1000, 100, "sym_a", "hash_a")
.AddFunction("func_b", 0x2000, 100, "sym_b", "hash_b")
.Build();
var proof2 = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("func_b", 0x2000, 100, "sym_b", "hash_b")
.AddFunction("func_c", 0x3000, 100, "sym_c", "hash_c")
.AddFunction("func_a", 0x1000, 100, "sym_a", "hash_a")
.Build();
// Assert - functions should be sorted by offset for determinism
proof1.Functions![0].Name.Should().Be(proof2.Functions![0].Name);
proof1.Functions![1].Name.Should().Be(proof2.Functions![1].Name);
proof1.Functions![2].Name.Should().Be(proof2.Functions![2].Name);
proof1.ProofId.Should().Be(proof2.ProofId);
}
}

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// FuncProofDsseServiceTests.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-18
// Description: Unit tests for FuncProof DSSE signing and verification.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Evidence.Models;
using StellaOps.Scanner.ProofSpine;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class FuncProofDsseServiceTests
{
private readonly Mock<IDsseSigningService> _signingServiceMock;
private readonly IOptions<FuncProofDsseOptions> _options;
private readonly ILogger<FuncProofDsseService> _logger;
public FuncProofDsseServiceTests()
{
_signingServiceMock = new Mock<IDsseSigningService>();
_options = Options.Create(new FuncProofDsseOptions
{
KeyId = "test-key-id",
Algorithm = "hs256"
});
_logger = NullLogger<FuncProofDsseService>.Instance;
}
[Fact]
public async Task SignAsync_WithValidProof_ReturnsSignedEnvelope()
{
// Arrange
var proof = CreateTestProof();
var expectedEnvelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new DsseSignature("test-key-id", "test-signature") });
_signingServiceMock
.Setup(x => x.SignAsync(
It.IsAny<object>(),
FuncProofConstants.MediaType,
It.IsAny<ICryptoProfile>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedEnvelope);
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.SignAsync(proof);
// Assert
result.Should().NotBeNull();
result.Envelope.Should().Be(expectedEnvelope);
result.EnvelopeId.Should().NotBeNullOrEmpty();
result.EnvelopeJson.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignAsync_WithNullProofId_ThrowsArgumentException()
{
// Arrange
var proof = new FuncProof
{
ProofId = null, // Invalid
BuildId = "build-123",
FileSha256 = "abc123"
};
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => service.SignAsync(proof));
}
[Fact]
public async Task SignAsync_CallsSigningServiceWithCorrectPayloadType()
{
// Arrange
var proof = CreateTestProof();
var capturedPayloadType = string.Empty;
_signingServiceMock
.Setup(x => x.SignAsync(
It.IsAny<object>(),
It.IsAny<string>(),
It.IsAny<ICryptoProfile>(),
It.IsAny<CancellationToken>()))
.Callback<object, string, ICryptoProfile, CancellationToken>((_, payloadType, _, _) =>
{
capturedPayloadType = payloadType;
})
.ReturnsAsync(CreateTestEnvelope());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
await service.SignAsync(proof);
// Assert
capturedPayloadType.Should().Be(FuncProofConstants.MediaType);
}
[Fact]
public async Task VerifyAsync_WithValidEnvelope_ReturnsSuccessResult()
{
// Arrange
var proof = CreateTestProof();
var proofJson = System.Text.Json.JsonSerializer.Serialize(proof);
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(proofJson)),
new[] { new DsseSignature("test-key-id", "test-signature") });
_signingServiceMock
.Setup(x => x.VerifyAsync(envelope, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseVerificationOutcome(true, true, null));
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.VerifyAsync(envelope);
// Assert
result.IsValid.Should().BeTrue();
result.IsTrusted.Should().BeTrue();
result.FailureReason.Should().BeNull();
result.FuncProof.Should().NotBeNull();
}
[Fact]
public async Task VerifyAsync_WithWrongPayloadType_ReturnsInvalidResult()
{
// Arrange
var envelope = new DsseEnvelope(
"application/wrong-type",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new DsseSignature("test-key-id", "test-signature") });
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.VerifyAsync(envelope);
// Assert
result.IsValid.Should().BeFalse();
result.FailureReason.Should().Contain("Invalid payload type");
}
[Fact]
public async Task VerifyAsync_WithFailedSignature_ReturnsInvalidResult()
{
// Arrange
var proof = CreateTestProof();
var proofJson = System.Text.Json.JsonSerializer.Serialize(proof);
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(proofJson)),
new[] { new DsseSignature("test-key-id", "bad-signature") });
_signingServiceMock
.Setup(x => x.VerifyAsync(envelope, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseVerificationOutcome(false, false, "dsse_sig_mismatch"));
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.VerifyAsync(envelope);
// Assert
result.IsValid.Should().BeFalse();
result.FailureReason.Should().Be("dsse_sig_mismatch");
}
[Fact]
public void ExtractPayload_WithValidEnvelope_ReturnsFuncProof()
{
// Arrange
var proof = CreateTestProof();
var proofJson = System.Text.Json.JsonSerializer.Serialize(proof, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(proofJson)),
Array.Empty<DsseSignature>());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var extracted = service.ExtractPayload(envelope);
// Assert
extracted.Should().NotBeNull();
extracted!.ProofId.Should().Be(proof.ProofId);
extracted.BuildId.Should().Be(proof.BuildId);
}
[Fact]
public void ExtractPayload_WithInvalidBase64_ReturnsNull()
{
// Arrange
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
"not-valid-base64!!!",
Array.Empty<DsseSignature>());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var extracted = service.ExtractPayload(envelope);
// Assert
extracted.Should().BeNull();
}
[Fact]
public void ExtractPayload_WithInvalidJson_ReturnsNull()
{
// Arrange
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("not-valid-json")),
Array.Empty<DsseSignature>());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var extracted = service.ExtractPayload(envelope);
// Assert
extracted.Should().BeNull();
}
[Fact]
public void ToUnsignedEnvelope_CreatesValidEnvelope()
{
// Arrange
var proof = CreateTestProof();
// Act
var envelope = proof.ToUnsignedEnvelope();
// Assert
envelope.Should().NotBeNull();
envelope.PayloadType.Should().Be(FuncProofConstants.MediaType);
envelope.Signatures.Should().BeEmpty();
envelope.Payload.Should().NotBeNullOrEmpty();
}
[Fact]
public void ParseEnvelope_WithValidJson_ReturnsEnvelope()
{
// Arrange
var envelope = new DsseEnvelope(
"test-type",
"dGVzdA==",
new[] { new DsseSignature("key", "sig") });
var json = System.Text.Json.JsonSerializer.Serialize(envelope, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
// Act
var parsed = FuncProofDsseExtensions.ParseEnvelope(json);
// Assert
parsed.Should().NotBeNull();
parsed!.PayloadType.Should().Be("test-type");
}
[Fact]
public void ParseEnvelope_WithInvalidJson_ReturnsNull()
{
// Act
var parsed = FuncProofDsseExtensions.ParseEnvelope("invalid json {{{");
// Assert
parsed.Should().BeNull();
}
[Fact]
public void ParseEnvelope_WithEmptyString_ReturnsNull()
{
// Act
var parsed = FuncProofDsseExtensions.ParseEnvelope("");
// Assert
parsed.Should().BeNull();
}
private static FuncProof CreateTestProof()
{
return new FuncProofBuilder()
.WithBinaryIdentity(
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"build-123",
1024)
.AddSection(".text", 0x1000, 0x5000, "section_hash")
.AddFunction("main", 0x1100, 256, "sym_main", "func_hash_main")
.WithMetadata("test-tool", "1.0.0", "2024-01-01T00:00:00Z")
.Build();
}
private static DsseEnvelope CreateTestEnvelope()
{
return new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new DsseSignature("test-key-id", "test-signature") });
}
}

View File

@@ -0,0 +1,350 @@
// -----------------------------------------------------------------------------
// SbomFuncProofLinkerTests.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-15 — SBOM evidence link unit tests
// Description: Tests for SBOM-FuncProof linking functionality.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Evidence;
using StellaOps.Scanner.Evidence.Models;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class SbomFuncProofLinkerTests
{
private readonly SbomFuncProofLinker _linker;
public SbomFuncProofLinkerTests()
{
_linker = new SbomFuncProofLinker(NullLogger<SbomFuncProofLinker>.Instance);
}
[Fact]
public void LinkFuncProofEvidence_AddsEvidenceToComponent()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act
var result = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://registry.example.com/proofs:funcproof-test");
// Assert
var doc = JsonNode.Parse(result) as JsonObject;
doc.Should().NotBeNull();
var component = (doc!["components"] as JsonArray)?[0] as JsonObject;
component.Should().NotBeNull();
var evidence = component!["evidence"] as JsonObject;
evidence.Should().NotBeNull();
var callflow = evidence!["callflow"] as JsonObject;
callflow.Should().NotBeNull();
var frames = callflow!["frames"] as JsonArray;
frames.Should().NotBeNull();
frames!.Count.Should().BeGreaterThan(0);
}
[Fact]
public void LinkFuncProofEvidence_AddsExternalReference()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act
var result = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://registry.example.com/proofs:funcproof-test");
// Assert
var doc = JsonNode.Parse(result) as JsonObject;
var component = (doc!["components"] as JsonArray)?[0] as JsonObject;
var externalRefs = component!["externalReferences"] as JsonArray;
externalRefs.Should().NotBeNull();
externalRefs!.Count.Should().BeGreaterThan(0);
var evidenceRef = externalRefs[0] as JsonObject;
evidenceRef!["type"]!.GetValue<string>().Should().Be("evidence");
evidenceRef["url"]!.GetValue<string>().Should().Contain("oci://");
}
[Fact]
public void LinkFuncProofEvidence_ThrowsForNonCycloneDx()
{
// Arrange
var spdxSbom = """
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"packages": []
}
""";
var funcProof = CreateTestFuncProof();
// Act & Assert
var act = () => _linker.LinkFuncProofEvidence(
spdxSbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://test");
act.Should().Throw<ArgumentException>()
.WithMessage("*CycloneDX*");
}
[Fact]
public void LinkFuncProofEvidence_ThrowsForMissingComponent()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act & Assert
var act = () => _linker.LinkFuncProofEvidence(
sbom,
"nonexistent-component",
funcProof,
"sha256:abc123",
"oci://test");
act.Should().Throw<ArgumentException>()
.WithMessage("*not found*");
}
[Fact]
public void ExtractFuncProofReferences_ReturnsEmptyForNoEvidence()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
// Act
var refs = _linker.ExtractFuncProofReferences(sbom, "component-1");
// Assert
refs.Should().BeEmpty();
}
[Fact]
public void ExtractFuncProofReferences_FindsLinkedEvidence()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
var linkedSbom = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123def456",
"oci://registry.example.com/proofs:funcproof-v1");
// Act
var refs = _linker.ExtractFuncProofReferences(linkedSbom, "component-1");
// Assert
refs.Should().HaveCount(1);
refs[0].ProofId.Should().Be(funcProof.ProofId);
refs[0].BuildId.Should().Be(funcProof.BuildId);
refs[0].FunctionCount.Should().Be(funcProof.Functions.Length);
}
[Fact]
public void CreateEvidenceRef_PopulatesAllFields()
{
// Arrange
var funcProof = CreateTestFuncProof();
// Act
var evidenceRef = _linker.CreateEvidenceRef(
funcProof,
"sha256:proof-digest-123",
"oci://registry/proof:v1");
// Assert
evidenceRef.ProofId.Should().Be(funcProof.ProofId);
evidenceRef.BuildId.Should().Be(funcProof.BuildId);
evidenceRef.FileSha256.Should().Be(funcProof.FileSha256);
evidenceRef.ProofDigest.Should().Be("sha256:proof-digest-123");
evidenceRef.Location.Should().Be("oci://registry/proof:v1");
evidenceRef.FunctionCount.Should().Be(2);
evidenceRef.TraceCount.Should().Be(1);
}
[Fact]
public void LinkFuncProofEvidence_IncludesProofProperties()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act
var result = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://registry.example.com/proofs:funcproof-test");
// Assert
var doc = JsonNode.Parse(result) as JsonObject;
var component = (doc!["components"] as JsonArray)?[0] as JsonObject;
var evidence = component!["evidence"] as JsonObject;
var frames = (evidence!["callflow"] as JsonObject)!["frames"] as JsonArray;
var properties = (frames![0] as JsonObject)!["properties"] as JsonArray;
properties.Should().NotBeNull();
var typeProperty = properties!.OfType<JsonObject>()
.FirstOrDefault(p => p["name"]?.GetValue<string>() == "stellaops:evidence:type");
typeProperty.Should().NotBeNull();
typeProperty!["value"]!.GetValue<string>().Should().Be("funcproof");
var proofIdProperty = properties.OfType<JsonObject>()
.FirstOrDefault(p => p["name"]?.GetValue<string>() == "stellaops:funcproof:proofId");
proofIdProperty.Should().NotBeNull();
proofIdProperty!["value"]!.GetValue<string>().Should().Be(funcProof.ProofId);
}
[Fact]
public void LinkFuncProofEvidence_MergesWithExistingEvidence()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof1 = CreateTestFuncProof("proof-1", "build-1");
var funcProof2 = CreateTestFuncProof("proof-2", "build-2");
// Link first proof
var linkedSbom = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof1,
"sha256:digest1",
"oci://registry/proof1:v1");
// Act - Link second proof
var result = _linker.LinkFuncProofEvidence(
linkedSbom,
"component-1",
funcProof2,
"sha256:digest2",
"oci://registry/proof2:v1");
// Assert
var refs = _linker.ExtractFuncProofReferences(result, "component-1");
refs.Should().HaveCount(2);
refs.Select(r => r.ProofId).Should().Contain("proof-1");
refs.Select(r => r.ProofId).Should().Contain("proof-2");
}
private static string CreateMinimalCycloneDxSbom(string purl, string bomRef)
{
return $$"""
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"serialNumber": "urn:uuid:{{Guid.NewGuid()}}",
"components": [
{
"type": "library",
"bom-ref": "{{bomRef}}",
"purl": "{{purl}}",
"name": "test-component",
"version": "1.0.0"
}
]
}
""";
}
private static FuncProof CreateTestFuncProof(
string? proofId = null,
string? buildId = null)
{
proofId ??= "graph:test-proof-123";
buildId ??= "gnu-build-id-abc123";
return new FuncProof
{
ProofId = proofId,
SchemaVersion = FuncProofConstants.SchemaVersion,
BuildId = buildId,
BuildIdType = "gnu-build-id",
FileSha256 = "abc123def456789",
BinaryFormat = "elf",
Architecture = "x86_64",
IsStripped = false,
Sections = ImmutableDictionary<string, FuncProofSection>.Empty.Add(
".text",
new FuncProofSection
{
Hash = "blake3:section-hash-123",
Offset = 0x1000,
Size = 0x5000,
VirtualAddress = 0x401000
}),
Functions = ImmutableArray.Create(
new FuncProofFunction
{
SymbolDigest = "blake3:func1-digest",
Symbol = "main",
MangledName = "main",
Start = "0x401000",
End = "0x401100",
Size = 256,
FunctionHash = "blake3:func1-hash",
Confidence = 1.0,
DetectionMethod = "dwarf"
},
new FuncProofFunction
{
SymbolDigest = "blake3:func2-digest",
Symbol = "helper",
MangledName = "_Z6helperv",
Start = "0x401100",
End = "0x401200",
Size = 256,
FunctionHash = "blake3:func2-hash",
Confidence = 0.8,
DetectionMethod = "symbol"
}),
Traces = ImmutableArray.Create(
new FuncProofTrace
{
TraceId = "trace-1",
EdgeListHash = "blake3:edge-hash-1",
HopCount = 1,
EntrySymbolDigest = "blake3:func1-digest",
SinkSymbolDigest = "blake3:func2-digest",
Path = ImmutableArray.Create("blake3:func1-digest", "blake3:func2-digest"),
Truncated = false
}),
Metadata = new FuncProofMetadata
{
Generator = "StellaOps.Scanner",
GeneratorVersion = "1.0.0",
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
Properties = ImmutableDictionary<string, string>.Empty
}
};
}
}

View File

@@ -15,7 +15,9 @@
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -24,5 +26,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Evidence/StellaOps.Scanner.Evidence.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
</ItemGroup>
</Project>