Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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