old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// <copyright file="DeterminizationOptionsTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_012_POLICY_determinization_reanalysis_config (POLICY-CONFIG-005)
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class DeterminizationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_HaveExpectedValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var options = new DeterminizationOptions();
|
||||
|
||||
// Assert - base options
|
||||
Assert.Equal(14.0, options.ConfidenceHalfLifeDays);
|
||||
Assert.Equal(0.1, options.ConfidenceFloor);
|
||||
Assert.Equal(0.60, options.ManualReviewEntropyThreshold);
|
||||
Assert.Equal(0.40, options.RefreshEntropyThreshold);
|
||||
Assert.Equal(30.0, options.StaleObservationDays);
|
||||
Assert.False(options.EnableDetailedLogging);
|
||||
Assert.True(options.EnableAutoRefresh);
|
||||
Assert.Equal(3, options.MaxSignalQueryRetries);
|
||||
|
||||
// Assert - reanalysis triggers (POLICY-CONFIG-001)
|
||||
Assert.Equal(0.2, options.Triggers.EpssDeltaThreshold);
|
||||
Assert.True(options.Triggers.TriggerOnThresholdCrossing);
|
||||
Assert.True(options.Triggers.TriggerOnRekorEntry);
|
||||
Assert.True(options.Triggers.TriggerOnVexStatusChange);
|
||||
Assert.True(options.Triggers.TriggerOnRuntimeTelemetryChange);
|
||||
Assert.True(options.Triggers.TriggerOnPatchProofAdded);
|
||||
Assert.True(options.Triggers.TriggerOnDsseValidationChange);
|
||||
Assert.False(options.Triggers.TriggerOnToolVersionChange); // Disabled by default
|
||||
Assert.Equal(15, options.Triggers.MinReanalysisIntervalMinutes);
|
||||
Assert.Equal(10, options.Triggers.MaxReanalysesPerDayPerCve);
|
||||
|
||||
// Assert - conflict policy
|
||||
Assert.Equal(ConflictAction.RequireManualReview, options.ConflictPolicy.VexReachabilityConflictAction);
|
||||
Assert.Equal(ConflictAction.RequireManualReview, options.ConflictPolicy.StaticRuntimeConflictAction);
|
||||
Assert.Equal(ConflictAction.RequestVendorClarification, options.ConflictPolicy.VexStatusConflictAction);
|
||||
Assert.Equal(ConflictAction.RequireManualReview, options.ConflictPolicy.BackportStatusConflictAction);
|
||||
Assert.Equal(0.85, options.ConflictPolicy.EscalationSeverityThreshold);
|
||||
Assert.Equal(48, options.ConflictPolicy.ConflictTtlHours);
|
||||
Assert.False(options.ConflictPolicy.EnableAutoResolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentThresholds_Development_IsRelaxed()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DeterminizationOptions();
|
||||
|
||||
// Act
|
||||
var dev = options.EnvironmentThresholds.Development;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.60, dev.MaxPassEntropy);
|
||||
Assert.Equal(1, dev.MinEvidenceCount);
|
||||
Assert.False(dev.RequireDsseSigning);
|
||||
Assert.False(dev.RequireRekorTransparency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentThresholds_Staging_IsStandard()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DeterminizationOptions();
|
||||
|
||||
// Act
|
||||
var staging = options.EnvironmentThresholds.Staging;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.40, staging.MaxPassEntropy);
|
||||
Assert.Equal(2, staging.MinEvidenceCount);
|
||||
Assert.False(staging.RequireDsseSigning);
|
||||
Assert.False(staging.RequireRekorTransparency);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentThresholds_Production_IsStrict()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DeterminizationOptions();
|
||||
|
||||
// Act
|
||||
var prod = options.EnvironmentThresholds.Production;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.25, prod.MaxPassEntropy);
|
||||
Assert.Equal(3, prod.MinEvidenceCount);
|
||||
Assert.True(prod.RequireDsseSigning);
|
||||
Assert.True(prod.RequireRekorTransparency);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dev", 0.60)]
|
||||
[InlineData("DEV", 0.60)]
|
||||
[InlineData("development", 0.60)]
|
||||
[InlineData("DEVELOPMENT", 0.60)]
|
||||
[InlineData("stage", 0.40)]
|
||||
[InlineData("STAGE", 0.40)]
|
||||
[InlineData("staging", 0.40)]
|
||||
[InlineData("qa", 0.40)]
|
||||
[InlineData("QA", 0.40)]
|
||||
[InlineData("prod", 0.25)]
|
||||
[InlineData("PROD", 0.25)]
|
||||
[InlineData("production", 0.25)]
|
||||
[InlineData("PRODUCTION", 0.25)]
|
||||
[InlineData("unknown", 0.40)] // Falls back to staging
|
||||
[InlineData("", 0.40)]
|
||||
public void GetForEnvironment_ReturnsCorrectThresholds(string envName, double expectedMaxEntropy)
|
||||
{
|
||||
// Arrange
|
||||
var options = new DeterminizationOptions();
|
||||
|
||||
// Act
|
||||
var thresholds = options.EnvironmentThresholds.GetForEnvironment(envName);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedMaxEntropy, thresholds.MaxPassEntropy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindFromConfiguration_LoadsAllSections()
|
||||
{
|
||||
// Arrange
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Determinization:ConfidenceHalfLifeDays"] = "21",
|
||||
["Determinization:ConfidenceFloor"] = "0.15",
|
||||
["Determinization:ManualReviewEntropyThreshold"] = "0.65",
|
||||
["Determinization:Triggers:EpssDeltaThreshold"] = "0.3",
|
||||
["Determinization:Triggers:TriggerOnToolVersionChange"] = "true",
|
||||
["Determinization:Triggers:MinReanalysisIntervalMinutes"] = "30",
|
||||
["Determinization:ConflictPolicy:EscalationSeverityThreshold"] = "0.9",
|
||||
["Determinization:ConflictPolicy:ConflictTtlHours"] = "72",
|
||||
["Determinization:EnvironmentThresholds:Production:MaxPassEntropy"] = "0.20",
|
||||
["Determinization:EnvironmentThresholds:Production:MinEvidenceCount"] = "4"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions<DeterminizationOptions>()
|
||||
.Bind(config.GetSection(DeterminizationOptions.SectionName));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var options = provider.GetRequiredService<IOptions<DeterminizationOptions>>().Value;
|
||||
|
||||
// Assert - base options
|
||||
Assert.Equal(21.0, options.ConfidenceHalfLifeDays);
|
||||
Assert.Equal(0.15, options.ConfidenceFloor);
|
||||
Assert.Equal(0.65, options.ManualReviewEntropyThreshold);
|
||||
|
||||
// Assert - triggers
|
||||
Assert.Equal(0.3, options.Triggers.EpssDeltaThreshold);
|
||||
Assert.True(options.Triggers.TriggerOnToolVersionChange);
|
||||
Assert.Equal(30, options.Triggers.MinReanalysisIntervalMinutes);
|
||||
|
||||
// Assert - conflict policy
|
||||
Assert.Equal(0.9, options.ConflictPolicy.EscalationSeverityThreshold);
|
||||
Assert.Equal(72, options.ConflictPolicy.ConflictTtlHours);
|
||||
|
||||
// Assert - environment thresholds
|
||||
Assert.Equal(0.20, options.EnvironmentThresholds.Production.MaxPassEntropy);
|
||||
Assert.Equal(4, options.EnvironmentThresholds.Production.MinEvidenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConflictAction_AllValuesAreDefined()
|
||||
{
|
||||
// Arrange & Act
|
||||
var values = Enum.GetValues<ConflictAction>();
|
||||
|
||||
// Assert - ensure all expected values exist
|
||||
Assert.Contains(ConflictAction.LogAndContinue, values);
|
||||
Assert.Contains(ConflictAction.RequireManualReview, values);
|
||||
Assert.Contains(ConflictAction.RequestVendorClarification, values);
|
||||
Assert.Contains(ConflictAction.EscalateToCommittee, values);
|
||||
Assert.Contains(ConflictAction.BlockUntilResolved, values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentThresholdValues_Presets_AreDeterministic()
|
||||
{
|
||||
// Verify presets don't change between calls (important for determinism)
|
||||
var relaxed1 = EnvironmentThresholdValues.Relaxed;
|
||||
var relaxed2 = EnvironmentThresholdValues.Relaxed;
|
||||
|
||||
var standard1 = EnvironmentThresholdValues.Standard;
|
||||
var standard2 = EnvironmentThresholdValues.Standard;
|
||||
|
||||
var strict1 = EnvironmentThresholdValues.Strict;
|
||||
var strict2 = EnvironmentThresholdValues.Strict;
|
||||
|
||||
// Records should be equal by value
|
||||
Assert.Equal(relaxed1, relaxed2);
|
||||
Assert.Equal(standard1, standard2);
|
||||
Assert.Equal(strict1, strict2);
|
||||
|
||||
// Different presets should not be equal
|
||||
Assert.NotEqual(relaxed1, standard1);
|
||||
Assert.NotEqual(standard1, strict1);
|
||||
Assert.NotEqual(relaxed1, strict1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// <copyright file="ReanalysisFingerprintTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-006)
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Models;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class ReanalysisFingerprintTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public ReanalysisFingerprintTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithAllInputs_GeneratesDeterministicFingerprint()
|
||||
{
|
||||
// Arrange
|
||||
var builder1 = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.WithDsseBundleDigest("sha256:bundle123")
|
||||
.AddEvidenceDigest("sha256:evidence1")
|
||||
.AddEvidenceDigest("sha256:evidence2")
|
||||
.WithToolVersion("scanner", "1.0.0")
|
||||
.WithToolVersion("policy-engine", "2.0.0")
|
||||
.WithProductVersion("myapp@1.2.3")
|
||||
.WithPolicyConfigHash("sha256:config456")
|
||||
.WithSignalWeightsHash("sha256:weights789");
|
||||
|
||||
var builder2 = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.WithDsseBundleDigest("sha256:bundle123")
|
||||
.AddEvidenceDigest("sha256:evidence1")
|
||||
.AddEvidenceDigest("sha256:evidence2")
|
||||
.WithToolVersion("scanner", "1.0.0")
|
||||
.WithToolVersion("policy-engine", "2.0.0")
|
||||
.WithProductVersion("myapp@1.2.3")
|
||||
.WithPolicyConfigHash("sha256:config456")
|
||||
.WithSignalWeightsHash("sha256:weights789");
|
||||
|
||||
// Act
|
||||
var fingerprint1 = builder1.Build();
|
||||
var fingerprint2 = builder2.Build();
|
||||
|
||||
// Assert - same inputs produce same fingerprint ID
|
||||
Assert.Equal(fingerprint1.FingerprintId, fingerprint2.FingerprintId);
|
||||
Assert.StartsWith("sha256:", fingerprint1.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithDifferentInputs_GeneratesDifferentFingerprint()
|
||||
{
|
||||
// Arrange
|
||||
var builder1 = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.WithDsseBundleDigest("sha256:bundle123")
|
||||
.WithProductVersion("myapp@1.2.3");
|
||||
|
||||
var builder2 = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.WithDsseBundleDigest("sha256:bundle456") // Different
|
||||
.WithProductVersion("myapp@1.2.3");
|
||||
|
||||
// Act
|
||||
var fingerprint1 = builder1.Build();
|
||||
var fingerprint2 = builder2.Build();
|
||||
|
||||
// Assert - different inputs produce different fingerprint IDs
|
||||
Assert.NotEqual(fingerprint1.FingerprintId, fingerprint2.FingerprintId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_EvidenceDigests_AreSortedDeterministically()
|
||||
{
|
||||
// Arrange - add in random order
|
||||
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.AddEvidenceDigest("sha256:zzz")
|
||||
.AddEvidenceDigest("sha256:aaa")
|
||||
.AddEvidenceDigest("sha256:mmm");
|
||||
|
||||
// Act
|
||||
var fingerprint = builder.Build();
|
||||
|
||||
// Assert - sorted alphabetically
|
||||
Assert.Equal(3, fingerprint.EvidenceDigests.Count);
|
||||
Assert.Equal("sha256:aaa", fingerprint.EvidenceDigests[0]);
|
||||
Assert.Equal("sha256:mmm", fingerprint.EvidenceDigests[1]);
|
||||
Assert.Equal("sha256:zzz", fingerprint.EvidenceDigests[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ToolVersions_AreSortedDeterministically()
|
||||
{
|
||||
// Arrange - add in random order
|
||||
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.WithToolVersion("zebra-tool", "1.0.0")
|
||||
.WithToolVersion("alpha-tool", "2.0.0")
|
||||
.WithToolVersion("mike-tool", "3.0.0");
|
||||
|
||||
// Act
|
||||
var fingerprint = builder.Build();
|
||||
|
||||
// Assert - sorted by key
|
||||
var keys = fingerprint.ToolVersions.Keys.ToList();
|
||||
Assert.Equal("alpha-tool", keys[0]);
|
||||
Assert.Equal("mike-tool", keys[1]);
|
||||
Assert.Equal("zebra-tool", keys[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Triggers_AreSortedByEventTypeThenTime()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.AddTrigger("vex.changed", 1, "excititor")
|
||||
.AddTrigger("epss.updated", 1, "signals")
|
||||
.AddTrigger("runtime.detected", 1, "zastava");
|
||||
|
||||
// Act
|
||||
var fingerprint = builder.Build();
|
||||
|
||||
// Assert - sorted by event type
|
||||
Assert.Equal(3, fingerprint.Triggers.Count);
|
||||
Assert.Equal("epss.updated", fingerprint.Triggers[0].EventType);
|
||||
Assert.Equal("runtime.detected", fingerprint.Triggers[1].EventType);
|
||||
Assert.Equal("vex.changed", fingerprint.Triggers[2].EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DuplicateEvidenceDigests_AreDeduped()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.AddEvidenceDigest("sha256:abc")
|
||||
.AddEvidenceDigest("sha256:abc") // duplicate
|
||||
.AddEvidenceDigest("sha256:def");
|
||||
|
||||
// Act
|
||||
var fingerprint = builder.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, fingerprint.EvidenceDigests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NextActions_AreSortedAndDeduped()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReanalysisFingerprintBuilder(_timeProvider)
|
||||
.AddNextAction("rescan")
|
||||
.AddNextAction("notify")
|
||||
.AddNextAction("rescan") // duplicate
|
||||
.AddNextAction("adjudicate");
|
||||
|
||||
// Act
|
||||
var fingerprint = builder.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, fingerprint.NextActions.Count);
|
||||
Assert.Equal("adjudicate", fingerprint.NextActions[0]);
|
||||
Assert.Equal("notify", fingerprint.NextActions[1]);
|
||||
Assert.Equal("rescan", fingerprint.NextActions[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SetsComputedAtFromTimeProvider()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReanalysisFingerprintBuilder(_timeProvider);
|
||||
|
||||
// Act
|
||||
var fingerprint = builder.Build();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), fingerprint.ComputedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// <copyright file="ConflictDetectorTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_004_POLICY_unknowns_determinization_greyqueue (POLICY-UNK-006)
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public class ConflictDetectorTests
|
||||
{
|
||||
private readonly ConflictDetector _detector;
|
||||
private readonly DateTimeOffset _now = new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public ConflictDetectorTests()
|
||||
{
|
||||
_detector = new ConflictDetector(NullLogger<ConflictDetector>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_NoConflicts_ReturnsNoConflictResult()
|
||||
{
|
||||
// Arrange - consistent signals
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "affected",
|
||||
vexConfidence: 0.9,
|
||||
reachable: true,
|
||||
runtimeDetected: true,
|
||||
backportDetected: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasConflict);
|
||||
Assert.Empty(result.Conflicts);
|
||||
Assert.Equal(AdjudicationPath.None, result.SuggestedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_VexNotAffectedButReachable_DetectsConflict()
|
||||
{
|
||||
// Arrange - VEX says not_affected but reachability shows exploitable
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "not_affected",
|
||||
vexConfidence: 0.9,
|
||||
reachable: true,
|
||||
runtimeDetected: false,
|
||||
backportDetected: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasConflict);
|
||||
Assert.Single(result.Conflicts);
|
||||
Assert.Equal(ConflictType.VexReachabilityContradiction, result.Conflicts[0].Type);
|
||||
Assert.Equal(0.9, result.Conflicts[0].Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_StaticUnreachableButRuntimeDetected_DetectsConflict()
|
||||
{
|
||||
// Arrange - static analysis says unreachable but runtime shows execution
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "affected",
|
||||
vexConfidence: 0.9,
|
||||
reachable: false,
|
||||
reachabilityStatus: ReachabilityStatus.Unreachable,
|
||||
runtimeDetected: true,
|
||||
backportDetected: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasConflict);
|
||||
Assert.Contains(result.Conflicts, c => c.Type == ConflictType.StaticRuntimeContradiction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_MultipleVexWithLowConfidence_DetectsConflict()
|
||||
{
|
||||
// Arrange - multiple VEX sources with conflicting status (low confidence)
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "affected",
|
||||
vexConfidence: 0.5, // Low confidence indicates conflict
|
||||
vexStatementCount: 3,
|
||||
reachable: true,
|
||||
runtimeDetected: false,
|
||||
backportDetected: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasConflict);
|
||||
Assert.Contains(result.Conflicts, c => c.Type == ConflictType.VexStatusConflict);
|
||||
Assert.Equal(AdjudicationPath.VendorClarification, result.SuggestedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_BackportedButVexAffected_DetectsConflict()
|
||||
{
|
||||
// Arrange - backport evidence says fixed but VEX still says affected
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "affected",
|
||||
vexConfidence: 0.9,
|
||||
reachable: false,
|
||||
runtimeDetected: false,
|
||||
backportDetected: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasConflict);
|
||||
Assert.Contains(result.Conflicts, c => c.Type == ConflictType.BackportStatusConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_MultipleConflicts_ReturnsSeverityBasedPath()
|
||||
{
|
||||
// Arrange - multiple conflicts
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "not_affected",
|
||||
vexConfidence: 0.5,
|
||||
vexStatementCount: 2,
|
||||
reachable: true,
|
||||
runtimeDetected: false,
|
||||
backportDetected: false);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasConflict);
|
||||
Assert.True(result.Conflicts.Count >= 2);
|
||||
Assert.True(result.Severity >= 0.7);
|
||||
Assert.Equal(AdjudicationPath.SecurityTeamReview, result.SuggestedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ConflictsAreSortedByTypeThenSeverity()
|
||||
{
|
||||
// Arrange - multiple conflicts of different types
|
||||
var snapshot = CreateSnapshot(
|
||||
vexStatus: "not_affected",
|
||||
vexConfidence: 0.5,
|
||||
vexStatementCount: 2,
|
||||
reachable: true,
|
||||
runtimeDetected: false,
|
||||
backportDetected: true);
|
||||
|
||||
// Act
|
||||
var result = _detector.Detect(snapshot);
|
||||
|
||||
// Assert - conflicts are sorted by type then severity descending
|
||||
for (int i = 1; i < result.Conflicts.Count; i++)
|
||||
{
|
||||
var prev = result.Conflicts[i - 1];
|
||||
var curr = result.Conflicts[i];
|
||||
Assert.True(
|
||||
prev.Type < curr.Type ||
|
||||
(prev.Type == curr.Type && prev.Severity >= curr.Severity),
|
||||
"Conflicts should be sorted by type then severity descending");
|
||||
}
|
||||
}
|
||||
|
||||
private SignalSnapshot CreateSnapshot(
|
||||
string vexStatus,
|
||||
double vexConfidence,
|
||||
bool reachable,
|
||||
bool runtimeDetected,
|
||||
bool backportDetected,
|
||||
ReachabilityStatus? reachabilityStatus = null,
|
||||
int vexStatementCount = 1)
|
||||
{
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Cve = "CVE-2024-12345",
|
||||
Purl = "pkg:nuget/Test@1.0.0",
|
||||
SnapshotAt = _now,
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence
|
||||
{
|
||||
Probability = 0.5,
|
||||
Percentile = 0.7,
|
||||
Model = "epss-v3",
|
||||
FetchedAt = _now
|
||||
},
|
||||
_now),
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary
|
||||
{
|
||||
Status = vexStatus,
|
||||
Confidence = vexConfidence,
|
||||
StatementCount = vexStatementCount,
|
||||
ComputedAt = _now
|
||||
},
|
||||
_now),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence
|
||||
{
|
||||
Status = reachabilityStatus ?? (reachable ? ReachabilityStatus.Reachable : ReachabilityStatus.NotAnalyzed),
|
||||
AnalyzedAt = _now,
|
||||
Confidence = 0.95
|
||||
},
|
||||
_now),
|
||||
Runtime = SignalState<RuntimeEvidence>.Queried(
|
||||
new RuntimeEvidence
|
||||
{
|
||||
Detected = runtimeDetected,
|
||||
Source = "tracer",
|
||||
ObservationStart = _now.AddDays(-7),
|
||||
ObservationEnd = _now,
|
||||
Confidence = 0.9
|
||||
},
|
||||
_now),
|
||||
Backport = SignalState<BackportEvidence>.Queried(
|
||||
new BackportEvidence
|
||||
{
|
||||
Detected = backportDetected,
|
||||
Source = "vendor-advisory",
|
||||
DetectedAt = _now,
|
||||
Confidence = 0.85
|
||||
},
|
||||
_now),
|
||||
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
|
||||
Cvss = SignalState<CvssEvidence>.NotQueried()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CvssThresholdGateTests.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate
|
||||
// Tasks: CVSS-GATE-008, CVSS-GATE-009
|
||||
// Description: Unit tests for CVSS threshold gate.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CvssThresholdGateTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment = "production",
|
||||
string? cveId = null,
|
||||
Dictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Environment = environment,
|
||||
CveId = cveId,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Disabled_ReturnsPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions { Enabled = false };
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CveOnDenylist_ReturnsFail()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Denylist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("denylist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CveOnAllowlist_ReturnsPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-99999" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-99999"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("allowlist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DenylistTakesPrecedenceOverAllowlist()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" },
|
||||
Denylist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "CVE-2024-12345" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("denylist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoCvssScore_FailOnMissingFalse_ReturnsPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions { FailOnMissingCvss = false };
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("no_cvss_available", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoCvssScore_FailOnMissingTrue_ReturnsFail()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions { FailOnMissingCvss = true };
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("missing_cvss", result.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(6.9, true)] // Below threshold
|
||||
[InlineData(7.0, false)] // At threshold (fails - must be strictly below)
|
||||
[InlineData(7.1, false)] // Above threshold
|
||||
[InlineData(9.9, false)] // Well above threshold
|
||||
public async Task EvaluateAsync_V31Score_DefaultThreshold_ReturnsExpected(double score, bool expectedPass)
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7.9, true)] // Below staging threshold
|
||||
[InlineData(8.0, false)] // At staging threshold
|
||||
[InlineData(8.5, false)] // Above staging threshold
|
||||
public async Task EvaluateAsync_StagingEnvironment_UsesStagingThreshold(double score, bool expectedPass)
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0,
|
||||
["staging"] = 8.0,
|
||||
["development"] = 9.0
|
||||
},
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8.9, true)] // Below development threshold
|
||||
[InlineData(9.0, false)] // At development threshold
|
||||
[InlineData(9.5, false)] // Above development threshold
|
||||
public async Task EvaluateAsync_DevelopmentEnvironment_UsesDevelopmentThreshold(double score, bool expectedPass)
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0,
|
||||
["staging"] = 8.0,
|
||||
["development"] = 9.0
|
||||
},
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = score };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UnknownEnvironment_UsesDefaultThreshold()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 5.0,
|
||||
Thresholds = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = 7.0
|
||||
},
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo { CvssV31BaseScore = 5.5 };
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "qa", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("cvss_exceeds_threshold", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_V40Score_UsesV40WhenPreferred()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v4.0"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 8.0, // Would fail
|
||||
CvssV40BaseScore = 6.0 // Would pass
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("v4.0", result.Details["cvss_version"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_HighestPreference_UsesHigherScore()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.5,
|
||||
CvssVersionPreference = "highest"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 7.0, // Would pass alone
|
||||
CvssV40BaseScore = 8.0 // Would fail, and is higher
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal(8.0, (double)result.Details["cvss_score"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireAllVersionsPass_BothMustPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.5,
|
||||
CvssVersionPreference = "highest",
|
||||
RequireAllVersionsPass = true
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 7.0, // Would pass
|
||||
CvssV40BaseScore = 8.0 // Would fail
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireAllVersionsPass_BothPass()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 8.5,
|
||||
CvssVersionPreference = "highest",
|
||||
RequireAllVersionsPass = true
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 7.0,
|
||||
CvssV40BaseScore = 8.0
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MetadataFallback_ExtractsFromContext()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["cvss_v31_score"] = "6.5"
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-00001", metadata: metadata));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal(6.5, (double)result.Details["cvss_score"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_CaseInsensitiveCveMatch()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
Allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cve-2024-12345" }
|
||||
};
|
||||
var gate = new CvssThresholdGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(cveId: "CVE-2024-12345"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("allowlist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_IncludesAllDetailsInResult()
|
||||
{
|
||||
var options = new CvssThresholdGateOptions
|
||||
{
|
||||
DefaultThreshold = 7.0,
|
||||
CvssVersionPreference = "v3.1"
|
||||
};
|
||||
var lookup = (string? _) => new CvssScoreInfo
|
||||
{
|
||||
CvssV31BaseScore = 8.5,
|
||||
CvssV40BaseScore = 7.2
|
||||
};
|
||||
var gate = new CvssThresholdGate(options, lookup);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "production", cveId: "CVE-2024-00001"));
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal(7.0, (double)result.Details["threshold"]);
|
||||
Assert.Equal("production", result.Details["environment"]);
|
||||
Assert.Equal("v3.1", result.Details["cvss_version"]);
|
||||
Assert.Equal(8.5, (double)result.Details["cvss_score"]);
|
||||
Assert.Equal(8.5, (double)result.Details["cvss_v31_score"]);
|
||||
Assert.Equal(7.2, (double)result.Details["cvss_v40_score"]);
|
||||
Assert.Equal("CVE-2024-00001", result.Details["cve_id"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomPresenceGateTests.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate
|
||||
// Tasks: SBOM-GATE-009
|
||||
// Description: Unit tests for SBOM presence gate.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SbomPresenceGateTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment = "production",
|
||||
Dictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Environment = environment,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Disabled_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { Enabled = false };
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OptionalEnforcement_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["development"] = SbomEnforcementLevel.Optional
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("optional_enforcement", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingSbom_RequiredEnforcement_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("sbom_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingSbom_RecommendedEnforcement_ReturnsPassWithWarning()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["staging"] = SbomEnforcementLevel.Recommended
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("sbom_missing_recommended", result.Reason);
|
||||
Assert.Contains("warning", result.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ValidSbom_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 10,
|
||||
HasPrimaryComponent = true,
|
||||
SchemaValid = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("sbom_valid", result.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("spdx-2.2")]
|
||||
[InlineData("spdx-2.3")]
|
||||
[InlineData("spdx-3.0.1")]
|
||||
[InlineData("cyclonedx-1.4")]
|
||||
[InlineData("cyclonedx-1.5")]
|
||||
[InlineData("cyclonedx-1.6")]
|
||||
[InlineData("cyclonedx-1.7")]
|
||||
public async Task EvaluateAsync_AcceptedFormats_ReturnsPass(string format)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = format,
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unknown-1.0")]
|
||||
[InlineData("custom-format")]
|
||||
[InlineData("spdx-1.0")]
|
||||
public async Task EvaluateAsync_InvalidFormat_ReturnsFail(string format)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = format,
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("invalid_format", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InsufficientComponents_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { MinimumComponents = 5 };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 3,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("insufficient_components", result.Reason);
|
||||
Assert.Equal(5, (int)result.Details["minimum_components"]);
|
||||
Assert.Equal(3, (int)result.Details["component_count"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SchemaValidationFailed_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { SchemaValidation = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
SchemaValid = false,
|
||||
SchemaErrors = new[] { "Missing required field 'name'", "Invalid date format" }
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("schema_validation_failed", result.Reason);
|
||||
Assert.Contains("schema_errors", result.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SignatureRequired_MissingSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequireSignature = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
HasSignature = false
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("signature_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SignatureRequired_InvalidSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequireSignature = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
HasSignature = true,
|
||||
SignatureValid = false
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("signature_invalid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SignatureRequired_ValidSignature_ReturnsPass()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequireSignature = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true,
|
||||
HasSignature = true,
|
||||
SignatureValid = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_PrimaryComponentRequired_Missing_ReturnsFail()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions { RequirePrimaryComponent = true };
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = false
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("primary_component_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("production", SbomEnforcementLevel.Required)]
|
||||
[InlineData("staging", SbomEnforcementLevel.Required)]
|
||||
[InlineData("development", SbomEnforcementLevel.Optional)]
|
||||
public async Task EvaluateAsync_EnvironmentEnforcement_UsesCorrectLevel(string environment, SbomEnforcementLevel expectedLevel)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = SbomEnforcementLevel.Required,
|
||||
["staging"] = SbomEnforcementLevel.Required,
|
||||
["development"] = SbomEnforcementLevel.Optional
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: environment));
|
||||
|
||||
Assert.Equal(expectedLevel.ToString(), result.Details["enforcement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UnknownEnvironment_UsesDefaultEnforcement()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions
|
||||
{
|
||||
DefaultEnforcement = SbomEnforcementLevel.Recommended,
|
||||
Enforcement = new Dictionary<string, SbomEnforcementLevel>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["production"] = SbomEnforcementLevel.Required
|
||||
}
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "qa"));
|
||||
|
||||
Assert.Equal(SbomEnforcementLevel.Recommended.ToString(), result.Details["enforcement"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MetadataFallback_ParsesSbomInfo()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["sbom_present"] = "true",
|
||||
["sbom_format"] = "cyclonedx-1.6",
|
||||
["sbom_component_count"] = "25",
|
||||
["sbom_has_primary_component"] = "true"
|
||||
};
|
||||
var gate = new SbomPresenceGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(metadata: metadata));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("cyclonedx-1.6", result.Details["format"]);
|
||||
Assert.Equal(25, (int)result.Details["component_count"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("SPDX-2.3", "spdx-2.3")]
|
||||
[InlineData("CycloneDX-1.6", "cyclonedx-1.6")]
|
||||
[InlineData("spdx 2.3", "spdx-2.3")]
|
||||
[InlineData("cdx-1.5", "cyclonedx-1.5")]
|
||||
public async Task EvaluateAsync_FormatNormalization_HandlesVariations(string inputFormat, string normalizedExpected)
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = inputFormat,
|
||||
ComponentCount = 5,
|
||||
HasPrimaryComponent = true
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
// If format was accepted, it was normalized correctly
|
||||
Assert.True(result.Passed, $"Format '{inputFormat}' should normalize to '{normalizedExpected}' and be accepted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_IncludesOptionalMetadata()
|
||||
{
|
||||
var options = new SbomPresenceGateOptions();
|
||||
var createdAt = new DateTimeOffset(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
var sbomInfo = new SbomInfo
|
||||
{
|
||||
Present = true,
|
||||
Format = "spdx-2.3",
|
||||
ComponentCount = 10,
|
||||
HasPrimaryComponent = true,
|
||||
DocumentUri = "urn:sbom:example:12345",
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
var gate = new SbomPresenceGate(options, _ => sbomInfo);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("urn:sbom:example:12345", result.Details["document_uri"]);
|
||||
Assert.Contains("2026-01-15", (string)result.Details["created_at"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SignatureRequiredGateTests.cs
|
||||
// Sprint: SPRINT_20260112_017_POLICY_signature_required_gate
|
||||
// Tasks: SIG-GATE-009
|
||||
// Description: Unit tests for signature required gate.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SignatureRequiredGateTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(string environment = "production") => new()
|
||||
{
|
||||
Environment = environment
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Disabled_ReturnsPass()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions { Enabled = false };
|
||||
var gate = new SignatureRequiredGate(options);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions();
|
||||
var signatures = new List<SignatureInfo>(); // No signatures
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("signature_validation_failed", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllValidSignatures_ReturnsPass()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions();
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new() { EvidenceType = "sbom", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("signatures_verified", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InvalidSignature_ReturnsFail()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions();
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new() { EvidenceType = "sbom", HasSignature = true, SignatureValid = false, VerificationErrors = new[] { "Invalid hash" } },
|
||||
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("failures", result.Details.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NotRequiredType_PassesWithoutSignature()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = false },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = true },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = true }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
// No SBOM signature - but it's not required
|
||||
new() { EvidenceType = "vex", HasSignature = true, SignatureValid = true },
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("build@company.com", new[] { "build@company.com" }, true)]
|
||||
[InlineData("release@company.com", new[] { "*@company.com" }, true)]
|
||||
[InlineData("external@other.com", new[] { "*@company.com" }, false)]
|
||||
[InlineData("build@company.com", new[] { "other@company.com" }, false)]
|
||||
public async Task EvaluateAsync_IssuerValidation_EnforcesConstraints(
|
||||
string signerIdentity,
|
||||
string[] trustedIssuers,
|
||||
bool expectedPass)
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedIssuers = new HashSet<string>(trustedIssuers, StringComparer.OrdinalIgnoreCase)
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
SignerIdentity = signerIdentity
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ES256", true)]
|
||||
[InlineData("RS256", true)]
|
||||
[InlineData("EdDSA", true)]
|
||||
[InlineData("UNKNOWN", false)]
|
||||
public async Task EvaluateAsync_AlgorithmValidation_EnforcesAccepted(string algorithm, bool expectedPass)
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
Algorithm = algorithm
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.Equal(expectedPass, result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeyIdValidation_EnforcesConstraints()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedKeyIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "key-001", "key-002" }
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
KeyId = "key-999",
|
||||
IsKeyless = false
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeylessSignature_ValidWithTransparencyLog()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EnableKeylessVerification = true,
|
||||
RequireTransparencyLogInclusion = true,
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true,
|
||||
HasTransparencyLogInclusion = true,
|
||||
CertificateChainValid = true
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeylessSignature_FailsWithoutTransparencyLog()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EnableKeylessVerification = true,
|
||||
RequireTransparencyLogInclusion = true,
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true,
|
||||
HasTransparencyLogInclusion = false
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_KeylessDisabled_FailsKeylessSignature()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EnableKeylessVerification = false,
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnvironmentOverride_SkipsTypes()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = true },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = true }
|
||||
},
|
||||
Environments = new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["development"] = new EnvironmentSignatureConfig
|
||||
{
|
||||
SkipEvidenceTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "sbom", "vex" }
|
||||
}
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
// Only attestation signature in development
|
||||
new() { EvidenceType = "attestation", HasSignature = true, SignatureValid = true }
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "development"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnvironmentOverride_AddsIssuers()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "prod@company.com" }
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
},
|
||||
Environments = new Dictionary<string, EnvironmentSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["staging"] = new EnvironmentSignatureConfig
|
||||
{
|
||||
AdditionalIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "staging@company.com" }
|
||||
}
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
SignerIdentity = "staging@company.com"
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext(environment: "staging"));
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InvalidCertificateChain_Fails()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig { Required = true },
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
IsKeyless = true,
|
||||
HasTransparencyLogInclusion = true,
|
||||
CertificateChainValid = false
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WildcardIssuerMatch_MatchesSubdomains()
|
||||
{
|
||||
var options = new SignatureRequiredGateOptions
|
||||
{
|
||||
EvidenceTypes = new Dictionary<string, EvidenceSignatureConfig>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sbom"] = new EvidenceSignatureConfig
|
||||
{
|
||||
Required = true,
|
||||
TrustedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "*@*.company.com" }
|
||||
},
|
||||
["vex"] = new EvidenceSignatureConfig { Required = false },
|
||||
["attestation"] = new EvidenceSignatureConfig { Required = false }
|
||||
}
|
||||
};
|
||||
var signatures = new List<SignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EvidenceType = "sbom",
|
||||
HasSignature = true,
|
||||
SignatureValid = true,
|
||||
SignerIdentity = "build@ci.company.com"
|
||||
}
|
||||
};
|
||||
var gate = new SignatureRequiredGate(options, _ => signatures);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_004_BE_policy_determinization_attested_rules (DET-ATT-004)
|
||||
// Task: Unit tests for VexProofGate anchor-aware mode
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
public class VexProofGateTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static MergeResult CreateMergeResult(VexStatus status) =>
|
||||
new()
|
||||
{
|
||||
Status = status,
|
||||
Confidence = 0.9,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = status,
|
||||
OriginalScore = 0.9,
|
||||
AdjustedScore = 0.9,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "Test claim"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions { Enabled = false };
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext { Environment = "production" };
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("disabled", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenAnchorAwareModeEnabled_RequiresAnchoring()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "false" // Not anchored
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("vex_not_anchored", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenAnchorAwareModeEnabled_PassesWithAnchoring()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = false
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_envelope_digest"] = "sha256:abc123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("proof_valid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenRekorRequired_FailsWithoutRekor()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = true
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_rekor_verified"] = "false"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("rekor_verification_missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenRekorRequired_PassesWithRekor()
|
||||
{
|
||||
// Arrange
|
||||
var options = new VexProofGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequireProofForNotAffected = true,
|
||||
AnchorAwareMode = true,
|
||||
RequireVexAnchoring = true,
|
||||
RequireRekorVerification = true
|
||||
};
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_envelope_digest"] = "sha256:abc123",
|
||||
["vex_proof_rekor_verified"] = "true",
|
||||
["vex_proof_rekor_log_index"] = "12345678"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("proof_valid", result.Reason);
|
||||
Assert.True(result.Details.ContainsKey("rekorLogIndex"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StrictAnchorAware_EnforcesAllRequirements()
|
||||
{
|
||||
// Arrange
|
||||
var options = VexProofGateOptions.StrictAnchorAware;
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_all_signed"] = "true",
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_envelope_digest"] = "sha256:abc123",
|
||||
["vex_proof_rekor_verified"] = "true",
|
||||
["vex_proof_rekor_log_index"] = "12345678"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("proof_valid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_StrictAnchorAware_FailsWithoutSignedStatements()
|
||||
{
|
||||
// Arrange
|
||||
var options = VexProofGateOptions.StrictAnchorAware;
|
||||
var gate = new VexProofGate(options);
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Environment = "production",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["vex_proof_id"] = "proof-123",
|
||||
["vex_proof_confidence_tier"] = "high",
|
||||
["vex_proof_all_signed"] = "false", // Not signed
|
||||
["vex_proof_anchored"] = "true",
|
||||
["vex_proof_rekor_verified"] = "true"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Passed);
|
||||
Assert.Equal("unsigned_statements", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrictAnchorAware_HasExpectedDefaults()
|
||||
{
|
||||
// Act
|
||||
var options = VexProofGateOptions.StrictAnchorAware;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.Enabled);
|
||||
Assert.Equal("high", options.MinimumConfidenceTier);
|
||||
Assert.True(options.RequireProofForNotAffected);
|
||||
Assert.True(options.RequireProofForFixed);
|
||||
Assert.True(options.RequireSignedStatements);
|
||||
Assert.True(options.AnchorAwareMode);
|
||||
Assert.True(options.RequireVexAnchoring);
|
||||
Assert.True(options.RequireRekorVerification);
|
||||
Assert.Equal(0, options.MaxAllowedConflicts);
|
||||
Assert.Equal(72, options.MaxProofAgeHours);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user