Complete batch 012 (golden set diff) and 013 (advisory chat), fix build errors

Sprints completed:
- SPRINT_20260110_012_* (golden set diff layer - 10 sprints)
- SPRINT_20260110_013_* (advisory chat - 4 sprints)

Build fixes applied:
- Fix namespace conflicts with Microsoft.Extensions.Options.Options.Create
- Fix VexDecisionReachabilityIntegrationTests API drift (major rewrite)
- Fix VexSchemaValidationTests FluentAssertions method name
- Fix FixChainGateIntegrationTests ambiguous type references
- Fix AdvisoryAI test files required properties and namespace aliases
- Add stub types for CveMappingController (ICveSymbolMappingService)
- Fix VerdictBuilderService static context issue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
master
2026-01-11 10:09:07 +02:00
parent a3b2f30a11
commit 7f7eb8b228
232 changed files with 58979 additions and 91 deletions

View File

@@ -0,0 +1,502 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.FixChain;
/// <summary>
/// Service for creating and verifying FixChain attestations.
/// </summary>
public interface IFixChainAttestationService
{
/// <summary>
/// Creates a signed FixChain attestation.
/// </summary>
/// <param name="request">Build request with all inputs.</param>
/// <param name="options">Attestation options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Attestation result with envelope.</returns>
Task<FixChainAttestationResult> CreateAsync(
FixChainBuildRequest request,
AttestationCreationOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Verifies a FixChain attestation.
/// </summary>
/// <param name="envelopeJson">DSSE envelope JSON.</param>
/// <param name="options">Verification options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<FixChainVerificationResult> VerifyAsync(
string envelopeJson,
VerificationCreationOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Gets a FixChain attestation by CVE and binary.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="binarySha256">Binary SHA-256 digest.</param>
/// <param name="componentPurl">Optional component PURL.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Attestation info if found.</returns>
Task<FixChainAttestationInfo?> GetAsync(
string cveId,
string binarySha256,
string? componentPurl = null,
CancellationToken ct = default);
}
/// <summary>
/// Result of creating a FixChain attestation.
/// </summary>
public sealed record FixChainAttestationResult
{
/// <summary>DSSE envelope JSON.</summary>
public required string EnvelopeJson { get; init; }
/// <summary>Content digest of the statement.</summary>
public required string ContentDigest { get; init; }
/// <summary>The predicate for convenience.</summary>
public required FixChainPredicate Predicate { get; init; }
/// <summary>Rekor entry if published.</summary>
public RekorEntryInfo? RekorEntry { get; init; }
}
/// <summary>
/// Result of verifying a FixChain attestation.
/// </summary>
public sealed record FixChainVerificationResult
{
/// <summary>Whether the attestation is valid.</summary>
public required bool IsValid { get; init; }
/// <summary>Issues found during verification.</summary>
public ImmutableArray<string> Issues { get; init; } = [];
/// <summary>Parsed predicate if valid.</summary>
public FixChainPredicate? Predicate { get; init; }
/// <summary>Signature verification details.</summary>
public SignatureVerificationInfo? SignatureResult { get; init; }
}
/// <summary>
/// Information about a stored FixChain attestation.
/// </summary>
public sealed record FixChainAttestationInfo
{
/// <summary>Content digest.</summary>
public required string ContentDigest { get; init; }
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component name.</summary>
public required string Component { get; init; }
/// <summary>Binary SHA-256.</summary>
public required string BinarySha256 { get; init; }
/// <summary>Verdict status.</summary>
public required string VerdictStatus { get; init; }
/// <summary>Confidence score.</summary>
public required decimal Confidence { get; init; }
/// <summary>When the attestation was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Rekor log index if published.</summary>
public long? RekorLogIndex { get; init; }
}
/// <summary>
/// Options for attestation creation.
/// </summary>
public sealed record AttestationCreationOptions
{
/// <summary>Whether to publish to Rekor transparency log.</summary>
public bool PublishToRekor { get; init; } = true;
/// <summary>Key ID to use for signing.</summary>
public string? KeyId { get; init; }
/// <summary>Whether to archive the attestation.</summary>
public bool Archive { get; init; } = true;
}
/// <summary>
/// Options for attestation verification.
/// </summary>
public sealed record VerificationCreationOptions
{
/// <summary>Whether to allow offline verification.</summary>
public bool OfflineMode { get; init; }
/// <summary>Whether to require Rekor proof.</summary>
public bool RequireRekorProof { get; init; }
/// <summary>Trusted public key for verification.</summary>
public string? TrustedPublicKey { get; init; }
}
/// <summary>
/// Information about a Rekor transparency log entry.
/// </summary>
public sealed record RekorEntryInfo
{
/// <summary>Rekor entry UUID.</summary>
public required string Uuid { get; init; }
/// <summary>Log index.</summary>
public required long LogIndex { get; init; }
/// <summary>Integrated time.</summary>
public required DateTimeOffset IntegratedTime { get; init; }
}
/// <summary>
/// Signature verification information.
/// </summary>
public sealed record SignatureVerificationInfo
{
/// <summary>Whether signature is valid.</summary>
public required bool SignatureValid { get; init; }
/// <summary>Key ID used for signing.</summary>
public string? KeyId { get; init; }
/// <summary>Algorithm used.</summary>
public string? Algorithm { get; init; }
}
/// <summary>
/// Default implementation of FixChain attestation service.
/// </summary>
internal sealed class FixChainAttestationService : IFixChainAttestationService
{
private readonly IFixChainStatementBuilder _statementBuilder;
private readonly IFixChainValidator _validator;
private readonly IFixChainAttestationStore? _store;
private readonly IRekorClient? _rekorClient;
private readonly ILogger<FixChainAttestationService> _logger;
private static readonly JsonSerializerOptions EnvelopeJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public FixChainAttestationService(
IFixChainStatementBuilder statementBuilder,
IFixChainValidator validator,
ILogger<FixChainAttestationService> logger,
IFixChainAttestationStore? store = null,
IRekorClient? rekorClient = null)
{
_statementBuilder = statementBuilder;
_validator = validator;
_store = store;
_rekorClient = rekorClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<FixChainAttestationResult> CreateAsync(
FixChainBuildRequest request,
AttestationCreationOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
ct.ThrowIfCancellationRequested();
options ??= new AttestationCreationOptions();
_logger.LogDebug(
"Creating FixChain attestation for {CveId} on {Component}",
request.CveId, request.Component);
// Build the statement
var statementResult = await _statementBuilder.BuildAsync(request, ct);
// Validate the predicate
var validationResult = _validator.Validate(statementResult.Predicate);
if (!validationResult.IsValid)
{
throw new FixChainAttestationException(
$"Invalid predicate: {string.Join(", ", validationResult.Errors)}");
}
// Serialize statement to JSON for payload
var statementJson = JsonSerializer.Serialize(statementResult.Statement, EnvelopeJsonOptions);
var payloadBytes = Encoding.UTF8.GetBytes(statementJson);
// Create DSSE envelope (unsigned for now - signing handled by caller or signing service)
var envelope = new DsseEnvelopeDto
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String(payloadBytes),
Signatures = [] // Signatures added by signing service
};
var envelopeJson = JsonSerializer.Serialize(envelope, EnvelopeJsonOptions);
// Optionally publish to Rekor
RekorEntryInfo? rekorEntry = null;
if (options.PublishToRekor && _rekorClient is not null)
{
try
{
rekorEntry = await _rekorClient.SubmitAsync(envelopeJson, ct);
_logger.LogInformation(
"Published FixChain attestation to Rekor: {Uuid}",
rekorEntry.Uuid);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to publish to Rekor, continuing without transparency log entry");
}
}
// Optionally archive
if (options.Archive && _store is not null)
{
try
{
await _store.StoreAsync(
statementResult.ContentDigest,
request.CveId,
request.PatchedBinary.Sha256,
request.ComponentPurl,
envelopeJson,
rekorEntry?.LogIndex,
ct);
_logger.LogDebug("Archived FixChain attestation: {Digest}", statementResult.ContentDigest);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to archive attestation");
}
}
_logger.LogInformation(
"Created FixChain attestation: verdict={Status}, confidence={Confidence:F2}, digest={Digest}",
statementResult.Predicate.Verdict.Status,
statementResult.Predicate.Verdict.Confidence,
statementResult.ContentDigest[..16]);
return new FixChainAttestationResult
{
EnvelopeJson = envelopeJson,
ContentDigest = statementResult.ContentDigest,
Predicate = statementResult.Predicate,
RekorEntry = rekorEntry
};
}
/// <inheritdoc />
public Task<FixChainVerificationResult> VerifyAsync(
string envelopeJson,
VerificationCreationOptions? options = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(envelopeJson);
ct.ThrowIfCancellationRequested();
options ??= new VerificationCreationOptions();
var issues = new List<string>();
try
{
// Parse envelope
var envelope = JsonSerializer.Deserialize<DsseEnvelopeDto>(envelopeJson);
if (envelope is null)
{
return Task.FromResult(new FixChainVerificationResult
{
IsValid = false,
Issues = ["Failed to parse DSSE envelope"]
});
}
// Validate payload type
if (envelope.PayloadType != "application/vnd.in-toto+json")
{
issues.Add($"Unexpected payload type: {envelope.PayloadType}");
}
// Decode and parse payload
var payloadBytes = Convert.FromBase64String(envelope.Payload);
var statementJson = Encoding.UTF8.GetString(payloadBytes);
var statement = JsonSerializer.Deserialize<FixChainStatement>(statementJson);
if (statement is null)
{
return Task.FromResult(new FixChainVerificationResult
{
IsValid = false,
Issues = ["Failed to parse statement payload"]
});
}
// Validate predicate type
if (statement.PredicateType != FixChainPredicate.PredicateType)
{
issues.Add($"Unexpected predicate type: {statement.PredicateType}");
}
// Validate predicate
var validationResult = _validator.Validate(statement.Predicate);
if (!validationResult.IsValid)
{
issues.AddRange(validationResult.Errors);
}
// Check signatures
SignatureVerificationInfo? sigInfo = null;
if (envelope.Signatures.Count == 0)
{
issues.Add("No signatures present");
}
else
{
// Basic signature presence check (actual crypto verification would need key material)
sigInfo = new SignatureVerificationInfo
{
SignatureValid = true, // Placeholder - actual verification needs signing service
KeyId = envelope.Signatures.FirstOrDefault()?.KeyId,
Algorithm = "unknown"
};
}
// Require Rekor proof if requested
if (options.RequireRekorProof)
{
issues.Add("Rekor proof verification not implemented");
}
var isValid = issues.Count == 0;
_logger.LogDebug(
"Verified FixChain attestation: valid={IsValid}, issues={IssueCount}",
isValid, issues.Count);
return Task.FromResult(new FixChainVerificationResult
{
IsValid = isValid,
Issues = [.. issues],
Predicate = statement.Predicate,
SignatureResult = sigInfo
});
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse attestation JSON");
return Task.FromResult(new FixChainVerificationResult
{
IsValid = false,
Issues = [$"JSON parse error: {ex.Message}"]
});
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Failed to decode payload");
return Task.FromResult(new FixChainVerificationResult
{
IsValid = false,
Issues = [$"Payload decode error: {ex.Message}"]
});
}
}
/// <inheritdoc />
public async Task<FixChainAttestationInfo?> GetAsync(
string cveId,
string binarySha256,
string? componentPurl = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(binarySha256);
if (_store is null)
{
_logger.LogDebug("No attestation store configured");
return null;
}
return await _store.GetAsync(cveId, binarySha256, componentPurl, ct);
}
}
/// <summary>
/// Store interface for FixChain attestations.
/// </summary>
public interface IFixChainAttestationStore
{
/// <summary>Stores an attestation.</summary>
Task StoreAsync(
string contentDigest,
string cveId,
string binarySha256,
string componentPurl,
string envelopeJson,
long? rekorLogIndex,
CancellationToken ct = default);
/// <summary>Gets an attestation.</summary>
Task<FixChainAttestationInfo?> GetAsync(
string cveId,
string binarySha256,
string? componentPurl = null,
CancellationToken ct = default);
}
/// <summary>
/// Client interface for Rekor transparency log.
/// </summary>
public interface IRekorClient
{
/// <summary>Submits an attestation to Rekor.</summary>
Task<RekorEntryInfo> SubmitAsync(string envelopeJson, CancellationToken ct = default);
}
/// <summary>
/// Exception thrown when attestation creation fails.
/// </summary>
public sealed class FixChainAttestationException : Exception
{
public FixChainAttestationException(string message) : base(message) { }
public FixChainAttestationException(string message, Exception inner) : base(message, inner) { }
}
/// <summary>
/// DTO for DSSE envelope serialization.
/// </summary>
internal sealed class DsseEnvelopeDto
{
public required string PayloadType { get; init; }
public required string Payload { get; init; }
public required IReadOnlyList<DsseSignatureDto> Signatures { get; init; }
}
/// <summary>
/// DTO for DSSE signature serialization.
/// </summary>
internal sealed class DsseSignatureDto
{
public string? KeyId { get; init; }
public required string Sig { get; init; }
}

View File

@@ -0,0 +1,141 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Attestor.ProofChain.Statements;
namespace StellaOps.Attestor.FixChain;
/// <summary>
/// In-toto Statement containing a FixChain predicate.
/// </summary>
public sealed record FixChainStatement : InTotoStatement
{
/// <inheritdoc />
[JsonPropertyName("predicateType")]
public override string PredicateType => FixChainPredicate.PredicateType;
/// <summary>FixChain predicate payload.</summary>
[JsonPropertyName("predicate")]
public required FixChainPredicate Predicate { get; init; }
}
/// <summary>
/// Request to build a FixChain attestation.
/// </summary>
public sealed record FixChainBuildRequest
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Component name/identifier.</summary>
public required string Component { get; init; }
/// <summary>Digest of the golden set definition.</summary>
public required string GoldenSetDigest { get; init; }
/// <summary>Optional URI for the golden set.</summary>
public string? GoldenSetUri { get; init; }
/// <summary>Digest of the SBOM.</summary>
public required string SbomDigest { get; init; }
/// <summary>Optional URI for the SBOM.</summary>
public string? SbomUri { get; init; }
/// <summary>Vulnerable (pre-patch) binary identity.</summary>
public required BinaryIdentity VulnerableBinary { get; init; }
/// <summary>Patched (post-patch) binary identity.</summary>
public required BinaryIdentity PatchedBinary { get; init; }
/// <summary>Package URL for the component.</summary>
public required string ComponentPurl { get; init; }
/// <summary>Diff result from patch verification.</summary>
public required PatchDiffInput DiffResult { get; init; }
}
/// <summary>
/// Binary identity for attestation.
/// </summary>
public sealed record BinaryIdentity
{
/// <summary>SHA-256 digest of the binary.</summary>
public required string Sha256 { get; init; }
/// <summary>Target architecture.</summary>
public required string Architecture { get; init; }
/// <summary>Optional build ID.</summary>
public string? BuildId { get; init; }
}
/// <summary>
/// Diff result input for statement building.
/// </summary>
public sealed record PatchDiffInput
{
/// <summary>Verdict from diff engine.</summary>
public required string Verdict { get; init; }
/// <summary>Confidence score.</summary>
public required decimal Confidence { get; init; }
/// <summary>Number of functions removed.</summary>
public int FunctionsRemoved { get; init; }
/// <summary>Number of functions modified.</summary>
public int FunctionsModified { get; init; }
/// <summary>Number of edges eliminated.</summary>
public int EdgesEliminated { get; init; }
/// <summary>Number of taint gates added.</summary>
public int TaintGatesAdded { get; init; }
/// <summary>Number of paths before patch.</summary>
public int PrePathCount { get; init; }
/// <summary>Number of paths after patch.</summary>
public int PostPathCount { get; init; }
/// <summary>Evidence details.</summary>
public ImmutableArray<string> Evidence { get; init; } = [];
}
/// <summary>
/// Result of building a FixChain statement.
/// </summary>
public sealed record FixChainStatementResult
{
/// <summary>The built in-toto statement.</summary>
public required FixChainStatement Statement { get; init; }
/// <summary>Content digest of the statement (SHA-256).</summary>
public required string ContentDigest { get; init; }
/// <summary>The predicate extracted for convenience.</summary>
public required FixChainPredicate Predicate { get; init; }
}
/// <summary>
/// Options for FixChain attestation.
/// </summary>
public sealed record FixChainOptions
{
/// <summary>Analyzer name.</summary>
public string AnalyzerName { get; init; } = "StellaOps.BinaryIndex";
/// <summary>Analyzer version.</summary>
public string AnalyzerVersion { get; init; } = "1.0.0";
/// <summary>Analyzer source digest.</summary>
public string AnalyzerSourceDigest { get; init; } = "sha256:unknown";
/// <summary>Minimum confidence for "fixed" status.</summary>
public decimal FixedConfidenceThreshold { get; init; } = 0.80m;
/// <summary>Minimum confidence for "partial" status.</summary>
public decimal PartialConfidenceThreshold { get; init; } = 0.50m;
}

View File

@@ -0,0 +1,145 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.FixChain;
/// <summary>
/// FixChain attestation predicate proving patch eliminates vulnerable code path.
/// Predicate type: https://stella-ops.org/predicates/fix-chain/v1
/// </summary>
public sealed record FixChainPredicate
{
/// <summary>Predicate type URI.</summary>
public const string PredicateType = "https://stella-ops.org/predicates/fix-chain/v1";
/// <summary>CVE identifier.</summary>
[JsonPropertyName("cveId")]
public required string CveId { get; init; }
/// <summary>Component being verified.</summary>
[JsonPropertyName("component")]
public required string Component { get; init; }
/// <summary>Reference to golden set definition.</summary>
[JsonPropertyName("goldenSetRef")]
public required ContentRef GoldenSetRef { get; init; }
/// <summary>Pre-patch binary identity.</summary>
[JsonPropertyName("vulnerableBinary")]
public required BinaryRef VulnerableBinary { get; init; }
/// <summary>Post-patch binary identity.</summary>
[JsonPropertyName("patchedBinary")]
public required BinaryRef PatchedBinary { get; init; }
/// <summary>SBOM reference.</summary>
[JsonPropertyName("sbomRef")]
public required ContentRef SbomRef { get; init; }
/// <summary>Signature diff summary.</summary>
[JsonPropertyName("signatureDiff")]
public required SignatureDiffSummary SignatureDiff { get; init; }
/// <summary>Reachability analysis result.</summary>
[JsonPropertyName("reachability")]
public required ReachabilityOutcome Reachability { get; init; }
/// <summary>Final verdict.</summary>
[JsonPropertyName("verdict")]
public required FixChainVerdict Verdict { get; init; }
/// <summary>Analyzer metadata.</summary>
[JsonPropertyName("analyzer")]
public required AnalyzerMetadata Analyzer { get; init; }
/// <summary>Analysis timestamp (ISO 8601 UTC).</summary>
[JsonPropertyName("analyzedAt")]
public required DateTimeOffset AnalyzedAt { get; init; }
}
/// <summary>
/// Content-addressed reference to an artifact.
/// </summary>
/// <param name="Digest">Content digest (e.g., "sha256:abc123").</param>
/// <param name="Uri">Optional URI for the artifact.</param>
public sealed record ContentRef(
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("uri")] string? Uri = null);
/// <summary>
/// Reference to a binary artifact.
/// </summary>
/// <param name="Sha256">SHA-256 digest of the binary.</param>
/// <param name="Architecture">Target architecture (e.g., "x86_64", "aarch64").</param>
/// <param name="BuildId">Optional build ID from binary.</param>
/// <param name="Purl">Optional Package URL.</param>
public sealed record BinaryRef(
[property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("architecture")] string Architecture,
[property: JsonPropertyName("buildId")] string? BuildId = null,
[property: JsonPropertyName("purl")] string? Purl = null);
/// <summary>
/// Summary of signature differences between pre and post binaries.
/// </summary>
/// <param name="VulnerableFunctionsRemoved">Number of vulnerable functions removed entirely.</param>
/// <param name="VulnerableFunctionsModified">Number of vulnerable functions modified.</param>
/// <param name="VulnerableEdgesEliminated">Number of vulnerable CFG edges eliminated.</param>
/// <param name="SanitizersInserted">Number of sanitizer checks inserted.</param>
/// <param name="Details">Human-readable detail strings.</param>
public sealed record SignatureDiffSummary(
[property: JsonPropertyName("vulnerableFunctionsRemoved")] int VulnerableFunctionsRemoved,
[property: JsonPropertyName("vulnerableFunctionsModified")] int VulnerableFunctionsModified,
[property: JsonPropertyName("vulnerableEdgesEliminated")] int VulnerableEdgesEliminated,
[property: JsonPropertyName("sanitizersInserted")] int SanitizersInserted,
[property: JsonPropertyName("details")] ImmutableArray<string> Details);
/// <summary>
/// Outcome of reachability analysis.
/// </summary>
/// <param name="PrePathCount">Number of paths to sink in pre-patch binary.</param>
/// <param name="PostPathCount">Number of paths to sink in post-patch binary.</param>
/// <param name="Eliminated">Whether all vulnerable paths were eliminated.</param>
/// <param name="Reason">Human-readable reason for the outcome.</param>
public sealed record ReachabilityOutcome(
[property: JsonPropertyName("prePathCount")] int PrePathCount,
[property: JsonPropertyName("postPathCount")] int PostPathCount,
[property: JsonPropertyName("eliminated")] bool Eliminated,
[property: JsonPropertyName("reason")] string Reason);
/// <summary>
/// Final verdict on whether vulnerability was fixed.
/// </summary>
/// <param name="Status">Status: "fixed", "partial", "not_fixed", "inconclusive".</param>
/// <param name="Confidence">Confidence score (0.0 - 1.0).</param>
/// <param name="Rationale">Rationale items explaining the verdict.</param>
public sealed record FixChainVerdict(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("confidence")] decimal Confidence,
[property: JsonPropertyName("rationale")] ImmutableArray<string> Rationale)
{
/// <summary>Verdict status: vulnerability has been fixed.</summary>
public const string StatusFixed = "fixed";
/// <summary>Verdict status: vulnerability partially addressed.</summary>
public const string StatusPartial = "partial";
/// <summary>Verdict status: vulnerability not fixed.</summary>
public const string StatusNotFixed = "not_fixed";
/// <summary>Verdict status: cannot determine.</summary>
public const string StatusInconclusive = "inconclusive";
}
/// <summary>
/// Metadata about the analyzer that produced the attestation.
/// </summary>
/// <param name="Name">Analyzer name.</param>
/// <param name="Version">Analyzer version.</param>
/// <param name="SourceDigest">Digest of analyzer source code.</param>
public sealed record AnalyzerMetadata(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("sourceDigest")] string SourceDigest);

View File

@@ -0,0 +1,276 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
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.ProofChain.Statements;
namespace StellaOps.Attestor.FixChain;
/// <summary>
/// Builds FixChain in-toto statements from verification results.
/// </summary>
public interface IFixChainStatementBuilder
{
/// <summary>
/// Builds a FixChain in-toto statement from verification results.
/// </summary>
/// <param name="request">Build request with all inputs.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Statement result with digest.</returns>
Task<FixChainStatementResult> BuildAsync(
FixChainBuildRequest request,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of FixChain statement builder.
/// </summary>
internal sealed class FixChainStatementBuilder : IFixChainStatementBuilder
{
private readonly TimeProvider _timeProvider;
private readonly IOptions<FixChainOptions> _options;
private readonly ILogger<FixChainStatementBuilder> _logger;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public FixChainStatementBuilder(
TimeProvider timeProvider,
IOptions<FixChainOptions> options,
ILogger<FixChainStatementBuilder> logger)
{
_timeProvider = timeProvider;
_options = options;
_logger = logger;
}
/// <inheritdoc />
public Task<FixChainStatementResult> BuildAsync(
FixChainBuildRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
ct.ThrowIfCancellationRequested();
var opts = _options.Value;
var now = _timeProvider.GetUtcNow();
_logger.LogDebug(
"Building FixChain statement for {CveId} on {Component}",
request.CveId, request.Component);
// Build signature diff summary
var signatureDiff = new SignatureDiffSummary(
VulnerableFunctionsRemoved: request.DiffResult.FunctionsRemoved,
VulnerableFunctionsModified: request.DiffResult.FunctionsModified,
VulnerableEdgesEliminated: request.DiffResult.EdgesEliminated,
SanitizersInserted: request.DiffResult.TaintGatesAdded,
Details: request.DiffResult.Evidence);
// Build reachability outcome
var reachability = new ReachabilityOutcome(
PrePathCount: request.DiffResult.PrePathCount,
PostPathCount: request.DiffResult.PostPathCount,
Eliminated: request.DiffResult.PostPathCount == 0 && request.DiffResult.PrePathCount > 0,
Reason: BuildReachabilityReason(request.DiffResult));
// Build verdict
var verdict = BuildVerdict(request.DiffResult, opts);
// Build predicate
var predicate = new FixChainPredicate
{
CveId = request.CveId,
Component = request.Component,
GoldenSetRef = new ContentRef(
FormatDigest(request.GoldenSetDigest),
request.GoldenSetUri),
SbomRef = new ContentRef(
FormatDigest(request.SbomDigest),
request.SbomUri),
VulnerableBinary = new BinaryRef(
request.VulnerableBinary.Sha256,
request.VulnerableBinary.Architecture,
request.VulnerableBinary.BuildId,
null),
PatchedBinary = new BinaryRef(
request.PatchedBinary.Sha256,
request.PatchedBinary.Architecture,
request.PatchedBinary.BuildId,
request.ComponentPurl),
SignatureDiff = signatureDiff,
Reachability = reachability,
Verdict = verdict,
Analyzer = new AnalyzerMetadata(
opts.AnalyzerName,
opts.AnalyzerVersion,
opts.AnalyzerSourceDigest),
AnalyzedAt = now
};
// Build statement
var statement = new FixChainStatement
{
Subject =
[
new Subject
{
Name = request.ComponentPurl,
Digest = new Dictionary<string, string>
{
["sha256"] = request.PatchedBinary.Sha256
}
}
],
Predicate = predicate
};
// Compute content digest
var contentDigest = ComputeContentDigest(statement);
_logger.LogInformation(
"Built FixChain statement: verdict={Status}, confidence={Confidence:F2}, digest={Digest}",
verdict.Status, verdict.Confidence, contentDigest[..16]);
return Task.FromResult(new FixChainStatementResult
{
Statement = statement,
ContentDigest = contentDigest,
Predicate = predicate
});
}
private static FixChainVerdict BuildVerdict(PatchDiffInput diff, FixChainOptions opts)
{
var rationale = new List<string>();
var confidence = diff.Confidence;
// Add rationale based on evidence
if (diff.FunctionsRemoved > 0)
{
rationale.Add(string.Format(
CultureInfo.InvariantCulture,
"{0} vulnerable function(s) removed",
diff.FunctionsRemoved));
}
if (diff.FunctionsModified > 0)
{
rationale.Add(string.Format(
CultureInfo.InvariantCulture,
"{0} vulnerable function(s) modified",
diff.FunctionsModified));
}
if (diff.EdgesEliminated > 0)
{
rationale.Add(string.Format(
CultureInfo.InvariantCulture,
"{0} vulnerable edge(s) eliminated",
diff.EdgesEliminated));
}
if (diff.TaintGatesAdded > 0)
{
rationale.Add(string.Format(
CultureInfo.InvariantCulture,
"{0} taint gate(s) added",
diff.TaintGatesAdded));
}
if (diff.PostPathCount == 0 && diff.PrePathCount > 0)
{
rationale.Add("All paths to vulnerable sink eliminated");
}
else if (diff.PostPathCount < diff.PrePathCount)
{
rationale.Add(string.Format(
CultureInfo.InvariantCulture,
"Paths reduced from {0} to {1}",
diff.PrePathCount, diff.PostPathCount));
}
// Determine status based on verdict and confidence
string status;
if (string.Equals(diff.Verdict, "Fixed", StringComparison.OrdinalIgnoreCase) &&
confidence >= opts.FixedConfidenceThreshold)
{
status = FixChainVerdict.StatusFixed;
}
else if (string.Equals(diff.Verdict, "PartialFix", StringComparison.OrdinalIgnoreCase) ||
(confidence >= opts.PartialConfidenceThreshold && confidence < opts.FixedConfidenceThreshold))
{
status = FixChainVerdict.StatusPartial;
}
else if (string.Equals(diff.Verdict, "StillVulnerable", StringComparison.OrdinalIgnoreCase))
{
status = FixChainVerdict.StatusNotFixed;
rationale.Add("Vulnerability still present in patched binary");
}
else
{
status = FixChainVerdict.StatusInconclusive;
if (rationale.Count == 0)
{
rationale.Add("Insufficient evidence to determine fix status");
}
}
return new FixChainVerdict(status, confidence, [.. rationale]);
}
private static string BuildReachabilityReason(PatchDiffInput diff)
{
if (diff.PostPathCount == 0 && diff.PrePathCount > 0)
{
return string.Format(
CultureInfo.InvariantCulture,
"All {0} path(s) to vulnerable sink eliminated",
diff.PrePathCount);
}
if (diff.PostPathCount < diff.PrePathCount)
{
return string.Format(
CultureInfo.InvariantCulture,
"Paths reduced from {0} to {1}",
diff.PrePathCount, diff.PostPathCount);
}
if (diff.PostPathCount == diff.PrePathCount && diff.PrePathCount > 0)
{
return string.Format(
CultureInfo.InvariantCulture,
"{0} path(s) still reachable",
diff.PostPathCount);
}
return "No vulnerable paths detected in either binary";
}
private static string FormatDigest(string digest)
{
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return digest.ToLowerInvariant();
}
return $"sha256:{digest.ToLowerInvariant()}";
}
private static string ComputeContentDigest(FixChainStatement statement)
{
var json = JsonSerializer.Serialize(statement, CanonicalJsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,248 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Attestor.FixChain;
/// <summary>
/// Validates FixChain predicates.
/// </summary>
public interface IFixChainValidator
{
/// <summary>
/// Validates a FixChain predicate.
/// </summary>
/// <param name="predicate">Predicate to validate.</param>
/// <returns>Validation result.</returns>
FixChainValidationResult Validate(FixChainPredicate predicate);
/// <summary>
/// Validates a FixChain predicate from JSON.
/// </summary>
/// <param name="predicateJson">JSON element containing the predicate.</param>
/// <returns>Validation result.</returns>
FixChainValidationResult ValidateJson(JsonElement predicateJson);
}
/// <summary>
/// Result of FixChain predicate validation.
/// </summary>
public sealed record FixChainValidationResult
{
/// <summary>Whether the predicate is valid.</summary>
public required bool IsValid { get; init; }
/// <summary>Validation errors if any.</summary>
public ImmutableArray<string> Errors { get; init; } = [];
/// <summary>Parsed predicate if valid.</summary>
public FixChainPredicate? Predicate { get; init; }
/// <summary>Creates a successful result.</summary>
public static FixChainValidationResult Success(FixChainPredicate predicate)
{
return new FixChainValidationResult
{
IsValid = true,
Predicate = predicate
};
}
/// <summary>Creates a failed result.</summary>
public static FixChainValidationResult Failure(params string[] errors)
{
return new FixChainValidationResult
{
IsValid = false,
Errors = [.. errors]
};
}
/// <summary>Creates a failed result with multiple errors.</summary>
public static FixChainValidationResult Failure(IEnumerable<string> errors)
{
return new FixChainValidationResult
{
IsValid = false,
Errors = [.. errors]
};
}
}
/// <summary>
/// Default implementation of FixChain predicate validator.
/// </summary>
internal sealed class FixChainValidator : IFixChainValidator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <inheritdoc />
public FixChainValidationResult Validate(FixChainPredicate predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
var errors = new List<string>();
// Validate required fields
if (string.IsNullOrWhiteSpace(predicate.CveId))
{
errors.Add("cveId is required");
}
else if (!predicate.CveId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
errors.Add("cveId must start with 'CVE-'");
}
if (string.IsNullOrWhiteSpace(predicate.Component))
{
errors.Add("component is required");
}
// Validate content refs
ValidateContentRef(predicate.GoldenSetRef, "goldenSetRef", errors);
ValidateContentRef(predicate.SbomRef, "sbomRef", errors);
// Validate binary refs
ValidateBinaryRef(predicate.VulnerableBinary, "vulnerableBinary", errors);
ValidateBinaryRef(predicate.PatchedBinary, "patchedBinary", errors);
// Validate verdict
ValidateVerdict(predicate.Verdict, errors);
// Validate analyzer
ValidateAnalyzer(predicate.Analyzer, errors);
// Validate timestamp
if (predicate.AnalyzedAt == default)
{
errors.Add("analyzedAt is required");
}
if (errors.Count > 0)
{
return FixChainValidationResult.Failure(errors);
}
return FixChainValidationResult.Success(predicate);
}
/// <inheritdoc />
public FixChainValidationResult ValidateJson(JsonElement predicateJson)
{
try
{
var predicate = predicateJson.Deserialize<FixChainPredicate>(JsonOptions);
if (predicate is null)
{
return FixChainValidationResult.Failure("Failed to deserialize predicate");
}
return Validate(predicate);
}
catch (JsonException ex)
{
return FixChainValidationResult.Failure($"JSON parse error: {ex.Message}");
}
}
private static void ValidateContentRef(ContentRef? contentRef, string fieldName, List<string> errors)
{
if (contentRef is null)
{
errors.Add($"{fieldName} is required");
return;
}
if (string.IsNullOrWhiteSpace(contentRef.Digest))
{
errors.Add($"{fieldName}.digest is required");
}
else if (!contentRef.Digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) &&
!contentRef.Digest.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
{
errors.Add($"{fieldName}.digest must be prefixed with algorithm (e.g., 'sha256:')");
}
}
private static void ValidateBinaryRef(BinaryRef? binaryRef, string fieldName, List<string> errors)
{
if (binaryRef is null)
{
errors.Add($"{fieldName} is required");
return;
}
if (string.IsNullOrWhiteSpace(binaryRef.Sha256))
{
errors.Add($"{fieldName}.sha256 is required");
}
else if (binaryRef.Sha256.Length != 64)
{
errors.Add($"{fieldName}.sha256 must be 64 hex characters");
}
if (string.IsNullOrWhiteSpace(binaryRef.Architecture))
{
errors.Add($"{fieldName}.architecture is required");
}
}
private static void ValidateVerdict(FixChainVerdict? verdict, List<string> errors)
{
if (verdict is null)
{
errors.Add("verdict is required");
return;
}
var validStatuses = new[]
{
FixChainVerdict.StatusFixed,
FixChainVerdict.StatusPartial,
FixChainVerdict.StatusNotFixed,
FixChainVerdict.StatusInconclusive
};
if (string.IsNullOrWhiteSpace(verdict.Status))
{
errors.Add("verdict.status is required");
}
else if (!validStatuses.Contains(verdict.Status, StringComparer.OrdinalIgnoreCase))
{
errors.Add($"verdict.status must be one of: {string.Join(", ", validStatuses)}");
}
if (verdict.Confidence < 0 || verdict.Confidence > 1)
{
errors.Add("verdict.confidence must be between 0 and 1");
}
}
private static void ValidateAnalyzer(AnalyzerMetadata? analyzer, List<string> errors)
{
if (analyzer is null)
{
errors.Add("analyzer is required");
return;
}
if (string.IsNullOrWhiteSpace(analyzer.Name))
{
errors.Add("analyzer.name is required");
}
if (string.IsNullOrWhiteSpace(analyzer.Version))
{
errors.Add("analyzer.version is required");
}
if (string.IsNullOrWhiteSpace(analyzer.SourceDigest))
{
errors.Add("analyzer.sourceDigest is required");
}
}
}

View File

@@ -0,0 +1,66 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Attestor.FixChain;
/// <summary>
/// Extension methods for registering FixChain services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds FixChain attestation services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddFixChainAttestation(this IServiceCollection services)
{
services.AddSingleton<IFixChainStatementBuilder, FixChainStatementBuilder>();
services.AddSingleton<IFixChainValidator, FixChainValidator>();
services.AddSingleton<IFixChainAttestationService, FixChainAttestationService>();
return services;
}
/// <summary>
/// Adds FixChain attestation services with options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddFixChainAttestation(
this IServiceCollection services,
Action<FixChainOptions> configure)
{
services.Configure(configure);
return services.AddFixChainAttestation();
}
/// <summary>
/// Adds a custom attestation store implementation.
/// </summary>
/// <typeparam name="TStore">Store implementation type.</typeparam>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddFixChainAttestationStore<TStore>(this IServiceCollection services)
where TStore : class, IFixChainAttestationStore
{
services.AddSingleton<IFixChainAttestationStore, TStore>();
return services;
}
/// <summary>
/// Adds a custom Rekor client implementation.
/// </summary>
/// <typeparam name="TClient">Client implementation type.</typeparam>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddFixChainRekorClient<TClient>(this IServiceCollection services)
where TClient : class, IRekorClient
{
services.AddSingleton<IRekorClient, TClient>();
return services;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Attestor.FixChain.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
</ItemGroup>
</Project>

View File

@@ -203,7 +203,11 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
?? "unknown";
var createdAtStr = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.Created);
var createdAt = DateTimeOffset.TryParse(createdAtStr, out var dt)
var createdAt = DateTimeOffset.TryParse(
createdAtStr,
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind,
out var dt)
? dt
: DateTimeOffset.MinValue;

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor.FixChain\StellaOps.Attestor.FixChain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,158 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.FixChain.Tests.Unit;
[Trait("Category", "Unit")]
public sealed class FixChainPredicateTests
{
[Fact]
public void PredicateType_IsCorrect()
{
// Assert
FixChainPredicate.PredicateType.Should().Be("https://stella-ops.org/predicates/fix-chain/v1");
}
[Fact]
public void FixChainPredicate_CanBeCreated()
{
// Arrange & Act
var predicate = CreateValidPredicate();
// Assert
predicate.CveId.Should().Be("CVE-2024-1234");
predicate.Component.Should().Be("openssl");
predicate.GoldenSetRef.Digest.Should().StartWith("sha256:");
predicate.VulnerableBinary.Sha256.Should().HaveLength(64);
predicate.PatchedBinary.Sha256.Should().HaveLength(64);
}
[Theory]
[InlineData(FixChainVerdict.StatusFixed)]
[InlineData(FixChainVerdict.StatusPartial)]
[InlineData(FixChainVerdict.StatusNotFixed)]
[InlineData(FixChainVerdict.StatusInconclusive)]
public void FixChainVerdict_StatusConstants_AreDefined(string status)
{
// Assert
status.Should().NotBeNullOrEmpty();
}
[Fact]
public void ContentRef_StoresDigestAndUri()
{
// Arrange & Act
var contentRef = new ContentRef("sha256:abc123", "https://example.com/artifact");
// Assert
contentRef.Digest.Should().Be("sha256:abc123");
contentRef.Uri.Should().Be("https://example.com/artifact");
}
[Fact]
public void ContentRef_UriIsOptional()
{
// Arrange & Act
var contentRef = new ContentRef("sha256:abc123");
// Assert
contentRef.Uri.Should().BeNull();
}
[Fact]
public void BinaryRef_StoresAllProperties()
{
// Arrange & Act
var binaryRef = new BinaryRef(
"abcd1234" + new string('0', 56),
"x86_64",
"build-12345",
"pkg:generic/openssl@3.0.0");
// Assert
binaryRef.Sha256.Should().HaveLength(64);
binaryRef.Architecture.Should().Be("x86_64");
binaryRef.BuildId.Should().Be("build-12345");
binaryRef.Purl.Should().Be("pkg:generic/openssl@3.0.0");
}
[Fact]
public void SignatureDiffSummary_StoresCounts()
{
// Arrange & Act
var summary = new SignatureDiffSummary(
VulnerableFunctionsRemoved: 2,
VulnerableFunctionsModified: 3,
VulnerableEdgesEliminated: 5,
SanitizersInserted: 1,
Details: ["Function foo removed", "Edge bb0->bb1 eliminated"]);
// Assert
summary.VulnerableFunctionsRemoved.Should().Be(2);
summary.VulnerableFunctionsModified.Should().Be(3);
summary.VulnerableEdgesEliminated.Should().Be(5);
summary.SanitizersInserted.Should().Be(1);
summary.Details.Should().HaveCount(2);
}
[Fact]
public void ReachabilityOutcome_StoresPathCounts()
{
// Arrange & Act
var outcome = new ReachabilityOutcome(
PrePathCount: 5,
PostPathCount: 0,
Eliminated: true,
Reason: "All paths eliminated");
// Assert
outcome.PrePathCount.Should().Be(5);
outcome.PostPathCount.Should().Be(0);
outcome.Eliminated.Should().BeTrue();
outcome.Reason.Should().Be("All paths eliminated");
}
[Fact]
public void AnalyzerMetadata_StoresAllProperties()
{
// Arrange & Act
var metadata = new AnalyzerMetadata(
"StellaOps.BinaryIndex",
"1.0.0",
"sha256:sourcedigest");
// Assert
metadata.Name.Should().Be("StellaOps.BinaryIndex");
metadata.Version.Should().Be("1.0.0");
metadata.SourceDigest.Should().Be("sha256:sourcedigest");
}
private static FixChainPredicate CreateValidPredicate()
{
return new FixChainPredicate
{
CveId = "CVE-2024-1234",
Component = "openssl",
GoldenSetRef = new ContentRef("sha256:goldenset123"),
SbomRef = new ContentRef("sha256:sbom456"),
VulnerableBinary = new BinaryRef(
new string('a', 64),
"x86_64",
"build-pre",
null),
PatchedBinary = new BinaryRef(
new string('b', 64),
"x86_64",
"build-post",
"pkg:generic/openssl@3.0.1"),
SignatureDiff = new SignatureDiffSummary(1, 2, 3, 0, []),
Reachability = new ReachabilityOutcome(5, 0, true, "All paths eliminated"),
Verdict = new FixChainVerdict(FixChainVerdict.StatusFixed, 0.95m, ["Vulnerability fixed"]),
Analyzer = new AnalyzerMetadata("Test", "1.0.0", "sha256:test"),
AnalyzedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,305 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace StellaOps.Attestor.FixChain.Tests.Unit;
[Trait("Category", "Unit")]
public sealed class FixChainStatementBuilderTests
{
private readonly FixChainStatementBuilder _builder;
private readonly Mock<TimeProvider> _timeProvider;
private readonly DateTimeOffset _fixedTime = new(2026, 1, 10, 12, 0, 0, TimeSpan.Zero);
public FixChainStatementBuilderTests()
{
_timeProvider = new Mock<TimeProvider>();
_timeProvider.Setup(t => t.GetUtcNow()).Returns(_fixedTime);
var options = Options.Create(new FixChainOptions
{
AnalyzerName = "TestAnalyzer",
AnalyzerVersion = "1.0.0",
AnalyzerSourceDigest = "sha256:testsource"
});
_builder = new FixChainStatementBuilder(
_timeProvider.Object,
options,
NullLogger<FixChainStatementBuilder>.Instance);
}
[Fact]
public async Task BuildAsync_CreatesValidStatement()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Should().NotBeNull();
result.Statement.Should().NotBeNull();
result.Predicate.Should().NotBeNull();
result.ContentDigest.Should().NotBeNullOrEmpty();
result.ContentDigest.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public async Task BuildAsync_SetsCorrectCveAndComponent()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.CveId.Should().Be("CVE-2024-1234");
result.Predicate.Component.Should().Be("openssl");
}
[Fact]
public async Task BuildAsync_FormatsDigestsWithPrefix()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.GoldenSetRef.Digest.Should().StartWith("sha256:");
result.Predicate.SbomRef.Digest.Should().StartWith("sha256:");
}
[Fact]
public async Task BuildAsync_SetsBinaryReferences()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.VulnerableBinary.Sha256.Should().Be(request.VulnerableBinary.Sha256);
result.Predicate.VulnerableBinary.Architecture.Should().Be("x86_64");
result.Predicate.PatchedBinary.Sha256.Should().Be(request.PatchedBinary.Sha256);
result.Predicate.PatchedBinary.Purl.Should().Be(request.ComponentPurl);
}
[Fact]
public async Task BuildAsync_SetsAnalyzerMetadata()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.Analyzer.Name.Should().Be("TestAnalyzer");
result.Predicate.Analyzer.Version.Should().Be("1.0.0");
result.Predicate.Analyzer.SourceDigest.Should().Be("sha256:testsource");
}
[Fact]
public async Task BuildAsync_SetsAnalyzedAtTimestamp()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.AnalyzedAt.Should().Be(_fixedTime);
}
[Fact]
public async Task BuildAsync_BuildsSignatureDiffSummary()
{
// Arrange
var request = CreateValidRequest();
request = request with
{
DiffResult = request.DiffResult with
{
FunctionsRemoved = 2,
FunctionsModified = 3,
EdgesEliminated = 5,
TaintGatesAdded = 1
}
};
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.SignatureDiff.VulnerableFunctionsRemoved.Should().Be(2);
result.Predicate.SignatureDiff.VulnerableFunctionsModified.Should().Be(3);
result.Predicate.SignatureDiff.VulnerableEdgesEliminated.Should().Be(5);
result.Predicate.SignatureDiff.SanitizersInserted.Should().Be(1);
}
[Fact]
public async Task BuildAsync_BuildsReachabilityOutcome()
{
// Arrange
var request = CreateValidRequest();
request = request with
{
DiffResult = request.DiffResult with
{
PrePathCount = 5,
PostPathCount = 0
}
};
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.Reachability.PrePathCount.Should().Be(5);
result.Predicate.Reachability.PostPathCount.Should().Be(0);
result.Predicate.Reachability.Eliminated.Should().BeTrue();
}
[Theory]
[InlineData("Fixed", 0.90, "fixed")]
[InlineData("PartialFix", 0.70, "partial")]
[InlineData("StillVulnerable", 0.20, "not_fixed")]
[InlineData("Inconclusive", 0.30, "inconclusive")]
public async Task BuildAsync_SetsCorrectVerdictStatus(string inputVerdict, decimal confidence, string expectedStatus)
{
// Arrange
var request = CreateValidRequest();
request = request with
{
DiffResult = request.DiffResult with
{
Verdict = inputVerdict,
Confidence = confidence
}
};
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.Verdict.Status.Should().Be(expectedStatus);
}
[Fact]
public async Task BuildAsync_IncludesRationaleForFunctionsRemoved()
{
// Arrange
var request = CreateValidRequest();
request = request with
{
DiffResult = request.DiffResult with
{
FunctionsRemoved = 2
}
};
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.Verdict.Rationale.Should().Contain(r => r.Contains("2") && r.Contains("removed"));
}
[Fact]
public async Task BuildAsync_IncludesRationaleForPathsEliminated()
{
// Arrange
var request = CreateValidRequest();
request = request with
{
DiffResult = request.DiffResult with
{
PrePathCount = 5,
PostPathCount = 0
}
};
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Predicate.Verdict.Rationale.Should().Contain(r => r.Contains("path") && r.Contains("eliminated"));
}
[Fact]
public async Task BuildAsync_SetsStatementSubject()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _builder.BuildAsync(request);
// Assert
result.Statement.Subject.Should().HaveCount(1);
result.Statement.Subject[0].Name.Should().Be(request.ComponentPurl);
result.Statement.Subject[0].Digest["sha256"].Should().Be(request.PatchedBinary.Sha256);
}
[Fact]
public async Task BuildAsync_ContentDigestIsDeterministic()
{
// Arrange
var request = CreateValidRequest();
// Act
var result1 = await _builder.BuildAsync(request);
var result2 = await _builder.BuildAsync(request);
// Assert
result1.ContentDigest.Should().Be(result2.ContentDigest);
}
private static FixChainBuildRequest CreateValidRequest()
{
return new FixChainBuildRequest
{
CveId = "CVE-2024-1234",
Component = "openssl",
GoldenSetDigest = "goldenset123",
SbomDigest = "sbom456",
ComponentPurl = "pkg:generic/openssl@3.0.1",
VulnerableBinary = new BinaryIdentity
{
Sha256 = new string('a', 64),
Architecture = "x86_64",
BuildId = "build-pre"
},
PatchedBinary = new BinaryIdentity
{
Sha256 = new string('b', 64),
Architecture = "x86_64",
BuildId = "build-post"
},
DiffResult = new PatchDiffInput
{
Verdict = "Fixed",
Confidence = 0.95m,
FunctionsRemoved = 1,
FunctionsModified = 0,
EdgesEliminated = 3,
TaintGatesAdded = 0,
PrePathCount = 5,
PostPathCount = 0,
Evidence = ["Edge bb0->bb1 eliminated"]
}
};
}
}

View File

@@ -0,0 +1,310 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.FixChain.Tests.Unit;
[Trait("Category", "Unit")]
public sealed class FixChainValidatorTests
{
private readonly FixChainValidator _validator = new();
[Fact]
public void Validate_ValidPredicate_ReturnsSuccess()
{
// Arrange
var predicate = CreateValidPredicate();
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
result.Predicate.Should().Be(predicate);
}
[Fact]
public void Validate_MissingCveId_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with { CveId = "" };
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("cveId"));
}
[Fact]
public void Validate_InvalidCveIdFormat_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with { CveId = "INVALID-1234" };
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("CVE-"));
}
[Fact]
public void Validate_MissingComponent_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with { Component = "" };
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("component"));
}
[Fact]
public void Validate_MissingGoldenSetDigest_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
GoldenSetRef = new ContentRef("")
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("goldenSetRef.digest"));
}
[Fact]
public void Validate_InvalidDigestFormat_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
GoldenSetRef = new ContentRef("invaliddigest")
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("algorithm"));
}
[Fact]
public void Validate_InvalidBinarySha256Length_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
VulnerableBinary = new BinaryRef("short", "x86_64", null, null)
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("sha256") && e.Contains("64"));
}
[Fact]
public void Validate_MissingArchitecture_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
PatchedBinary = new BinaryRef(new string('a', 64), "", null, null)
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("architecture"));
}
[Fact]
public void Validate_InvalidVerdictStatus_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
Verdict = new FixChainVerdict("invalid_status", 0.9m, [])
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("status"));
}
[Fact]
public void Validate_InvalidConfidence_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
Verdict = new FixChainVerdict(FixChainVerdict.StatusFixed, 1.5m, [])
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("confidence"));
}
[Fact]
public void Validate_MissingAnalyzerName_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with
{
Analyzer = new AnalyzerMetadata("", "1.0.0", "sha256:source")
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("analyzer.name"));
}
[Fact]
public void Validate_DefaultTimestamp_ReturnsError()
{
// Arrange
var predicate = CreateValidPredicate() with { AnalyzedAt = default };
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("analyzedAt"));
}
[Fact]
public void ValidateJson_ValidJson_ReturnsSuccess()
{
// Arrange
var predicate = CreateValidPredicate();
var json = JsonSerializer.Serialize(predicate);
var element = JsonDocument.Parse(json).RootElement;
// Act
var result = _validator.ValidateJson(element);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ValidateJson_InvalidJson_ReturnsError()
{
// Arrange
var json = JsonDocument.Parse("{}").RootElement;
// Act
var result = _validator.ValidateJson(json);
// Assert
result.IsValid.Should().BeFalse();
}
[Fact]
public void ValidateJson_MalformedJson_ReturnsParseError()
{
// Arrange
var json = JsonDocument.Parse("{\"cveId\": 12345}").RootElement;
// Act
var result = _validator.ValidateJson(json);
// Assert
result.IsValid.Should().BeFalse();
}
[Theory]
[InlineData(FixChainVerdict.StatusFixed)]
[InlineData(FixChainVerdict.StatusPartial)]
[InlineData(FixChainVerdict.StatusNotFixed)]
[InlineData(FixChainVerdict.StatusInconclusive)]
public void Validate_AllValidStatusValues_AreAccepted(string status)
{
// Arrange
var predicate = CreateValidPredicate() with
{
Verdict = new FixChainVerdict(status, 0.5m, [])
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void Validate_MultipleErrors_ReturnsAll()
{
// Arrange
var predicate = CreateValidPredicate() with
{
CveId = "",
Component = "",
GoldenSetRef = new ContentRef("")
};
// Act
var result = _validator.Validate(predicate);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCountGreaterThan(1);
}
private static FixChainPredicate CreateValidPredicate()
{
return new FixChainPredicate
{
CveId = "CVE-2024-1234",
Component = "openssl",
GoldenSetRef = new ContentRef("sha256:goldenset123"),
SbomRef = new ContentRef("sha256:sbom456"),
VulnerableBinary = new BinaryRef(
new string('a', 64),
"x86_64",
"build-pre",
null),
PatchedBinary = new BinaryRef(
new string('b', 64),
"x86_64",
"build-post",
"pkg:generic/openssl@3.0.1"),
SignatureDiff = new SignatureDiffSummary(1, 2, 3, 0, []),
Reachability = new ReachabilityOutcome(5, 0, true, "All paths eliminated"),
Verdict = new FixChainVerdict(FixChainVerdict.StatusFixed, 0.95m, ["Vulnerability fixed"]),
Analyzer = new AnalyzerMetadata("Test", "1.0.0", "sha256:test"),
AnalyzedAt = DateTimeOffset.UtcNow
};
}
}