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:
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stella-ops.org/schemas/predicates/fix-chain/v1",
|
||||
"title": "FixChain Predicate",
|
||||
"description": "Attestation proving patch eliminates vulnerable code path",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cveId",
|
||||
"component",
|
||||
"goldenSetRef",
|
||||
"vulnerableBinary",
|
||||
"patchedBinary",
|
||||
"sbomRef",
|
||||
"signatureDiff",
|
||||
"reachability",
|
||||
"verdict",
|
||||
"analyzer",
|
||||
"analyzedAt"
|
||||
],
|
||||
"properties": {
|
||||
"cveId": {
|
||||
"type": "string",
|
||||
"description": "CVE or GHSA identifier for the vulnerability",
|
||||
"pattern": "^CVE-\\d{4}-\\d{4,}$|^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$"
|
||||
},
|
||||
"component": {
|
||||
"type": "string",
|
||||
"description": "Component being verified",
|
||||
"minLength": 1
|
||||
},
|
||||
"goldenSetRef": {
|
||||
"$ref": "#/$defs/contentRef",
|
||||
"description": "Reference to golden set definition"
|
||||
},
|
||||
"vulnerableBinary": {
|
||||
"$ref": "#/$defs/binaryRef",
|
||||
"description": "Pre-patch binary identity"
|
||||
},
|
||||
"patchedBinary": {
|
||||
"$ref": "#/$defs/binaryRef",
|
||||
"description": "Post-patch binary identity"
|
||||
},
|
||||
"sbomRef": {
|
||||
"$ref": "#/$defs/contentRef",
|
||||
"description": "SBOM reference"
|
||||
},
|
||||
"signatureDiff": {
|
||||
"$ref": "#/$defs/signatureDiffSummary",
|
||||
"description": "Summary of signature differences"
|
||||
},
|
||||
"reachability": {
|
||||
"$ref": "#/$defs/reachabilityOutcome",
|
||||
"description": "Reachability analysis result"
|
||||
},
|
||||
"verdict": {
|
||||
"$ref": "#/$defs/verdict",
|
||||
"description": "Final verdict"
|
||||
},
|
||||
"analyzer": {
|
||||
"$ref": "#/$defs/analyzerMetadata",
|
||||
"description": "Analyzer metadata"
|
||||
},
|
||||
"analyzedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Analysis timestamp (ISO 8601 UTC)"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"contentRef": {
|
||||
"type": "object",
|
||||
"description": "Content-addressed reference to an artifact",
|
||||
"required": ["digest"],
|
||||
"properties": {
|
||||
"digest": {
|
||||
"type": "string",
|
||||
"description": "Content digest (e.g., sha256:abc123...)",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$|^sha512:[a-f0-9]{128}$"
|
||||
},
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Optional URI for the artifact"
|
||||
}
|
||||
}
|
||||
},
|
||||
"binaryRef": {
|
||||
"type": "object",
|
||||
"description": "Reference to a binary artifact",
|
||||
"required": ["sha256", "architecture"],
|
||||
"properties": {
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"description": "SHA-256 digest of the binary",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"architecture": {
|
||||
"type": "string",
|
||||
"description": "Target architecture (e.g., x86_64, aarch64)"
|
||||
},
|
||||
"buildId": {
|
||||
"type": "string",
|
||||
"description": "Optional build ID from binary"
|
||||
},
|
||||
"purl": {
|
||||
"type": "string",
|
||||
"description": "Optional Package URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"signatureDiffSummary": {
|
||||
"type": "object",
|
||||
"description": "Summary of signature differences between pre and post binaries",
|
||||
"required": [
|
||||
"vulnerableFunctionsRemoved",
|
||||
"vulnerableFunctionsModified",
|
||||
"vulnerableEdgesEliminated",
|
||||
"sanitizersInserted",
|
||||
"details"
|
||||
],
|
||||
"properties": {
|
||||
"vulnerableFunctionsRemoved": {
|
||||
"type": "integer",
|
||||
"description": "Number of vulnerable functions removed entirely",
|
||||
"minimum": 0
|
||||
},
|
||||
"vulnerableFunctionsModified": {
|
||||
"type": "integer",
|
||||
"description": "Number of vulnerable functions modified",
|
||||
"minimum": 0
|
||||
},
|
||||
"vulnerableEdgesEliminated": {
|
||||
"type": "integer",
|
||||
"description": "Number of vulnerable CFG edges eliminated",
|
||||
"minimum": 0
|
||||
},
|
||||
"sanitizersInserted": {
|
||||
"type": "integer",
|
||||
"description": "Number of sanitizer checks inserted",
|
||||
"minimum": 0
|
||||
},
|
||||
"details": {
|
||||
"type": "array",
|
||||
"description": "Human-readable detail strings",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reachabilityOutcome": {
|
||||
"type": "object",
|
||||
"description": "Outcome of reachability analysis",
|
||||
"required": ["prePathCount", "postPathCount", "eliminated", "reason"],
|
||||
"properties": {
|
||||
"prePathCount": {
|
||||
"type": "integer",
|
||||
"description": "Number of paths to sink in pre-patch binary",
|
||||
"minimum": 0
|
||||
},
|
||||
"postPathCount": {
|
||||
"type": "integer",
|
||||
"description": "Number of paths to sink in post-patch binary",
|
||||
"minimum": 0
|
||||
},
|
||||
"eliminated": {
|
||||
"type": "boolean",
|
||||
"description": "Whether all vulnerable paths were eliminated"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Human-readable reason for the outcome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verdict": {
|
||||
"type": "object",
|
||||
"description": "Final verdict on whether vulnerability was fixed",
|
||||
"required": ["status", "confidence", "rationale"],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Verdict status",
|
||||
"enum": ["fixed", "partial", "not_fixed", "inconclusive"]
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"description": "Confidence score (0.0 - 1.0)",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"rationale": {
|
||||
"type": "array",
|
||||
"description": "Rationale items explaining the verdict",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzerMetadata": {
|
||||
"type": "object",
|
||||
"description": "Metadata about the analyzer that produced the attestation",
|
||||
"required": ["name", "version", "sourceDigest"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Analyzer name"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Analyzer version"
|
||||
},
|
||||
"sourceDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of analyzer source code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ public sealed class PredicateTypeRouter : IPredicateTypeRouter
|
||||
"https://stella-ops.org/predicates/delta-verdict/v1",
|
||||
"https://stella-ops.org/predicates/policy-decision/v1",
|
||||
"https://stella-ops.org/predicates/unknowns-budget/v1",
|
||||
// FixChain predicate for patch verification (Sprint 20260110_012_005)
|
||||
"https://stella-ops.org/predicates/fix-chain/v1",
|
||||
// Delta predicate types for lineage comparison (Sprint 20251228_007)
|
||||
"stella.ops/vex-delta@v1",
|
||||
"stella.ops/sbom-delta@v1",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.FixChain.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the FixChain attestation workflow.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FixChainAttestationIntegrationTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public FixChainAttestationIntegrationTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddLogging();
|
||||
services.Configure<FixChainOptions>(opts =>
|
||||
{
|
||||
opts.AnalyzerName = "TestAnalyzer";
|
||||
opts.AnalyzerVersion = "1.0.0";
|
||||
opts.AnalyzerSourceDigest = "sha256:integrationtest";
|
||||
});
|
||||
|
||||
services.AddFixChainAttestation();
|
||||
|
||||
_services = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_CreateAndVerify_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest("CVE-2024-12345", "openssl");
|
||||
|
||||
// Act - Create attestation
|
||||
var createResult = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert - Creation succeeded
|
||||
createResult.Should().NotBeNull();
|
||||
createResult.EnvelopeJson.Should().NotBeNullOrEmpty();
|
||||
createResult.Predicate.CveId.Should().Be("CVE-2024-12345");
|
||||
createResult.Predicate.Component.Should().Be("openssl");
|
||||
|
||||
// Act - Verify attestation
|
||||
var verifyResult = await attestationService.VerifyAsync(createResult.EnvelopeJson);
|
||||
|
||||
// Assert - Verification parses correctly
|
||||
verifyResult.Predicate.Should().NotBeNull();
|
||||
verifyResult.Predicate!.CveId.Should().Be("CVE-2024-12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_WithFixedVerdict_ProducesCorrectAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest(
|
||||
"CVE-2024-0727",
|
||||
"openssl",
|
||||
verdict: "Fixed",
|
||||
confidence: 0.95m,
|
||||
prePathCount: 5,
|
||||
postPathCount: 0);
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Status.Should().Be("fixed");
|
||||
result.Predicate.Verdict.Confidence.Should().Be(0.95m);
|
||||
result.Predicate.Reachability.Eliminated.Should().BeTrue();
|
||||
result.Predicate.Reachability.PrePathCount.Should().Be(5);
|
||||
result.Predicate.Reachability.PostPathCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_WithPartialFix_ProducesCorrectAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest(
|
||||
"CVE-2024-0728",
|
||||
"libxml2",
|
||||
verdict: "PartialFix",
|
||||
confidence: 0.60m,
|
||||
prePathCount: 5,
|
||||
postPathCount: 2);
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Status.Should().Be("partial");
|
||||
result.Predicate.Reachability.Eliminated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_EnvelopeContainsValidInTotoStatement()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest("CVE-2024-12345", "test");
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert - Parse envelope
|
||||
var envelope = JsonDocument.Parse(result.EnvelopeJson);
|
||||
envelope.RootElement.GetProperty("payloadType").GetString()
|
||||
.Should().Be("application/vnd.in-toto+json");
|
||||
|
||||
// Decode payload
|
||||
var payloadBase64 = envelope.RootElement.GetProperty("payload").GetString();
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64!);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
|
||||
// Parse statement
|
||||
var statement = JsonDocument.Parse(payloadJson);
|
||||
statement.RootElement.GetProperty("_type").GetString()
|
||||
.Should().Be("https://in-toto.io/Statement/v1");
|
||||
statement.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be("https://stella-ops.org/predicates/fix-chain/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_SubjectMatchesPatchedBinary()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var patchedSha = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
var request = CreateTestRequest("CVE-2024-12345", "test", patchedBinarySha256: patchedSha);
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonDocument.Parse(result.EnvelopeJson);
|
||||
var payloadBase64 = envelope.RootElement.GetProperty("payload").GetString();
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64!);
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var statement = JsonDocument.Parse(payloadJson);
|
||||
|
||||
var subject = statement.RootElement.GetProperty("subject")[0];
|
||||
subject.GetProperty("digest").GetProperty("sha256").GetString()
|
||||
.Should().Be(patchedSha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_VerdictRationaleIsPopulated()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest(
|
||||
"CVE-2024-12345",
|
||||
"test",
|
||||
functionsRemoved: 3,
|
||||
edgesEliminated: 5);
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Rationale.Should().NotBeEmpty();
|
||||
result.Predicate.Verdict.Rationale.Should().ContainMatch("*removed*");
|
||||
result.Predicate.Verdict.Rationale.Should().ContainMatch("*edge*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_AnalyzerMetadataFromOptions()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest("CVE-2024-12345", "test");
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(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:integrationtest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_TimestampFromTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest("CVE-2024-12345", "test");
|
||||
|
||||
// Act
|
||||
var result = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.AnalyzedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_ContentDigestIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request = CreateTestRequest("CVE-2024-12345", "test");
|
||||
|
||||
// Act
|
||||
var result1 = await attestationService.CreateAsync(request);
|
||||
var result2 = await attestationService.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result1.ContentDigest.Should().Be(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_DifferentCveProducesDifferentDigest()
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = _services.GetRequiredService<IFixChainAttestationService>();
|
||||
var request1 = CreateTestRequest("CVE-2024-12345", "test");
|
||||
var request2 = CreateTestRequest("CVE-2024-99999", "test");
|
||||
|
||||
// Act
|
||||
var result1 = await attestationService.CreateAsync(request1);
|
||||
var result2 = await attestationService.CreateAsync(request2);
|
||||
|
||||
// Assert
|
||||
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_InMemoryStore_StoresAndRetrieves()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryFixChainStore();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddLogging();
|
||||
services.Configure<FixChainOptions>(opts => { });
|
||||
services.AddFixChainAttestation();
|
||||
services.AddFixChainAttestationStore<InMemoryFixChainStore>();
|
||||
services.AddSingleton(store);
|
||||
|
||||
var sp = services.BuildServiceProvider();
|
||||
var attestationService = sp.GetRequiredService<IFixChainAttestationService>();
|
||||
|
||||
var request = CreateTestRequest("CVE-2024-12345", "test");
|
||||
|
||||
// Act
|
||||
await attestationService.CreateAsync(request);
|
||||
var retrieved = await attestationService.GetAsync("CVE-2024-12345", request.PatchedBinary.Sha256);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.CveId.Should().Be("CVE-2024-12345");
|
||||
}
|
||||
|
||||
private static FixChainBuildRequest CreateTestRequest(
|
||||
string cveId,
|
||||
string component,
|
||||
string verdict = "Fixed",
|
||||
decimal confidence = 0.90m,
|
||||
int prePathCount = 3,
|
||||
int postPathCount = 0,
|
||||
int functionsRemoved = 1,
|
||||
int edgesEliminated = 2,
|
||||
string patchedBinarySha256 = "2222222222222222222222222222222222222222222222222222222222222222")
|
||||
{
|
||||
return new FixChainBuildRequest
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = component,
|
||||
GoldenSetDigest = "goldenset123",
|
||||
SbomDigest = "sbom456",
|
||||
VulnerableBinary = new BinaryIdentity
|
||||
{
|
||||
Sha256 = "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
Architecture = "x86_64"
|
||||
},
|
||||
PatchedBinary = new BinaryIdentity
|
||||
{
|
||||
Sha256 = patchedBinarySha256,
|
||||
Architecture = "x86_64"
|
||||
},
|
||||
ComponentPurl = $"pkg:deb/debian/{component}@1.0.0",
|
||||
DiffResult = new PatchDiffInput
|
||||
{
|
||||
Verdict = verdict,
|
||||
Confidence = confidence,
|
||||
FunctionsRemoved = functionsRemoved,
|
||||
FunctionsModified = 0,
|
||||
EdgesEliminated = edgesEliminated,
|
||||
TaintGatesAdded = 0,
|
||||
PrePathCount = prePathCount,
|
||||
PostPathCount = postPathCount,
|
||||
Evidence = ["Integration test evidence"]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryFixChainStore : IFixChainAttestationStore
|
||||
{
|
||||
private readonly Dictionary<string, FixChainAttestationInfo> _store = new();
|
||||
|
||||
public Task StoreAsync(
|
||||
string contentDigest,
|
||||
string cveId,
|
||||
string binarySha256,
|
||||
string componentPurl,
|
||||
string envelopeJson,
|
||||
long? rekorLogIndex,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = $"{cveId}:{binarySha256}";
|
||||
_store[key] = new FixChainAttestationInfo
|
||||
{
|
||||
ContentDigest = contentDigest,
|
||||
CveId = cveId,
|
||||
Component = componentPurl,
|
||||
BinarySha256 = binarySha256,
|
||||
VerdictStatus = "fixed",
|
||||
Confidence = 0.95m,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
RekorLogIndex = rekorLogIndex
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FixChainAttestationInfo?> GetAsync(
|
||||
string cveId,
|
||||
string binarySha256,
|
||||
string? componentPurl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = $"{cveId}:{binarySha256}";
|
||||
return Task.FromResult(_store.GetValueOrDefault(key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Time.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.FixChain\StellaOps.Attestor.FixChain.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,387 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
using FluentAssertions;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using Moq;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.FixChain.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FixChainAttestationService"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FixChainAttestationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FixChainStatementBuilder _statementBuilder;
|
||||
private readonly FixChainValidator _validator;
|
||||
private readonly FixChainAttestationService _service;
|
||||
|
||||
public FixChainAttestationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new FixChainOptions
|
||||
{
|
||||
AnalyzerName = "TestAnalyzer",
|
||||
AnalyzerVersion = "1.0.0",
|
||||
AnalyzerSourceDigest = "sha256:test123"
|
||||
});
|
||||
|
||||
_statementBuilder = new FixChainStatementBuilder(
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<FixChainStatementBuilder>.Instance);
|
||||
|
||||
_validator = new FixChainValidator();
|
||||
|
||||
_service = new FixChainAttestationService(
|
||||
_statementBuilder,
|
||||
_validator,
|
||||
NullLogger<FixChainAttestationService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidRequest_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.EnvelopeJson.Should().NotBeNullOrEmpty();
|
||||
result.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
result.Predicate.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_EnvelopeIsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
var parseAction = () => JsonDocument.Parse(result.EnvelopeJson);
|
||||
parseAction.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_EnvelopeHasCorrectPayloadType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonDocument.Parse(result.EnvelopeJson);
|
||||
envelope.RootElement.GetProperty("payloadType").GetString()
|
||||
.Should().Be("application/vnd.in-toto+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PayloadIsBase64Encoded()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonDocument.Parse(result.EnvelopeJson);
|
||||
var payload = envelope.RootElement.GetProperty("payload").GetString();
|
||||
|
||||
var decodeAction = () => Convert.FromBase64String(payload!);
|
||||
decodeAction.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PredicateMatchesEnvelopeContent()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.CveId.Should().Be(request.CveId);
|
||||
result.Predicate.Component.Should().Be(request.Component);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithNullRequest_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_service.CreateAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithCancellation_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
_service.CreateAsync(request, null, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidEnvelope_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
var createResult = await _service.CreateAsync(request);
|
||||
|
||||
// Act
|
||||
var verifyResult = await _service.VerifyAsync(createResult.EnvelopeJson);
|
||||
|
||||
// Assert - Note: unsigned envelope has issues
|
||||
verifyResult.Predicate.Should().NotBeNull();
|
||||
verifyResult.Predicate!.CveId.Should().Be(request.CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithInvalidJson_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = "{ invalid json }";
|
||||
|
||||
// Act
|
||||
var result = await _service.VerifyAsync(invalidJson);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithEmptyString_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.VerifyAsync(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithNullString_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_service.VerifyAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithWrongPayloadType_ReturnsIssue()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "wrong/type",
|
||||
payload = Convert.ToBase64String("{}"u8.ToArray()),
|
||||
signatures = Array.Empty<object>()
|
||||
};
|
||||
var json = JsonSerializer.Serialize(envelope);
|
||||
|
||||
// Act
|
||||
var result = await _service.VerifyAsync(json);
|
||||
|
||||
// Assert
|
||||
result.Issues.Should().Contain(i => i.Contains("payload type"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithNoSignatures_ReturnsIssue()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
var createResult = await _service.CreateAsync(request);
|
||||
|
||||
// Act
|
||||
var result = await _service.VerifyAsync(createResult.EnvelopeJson);
|
||||
|
||||
// Assert
|
||||
result.Issues.Should().Contain(i => i.Contains("signature") || i.Contains("No signatures"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNoStore_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetAsync("CVE-2024-12345", "abc123");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithStore_StoresAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var mockStore = new Mock<IFixChainAttestationStore>();
|
||||
var service = new FixChainAttestationService(
|
||||
_statementBuilder,
|
||||
_validator,
|
||||
NullLogger<FixChainAttestationService>.Instance,
|
||||
mockStore.Object);
|
||||
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
await service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
mockStore.Verify(s => s.StoreAsync(
|
||||
It.IsAny<string>(),
|
||||
request.CveId,
|
||||
request.PatchedBinary.Sha256,
|
||||
request.ComponentPurl,
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithStoreException_ContinuesWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var mockStore = new Mock<IFixChainAttestationStore>();
|
||||
mockStore.Setup(s => s.StoreAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<long?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Store error"));
|
||||
|
||||
var service = new FixChainAttestationService(
|
||||
_statementBuilder,
|
||||
_validator,
|
||||
NullLogger<FixChainAttestationService>.Instance,
|
||||
mockStore.Object);
|
||||
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAsync(request);
|
||||
|
||||
// Assert - Should not throw, should return result
|
||||
result.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithArchiveDisabled_SkipsStore()
|
||||
{
|
||||
// Arrange
|
||||
var mockStore = new Mock<IFixChainAttestationStore>();
|
||||
var service = new FixChainAttestationService(
|
||||
_statementBuilder,
|
||||
_validator,
|
||||
NullLogger<FixChainAttestationService>.Instance,
|
||||
mockStore.Object);
|
||||
|
||||
var request = CreateTestRequest();
|
||||
var options = new AttestationCreationOptions { Archive = false };
|
||||
|
||||
// Act
|
||||
await service.CreateAsync(request, options);
|
||||
|
||||
// Assert
|
||||
mockStore.Verify(s => s.StoreAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<long?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithStore_CallsStore()
|
||||
{
|
||||
// Arrange
|
||||
var mockStore = new Mock<IFixChainAttestationStore>();
|
||||
var expectedInfo = new FixChainAttestationInfo
|
||||
{
|
||||
ContentDigest = "sha256:test",
|
||||
CveId = "CVE-2024-12345",
|
||||
Component = "test",
|
||||
BinarySha256 = "abc123",
|
||||
VerdictStatus = "fixed",
|
||||
Confidence = 0.95m,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
mockStore.Setup(s => s.GetAsync("CVE-2024-12345", "abc123", null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedInfo);
|
||||
|
||||
var service = new FixChainAttestationService(
|
||||
_statementBuilder,
|
||||
_validator,
|
||||
NullLogger<FixChainAttestationService>.Instance,
|
||||
mockStore.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync("CVE-2024-12345", "abc123");
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedInfo);
|
||||
}
|
||||
|
||||
private static FixChainBuildRequest CreateTestRequest()
|
||||
{
|
||||
return new FixChainBuildRequest
|
||||
{
|
||||
CveId = "CVE-2024-12345",
|
||||
Component = "test-component",
|
||||
GoldenSetDigest = "0123456789abcdef",
|
||||
SbomDigest = "fedcba9876543210",
|
||||
VulnerableBinary = new BinaryIdentity
|
||||
{
|
||||
Sha256 = new string('1', 64),
|
||||
Architecture = "x86_64"
|
||||
},
|
||||
PatchedBinary = new BinaryIdentity
|
||||
{
|
||||
Sha256 = new string('2', 64),
|
||||
Architecture = "x86_64"
|
||||
},
|
||||
ComponentPurl = "pkg:deb/debian/test@1.0.0",
|
||||
DiffResult = new PatchDiffInput
|
||||
{
|
||||
Verdict = "Fixed",
|
||||
Confidence = 0.95m,
|
||||
FunctionsRemoved = 1,
|
||||
FunctionsModified = 0,
|
||||
EdgesEliminated = 2,
|
||||
TaintGatesAdded = 0,
|
||||
PrePathCount = 3,
|
||||
PostPathCount = 0,
|
||||
Evidence = ["Test evidence"]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// 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 Microsoft.Extensions.Time.Testing;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.FixChain.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FixChainStatementBuilder"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FixChainStatementBuilderTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IOptions<FixChainOptions> _options;
|
||||
private readonly FixChainStatementBuilder _builder;
|
||||
|
||||
public FixChainStatementBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = Options.Create(new FixChainOptions
|
||||
{
|
||||
AnalyzerName = "TestAnalyzer",
|
||||
AnalyzerVersion = "1.0.0",
|
||||
AnalyzerSourceDigest = "sha256:test123",
|
||||
FixedConfidenceThreshold = 0.80m,
|
||||
PartialConfidenceThreshold = 0.50m
|
||||
});
|
||||
_builder = new FixChainStatementBuilder(
|
||||
_timeProvider,
|
||||
_options,
|
||||
NullLogger<FixChainStatementBuilder>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_WithValidRequest_ReturnsStatementResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Statement.Should().NotBeNull();
|
||||
result.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
result.ContentDigest.Should().HaveLength(64); // SHA-256 hex length
|
||||
result.Predicate.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsCorrectCveId()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(cveId: "CVE-2024-12345");
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.CveId.Should().Be("CVE-2024-12345");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsCorrectComponent()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(component: "openssl");
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Component.Should().Be("openssl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_FormatsDigestWithSha256Prefix()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(goldenSetDigest: "abc123def456");
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.GoldenSetRef.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_PreservesExistingSha256Prefix()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(goldenSetDigest: "sha256:abc123def456");
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.GoldenSetRef.Digest.Should().Be("sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsBinaryReferences()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.VulnerableBinary.Should().NotBeNull();
|
||||
result.Predicate.VulnerableBinary.Sha256.Should().Be(request.VulnerableBinary.Sha256);
|
||||
result.Predicate.VulnerableBinary.Architecture.Should().Be(request.VulnerableBinary.Architecture);
|
||||
|
||||
result.Predicate.PatchedBinary.Should().NotBeNull();
|
||||
result.Predicate.PatchedBinary.Sha256.Should().Be(request.PatchedBinary.Sha256);
|
||||
result.Predicate.PatchedBinary.Architecture.Should().Be(request.PatchedBinary.Architecture);
|
||||
result.Predicate.PatchedBinary.Purl.Should().Be(request.ComponentPurl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsSignatureDiffSummary()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(
|
||||
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_SetsReachabilityOutcome_WhenAllPathsEliminated()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(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();
|
||||
result.Predicate.Reachability.Reason.Should().Contain("eliminated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsReachabilityOutcome_WhenPathsReduced()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(prePathCount: 5, postPathCount: 2);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Reachability.Eliminated.Should().BeFalse();
|
||||
result.Predicate.Reachability.Reason.Should().Contain("reduced");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_VerdictFixed_WhenHighConfidenceAndFixedVerdict()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(verdict: "Fixed", confidence: 0.95m);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Status.Should().Be(FixChainVerdict.StatusFixed);
|
||||
result.Predicate.Verdict.Confidence.Should().Be(0.95m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_VerdictPartial_WhenMediumConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(verdict: "PartialFix", confidence: 0.60m);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Status.Should().Be(FixChainVerdict.StatusPartial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_VerdictNotFixed_WhenStillVulnerable()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(verdict: "StillVulnerable", confidence: 0.10m);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Status.Should().Be(FixChainVerdict.StatusNotFixed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_VerdictInconclusive_WhenLowConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(verdict: "Unknown", confidence: 0.20m);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Status.Should().Be(FixChainVerdict.StatusInconclusive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsAnalyzerMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// 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:test123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SetsAnalyzedAtFromTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.AnalyzedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_CreatesValidInTotoStatement()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Statement.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
result.Statement.PredicateType.Should().Be(FixChainPredicate.PredicateType);
|
||||
result.Statement.Subject.Should().HaveCount(1);
|
||||
result.Statement.Subject[0].Name.Should().Be(request.ComponentPurl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_SubjectDigestMatchesPatchedBinary()
|
||||
{
|
||||
// Arrange
|
||||
var patchedSha = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
var request = CreateTestRequest(patchedBinarySha256: patchedSha);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Statement.Subject[0].Digest.Should().ContainKey("sha256");
|
||||
result.Statement.Subject[0].Digest["sha256"].Should().Be(patchedSha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ThrowsOnNullRequest()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
_builder.BuildAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ThrowsOnCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
_builder.BuildAsync(request, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ContentDigestIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest();
|
||||
|
||||
// Act
|
||||
var result1 = await _builder.BuildAsync(request);
|
||||
var result2 = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result1.ContentDigest.Should().Be(result2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_IncludesRationaleForFunctionsRemoved()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(functionsRemoved: 3);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Rationale.Should()
|
||||
.Contain(r => r.Contains("3") && r.Contains("removed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_IncludesRationaleForEdgesEliminated()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(edgesEliminated: 5);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Rationale.Should()
|
||||
.Contain(r => r.Contains("5") && r.Contains("edge"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_IncludesRationaleForPathsEliminated()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateTestRequest(prePathCount: 10, postPathCount: 0);
|
||||
|
||||
// Act
|
||||
var result = await _builder.BuildAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.Verdict.Rationale.Should()
|
||||
.Contain(r => r.Contains("All paths") || r.Contains("eliminated"));
|
||||
}
|
||||
|
||||
private static FixChainBuildRequest CreateTestRequest(
|
||||
string cveId = "CVE-2024-99999",
|
||||
string component = "test-component",
|
||||
string goldenSetDigest = "0123456789abcdef",
|
||||
string sbomDigest = "fedcba9876543210",
|
||||
string vulnerableBinarySha256 = "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
string patchedBinarySha256 = "2222222222222222222222222222222222222222222222222222222222222222",
|
||||
string componentPurl = "pkg:deb/debian/test-component@1.0.0",
|
||||
string verdict = "Fixed",
|
||||
decimal confidence = 0.90m,
|
||||
int functionsRemoved = 1,
|
||||
int functionsModified = 0,
|
||||
int edgesEliminated = 2,
|
||||
int taintGatesAdded = 0,
|
||||
int prePathCount = 3,
|
||||
int postPathCount = 0)
|
||||
{
|
||||
return new FixChainBuildRequest
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = component,
|
||||
GoldenSetDigest = goldenSetDigest,
|
||||
SbomDigest = sbomDigest,
|
||||
VulnerableBinary = new BinaryIdentity
|
||||
{
|
||||
Sha256 = vulnerableBinarySha256,
|
||||
Architecture = "x86_64"
|
||||
},
|
||||
PatchedBinary = new BinaryIdentity
|
||||
{
|
||||
Sha256 = patchedBinarySha256,
|
||||
Architecture = "x86_64"
|
||||
},
|
||||
ComponentPurl = componentPurl,
|
||||
DiffResult = new PatchDiffInput
|
||||
{
|
||||
Verdict = verdict,
|
||||
Confidence = confidence,
|
||||
FunctionsRemoved = functionsRemoved,
|
||||
FunctionsModified = functionsModified,
|
||||
EdgesEliminated = edgesEliminated,
|
||||
TaintGatesAdded = taintGatesAdded,
|
||||
PrePathCount = prePathCount,
|
||||
PostPathCount = postPathCount,
|
||||
Evidence = ["Test evidence"]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FixChainValidator"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FixChainValidatorTests
|
||||
{
|
||||
private readonly FixChainValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidPredicate_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_WithNullPredicate_ThrowsArgumentNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => _validator.Validate(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyCveId_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_WithInvalidCveIdFormat_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with { CveId = "INVALID-123" };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("CVE-"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidCveFormat_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with { CveId = "CVE-2024-12345" };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyComponent_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_WithNullGoldenSetRef_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with { GoldenSetRef = null! };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("goldenSetRef"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyGoldenSetDigest_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_WithInvalidDigestPrefix_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
GoldenSetRef = new ContentRef("md5:abc123")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("algorithm"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithSha512Digest_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
GoldenSetRef = new ContentRef("sha512:abc123")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNullVulnerableBinary_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with { VulnerableBinary = null! };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("vulnerableBinary"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyBinarySha256_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
VulnerableBinary = new BinaryRef("", "x86_64")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("vulnerableBinary.sha256"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithWrongLengthBinarySha256_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
VulnerableBinary = new BinaryRef("abc123", "x86_64")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("64 hex"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyArchitecture_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
VulnerableBinary = new BinaryRef(new string('a', 64), "")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("architecture"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNullVerdict_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with { Verdict = null! };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("verdict"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyVerdictStatus_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
Verdict = new FixChainVerdict("", 0.5m, [])
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("verdict.status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithInvalidVerdictStatus_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
Verdict = new FixChainVerdict("invalid_status", 0.5m, [])
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("verdict.status"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("fixed")]
|
||||
[InlineData("partial")]
|
||||
[InlineData("not_fixed")]
|
||||
[InlineData("inconclusive")]
|
||||
public void Validate_WithValidVerdictStatus_ReturnsSuccess(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_WithConfidenceBelowZero_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
Verdict = new FixChainVerdict("fixed", -0.1m, [])
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("confidence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithConfidenceAboveOne_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
Verdict = new FixChainVerdict("fixed", 1.1m, [])
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("confidence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithNullAnalyzer_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with { Analyzer = null! };
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("analyzer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyAnalyzerName_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
Analyzer = new AnalyzerMetadata("", "1.0", "sha256:abc")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("analyzer.name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDefaultAnalyzedAt_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_WithValidJson_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_WithInvalidJson_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{ \"invalid\": true }";
|
||||
var element = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
// Act
|
||||
var result = _validator.ValidateJson(element);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMultipleErrors_ReturnsAllErrors()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = CreateValidPredicate() with
|
||||
{
|
||||
CveId = "",
|
||||
Component = "",
|
||||
Verdict = null!
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(predicate);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().HaveCountGreaterOrEqualTo(3);
|
||||
}
|
||||
|
||||
private static FixChainPredicate CreateValidPredicate()
|
||||
{
|
||||
return new FixChainPredicate
|
||||
{
|
||||
CveId = "CVE-2024-12345",
|
||||
Component = "test-component",
|
||||
GoldenSetRef = new ContentRef("sha256:" + new string('a', 64)),
|
||||
SbomRef = new ContentRef("sha256:" + new string('b', 64)),
|
||||
VulnerableBinary = new BinaryRef(new string('1', 64), "x86_64"),
|
||||
PatchedBinary = new BinaryRef(new string('2', 64), "x86_64"),
|
||||
SignatureDiff = new SignatureDiffSummary(1, 2, 3, 0, []),
|
||||
Reachability = new ReachabilityOutcome(5, 0, true, "All paths eliminated"),
|
||||
Verdict = new FixChainVerdict("fixed", 0.95m, ["Test rationale"]),
|
||||
Analyzer = new AnalyzerMetadata("TestAnalyzer", "1.0.0", "sha256:test"),
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user