audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -12,11 +12,14 @@ namespace StellaOps.Excititor.Worker.Auth;
/// </summary>
public sealed class TenantAuthorityClientFactory : ITenantAuthorityClientFactory
{
internal const string AuthorityClientName = "StellaOps.Excititor.Worker.Authority";
private readonly TenantAuthorityOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
public TenantAuthorityClientFactory(IOptions<TenantAuthorityOptions> options)
public TenantAuthorityClientFactory(IOptions<TenantAuthorityOptions> options, IHttpClientFactory httpClientFactory)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
}
public HttpClient Create(string tenant)
@@ -31,10 +34,8 @@ public sealed class TenantAuthorityClientFactory : ITenantAuthorityClientFactory
throw new InvalidOperationException($"Authority base URL not configured for tenant '{tenant}'.");
}
var client = new HttpClient
{
BaseAddress = new Uri(baseUrl, UriKind.Absolute),
};
var client = _httpClientFactory.CreateClient(AuthorityClientName);
client.BaseAddress = new Uri(baseUrl, UriKind.Absolute);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Add("X-Tenant", tenant);

View File

@@ -0,0 +1,13 @@
using System;
namespace StellaOps.Excititor.Worker.Orchestration;
internal interface IGuidGenerator
{
Guid NewGuid();
}
internal sealed class DefaultGuidGenerator : IGuidGenerator
{
public Guid NewGuid() => Guid.NewGuid();
}

View File

@@ -28,6 +28,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
private readonly IVexConnectorStateRepository _stateRepository;
private readonly IAppendOnlyCheckpointStore? _checkpointStore;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly IOptions<VexWorkerOrchestratorOptions> _options;
private readonly ILogger<VexWorkerOrchestratorClient> _logger;
private readonly HttpClient? _httpClient;
@@ -38,6 +39,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
public VexWorkerOrchestratorClient(
IVexConnectorStateRepository stateRepository,
TimeProvider timeProvider,
IGuidGenerator guidGenerator,
IOptions<VexWorkerOrchestratorOptions> options,
ILogger<VexWorkerOrchestratorClient> logger,
HttpClient? httpClient = null,
@@ -46,6 +48,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
_checkpointStore = checkpointStore;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient = httpClient;
@@ -63,7 +66,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
CancellationToken cancellationToken = default)
{
var startedAt = _timeProvider.GetUtcNow();
var fallbackContext = new VexWorkerJobContext(tenant, connectorId, Guid.NewGuid(), checkpoint, startedAt);
var fallbackContext = new VexWorkerJobContext(tenant, connectorId, _guidGenerator.NewGuid(), checkpoint, startedAt);
if (!CanUseRemote())
{
@@ -479,7 +482,7 @@ internal sealed class VexWorkerOrchestratorClient : IVexWorkerOrchestratorClient
return null;
}
return DateTimeOffset.TryParse(checkpoint, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)
return DateTimeOffset.TryParse(checkpoint, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed
: null;
}

View File

@@ -18,6 +18,7 @@ using StellaOps.Excititor.Persistence.Extensions;
using StellaOps.Excititor.Worker.Auth;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Orchestration;
using StellaOps.Excititor.Worker.Plugins;
using StellaOps.Excititor.Worker.Scheduling;
using StellaOps.Excititor.Worker.Signature;
using StellaOps.Excititor.Attestation.Extensions;
@@ -51,9 +52,9 @@ services.AddOptions<VexStorageOptions>()
.ValidateOnStart();
services.AddExcititorPersistence(configuration);
services.AddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.TryAddSingleton<IVexProviderStore, InMemoryVexProviderStore>();
services.TryAddScoped<IVexConnectorStateRepository, InMemoryVexConnectorStateRepository>();
services.AddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.TryAddSingleton<IVexClaimStore, InMemoryVexClaimStore>();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
services.AddOpenVexNormalizer();
@@ -82,6 +83,7 @@ services.AddExcititorAocGuards();
services.AddSingleton<IValidateOptions<VexWorkerOptions>, VexWorkerOptionsValidator>();
services.AddSingleton(TimeProvider.System);
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
services.PostConfigure<VexWorkerOptions>(options =>
{
if (!options.Providers.Any(provider => string.Equals(provider.ProviderId, "excititor:redhat", StringComparison.OrdinalIgnoreCase)))
@@ -93,40 +95,15 @@ services.PostConfigure<VexWorkerOptions>(options =>
}
});
// Load VEX connector plugins
services.AddSingleton<VexWorkerPluginCatalogLoader>();
services.AddSingleton<PluginCatalog>(provider =>
{
var opts = provider.GetRequiredService<IOptions<VexWorkerPluginOptions>>().Value;
var catalog = new PluginCatalog();
var directory = opts.ResolveDirectory();
if (Directory.Exists(directory))
{
catalog.AddFromDirectory(directory, opts.ResolveSearchPattern());
}
else
{
// Fallback: try loading from plugins/excititor directory
var fallbackPath = Path.Combine(AppContext.BaseDirectory, "plugins", "excititor");
if (Directory.Exists(fallbackPath))
{
catalog.AddFromDirectory(fallbackPath, "StellaOps.Excititor.Connectors.*.dll");
}
else
{
var logger = provider.GetRequiredService<ILogger<Program>>();
logger.LogWarning(
"Excititor worker plugin directory '{Directory}' does not exist; proceeding without external connectors.",
directory);
}
}
return catalog;
});
provider.GetRequiredService<VexWorkerPluginCatalogLoader>().Load().Catalog);
// Orchestrator worker SDK integration
services.AddOptions<VexWorkerOrchestratorOptions>()
.Bind(configuration.GetSection("Excititor:Worker:Orchestrator"))
.ValidateOnStart();
services.AddHttpClient(TenantAuthorityClientFactory.AuthorityClientName);
services.AddHttpClient<IVexWorkerOrchestratorClient, VexWorkerOrchestratorClient>((provider, client) =>
{
var opts = provider.GetRequiredService<IOptions<VexWorkerOrchestratorOptions>>().Value;

View File

@@ -1,8 +1,10 @@
using System;
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -206,7 +208,13 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
await SafeWaitForTaskAsync(heartbeatTask).ConfigureAwait(false);
var error = VexWorkerError.Cancelled("Operation cancelled by host");
await _orchestratorClient.FailJobAsync(jobContext, error, CancellationToken.None).ConfigureAwait(false);
try
{
await _orchestratorClient.FailJobAsync(jobContext, error, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
throw;
}
@@ -221,7 +229,7 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
// Apply backoff delay for retryable errors
var retryDelay = classifiedError.Retryable
? (int)CalculateDelayWithJitter(1).TotalSeconds
? (int)CalculateDelayWithJitter(connector.Id, (stateBeforeRun?.FailureCount ?? 0) + 1).TotalSeconds
: (int?)null;
var errorWithRetry = classifiedError.Retryable && retryDelay.HasValue
@@ -235,7 +243,13 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
classifiedError.Details)
: classifiedError;
await _orchestratorClient.FailJobAsync(jobContext, errorWithRetry, CancellationToken.None).ConfigureAwait(false);
try
{
await _orchestratorClient.FailJobAsync(jobContext, errorWithRetry, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
await UpdateFailureStateAsync(stateRepository, descriptor.Id, _timeProvider.GetUtcNow(), ex, classifiedError.Retryable, cancellationToken).ConfigureAwait(false);
throw;
@@ -291,7 +305,7 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
if (retryable)
{
// Apply exponential backoff for retryable errors
var delay = CalculateDelayWithJitter(failureCount);
var delay = CalculateDelayWithJitter(connectorId, failureCount);
nextEligible = failureTime + delay;
if (failureCount >= _retryOptions.FailureThreshold)
@@ -338,7 +352,7 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
nextEligible);
}
private TimeSpan CalculateDelayWithJitter(int failureCount)
internal TimeSpan CalculateDelayWithJitter(string connectorId, int failureCount)
{
var exponent = Math.Max(0, failureCount - 1);
var factor = Math.Pow(2, exponent);
@@ -351,25 +365,24 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
var minFactor = 1.0 - _retryOptions.JitterRatio;
var maxFactor = 1.0 + _retryOptions.JitterRatio;
Span<byte> buffer = stackalloc byte[8];
RandomNumberGenerator.Fill(buffer);
var sample = BitConverter.ToUInt64(buffer) / (double)ulong.MaxValue;
var sample = ComputeDeterministicSample(connectorId, failureCount);
var jitterFactor = minFactor + (maxFactor - minFactor) * sample;
var jitteredTicks = (long)Math.Round(baselineTicks * jitterFactor);
var jitteredTicks = (long)Math.Round(baselineTicks * jitterFactor, MidpointRounding.AwayFromZero);
if (jitteredTicks < _retryOptions.BaseDelay.Ticks)
{
jitteredTicks = _retryOptions.BaseDelay.Ticks;
}
if (jitteredTicks > _retryOptions.MaxDelay.Ticks)
{
jitteredTicks = _retryOptions.MaxDelay.Ticks;
}
jitteredTicks = Math.Clamp(jitteredTicks, _retryOptions.BaseDelay.Ticks, _retryOptions.MaxDelay.Ticks);
return TimeSpan.FromTicks(jitteredTicks);
}
internal static double ComputeDeterministicSample(string connectorId, int failureCount)
{
var normalizedId = string.IsNullOrWhiteSpace(connectorId) ? "unknown" : connectorId.Trim();
var input = string.Concat(normalizedId, ":", failureCount.ToString(CultureInfo.InvariantCulture));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
var value = BinaryPrimitives.ReadUInt64BigEndian(hash.AsSpan(0, 8));
return value / (double)ulong.MaxValue;
}
private static string Truncate(string? value, int maxLength)
{
if (string.IsNullOrEmpty(value))

View File

@@ -63,7 +63,7 @@ internal sealed class VexWorkerHostedService : BackgroundService
await Task.Delay(schedule.InitialDelay, cancellationToken).ConfigureAwait(false);
}
using var timer = new PeriodicTimer(schedule.Interval);
using var timer = new PeriodicTimer(schedule.Interval, _timeProvider);
do
{
var startedAt = _timeProvider.GetUtcNow();

View File

@@ -153,7 +153,7 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
var predicate = statement.Predicate ?? throw new InvalidOperationException("DSSE predicate is missing.");
var request = BuildAttestationRequest(statement, predicate);
var attestationMetadata = BuildAttestationMetadata(statement, envelope, metadata);
var attestationMetadata = BuildAttestationMetadata(statement, envelope, metadata, document.RetrievedAt);
var verificationRequest = new VexAttestationVerificationRequest(
request,
@@ -251,7 +251,8 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
private VexAttestationMetadata BuildAttestationMetadata(
VexInTotoStatement statement,
DsseEnvelope envelope,
ImmutableDictionary<string, string> metadata)
ImmutableDictionary<string, string> metadata,
DateTimeOffset fallbackSignedAt)
{
VexRekorReference? rekor = null;
if (metadata.TryGetValue("vex.signature.transparencyLogReference", out var rekorValue) && !string.IsNullOrWhiteSpace(rekorValue))
@@ -259,7 +260,7 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
rekor = new VexRekorReference("0.1", rekorValue);
}
DateTimeOffset signedAt;
DateTimeOffset? signedAt = null;
if (metadata.TryGetValue("vex.signature.verifiedAt", out var signedAtRaw)
&& DateTimeOffset.TryParse(signedAtRaw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedSignedAt))
{
@@ -267,7 +268,7 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
}
else
{
signedAt = _timeProvider.GetUtcNow();
signedAt = fallbackSignedAt;
}
return new VexAttestationMetadata(
@@ -320,7 +321,7 @@ internal sealed class WorkerSignatureVerifier : IVexSignatureVerifier
keyId = diagnosticKeyId;
}
var verifiedAt = attestationMetadata.SignedAt ?? _timeProvider.GetUtcNow();
var verifiedAt = attestationMetadata.SignedAt;
return new VexSignatureMetadata(
type!,

View File

@@ -0,0 +1,23 @@
using System.Collections.Immutable;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Evidence linker helper for binary diff predicates.
/// </summary>
public sealed class BinaryDiffEvidenceLinker
{
private readonly IVexEvidenceLinker _linker;
public BinaryDiffEvidenceLinker(IVexEvidenceLinker linker)
{
_linker = linker ?? throw new ArgumentNullException(nameof(linker));
}
public Task<ImmutableArray<VexEvidenceLink>> LinkAsync(
BinaryDiffPredicate diff,
string dsseEnvelopeUri,
CancellationToken cancellationToken = default)
=> _linker.AutoLinkFromBinaryDiffAsync(diff, dsseEnvelopeUri, cancellationToken);
}

View File

@@ -0,0 +1,71 @@
using System.Text;
using Microsoft.Extensions.Options;
using StellaOps.Attestation;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Validates DSSE envelopes using the shared DSSE verifier.
/// </summary>
public sealed class DsseEvidenceSignatureValidator : IVexEvidenceSignatureValidator
{
private readonly IDsseVerifier _verifier;
private readonly IEvidencePublicKeyResolver _keyResolver;
private readonly VexEvidenceLinkOptions _options;
public DsseEvidenceSignatureValidator(
IDsseVerifier verifier,
IEvidencePublicKeyResolver keyResolver,
IOptions<VexEvidenceLinkOptions> options)
{
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
_keyResolver = keyResolver ?? throw new ArgumentNullException(nameof(keyResolver));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async Task<EvidenceSignatureValidation> ValidateAsync(EvidenceSource source, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(source);
ct.ThrowIfCancellationRequested();
if (!_options.ValidateSignatures)
{
return new EvidenceSignatureValidation
{
IsVerified = false,
FailureReason = "validation_disabled"
};
}
if (source.EnvelopeBytes is null || source.EnvelopeBytes.Length == 0)
{
return new EvidenceSignatureValidation
{
IsVerified = false,
FailureReason = "envelope_missing"
};
}
var envelopeJson = Encoding.UTF8.GetString(source.EnvelopeBytes);
var result = await _verifier
.VerifyAsync(envelopeJson, (keyId, token) => _keyResolver.ResolveAsync(keyId, source, token), ct)
.ConfigureAwait(false);
if (!result.IsValid)
{
var reason = result.Issues.IsDefaultOrEmpty ? "signature_invalid" : string.Join(",", result.Issues);
return new EvidenceSignatureValidation
{
IsVerified = false,
FailureReason = reason
};
}
var signer = result.PrimaryKeyId ?? (result.VerifiedKeyIds.Length > 0 ? result.VerifiedKeyIds[0] : null);
return new EvidenceSignatureValidation
{
IsVerified = true,
SignerIdentity = signer
};
}
}

View File

@@ -0,0 +1,54 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// In-memory store for evidence links.
/// </summary>
public sealed class InMemoryVexEvidenceLinkStore : IVexEvidenceLinkStore
{
private readonly ConcurrentDictionary<string, VexEvidenceLink> _links = new(StringComparer.Ordinal);
public Task SaveAsync(VexEvidenceLink link, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(link);
ct.ThrowIfCancellationRequested();
_links.AddOrUpdate(link.LinkId, link, static (_, existing) => existing);
return Task.CompletedTask;
}
public Task<VexEvidenceLink?> GetAsync(string linkId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
ct.ThrowIfCancellationRequested();
_links.TryGetValue(linkId, out var link);
return Task.FromResult(link);
}
public Task<ImmutableArray<VexEvidenceLink>> GetByVexEntryAsync(string vexEntryId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vexEntryId);
ct.ThrowIfCancellationRequested();
var results = _links.Values
.Where(link => string.Equals(link.VexEntryId, vexEntryId, StringComparison.Ordinal))
.OrderByDescending(link => link.Confidence)
.ThenBy(link => link.LinkedAt)
.ThenBy(link => link.LinkId, StringComparer.Ordinal)
.ToImmutableArray();
return Task.FromResult(results);
}
public Task DeleteAsync(string linkId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(linkId);
ct.ThrowIfCancellationRequested();
_links.TryRemove(linkId, out _);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,94 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Link between a VEX entry and supporting evidence.
/// </summary>
public sealed record VexEvidenceLink
{
/// <summary>Unique link identifier.</summary>
public required string LinkId { get; init; }
/// <summary>VEX entry this evidence supports.</summary>
public required string VexEntryId { get; init; }
/// <summary>Type of evidence.</summary>
public required EvidenceType EvidenceType { get; init; }
/// <summary>URI to the evidence artifact (oci://, cas://, file://).</summary>
public required string EvidenceUri { get; init; }
/// <summary>Digest of the DSSE envelope (sha256:...).</summary>
public required string EnvelopeDigest { get; init; }
/// <summary>Predicate type in the DSSE envelope.</summary>
public required string PredicateType { get; init; }
/// <summary>Confidence score from the evidence (0.0-1.0).</summary>
public required double Confidence { get; init; }
/// <summary>Justification derived from evidence.</summary>
public required VexJustification Justification { get; init; }
/// <summary>When the evidence was created.</summary>
public required DateTimeOffset EvidenceCreatedAt { get; init; }
/// <summary>When the link was created.</summary>
public required DateTimeOffset LinkedAt { get; init; }
/// <summary>Signer identity (key ID or certificate subject).</summary>
public string? SignerIdentity { get; init; }
/// <summary>Rekor log index if submitted to transparency log.</summary>
public string? RekorLogIndex { get; init; }
/// <summary>Whether the evidence signature was validated.</summary>
public bool SignatureValidated { get; init; }
/// <summary>Additional metadata as key-value pairs.</summary>
public ImmutableDictionary<string, string> Metadata { get; init; }
= ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Types of evidence that can support VEX assertions.
/// </summary>
public enum EvidenceType
{
/// <summary>Binary-level diff showing patch applied.</summary>
BinaryDiff,
/// <summary>Call graph analysis showing code not reachable.</summary>
ReachabilityAnalysis,
/// <summary>Runtime analysis showing code not executed.</summary>
RuntimeAnalysis,
/// <summary>Human attestation (manual review).</summary>
HumanAttestation,
/// <summary>Vendor advisory or statement.</summary>
VendorAdvisory,
/// <summary>Other/custom evidence type.</summary>
Other
}
/// <summary>
/// Collection of evidence links for a VEX entry.
/// </summary>
public sealed record VexEvidenceLinkSet
{
/// <summary>VEX entry ID.</summary>
public required string VexEntryId { get; init; }
/// <summary>All evidence links, sorted by confidence descending.</summary>
public required ImmutableArray<VexEvidenceLink> Links { get; init; }
/// <summary>Highest confidence among all links.</summary>
public double MaxConfidence => Links.IsEmpty ? 0 : Links.Max(link => link.Confidence);
/// <summary>Primary link (highest confidence).</summary>
public VexEvidenceLink? PrimaryLink => Links.IsEmpty ? null : Links[0];
}

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Configuration options for evidence linking.
/// </summary>
public sealed class VexEvidenceLinkOptions
{
public const string SectionName = "Excititor:Evidence:Linking";
/// <summary>Enable evidence linking services.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Auto-link evidence from binary diff predicates.</summary>
public bool AutoLinkOnBinaryDiff { get; set; } = true;
/// <summary>Minimum confidence required to keep a link.</summary>
public double ConfidenceThreshold { get; set; } = 0.8;
/// <summary>Verify DSSE signatures when envelope bytes are available.</summary>
public bool ValidateSignatures { get; set; } = true;
/// <summary>Verify Rekor inclusion if available.</summary>
public bool ValidateRekorInclusion { get; set; } = false;
/// <summary>Allow links even when signatures cannot be verified.</summary>
public bool AllowUnverifiedEvidence { get; set; } = true;
/// <summary>Maximum number of links to retain per VEX entry.</summary>
public int MaxLinksPerEntry { get; set; } = 10;
}

View File

@@ -0,0 +1,346 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using Microsoft.Extensions.Options;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Default evidence linker implementation.
/// </summary>
public sealed class VexEvidenceLinker : IVexEvidenceLinker
{
private readonly IVexEvidenceLinkStore _store;
private readonly IBinaryDiffVexEntryResolver _binaryDiffResolver;
private readonly IVexEvidenceSignatureValidator _signatureValidator;
private readonly TimeProvider _timeProvider;
private readonly VexEvidenceLinkOptions _options;
public VexEvidenceLinker(
IVexEvidenceLinkStore store,
IBinaryDiffVexEntryResolver binaryDiffResolver,
IVexEvidenceSignatureValidator signatureValidator,
TimeProvider timeProvider,
IOptions<VexEvidenceLinkOptions> options)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_binaryDiffResolver = binaryDiffResolver ?? throw new ArgumentNullException(nameof(binaryDiffResolver));
_signatureValidator = signatureValidator ?? throw new ArgumentNullException(nameof(signatureValidator));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async Task<VexEvidenceLink> LinkAsync(
string vexEntryId,
EvidenceSource source,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vexEntryId);
ArgumentNullException.ThrowIfNull(source);
cancellationToken.ThrowIfCancellationRequested();
if (!_options.Enabled)
{
throw new InvalidOperationException("Evidence linking is disabled.");
}
var trimmedUri = source.Uri?.Trim();
if (string.IsNullOrWhiteSpace(trimmedUri))
{
throw new ArgumentException("Evidence URI must be provided.", nameof(source));
}
var normalizedDigest = NormalizeDigest(source.Digest);
if (string.IsNullOrWhiteSpace(normalizedDigest))
{
throw new ArgumentException("Evidence digest must be provided.", nameof(source));
}
var confidence = NormalizeConfidence(source.Confidence);
var now = _timeProvider.GetUtcNow();
var evidenceCreatedAt = source.EvidenceCreatedAt ?? now;
EvidenceSignatureValidation? validation = null;
if (_options.ValidateSignatures)
{
validation = await _signatureValidator.ValidateAsync(source, cancellationToken).ConfigureAwait(false);
if (!validation.IsVerified && !_options.AllowUnverifiedEvidence)
{
throw new InvalidOperationException(
$"Evidence signature validation failed: {validation.FailureReason ?? "unknown"}");
}
}
var link = new VexEvidenceLink
{
LinkId = VexEvidenceLinkIds.BuildLinkId(vexEntryId, source),
VexEntryId = vexEntryId.Trim(),
EvidenceType = source.Type,
EvidenceUri = trimmedUri,
EnvelopeDigest = normalizedDigest,
PredicateType = source.PredicateType ?? string.Empty,
Confidence = confidence,
Justification = source.Justification ?? VexJustification.CodeNotReachable,
EvidenceCreatedAt = evidenceCreatedAt,
LinkedAt = now,
SignerIdentity = validation?.SignerIdentity ?? source.SignerIdentity,
RekorLogIndex = validation?.RekorLogIndex ?? source.RekorLogIndex,
SignatureValidated = validation?.IsVerified ?? false,
Metadata = NormalizeMetadata(source.Metadata)
};
await _store.SaveAsync(link, cancellationToken).ConfigureAwait(false);
return link;
}
public async Task<VexEvidenceLinkSet> GetLinksAsync(
string vexEntryId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vexEntryId);
var links = await _store.GetByVexEntryAsync(vexEntryId, cancellationToken).ConfigureAwait(false);
if (_options.MaxLinksPerEntry > 0 && links.Length > _options.MaxLinksPerEntry)
{
links = links.Take(_options.MaxLinksPerEntry).ToImmutableArray();
}
return new VexEvidenceLinkSet
{
VexEntryId = vexEntryId.Trim(),
Links = links
};
}
public async Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
BinaryDiffPredicate diff,
string dsseEnvelopeUri,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(diff);
ArgumentException.ThrowIfNullOrWhiteSpace(dsseEnvelopeUri);
if (!_options.Enabled || !_options.AutoLinkOnBinaryDiff)
{
return ImmutableArray<VexEvidenceLink>.Empty;
}
var builder = ImmutableArray.CreateBuilder<VexEvidenceLink>();
var envelopeDigest = ExtractDigestOrHash(dsseEnvelopeUri);
foreach (var finding in diff.Findings)
{
cancellationToken.ThrowIfCancellationRequested();
if (finding.Verdict != Verdict.Patched)
{
continue;
}
var vexEntryIds = _binaryDiffResolver.ResolveEntryIds(diff, finding);
if (vexEntryIds.IsDefaultOrEmpty)
{
continue;
}
var confidence = NormalizeConfidence(finding.Confidence ?? 0.9);
if (confidence < _options.ConfidenceThreshold)
{
continue;
}
var justification = MapJustification(finding);
var evidenceCreatedAt = diff.Metadata.AnalysisTimestamp;
foreach (var vexEntryId in vexEntryIds)
{
if (string.IsNullOrWhiteSpace(vexEntryId))
{
continue;
}
var source = new EvidenceSource
{
Type = EvidenceType.BinaryDiff,
Uri = dsseEnvelopeUri,
Digest = envelopeDigest,
PredicateType = BinaryDiffPredicate.PredicateType,
Confidence = confidence,
Justification = justification,
EvidenceCreatedAt = evidenceCreatedAt,
Metadata = BuildBinaryDiffMetadata(finding)
};
try
{
var link = await LinkAsync(vexEntryId, source, cancellationToken).ConfigureAwait(false);
builder.Add(link);
}
catch (InvalidOperationException)
{
// Skip invalid evidence links when validation is enforced.
}
}
}
return builder.ToImmutable();
}
private static ImmutableDictionary<string, string> BuildBinaryDiffMetadata(BinaryDiffFinding finding)
{
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadata["binary.path"] = finding.Path ?? string.Empty;
metadata["binary.format"] = finding.BinaryFormat.ToString().ToLowerInvariant();
metadata["binary.changeType"] = finding.ChangeType.ToString().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(finding.LayerDigest))
{
metadata["binary.layerDigest"] = finding.LayerDigest;
}
return metadata.ToImmutable();
}
private static VexJustification MapJustification(BinaryDiffFinding finding)
{
var deltas = finding.SectionDeltas;
if (!deltas.IsDefaultOrEmpty)
{
var hasTextChange = deltas.Any(delta =>
string.Equals(delta.Section, ".text", StringComparison.Ordinal) &&
delta.Status != SectionStatus.Identical);
if (hasTextChange)
{
return VexJustification.CodeNotPresent;
}
if (deltas.Any())
{
return VexJustification.ProtectedAtRuntime;
}
}
return VexJustification.CodeNotReachable;
}
private static double NormalizeConfidence(double confidence)
{
if (double.IsNaN(confidence) || double.IsInfinity(confidence))
{
return 0;
}
return Math.Clamp(confidence, 0, 1);
}
private static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return string.Empty;
}
return digest.Trim().ToLowerInvariant();
}
private static ImmutableDictionary<string, string> NormalizeMetadata(ImmutableDictionary<string, string> metadata)
{
if (metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
builder[pair.Key.Trim()] = pair.Value?.Trim() ?? string.Empty;
}
return builder.ToImmutable();
}
private static string ExtractDigestOrHash(string uri)
{
if (TryExtractDigest(uri, out var digest))
{
return digest;
}
var bytes = Encoding.UTF8.GetBytes(uri.Trim());
var hash = SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool TryExtractDigest(string uri, out string digest)
{
digest = string.Empty;
if (string.IsNullOrWhiteSpace(uri))
{
return false;
}
var normalized = uri.Trim();
var index = normalized.IndexOf("sha256:", StringComparison.OrdinalIgnoreCase);
if (index < 0)
{
return false;
}
var start = index + "sha256:".Length;
var end = start;
while (end < normalized.Length && IsHex(normalized[end]))
{
end++;
}
if (end <= start)
{
return false;
}
digest = "sha256:" + normalized[start..end].ToLowerInvariant();
return true;
}
private static bool IsHex(char value)
{
return (value >= '0' && value <= '9') ||
(value >= 'a' && value <= 'f') ||
(value >= 'A' && value <= 'F');
}
}
/// <summary>
/// Helper for deterministic VEX evidence identifiers.
/// </summary>
public static class VexEvidenceLinkIds
{
public static string BuildVexEntryId(string vulnerabilityId, string productKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
var vuln = vulnerabilityId.Trim().ToUpperInvariant();
var product = productKey.Trim();
return FormattableString.Invariant($"vex:{vuln}:{product}");
}
public static string BuildLinkId(string vexEntryId, EvidenceSource source)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vexEntryId);
ArgumentNullException.ThrowIfNull(source);
var payload = FormattableString.Invariant(
$"{vexEntryId.Trim()}|{source.Type}|{source.Uri?.Trim()}|{source.Digest?.Trim()}|{source.PredicateType?.Trim()}");
var bytes = Encoding.UTF8.GetBytes(payload);
var hash = SHA256.HashData(bytes);
return "vexlink:" + Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Immutable;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Service for linking VEX assertions to supporting evidence.
/// </summary>
public interface IVexEvidenceLinker
{
/// <summary>
/// Creates a link between a VEX entry and evidence.
/// </summary>
Task<VexEvidenceLink> LinkAsync(
string vexEntryId,
EvidenceSource source,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all evidence links for a VEX entry.
/// </summary>
Task<VexEvidenceLinkSet> GetLinksAsync(
string vexEntryId,
CancellationToken cancellationToken = default);
/// <summary>
/// Auto-links evidence from a binary diff result.
/// </summary>
Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
BinaryDiffPredicate diff,
string dsseEnvelopeUri,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Source of evidence for linking.
/// </summary>
public sealed record EvidenceSource
{
/// <summary>Evidence type.</summary>
public required EvidenceType Type { get; init; }
/// <summary>URI to the evidence artifact.</summary>
public required string Uri { get; init; }
/// <summary>Digest of the evidence artifact or DSSE envelope.</summary>
public required string Digest { get; init; }
/// <summary>Predicate type if DSSE/in-toto.</summary>
public string? PredicateType { get; init; }
/// <summary>Confidence score.</summary>
public double Confidence { get; init; } = 1.0;
/// <summary>Justification override when linking.</summary>
public VexJustification? Justification { get; init; }
/// <summary>Evidence creation time when known.</summary>
public DateTimeOffset? EvidenceCreatedAt { get; init; }
/// <summary>Signer identity (key ID or subject).</summary>
public string? SignerIdentity { get; init; }
/// <summary>Rekor log index if available.</summary>
public string? RekorLogIndex { get; init; }
/// <summary>DSSE envelope bytes for validation.</summary>
public byte[]? EnvelopeBytes { get; init; }
/// <summary>Additional metadata.</summary>
public ImmutableDictionary<string, string> Metadata { get; init; }
= ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Storage for evidence links.
/// </summary>
public interface IVexEvidenceLinkStore
{
Task SaveAsync(VexEvidenceLink link, CancellationToken ct = default);
Task<VexEvidenceLink?> GetAsync(string linkId, CancellationToken ct = default);
Task<ImmutableArray<VexEvidenceLink>> GetByVexEntryAsync(string vexEntryId, CancellationToken ct = default);
Task DeleteAsync(string linkId, CancellationToken ct = default);
}
/// <summary>
/// Resolves VEX entry IDs for binary diff findings.
/// </summary>
public interface IBinaryDiffVexEntryResolver
{
ImmutableArray<string> ResolveEntryIds(BinaryDiffPredicate diff, BinaryDiffFinding finding);
}
/// <summary>
/// Evidence signature validation service.
/// </summary>
public interface IVexEvidenceSignatureValidator
{
Task<EvidenceSignatureValidation> ValidateAsync(EvidenceSource source, CancellationToken ct = default);
}
/// <summary>
/// Resolves public keys for evidence signature verification.
/// </summary>
public interface IEvidencePublicKeyResolver
{
Task<string?> ResolveAsync(string? keyId, EvidenceSource source, CancellationToken ct = default);
}
/// <summary>
/// Result for evidence signature validation.
/// </summary>
public sealed record EvidenceSignatureValidation
{
public required bool IsVerified { get; init; }
public string? SignerIdentity { get; init; }
public string? RekorLogIndex { get; init; }
public string? FailureReason { get; init; }
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Immutable;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
namespace StellaOps.Excititor.Core.Evidence;
/// <summary>
/// Default resolver that returns no VEX entry IDs.
/// </summary>
public sealed class NoopBinaryDiffVexEntryResolver : IBinaryDiffVexEntryResolver
{
public ImmutableArray<string> ResolveEntryIds(BinaryDiffPredicate diff, BinaryDiffFinding finding)
=> ImmutableArray<string>.Empty;
}
/// <summary>
/// Default evidence signature validator (no verification).
/// </summary>
public sealed class NoopEvidenceSignatureValidator : IVexEvidenceSignatureValidator
{
public Task<EvidenceSignatureValidation> ValidateAsync(EvidenceSource source, CancellationToken ct = default)
=> Task.FromResult(new EvidenceSignatureValidation
{
IsVerified = false,
FailureReason = "verification_unavailable"
});
}
/// <summary>
/// Default public key resolver that returns no keys.
/// </summary>
public sealed class NoopEvidencePublicKeyResolver : IEvidencePublicKeyResolver
{
public Task<string?> ResolveAsync(string? keyId, EvidenceSource source, CancellationToken ct = default)
=> Task.FromResult<string?>(null);
}

View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Attestation;
namespace StellaOps.Excititor.Core.Evidence;
public static class VexEvidenceServiceCollectionExtensions
{
public static IServiceCollection AddVexEvidenceLinking(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration is not null)
{
services.Configure<VexEvidenceLinkOptions>(
configuration.GetSection(VexEvidenceLinkOptions.SectionName));
}
else
{
services.Configure<VexEvidenceLinkOptions>(_ => { });
}
services.TryAddSingleton<IVexEvidenceLinkStore, InMemoryVexEvidenceLinkStore>();
services.TryAddSingleton<IBinaryDiffVexEntryResolver, NoopBinaryDiffVexEntryResolver>();
services.TryAddSingleton<IEvidencePublicKeyResolver, NoopEvidencePublicKeyResolver>();
services.TryAddSingleton<IDsseVerifier, DsseVerifier>();
services.TryAddSingleton<IVexEvidenceSignatureValidator, DsseEvidenceSignatureValidator>();
services.TryAddSingleton<IVexEvidenceLinker, VexEvidenceLinker>();
services.TryAddSingleton<BinaryDiffEvidenceLinker>();
return services;
}
}

View File

@@ -19,6 +19,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
<ProjectReference Include="../../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
<ProjectReference Include="../../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />

View File

@@ -8,3 +8,10 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0312-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0312-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0312-A | TODO | Revalidated 2026-01-07 (open findings; pending approval). |
| VEX-LINK-MODELS-0001 | DONE | SPRINT_20260113_003_001 - Evidence link models. |
| VEX-LINK-INTERFACE-0001 | DONE | SPRINT_20260113_003_001 - Evidence linker interfaces. |
| VEX-LINK-BINARYDIFF-0001 | DONE | SPRINT_20260113_003_001 - Binary diff evidence linking. |
| VEX-LINK-STORE-0001 | DONE | SPRINT_20260113_003_001 - In-memory store and persistence schema. |
| VEX-LINK-AUTOLINK-0001 | DONE | SPRINT_20260113_003_001 - Auto-linking pipeline. |
| VEX-LINK-VALIDATION-0001 | DONE | SPRINT_20260113_003_001 - DSSE signature validation. |
| VEX-LINK-DI-0001 | DONE | SPRINT_20260113_003_001 - DI registration and options. |

View File

@@ -5,6 +5,7 @@ using System.Collections.Immutable;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core.Evidence;
namespace StellaOps.Excititor.Core;
@@ -24,7 +25,8 @@ public sealed record VexExportRequest(
VexQuery Query,
ImmutableArray<VexConsensus> Consensus,
ImmutableArray<VexClaim> Claims,
DateTimeOffset GeneratedAt);
DateTimeOffset GeneratedAt,
ImmutableDictionary<string, VexEvidenceLinkSet>? EvidenceLinks = null);
public sealed record VexExportResult(
VexContentAddress Digest,

View File

@@ -8,6 +8,7 @@ using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Core.Storage;
@@ -45,6 +46,7 @@ public sealed class VexExportEngine : IExportEngine
private readonly IReadOnlyList<IVexArtifactStore> _artifactStores;
private readonly IVexAttestationClient? _attestationClient;
private readonly IVexMirrorBundlePublisher? _mirrorPublisher;
private readonly IVexEvidenceLinker? _evidenceLinker;
public VexExportEngine(
IVexExportStore exportStore,
@@ -55,7 +57,8 @@ public sealed class VexExportEngine : IExportEngine
IVexCacheIndex? cacheIndex = null,
IEnumerable<IVexArtifactStore>? artifactStores = null,
IVexAttestationClient? attestationClient = null,
IVexMirrorBundlePublisher? mirrorPublisher = null)
IVexMirrorBundlePublisher? mirrorPublisher = null,
IVexEvidenceLinker? evidenceLinker = null)
{
_exportStore = exportStore ?? throw new ArgumentNullException(nameof(exportStore));
_policyEvaluator = policyEvaluator ?? throw new ArgumentNullException(nameof(policyEvaluator));
@@ -65,6 +68,7 @@ public sealed class VexExportEngine : IExportEngine
_artifactStores = artifactStores?.ToArray() ?? Array.Empty<IVexArtifactStore>();
_attestationClient = attestationClient;
_mirrorPublisher = mirrorPublisher;
_evidenceLinker = evidenceLinker;
if (exporters is null)
{
@@ -115,11 +119,13 @@ public sealed class VexExportEngine : IExportEngine
var envelopeContext = VexExportEnvelopeBuilder.Build(dataset, policySnapshot, context.RequestedAt);
var exporter = ResolveExporter(context.Format);
var evidenceLinks = await BuildEvidenceLinksAsync(dataset.Claims, cancellationToken).ConfigureAwait(false);
var exportRequest = new VexExportRequest(
context.Query,
envelopeContext.Consensus,
dataset.Claims,
context.RequestedAt);
context.RequestedAt,
evidenceLinks);
var digest = exporter.Digest(exportRequest);
var exportId = FormattableString.Invariant($"exports/{context.RequestedAt:yyyyMMddTHHmmssfffZ}/{digest.Digest}");
@@ -282,6 +288,35 @@ public sealed class VexExportEngine : IExportEngine
=> _exporters.TryGetValue(format, out var exporter)
? exporter
: throw new InvalidOperationException($"No exporter registered for format '{format}'.");
private async Task<ImmutableDictionary<string, VexEvidenceLinkSet>> BuildEvidenceLinksAsync(
ImmutableArray<VexClaim> claims,
CancellationToken cancellationToken)
{
if (_evidenceLinker is null || claims.IsDefaultOrEmpty || claims.Length == 0)
{
return ImmutableDictionary<string, VexEvidenceLinkSet>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, VexEvidenceLinkSet>(StringComparer.Ordinal);
foreach (var claim in claims)
{
cancellationToken.ThrowIfCancellationRequested();
var entryId = VexEvidenceLinkIds.BuildVexEntryId(claim.VulnerabilityId, claim.Product.Key);
if (builder.ContainsKey(entryId))
{
continue;
}
var links = await _evidenceLinker.GetLinksAsync(entryId, cancellationToken).ConfigureAwait(false);
if (!links.Links.IsDefaultOrEmpty && links.Links.Length > 0)
{
builder[entryId] = links;
}
}
return builder.ToImmutable();
}
}
public static class VexExportServiceCollectionExtensions

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0315-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0315-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0315-A | TODO | Revalidated 2026-01-07 (open findings; pending approval). |
| VEX-LINK-EXPORT-0001 | DONE | SPRINT_20260113_003_001 - Export engine passes evidence links. |

View File

@@ -9,6 +9,7 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
namespace StellaOps.Excititor.Formats.CycloneDX;
@@ -50,7 +51,8 @@ public sealed class CycloneDxExporter : IVexExporter
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var reconciliation = CycloneDxComponentReconciler.Reconcile(request.Claims);
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs);
var evidenceLinks = request.EvidenceLinks ?? ImmutableDictionary<string, VexEvidenceLinkSet>.Empty;
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs, evidenceLinks);
var missingJustifications = request.Claims
.Where(static claim => claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
@@ -78,7 +80,8 @@ public sealed class CycloneDxExporter : IVexExporter
private static ImmutableArray<CycloneDxVulnerabilityEntry> BuildVulnerabilities(
ImmutableArray<VexClaim> claims,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs)
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs,
ImmutableDictionary<string, VexEvidenceLinkSet> evidenceLinks)
{
var entries = ImmutableArray.CreateBuilder<CycloneDxVulnerabilityEntry>();
@@ -92,6 +95,7 @@ public sealed class CycloneDxExporter : IVexExporter
var analysis = new CycloneDxAnalysis(
State: MapStatus(claim.Status),
Justification: claim.Justification?.ToString().ToLowerInvariant(),
Detail: null,
Responses: null);
var affects = BuildAffects(componentRef, claim);
@@ -102,6 +106,13 @@ public sealed class CycloneDxExporter : IVexExporter
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
new CycloneDxProperty("stellaops/documentDigest", claim.Document.Digest));
var evidenceLink = ResolveEvidenceLink(claim, evidenceLinks);
if (evidenceLink is not null)
{
analysis = analysis with { Detail = FormattableString.Invariant($"Evidence: {evidenceLink.EvidenceUri}") };
properties = AppendEvidenceProperties(properties, evidenceLink);
}
var vulnerabilityId = claim.VulnerabilityId;
var bomRef = FormattableString.Invariant($"{vulnerabilityId}#{Normalize(componentRef)}");
@@ -123,6 +134,50 @@ public sealed class CycloneDxExporter : IVexExporter
.ToImmutableArray();
}
private static VexEvidenceLink? ResolveEvidenceLink(
VexClaim claim,
ImmutableDictionary<string, VexEvidenceLinkSet> evidenceLinks)
{
if (evidenceLinks.Count == 0)
{
return null;
}
var entryId = VexEvidenceLinkIds.BuildVexEntryId(claim.VulnerabilityId, claim.Product.Key);
return evidenceLinks.TryGetValue(entryId, out var linkSet)
? linkSet.PrimaryLink
: null;
}
private static ImmutableArray<CycloneDxProperty> AppendEvidenceProperties(
ImmutableArray<CycloneDxProperty> properties,
VexEvidenceLink link)
{
var builder = properties.ToBuilder();
builder.Add(new CycloneDxProperty("stellaops:evidence:type", link.EvidenceType.ToString().ToLowerInvariant()));
builder.Add(new CycloneDxProperty("stellaops:evidence:uri", link.EvidenceUri));
builder.Add(new CycloneDxProperty(
"stellaops:evidence:confidence",
link.Confidence.ToString("F2", CultureInfo.InvariantCulture)));
builder.Add(new CycloneDxProperty("stellaops:evidence:predicate-type", link.PredicateType));
builder.Add(new CycloneDxProperty("stellaops:evidence:envelope-digest", link.EnvelopeDigest));
builder.Add(new CycloneDxProperty(
"stellaops:evidence:signature-validated",
link.SignatureValidated ? "true" : "false"));
if (!string.IsNullOrWhiteSpace(link.SignerIdentity))
{
builder.Add(new CycloneDxProperty("stellaops:evidence:signer", link.SignerIdentity));
}
if (!string.IsNullOrWhiteSpace(link.RekorLogIndex))
{
builder.Add(new CycloneDxProperty("stellaops:evidence:rekor-index", link.RekorLogIndex));
}
return builder.ToImmutable();
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -290,6 +345,7 @@ internal sealed record CycloneDxVulnerabilityEntry(
internal sealed record CycloneDxAnalysis(
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("detail"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Detail,
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
internal sealed record CycloneDxAffectEntry(

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0319-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0319-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0319-A | TODO | Revalidated 2026-01-07 (open findings; pending approval). |
| VEX-LINK-CYCLONEDX-0001 | DONE | SPRINT_20260113_003_001 - Evidence metadata in CycloneDX output. |

View File

@@ -0,0 +1,23 @@
-- VEX evidence links
CREATE TABLE IF NOT EXISTS vex.evidence_links (
link_id TEXT PRIMARY KEY,
vex_entry_id TEXT NOT NULL,
evidence_type TEXT NOT NULL,
evidence_uri TEXT NOT NULL,
envelope_digest TEXT NOT NULL,
predicate_type TEXT NOT NULL,
confidence DOUBLE PRECISION NOT NULL,
justification TEXT NOT NULL,
evidence_created_at TIMESTAMPTZ NOT NULL,
linked_at TIMESTAMPTZ NOT NULL,
signer_identity TEXT NULL,
rekor_log_index TEXT NULL,
signature_validated BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS ix_evidence_links_vex_entry_id
ON vex.evidence_links (vex_entry_id);
CREATE INDEX IF NOT EXISTS ix_evidence_links_envelope_digest
ON vex.evidence_links (envelope_digest);

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0323-M | DONE | Revalidated 2026-01-07; maintainability audit for Excititor.Persistence. |
| AUDIT-0323-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.Persistence. |
| AUDIT-0323-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
| VEX-LINK-STORE-0001 | DONE | SPRINT_20260113_003_001 - Evidence link migration added. |

View File

@@ -0,0 +1,252 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.TimeProvider.Testing;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Evidence;
public sealed class VexEvidenceLinkerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task LinkAsync_CreatesDeterministicLink()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new VexEvidenceLinkOptions
{
ValidateSignatures = false,
AllowUnverifiedEvidence = true
});
var linker = new VexEvidenceLinker(
new InMemoryVexEvidenceLinkStore(),
new NoopBinaryDiffVexEntryResolver(),
new NoopEvidenceSignatureValidator(),
time,
options);
var source = new EvidenceSource
{
Type = EvidenceType.BinaryDiff,
Uri = "oci://registry/evidence@sha256:abc",
Digest = "sha256:abc",
PredicateType = BinaryDiffPredicate.PredicateType,
Confidence = 0.92,
Justification = VexJustification.CodeNotPresent,
EvidenceCreatedAt = time.GetUtcNow()
};
var link = await linker.LinkAsync("vex:CVE-2026-0001:pkg:demo", source);
Assert.Equal("vex:CVE-2026-0001:pkg:demo", link.VexEntryId);
Assert.Equal("sha256:abc", link.EnvelopeDigest);
Assert.Equal(VexJustification.CodeNotPresent, link.Justification);
Assert.False(link.SignatureValidated);
Assert.Equal(VexEvidenceLinkIds.BuildLinkId(link.VexEntryId, source), link.LinkId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetLinksAsync_OrdersByConfidenceThenTime()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new VexEvidenceLinkOptions
{
ValidateSignatures = false,
AllowUnverifiedEvidence = true
});
var store = new InMemoryVexEvidenceLinkStore();
var linker = new VexEvidenceLinker(
store,
new NoopBinaryDiffVexEntryResolver(),
new NoopEvidenceSignatureValidator(),
time,
options);
var entryId = "vex:CVE-2026-0002:pkg:demo";
await linker.LinkAsync(entryId, new EvidenceSource
{
Type = EvidenceType.BinaryDiff,
Uri = "oci://registry/evidence@sha256:001",
Digest = "sha256:001",
PredicateType = BinaryDiffPredicate.PredicateType,
Confidence = 0.5,
Justification = VexJustification.CodeNotPresent,
EvidenceCreatedAt = time.GetUtcNow()
});
time.Advance(TimeSpan.FromMinutes(1));
await linker.LinkAsync(entryId, new EvidenceSource
{
Type = EvidenceType.BinaryDiff,
Uri = "oci://registry/evidence@sha256:002",
Digest = "sha256:002",
PredicateType = BinaryDiffPredicate.PredicateType,
Confidence = 0.9,
Justification = VexJustification.CodeNotReachable,
EvidenceCreatedAt = time.GetUtcNow()
});
var links = await linker.GetLinksAsync(entryId);
Assert.Equal(2, links.Links.Length);
Assert.Equal("sha256:002", links.Links[0].EnvelopeDigest);
Assert.Equal("sha256:001", links.Links[1].EnvelopeDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AutoLinkFromBinaryDiffAsync_RespectsThresholdAndJustification()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new VexEvidenceLinkOptions
{
ValidateSignatures = false,
AllowUnverifiedEvidence = true,
ConfidenceThreshold = 0.8
});
var linker = new VexEvidenceLinker(
new InMemoryVexEvidenceLinkStore(),
new TestBinaryDiffResolver(),
new NoopEvidenceSignatureValidator(),
time,
options);
var predicate = BuildPredicate(time.GetUtcNow(), 0.95, SectionStatus.Modified);
var links = await linker.AutoLinkFromBinaryDiffAsync(predicate, "oci://registry/evidence@sha256:deadbeef");
Assert.Single(links);
Assert.Equal(VexJustification.CodeNotPresent, links[0].Justification);
Assert.Equal("sha256:deadbeef", links[0].EnvelopeDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AutoLinkFromBinaryDiffAsync_SkipsLowConfidence()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new VexEvidenceLinkOptions
{
ValidateSignatures = false,
AllowUnverifiedEvidence = true,
ConfidenceThreshold = 0.9
});
var linker = new VexEvidenceLinker(
new InMemoryVexEvidenceLinkStore(),
new TestBinaryDiffResolver(),
new NoopEvidenceSignatureValidator(),
time,
options);
var predicate = BuildPredicate(time.GetUtcNow(), 0.4, SectionStatus.Modified);
var links = await linker.AutoLinkFromBinaryDiffAsync(predicate, "oci://registry/evidence@sha256:deadbeef");
Assert.Empty(links);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task LinkAsync_RejectsUnverifiedEvidenceWhenRequired()
{
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new VexEvidenceLinkOptions
{
ValidateSignatures = true,
AllowUnverifiedEvidence = false
});
var linker = new VexEvidenceLinker(
new InMemoryVexEvidenceLinkStore(),
new NoopBinaryDiffVexEntryResolver(),
new AlwaysFailSignatureValidator(),
time,
options);
var source = new EvidenceSource
{
Type = EvidenceType.BinaryDiff,
Uri = "oci://registry/evidence@sha256:dead",
Digest = "sha256:dead",
PredicateType = BinaryDiffPredicate.PredicateType,
Confidence = 0.9,
Justification = VexJustification.CodeNotReachable,
EvidenceCreatedAt = time.GetUtcNow()
};
await Assert.ThrowsAsync<InvalidOperationException>(() =>
linker.LinkAsync("vex:CVE-2026-0010:pkg:demo", source));
}
private static BinaryDiffPredicate BuildPredicate(
DateTimeOffset timestamp,
double confidence,
SectionStatus textStatus)
{
var finding = new BinaryDiffFinding
{
Path = "/usr/bin/demo",
ChangeType = ChangeType.Modified,
BinaryFormat = BinaryFormat.Elf,
Verdict = Verdict.Patched,
Confidence = confidence,
SectionDeltas =
[
new SectionDelta
{
Section = ".text",
Status = textStatus
}
]
};
return new BinaryDiffPredicate
{
Subjects =
[
new BinaryDiffSubject
{
Name = "demo",
Digest = ImmutableDictionary<string, string>.Empty
}
],
Inputs = new BinaryDiffInputs
{
Base = new BinaryDiffImageReference
{
Digest = "sha256:base"
},
Target = new BinaryDiffImageReference
{
Digest = "sha256:target"
}
},
Findings = [finding],
Metadata = new BinaryDiffMetadata
{
ToolVersion = "1.0.0",
AnalysisTimestamp = timestamp,
TotalBinaries = 1,
ModifiedBinaries = 1,
AnalyzedSections = ImmutableArray<string>.Empty
}
};
}
private sealed class TestBinaryDiffResolver : IBinaryDiffVexEntryResolver
{
public ImmutableArray<string> ResolveEntryIds(BinaryDiffPredicate diff, BinaryDiffFinding finding)
=> ImmutableArray.Create("vex:CVE-2026-0009:pkg:demo");
}
private sealed class AlwaysFailSignatureValidator : IVexEvidenceSignatureValidator
{
public Task<EvidenceSignatureValidation> ValidateAsync(EvidenceSource source, CancellationToken ct = default)
=> Task.FromResult(new EvidenceSignatureValidation
{
IsVerified = false,
FailureReason = "signature_invalid"
});
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0313-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0313-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0313-A | DONE | Waived (test project; revalidated 2026-01-07). |
| VEX-LINK-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence linker tests. |

View File

@@ -5,6 +5,7 @@ using System.Text;
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
@@ -163,6 +164,33 @@ public sealed class ExportEngineTests
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExportAsync_PopulatesEvidenceLinksWhenAvailable()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new InMemoryExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var evidenceLinker = new TestEvidenceLinker();
var engine = new VexExportEngine(
store,
evaluator,
dataSource,
new[] { exporter },
NullLogger<VexExportEngine>.Instance,
evidenceLinker: evidenceLinker);
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var context = new VexExportRequestContext(query, VexExportFormat.Json, DateTimeOffset.UtcNow);
await engine.ExportAsync(context, CancellationToken.None);
Assert.NotNull(exporter.LastRequest);
Assert.False(exporter.LastRequest!.EvidenceLinks.IsDefaultOrEmpty);
Assert.Contains(evidenceLinker.EntryId, exporter.LastRequest.EvidenceLinks!.Keys);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExportAsync_IncludesQuietProvenanceMetadata()
@@ -397,15 +425,70 @@ public sealed class ExportEngineTests
public VexExportFormat Format { get; }
public VexExportRequest? LastRequest { get; private set; }
public VexContentAddress Digest(VexExportRequest request)
=> new("sha256", "deadbeef");
public ValueTask<VexExportResult> SerializeAsync(VexExportRequest request, Stream output, CancellationToken cancellationToken)
{
LastRequest = request;
var bytes = System.Text.Encoding.UTF8.GetBytes("{}");
output.Write(bytes);
return ValueTask.FromResult(new VexExportResult(new VexContentAddress("sha256", "deadbeef"), bytes.Length, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class TestEvidenceLinker : IVexEvidenceLinker
{
public TestEvidenceLinker()
{
EntryId = VexEvidenceLinkIds.BuildVexEntryId("CVE-2025-0001", "pkg:demo/app");
}
public string EntryId { get; }
public Task<VexEvidenceLink> LinkAsync(string vexEntryId, EvidenceSource source, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<VexEvidenceLinkSet> GetLinksAsync(string vexEntryId, CancellationToken cancellationToken = default)
{
if (!string.Equals(vexEntryId, EntryId, StringComparison.Ordinal))
{
return Task.FromResult(new VexEvidenceLinkSet
{
VexEntryId = vexEntryId,
Links = ImmutableArray<VexEvidenceLink>.Empty
});
}
var link = new VexEvidenceLink
{
LinkId = "vexlink:test",
VexEntryId = vexEntryId,
EvidenceType = EvidenceType.BinaryDiff,
EvidenceUri = "oci://registry/evidence@sha256:abc",
EnvelopeDigest = "sha256:abc",
PredicateType = "stellaops.binarydiff.v1",
Confidence = 0.9,
Justification = VexJustification.CodeNotReachable,
EvidenceCreatedAt = DateTimeOffset.UtcNow,
LinkedAt = DateTimeOffset.UtcNow,
SignatureValidated = false
};
return Task.FromResult(new VexEvidenceLinkSet
{
VexEntryId = vexEntryId,
Links = ImmutableArray.Create(link)
});
}
public Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
StellaOps.Attestor.StandardPredicates.BinaryDiff.BinaryDiffPredicate diff,
string dsseEnvelopeUri,
CancellationToken cancellationToken = default)
=> Task.FromResult(ImmutableArray<VexEvidenceLink>.Empty);
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0316-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0316-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0316-A | DONE | Waived (test project; revalidated 2026-01-07). |
| VEX-LINK-EXPORT-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence link wiring tests. |

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.Excititor.Formats.CycloneDX;
@@ -70,4 +71,68 @@ public sealed class CycloneDxExporterTests
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SerializeAsync_IncludesEvidenceMetadata()
{
var claim = new VexClaim(
"CVE-2025-7001",
"vendor:evidence",
new VexProduct("pkg:demo/agent@2.0.0", "Demo Agent", "2.0.0", "pkg:demo/agent@2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/cyclonedx/2")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.CodeNotReachable);
var entryId = VexEvidenceLinkIds.BuildVexEntryId(claim.VulnerabilityId, claim.Product.Key);
var evidence = new VexEvidenceLink
{
LinkId = "vexlink:test",
VexEntryId = entryId,
EvidenceType = EvidenceType.BinaryDiff,
EvidenceUri = "oci://registry/evidence@sha256:feed",
EnvelopeDigest = "sha256:feed",
PredicateType = "stellaops.binarydiff.v1",
Confidence = 0.95,
Justification = VexJustification.CodeNotPresent,
EvidenceCreatedAt = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
LinkedAt = new DateTimeOffset(2025, 10, 12, 0, 0, 1, TimeSpan.Zero),
SignatureValidated = true
};
var evidenceLinks = ImmutableDictionary<string, VexEvidenceLinkSet>.Empty.Add(
entryId,
new VexEvidenceLinkSet
{
VexEntryId = entryId,
Links = ImmutableArray.Create(evidence)
});
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
ImmutableArray.Create(claim),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
evidenceLinks);
var exporter = new CycloneDxExporter();
await using var stream = new MemoryStream();
await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var vulnerability = document.RootElement.GetProperty("vulnerabilities").EnumerateArray().Single();
vulnerability.GetProperty("analysis").GetProperty("detail").GetString()
.Should().Be("Evidence: oci://registry/evidence@sha256:feed");
var properties = vulnerability.GetProperty("properties").EnumerateArray().ToArray();
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:type");
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:uri");
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:confidence");
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:predicate-type");
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:envelope-digest");
properties.Should().Contain(p => p.GetProperty("name").GetString() == "stellaops:evidence:signature-validated");
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0320-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0320-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0320-A | DONE | Waived (test project; revalidated 2026-01-07). |
| VEX-LINK-CYCLONEDX-TESTS-0001 | DONE | SPRINT_20260113_003_001 - Evidence properties tests. |

View File

@@ -414,6 +414,42 @@ public sealed class DefaultVexProviderRunnerTests
state.NextEligibleRun.Should().Be(now + TimeSpan.FromHours(12));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeDeterministicSample_IsStable()
{
var sample1 = DefaultVexProviderRunner.ComputeDeterministicSample("excititor:test", 2);
var sample2 = DefaultVexProviderRunner.ComputeDeterministicSample("excititor:test", 2);
sample1.Should().Be(sample2);
sample1.Should().BeGreaterThanOrEqualTo(0d);
sample1.Should().BeLessThanOrEqualTo(1d);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CalculateDelayWithJitter_IsDeterministic()
{
var now = new DateTimeOffset(2025, 10, 21, 22, 0, 0, TimeSpan.Zero);
var time = new FixedTimeProvider(now);
var connector = TestConnector.Success("excititor:test");
var stateRepository = new InMemoryStateRepository();
var services = CreateServiceProvider(connector, stateRepository);
var runner = CreateRunner(services, time, options =>
{
options.Retry.BaseDelay = TimeSpan.FromMinutes(1);
options.Retry.MaxDelay = TimeSpan.FromMinutes(10);
options.Retry.JitterRatio = 0.2;
});
var delay1 = runner.CalculateDelayWithJitter(connector.Id, 2);
var delay2 = runner.CalculateDelayWithJitter(connector.Id, 2);
delay1.Should().Be(delay2);
delay1.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMinutes(1));
delay1.Should().BeLessThanOrEqualTo(TimeSpan.FromMinutes(10));
}
private static ServiceProvider CreateServiceProvider(
IVexConnector connector,
InMemoryStateRepository stateRepository,
@@ -590,7 +626,12 @@ public sealed class DefaultVexProviderRunnerTests
private sealed class NoopOrchestratorClient : IVexWorkerOrchestratorClient
{
public ValueTask<VexWorkerJobContext> StartJobAsync(string tenant, string connectorId, string? checkpoint, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new VexWorkerJobContext(tenant, connectorId, Guid.NewGuid(), checkpoint, DateTimeOffset.UtcNow));
=> ValueTask.FromResult(new VexWorkerJobContext(
tenant,
connectorId,
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
checkpoint,
DateTimeOffset.UnixEpoch));
public ValueTask SendHeartbeatAsync(VexWorkerJobContext context, VexWorkerHeartbeat heartbeat, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -26,7 +27,8 @@ public class VexWorkerOrchestratorClientTests
[Fact]
public async Task StartJobAsync_CreatesJobContext()
{
var client = CreateClient();
var expectedRunId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var client = CreateClient(guidGenerator: new FixedGuidGenerator(expectedRunId));
var context = await client.StartJobAsync("tenant-a", "connector-001", "checkpoint-123");
@@ -34,7 +36,7 @@ public class VexWorkerOrchestratorClientTests
Assert.Equal("tenant-a", context.Tenant);
Assert.Equal("connector-001", context.ConnectorId);
Assert.Equal("checkpoint-123", context.Checkpoint);
Assert.NotEqual(Guid.Empty, context.RunId);
Assert.Equal(expectedRunId, context.RunId);
}
[Fact]
@@ -63,8 +65,8 @@ public class VexWorkerOrchestratorClientTests
[Fact]
public async Task StartJobAsync_UsesOrchestratorClaim_WhenAvailable()
{
var jobId = Guid.NewGuid();
var leaseId = Guid.NewGuid();
var jobId = Guid.Parse("22222222-2222-2222-2222-222222222222");
var leaseId = Guid.Parse("33333333-3333-3333-3333-333333333333");
var handler = new StubHandler(request =>
{
@@ -104,9 +106,12 @@ public class VexWorkerOrchestratorClientTests
[Fact]
public async Task SendHeartbeatAsync_ExtendsLeaseViaOrchestrator()
{
var jobId = Guid.NewGuid();
var leaseId = Guid.NewGuid();
var leaseUntil = DateTimeOffset.Parse("2025-12-01T12:05:00Z");
var jobId = Guid.Parse("44444444-4444-4444-4444-444444444444");
var leaseId = Guid.Parse("55555555-5555-5555-5555-555555555555");
var leaseUntil = DateTimeOffset.Parse(
"2025-12-01T12:05:00Z",
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
var handler = new StubHandler(request =>
{
@@ -170,8 +175,8 @@ public class VexWorkerOrchestratorClientTests
[Fact]
public async Task SendHeartbeatAsync_StoresThrottleCommand_On429()
{
var jobId = Guid.NewGuid();
var leaseId = Guid.NewGuid();
var jobId = Guid.Parse("66666666-6666-6666-6666-666666666666");
var leaseId = Guid.Parse("77777777-7777-7777-7777-777777777777");
var handler = new StubHandler(request =>
{
@@ -295,9 +300,9 @@ public class VexWorkerOrchestratorClientTests
var context = new VexWorkerJobContext(
"tenant-a",
"connector-001",
Guid.NewGuid(),
Guid.Parse("88888888-8888-8888-8888-888888888888"),
null,
DateTimeOffset.UtcNow);
new DateTimeOffset(2025, 11, 27, 12, 0, 0, TimeSpan.Zero));
Assert.Equal(0, context.Sequence);
Assert.Equal(1, context.NextSequence());
@@ -307,7 +312,8 @@ public class VexWorkerOrchestratorClientTests
private VexWorkerOrchestratorClient CreateClient(
HttpClient? httpClient = null,
Action<VexWorkerOrchestratorOptions>? configure = null)
Action<VexWorkerOrchestratorOptions>? configure = null,
IGuidGenerator? guidGenerator = null)
{
var opts = new VexWorkerOrchestratorOptions
{
@@ -320,6 +326,7 @@ public class VexWorkerOrchestratorClientTests
return new VexWorkerOrchestratorClient(
_stateRepository,
_timeProvider,
guidGenerator ?? new FixedGuidGenerator(Guid.Parse("99999999-9999-9999-9999-999999999999")),
Microsoft.Extensions.Options.Options.Create(opts),
NullLogger<VexWorkerOrchestratorClient>.Instance,
httpClient);
@@ -380,4 +387,13 @@ public class VexWorkerOrchestratorClientTests
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed class FixedGuidGenerator : IGuidGenerator
{
private readonly Guid _value;
public FixedGuidGenerator(Guid value) => _value = value;
public Guid NewGuid() => _value;
}
}

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using Xunit;
namespace StellaOps.Excititor.Worker.Tests.Scheduling;
public sealed class VexConsensusRefreshServiceTests
{
[Fact]
public void ScheduleRefresh_IgnoresWhenDisabled()
{
var options = new VexWorkerOptions { DisableConsensus = true };
var monitor = new TestOptionsMonitor(options);
var service = new VexConsensusRefreshService(
new StubScopeFactory(),
monitor,
NullLogger<VexConsensusRefreshService>.Instance,
new FixedTimeProvider());
service.ScheduleRefresh("CVE-2025-0001", "pkg:example/test");
Assert.Equal(0, GetScheduledCount(service));
}
[Fact]
public void ScheduleRefresh_DeduplicatesRequests()
{
var options = new VexWorkerOptions { DisableConsensus = false };
var monitor = new TestOptionsMonitor(options);
var service = new VexConsensusRefreshService(
new StubScopeFactory(),
monitor,
NullLogger<VexConsensusRefreshService>.Instance,
new FixedTimeProvider());
service.ScheduleRefresh("CVE-2025-0001", "pkg:example/test");
service.ScheduleRefresh("CVE-2025-0001", "pkg:example/test");
Assert.Equal(1, GetScheduledCount(service));
}
[Fact]
public void ScheduleRefresh_RespectsUpdatedOptions()
{
var options = new VexWorkerOptions { DisableConsensus = true };
var monitor = new TestOptionsMonitor(options);
var service = new VexConsensusRefreshService(
new StubScopeFactory(),
monitor,
NullLogger<VexConsensusRefreshService>.Instance,
new FixedTimeProvider());
service.ScheduleRefresh("CVE-2025-0002", "pkg:example/test");
Assert.Equal(0, GetScheduledCount(service));
monitor.Update(new VexWorkerOptions { DisableConsensus = false });
service.ScheduleRefresh("CVE-2025-0002", "pkg:example/test");
Assert.Equal(1, GetScheduledCount(service));
}
private static int GetScheduledCount(VexConsensusRefreshService service)
{
var field = typeof(VexConsensusRefreshService).GetField("_scheduledKeys", BindingFlags.NonPublic | BindingFlags.Instance);
var keys = (ConcurrentDictionary<string, byte>?)field?.GetValue(service);
return keys?.Count ?? 0;
}
private sealed class StubScopeFactory : IServiceScopeFactory
{
public IServiceScope CreateScope() => new StubScope();
}
private sealed class StubScope : IServiceScope
{
public IServiceProvider ServiceProvider => new ServiceCollection().BuildServiceProvider();
public void Dispose()
{
}
}
private sealed class TestOptionsMonitor : IOptionsMonitor<VexWorkerOptions>
{
private VexWorkerOptions _current;
private event Action<VexWorkerOptions, string?>? _listeners;
public TestOptionsMonitor(VexWorkerOptions current) => _current = current;
public VexWorkerOptions CurrentValue => _current;
public VexWorkerOptions Get(string? name) => _current;
public IDisposable OnChange(Action<VexWorkerOptions, string?> listener)
{
_listeners += listener;
return new CallbackDisposable(() => _listeners -= listener);
}
public void Update(VexWorkerOptions options)
{
_current = options;
_listeners?.Invoke(options, null);
}
private sealed class CallbackDisposable : IDisposable
{
private readonly Action _dispose;
public CallbackDisposable(Action dispose) => _dispose = dispose;
public void Dispose() => _dispose();
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow = new(2025, 11, 27, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using Xunit;
namespace StellaOps.Excititor.Worker.Tests.Scheduling;
public sealed class VexWorkerHostedServiceTests
{
[Fact]
public async Task ExecuteAsync_NoSchedules_DoesNotInvokeRunner()
{
var options = Options.Create(new VexWorkerOptions());
var runner = new RecordingRunner();
var service = new TestableVexWorkerHostedService(options, runner);
await service.RunAsync(CancellationToken.None);
Assert.Equal(0, runner.Invocations);
}
[Fact]
public async Task ExecuteAsync_ScheduleRunsOnceBeforeCancellation()
{
var workerOptions = new VexWorkerOptions();
workerOptions.Providers.Add(new VexWorkerProviderOptions
{
ProviderId = "excititor:test",
Enabled = true,
Interval = TimeSpan.FromHours(1),
InitialDelay = TimeSpan.Zero,
});
var options = Options.Create(workerOptions);
var runner = new RecordingRunner();
var service = new TestableVexWorkerHostedService(options, runner);
using var cts = new CancellationTokenSource();
var runTask = service.RunAsync(cts.Token);
await runner.WaitForFirstRunAsync();
cts.Cancel();
await runTask;
Assert.Equal(1, runner.Invocations);
}
private sealed class TestableVexWorkerHostedService : VexWorkerHostedService
{
public TestableVexWorkerHostedService(IOptions<VexWorkerOptions> options, IVexProviderRunner runner)
: base(options, runner, NullLogger<VexWorkerHostedService>.Instance, new FixedTimeProvider())
{
}
public Task RunAsync(CancellationToken cancellationToken) => ExecuteAsync(cancellationToken);
}
private sealed class RecordingRunner : IVexProviderRunner
{
private readonly TaskCompletionSource<bool> _firstRun = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int Invocations { get; private set; }
public ValueTask RunAsync(VexWorkerSchedule schedule, CancellationToken cancellationToken)
{
Invocations++;
_firstRun.TrySetResult(true);
return ValueTask.CompletedTask;
}
public Task WaitForFirstRunAsync()
=> _firstRun.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow = new(2025, 11, 27, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -23,6 +24,7 @@ public sealed class WorkerSignatureVerifierTests
[Fact]
public async Task VerifyAsync_ReturnsMetadata_WhenSignatureHintsPresent()
{
var now = new DateTimeOffset(2025, 12, 1, 10, 0, 0, TimeSpan.Zero);
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
var digest = ComputeDigest(content);
var metadata = ImmutableDictionary<string, string>.Empty
@@ -31,14 +33,14 @@ public sealed class WorkerSignatureVerifierTests
.Add("vex.signature.subject", "subject")
.Add("vex.signature.issuer", "issuer")
.Add("vex.signature.keyId", "kid")
.Add("vex.signature.verifiedAt", DateTimeOffset.UtcNow.ToString("O"))
.Add("vex.signature.verifiedAt", now.ToString("O"))
.Add("vex.signature.transparencyLogReference", "rekor://entry");
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.Csaf,
new Uri("https://example.org/vex.json"),
DateTimeOffset.UtcNow,
now,
digest,
content,
metadata);
@@ -60,13 +62,14 @@ public sealed class WorkerSignatureVerifierTests
[Fact]
public async Task VerifyAsync_Throws_WhenChecksumMismatch()
{
var now = new DateTimeOffset(2025, 12, 1, 11, 0, 0, TimeSpan.Zero);
var content = Encoding.UTF8.GetBytes("{\"id\":\"1\"}");
var metadata = ImmutableDictionary<string, string>.Empty;
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.CycloneDx,
new Uri("https://example.org/vex.json"),
DateTimeOffset.UtcNow,
now,
"sha256:deadbeef",
content,
metadata);
@@ -82,7 +85,7 @@ public sealed class WorkerSignatureVerifierTests
[Fact]
public async Task VerifyAsync_Attestation_UsesVerifier()
{
var now = DateTimeOffset.UtcNow;
var now = new DateTimeOffset(2025, 12, 2, 8, 0, 0, TimeSpan.Zero);
var (document, metadata) = CreateAttestationDocument(now, subject: "export-1", includeRekor: true);
var attestationVerifier = new StubAttestationVerifier(true);
@@ -103,7 +106,7 @@ public sealed class WorkerSignatureVerifierTests
[Fact]
public async Task VerifyAsync_AttestationThrows_WhenVerifierInvalid()
{
var now = DateTimeOffset.UtcNow;
var now = new DateTimeOffset(2025, 12, 2, 9, 0, 0, TimeSpan.Zero);
var (document, metadata) = CreateAttestationDocument(now, subject: "export-2", includeRekor: true);
var attestationVerifier = new StubAttestationVerifier(false);
@@ -131,7 +134,7 @@ public sealed class WorkerSignatureVerifierTests
var verifier = new WorkerSignatureVerifier(
NullLogger<WorkerSignatureVerifier>.Instance,
attestationVerifier,
new FixedTimeProvider(now),
new FixedTimeProvider(now.AddMinutes(5)),
StubIssuerDirectoryClient.DefaultFor("tenant-a", "issuer-from-attestation", "kid-from-attestation"));
var result = await verifier.VerifyAsync(document, CancellationToken.None);
@@ -147,7 +150,7 @@ public sealed class WorkerSignatureVerifierTests
[Fact]
public async Task VerifyAsync_AttachesIssuerTrustMetadata()
{
var now = DateTimeOffset.UtcNow;
var now = new DateTimeOffset(2025, 12, 3, 10, 0, 0, TimeSpan.Zero);
var content = Encoding.UTF8.GetBytes("{\"id\":\"trust\"}");
var digest = ComputeDigest(content);
var metadata = ImmutableDictionary<string, string>.Empty
@@ -180,6 +183,46 @@ public sealed class WorkerSignatureVerifierTests
result.Trust!.IssuerId.Should().Be("issuer-a");
}
[Fact]
public async Task VerifyAsync_ParsesVerifiedAt_InInvariantCulture()
{
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
var now = new DateTimeOffset(2025, 12, 4, 9, 0, 0, TimeSpan.Zero);
var content = Encoding.UTF8.GetBytes("{\"id\":\"culture\"}");
var digest = ComputeDigest(content);
var metadata = ImmutableDictionary<string, string>.Empty
.Add("tenant", "tenant-a")
.Add("vex.signature.type", "cosign")
.Add("vex.signature.verifiedAt", now.ToString("O", CultureInfo.InvariantCulture));
var document = new VexRawDocument(
"provider-a",
VexDocumentFormat.Csaf,
new Uri("https://example.org/vex-culture.json"),
now,
digest,
content,
metadata);
var verifier = new WorkerSignatureVerifier(
NullLogger<WorkerSignatureVerifier>.Instance,
issuerDirectoryClient: StubIssuerDirectoryClient.Empty());
var result = await verifier.VerifyAsync(document, CancellationToken.None);
result.Should().NotBeNull();
result!.VerifiedAt.Should().Be(now);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
private static string ComputeDigest(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];

View File

@@ -1,5 +1,5 @@
using System;
using System.Net.Http.Headers;
using System.Net.Http;
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Worker.Auth;
@@ -18,7 +18,9 @@ public sealed class TenantAuthorityClientFactoryTests
{
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
var factory = new TenantAuthorityClientFactory(
Options.Create(options),
new StubHttpClientFactory());
using var client = factory.Create("tenant-a");
@@ -33,7 +35,9 @@ public sealed class TenantAuthorityClientFactoryTests
{
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
var factory = new TenantAuthorityClientFactory(
Options.Create(options),
new StubHttpClientFactory());
FluentActions.Invoking(() => factory.Create(string.Empty))
.Should().Throw<ArgumentException>();
@@ -45,9 +49,16 @@ public sealed class TenantAuthorityClientFactoryTests
{
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(Microsoft.Extensions.Options.Options.Create(options));
var factory = new TenantAuthorityClientFactory(
Options.Create(options),
new StubHttpClientFactory());
FluentActions.Invoking(() => factory.Create("tenant-b"))
.Should().Throw<InvalidOperationException>();
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
}