Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. |
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user