old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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()
};
}
}

View File

@@ -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"]);
}
}

View File

@@ -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"]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}