Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -16,6 +16,8 @@ using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Core.Storage;
using StellaOps.Excititor.WebService.Services;
@@ -35,7 +37,8 @@ internal static class ResolveEndpoint
HttpContext httpContext,
IVexClaimStore claimStore,
[FromServices] IVexConsensusStore? consensusStore,
IVexProviderStore providerStore,
OpenVexStatementMerger merger,
IVexLatticeProvider lattice,
IVexPolicyProvider policyProvider,
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
@@ -93,9 +96,7 @@ internal static class ResolveEndpoint
return Results.Empty;
}
var resolver = new VexConsensusResolver(snapshot.ConsensusPolicy);
var resolvedAt = timeProvider.GetUtcNow();
var providerCache = new Dictionary<string, VexProvider>(StringComparer.Ordinal);
var results = new List<VexResolveResult>((int)pairCount);
foreach (var productKey in productKeys)
@@ -107,23 +108,16 @@ internal static class ResolveEndpoint
var claimArray = claims.Count == 0 ? Array.Empty<VexClaim>() : claims.ToArray();
var signals = AggregateSignals(claimArray);
var providers = await LoadProvidersAsync(claimArray, providerStore, providerCache, cancellationToken)
.ConfigureAwait(false);
var product = ResolveProduct(claimArray, productKey);
var calculatedAt = timeProvider.GetUtcNow();
var resolution = resolver.Resolve(new VexConsensusRequest(
var (consensus, decisions) = BuildConsensus(
vulnerabilityId,
product,
claimArray,
providers,
calculatedAt,
snapshot.ConsensusOptions.WeightCeiling,
product,
signals,
snapshot.RevisionId,
snapshot.Digest));
var consensus = resolution.Consensus;
snapshot,
merger,
lattice,
timeProvider);
if (!string.Equals(consensus.PolicyVersion, snapshot.Version, StringComparison.Ordinal) ||
!string.Equals(consensus.PolicyRevisionId, snapshot.RevisionId, StringComparison.Ordinal) ||
@@ -158,10 +152,6 @@ internal static class ResolveEndpoint
logger,
cancellationToken).ConfigureAwait(false);
var decisions = resolution.DecisionLog.IsDefault
? Array.Empty<VexConsensusDecisionTelemetry>()
: resolution.DecisionLog.ToArray();
results.Add(new VexResolveResult(
consensus.VulnerabilityId,
consensus.Product.Key,
@@ -285,44 +275,6 @@ internal static class ResolveEndpoint
return new VexSignalSnapshot(bestSeverity, kev, bestEpss);
}
private static async Task<IReadOnlyDictionary<string, VexProvider>> LoadProvidersAsync(
IReadOnlyList<VexClaim> claims,
IVexProviderStore providerStore,
IDictionary<string, VexProvider> cache,
CancellationToken cancellationToken)
{
if (claims.Count == 0)
{
return ImmutableDictionary<string, VexProvider>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, VexProvider>(StringComparer.Ordinal);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var providerId in claims.Select(claim => claim.ProviderId))
{
if (!seen.Add(providerId))
{
continue;
}
if (cache.TryGetValue(providerId, out var cached))
{
builder[providerId] = cached;
continue;
}
var provider = await providerStore.FindAsync(providerId, cancellationToken).ConfigureAwait(false);
if (provider is not null)
{
cache[providerId] = provider;
builder[providerId] = provider;
}
}
return builder.ToImmutable();
}
private static VexProduct ResolveProduct(IReadOnlyList<VexClaim> claims, string productKey)
{
if (claims.Count > 0)
@@ -334,6 +286,118 @@ internal static class ResolveEndpoint
return new VexProduct(productKey, name: null, version: null, purl: inferredPurl);
}
private static (VexConsensus Consensus, IReadOnlyList<VexConsensusDecisionTelemetry> Decisions) BuildConsensus(
string vulnerabilityId,
IReadOnlyList<VexClaim> claims,
VexProduct product,
VexSignalSnapshot? signals,
VexPolicySnapshot snapshot,
OpenVexStatementMerger merger,
IVexLatticeProvider lattice,
TimeProvider timeProvider)
{
var calculatedAt = timeProvider.GetUtcNow();
if (claims.Count == 0)
{
var emptyConsensus = new VexConsensus(
vulnerabilityId,
product,
VexConsensusStatus.UnderInvestigation,
calculatedAt,
Array.Empty<VexConsensusSource>(),
Array.Empty<VexConsensusConflict>(),
signals,
snapshot.Version,
"No claims available.",
snapshot.RevisionId,
snapshot.Digest);
return (emptyConsensus, Array.Empty<VexConsensusDecisionTelemetry>());
}
var mergeResult = merger.MergeClaims(claims);
var consensusStatus = MapConsensusStatus(mergeResult.ResultStatement.Status);
var sources = claims
.Select(claim => new VexConsensusSource(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
(double)lattice.GetTrustWeight(claim),
claim.Justification,
claim.Detail,
claim.Confidence))
.ToArray();
var conflicts = claims
.Where(claim => claim.Status != mergeResult.ResultStatement.Status)
.Select(claim => new VexConsensusConflict(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
claim.Justification,
claim.Detail,
"status_conflict"))
.ToArray();
var summary = MergeTraceWriter.ToExplanation(mergeResult);
var decisions = BuildDecisionLog(claims, lattice);
var consensus = new VexConsensus(
vulnerabilityId,
product,
consensusStatus,
calculatedAt,
sources,
conflicts,
signals,
snapshot.Version,
summary,
snapshot.RevisionId,
snapshot.Digest);
return (consensus, decisions);
}
private static VexConsensusStatus MapConsensusStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => VexConsensusStatus.Affected,
VexClaimStatus.NotAffected => VexConsensusStatus.NotAffected,
VexClaimStatus.Fixed => VexConsensusStatus.Fixed,
_ => VexConsensusStatus.UnderInvestigation,
};
private static IReadOnlyList<VexConsensusDecisionTelemetry> BuildDecisionLog(
IReadOnlyList<VexClaim> claims,
IVexLatticeProvider lattice)
{
if (claims.Count == 0)
{
return Array.Empty<VexConsensusDecisionTelemetry>();
}
var decisions = new List<VexConsensusDecisionTelemetry>(claims.Count);
foreach (var claim in claims)
{
var weight = lattice.GetTrustWeight(claim);
var included = weight > 0;
var reason = included ? null : "weight_not_positive";
decisions.Add(new VexConsensusDecisionTelemetry(
claim.ProviderId,
claim.Document.Digest,
claim.Status,
included,
(double)weight,
reason,
claim.Justification,
claim.Detail));
}
return decisions;
}
private static ConsensusPayload PreparePayload(VexConsensus consensus)
{
var canonicalJson = VexCanonicalJsonSerializer.Serialize(consensus);

View File

@@ -8,6 +8,8 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Worker.Options;
@@ -276,8 +278,9 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
var consensusStore = scope.ServiceProvider.GetRequiredService<IVexConsensusStore>();
var holdStore = scope.ServiceProvider.GetRequiredService<IVexConsensusHoldStore>();
var claimStore = scope.ServiceProvider.GetRequiredService<IVexClaimStore>();
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
var policyProvider = scope.ServiceProvider.GetRequiredService<IVexPolicyProvider>();
var merger = scope.ServiceProvider.GetRequiredService<OpenVexStatementMerger>();
var lattice = scope.ServiceProvider.GetRequiredService<IVexLatticeProvider>();
existingConsensus ??= await consensusStore.FindAsync(vulnerabilityId, productKey, cancellationToken).ConfigureAwait(false);
@@ -292,25 +295,18 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
var claimList = claims as IReadOnlyList<VexClaim> ?? claims.ToList();
var snapshot = policyProvider.GetSnapshot();
var providerCache = new Dictionary<string, VexProvider>(StringComparer.Ordinal);
var providers = await LoadProvidersAsync(claimList, providerStore, providerCache, cancellationToken).ConfigureAwait(false);
var product = ResolveProduct(claimList, productKey);
var calculatedAt = _timeProvider.GetUtcNow();
var resolver = new VexConsensusResolver(snapshot.ConsensusPolicy);
var request = new VexConsensusRequest(
vulnerabilityId,
product,
claimList.ToArray(),
providers,
calculatedAt,
snapshot.ConsensusOptions.WeightCeiling,
AggregateSignals(claimList),
snapshot.RevisionId,
snapshot.Digest);
var resolution = resolver.Resolve(request);
var candidate = NormalizePolicyMetadata(resolution.Consensus, snapshot);
var candidate = NormalizePolicyMetadata(
BuildConsensus(
vulnerabilityId,
claimList,
product,
AggregateSignals(claimList),
snapshot,
merger,
lattice,
_timeProvider),
snapshot);
await ApplyConsensusAsync(
candidate,
@@ -482,6 +478,83 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
return new VexProduct(productKey, name: null, version: null, purl: inferredPurl);
}
private static VexConsensus BuildConsensus(
string vulnerabilityId,
IReadOnlyList<VexClaim> claims,
VexProduct product,
VexSignalSnapshot? signals,
VexPolicySnapshot snapshot,
OpenVexStatementMerger merger,
IVexLatticeProvider lattice,
TimeProvider timeProvider)
{
var calculatedAt = timeProvider.GetUtcNow();
if (claims.Count == 0)
{
return new VexConsensus(
vulnerabilityId,
product,
VexConsensusStatus.UnderInvestigation,
calculatedAt,
Array.Empty<VexConsensusSource>(),
Array.Empty<VexConsensusConflict>(),
signals,
snapshot.Version,
"No claims available.",
snapshot.RevisionId,
snapshot.Digest);
}
var mergeResult = merger.MergeClaims(claims);
var consensusStatus = MapConsensusStatusFromClaim(mergeResult.ResultStatement.Status);
var sources = claims
.Select(claim => new VexConsensusSource(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
(double)lattice.GetTrustWeight(claim),
claim.Justification,
claim.Detail,
claim.Confidence))
.ToArray();
var conflicts = claims
.Where(claim => claim.Status != mergeResult.ResultStatement.Status)
.Select(claim => new VexConsensusConflict(
claim.ProviderId,
claim.Status,
claim.Document.Digest,
claim.Justification,
claim.Detail,
"status_conflict"))
.ToArray();
var summary = MergeTraceWriter.ToExplanation(mergeResult);
return new VexConsensus(
vulnerabilityId,
product,
consensusStatus,
calculatedAt,
sources,
conflicts,
signals,
snapshot.Version,
summary,
snapshot.RevisionId,
snapshot.Digest);
}
private static VexConsensusStatus MapConsensusStatusFromClaim(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => VexConsensusStatus.Affected,
VexClaimStatus.NotAffected => VexConsensusStatus.NotAffected,
VexClaimStatus.Fixed => VexConsensusStatus.Fixed,
_ => VexConsensusStatus.UnderInvestigation,
};
private static VexSignalSnapshot? AggregateSignals(IReadOnlyList<VexClaim> claims)
{
if (claims.Count == 0)
@@ -542,44 +615,6 @@ internal sealed class VexConsensusRefreshService : BackgroundService, IVexConsen
return new VexSignalSnapshot(bestSeverity, kev, bestEpss);
}
private static async Task<IReadOnlyDictionary<string, VexProvider>> LoadProvidersAsync(
IReadOnlyList<VexClaim> claims,
IVexProviderStore providerStore,
IDictionary<string, VexProvider> cache,
CancellationToken cancellationToken)
{
if (claims.Count == 0)
{
return ImmutableDictionary<string, VexProvider>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, VexProvider>(StringComparer.Ordinal);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var providerId in claims.Select(claim => claim.ProviderId))
{
if (!seen.Add(providerId))
{
continue;
}
if (cache.TryGetValue(providerId, out var cached))
{
builder[providerId] = cached;
continue;
}
var provider = await providerStore.FindAsync(providerId, cancellationToken).ConfigureAwait(false);
if (provider is not null)
{
cache[providerId] = provider;
builder[providerId] = provider;
}
}
return builder.ToImmutable();
}
private readonly record struct RefreshRequest(string VulnerabilityId, string ProductKey);
private sealed record RefreshState(

View File

@@ -0,0 +1,183 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core;
public sealed record ComparisonResult
{
public required string SourceId { get; init; }
public required int TotalPredictions { get; init; }
public required int CorrectPredictions { get; init; }
public required int FalseNegatives { get; init; }
public required int FalsePositives { get; init; }
public required double Accuracy { get; init; }
public required double ConfidenceInterval { get; init; }
public required CalibrationBias? DetectedBias { get; init; }
}
public enum CalibrationBias
{
None,
OptimisticBias,
PessimisticBias,
ScopeBias,
}
public sealed record CalibrationObservation
{
public required string SourceId { get; init; }
public required string VulnerabilityId { get; init; }
public required string AssetDigest { get; init; }
public required VexClaimStatus Status { get; init; }
public bool ScopeMismatch { get; init; }
}
public sealed record CalibrationTruth
{
public required string VulnerabilityId { get; init; }
public required string AssetDigest { get; init; }
public required VexClaimStatus Status { get; init; }
}
public interface ICalibrationDatasetProvider
{
Task<IReadOnlyList<CalibrationObservation>> GetObservationsAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default);
Task<IReadOnlyList<CalibrationTruth>> GetTruthAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default);
}
public interface ICalibrationComparisonEngine
{
Task<IReadOnlyList<ComparisonResult>> CompareAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default);
}
public sealed class CalibrationComparisonEngine : ICalibrationComparisonEngine
{
private readonly ICalibrationDatasetProvider _datasetProvider;
public CalibrationComparisonEngine(ICalibrationDatasetProvider datasetProvider)
{
_datasetProvider = datasetProvider ?? throw new ArgumentNullException(nameof(datasetProvider));
}
public async Task<IReadOnlyList<ComparisonResult>> CompareAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default)
{
var observations = await _datasetProvider.GetObservationsAsync(tenant, epochStart, epochEnd, ct).ConfigureAwait(false);
var truths = await _datasetProvider.GetTruthAsync(tenant, epochStart, epochEnd, ct).ConfigureAwait(false);
var truthByKey = truths
.GroupBy(t => (t.VulnerabilityId, t.AssetDigest))
.ToDictionary(g => g.Key, g => g.First(), StringTupleComparer.Instance);
var results = new List<ComparisonResult>();
foreach (var group in observations.GroupBy(o => o.SourceId, StringComparer.Ordinal))
{
var total = 0;
var correct = 0;
var falseNegatives = 0;
var falsePositives = 0;
var scopeMismatches = 0;
foreach (var obs in group)
{
if (!truthByKey.TryGetValue((obs.VulnerabilityId, obs.AssetDigest), out var truth))
{
continue;
}
total++;
if (obs.ScopeMismatch)
{
scopeMismatches++;
}
if (obs.Status == truth.Status)
{
correct++;
continue;
}
var predictedAffected = IsAffected(obs.Status);
var truthAffected = IsAffected(truth.Status);
if (!predictedAffected && truthAffected)
{
falseNegatives++;
}
else if (predictedAffected && !truthAffected)
{
falsePositives++;
}
}
var accuracy = total == 0 ? 0.0 : (double)correct / total;
var ci = total == 0 ? 0.0 : 1.96 * Math.Sqrt(accuracy * (1 - accuracy) / total);
var bias = DetectBias(falseNegatives, falsePositives, scopeMismatches, total);
results.Add(new ComparisonResult
{
SourceId = group.Key,
TotalPredictions = total,
CorrectPredictions = correct,
FalseNegatives = falseNegatives,
FalsePositives = falsePositives,
Accuracy = accuracy,
ConfidenceInterval = ci,
DetectedBias = bias,
});
}
return results
.OrderBy(r => r.SourceId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static bool IsAffected(VexClaimStatus status)
=> status == VexClaimStatus.Affected;
private static CalibrationBias DetectBias(int falseNegatives, int falsePositives, int scopeMismatches, int total)
{
if (total > 0 && scopeMismatches >= Math.Max(2, total / 3))
{
return CalibrationBias.ScopeBias;
}
if (falseNegatives >= 2 && falseNegatives > falsePositives)
{
return CalibrationBias.OptimisticBias;
}
if (falsePositives >= 2 && falsePositives > falseNegatives)
{
return CalibrationBias.PessimisticBias;
}
return CalibrationBias.None;
}
private sealed class StringTupleComparer : IEqualityComparer<(string, string)>
{
public static readonly StringTupleComparer Instance = new();
public bool Equals((string, string) x, (string, string) y)
=> StringComparer.Ordinal.Equals(x.Item1, y.Item1) && StringComparer.Ordinal.Equals(x.Item2, y.Item2);
public int GetHashCode((string, string) obj)
=> HashCode.Combine(StringComparer.Ordinal.GetHashCode(obj.Item1), StringComparer.Ordinal.GetHashCode(obj.Item2));
}
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core;
public sealed record CalibrationManifest
{
public required string ManifestId { get; init; }
public required string Tenant { get; init; }
public required int EpochNumber { get; init; }
public required DateTimeOffset EpochStart { get; init; }
public required DateTimeOffset EpochEnd { get; init; }
public required ImmutableArray<CalibrationAdjustment> Adjustments { get; init; }
public required CalibrationMetrics Metrics { get; init; }
public required string ManifestDigest { get; init; }
public string? Signature { get; init; }
}
public sealed record CalibrationAdjustment
{
public required string SourceId { get; init; }
public required TrustVector OldVector { get; init; }
public required TrustVector NewVector { get; init; }
public required double Delta { get; init; }
public required string Reason { get; init; }
public required int SampleCount { get; init; }
public required double AccuracyBefore { get; init; }
public required double AccuracyAfter { get; init; }
}
public sealed record CalibrationMetrics
{
public required int TotalVerdicts { get; init; }
public required int CorrectVerdicts { get; init; }
public required int PostMortemReversals { get; init; }
public required double OverallAccuracy { get; init; }
}

View File

@@ -0,0 +1,319 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Core;
public interface ICalibrationManifestStore
{
Task StoreAsync(CalibrationManifest manifest, CancellationToken ct = default);
Task<CalibrationManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default);
Task<CalibrationManifest?> GetLatestAsync(string tenant, CancellationToken ct = default);
}
public interface ICalibrationManifestSigner
{
Task<string?> SignAsync(CalibrationManifest manifest, CancellationToken ct = default);
Task<bool> VerifyAsync(CalibrationManifest manifest, CancellationToken ct = default);
}
public sealed class NullCalibrationManifestSigner : ICalibrationManifestSigner
{
public Task<string?> SignAsync(CalibrationManifest manifest, CancellationToken ct = default)
=> Task.FromResult<string?>(null);
public Task<bool> VerifyAsync(CalibrationManifest manifest, CancellationToken ct = default)
=> Task.FromResult(true);
}
public interface ICalibrationIdGenerator
{
string NextId();
}
public sealed class GuidCalibrationIdGenerator : ICalibrationIdGenerator
{
public string NextId() => Guid.NewGuid().ToString("n");
}
public sealed record TrustCalibrationOptions
{
public TimeSpan EpochDuration { get; init; } = TimeSpan.FromDays(30);
public double AccuracyRegressionThreshold { get; init; } = 0.05;
public bool AutoRollbackEnabled { get; init; } = true;
}
public interface ITrustCalibrationService
{
Task<CalibrationManifest> RunEpochAsync(
string tenant,
DateTimeOffset? epochEnd = null,
CancellationToken ct = default);
Task<CalibrationManifest?> GetLatestAsync(
string tenant,
CancellationToken ct = default);
Task ApplyCalibrationAsync(
string tenant,
string manifestId,
CancellationToken ct = default);
Task RollbackAsync(
string tenant,
string manifestId,
CancellationToken ct = default);
}
public sealed class TrustCalibrationService : ITrustCalibrationService
{
private readonly ICalibrationComparisonEngine _comparisonEngine;
private readonly ITrustVectorCalibrator _calibrator;
private readonly IVexProviderStore _providerStore;
private readonly ICalibrationManifestStore _manifestStore;
private readonly ICalibrationManifestSigner _signer;
private readonly ICalibrationIdGenerator _idGenerator;
private readonly TrustCalibrationOptions _options;
public TrustCalibrationService(
ICalibrationComparisonEngine comparisonEngine,
ITrustVectorCalibrator calibrator,
IVexProviderStore providerStore,
ICalibrationManifestStore manifestStore,
ICalibrationManifestSigner? signer = null,
ICalibrationIdGenerator? idGenerator = null,
TrustCalibrationOptions? options = null)
{
_comparisonEngine = comparisonEngine ?? throw new ArgumentNullException(nameof(comparisonEngine));
_calibrator = calibrator ?? throw new ArgumentNullException(nameof(calibrator));
_providerStore = providerStore ?? throw new ArgumentNullException(nameof(providerStore));
_manifestStore = manifestStore ?? throw new ArgumentNullException(nameof(manifestStore));
_signer = signer ?? new NullCalibrationManifestSigner();
_idGenerator = idGenerator ?? new GuidCalibrationIdGenerator();
_options = options ?? new TrustCalibrationOptions();
}
public async Task<CalibrationManifest> RunEpochAsync(
string tenant,
DateTimeOffset? epochEnd = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
var end = epochEnd ?? DateTimeOffset.UtcNow;
var start = end - _options.EpochDuration;
var comparisons = await _comparisonEngine.CompareAsync(tenant, start, end, ct).ConfigureAwait(false);
var providers = await _providerStore.ListAsync(ct).ConfigureAwait(false);
var providerMap = providers.ToDictionary(p => p.Id, StringComparer.Ordinal);
var adjustments = new List<CalibrationAdjustment>();
foreach (var comparison in comparisons.OrderBy(c => c.SourceId, StringComparer.Ordinal))
{
if (!providerMap.TryGetValue(comparison.SourceId, out var provider))
{
continue;
}
var currentVector = provider.Trust.Vector ?? DefaultTrustVectors.GetDefault(provider.Kind);
var bias = comparison.DetectedBias;
var updatedVector = _calibrator.Calibrate(currentVector, comparison, bias);
var delta = Math.Abs(updatedVector.Provenance - currentVector.Provenance)
+ Math.Abs(updatedVector.Coverage - currentVector.Coverage)
+ Math.Abs(updatedVector.Replayability - currentVector.Replayability);
adjustments.Add(new CalibrationAdjustment
{
SourceId = provider.Id,
OldVector = currentVector,
NewVector = updatedVector,
Delta = delta,
Reason = BuildReason(comparison, bias),
SampleCount = comparison.TotalPredictions,
AccuracyBefore = comparison.Accuracy,
AccuracyAfter = comparison.Accuracy,
});
}
var totalVerdicts = comparisons.Sum(c => c.TotalPredictions);
var correctVerdicts = comparisons.Sum(c => c.CorrectPredictions);
var postMortemReversals = comparisons.Sum(c => c.FalseNegatives);
var overallAccuracy = totalVerdicts == 0 ? 0.0 : (double)correctVerdicts / totalVerdicts;
var metrics = new CalibrationMetrics
{
TotalVerdicts = totalVerdicts,
CorrectVerdicts = correctVerdicts,
PostMortemReversals = postMortemReversals,
OverallAccuracy = overallAccuracy,
};
var epochNumber = await NextEpochNumberAsync(tenant, ct).ConfigureAwait(false);
var manifestId = _idGenerator.NextId();
var manifestPayload = new CalibrationManifestPayload
{
ManifestId = manifestId,
Tenant = tenant,
EpochNumber = epochNumber,
EpochStart = start,
EpochEnd = end,
Adjustments = adjustments.ToImmutableArray(),
Metrics = metrics,
};
var digest = ComputeDigest(manifestPayload);
var unsignedManifest = new CalibrationManifest
{
ManifestId = manifestId,
Tenant = tenant,
EpochNumber = epochNumber,
EpochStart = start,
EpochEnd = end,
Adjustments = manifestPayload.Adjustments,
Metrics = metrics,
ManifestDigest = digest,
};
var signature = await _signer.SignAsync(unsignedManifest, ct).ConfigureAwait(false);
var manifest = unsignedManifest with { Signature = signature };
await _manifestStore.StoreAsync(manifest, ct).ConfigureAwait(false);
return manifest;
}
public Task<CalibrationManifest?> GetLatestAsync(string tenant, CancellationToken ct = default)
=> _manifestStore.GetLatestAsync(tenant, ct);
public async Task ApplyCalibrationAsync(string tenant, string manifestId, CancellationToken ct = default)
{
var manifest = await _manifestStore.GetByIdAsync(tenant, manifestId, ct).ConfigureAwait(false);
if (manifest is null)
{
return;
}
if (_options.AutoRollbackEnabled && HasRegression(manifest))
{
await RollbackAsync(tenant, manifestId, ct).ConfigureAwait(false);
return;
}
var providers = await _providerStore.ListAsync(ct).ConfigureAwait(false);
var providerMap = providers.ToDictionary(p => p.Id, StringComparer.Ordinal);
foreach (var adjustment in manifest.Adjustments)
{
if (!providerMap.TryGetValue(adjustment.SourceId, out var provider))
{
continue;
}
var updatedTrust = new VexProviderTrust(
provider.Trust.Weight,
provider.Trust.Cosign,
provider.Trust.PgpFingerprints,
adjustment.NewVector,
provider.Trust.Weights);
var updatedProvider = new VexProvider(
provider.Id,
provider.DisplayName,
provider.Kind,
provider.BaseUris,
provider.Discovery,
updatedTrust,
provider.Enabled);
await _providerStore.SaveAsync(updatedProvider, ct).ConfigureAwait(false);
}
}
public async Task RollbackAsync(string tenant, string manifestId, CancellationToken ct = default)
{
var manifest = await _manifestStore.GetByIdAsync(tenant, manifestId, ct).ConfigureAwait(false);
if (manifest is null)
{
return;
}
var providers = await _providerStore.ListAsync(ct).ConfigureAwait(false);
var providerMap = providers.ToDictionary(p => p.Id, StringComparer.Ordinal);
foreach (var adjustment in manifest.Adjustments)
{
if (!providerMap.TryGetValue(adjustment.SourceId, out var provider))
{
continue;
}
var updatedTrust = new VexProviderTrust(
provider.Trust.Weight,
provider.Trust.Cosign,
provider.Trust.PgpFingerprints,
adjustment.OldVector,
provider.Trust.Weights);
var updatedProvider = new VexProvider(
provider.Id,
provider.DisplayName,
provider.Kind,
provider.BaseUris,
provider.Discovery,
updatedTrust,
provider.Enabled);
await _providerStore.SaveAsync(updatedProvider, ct).ConfigureAwait(false);
}
}
private async Task<int> NextEpochNumberAsync(string tenant, CancellationToken ct)
{
var latest = await _manifestStore.GetLatestAsync(tenant, ct).ConfigureAwait(false);
if (latest is null)
{
return 1;
}
return latest.EpochNumber + 1;
}
private bool HasRegression(CalibrationManifest manifest)
{
foreach (var adjustment in manifest.Adjustments)
{
if (adjustment.AccuracyAfter + _options.AccuracyRegressionThreshold < adjustment.AccuracyBefore)
{
return true;
}
}
return false;
}
private static string BuildReason(ComparisonResult result, CalibrationBias? bias)
{
var biasText = bias?.ToString() ?? "None";
return $"calibration:{biasText}:accuracy={result.Accuracy:0.000}";
}
private static string ComputeDigest(CalibrationManifestPayload payload)
{
var json = VexCanonicalJsonSerializer.Serialize(payload);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
var hex = Convert.ToHexString(hash).ToLowerInvariant();
return $"sha256:{hex}";
}
private sealed record CalibrationManifestPayload
{
public required string ManifestId { get; init; }
public required string Tenant { get; init; }
public required int EpochNumber { get; init; }
public required DateTimeOffset EpochStart { get; init; }
public required DateTimeOffset EpochEnd { get; init; }
public required ImmutableArray<CalibrationAdjustment> Adjustments { get; init; }
public required CalibrationMetrics Metrics { get; init; }
}
}

View File

@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
namespace StellaOps.Excititor.Core;
public sealed record CalibrationDelta(double DeltaP, double DeltaC, double DeltaR)
{
public static CalibrationDelta Zero => new(0, 0, 0);
}
public interface ITrustVectorCalibrator
{
TrustVector Calibrate(
TrustVector current,
ComparisonResult comparison,
CalibrationBias? detectedBias);
}
public sealed class TrustVectorCalibrator : ITrustVectorCalibrator
{
private readonly ConcurrentDictionary<string, CalibrationDelta> _momentumState = new(StringComparer.Ordinal);
public double LearningRate { get; init; } = 0.02;
public double MaxAdjustmentPerEpoch { get; init; } = 0.05;
public double MinValue { get; init; } = 0.10;
public double MaxValue { get; init; } = 1.00;
public double MomentumFactor { get; init; } = 0.9;
public TrustVector Calibrate(
TrustVector current,
ComparisonResult comparison,
CalibrationBias? detectedBias)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(comparison);
if (comparison.Accuracy >= 0.95)
{
return current;
}
var rawDelta = CalculateAdjustment(comparison, detectedBias);
var previous = _momentumState.TryGetValue(comparison.SourceId, out var prior)
? prior
: CalibrationDelta.Zero;
var blended = new CalibrationDelta(
Blend(previous.DeltaP, rawDelta.DeltaP),
Blend(previous.DeltaC, rawDelta.DeltaC),
Blend(previous.DeltaR, rawDelta.DeltaR));
var updated = ApplyAdjustment(current, blended);
_momentumState[comparison.SourceId] = blended;
return updated;
}
private CalibrationDelta CalculateAdjustment(ComparisonResult comparison, CalibrationBias? bias)
{
if (double.IsNaN(LearningRate) || double.IsInfinity(LearningRate) || LearningRate <= 0)
{
throw new InvalidOperationException("LearningRate must be a finite positive value.");
}
var delta = (1.0 - comparison.Accuracy) * LearningRate;
delta = Math.Min(delta, MaxAdjustmentPerEpoch);
return bias switch
{
CalibrationBias.OptimisticBias => new CalibrationDelta(-delta, 0, 0),
CalibrationBias.PessimisticBias => new CalibrationDelta(+delta, 0, 0),
CalibrationBias.ScopeBias => new CalibrationDelta(0, -delta, 0),
_ => new CalibrationDelta(-delta / 3, -delta / 3, -delta / 3),
};
}
private TrustVector ApplyAdjustment(TrustVector current, CalibrationDelta delta)
{
return new TrustVector
{
Provenance = Clamp(current.Provenance + delta.DeltaP),
Coverage = Clamp(current.Coverage + delta.DeltaC),
Replayability = Clamp(current.Replayability + delta.DeltaR),
};
}
private double Clamp(double value)
=> Math.Min(MaxValue, Math.Max(MinValue, value));
private double Blend(double previous, double current)
=> (previous * MomentumFactor) + (current * (1 - MomentumFactor));
}

View File

@@ -0,0 +1,176 @@
using StellaOps.Excititor.Core.Reachability;
namespace StellaOps.Excititor.Core.Justification;
/// <summary>
/// Generates VEX justifications from reachability slice verdicts.
/// </summary>
public sealed class ReachabilityJustificationGenerator
{
private readonly TimeProvider _timeProvider;
public ReachabilityJustificationGenerator(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Generate code_not_reachable justification from slice verdict.
/// </summary>
public VexJustification GenerateCodeNotReachable(SliceVerdict verdict, string cveId, string purl)
{
ArgumentNullException.ThrowIfNull(verdict);
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
if (verdict.Status != SliceVerdictStatus.Unreachable)
{
throw new ArgumentException(
$"Cannot generate code_not_reachable justification for verdict status: {verdict.Status}",
nameof(verdict));
}
var details = BuildJustificationDetails(verdict, cveId, purl);
var evidence = BuildEvidence(verdict);
return new VexJustification
{
Category = VexJustificationCategory.CodeNotReachable,
Details = details,
Evidence = evidence,
GeneratedAt = _timeProvider.GetUtcNow(),
Confidence = verdict.Confidence
};
}
/// <summary>
/// Generate vulnerable_code_not_present justification.
/// </summary>
public VexJustification GenerateCodeNotPresent(string cveId, string purl, string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
return new VexJustification
{
Category = VexJustificationCategory.VulnerableCodeNotPresent,
Details = $"The vulnerable code for {cveId} in {purl} is not present in the deployed artifact. {reason}",
GeneratedAt = _timeProvider.GetUtcNow(),
Confidence = 1.0
};
}
/// <summary>
/// Generate requires_configuration justification for gated paths.
/// </summary>
public VexJustification GenerateRequiresConfiguration(SliceVerdict verdict, string cveId, string purl)
{
ArgumentNullException.ThrowIfNull(verdict);
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
if (verdict.Status != SliceVerdictStatus.Gated)
{
throw new ArgumentException(
$"Cannot generate requires_configuration justification for verdict status: {verdict.Status}",
nameof(verdict));
}
var gateInfo = BuildGateInformation(verdict);
var details = $"The vulnerable code path for {cveId} in {purl} is behind configuration gates that are not enabled in the current deployment. {gateInfo}";
return new VexJustification
{
Category = VexJustificationCategory.RequiresConfiguration,
Details = details,
Evidence = BuildEvidence(verdict),
GeneratedAt = _timeProvider.GetUtcNow(),
Confidence = verdict.Confidence
};
}
private static string BuildJustificationDetails(SliceVerdict verdict, string cveId, string purl)
{
var baseMessage = $"Static reachability analysis determined no execution path from application entrypoints to the vulnerable function in {cveId} affecting {purl}.";
if (verdict.UnknownCount > 0)
{
baseMessage += $" Analysis encountered {verdict.UnknownCount} unknown node(s) which may affect confidence.";
}
if (verdict.Reasons is not null && verdict.Reasons.Count > 0)
{
var reasonsText = string.Join(", ", verdict.Reasons);
baseMessage += $" Reasons: {reasonsText}.";
}
return baseMessage;
}
private static VexJustificationEvidence BuildEvidence(SliceVerdict verdict)
{
return new VexJustificationEvidence
{
SliceDigest = verdict.SliceDigest,
SliceUri = verdict.SliceUri ?? $"cas://slices/{verdict.SliceDigest}",
AnalyzerVersion = verdict.AnalyzerVersion ?? "scanner.native:unknown",
Confidence = verdict.Confidence,
UnknownCount = verdict.UnknownCount,
PathWitnessCount = verdict.PathWitnesses?.Count ?? 0
};
}
private static string BuildGateInformation(SliceVerdict verdict)
{
if (verdict.GatedPaths is null || verdict.GatedPaths.Count == 0)
{
return "No specific gate information available.";
}
var gates = verdict.GatedPaths
.GroupBy(g => g.GateType)
.Select(g => $"{g.Count()} {g.Key} gate(s)")
.ToList();
return $"Gate types: {string.Join(", ", gates)}.";
}
}
/// <summary>
/// VEX justification generated from reachability analysis.
/// </summary>
public sealed record VexJustification
{
public required VexJustificationCategory Category { get; init; }
public required string Details { get; init; }
public VexJustificationEvidence? Evidence { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required double Confidence { get; init; }
}
/// <summary>
/// Evidence supporting a VEX justification.
/// </summary>
public sealed record VexJustificationEvidence
{
public required string SliceDigest { get; init; }
public required string SliceUri { get; init; }
public required string AnalyzerVersion { get; init; }
public required double Confidence { get; init; }
public int UnknownCount { get; init; }
public int PathWitnessCount { get; init; }
}
/// <summary>
/// VEX justification categories.
/// </summary>
public enum VexJustificationCategory
{
CodeNotReachable,
VulnerableCodeNotPresent,
RequiresConfiguration,
RequiresDependency,
RequiresEnvironment,
InlineMitigationsAlreadyExist
}

View File

@@ -0,0 +1,197 @@
using System.Collections.Immutable;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Input for scoring a VEX claim with trust lattice.
/// </summary>
public sealed record ClaimWithContext
{
public required VexClaim Claim { get; init; }
public required TrustVector TrustVector { get; init; }
public required ClaimStrength Strength { get; init; }
}
/// <summary>
/// Merges multiple VEX claims using trust lattice scoring.
/// </summary>
public sealed class ClaimScoreMerger
{
private readonly ClaimScoreCalculator _calculator;
private readonly TrustWeights _weights;
private readonly double _conflictPenalty;
public ClaimScoreMerger(
ClaimScoreCalculator calculator,
TrustWeights? weights = null,
double conflictPenalty = 0.25)
{
_calculator = calculator ?? throw new ArgumentNullException(nameof(calculator));
_weights = weights ?? TrustWeights.Default;
_conflictPenalty = NormalizePenalty(conflictPenalty);
}
/// <summary>
/// Merge multiple claims with context and select the winner based on trust scores.
/// </summary>
public MergeResult Merge(IEnumerable<ClaimWithContext> claimsWithContext)
{
ArgumentNullException.ThrowIfNull(claimsWithContext);
var claimsList = claimsWithContext.ToImmutableArray();
if (claimsList.Length == 0)
{
return new MergeResult
{
WinningClaim = null,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
HasConflict = false,
ConflictPenaltyApplied = 0.0,
MergeTimestampUtc = DateTime.UtcNow
};
}
// Score all claims
var scoredClaims = ScoreClaims(claimsList);
// Detect conflicts
var hasConflict = DetectConflict(scoredClaims);
// Apply conflict penalty if needed
if (hasConflict)
{
scoredClaims = ApplyConflictPenalty(scoredClaims, _conflictPenalty);
}
// Select winner (highest score)
var sorted = scoredClaims.OrderByDescending(sc => sc.FinalScore).ToImmutableArray();
var winner = sorted.FirstOrDefault();
return new MergeResult
{
WinningClaim = winner?.Claim,
AllClaims = sorted,
HasConflict = hasConflict,
ConflictPenaltyApplied = hasConflict ? _conflictPenalty : 0.0,
MergeTimestampUtc = DateTime.UtcNow
};
}
private ImmutableArray<ScoredClaim> ScoreClaims(ImmutableArray<ClaimWithContext> claimsWithContext)
{
var cutoff = DateTimeOffset.UtcNow;
return claimsWithContext.Select(cwc =>
{
var score = _calculator.Compute(
cwc.TrustVector,
_weights,
cwc.Strength,
cwc.Claim.FirstSeen,
cutoff);
return new ScoredClaim
{
Claim = cwc.Claim,
BaseTrust = score.BaseTrust,
Strength = score.StrengthMultiplier,
Freshness = score.FreshnessMultiplier,
FinalScore = score.Score,
PenaltyApplied = 0.0
};
}).ToImmutableArray();
}
private static bool DetectConflict(ImmutableArray<ScoredClaim> scoredClaims)
{
if (scoredClaims.Length < 2)
{
return false;
}
// Group by status - if multiple different statuses exist, there's a conflict
var statuses = scoredClaims
.Select(sc => sc.Claim.Status)
.Distinct()
.ToList();
return statuses.Count > 1;
}
private static ImmutableArray<ScoredClaim> ApplyConflictPenalty(
ImmutableArray<ScoredClaim> scoredClaims,
double penalty)
{
return scoredClaims.Select(sc => sc with
{
FinalScore = sc.FinalScore * (1.0 - penalty),
PenaltyApplied = penalty
}).ToImmutableArray();
}
private static double NormalizePenalty(double penalty)
{
if (double.IsNaN(penalty) || double.IsInfinity(penalty))
{
throw new ArgumentOutOfRangeException(nameof(penalty), "Conflict penalty must be a finite number.");
}
if (penalty < 0.0)
{
return 0.0;
}
if (penalty > 1.0)
{
return 1.0;
}
return penalty;
}
}
/// <summary>
/// Result of merging multiple VEX claims.
/// </summary>
public sealed record MergeResult
{
/// <summary>Winning claim (highest score after penalties).</summary>
public required VexClaim? WinningClaim { get; init; }
/// <summary>All claims with their scores.</summary>
public required ImmutableArray<ScoredClaim> AllClaims { get; init; }
/// <summary>Whether conflicting statuses were detected.</summary>
public required bool HasConflict { get; init; }
/// <summary>Conflict penalty applied (0.0 if no conflict).</summary>
public required double ConflictPenaltyApplied { get; init; }
/// <summary>Timestamp when merge was performed.</summary>
public required DateTime MergeTimestampUtc { get; init; }
}
/// <summary>
/// A VEX claim with its computed scores.
/// </summary>
public sealed record ScoredClaim
{
/// <summary>The original claim.</summary>
public required VexClaim Claim { get; init; }
/// <summary>Base trust from trust vector.</summary>
public required double BaseTrust { get; init; }
/// <summary>Strength multiplier.</summary>
public required double Strength { get; init; }
/// <summary>Freshness multiplier.</summary>
public required double Freshness { get; init; }
/// <summary>Final composite score.</summary>
public required double FinalScore { get; init; }
/// <summary>Penalty applied (conflict or other).</summary>
public required double PenaltyApplied { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace StellaOps.Excititor.Core.Lattice;
public interface IVexLatticeProvider
{
VexLatticeResult Join(VexClaim left, VexClaim right);
VexLatticeResult Meet(VexClaim left, VexClaim right);
bool IsHigher(VexClaimStatus a, VexClaimStatus b);
decimal GetTrustWeight(VexClaim statement);
VexConflictResolution ResolveConflict(VexClaim left, VexClaim right);
}
public sealed record VexLatticeResult(
VexClaimStatus ResultStatus,
VexClaim? WinningStatement,
string Reason,
decimal? TrustDelta);
public sealed record VexConflictResolution(
VexClaim Winner,
VexClaim Loser,
ConflictResolutionReason Reason,
MergeTrace Trace);
public enum ConflictResolutionReason
{
TrustWeight,
Freshness,
LatticePosition,
Tie,
}
public sealed record MergeTrace
{
public required string LeftSource { get; init; }
public required string RightSource { get; init; }
public required VexClaimStatus LeftStatus { get; init; }
public required VexClaimStatus RightStatus { get; init; }
public required decimal LeftTrust { get; init; }
public required decimal RightTrust { get; init; }
public required VexClaimStatus ResultStatus { get; init; }
public required string Explanation { get; init; }
public DateTimeOffset EvaluatedAt { get; init; }
}

View File

@@ -0,0 +1,244 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Excititor.Core.Lattice;
public sealed class PolicyLatticeAdapter : IVexLatticeProvider
{
private readonly ITrustWeightRegistry _trustRegistry;
private readonly ILogger<PolicyLatticeAdapter> _logger;
private readonly TimeProvider _timeProvider;
public PolicyLatticeAdapter(
ITrustWeightRegistry trustRegistry,
ILogger<PolicyLatticeAdapter> logger,
TimeProvider? timeProvider = null)
{
_trustRegistry = trustRegistry ?? throw new ArgumentNullException(nameof(trustRegistry));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public VexLatticeResult Join(VexClaim left, VexClaim right)
{
var leftValue = MapStatus(left.Status);
var rightValue = MapStatus(right.Status);
var joinResult = K4Lattice.Join(leftValue, rightValue);
var resultStatus = MapStatus(joinResult);
var winner = DetermineWinner(left, right, resultStatus);
var trustDelta = Math.Abs(GetTrustWeight(left) - GetTrustWeight(right));
return new VexLatticeResult(
resultStatus,
winner,
$"K4 join: {leftValue} + {rightValue} = {joinResult}",
trustDelta);
}
public VexLatticeResult Meet(VexClaim left, VexClaim right)
{
var leftValue = MapStatus(left.Status);
var rightValue = MapStatus(right.Status);
var meetResult = K4Lattice.Meet(leftValue, rightValue);
var resultStatus = MapStatus(meetResult);
var winner = DetermineWinner(left, right, resultStatus);
var trustDelta = Math.Abs(GetTrustWeight(left) - GetTrustWeight(right));
return new VexLatticeResult(
resultStatus,
winner,
$"K4 meet: {leftValue} * {rightValue} = {meetResult}",
trustDelta);
}
public bool IsHigher(VexClaimStatus a, VexClaimStatus b)
{
var valueA = MapStatus(a);
var valueB = MapStatus(b);
if (valueA == valueB)
{
return false;
}
return K4Lattice.LessOrEqual(valueB, valueA) && !K4Lattice.LessOrEqual(valueA, valueB);
}
public decimal GetTrustWeight(VexClaim statement)
{
if (statement.Document.Signature?.Trust is { } trust)
{
return ClampWeight(trust.EffectiveWeight);
}
var sourceKey = ExtractSourceKey(statement);
return _trustRegistry.GetWeight(sourceKey);
}
public VexConflictResolution ResolveConflict(VexClaim left, VexClaim right)
{
var leftWeight = GetTrustWeight(left);
var rightWeight = GetTrustWeight(right);
VexClaim winner;
VexClaim loser;
ConflictResolutionReason reason;
if (Math.Abs(leftWeight - rightWeight) > 0.01m)
{
if (leftWeight > rightWeight)
{
winner = left;
loser = right;
}
else
{
winner = right;
loser = left;
}
reason = ConflictResolutionReason.TrustWeight;
}
else if (IsHigher(left.Status, right.Status))
{
winner = left;
loser = right;
reason = ConflictResolutionReason.LatticePosition;
}
else if (IsHigher(right.Status, left.Status))
{
winner = right;
loser = left;
reason = ConflictResolutionReason.LatticePosition;
}
else if (left.LastSeen > right.LastSeen)
{
winner = left;
loser = right;
reason = ConflictResolutionReason.Freshness;
}
else if (right.LastSeen > left.LastSeen)
{
winner = right;
loser = left;
reason = ConflictResolutionReason.Freshness;
}
else
{
winner = left;
loser = right;
reason = ConflictResolutionReason.Tie;
}
var trace = new MergeTrace
{
LeftSource = ExtractSourceKey(left),
RightSource = ExtractSourceKey(right),
LeftStatus = left.Status,
RightStatus = right.Status,
LeftTrust = leftWeight,
RightTrust = rightWeight,
ResultStatus = winner.Status,
Explanation = BuildExplanation(winner, loser, reason),
EvaluatedAt = SelectEvaluationTime(left, right),
};
_logger.LogDebug(
"VEX conflict resolved: {Winner} ({WinnerStatus}) won over {Loser} ({LoserStatus}) by {Reason}",
winner.ProviderId,
winner.Status,
loser.ProviderId,
loser.Status,
reason);
return new VexConflictResolution(winner, loser, reason, trace);
}
private static K4Value MapStatus(VexClaimStatus status) => status switch
{
VexClaimStatus.Affected => K4Value.Conflict,
VexClaimStatus.UnderInvestigation => K4Value.Unknown,
VexClaimStatus.Fixed => K4Value.True,
VexClaimStatus.NotAffected => K4Value.False,
_ => K4Value.Unknown,
};
private static VexClaimStatus MapStatus(K4Value value) => value switch
{
K4Value.Conflict => VexClaimStatus.Affected,
K4Value.True => VexClaimStatus.Fixed,
K4Value.False => VexClaimStatus.NotAffected,
_ => VexClaimStatus.UnderInvestigation,
};
private VexClaim? DetermineWinner(VexClaim left, VexClaim right, VexClaimStatus resultStatus)
{
if (left.Status == resultStatus)
{
return left;
}
if (right.Status == resultStatus)
{
return right;
}
return GetTrustWeight(left) >= GetTrustWeight(right) ? left : right;
}
private static string ExtractSourceKey(VexClaim statement)
{
var signature = statement.Document.Signature;
if (signature?.Trust is { } trust && !string.IsNullOrWhiteSpace(trust.IssuerId))
{
return trust.IssuerId.Trim().ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(signature?.Issuer))
{
return signature.Issuer.Trim().ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(signature?.Subject))
{
return signature.Subject.Trim().ToLowerInvariant();
}
return statement.ProviderId.Trim().ToLowerInvariant();
}
private static string BuildExplanation(VexClaim winner, VexClaim loser, ConflictResolutionReason reason)
{
return reason switch
{
ConflictResolutionReason.TrustWeight =>
$"'{winner.ProviderId}' has higher trust weight than '{loser.ProviderId}'",
ConflictResolutionReason.Freshness =>
$"'{winner.ProviderId}' is more recent ({winner.LastSeen:O}) than '{loser.ProviderId}' ({loser.LastSeen:O})",
ConflictResolutionReason.LatticePosition =>
$"'{winner.Status}' is higher in K4 lattice than '{loser.Status}'",
ConflictResolutionReason.Tie =>
$"Tie between '{winner.ProviderId}' and '{loser.ProviderId}', using first",
_ => "Unknown resolution",
};
}
private static decimal ClampWeight(decimal weight)
{
if (weight < 0m)
{
return 0m;
}
return weight > 1m ? 1m : weight;
}
private DateTimeOffset SelectEvaluationTime(VexClaim left, VexClaim right)
{
var candidate = left.LastSeen >= right.LastSeen ? left.LastSeen : right.LastSeen;
var now = _timeProvider.GetUtcNow();
return candidate <= now ? candidate : now;
}
}

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Excititor.Core.Lattice;
public interface ITrustWeightRegistry
{
decimal GetWeight(string sourceKey);
void RegisterWeight(string sourceKey, decimal weight);
IReadOnlyDictionary<string, decimal> GetAllWeights();
}
public sealed class TrustWeightRegistry : ITrustWeightRegistry
{
private static readonly Dictionary<string, decimal> DefaultWeights = new(StringComparer.OrdinalIgnoreCase)
{
["vendor"] = 1.0m,
["distro"] = 0.9m,
["nvd"] = 0.8m,
["ghsa"] = 0.75m,
["osv"] = 0.7m,
["cisa"] = 0.85m,
["first-party"] = 0.95m,
["community"] = 0.5m,
["unknown"] = 0.3m,
};
private readonly Dictionary<string, decimal> _weights = new(StringComparer.OrdinalIgnoreCase);
private readonly TrustWeightOptions _options;
private readonly ILogger<TrustWeightRegistry> _logger;
public TrustWeightRegistry(IOptions<TrustWeightOptions> options, ILogger<TrustWeightRegistry> logger)
{
_options = options?.Value ?? new TrustWeightOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
foreach (var (key, weight) in DefaultWeights)
{
_weights[key] = weight;
}
foreach (var (key, weight) in _options.SourceWeights)
{
_weights[key] = ClampWeight(weight);
_logger.LogDebug("Configured trust weight: {Source} = {Weight}", key, _weights[key]);
}
}
public decimal GetWeight(string sourceKey)
{
if (string.IsNullOrWhiteSpace(sourceKey))
{
return _weights["unknown"];
}
if (_weights.TryGetValue(sourceKey, out var weight))
{
return weight;
}
foreach (var category in DefaultWeights.Keys)
{
if (sourceKey.Contains(category, StringComparison.OrdinalIgnoreCase))
{
return _weights[category];
}
}
return _weights["unknown"];
}
public void RegisterWeight(string sourceKey, decimal weight)
{
if (string.IsNullOrWhiteSpace(sourceKey))
{
return;
}
var clamped = ClampWeight(weight);
_weights[sourceKey] = clamped;
_logger.LogInformation("Registered trust weight: {Source} = {Weight}", sourceKey, clamped);
}
public IReadOnlyDictionary<string, decimal> GetAllWeights()
=> new Dictionary<string, decimal>(_weights, StringComparer.OrdinalIgnoreCase);
private static decimal ClampWeight(decimal weight)
{
if (weight < 0m)
{
return 0m;
}
return weight > 1m ? 1m : weight;
}
}
public sealed class TrustWeightOptions
{
public Dictionary<string, decimal> SourceWeights { get; set; } = [];
}

View File

@@ -0,0 +1,165 @@
namespace StellaOps.Excititor.Core.Reachability;
/// <summary>
/// Represents a slice verdict from the Scanner service.
/// </summary>
public sealed record SliceVerdict
{
/// <summary>
/// Reachability verdict status.
/// </summary>
public required SliceVerdictStatus Status { get; init; }
/// <summary>
/// Confidence score [0.0, 1.0].
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Digest of the slice in CAS.
/// </summary>
public required string SliceDigest { get; init; }
/// <summary>
/// URI to retrieve the full slice.
/// </summary>
public string? SliceUri { get; init; }
/// <summary>
/// Path witnesses if reachable.
/// </summary>
public IReadOnlyList<string>? PathWitnesses { get; init; }
/// <summary>
/// Reasons for the verdict.
/// </summary>
public IReadOnlyList<string>? Reasons { get; init; }
/// <summary>
/// Number of unknown nodes in the slice.
/// </summary>
public int UnknownCount { get; init; }
/// <summary>
/// Gated paths requiring conditions to be satisfied.
/// </summary>
public IReadOnlyList<GatedPathInfo>? GatedPaths { get; init; }
/// <summary>
/// Analyzer version that produced the slice.
/// </summary>
public string? AnalyzerVersion { get; init; }
}
/// <summary>
/// Slice verdict status.
/// </summary>
public enum SliceVerdictStatus
{
/// <summary>
/// Vulnerable code is reachable from entrypoints.
/// </summary>
Reachable,
/// <summary>
/// Vulnerable code is not reachable from entrypoints.
/// </summary>
Unreachable,
/// <summary>
/// Reachability could not be determined.
/// </summary>
Unknown,
/// <summary>
/// Vulnerable code is behind a feature gate.
/// </summary>
Gated,
/// <summary>
/// Reachability confirmed by runtime observation.
/// </summary>
ObservedReachable
}
/// <summary>
/// Information about a gated path.
/// </summary>
public sealed record GatedPathInfo
{
/// <summary>
/// Unique identifier for the path.
/// </summary>
public required string PathId { get; init; }
/// <summary>
/// Type of gate (feature_flag, config, auth, admin_only).
/// </summary>
public required string GateType { get; init; }
/// <summary>
/// Condition required for the path to be active.
/// </summary>
public required string GateCondition { get; init; }
/// <summary>
/// Whether the gate condition is currently satisfied.
/// </summary>
public required bool GateSatisfied { get; init; }
}
/// <summary>
/// Query parameters for slice verdict lookup.
/// </summary>
public sealed record SliceVerdictQuery
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Scan ID for context.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// Optional policy hash for policy-bound queries.
/// </summary>
public string? PolicyHash { get; init; }
/// <summary>
/// Optional entrypoint symbols to query from.
/// </summary>
public IReadOnlyList<string>? Entrypoints { get; init; }
}
/// <summary>
/// Consumes slice verdicts from the Scanner service.
/// </summary>
public interface ISliceVerdictConsumer
{
/// <summary>
/// Query a slice verdict for a CVE+PURL combination.
/// </summary>
Task<SliceVerdict?> GetVerdictAsync(
SliceVerdictQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Query slice verdicts for multiple CVE+PURL combinations.
/// </summary>
Task<IReadOnlyDictionary<string, SliceVerdict>> GetVerdictsAsync(
IEnumerable<SliceVerdictQuery> queries,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidate cached verdicts for a scan.
/// </summary>
void InvalidateCache(string scanId);
}

View File

@@ -0,0 +1,182 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace StellaOps.Excititor.Core.Reachability;
/// <summary>
/// Consumes slice verdicts from Scanner API for VEX decision automation.
/// </summary>
public sealed class SliceVerdictConsumer : ISliceVerdictConsumer
{
private readonly ISliceQueryClient _sliceClient;
private readonly ILogger<SliceVerdictConsumer> _logger;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, SliceVerdict>> _cache = new();
public SliceVerdictConsumer(
ISliceQueryClient sliceClient,
ILogger<SliceVerdictConsumer> logger)
{
_sliceClient = sliceClient ?? throw new ArgumentNullException(nameof(sliceClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<SliceVerdict?> GetVerdictAsync(
SliceVerdictQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var cacheKey = $"{query.CveId}|{query.Purl}";
if (_cache.TryGetValue(query.ScanId, out var scanCache) &&
scanCache.TryGetValue(cacheKey, out var cached))
{
_logger.LogDebug("Cache hit for slice verdict {CveId} + {Purl}", query.CveId, query.Purl);
return cached;
}
try
{
var response = await _sliceClient.QuerySliceAsync(query.ScanId, query.CveId, query.Purl, cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
_logger.LogWarning("No slice verdict found for {CveId} + {Purl} in scan {ScanId}",
query.CveId, query.Purl, query.ScanId);
return null;
}
var result = new SliceVerdict
{
Status = ParseVerdictStatus(response.Verdict),
Confidence = response.Confidence,
SliceDigest = response.SliceDigest,
PathWitnesses = response.PathWitnesses,
UnknownCount = response.UnknownCount ?? 0,
GatedPaths = response.GatedPaths?.Select(gp => new GatedPathInfo
{
PathId = gp,
GateType = "unknown",
GateCondition = "unknown",
GateSatisfied = false
}).ToList()
};
var cache = _cache.GetOrAdd(query.ScanId, _ => new ConcurrentDictionary<string, SliceVerdict>());
cache.TryAdd(cacheKey, result);
_logger.LogInformation(
"Queried slice verdict for {CveId} + {Purl}: {Verdict} (confidence: {Confidence:F3})",
query.CveId,
query.Purl,
result.Status,
result.Confidence);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying slice verdict for {CveId} + {Purl}", query.CveId, query.Purl);
return null;
}
}
public async Task<IReadOnlyDictionary<string, SliceVerdict>> GetVerdictsAsync(
IEnumerable<SliceVerdictQuery> queries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(queries);
var results = new Dictionary<string, SliceVerdict>();
foreach (var query in queries)
{
var verdict = await GetVerdictAsync(query, cancellationToken).ConfigureAwait(false);
if (verdict is not null)
{
results[$"{query.CveId}|{query.Purl}"] = verdict;
}
}
return results;
}
public void InvalidateCache(string scanId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
_cache.TryRemove(scanId, out _);
_logger.LogInformation("Invalidated slice verdict cache for scan {ScanId}", scanId);
}
public VexStatusInfluence MapToVexInfluence(SliceVerdict verdict)
{
ArgumentNullException.ThrowIfNull(verdict);
return verdict.Status switch
{
SliceVerdictStatus.Unreachable when verdict.Confidence >= 0.9 => VexStatusInfluence.SuggestNotAffected,
SliceVerdictStatus.Unreachable when verdict.Confidence >= 0.7 => VexStatusInfluence.RequiresManualTriage,
SliceVerdictStatus.Reachable => VexStatusInfluence.MaintainAffected,
SliceVerdictStatus.ObservedReachable => VexStatusInfluence.MaintainAffected,
SliceVerdictStatus.Gated when verdict.Confidence >= 0.8 => VexStatusInfluence.RequiresManualTriage,
SliceVerdictStatus.Unknown => VexStatusInfluence.RequiresManualTriage,
_ => VexStatusInfluence.None
};
}
private static SliceVerdictStatus ParseVerdictStatus(string verdict)
{
return verdict.ToLowerInvariant() switch
{
"reachable" => SliceVerdictStatus.Reachable,
"unreachable" => SliceVerdictStatus.Unreachable,
"gated" => SliceVerdictStatus.Gated,
"observed_reachable" => SliceVerdictStatus.ObservedReachable,
_ => SliceVerdictStatus.Unknown
};
}
}
/// <summary>
/// Client for querying Scanner slice API.
/// </summary>
public interface ISliceQueryClient
{
Task<SliceQueryResponse?> QuerySliceAsync(
string scanId,
string cveId,
string purl,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Response from slice query API.
/// </summary>
public sealed record SliceQueryResponse
{
public required string Verdict { get; init; }
public required double Confidence { get; init; }
public required string SliceDigest { get; init; }
public IReadOnlyList<string>? PathWitnesses { get; init; }
public int? UnknownCount { get; init; }
public IReadOnlyList<string>? GatedPaths { get; init; }
}
/// <summary>
/// How a slice verdict influences VEX status decision.
/// </summary>
public enum VexStatusInfluence
{
/// <summary>No influence.</summary>
None = 0,
/// <summary>Suggest changing to "not_affected".</summary>
SuggestNotAffected = 1,
/// <summary>Maintain current "affected" status.</summary>
MaintainAffected = 2,
/// <summary>Requires manual triage before decision.</summary>
RequiresManualTriage = 3
}

View File

@@ -15,5 +15,6 @@
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.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" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,57 @@
namespace StellaOps.Excititor.Core;
public sealed record ClaimScoreResult
{
public required double Score { get; init; }
public required double BaseTrust { get; init; }
public required double StrengthMultiplier { get; init; }
public required double FreshnessMultiplier { get; init; }
public required TrustVector Vector { get; init; }
public required TrustWeights Weights { get; init; }
}
public interface IClaimScoreCalculator
{
ClaimScoreResult Compute(
TrustVector vector,
TrustWeights weights,
ClaimStrength strength,
DateTimeOffset issuedAt,
DateTimeOffset cutoff);
}
public sealed class ClaimScoreCalculator : IClaimScoreCalculator
{
private readonly FreshnessCalculator _freshnessCalculator;
public ClaimScoreCalculator(FreshnessCalculator? freshnessCalculator = null)
{
_freshnessCalculator = freshnessCalculator ?? new FreshnessCalculator();
}
public ClaimScoreResult Compute(
TrustVector vector,
TrustWeights weights,
ClaimStrength strength,
DateTimeOffset issuedAt,
DateTimeOffset cutoff)
{
ArgumentNullException.ThrowIfNull(vector);
ArgumentNullException.ThrowIfNull(weights);
var baseTrust = vector.ComputeBaseTrust(weights);
var strengthMultiplier = strength.ToMultiplier();
var freshnessMultiplier = _freshnessCalculator.Compute(issuedAt, cutoff);
var score = baseTrust * strengthMultiplier * freshnessMultiplier;
return new ClaimScoreResult
{
Score = score,
BaseTrust = baseTrust,
StrengthMultiplier = strengthMultiplier,
FreshnessMultiplier = freshnessMultiplier,
Vector = vector,
Weights = weights,
};
}
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Excititor.Core;
public enum ClaimStrength
{
/// <summary>Exploitability analysis with reachability proof subgraph.</summary>
ExploitabilityWithReachability = 100,
/// <summary>Config/feature-flag reason with evidence.</summary>
ConfigWithEvidence = 80,
/// <summary>Vendor blanket statement.</summary>
VendorBlanket = 60,
/// <summary>Under investigation.</summary>
UnderInvestigation = 40,
}
public static class ClaimStrengthExtensions
{
public static double ToMultiplier(this ClaimStrength strength)
=> (int)strength / 100.0;
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.Excititor.Core;
public interface ICoverageScorer
{
double Score(CoverageSignal signal);
}
public enum CoverageLevel
{
ExactWithContext,
VersionRangePartialContext,
ProductLevel,
FamilyHeuristic,
}
public sealed record CoverageSignal
{
public CoverageLevel Level { get; init; }
}
public sealed class CoverageScorer : ICoverageScorer
{
public double Score(CoverageSignal signal)
{
ArgumentNullException.ThrowIfNull(signal);
return signal.Level switch
{
CoverageLevel.ExactWithContext => 1.00,
CoverageLevel.VersionRangePartialContext => 0.75,
CoverageLevel.ProductLevel => 0.50,
CoverageLevel.FamilyHeuristic => 0.25,
_ => 0.25,
};
}
}

View File

@@ -0,0 +1,49 @@
namespace StellaOps.Excititor.Core;
public static class DefaultTrustVectors
{
public static TrustVector Vendor => new()
{
Provenance = 0.90,
Coverage = 0.70,
Replayability = 0.60,
};
public static TrustVector Distro => new()
{
Provenance = 0.80,
Coverage = 0.85,
Replayability = 0.60,
};
public static TrustVector Internal => new()
{
Provenance = 0.85,
Coverage = 0.95,
Replayability = 0.90,
};
public static TrustVector Hub => new()
{
Provenance = 0.60,
Coverage = 0.50,
Replayability = 0.40,
};
public static TrustVector Attestation => new()
{
Provenance = 0.95,
Coverage = 0.80,
Replayability = 0.70,
};
public static TrustVector GetDefault(VexProviderKind kind) => kind switch
{
VexProviderKind.Vendor => Vendor,
VexProviderKind.Distro => Distro,
VexProviderKind.Platform => Internal,
VexProviderKind.Hub => Hub,
VexProviderKind.Attestation => Attestation,
_ => Hub,
};
}

View File

@@ -0,0 +1,47 @@
namespace StellaOps.Excititor.Core;
public sealed class FreshnessCalculator
{
private double _halfLifeDays = 90.0;
private double _floor = 0.35;
public double HalfLifeDays
{
get => _halfLifeDays;
init
{
if (value <= 0 || double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(nameof(HalfLifeDays), "Half-life must be a finite positive value.");
}
_halfLifeDays = value;
}
}
public double Floor
{
get => _floor;
init
{
if (value < 0 || value > 1 || double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(nameof(Floor), "Floor must be between 0 and 1.");
}
_floor = value;
}
}
public double Compute(DateTimeOffset issuedAt, DateTimeOffset cutoff)
{
var ageDays = (cutoff - issuedAt).TotalDays;
if (ageDays <= 0)
{
return 1.0;
}
var decay = Math.Exp(-Math.Log(2) * ageDays / HalfLifeDays);
return Math.Max(decay, Floor);
}
}

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Excititor.Core;
public interface IProvenanceScorer
{
double Score(ProvenanceSignal signal);
}
public sealed record ProvenanceSignal
{
public bool DsseSigned { get; init; }
public bool HasTransparencyLog { get; init; }
public bool KeyAllowListed { get; init; }
public bool PublicKeyKnown { get; init; }
public bool AuthenticatedUnsigned { get; init; }
public bool ManualImport { get; init; }
}
public static class ProvenanceScores
{
public const double FullyAttested = 1.00;
public const double SignedNoLog = 0.75;
public const double AuthenticatedUnsigned = 0.40;
public const double ManualImport = 0.10;
}
public sealed class ProvenanceScorer : IProvenanceScorer
{
public double Score(ProvenanceSignal signal)
{
ArgumentNullException.ThrowIfNull(signal);
if (signal.DsseSigned && signal.HasTransparencyLog && signal.KeyAllowListed)
{
return ProvenanceScores.FullyAttested;
}
if (signal.DsseSigned && (signal.PublicKeyKnown || signal.KeyAllowListed))
{
return ProvenanceScores.SignedNoLog;
}
if (signal.AuthenticatedUnsigned)
{
return ProvenanceScores.AuthenticatedUnsigned;
}
if (signal.ManualImport)
{
return ProvenanceScores.ManualImport;
}
return ProvenanceScores.ManualImport;
}
}

View File

@@ -0,0 +1,34 @@
namespace StellaOps.Excititor.Core;
public interface IReplayabilityScorer
{
double Score(ReplayabilitySignal signal);
}
public enum ReplayabilityLevel
{
FullyPinned,
MostlyPinned,
Ephemeral,
}
public sealed record ReplayabilitySignal
{
public ReplayabilityLevel Level { get; init; }
}
public sealed class ReplayabilityScorer : IReplayabilityScorer
{
public double Score(ReplayabilitySignal signal)
{
ArgumentNullException.ThrowIfNull(signal);
return signal.Level switch
{
ReplayabilityLevel.FullyPinned => 1.00,
ReplayabilityLevel.MostlyPinned => 0.60,
ReplayabilityLevel.Ephemeral => 0.20,
_ => 0.20,
};
}
}

View File

@@ -0,0 +1,239 @@
using System.Collections.Concurrent;
namespace StellaOps.Excititor.Core;
public sealed record SourceClassification
{
public required VexProviderKind Kind { get; init; }
public required TrustVector DefaultVector { get; init; }
public required double Confidence { get; init; }
public required string Reason { get; init; }
public required bool IsOverride { get; init; }
}
public interface ISourceClassificationService
{
SourceClassification Classify(
string issuerId,
string? issuerDomain,
string? signatureType,
string contentFormat);
void RegisterOverride(string issuerPattern, VexProviderKind kind);
}
public sealed record SourceClassificationOptions
{
public IReadOnlyCollection<string> VendorDomains { get; init; } = new[]
{
"microsoft.com",
"redhat.com",
"oracle.com",
"apple.com",
"vmware.com",
"cisco.com",
"elastic.co",
"hashicorp.com",
"splunk.com",
};
public IReadOnlyCollection<string> DistroDomains { get; init; } = new[]
{
"debian.org",
"ubuntu.com",
"canonical.com",
"suse.com",
"opensuse.org",
"almalinux.org",
"rockylinux.org",
"fedora.org",
};
public IReadOnlyCollection<string> HubDomains { get; init; } = new[]
{
"osv.dev",
"github.com",
"securityadvisories.github.com",
"nvd.nist.gov",
};
public IReadOnlyCollection<string> InternalDomains { get; init; } = new[]
{
"internal",
"local",
"corp",
};
}
public sealed class SourceClassificationService : ISourceClassificationService
{
private readonly SourceClassificationOptions _options;
private readonly ConcurrentQueue<SourceOverride> _overrides = new();
public SourceClassificationService(SourceClassificationOptions? options = null)
{
_options = options ?? new SourceClassificationOptions();
}
public void RegisterOverride(string issuerPattern, VexProviderKind kind)
{
if (string.IsNullOrWhiteSpace(issuerPattern))
{
throw new ArgumentException("Override pattern must be provided.", nameof(issuerPattern));
}
_overrides.Enqueue(new SourceOverride(issuerPattern.Trim(), kind));
}
public SourceClassification Classify(
string issuerId,
string? issuerDomain,
string? signatureType,
string contentFormat)
{
if (string.IsNullOrWhiteSpace(issuerId))
{
throw new ArgumentException("Issuer id must be provided.", nameof(issuerId));
}
if (string.IsNullOrWhiteSpace(contentFormat))
{
throw new ArgumentException("Content format must be provided.", nameof(contentFormat));
}
var domain = NormalizeDomain(issuerDomain);
var signature = signatureType?.Trim() ?? string.Empty;
var format = contentFormat.Trim();
foreach (var entry in _overrides)
{
if (Matches(entry.Pattern, issuerId) || (!string.IsNullOrWhiteSpace(domain) && Matches(entry.Pattern, domain)))
{
return BuildClassification(entry.Kind, 1.0, $"override:{entry.Pattern}", isOverride: true);
}
}
if (format.Contains("attestation", StringComparison.OrdinalIgnoreCase) ||
signature.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
signature.Contains("sigstore", StringComparison.OrdinalIgnoreCase) ||
signature.Contains("cosign", StringComparison.OrdinalIgnoreCase))
{
return BuildClassification(VexProviderKind.Attestation, 0.85, "attestation_or_signed", isOverride: false);
}
if (!string.IsNullOrWhiteSpace(domain))
{
if (IsDomainMatch(domain, _options.DistroDomains))
{
return BuildClassification(VexProviderKind.Distro, 0.90, "known_distro_domain", isOverride: false);
}
if (IsDomainMatch(domain, _options.VendorDomains))
{
return BuildClassification(VexProviderKind.Vendor, 0.90, "known_vendor_domain", isOverride: false);
}
if (IsDomainMatch(domain, _options.HubDomains) || issuerId.Contains("hub", StringComparison.OrdinalIgnoreCase))
{
return BuildClassification(VexProviderKind.Hub, 0.75, "hub_domain_or_id", isOverride: false);
}
if (IsInternalDomain(domain))
{
return BuildClassification(VexProviderKind.Platform, 0.70, "internal_domain", isOverride: false);
}
}
if (format.Contains("csaf", StringComparison.OrdinalIgnoreCase))
{
return BuildClassification(VexProviderKind.Vendor, 0.65, "csaf_format", isOverride: false);
}
if (format.Contains("openvex", StringComparison.OrdinalIgnoreCase) ||
format.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase))
{
return BuildClassification(VexProviderKind.Hub, 0.55, "generic_vex_format", isOverride: false);
}
return BuildClassification(VexProviderKind.Hub, 0.50, "fallback", isOverride: false);
}
private static SourceClassification BuildClassification(VexProviderKind kind, double confidence, string reason, bool isOverride)
=> new()
{
Kind = kind,
DefaultVector = DefaultTrustVectors.GetDefault(kind),
Confidence = confidence,
Reason = reason,
IsOverride = isOverride,
};
private static bool Matches(string pattern, string value)
{
if (pattern == "*")
{
return true;
}
if (!pattern.Contains('*'))
{
return string.Equals(pattern, value, StringComparison.OrdinalIgnoreCase);
}
var trimmed = pattern.Trim('*');
if (pattern.StartsWith('*') && pattern.EndsWith('*'))
{
return value.Contains(trimmed, StringComparison.OrdinalIgnoreCase);
}
if (pattern.StartsWith('*'))
{
return value.EndsWith(trimmed, StringComparison.OrdinalIgnoreCase);
}
if (pattern.EndsWith('*'))
{
return value.StartsWith(trimmed, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(pattern, value, StringComparison.OrdinalIgnoreCase);
}
private static bool IsDomainMatch(string domain, IEnumerable<string> candidates)
{
foreach (var candidate in candidates)
{
if (domain.EndsWith(candidate, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private bool IsInternalDomain(string domain)
{
foreach (var token in _options.InternalDomains)
{
if (domain.Contains(token, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string? NormalizeDomain(string? domain)
{
if (string.IsNullOrWhiteSpace(domain))
{
return null;
}
return domain.Trim().TrimStart('.');
}
private sealed record SourceOverride(string Pattern, VexProviderKind Kind);
}

View File

@@ -0,0 +1,67 @@
using System.Globalization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// 3-component trust vector for VEX sources.
/// </summary>
public sealed record TrustVector
{
private double _provenance;
private double _coverage;
private double _replayability;
/// <summary>Provenance score: cryptographic & process integrity [0..1].</summary>
public required double Provenance
{
get => _provenance;
init => _provenance = NormalizeScore(value, nameof(Provenance));
}
/// <summary>Coverage score: scope match precision [0..1].</summary>
public required double Coverage
{
get => _coverage;
init => _coverage = NormalizeScore(value, nameof(Coverage));
}
/// <summary>Replayability score: determinism and pinning [0..1].</summary>
public required double Replayability
{
get => _replayability;
init => _replayability = NormalizeScore(value, nameof(Replayability));
}
/// <summary>Compute base trust using provided weights.</summary>
public double ComputeBaseTrust(TrustWeights weights)
{
ArgumentNullException.ThrowIfNull(weights);
return weights.WP * Provenance + weights.WC * Coverage + weights.WR * Replayability;
}
public static TrustVector FromLegacyWeight(double weight)
{
var normalized = NormalizeScore(weight, nameof(weight));
return new TrustVector
{
Provenance = normalized,
Coverage = normalized,
Replayability = normalized,
};
}
internal static double NormalizeScore(double value, string name)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(name, "Score must be finite.");
}
if (value < 0 || value > 1)
{
throw new ArgumentOutOfRangeException(name, value.ToString(CultureInfo.InvariantCulture), "Score must be between 0 and 1.");
}
return value;
}
}

View File

@@ -0,0 +1,48 @@
using System.Globalization;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Configurable weights for trust vector components.
/// </summary>
public sealed record TrustWeights
{
private double _wP = 0.45;
private double _wC = 0.35;
private double _wR = 0.20;
public double WP
{
get => _wP;
init => _wP = NormalizeWeight(value, nameof(WP));
}
public double WC
{
get => _wC;
init => _wC = NormalizeWeight(value, nameof(WC));
}
public double WR
{
get => _wR;
init => _wR = NormalizeWeight(value, nameof(WR));
}
public static TrustWeights Default => new();
internal static double NormalizeWeight(double value, string name)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new ArgumentOutOfRangeException(name, "Weight must be finite.");
}
if (value < 0 || value > 1)
{
throw new ArgumentOutOfRangeException(name, value.ToString(CultureInfo.InvariantCulture), "Weight must be between 0 and 1.");
}
return value;
}
}

View File

@@ -41,10 +41,115 @@ public static class VexCanonicalJsonSerializer
new[]
{
"weight",
"vector",
"weights",
"cosign",
"pgpFingerprints",
}
},
{
typeof(TrustVector),
new[]
{
"provenance",
"coverage",
"replayability",
}
},
{
typeof(TrustWeights),
new[]
{
"wP",
"wC",
"wR",
}
},
{
typeof(ClaimScoreResult),
new[]
{
"score",
"baseTrust",
"strengthMultiplier",
"freshnessMultiplier",
"vector",
"weights",
}
},
{
typeof(CalibrationManifest),
new[]
{
"manifestId",
"tenant",
"epochNumber",
"epochStart",
"epochEnd",
"adjustments",
"metrics",
"manifestDigest",
"signature",
}
},
{
typeof(CalibrationAdjustment),
new[]
{
"sourceId",
"oldVector",
"newVector",
"delta",
"reason",
"sampleCount",
"accuracyBefore",
"accuracyAfter",
}
},
{
typeof(CalibrationMetrics),
new[]
{
"totalVerdicts",
"correctVerdicts",
"postMortemReversals",
"overallAccuracy",
}
},
{
typeof(ComparisonResult),
new[]
{
"sourceId",
"totalPredictions",
"correctPredictions",
"falseNegatives",
"falsePositives",
"accuracy",
"confidenceInterval",
"detectedBias",
}
},
{
typeof(CalibrationObservation),
new[]
{
"sourceId",
"vulnerabilityId",
"assetDigest",
"status",
"scopeMismatch",
}
},
{
typeof(CalibrationTruth),
new[]
{
"vulnerabilityId",
"assetDigest",
"status",
}
},
{
typeof(VexCosignTrust),
new[]

View File

@@ -11,7 +11,7 @@ namespace StellaOps.Excititor.Core;
/// Use append-only linksets with <see cref="StellaOps.Excititor.Core.Observations.IAppendOnlyLinksetStore"/>
/// and let downstream policy engines make verdicts.
/// </remarks>
[Obsolete("Consensus logic is deprecated per AOC-19. Use append-only linksets instead.", DiagnosticId = "EXCITITOR001")]
[Obsolete("Consensus logic is deprecated per AOC-19. Use OpenVexStatementMerger with IVexLatticeProvider instead.", true)]
public sealed class VexConsensusResolver
{
private readonly IVexConsensusPolicy _policy;

View File

@@ -106,11 +106,15 @@ public sealed record VexProviderTrust
public VexProviderTrust(
double weight,
VexCosignTrust? cosign,
IEnumerable<string>? pgpFingerprints = null)
IEnumerable<string>? pgpFingerprints = null,
TrustVector? vector = null,
TrustWeights? weights = null)
{
Weight = NormalizeWeight(weight);
Cosign = cosign;
PgpFingerprints = NormalizeFingerprints(pgpFingerprints);
Vector = vector;
Weights = weights;
}
public double Weight { get; }
@@ -119,6 +123,16 @@ public sealed record VexProviderTrust
public ImmutableArray<string> PgpFingerprints { get; }
/// <summary>Optional trust vector; falls back to legacy weight when null.</summary>
public TrustVector? Vector { get; }
/// <summary>Optional per-provider weight overrides.</summary>
public TrustWeights? Weights { get; }
public TrustVector EffectiveVector => Vector ?? TrustVector.FromLegacyWeight(Weight);
public TrustWeights EffectiveWeights => Weights ?? TrustWeights.Default;
private static double NormalizeWeight(double weight)
{
if (double.IsNaN(weight) || double.IsInfinity(weight))

View File

@@ -0,0 +1,132 @@
using StellaOps.Excititor.Core.Justification;
using StellaOps.Excititor.Core.Reachability;
namespace StellaOps.Excititor.Export;
/// <summary>
/// Enriches VEX exports with reachability evidence from slices.
/// </summary>
public sealed class ReachabilityEvidenceEnricher
{
/// <summary>
/// Enrich VEX statement with reachability evidence.
/// </summary>
public EnrichedVexStatement Enrich(
VexStatement statement,
SliceVerdict? verdict,
VexJustification? justification)
{
ArgumentNullException.ThrowIfNull(statement);
if (verdict is null && justification is null)
{
return new EnrichedVexStatement
{
Statement = statement,
ReachabilityEvidence = null
};
}
var evidence = BuildEvidence(verdict, justification);
return new EnrichedVexStatement
{
Statement = statement,
ReachabilityEvidence = evidence
};
}
private static ReachabilityEvidence? BuildEvidence(
SliceVerdict? verdict,
VexJustification? justification)
{
if (verdict is null)
{
return null;
}
return new ReachabilityEvidence
{
SliceDigest = verdict.SliceDigest,
SliceUri = verdict.SliceUri,
VerdictStatus = verdict.Status.ToString().ToLowerInvariant(),
Confidence = verdict.Confidence,
UnknownCount = verdict.UnknownCount,
PathWitnesses = verdict.PathWitnesses?.ToList(),
GatedPaths = verdict.GatedPaths?
.Select(g => new GateEvidenceInfo
{
GateType = g.GateType,
GateCondition = g.GateCondition,
GateSatisfied = g.GateSatisfied
})
.ToList(),
AnalyzerVersion = verdict.AnalyzerVersion,
Justification = justification is null ? null : new JustificationSummary
{
Category = justification.Category.ToString(),
Details = justification.Details,
Confidence = justification.Confidence,
GeneratedAt = justification.GeneratedAt
}
};
}
}
/// <summary>
/// VEX statement enriched with reachability evidence.
/// </summary>
public sealed record EnrichedVexStatement
{
public required VexStatement Statement { get; init; }
public ReachabilityEvidence? ReachabilityEvidence { get; init; }
}
/// <summary>
/// Reachability evidence to include in VEX export.
/// </summary>
public sealed record ReachabilityEvidence
{
public required string SliceDigest { get; init; }
public string? SliceUri { get; init; }
public required string VerdictStatus { get; init; }
public required double Confidence { get; init; }
public int UnknownCount { get; init; }
public List<string>? PathWitnesses { get; init; }
public List<GateEvidenceInfo>? GatedPaths { get; init; }
public string? AnalyzerVersion { get; init; }
public JustificationSummary? Justification { get; init; }
}
/// <summary>
/// Gate information in evidence.
/// </summary>
public sealed record GateEvidenceInfo
{
public required string GateType { get; init; }
public required string GateCondition { get; init; }
public required bool GateSatisfied { get; init; }
}
/// <summary>
/// Justification summary in evidence.
/// </summary>
public sealed record JustificationSummary
{
public required string Category { get; init; }
public required string Details { get; init; }
public required double Confidence { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// VEX statement (placeholder - actual definition elsewhere).
/// </summary>
public sealed record VexStatement
{
public required string CveId { get; init; }
public required string Purl { get; init; }
public required string Status { get; init; }
public string? Justification { get; init; }
public DateTimeOffset? StatusDate { get; init; }
}

View File

@@ -146,7 +146,7 @@ public sealed class S3ArtifactStore : IVexArtifactStore
VexExportFormat.JsonLines => "application/json",
VexExportFormat.OpenVex => "application/vnd.openvex+json",
VexExportFormat.Csaf => "application/json",
VexExportFormat.CycloneDx => "application/vnd.cyclonedx+json",
VexExportFormat.CycloneDx => "application/vnd.cyclonedx+json; version=1.7",
_ => "application/octet-stream",
},
};

View File

@@ -65,7 +65,7 @@ public sealed class CycloneDxExporter : IVexExporter
var document = new CycloneDxExportDocument(
BomFormat: "CycloneDX",
SpecVersion: "1.6",
SpecVersion: "1.7",
SerialNumber: FormattableString.Invariant($"urn:uuid:{BuildDeterministicGuid(signatureHash.Digest)}"),
Version: 1,
Metadata: new CycloneDxMetadata(generatedAt),
@@ -94,7 +94,9 @@ public sealed class CycloneDxExporter : IVexExporter
Justification: claim.Justification?.ToString().ToLowerInvariant(),
Responses: null);
var affects = ImmutableArray.Create(new CycloneDxAffectEntry(componentRef));
var affects = BuildAffects(componentRef, claim);
var ratings = BuildRatings(claim);
var source = BuildSource(claim);
var properties = ImmutableArray.Create(
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
@@ -109,6 +111,8 @@ public sealed class CycloneDxExporter : IVexExporter
Description: claim.Detail,
Analysis: analysis,
Affects: affects,
Ratings: ratings,
Source: source,
Properties: properties));
}
@@ -173,6 +177,68 @@ public sealed class CycloneDxExporter : IVexExporter
return builder.ToImmutable();
}
private static ImmutableArray<CycloneDxAffectEntry> BuildAffects(string componentRef, VexClaim claim)
{
var versions = BuildAffectedVersions(claim);
return ImmutableArray.Create(new CycloneDxAffectEntry(componentRef, versions));
}
private static ImmutableArray<CycloneDxAffectVersion>? BuildAffectedVersions(VexClaim claim)
{
var version = claim.Product.Version;
if (string.IsNullOrWhiteSpace(version))
{
return null;
}
return ImmutableArray.Create(new CycloneDxAffectVersion(version.Trim(), range: null, status: null));
}
private static CycloneDxSource? BuildSource(VexClaim claim)
{
var url = claim.Product.Purl ?? claim.Document.SourceUri?.ToString();
if (string.IsNullOrWhiteSpace(claim.ProviderId) && string.IsNullOrWhiteSpace(url))
{
return null;
}
return new CycloneDxSource(claim.ProviderId, string.IsNullOrWhiteSpace(url) ? null : url);
}
private static ImmutableArray<CycloneDxRating>? BuildRatings(VexClaim claim)
{
var severity = claim.Signals?.Severity;
if (severity is null)
{
return null;
}
var method = NormalizeSeverityScheme(severity.Scheme);
var rating = new CycloneDxRating(
Method: method,
Score: severity.Score,
Severity: severity.Label,
Vector: severity.Vector);
return ImmutableArray.Create(rating);
}
private static string NormalizeSeverityScheme(string scheme)
{
if (string.IsNullOrWhiteSpace(scheme))
{
return "other";
}
return scheme.Trim().ToLowerInvariant() switch
{
"cvss4" or "cvss-4" or "cvss-4.0" or "cvssv4" => "CVSSv4",
"cvss3" or "cvss-3" or "cvss-3.1" or "cvssv3" => "CVSSv3",
"cvss2" or "cvss-2" or "cvssv2" => "CVSSv2",
_ => scheme.Trim()
};
}
private static string BuildDeterministicGuid(string digest)
{
if (string.IsNullOrWhiteSpace(digest) || digest.Length < 32)
@@ -217,6 +283,8 @@ internal sealed record CycloneDxVulnerabilityEntry(
[property: JsonPropertyName("description"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description,
[property: JsonPropertyName("analysis")] CycloneDxAnalysis Analysis,
[property: JsonPropertyName("affects")] ImmutableArray<CycloneDxAffectEntry> Affects,
[property: JsonPropertyName("ratings"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxRating>? Ratings,
[property: JsonPropertyName("source"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CycloneDxSource? Source,
[property: JsonPropertyName("properties")] ImmutableArray<CycloneDxProperty> Properties);
internal sealed record CycloneDxAnalysis(
@@ -225,4 +293,20 @@ internal sealed record CycloneDxAnalysis(
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
internal sealed record CycloneDxAffectEntry(
[property: JsonPropertyName("ref")] string Reference);
[property: JsonPropertyName("ref")] string Reference,
[property: JsonPropertyName("versions"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxAffectVersion>? Versions);
internal sealed record CycloneDxAffectVersion(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("range"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Range,
[property: JsonPropertyName("status"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Status);
internal sealed record CycloneDxSource(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("url"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Url);
internal sealed record CycloneDxRating(
[property: JsonPropertyName("method")] string Method,
[property: JsonPropertyName("score"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] double? Score,
[property: JsonPropertyName("severity"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Severity,
[property: JsonPropertyName("vector"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Vector);

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -185,7 +186,7 @@ public sealed class CycloneDxNormalizer : IVexNormalizer
using var json = JsonDocument.Parse(document.Content.ToArray());
var root = json.RootElement;
var specVersion = TryGetString(root, "specVersion");
var specVersion = NormalizeSpecVersion(TryGetString(root, "specVersion"));
var bomVersion = TryGetString(root, "version");
var serialNumber = TryGetString(root, "serialNumber");
@@ -410,6 +411,22 @@ public sealed class CycloneDxNormalizer : IVexNormalizer
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
private static string? NormalizeSpecVersion(string? specVersion)
{
if (string.IsNullOrWhiteSpace(specVersion))
{
return null;
}
var trimmed = specVersion.Trim();
if (Version.TryParse(trimmed, out var parsed) && parsed.Major == 1)
{
return string.Create(CultureInfo.InvariantCulture, $"{parsed.Major}.{parsed.Minor}");
}
return trimmed;
}
}
private sealed record CycloneDxParseResult(

View File

@@ -0,0 +1,5 @@
# Excititor CycloneDX Format Tasks
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `SPRINT-3600-0002-CDX` | `docs/implplan/SPRINT_3600_0002_0001_cyclonedx_1_7_upgrade.md` | DOING | Update CycloneDX VEX export defaults and media types for 1.7. |

View File

@@ -0,0 +1,92 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using StellaOps.Excititor.Core.Lattice;
namespace StellaOps.Excititor.Formats.OpenVEX;
public static class MergeTraceWriter
{
public static string ToExplanation(VexMergeResult result)
{
if (!result.HadConflicts)
{
return result.InputCount switch
{
0 => "No VEX statements to merge.",
1 => $"Single statement from '{result.ResultStatement.ProviderId}': {result.ResultStatement.Status}",
_ => $"All {result.InputCount} statements agreed: {result.ResultStatement.Status}",
};
}
var sb = new StringBuilder();
sb.AppendLine($"Merged {result.InputCount} statements with {result.Traces.Count} conflicts:");
sb.AppendLine();
foreach (var trace in result.Traces)
{
sb.AppendLine($" Conflict: {trace.LeftSource} ({trace.LeftStatus}) vs {trace.RightSource} ({trace.RightStatus})");
sb.AppendLine(
$" Trust: {trace.LeftTrust.ToString(\"P0\", CultureInfo.InvariantCulture)} vs {trace.RightTrust.ToString(\"P0\", CultureInfo.InvariantCulture)}");
sb.AppendLine($" Resolution: {trace.Explanation}");
sb.AppendLine();
}
sb.AppendLine($"Final result: {result.ResultStatement.Status} from '{result.ResultStatement.ProviderId}'");
return sb.ToString();
}
public static string ToJson(VexMergeResult result)
{
var trace = new
{
inputCount = result.InputCount,
hadConflicts = result.HadConflicts,
result = new
{
status = result.ResultStatement.Status.ToString(),
source = result.ResultStatement.ProviderId,
timestamp = result.ResultStatement.LastSeen,
},
conflicts = result.Traces.Select(t => new
{
left = new { source = t.LeftSource, status = t.LeftStatus.ToString(), trust = t.LeftTrust },
right = new { source = t.RightSource, status = t.RightStatus.ToString(), trust = t.RightTrust },
outcome = t.ResultStatus.ToString(),
explanation = t.Explanation,
evaluatedAt = t.EvaluatedAt,
}),
};
return JsonSerializer.Serialize(trace, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});
}
public static VexAnnotation ToAnnotation(VexMergeResult result)
{
return new VexAnnotation
{
Type = "merge-provenance",
Text = result.HadConflicts
? $"Merged from {result.InputCount} sources with {result.Traces.Count} conflicts"
: $"Merged from {result.InputCount} sources (no conflicts)",
Details = new Dictionary<string, object>
{
["inputCount"] = result.InputCount,
["hadConflicts"] = result.HadConflicts,
["conflictCount"] = result.Traces.Count,
["traces"] = result.Traces,
},
};
}
}
public sealed record VexAnnotation
{
public required string Type { get; init; }
public required string Text { get; init; }
public IDictionary<string, object> Details { get; init; } = new Dictionary<string, object>();
}

View File

@@ -19,8 +19,11 @@ namespace StellaOps.Excititor.Formats.OpenVEX;
/// </summary>
public sealed class OpenVexExporter : IVexExporter
{
public OpenVexExporter()
private readonly OpenVexStatementMerger _merger;
public OpenVexExporter(OpenVexStatementMerger merger)
{
_merger = merger ?? throw new ArgumentNullException(nameof(merger));
}
public VexExportFormat Format => VexExportFormat.OpenVex;
@@ -51,7 +54,7 @@ public sealed class OpenVexExporter : IVexExporter
private OpenVexExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var mergeResult = OpenVexStatementMerger.Merge(request.Claims);
var mergeResult = _merger.Merge(request.Claims);
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
@@ -209,7 +212,8 @@ internal sealed record OpenVexExportStatement(
[property: JsonPropertyName("last_updated")] string LastUpdated,
[property: JsonPropertyName("products")] ImmutableArray<OpenVexExportProduct> Products,
[property: JsonPropertyName("statement"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Statement,
[property: JsonPropertyName("sources")] ImmutableArray<OpenVexExportSource> Sources);
[property: JsonPropertyName("sources")] ImmutableArray<OpenVexExportSource> Sources,
[property: JsonPropertyName("reachability_evidence"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] OpenVexReachabilityEvidence? ReachabilityEvidence = null);
internal sealed record OpenVexExportProduct(
[property: JsonPropertyName("id")] string Id,
@@ -245,3 +249,25 @@ internal sealed record OpenVexExportMetadata(
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("source_providers")] ImmutableArray<string> SourceProviders,
[property: JsonPropertyName("diagnostics")] ImmutableDictionary<string, string> Diagnostics);
/// <summary>
/// Reachability evidence for VEX statements. Links to attested slice for verification.
/// Added in Sprint 3830 for code_not_reachable justification support.
/// </summary>
internal sealed record OpenVexReachabilityEvidence(
[property: JsonPropertyName("slice_digest")] string SliceDigest,
[property: JsonPropertyName("slice_uri")] string SliceUri,
[property: JsonPropertyName("analyzer_version")] string AnalyzerVersion,
[property: JsonPropertyName("confidence")] double Confidence,
[property: JsonPropertyName("verdict")] string Verdict,
[property: JsonPropertyName("unknown_count"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] int UnknownCount = 0,
[property: JsonPropertyName("path_count"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] int PathCount = 0,
[property: JsonPropertyName("gated_paths"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<OpenVexGatedPath>? GatedPaths = null);
/// <summary>
/// Gated path information in reachability evidence.
/// </summary>
internal sealed record OpenVexGatedPath(
[property: JsonPropertyName("path_id")] string PathId,
[property: JsonPropertyName("gate_type")] string GateType,
[property: JsonPropertyName("gate_condition")] string GateCondition,
[property: JsonPropertyName("gate_satisfied")] bool GateSatisfied);

View File

@@ -1,25 +1,91 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
namespace StellaOps.Excititor.Formats.OpenVEX;
/// <summary>
/// Provides deterministic merging utilities for OpenVEX statements derived from normalized VEX claims.
/// </summary>
public static class OpenVexStatementMerger
public sealed class OpenVexStatementMerger
{
private static readonly ImmutableDictionary<VexClaimStatus, int> StatusRiskPrecedence = new Dictionary<VexClaimStatus, int>
{
[VexClaimStatus.Affected] = 3,
[VexClaimStatus.UnderInvestigation] = 2,
[VexClaimStatus.Fixed] = 1,
[VexClaimStatus.NotAffected] = 0,
}.ToImmutableDictionary();
private readonly IVexLatticeProvider _lattice;
private readonly ILogger<OpenVexStatementMerger> _logger;
public static OpenVexMergeResult Merge(IEnumerable<VexClaim> claims)
public OpenVexStatementMerger(
IVexLatticeProvider lattice,
ILogger<OpenVexStatementMerger> logger)
{
_lattice = lattice ?? throw new ArgumentNullException(nameof(lattice));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public VexMergeResult MergeClaims(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var claimList = claims
.Where(static claim => claim is not null)
.ToList();
if (claimList.Count == 0)
{
return VexMergeResult.Empty();
}
if (claimList.Count == 1)
{
return VexMergeResult.Single(claimList[0]);
}
var weighted = claimList
.Select(claim => new MergeCandidate(claim, _lattice.GetTrustWeight(claim)))
.OrderByDescending(candidate => candidate.TrustWeight)
.ThenByDescending(candidate => candidate.Claim.LastSeen)
.ThenBy(candidate => candidate.Claim.ProviderId, StringComparer.Ordinal)
.ThenBy(candidate => candidate.Claim.Document.Digest, StringComparer.Ordinal)
.ThenBy(candidate => candidate.Claim.Document.SourceUri.ToString(), StringComparer.Ordinal)
.ToList();
var traces = new List<MergeTrace>();
var current = weighted[0].Claim;
for (var i = 1; i < weighted.Count; i++)
{
var next = weighted[i].Claim;
if (current.Status != next.Status)
{
var resolution = _lattice.ResolveConflict(current, next);
traces.Add(resolution.Trace);
current = resolution.Winner;
_logger.LogDebug(
"Merged VEX statement: {Status} from {Source} (reason: {Reason})",
current.Status,
current.ProviderId,
resolution.Reason);
}
else
{
var currentWeight = _lattice.GetTrustWeight(current);
var nextWeight = _lattice.GetTrustWeight(next);
if (nextWeight > currentWeight ||
(nextWeight == currentWeight && next.LastSeen > current.LastSeen))
{
current = next;
}
}
}
return new VexMergeResult(current, claimList.Count, traces.Count > 0, traces);
}
public OpenVexMergeResult Merge(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
@@ -40,8 +106,9 @@ public static class OpenVexStatementMerger
continue;
}
var mergeResult = MergeClaims(orderedClaims);
var mergedProduct = MergeProduct(orderedClaims);
var sources = BuildSources(orderedClaims);
var sources = BuildSources(orderedClaims, _lattice);
var firstSeen = orderedClaims.Min(static claim => claim.FirstSeen);
var lastSeen = orderedClaims.Max(static claim => claim.LastSeen);
var statusSet = orderedClaims
@@ -57,7 +124,7 @@ public static class OpenVexStatementMerger
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}={string.Join('|', statusSet.Select(static status => status.ToString().ToLowerInvariant()))}"));
}
var canonicalStatus = SelectCanonicalStatus(statusSet);
var canonicalStatus = mergeResult.ResultStatement.Status;
var justification = SelectJustification(canonicalStatus, orderedClaims, diagnostics, group.Key);
if (canonicalStatus == VexClaimStatus.NotAffected && justification is null)
@@ -77,6 +144,9 @@ public static class OpenVexStatementMerger
justification,
detail,
sources,
mergeResult.InputCount,
mergeResult.HadConflicts,
mergeResult.Traces.ToImmutableArray(),
firstSeen,
lastSeen));
}
@@ -96,19 +166,6 @@ public static class OpenVexStatementMerger
return new OpenVexMergeResult(orderedStatements, orderedDiagnostics);
}
private static VexClaimStatus SelectCanonicalStatus(IReadOnlyCollection<VexClaimStatus> statuses)
{
if (statuses.Count == 0)
{
return VexClaimStatus.UnderInvestigation;
}
return statuses
.OrderByDescending(static status => StatusRiskPrecedence.GetValueOrDefault(status, -1))
.ThenBy(static status => status.ToString(), StringComparer.Ordinal)
.First();
}
private static VexJustification? SelectJustification(
VexClaimStatus canonicalStatus,
ImmutableArray<VexClaim> claims,
@@ -166,7 +223,9 @@ public static class OpenVexStatementMerger
return string.Join("; ", details.OrderBy(static detail => detail, StringComparer.Ordinal));
}
private static ImmutableArray<OpenVexSourceEntry> BuildSources(ImmutableArray<VexClaim> claims)
private static ImmutableArray<OpenVexSourceEntry> BuildSources(
ImmutableArray<VexClaim> claims,
IVexLatticeProvider lattice)
{
var builder = ImmutableArray.CreateBuilder<OpenVexSourceEntry>(claims.Length);
var now = DateTimeOffset.UtcNow;
@@ -176,6 +235,7 @@ public static class OpenVexStatementMerger
// Extract VEX Lens enrichment from signature metadata
var signature = claim.Document.Signature;
var trust = signature?.Trust;
var trustWeight = lattice.GetTrustWeight(claim);
// Compute staleness from trust metadata retrieval time or last seen
long? stalenessSeconds = null;
@@ -219,7 +279,7 @@ public static class OpenVexStatementMerger
signatureType: signature?.Type,
keyId: signature?.KeyId,
transparencyLogRef: signature?.TransparencyLogReference,
trustWeight: trust?.EffectiveWeight,
trustWeight: trustWeight,
trustTier: trustTier,
stalenessSeconds: stalenessSeconds,
productTreeSnippet: productTreeSnippet));
@@ -321,12 +381,27 @@ public static class OpenVexStatementMerger
entries.Add(value);
}
private sealed record MergeCandidate(VexClaim Claim, decimal TrustWeight);
}
public sealed record OpenVexMergeResult(
ImmutableArray<OpenVexMergedStatement> Statements,
ImmutableDictionary<string, string> Diagnostics);
public sealed record VexMergeResult(
VexClaim ResultStatement,
int InputCount,
bool HadConflicts,
IReadOnlyList<MergeTrace> Traces)
{
public static VexMergeResult Empty() =>
new(default!, 0, false, Array.Empty<MergeTrace>());
public static VexMergeResult Single(VexClaim statement) =>
new(statement, 1, false, Array.Empty<MergeTrace>());
}
public sealed record OpenVexMergedStatement(
string VulnerabilityId,
VexProduct Product,
@@ -334,6 +409,9 @@ public sealed record OpenVexMergedStatement(
VexJustification? Justification,
string? Detail,
ImmutableArray<OpenVexSourceEntry> Sources,
int InputCount,
bool HadConflicts,
ImmutableArray<MergeTrace> Traces,
DateTimeOffset FirstObserved,
DateTimeOffset LastObserved);

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
namespace StellaOps.Excititor.Formats.OpenVEX;
@@ -8,6 +9,10 @@ public static class OpenVexFormatsServiceCollectionExtensions
public static IServiceCollection AddOpenVexNormalizer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<TrustWeightOptions>();
services.AddSingleton<ITrustWeightRegistry, TrustWeightRegistry>();
services.AddSingleton<IVexLatticeProvider, PolicyLatticeAdapter>();
services.AddSingleton<OpenVexStatementMerger>();
services.AddSingleton<IVexNormalizer, OpenVexNormalizer>();
services.AddSingleton<IVexExporter, OpenVexExporter>();
return services;

View File

@@ -0,0 +1,61 @@
-- Excititor Schema Migration 006: Calibration Manifests
-- Sprint: SPRINT_7100_0002_0002 - Source Defaults & Calibration
-- Task: T7 - Calibration storage schema
-- Category: D (new tables)
BEGIN;
CREATE TABLE IF NOT EXISTS excititor.calibration_manifests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manifest_id TEXT NOT NULL UNIQUE,
tenant TEXT NOT NULL,
epoch_number INTEGER NOT NULL,
epoch_start TIMESTAMPTZ NOT NULL,
epoch_end TIMESTAMPTZ NOT NULL,
metrics_json JSONB NOT NULL,
manifest_digest TEXT NOT NULL,
signature TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_at TIMESTAMPTZ,
UNIQUE (tenant, epoch_number)
);
CREATE TABLE IF NOT EXISTS excititor.calibration_adjustments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manifest_id TEXT NOT NULL REFERENCES excititor.calibration_manifests(manifest_id),
source_id TEXT NOT NULL,
old_provenance DOUBLE PRECISION NOT NULL,
old_coverage DOUBLE PRECISION NOT NULL,
old_replayability DOUBLE PRECISION NOT NULL,
new_provenance DOUBLE PRECISION NOT NULL,
new_coverage DOUBLE PRECISION NOT NULL,
new_replayability DOUBLE PRECISION NOT NULL,
delta DOUBLE PRECISION NOT NULL,
reason TEXT NOT NULL,
sample_count INTEGER NOT NULL,
accuracy_before DOUBLE PRECISION NOT NULL,
accuracy_after DOUBLE PRECISION NOT NULL
);
CREATE TABLE IF NOT EXISTS excititor.source_trust_vectors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant TEXT NOT NULL,
source_id TEXT NOT NULL,
provenance DOUBLE PRECISION NOT NULL,
coverage DOUBLE PRECISION NOT NULL,
replayability DOUBLE PRECISION NOT NULL,
calibration_manifest_id TEXT REFERENCES excititor.calibration_manifests(manifest_id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant, source_id)
);
CREATE INDEX IF NOT EXISTS idx_calibration_tenant_epoch
ON excititor.calibration_manifests(tenant, epoch_number DESC);
CREATE INDEX IF NOT EXISTS idx_calibration_adjustments_manifest
ON excititor.calibration_adjustments(manifest_id);
CREATE INDEX IF NOT EXISTS idx_source_vectors_tenant
ON excititor.source_trust_vectors(tenant);
COMMIT;

View File

@@ -172,6 +172,18 @@ public sealed class PostgresVexProviderStore : RepositoryBase<ExcititorDataSourc
var obj = new
{
weight = trust.Weight,
vector = trust.Vector is null ? null : new
{
provenance = trust.Vector.Provenance,
coverage = trust.Vector.Coverage,
replayability = trust.Vector.Replayability,
},
weights = trust.Weights is null ? null : new
{
wP = trust.Weights.WP,
wC = trust.Weights.WC,
wR = trust.Weights.WR,
},
cosign = trust.Cosign is null ? null : new { issuer = trust.Cosign.Issuer, identityPattern = trust.Cosign.IdentityPattern },
pgpFingerprints = trust.PgpFingerprints.IsDefault ? Array.Empty<string>() : trust.PgpFingerprints.ToArray()
};
@@ -196,6 +208,38 @@ public sealed class PostgresVexProviderStore : RepositoryBase<ExcititorDataSourc
weight = w;
}
TrustVector? vector = null;
if (root.TryGetProperty("vector", out var vProp) && vProp.ValueKind == JsonValueKind.Object)
{
if (TryGetDouble(vProp, "provenance", out var provenance) &&
TryGetDouble(vProp, "coverage", out var coverage) &&
TryGetDouble(vProp, "replayability", out var replayability))
{
vector = new TrustVector
{
Provenance = provenance,
Coverage = coverage,
Replayability = replayability,
};
}
}
TrustWeights? weights = null;
if (root.TryGetProperty("weights", out var wObj) && wObj.ValueKind == JsonValueKind.Object)
{
if (TryGetDouble(wObj, "wP", out var wP) &&
TryGetDouble(wObj, "wC", out var wC) &&
TryGetDouble(wObj, "wR", out var wR))
{
weights = new TrustWeights
{
WP = wP,
WC = wC,
WR = wR,
};
}
}
VexCosignTrust? cosign = null;
if (root.TryGetProperty("cosign", out var cProp) && cProp.ValueKind == JsonValueKind.Object)
{
@@ -216,7 +260,7 @@ public sealed class PostgresVexProviderStore : RepositoryBase<ExcititorDataSourc
.Where(s => !string.IsNullOrWhiteSpace(s));
}
return new VexProviderTrust(weight, cosign, fingerprints);
return new VexProviderTrust(weight, cosign, fingerprints, vector, weights);
}
catch
{
@@ -224,6 +268,23 @@ public sealed class PostgresVexProviderStore : RepositoryBase<ExcititorDataSourc
}
}
private static bool TryGetDouble(JsonElement element, string propertyName, out double value)
{
value = default;
if (!element.TryGetProperty(propertyName, out var prop) || !prop.TryGetDouble(out var parsed))
{
return false;
}
if (double.IsNaN(parsed) || double.IsInfinity(parsed) || parsed < 0 || parsed > 1)
{
return false;
}
value = parsed;
return true;
}
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
{
if (_initialized)

View File

@@ -0,0 +1,97 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Calibration;
public sealed class CalibrationComparisonEngineTests
{
[Fact]
public async Task CompareAsync_ComputesAccuracyAndBias()
{
var dataset = new FakeDatasetProvider();
var engine = new CalibrationComparisonEngine(dataset);
var results = await engine.CompareAsync("tenant-a",
DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
results.Should().HaveCount(2);
results[0].SourceId.Should().Be("source-a");
results[0].FalseNegatives.Should().Be(2);
results[0].DetectedBias.Should().Be(CalibrationBias.OptimisticBias);
results[1].SourceId.Should().Be("source-b");
results[1].FalsePositives.Should().Be(2);
results[1].DetectedBias.Should().Be(CalibrationBias.PessimisticBias);
}
private sealed class FakeDatasetProvider : ICalibrationDatasetProvider
{
public Task<IReadOnlyList<CalibrationObservation>> GetObservationsAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default)
{
var observations = new List<CalibrationObservation>
{
new()
{
SourceId = "source-a",
VulnerabilityId = "CVE-1",
AssetDigest = "sha256:asset1",
Status = VexClaimStatus.NotAffected,
},
new()
{
SourceId = "source-a",
VulnerabilityId = "CVE-2",
AssetDigest = "sha256:asset2",
Status = VexClaimStatus.NotAffected,
},
new()
{
SourceId = "source-a",
VulnerabilityId = "CVE-3",
AssetDigest = "sha256:asset3",
Status = VexClaimStatus.Affected,
},
new()
{
SourceId = "source-b",
VulnerabilityId = "CVE-4",
AssetDigest = "sha256:asset4",
Status = VexClaimStatus.Affected,
},
new()
{
SourceId = "source-b",
VulnerabilityId = "CVE-5",
AssetDigest = "sha256:asset5",
Status = VexClaimStatus.Affected,
},
};
return Task.FromResult<IReadOnlyList<CalibrationObservation>>(observations);
}
public Task<IReadOnlyList<CalibrationTruth>> GetTruthAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default)
{
var truths = new List<CalibrationTruth>
{
new() { VulnerabilityId = "CVE-1", AssetDigest = "sha256:asset1", Status = VexClaimStatus.Affected },
new() { VulnerabilityId = "CVE-2", AssetDigest = "sha256:asset2", Status = VexClaimStatus.Affected },
new() { VulnerabilityId = "CVE-3", AssetDigest = "sha256:asset3", Status = VexClaimStatus.Affected },
new() { VulnerabilityId = "CVE-4", AssetDigest = "sha256:asset4", Status = VexClaimStatus.NotAffected },
new() { VulnerabilityId = "CVE-5", AssetDigest = "sha256:asset5", Status = VexClaimStatus.NotAffected },
};
return Task.FromResult<IReadOnlyList<CalibrationTruth>>(truths);
}
}
}

View File

@@ -0,0 +1,33 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Calibration;
public sealed class DefaultTrustVectorsTests
{
[Fact]
public void DefaultTrustVectors_MatchSpecification()
{
DefaultTrustVectors.Vendor.Should().Be(new Core.TrustVector
{
Provenance = 0.90,
Coverage = 0.70,
Replayability = 0.60,
});
DefaultTrustVectors.Distro.Should().Be(new Core.TrustVector
{
Provenance = 0.80,
Coverage = 0.85,
Replayability = 0.60,
});
DefaultTrustVectors.Internal.Should().Be(new Core.TrustVector
{
Provenance = 0.85,
Coverage = 0.95,
Replayability = 0.90,
});
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Calibration;
public sealed class SourceClassificationServiceTests
{
[Fact]
public void Classification_UsesOverridesFirst()
{
var service = new SourceClassificationService();
service.RegisterOverride("vendor-*", VexProviderKind.Vendor);
var result = service.Classify("vendor-foo", "example.com", null, "csaf");
result.Kind.Should().Be(VexProviderKind.Vendor);
result.IsOverride.Should().BeTrue();
result.Confidence.Should().Be(1.0);
}
[Fact]
public void Classification_DetectsDistroDomains()
{
var service = new SourceClassificationService();
var result = service.Classify("ubuntu", "ubuntu.com", null, "csaf");
result.Kind.Should().Be(VexProviderKind.Distro);
result.Reason.Should().Contain("distro");
}
[Fact]
public void Classification_DetectsAttestations()
{
var service = new SourceClassificationService();
var result = service.Classify("sigstore", "example.org", "dsse", "oci_attestation");
result.Kind.Should().Be(VexProviderKind.Attestation);
}
[Fact]
public void Classification_FallsBackToHub()
{
var service = new SourceClassificationService();
var result = service.Classify("random", "unknown.example", null, "openvex");
result.Kind.Should().Be(VexProviderKind.Hub);
}
}

View File

@@ -0,0 +1,171 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Storage;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Calibration;
public sealed class TrustCalibrationServiceTests
{
[Fact]
public async Task RunEpochAsync_StoresManifestAndAdjustments()
{
var comparisonEngine = new FakeComparisonEngine();
var providerStore = new InMemoryProviderStore(new VexProvider(
"provider-a",
"Provider A",
VexProviderKind.Vendor,
Array.Empty<Uri>(),
VexProviderDiscovery.Empty,
new VexProviderTrust(1.0, cosign: null, vector: new Core.TrustVector
{
Provenance = 0.8,
Coverage = 0.7,
Replayability = 0.6,
}),
enabled: true));
var manifestStore = new InMemoryManifestStore();
var service = new TrustCalibrationService(
comparisonEngine,
new TrustVectorCalibrator { MomentumFactor = 0.0 },
providerStore,
manifestStore,
signer: new NullCalibrationManifestSigner(),
idGenerator: new FixedCalibrationIdGenerator("manifest-1"),
options: new TrustCalibrationOptions { EpochDuration = TimeSpan.FromDays(30) });
var manifest = await service.RunEpochAsync("tenant-a", DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
manifest.ManifestId.Should().Be("manifest-1");
manifest.Adjustments.Should().HaveCount(1);
(await manifestStore.GetLatestAsync("tenant-a")).Should().NotBeNull();
}
[Fact]
public async Task ApplyCalibrationAsync_UpdatesProviderVectors()
{
var comparisonEngine = new FakeComparisonEngine();
var providerStore = new InMemoryProviderStore(new VexProvider(
"provider-a",
"Provider A",
VexProviderKind.Vendor,
Array.Empty<Uri>(),
VexProviderDiscovery.Empty,
new VexProviderTrust(1.0, cosign: null, vector: new Core.TrustVector
{
Provenance = 0.9,
Coverage = 0.9,
Replayability = 0.9,
}),
enabled: true));
var manifestStore = new InMemoryManifestStore();
var service = new TrustCalibrationService(
comparisonEngine,
new TrustVectorCalibrator { MomentumFactor = 0.0 },
providerStore,
manifestStore,
signer: new NullCalibrationManifestSigner(),
idGenerator: new FixedCalibrationIdGenerator("manifest-2"));
var manifest = await service.RunEpochAsync("tenant-a", DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
await service.ApplyCalibrationAsync("tenant-a", manifest.ManifestId);
var updated = await providerStore.FindAsync("provider-a", CancellationToken.None);
updated!.Trust.Vector!.Provenance.Should().BeLessThan(0.9);
}
private sealed class FakeComparisonEngine : ICalibrationComparisonEngine
{
public Task<IReadOnlyList<ComparisonResult>> CompareAsync(
string tenant,
DateTimeOffset epochStart,
DateTimeOffset epochEnd,
CancellationToken ct = default)
{
IReadOnlyList<ComparisonResult> results = new[]
{
new ComparisonResult
{
SourceId = "provider-a",
TotalPredictions = 10,
CorrectPredictions = 5,
FalseNegatives = 2,
FalsePositives = 0,
Accuracy = 0.5,
ConfidenceInterval = 0.2,
DetectedBias = CalibrationBias.OptimisticBias,
}
};
return Task.FromResult(results);
}
}
private sealed class InMemoryManifestStore : ICalibrationManifestStore
{
private readonly List<CalibrationManifest> _manifests = new();
public Task StoreAsync(CalibrationManifest manifest, CancellationToken ct = default)
{
_manifests.Add(manifest);
return Task.CompletedTask;
}
public Task<CalibrationManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default)
{
var match = _manifests.LastOrDefault(m => m.Tenant == tenant && m.ManifestId == manifestId);
return Task.FromResult(match);
}
public Task<CalibrationManifest?> GetLatestAsync(string tenant, CancellationToken ct = default)
{
var match = _manifests
.Where(m => m.Tenant == tenant)
.OrderByDescending(m => m.EpochNumber)
.FirstOrDefault();
return Task.FromResult(match);
}
}
private sealed class InMemoryProviderStore : IVexProviderStore
{
private readonly Dictionary<string, VexProvider> _providers;
public InMemoryProviderStore(params VexProvider[] providers)
{
_providers = providers.ToDictionary(p => p.Id, StringComparer.Ordinal);
}
public ValueTask<VexProvider?> FindAsync(string id, CancellationToken cancellationToken)
{
_providers.TryGetValue(id, out var provider);
return ValueTask.FromResult(provider);
}
public ValueTask SaveAsync(VexProvider provider, CancellationToken cancellationToken)
{
_providers[provider.Id] = provider;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexProvider>> ListAsync(CancellationToken cancellationToken)
{
IReadOnlyCollection<VexProvider> values = _providers.Values.ToArray();
return ValueTask.FromResult(values);
}
}
private sealed class FixedCalibrationIdGenerator : ICalibrationIdGenerator
{
private readonly string _value;
public FixedCalibrationIdGenerator(string value)
{
_value = value;
}
public string NextId() => _value;
}
}

View File

@@ -0,0 +1,105 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Calibration;
public sealed class TrustVectorCalibratorTests
{
[Fact]
public void Calibrate_NoChangeWhenAccuracyHigh()
{
var calibrator = new TrustVectorCalibrator();
var current = new Core.TrustVector
{
Provenance = 0.8,
Coverage = 0.8,
Replayability = 0.8,
};
var comparison = new ComparisonResult
{
SourceId = "source",
TotalPredictions = 100,
CorrectPredictions = 98,
FalseNegatives = 1,
FalsePositives = 1,
Accuracy = 0.98,
ConfidenceInterval = 0.01,
DetectedBias = CalibrationBias.None,
};
calibrator.Calibrate(current, comparison, comparison.DetectedBias).Should().Be(current);
}
[Fact]
public void Calibrate_AdjustsWithinBounds()
{
var calibrator = new TrustVectorCalibrator
{
LearningRate = 0.02,
MaxAdjustmentPerEpoch = 0.05,
MinValue = 0.1,
MaxValue = 1.0,
MomentumFactor = 0.0,
};
var current = new Core.TrustVector
{
Provenance = 0.2,
Coverage = 0.5,
Replayability = 0.7,
};
var comparison = new ComparisonResult
{
SourceId = "source",
TotalPredictions = 10,
CorrectPredictions = 5,
FalseNegatives = 2,
FalsePositives = 0,
Accuracy = 0.5,
ConfidenceInterval = 0.2,
DetectedBias = CalibrationBias.OptimisticBias,
};
var updated = calibrator.Calibrate(current, comparison, comparison.DetectedBias);
updated.Provenance.Should().BeLessThan(current.Provenance);
updated.Coverage.Should().Be(current.Coverage);
updated.Replayability.Should().Be(current.Replayability);
updated.Provenance.Should().BeGreaterThanOrEqualTo(0.1);
}
[Fact]
public void Calibrate_IsDeterministic()
{
var comparison = new ComparisonResult
{
SourceId = "source",
TotalPredictions = 10,
CorrectPredictions = 5,
FalseNegatives = 2,
FalsePositives = 0,
Accuracy = 0.5,
ConfidenceInterval = 0.2,
DetectedBias = CalibrationBias.OptimisticBias,
};
var current = new Core.TrustVector
{
Provenance = 0.7,
Coverage = 0.6,
Replayability = 0.5,
};
var expected = new TrustVectorCalibrator { MomentumFactor = 0.0 }
.Calibrate(current, comparison, comparison.DetectedBias);
for (var i = 0; i < 1000; i++)
{
var result = new TrustVectorCalibrator { MomentumFactor = 0.0 }
.Calibrate(current, comparison, comparison.DetectedBias);
result.Should().Be(expected);
}
}
}

View File

@@ -0,0 +1,121 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
namespace StellaOps.Excititor.Core.Tests.Lattice;
public sealed class PolicyLatticeAdapterTests
{
private static readonly DateTimeOffset Older = DateTimeOffset.Parse("2025-10-01T00:00:00Z");
private static readonly DateTimeOffset Newer = DateTimeOffset.Parse("2025-10-02T00:00:00Z");
[Theory]
[InlineData(VexClaimStatus.Affected, VexClaimStatus.NotAffected, VexClaimStatus.Affected)]
[InlineData(VexClaimStatus.Fixed, VexClaimStatus.NotAffected, VexClaimStatus.Affected)]
[InlineData(VexClaimStatus.UnderInvestigation, VexClaimStatus.Fixed, VexClaimStatus.Fixed)]
public void Join_ReturnsExpectedK4Result(
VexClaimStatus left,
VexClaimStatus right,
VexClaimStatus expected)
{
var adapter = CreateAdapter();
var leftStmt = CreateClaim(left, "source1", Older, Older);
var rightStmt = CreateClaim(right, "source2", Older, Older);
var result = adapter.Join(leftStmt, rightStmt);
result.ResultStatus.Should().Be(expected);
}
[Fact]
public void ResolveConflict_TrustWeightWins()
{
var adapter = CreateAdapter();
var vendor = CreateClaim(VexClaimStatus.NotAffected, "vendor", Older, Older);
var community = CreateClaim(VexClaimStatus.Affected, "community", Older, Older);
var result = adapter.ResolveConflict(vendor, community);
result.Winner.Should().Be(vendor);
result.Reason.Should().Be(ConflictResolutionReason.TrustWeight);
}
[Fact]
public void ResolveConflict_EqualTrust_UsesLatticePosition()
{
var registry = CreateRegistry();
registry.RegisterWeight("vendor-a", 0.9m);
registry.RegisterWeight("vendor-b", 0.9m);
var adapter = CreateAdapter(registry);
var affected = CreateClaim(VexClaimStatus.Affected, "vendor-a", Older, Older);
var notAffected = CreateClaim(VexClaimStatus.NotAffected, "vendor-b", Older, Older);
var result = adapter.ResolveConflict(affected, notAffected);
result.Winner.Status.Should().Be(VexClaimStatus.Affected);
result.Reason.Should().Be(ConflictResolutionReason.LatticePosition);
}
[Fact]
public void ResolveConflict_EqualTrustAndStatus_UsesFreshness()
{
var adapter = CreateAdapter();
var older = CreateClaim(VexClaimStatus.Affected, "vendor", Older, Older);
var newer = CreateClaim(VexClaimStatus.Affected, "vendor", Older, Newer);
var result = adapter.ResolveConflict(older, newer);
result.Winner.Should().Be(newer);
result.Reason.Should().Be(ConflictResolutionReason.Freshness);
}
[Fact]
public void ResolveConflict_GeneratesTrace()
{
var adapter = CreateAdapter();
var left = CreateClaim(VexClaimStatus.Affected, "vendor", Older, Older);
var right = CreateClaim(VexClaimStatus.NotAffected, "distro", Older, Older);
var result = adapter.ResolveConflict(left, right);
result.Trace.Should().NotBeNull();
result.Trace.LeftSource.Should().Be("vendor");
result.Trace.RightSource.Should().Be("distro");
result.Trace.Explanation.Should().NotBeNullOrEmpty();
}
private static PolicyLatticeAdapter CreateAdapter(ITrustWeightRegistry? registry = null)
{
registry ??= CreateRegistry();
return new PolicyLatticeAdapter(registry, NullLogger<PolicyLatticeAdapter>.Instance);
}
private static TrustWeightRegistry CreateRegistry()
{
return new TrustWeightRegistry(
Options.Create(new TrustWeightOptions()),
NullLogger<TrustWeightRegistry>.Instance);
}
private static VexClaim CreateClaim(
VexClaimStatus status,
string providerId,
DateTimeOffset firstSeen,
DateTimeOffset lastSeen)
{
return new VexClaim(
"CVE-2025-1234",
providerId,
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
status,
new VexClaimDocument(
VexDocumentFormat.OpenVex,
$"sha256:{providerId}",
new Uri($"https://example.com/{providerId}")),
firstSeen,
lastSeen);
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core.Lattice;
namespace StellaOps.Excititor.Core.Tests.Lattice;
public sealed class TrustWeightRegistryTests
{
[Fact]
public void GetWeight_KnownSource_ReturnsConfiguredWeight()
{
var registry = CreateRegistry();
registry.GetWeight("vendor").Should().Be(1.0m);
}
[Fact]
public void GetWeight_UnknownSource_ReturnsFallback()
{
var registry = CreateRegistry();
registry.GetWeight("mystery").Should().Be(0.3m);
}
[Fact]
public void GetWeight_CategoryMatch_ReturnsCategoryWeight()
{
var registry = CreateRegistry();
registry.GetWeight("red-hat-vendor").Should().Be(1.0m);
}
[Fact]
public void RegisterWeight_ClampsRange()
{
var registry = CreateRegistry();
registry.RegisterWeight("custom", 2.5m);
registry.RegisterWeight("low", -1.0m);
registry.GetWeight("custom").Should().Be(1.0m);
registry.GetWeight("low").Should().Be(0.0m);
}
private static TrustWeightRegistry CreateRegistry()
{
return new TrustWeightRegistry(
Options.Create(new TrustWeightOptions()),
NullLogger<TrustWeightRegistry>.Instance);
}
}

View File

@@ -0,0 +1,51 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.TrustVectorTests;
public sealed class ClaimScoreCalculatorTests
{
[Fact]
public void ClaimScoreCalculator_ComputesScore()
{
var vector = new Core.TrustVector
{
Provenance = 0.9,
Coverage = 0.8,
Replayability = 0.7,
};
var weights = new TrustWeights { WP = 0.45, WC = 0.35, WR = 0.20 };
var calculator = new ClaimScoreCalculator(new FreshnessCalculator { HalfLifeDays = 90, Floor = 0.35 });
var issuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var cutoff = issuedAt.AddDays(45);
var result = calculator.Compute(vector, weights, ClaimStrength.ConfigWithEvidence, issuedAt, cutoff);
result.BaseTrust.Should().BeApproximately(0.82, 0.0001);
result.StrengthMultiplier.Should().Be(0.8);
result.FreshnessMultiplier.Should().BeGreaterThan(0.7);
result.Score.Should().BeApproximately(result.BaseTrust * result.StrengthMultiplier * result.FreshnessMultiplier, 0.0001);
}
[Fact]
public void ClaimScoreCalculator_IsDeterministic()
{
var vector = new Core.TrustVector
{
Provenance = 0.7,
Coverage = 0.6,
Replayability = 0.5,
};
var weights = new TrustWeights { WP = 0.4, WC = 0.4, WR = 0.2 };
var calculator = new ClaimScoreCalculator(new FreshnessCalculator { HalfLifeDays = 60, Floor = 0.4 });
var issuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var cutoff = issuedAt.AddDays(30);
var first = calculator.Compute(vector, weights, ClaimStrength.VendorBlanket, issuedAt, cutoff).Score;
for (var i = 0; i < 1000; i++)
{
calculator.Compute(vector, weights, ClaimStrength.VendorBlanket, issuedAt, cutoff).Score.Should().Be(first);
}
}
}

View File

@@ -0,0 +1,38 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.TrustVectorTests;
public sealed class FreshnessCalculatorTests
{
[Fact]
public void FreshnessCalculator_ReturnsFullForFutureDates()
{
var calculator = new FreshnessCalculator();
var issuedAt = DateTimeOffset.Parse("2025-12-20T00:00:00Z");
var cutoff = DateTimeOffset.Parse("2025-12-10T00:00:00Z");
calculator.Compute(issuedAt, cutoff).Should().Be(1.0);
}
[Fact]
public void FreshnessCalculator_DecaysWithHalfLife()
{
var calculator = new FreshnessCalculator { HalfLifeDays = 90, Floor = 0.35 };
var issuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var cutoff = issuedAt.AddDays(90);
calculator.Compute(issuedAt, cutoff).Should().BeApproximately(0.5, 0.0001);
}
[Fact]
public void FreshnessCalculator_RespectsFloor()
{
var calculator = new FreshnessCalculator { HalfLifeDays = 10, Floor = 0.35 };
var issuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var cutoff = issuedAt.AddDays(365);
calculator.Compute(issuedAt, cutoff).Should().Be(0.35);
}
}

View File

@@ -0,0 +1,58 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.TrustVectorTests;
public sealed class ScorersTests
{
[Fact]
public void ProvenanceScorer_ReturnsExpectedTiers()
{
var scorer = new ProvenanceScorer();
scorer.Score(new ProvenanceSignal
{
DsseSigned = true,
HasTransparencyLog = true,
KeyAllowListed = true,
}).Should().Be(ProvenanceScores.FullyAttested);
scorer.Score(new ProvenanceSignal
{
DsseSigned = true,
PublicKeyKnown = true,
}).Should().Be(ProvenanceScores.SignedNoLog);
scorer.Score(new ProvenanceSignal
{
AuthenticatedUnsigned = true,
}).Should().Be(ProvenanceScores.AuthenticatedUnsigned);
scorer.Score(new ProvenanceSignal
{
ManualImport = true,
}).Should().Be(ProvenanceScores.ManualImport);
}
[Fact]
public void CoverageScorer_ReturnsExpectedTiers()
{
var scorer = new CoverageScorer();
scorer.Score(new CoverageSignal { Level = CoverageLevel.ExactWithContext }).Should().Be(1.00);
scorer.Score(new CoverageSignal { Level = CoverageLevel.VersionRangePartialContext }).Should().Be(0.75);
scorer.Score(new CoverageSignal { Level = CoverageLevel.ProductLevel }).Should().Be(0.50);
scorer.Score(new CoverageSignal { Level = CoverageLevel.FamilyHeuristic }).Should().Be(0.25);
}
[Fact]
public void ReplayabilityScorer_ReturnsExpectedTiers()
{
var scorer = new ReplayabilityScorer();
scorer.Score(new ReplayabilitySignal { Level = ReplayabilityLevel.FullyPinned }).Should().Be(1.00);
scorer.Score(new ReplayabilitySignal { Level = ReplayabilityLevel.MostlyPinned }).Should().Be(0.60);
scorer.Score(new ReplayabilitySignal { Level = ReplayabilityLevel.Ephemeral }).Should().Be(0.20);
}
}

View File

@@ -0,0 +1,48 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.TrustVectorTests;
public sealed class TrustVectorTests
{
[Fact]
public void TrustVector_ValidatesRange()
{
Action action = () => _ = new Core.TrustVector
{
Provenance = -0.1,
Coverage = 0.5,
Replayability = 0.5,
};
action.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void TrustVector_ComputesBaseTrust()
{
var vector = new Core.TrustVector
{
Provenance = 0.9,
Coverage = 0.6,
Replayability = 0.3,
};
var weights = new TrustWeights { WP = 0.5, WC = 0.3, WR = 0.2 };
var baseTrust = vector.ComputeBaseTrust(weights);
baseTrust.Should().BeApproximately(0.69, 0.0001);
}
[Fact]
public void TrustVector_FromLegacyWeight_MapsAllComponents()
{
var vector = Core.TrustVector.FromLegacyWeight(0.72);
vector.Provenance.Should().Be(0.72);
vector.Coverage.Should().Be(0.72);
vector.Replayability.Should().Be(0.72);
}
}

View File

@@ -0,0 +1,25 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.TrustVectorTests;
public sealed class TrustWeightsTests
{
[Fact]
public void TrustWeights_ValidatesRange()
{
Action action = () => _ = new TrustWeights { WP = 1.2 };
action.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void TrustWeights_DefaultsMatchSpec()
{
var weights = new TrustWeights();
weights.WP.Should().Be(0.45);
weights.WC.Should().Be(0.35);
weights.WR.Should().Be(0.20);
}
}

View File

@@ -0,0 +1,43 @@
using FluentAssertions;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.TrustVectorTests;
public sealed class VexProviderTrustTests
{
[Fact]
public void EffectiveVector_UsesLegacyWeightWhenVectorMissing()
{
var trust = new VexProviderTrust(0.72, cosign: null);
trust.EffectiveVector.Provenance.Should().Be(0.72);
trust.EffectiveVector.Coverage.Should().Be(0.72);
trust.EffectiveVector.Replayability.Should().Be(0.72);
}
[Fact]
public void EffectiveWeights_FallsBackToDefaults()
{
var trust = new VexProviderTrust(0.9, cosign: null);
trust.EffectiveWeights.WP.Should().Be(0.45);
trust.EffectiveWeights.WC.Should().Be(0.35);
trust.EffectiveWeights.WR.Should().Be(0.20);
}
[Fact]
public void EffectiveVector_UsesConfiguredVector()
{
var vector = new Core.TrustVector
{
Provenance = 0.6,
Coverage = 0.5,
Replayability = 0.4,
};
var trust = new VexProviderTrust(0.9, cosign: null, vector: vector);
trust.EffectiveVector.Should().Be(vector);
}
}

View File

@@ -1,227 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Excititor.Core;
using Xunit;
namespace StellaOps.Excititor.Core.Tests;
public sealed class VexConsensusResolverTests
{
private static readonly VexProduct DemoProduct = new(
key: "pkg:demo/app",
name: "Demo App",
version: "1.0.0",
purl: "pkg:demo/app@1.0.0",
cpe: "cpe:2.3:a:demo:app:1.0.0");
[Fact]
public void Resolve_SingleAcceptedClaim_SelectsStatus()
{
var provider = CreateProvider("redhat", VexProviderKind.Vendor);
var claim = CreateClaim(
"CVE-2025-0001",
provider.Id,
VexClaimStatus.Affected,
justification: null);
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
var result = resolver.Resolve(new VexConsensusRequest(
claim.VulnerabilityId,
DemoProduct,
new[] { claim },
new Dictionary<string, VexProvider> { [provider.Id] = provider },
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.Affected, result.Consensus.Status);
Assert.Equal("baseline/v1", result.Consensus.PolicyVersion);
Assert.Single(result.Consensus.Sources);
Assert.Empty(result.Consensus.Conflicts);
Assert.NotNull(result.Consensus.Summary);
Assert.Contains("affected", result.Consensus.Summary!, StringComparison.Ordinal);
var decision = Assert.Single(result.DecisionLog);
Assert.True(decision.Included);
Assert.Equal(provider.Id, decision.ProviderId);
Assert.Null(decision.Reason);
}
[Fact]
public void Resolve_NotAffectedWithoutJustification_IsRejected()
{
var provider = CreateProvider("cisco", VexProviderKind.Vendor);
var claim = CreateClaim(
"CVE-2025-0002",
provider.Id,
VexClaimStatus.NotAffected,
justification: null);
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
var result = resolver.Resolve(new VexConsensusRequest(
claim.VulnerabilityId,
DemoProduct,
new[] { claim },
new Dictionary<string, VexProvider> { [provider.Id] = provider },
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.UnderInvestigation, result.Consensus.Status);
Assert.Empty(result.Consensus.Sources);
var conflict = Assert.Single(result.Consensus.Conflicts);
Assert.Equal("missing_justification", conflict.Reason);
var decision = Assert.Single(result.DecisionLog);
Assert.False(decision.Included);
Assert.Equal("missing_justification", decision.Reason);
}
[Fact]
public void Resolve_MajorityWeightWins_WithConflictingSources()
{
var vendor = CreateProvider("redhat", VexProviderKind.Vendor);
var distro = CreateProvider("fedora", VexProviderKind.Distro);
var claims = new[]
{
CreateClaim(
"CVE-2025-0003",
vendor.Id,
VexClaimStatus.Affected,
detail: "Vendor advisory",
documentDigest: "sha256:vendor"),
CreateClaim(
"CVE-2025-0003",
distro.Id,
VexClaimStatus.NotAffected,
justification: VexJustification.ComponentNotPresent,
detail: "Distro package not shipped",
documentDigest: "sha256:distro"),
};
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy());
var result = resolver.Resolve(new VexConsensusRequest(
"CVE-2025-0003",
DemoProduct,
claims,
new Dictionary<string, VexProvider>
{
[vendor.Id] = vendor,
[distro.Id] = distro,
},
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.Affected, result.Consensus.Status);
Assert.Equal(2, result.Consensus.Sources.Length);
Assert.Equal(1.0, result.Consensus.Sources.First(s => s.ProviderId == vendor.Id).Weight);
Assert.Contains(result.Consensus.Conflicts, c => c.ProviderId == distro.Id && c.Reason == "status_conflict");
Assert.NotNull(result.Consensus.Summary);
Assert.Contains("affected", result.Consensus.Summary!, StringComparison.Ordinal);
}
[Fact]
public void Resolve_TieFallsBackToUnderInvestigation()
{
var hub = CreateProvider("hub", VexProviderKind.Hub);
var platform = CreateProvider("platform", VexProviderKind.Platform);
var claims = new[]
{
CreateClaim(
"CVE-2025-0004",
hub.Id,
VexClaimStatus.Affected,
detail: "Hub escalation",
documentDigest: "sha256:hub"),
CreateClaim(
"CVE-2025-0004",
platform.Id,
VexClaimStatus.NotAffected,
justification: VexJustification.ProtectedByMitigatingControl,
detail: "Runtime mitigations",
documentDigest: "sha256:platform"),
};
var resolver = new VexConsensusResolver(new BaselineVexConsensusPolicy(
new VexConsensusPolicyOptions(
hubWeight: 0.5,
platformWeight: 0.5)));
var result = resolver.Resolve(new VexConsensusRequest(
"CVE-2025-0004",
DemoProduct,
claims,
new Dictionary<string, VexProvider>
{
[hub.Id] = hub,
[platform.Id] = platform,
},
DateTimeOffset.Parse("2025-10-15T12:00:00Z")));
Assert.Equal(VexConsensusStatus.UnderInvestigation, result.Consensus.Status);
Assert.Equal(2, result.Consensus.Conflicts.Length);
Assert.NotNull(result.Consensus.Summary);
Assert.Contains("No majority consensus", result.Consensus.Summary!, StringComparison.Ordinal);
}
[Fact]
public void Resolve_RespectsRaisedWeightCeiling()
{
var provider = CreateProvider("vendor", VexProviderKind.Vendor);
var claim = CreateClaim(
"CVE-2025-0100",
provider.Id,
VexClaimStatus.Affected,
documentDigest: "sha256:vendor");
var policy = new BaselineVexConsensusPolicy(new VexConsensusPolicyOptions(
vendorWeight: 1.4,
weightCeiling: 2.0));
var resolver = new VexConsensusResolver(policy);
var result = resolver.Resolve(new VexConsensusRequest(
claim.VulnerabilityId,
DemoProduct,
new[] { claim },
new Dictionary<string, VexProvider> { [provider.Id] = provider },
DateTimeOffset.Parse("2025-10-15T12:00:00Z"),
WeightCeiling: 2.0));
var source = Assert.Single(result.Consensus.Sources);
Assert.Equal(1.4, source.Weight);
}
private static VexProvider CreateProvider(string id, VexProviderKind kind)
=> new(
id,
displayName: id.ToUpperInvariant(),
kind,
baseUris: Array.Empty<Uri>(),
trust: new VexProviderTrust(weight: 1.0, cosign: null));
private static VexClaim CreateClaim(
string vulnerabilityId,
string providerId,
VexClaimStatus status,
VexJustification? justification = null,
string? detail = null,
string? documentDigest = null)
=> new(
vulnerabilityId,
providerId,
DemoProduct,
status,
new VexClaimDocument(
VexDocumentFormat.Csaf,
documentDigest ?? $"sha256:{providerId}",
new Uri($"https://example.org/{providerId}/{vulnerabilityId}.json"),
"1"),
firstSeen: DateTimeOffset.Parse("2025-10-10T12:00:00Z"),
lastSeen: DateTimeOffset.Parse("2025-10-11T12:00:00Z"),
justification,
detail,
confidence: null,
additionalMetadata: ImmutableDictionary<string, string>.Empty);
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
@@ -20,7 +21,13 @@ public sealed class CycloneDxExporterTests
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/cyclonedx/1")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Issue resolved in 1.2.3"));
detail: "Issue resolved in 1.2.3",
signals: new VexSignalSnapshot(
new VexSeveritySignal(
scheme: "cvss-4.0",
score: 9.3,
label: "critical",
vector: "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H"))));
var request = new VexExportRequest(
VexQuery.Empty,
@@ -37,9 +44,25 @@ public sealed class CycloneDxExporterTests
var root = document.RootElement;
root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
root.GetProperty("specVersion").GetString().Should().Be("1.7");
root.GetProperty("components").EnumerateArray().Should().HaveCount(1);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(1);
var vulnerability = root.GetProperty("vulnerabilities").EnumerateArray().Single();
var rating = vulnerability.GetProperty("ratings").EnumerateArray().Single();
rating.GetProperty("method").GetString().Should().Be("CVSSv4");
rating.GetProperty("score").GetDouble().Should().Be(9.3);
rating.GetProperty("severity").GetString().Should().Be("critical");
rating.GetProperty("vector").GetString().Should().Be("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H");
var affect = vulnerability.GetProperty("affects").EnumerateArray().Single();
var affectedVersion = affect.GetProperty("versions").EnumerateArray().Single();
affectedVersion.GetProperty("version").GetString().Should().Be("1.2.3");
var source = vulnerability.GetProperty("source");
source.GetProperty("name").GetString().Should().Be("vendor:demo");
source.GetProperty("url").GetString().Should().Be("pkg:demo/component@1.2.3");
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");

View File

@@ -90,4 +90,52 @@ public sealed class CycloneDxNormalizerTests
investigating.Product.Name.Should().Be("pkg:npm/missing/component@2.0.0");
investigating.AdditionalMetadata.Should().ContainKey("cyclonedx.specVersion");
}
[Fact]
public async Task NormalizeAsync_NormalizesSpecVersion()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.7.0",
"metadata": {
"timestamp": "2025-10-15T12:00:00Z"
},
"components": [
{
"bom-ref": "pkg:npm/acme/lib@2.1.0",
"name": "acme-lib",
"version": "2.1.0",
"purl": "pkg:npm/acme/lib@2.1.0"
}
],
"vulnerabilities": [
{
"id": "CVE-2025-2000",
"analysis": { "state": "affected" },
"affects": [
{ "ref": "pkg:npm/acme/lib@2.1.0" }
]
}
]
}
""";
var rawDocument = new VexRawDocument(
"excititor:cyclonedx",
VexDocumentFormat.CycloneDx,
new Uri("https://example.org/vex-17.json"),
new DateTimeOffset(2025, 10, 16, 0, 0, 0, TimeSpan.Zero),
"sha256:dummydigest",
Encoding.UTF8.GetBytes(json),
ImmutableDictionary<string, string>.Empty);
var provider = new VexProvider("excititor:cyclonedx", "CycloneDX Provider", VexProviderKind.Vendor);
var normalizer = new CycloneDxNormalizer(NullLogger<CycloneDxNormalizer>.Instance);
var batch = await normalizer.NormalizeAsync(rawDocument, provider, CancellationToken.None);
batch.Claims.Should().HaveCount(1);
batch.Claims[0].AdditionalMetadata["cyclonedx.specVersion"].Should().Be("1.7");
}
}

View File

@@ -1,15 +1,33 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexStatementMergerTests
{
private static OpenVexStatementMerger CreateMerger()
{
var registry = new TrustWeightRegistry(
Options.Create(new TrustWeightOptions()),
NullLogger<TrustWeightRegistry>.Instance);
var lattice = new PolicyLatticeAdapter(
registry,
NullLogger<PolicyLatticeAdapter>.Instance);
return new OpenVexStatementMerger(
lattice,
NullLogger<OpenVexStatementMerger>.Instance);
}
[Fact]
public void Merge_DetectsConflictsAndSelectsCanonicalStatus()
{
var merger = CreateMerger();
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-4000",
@@ -29,11 +47,82 @@ public sealed class OpenVexStatementMergerTests
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = OpenVexStatementMerger.Merge(claims);
var result = merger.Merge(claims);
result.Statements.Should().HaveCount(1);
var statement = result.Statements[0];
statement.Status.Should().Be(VexClaimStatus.Affected);
result.Diagnostics.Should().ContainKey("openvex.status_conflict");
}
[Fact]
public void MergeClaims_NoStatements_ReturnsEmpty()
{
var merger = CreateMerger();
var result = merger.MergeClaims(Array.Empty<VexClaim>());
result.InputCount.Should().Be(0);
result.HadConflicts.Should().BeFalse();
}
[Fact]
public void MergeClaims_SingleStatement_ReturnsSingle()
{
var merger = CreateMerger();
var claim = CreateClaim(VexClaimStatus.NotAffected, "vendor");
var result = merger.MergeClaims(new[] { claim });
result.InputCount.Should().Be(1);
result.ResultStatement.Should().Be(claim);
result.HadConflicts.Should().BeFalse();
}
[Fact]
public void MergeClaims_ConflictingStatements_UsesLattice()
{
var merger = CreateMerger();
var vendor = CreateClaim(VexClaimStatus.NotAffected, "vendor");
var nvd = CreateClaim(VexClaimStatus.Affected, "nvd");
var result = merger.MergeClaims(new[] { vendor, nvd });
result.InputCount.Should().Be(2);
result.HadConflicts.Should().BeTrue();
result.Traces.Should().HaveCount(1);
result.ResultStatement.Status.Should().Be(VexClaimStatus.Affected);
}
[Fact]
public void MergeClaims_MultipleStatements_CollectsAllTraces()
{
var merger = CreateMerger();
var claims = new[]
{
CreateClaim(VexClaimStatus.Affected, "source1"),
CreateClaim(VexClaimStatus.NotAffected, "source2"),
CreateClaim(VexClaimStatus.Fixed, "source3"),
};
var result = merger.MergeClaims(claims);
result.InputCount.Should().Be(3);
result.Traces.Should().NotBeEmpty();
}
private static VexClaim CreateClaim(VexClaimStatus status, string providerId)
{
return new VexClaim(
"CVE-2025-4001",
providerId,
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
status,
new VexClaimDocument(
VexDocumentFormat.OpenVex,
$"sha256:{providerId}",
new Uri($"https://example.com/{providerId}")),
DateTimeOffset.Parse("2025-12-01T00:00:00Z"),
DateTimeOffset.Parse("2025-12-02T00:00:00Z"));
}
}

View File

@@ -136,7 +136,14 @@ public sealed class PostgresVexProviderStoreTests : IAsyncLifetime
{
// Arrange
var cosign = new VexCosignTrust("https://accounts.google.com", "@redhat.com$");
var trust = new VexProviderTrust(0.9, cosign, ["ABCD1234", "EFGH5678"]);
var vector = new TrustVector
{
Provenance = 0.9,
Coverage = 0.8,
Replayability = 0.7,
};
var weights = new TrustWeights { WP = 0.4, WC = 0.4, WR = 0.2 };
var trust = new VexProviderTrust(0.9, cosign, ["ABCD1234", "EFGH5678"], vector, weights);
var provider = new VexProvider(
"trusted-provider", "Trusted Provider", VexProviderKind.Attestation,
[], VexProviderDiscovery.Empty, trust, true);
@@ -152,5 +159,7 @@ public sealed class PostgresVexProviderStoreTests : IAsyncLifetime
fetched.Trust.Cosign!.Issuer.Should().Be("https://accounts.google.com");
fetched.Trust.Cosign.IdentityPattern.Should().Be("@redhat.com$");
fetched.Trust.PgpFingerprints.Should().HaveCount(2);
fetched.Trust.Vector.Should().Be(vector);
fetched.Trust.Weights.Should().Be(weights);
}
}

View File

@@ -232,9 +232,9 @@ public sealed class BatchIngestValidationTests : IDisposable
public static IReadOnlyList<VexFixture> CreateBatch()
=> new[]
{
CreateCycloneDxFixture("001", "sha256:batch-cdx-001", "CDX-BATCH-001", "not_affected"),
CreateCycloneDxFixture("002", "sha256:batch-cdx-002", "CDX-BATCH-002", "affected"),
CreateCycloneDxFixture("003", "sha256:batch-cdx-003", "CDX-BATCH-003", "fixed"),
CreateCycloneDxFixture("001", "sha256:batch-cdx-001", "CDX-BATCH-001", "not_affected", "1.7"),
CreateCycloneDxFixture("002", "sha256:batch-cdx-002", "CDX-BATCH-002", "affected", "1.7"),
CreateCycloneDxFixture("003", "sha256:batch-cdx-003", "CDX-BATCH-003", "fixed", "1.6"),
CreateCsafFixture("010", "sha256:batch-csaf-001", "CSAF-BATCH-001", "fixed"),
CreateCsafFixture("011", "sha256:batch-csaf-002", "CSAF-BATCH-002", "known_affected"),
CreateCsafFixture("012", "sha256:batch-csaf-003", "CSAF-BATCH-003", "known_not_affected"),
@@ -243,13 +243,13 @@ public sealed class BatchIngestValidationTests : IDisposable
CreateOpenVexFixture("022", "sha256:batch-openvex-003", "OVX-BATCH-003", "fixed")
};
private static VexFixture CreateCycloneDxFixture(string suffix, string digest, string upstreamId, string state)
private static VexFixture CreateCycloneDxFixture(string suffix, string digest, string upstreamId, string state, string specVersion)
{
var vulnerabilityId = $"CVE-2025-{suffix}";
var raw = $$"""
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"specVersion": "{{specVersion}}",
"version": 1,
"metadata": {
"timestamp": "2025-11-08T00:00:00Z",
@@ -277,7 +277,7 @@ public sealed class BatchIngestValidationTests : IDisposable
connector: "cdx-batch",
stream: "cyclonedx-vex",
format: "cyclonedx",
specVersion: "1.6",
specVersion: specVersion,
rawJson: raw,
digest: digest,
upstreamId: upstreamId,

View File

@@ -101,10 +101,10 @@ public sealed class VexGuardSchemaTests
},
"content": {
"format": "CycloneDX",
"spec_version": "1.6",
"spec_version": "1.7",
"raw": {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"specVersion": "1.7",
"serialNumber": "urn:uuid:12345678-1234-5678-9abc-def012345678",
"version": 1,
"metadata": {