doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -16,4 +16,8 @@
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -11,4 +11,8 @@
|
||||
<ProjectReference Include="../../StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-005 - Unit tests for GateEvaluator
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.DeltaVerdict.Bundles;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Bundles;
|
||||
|
||||
public class GateEvaluatorTests
|
||||
{
|
||||
private readonly GateEvaluator _evaluator = new();
|
||||
private readonly DateTimeOffset _evaluatedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.70, GateAction.Block)] // Above block threshold
|
||||
[InlineData(0.65, GateAction.Block)] // At block threshold
|
||||
[InlineData(0.64, GateAction.Warn)] // Below block, above warn
|
||||
[InlineData(0.50, GateAction.Warn)] // In warn range
|
||||
[InlineData(0.40, GateAction.Warn)] // At warn threshold
|
||||
[InlineData(0.39, GateAction.Pass)] // Below warn threshold
|
||||
[InlineData(0.10, GateAction.Pass)] // Well below all thresholds
|
||||
public void Evaluate_WithDefaultConfig_ReturnsExpectedAction(double score, GateAction expectedAction)
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput();
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(score, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(expectedAction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_BlockDecision_ContainsCorrectThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput();
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.70, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Threshold.Should().Be(0.65);
|
||||
decision.MatchedRules.Should().Contain("block_threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithTrustedVexNotAffected_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "not_affected", vexSource: ".vex/document.json");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt); // Would normally block
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Pass);
|
||||
decision.MatchedRules.Should().Contain("auto_pass_trusted_vex");
|
||||
decision.Reason.Should().Contain("not_affected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithTrustedVexFixed_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "fixed", vexSource: "vendor:acme");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithUntrustedVexSource_DoesNotAutoPass()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "not_affected", vexSource: "unknown-source");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Block); // VEX not authoritative
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithVexAffected_DoesNotAutoPass()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "affected", vexSource: "vendor:acme");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Block); // affected status doesn't auto-pass
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithAutoPassDisabled_IgnoresVex()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "not_affected", vexSource: ".vex/document.json");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = false };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WarnWithHighPatchProof_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(patchProofConfidence: 0.75); // Above bypass (0.70)
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.50, input, config, _evaluatedAt); // In warn range
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Pass);
|
||||
decision.MatchedRules.Should().Contain("warn_threshold");
|
||||
decision.MatchedRules.Should().Contain("patch_proof_bypass");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WarnWithLowPatchProof_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(patchProofConfidence: 0.50); // Below bypass
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.50, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_StrictConfig_HasLowerThresholds()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput();
|
||||
var config = GateConfiguration.Strict;
|
||||
|
||||
// Act
|
||||
var decision55 = _evaluator.Evaluate(0.55, input, config, _evaluatedAt);
|
||||
var decision35 = _evaluator.Evaluate(0.35, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision55.Action.Should().Be(GateAction.Block); // 0.50 strict block threshold
|
||||
decision35.Action.Should().Be(GateAction.Warn); // 0.30 strict warn threshold
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_BlockDecision_IncludesSuggestions()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput();
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.75, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Suggestions.Should().NotBeEmpty();
|
||||
decision.Suggestions.Should().Contain(s => s.Contains("urgently") || s.Contains("VEX"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WarnDecision_IncludesSuggestions()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput();
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.50, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Suggestions.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithCustomBlockRule_OverridesDefaultThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(cvssBase: 9.0);
|
||||
var customRule = new GateRule
|
||||
{
|
||||
Id = "critical-cvss",
|
||||
Name = "Block Critical CVSS",
|
||||
Condition = "cvss >= 9.0",
|
||||
Action = GateAction.Block,
|
||||
Priority = 0 // High priority
|
||||
};
|
||||
var config = GateConfiguration.Default with
|
||||
{
|
||||
CustomRules = ImmutableArray.Create(customRule)
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.30, input, config, _evaluatedAt); // Would normally pass
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Block);
|
||||
decision.MatchedRules.Should().Contain("critical-cvss");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithCustomEpssRule_Matches()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(epssScore: 0.6);
|
||||
var customRule = new GateRule
|
||||
{
|
||||
Id = "high-epss",
|
||||
Name = "Block High EPSS",
|
||||
Condition = "epss >= 0.5",
|
||||
Action = GateAction.Block,
|
||||
Priority = 0
|
||||
};
|
||||
var config = GateConfiguration.Default with
|
||||
{
|
||||
CustomRules = ImmutableArray.Create(customRule)
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.30, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Block);
|
||||
decision.MatchedRules.Should().Contain("high-epss");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_CustomRulesRespectPriority()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(cvssBase: 8.0, epssScore: 0.6);
|
||||
var rule1 = new GateRule
|
||||
{
|
||||
Id = "warn-cvss",
|
||||
Name = "Warn High CVSS",
|
||||
Condition = "cvss >= 7.0",
|
||||
Action = GateAction.Warn,
|
||||
Priority = 10 // Lower priority
|
||||
};
|
||||
var rule2 = new GateRule
|
||||
{
|
||||
Id = "block-epss",
|
||||
Name = "Block High EPSS",
|
||||
Condition = "epss >= 0.5",
|
||||
Action = GateAction.Block,
|
||||
Priority = 1 // Higher priority
|
||||
};
|
||||
var config = GateConfiguration.Default with
|
||||
{
|
||||
CustomRules = ImmutableArray.Create(rule1, rule2)
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.30, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Block);
|
||||
decision.MatchedRules.Should().Contain("block-epss");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateBatch_EvaluatesAllFindings()
|
||||
{
|
||||
// Arrange
|
||||
var findings = new List<(double FinalScore, EvidenceWeightedScoreInput Input)>
|
||||
{
|
||||
(0.75, CreateTestInput()),
|
||||
(0.50, CreateTestInput()),
|
||||
(0.20, CreateTestInput())
|
||||
};
|
||||
var config = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var decisions = _evaluator.EvaluateBatch(findings, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decisions.Should().HaveCount(3);
|
||||
decisions[0].Action.Should().Be(GateAction.Block);
|
||||
decisions[1].Action.Should().Be(GateAction.Warn);
|
||||
decisions[2].Action.Should().Be(GateAction.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_InProjectVex_IsAlwaysAuthoritative()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "not_affected", vexSource: "in-project:local");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.90, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VendorPrefixVex_IsAuthoritative()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateTestInput(vexStatus: "fixed", vexSource: "vendor:microsoft");
|
||||
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var decision = _evaluator.Evaluate(0.90, input, config, _evaluatedAt);
|
||||
|
||||
// Assert
|
||||
decision.Action.Should().Be(GateAction.Pass);
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreInput CreateTestInput(
|
||||
double cvssBase = 7.5,
|
||||
double epssScore = 0.42,
|
||||
double patchProofConfidence = 0.3,
|
||||
string? vexStatus = null,
|
||||
string? vexSource = null)
|
||||
{
|
||||
return new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
|
||||
CvssBase = cvssBase,
|
||||
EpssScore = epssScore,
|
||||
PatchProofConfidence = patchProofConfidence,
|
||||
Rch = 0.7,
|
||||
Rts = 0.3,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.6,
|
||||
Src = 0.8,
|
||||
Mit = 0.2,
|
||||
VexStatus = vexStatus,
|
||||
VexSource = vexSource
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-002 - Unit tests for VerdictBundleBuilder
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.DeltaVerdict.Bundles;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Bundles;
|
||||
|
||||
public class VerdictBundleBuilderTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-18T12:00:00Z"));
|
||||
private readonly VerdictBundleBuilder _builder;
|
||||
|
||||
public VerdictBundleBuilderTests()
|
||||
{
|
||||
var gateEvaluator = new GateEvaluator();
|
||||
_builder = new VerdictBundleBuilder(gateEvaluator, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithValidInput_ReturnsVerdictBundle()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult();
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle.Should().NotBeNull();
|
||||
bundle.FindingId.Should().Be("CVE-2024-1234@pkg:npm/lodash@4.17.20");
|
||||
bundle.SchemaVersion.Should().Be(VerdictBundle.CurrentSchemaVersion);
|
||||
bundle.BundleId.Should().StartWith("sha256:");
|
||||
bundle.BundleDigest.Should().Be(bundle.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ExtractsInputs_WithCorrectValues()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult();
|
||||
var input = CreateTestEwsInput(cvssBase: 7.5, epssScore: 0.42, reachability: 0.8);
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle.Inputs.Cvss.BaseScore.Should().Be(7.5);
|
||||
bundle.Inputs.Epss.Probability.Should().Be(0.42);
|
||||
bundle.Inputs.Reachability.Value.Should().Be(0.8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CreatesNormalizationTrace_FromBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult();
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle.Normalization.Should().NotBeNull();
|
||||
bundle.Normalization.Dimensions.Should().NotBeEmpty();
|
||||
bundle.Normalization.Dimensions
|
||||
.Should().Contain(d => d.Symbol == "CVS" || d.Symbol == "RCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ComputesScores_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 65);
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle.FinalScore.Should().Be(0.65);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithBlockingScore_ReturnsBlockDecision()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 70); // Above default block threshold (0.65)
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
var gateConfig = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
|
||||
|
||||
// Assert
|
||||
bundle.Gate.Action.Should().Be(GateAction.Block);
|
||||
bundle.Gate.Threshold.Should().Be(0.65);
|
||||
bundle.Gate.MatchedRules.Should().Contain("block_threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithWarnScore_ReturnsWarnDecision()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 50); // Between warn (0.40) and block (0.65)
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
var gateConfig = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
|
||||
|
||||
// Assert
|
||||
bundle.Gate.Action.Should().Be(GateAction.Warn);
|
||||
bundle.Gate.Threshold.Should().Be(0.40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithPassScore_ReturnsPassDecision()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 30); // Below warn threshold
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
var gateConfig = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
|
||||
|
||||
// Assert
|
||||
bundle.Gate.Action.Should().Be(GateAction.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTrustedVexNotAffected_ReturnsPassDecision()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 70, flags: ["vex-override", "vendor-na"]);
|
||||
var input = CreateTestEwsInput(vexStatus: "not_affected", vexSource: ".vex/document.json");
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
var gateConfig = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
|
||||
|
||||
// Assert
|
||||
bundle.Gate.Action.Should().Be(GateAction.Pass);
|
||||
bundle.Gate.MatchedRules.Should().Contain("auto_pass_trusted_vex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithVexOverride_SetsOverrideField()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 0, flags: ["vex-override", "vendor-na"]);
|
||||
var input = CreateTestEwsInput(vexStatus: "not_affected", vexSource: "vendor:acme");
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle.Override.Should().NotBeNull();
|
||||
bundle.Override!.Applied.Should().BeTrue();
|
||||
bundle.Override.Type.Should().Be("vex_not_affected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GeneratesContentAddressedBundleId()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult();
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle1 = _builder.Build(ewsResult, input, policy);
|
||||
var bundle2 = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert - Same inputs should produce same digest
|
||||
bundle1.BundleId.Should().Be(bundle2.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentInputs_ProduceDifferentBundleIds()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult1 = CreateTestEwsResult(score: 50);
|
||||
var ewsResult2 = CreateTestEwsResult(score: 60);
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle1 = _builder.Build(ewsResult1, input, policy);
|
||||
var bundle2 = _builder.Build(ewsResult2, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle1.BundleId.Should().NotBe(bundle2.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SetsComputedAtTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTime = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
|
||||
var ewsResult = CreateTestEwsResult();
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy);
|
||||
|
||||
// Assert
|
||||
bundle.ComputedAt.Should().Be(expectedTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithStrictGateConfiguration_LowersThresholds()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 55); // Above strict block (0.50), below default block (0.65)
|
||||
var input = CreateTestEwsInput();
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
var gateConfig = GateConfiguration.Strict;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
|
||||
|
||||
// Assert
|
||||
bundle.Gate.Action.Should().Be(GateAction.Block);
|
||||
bundle.Gate.Threshold.Should().Be(0.50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithPatchProofBypass_ConvertsWarnToPass()
|
||||
{
|
||||
// Arrange
|
||||
var ewsResult = CreateTestEwsResult(score: 50); // In warn range
|
||||
var input = CreateTestEwsInput(patchProofConfidence: 0.75); // Above bypass threshold (0.70)
|
||||
var policy = EvidenceWeightPolicy.AdvisoryProduction;
|
||||
var gateConfig = GateConfiguration.Default;
|
||||
|
||||
// Act
|
||||
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
|
||||
|
||||
// Assert
|
||||
bundle.Gate.Action.Should().Be(GateAction.Pass);
|
||||
bundle.Gate.MatchedRules.Should().Contain("patch_proof_bypass");
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreResult CreateTestEwsResult(
|
||||
int score = 50,
|
||||
string[] flags = null!)
|
||||
{
|
||||
flags ??= ["high-epss"];
|
||||
|
||||
return new EvidenceWeightedScoreResult
|
||||
{
|
||||
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
|
||||
Score = score,
|
||||
Bucket = score >= 90 ? ScoreBucket.ActNow :
|
||||
score >= 70 ? ScoreBucket.ScheduleNext :
|
||||
score >= 40 ? ScoreBucket.Investigate :
|
||||
ScoreBucket.Watchlist,
|
||||
Inputs = new EvidenceInputValues(0.7, 0.3, 0.5, 0.6, 0.8, 0.2),
|
||||
Weights = EvidenceWeights.Advisory,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution
|
||||
{
|
||||
Dimension = "CVSS Base",
|
||||
Symbol = "CVS",
|
||||
InputValue = 0.75,
|
||||
Weight = 0.25,
|
||||
Contribution = 0.1875
|
||||
},
|
||||
new DimensionContribution
|
||||
{
|
||||
Dimension = "EPSS",
|
||||
Symbol = "EPS",
|
||||
InputValue = 0.42,
|
||||
Weight = 0.30,
|
||||
Contribution = 0.126
|
||||
},
|
||||
new DimensionContribution
|
||||
{
|
||||
Dimension = "Reachability",
|
||||
Symbol = "RCH",
|
||||
InputValue = 0.7,
|
||||
Weight = 0.20,
|
||||
Contribution = 0.14
|
||||
},
|
||||
new DimensionContribution
|
||||
{
|
||||
Dimension = "Exploit Maturity",
|
||||
Symbol = "XPL",
|
||||
InputValue = 0.6,
|
||||
Weight = 0.10,
|
||||
Contribution = 0.06
|
||||
},
|
||||
new DimensionContribution
|
||||
{
|
||||
Dimension = "Patch Proof",
|
||||
Symbol = "PPF",
|
||||
InputValue = 0.3,
|
||||
Weight = 0.15,
|
||||
Contribution = -0.045,
|
||||
IsSubtractive = true
|
||||
}
|
||||
],
|
||||
Flags = flags,
|
||||
Explanations = ["CVSS: high (75%)", "EPSS: medium (42%)"],
|
||||
Caps = AppliedGuardrails.None(score),
|
||||
PolicyDigest = "abc123",
|
||||
CalculatedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z")
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreInput CreateTestEwsInput(
|
||||
double cvssBase = 7.5,
|
||||
double epssScore = 0.42,
|
||||
double reachability = 0.7,
|
||||
double patchProofConfidence = 0.3,
|
||||
string? vexStatus = null,
|
||||
string? vexSource = null)
|
||||
{
|
||||
return new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
|
||||
CvssBase = cvssBase,
|
||||
CvssVersion = "3.1",
|
||||
EpssScore = epssScore,
|
||||
ExploitMaturity = ExploitMaturityLevel.Functional,
|
||||
PatchProofConfidence = patchProofConfidence,
|
||||
Rch = reachability,
|
||||
Rts = 0.3,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.6,
|
||||
Src = 0.8,
|
||||
Mit = 0.2,
|
||||
VexStatus = vexStatus,
|
||||
VexSource = vexSource
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-004 - Unit tests for VerdictRekorAnchorService
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.DeltaVerdict.Bundles;
|
||||
using StellaOps.DeltaVerdict.Manifest;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Bundles;
|
||||
|
||||
public class VerdictRekorAnchorServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly StubVerdictRekorClient _stubClient;
|
||||
private readonly VerdictRekorAnchorService _anchorService;
|
||||
private readonly VerdictSigningService _signingService;
|
||||
private readonly string _testSecret;
|
||||
|
||||
public VerdictRekorAnchorServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
_stubClient = new StubVerdictRekorClient(_timeProvider);
|
||||
_anchorService = new VerdictRekorAnchorService(_stubClient, _timeProvider);
|
||||
_signingService = new VerdictSigningService();
|
||||
_testSecret = Convert.ToBase64String(new byte[32] {
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
||||
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_WithSignedBundle_ReturnsAnchoredBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.AnchorAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.AnchoredBundle.Should().NotBeNull();
|
||||
result.AnchoredBundle!.RekorAnchor.Should().NotBeNull();
|
||||
result.Linkage.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_WithSignedBundle_SetsRekorFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.AnchorAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
var anchor = result.AnchoredBundle!.RekorAnchor!;
|
||||
anchor.Uuid.Should().NotBeNullOrEmpty();
|
||||
anchor.LogIndex.Should().BeGreaterThan(0);
|
||||
anchor.IntegratedTime.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_WithSignedBundle_IncludesInclusionProof()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.AnchorAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
var proof = result.AnchoredBundle!.RekorAnchor!.InclusionProof;
|
||||
proof.Should().NotBeNull();
|
||||
proof!.TreeSize.Should().BeGreaterThan(0);
|
||||
proof.RootHash.Should().NotBeNullOrEmpty();
|
||||
proof.LogId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_WithUnsignedBundle_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.AnchorAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("must be signed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_MultipleSubmissions_GetUniqueLogIndexes()
|
||||
{
|
||||
// Arrange
|
||||
var bundle1 = await CreateSignedBundle();
|
||||
var bundle2 = await CreateSignedBundle("CVE-2024-5678@pkg:npm/express@4.0.0");
|
||||
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
|
||||
// Act
|
||||
var result1 = await _anchorService.AnchorAsync(bundle1, options);
|
||||
var result2 = await _anchorService.AnchorAsync(bundle2, options);
|
||||
|
||||
// Assert
|
||||
result1.Linkage!.LogIndex.Should().NotBe(result2.Linkage!.LogIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithValidAnchor_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var anchorOptions = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
var anchoredResult = await _anchorService.AnchorAsync(bundle, anchorOptions);
|
||||
var verifyOptions = VerdictAnchorVerificationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredResult.AnchoredBundle!, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.VerifiedUuid.Should().NotBeNullOrEmpty();
|
||||
result.VerifiedLogIndex.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithNoAnchor_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var verifyOptions = VerdictAnchorVerificationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(bundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("no Rekor anchor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithFutureTimestamp_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var futureTime = _timeProvider.GetUtcNow().AddHours(2).ToUnixTimeSeconds();
|
||||
var anchoredBundle = bundle with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = futureTime,
|
||||
InclusionProof = new InclusionProof
|
||||
{
|
||||
TreeSize = 1,
|
||||
RootHash = "abc123",
|
||||
Hashes = ImmutableArray<string>.Empty,
|
||||
LogId = "test-log"
|
||||
}
|
||||
}
|
||||
};
|
||||
var verifyOptions = VerdictAnchorVerificationOptions.Default;
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("future");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithOldTimestamp_ReturnsFail_WhenMaxAgeExceeded()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var oldTime = _timeProvider.GetUtcNow().AddHours(-48).ToUnixTimeSeconds();
|
||||
var anchoredBundle = bundle with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = oldTime,
|
||||
InclusionProof = new InclusionProof
|
||||
{
|
||||
TreeSize = 1,
|
||||
RootHash = "abc123",
|
||||
Hashes = ImmutableArray<string>.Empty,
|
||||
LogId = "test-log"
|
||||
}
|
||||
}
|
||||
};
|
||||
var verifyOptions = new VerdictAnchorVerificationOptions { MaxAgeHours = 24 };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("older than");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithNoInclusionProof_ReturnsFail_WhenRequired()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var anchoredBundle = bundle with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds(),
|
||||
InclusionProof = null
|
||||
}
|
||||
};
|
||||
var verifyOptions = new VerdictAnchorVerificationOptions { RequireInclusionProof = true };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("no inclusion proof");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithNoInclusionProof_ReturnsSuccess_WhenNotRequired()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var anchoredBundle = bundle with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds(),
|
||||
InclusionProof = null
|
||||
}
|
||||
};
|
||||
var verifyOptions = VerdictAnchorVerificationOptions.Relaxed;
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithInvalidUuid_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var anchoredBundle = bundle with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds()
|
||||
}
|
||||
};
|
||||
var verifyOptions = VerdictAnchorVerificationOptions.Relaxed;
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid Rekor UUID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_WithNegativeLogIndex_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var anchoredBundle = bundle with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "valid-uuid",
|
||||
LogIndex = -1,
|
||||
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds()
|
||||
}
|
||||
};
|
||||
var verifyOptions = VerdictAnchorVerificationOptions.Relaxed;
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid log index");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_VerifiesInclusionProof_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var anchorOptions = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
var anchoredResult = await _anchorService.AnchorAsync(bundle, anchorOptions);
|
||||
var verifyOptions = new VerdictAnchorVerificationOptions
|
||||
{
|
||||
RequireInclusionProof = true,
|
||||
VerifyInclusionProof = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.VerifyAnchorAsync(anchoredResult.AnchoredBundle!, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.VerifiedIntegratedTime.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_PreservesOriginalBundleFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = await CreateSignedBundle();
|
||||
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
|
||||
|
||||
// Act
|
||||
var result = await _anchorService.AnchorAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
result.AnchoredBundle!.BundleId.Should().Be(bundle.BundleId);
|
||||
result.AnchoredBundle.FindingId.Should().Be(bundle.FindingId);
|
||||
result.AnchoredBundle.FinalScore.Should().Be(bundle.FinalScore);
|
||||
result.AnchoredBundle.DsseSignature.Should().Be(bundle.DsseSignature);
|
||||
}
|
||||
|
||||
private async Task<VerdictBundle> CreateSignedBundle(string? findingId = null)
|
||||
{
|
||||
var bundle = CreateTestBundle(findingId);
|
||||
var options = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
return await _signingService.SignAsync(bundle, options);
|
||||
}
|
||||
|
||||
private static VerdictBundle CreateTestBundle(string? findingId = null)
|
||||
{
|
||||
var computedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
|
||||
|
||||
return new VerdictBundle
|
||||
{
|
||||
BundleId = "sha256:abc123",
|
||||
FindingId = findingId ?? "CVE-2024-1234@pkg:npm/lodash@4.17.20",
|
||||
ManifestRef = new ScoringManifestRef
|
||||
{
|
||||
ScoringVersion = "v2026-01-18-1",
|
||||
ManifestDigest = "sha256:manifest123"
|
||||
},
|
||||
Inputs = new VerdictInputs
|
||||
{
|
||||
Cvss = new CvssInput
|
||||
{
|
||||
BaseScore = 7.5,
|
||||
Version = "3.1",
|
||||
Source = "nvd",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
Epss = new EpssInput
|
||||
{
|
||||
Probability = 0.42,
|
||||
Source = "first.org",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
Reachability = new ReachabilityInputRecord
|
||||
{
|
||||
Level = "function",
|
||||
Value = 0.7,
|
||||
Source = "stella-scanner",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
ExploitMaturity = new ExploitMaturityInput
|
||||
{
|
||||
Level = "poc",
|
||||
Value = 0.5,
|
||||
Source = "nvd",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
PatchProof = new PatchProofInput
|
||||
{
|
||||
Confidence = 0.3,
|
||||
Source = "stella-verifier",
|
||||
CapturedAt = computedAt
|
||||
}
|
||||
},
|
||||
Normalization = new NormalizationTrace
|
||||
{
|
||||
Dimensions = ImmutableArray.Create(
|
||||
new DimensionNormalization
|
||||
{
|
||||
Dimension = "cvss_base",
|
||||
Symbol = "CVS",
|
||||
RawValue = 7.5,
|
||||
NormalizedValue = 0.75,
|
||||
Method = "linear",
|
||||
Weight = 0.25,
|
||||
Contribution = 0.1875
|
||||
})
|
||||
},
|
||||
RawScore = 0.65,
|
||||
FinalScore = 0.65,
|
||||
Gate = new GateDecision
|
||||
{
|
||||
Action = GateAction.Block,
|
||||
Reason = "Score 0.65 exceeds block threshold 0.65",
|
||||
Threshold = 0.65,
|
||||
MatchedRules = ["block_threshold"],
|
||||
Suggestions = ["Review finding urgently"]
|
||||
},
|
||||
ComputedAt = computedAt,
|
||||
BundleDigest = "sha256:abc123"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-003 - Unit tests for VerdictSigningService
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.DeltaVerdict.Bundles;
|
||||
using StellaOps.DeltaVerdict.Manifest;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Bundles;
|
||||
|
||||
public class VerdictSigningServiceTests
|
||||
{
|
||||
private readonly VerdictSigningService _signingService = new();
|
||||
private readonly string _testSecret = Convert.ToBase64String(new byte[32] {
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
||||
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithValidBundle_ReturnsSignedBundle()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var signedBundle = await _signingService.SignAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
signedBundle.DsseSignature.Should().NotBeNullOrEmpty();
|
||||
signedBundle.BundleId.Should().Be(bundle.BundleId);
|
||||
signedBundle.FindingId.Should().Be(bundle.FindingId);
|
||||
signedBundle.FinalScore.Should().Be(bundle.FinalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesValidDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var signedBundle = await _signingService.SignAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonSerializer.Deserialize<VerdictDsseEnvelope>(signedBundle.DsseSignature!);
|
||||
envelope.Should().NotBeNull();
|
||||
envelope!.PayloadType.Should().Be(VerdictSigningService.PayloadType);
|
||||
envelope.Payload.Should().NotBeNullOrEmpty();
|
||||
envelope.Signatures.Should().HaveCount(1);
|
||||
envelope.Signatures[0].KeyId.Should().Be("test-key-1");
|
||||
envelope.Signatures[0].Sig.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidSignature_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
|
||||
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(signedBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.VerifiedKeyId.Should().Be("test-key-1");
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithWrongSecret_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
|
||||
|
||||
var wrongSecret = Convert.ToBase64String(new byte[32]);
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = wrongSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(signedBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Signature verification failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithWrongKeyId_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
|
||||
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "different-key",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(signedBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Signature verification failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithUnsignedBundle_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(bundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Bundle is not signed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithInvalidEnvelope_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle() with { DsseSignature = "invalid json" };
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(bundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid signature envelope");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DetectsTamperedContent()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
|
||||
|
||||
// Tamper with the content
|
||||
var tamperedBundle = signedBundle with { FinalScore = 0.99 };
|
||||
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(tamperedBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("modified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSha256Algorithm_ProducesSignature()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-sha256",
|
||||
Algorithm = VerdictSigningAlgorithm.Sha256
|
||||
};
|
||||
|
||||
// Act
|
||||
var signedBundle = await _signingService.SignAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
signedBundle.DsseSignature.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_SigningTwiceWithSameOptions_ProducesSameSignature()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var signedBundle1 = await _signingService.SignAsync(bundle, options);
|
||||
var signedBundle2 = await _signingService.SignAsync(bundle, options);
|
||||
|
||||
// Assert
|
||||
signedBundle1.DsseSignature.Should().Be(signedBundle2.DsseSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCanonicalJson_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
|
||||
// Act
|
||||
var json1 = _signingService.GetCanonicalJson(bundle);
|
||||
var json2 = _signingService.GetCanonicalJson(bundle);
|
||||
|
||||
// Assert
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCanonicalJson_ExcludesSignatureAndRekorFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle() with
|
||||
{
|
||||
DsseSignature = "some-signature",
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 123,
|
||||
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = _signingService.GetCanonicalJson(bundle);
|
||||
|
||||
// Assert
|
||||
json.Should().NotContain("dsse_signature");
|
||||
json.Should().NotContain("rekor_anchor");
|
||||
json.Should().Contain("finding_id");
|
||||
json.Should().Contain("final_score");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithExistingSignature_ReplacesSignature()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options1 = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var options2 = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "key-2",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var firstSigned = await _signingService.SignAsync(bundle, options1);
|
||||
var secondSigned = await _signingService.SignAsync(firstSigned, options2);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonSerializer.Deserialize<VerdictDsseEnvelope>(secondSigned.DsseSignature!);
|
||||
envelope!.Signatures.Should().HaveCount(1);
|
||||
envelope.Signatures[0].KeyId.Should().Be("key-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithWrongPayloadType_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
|
||||
|
||||
// Tamper with envelope to change payload type
|
||||
var envelope = JsonSerializer.Deserialize<VerdictDsseEnvelope>(signedBundle.DsseSignature!);
|
||||
var tamperedEnvelope = new VerdictDsseEnvelope(
|
||||
"application/wrong-type",
|
||||
envelope!.Payload,
|
||||
envelope.Signatures);
|
||||
var tamperedBundle = signedBundle with
|
||||
{
|
||||
DsseSignature = JsonSerializer.Serialize(tamperedEnvelope)
|
||||
};
|
||||
|
||||
var verifyOptions = new VerdictVerificationOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _signingService.VerifyAsync(tamperedBundle, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid payload type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ThrowsOnMissingHmacSecret()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = CreateTestBundle();
|
||||
var options = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "test-key-1",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = null
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _signingService.SignAsync(bundle, options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PayloadType_IsCorrect()
|
||||
{
|
||||
// Assert
|
||||
VerdictSigningService.PayloadType.Should().Be("application/vnd.stella.scoring.v1+json");
|
||||
}
|
||||
|
||||
private static VerdictBundle CreateTestBundle()
|
||||
{
|
||||
var computedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
|
||||
|
||||
return new VerdictBundle
|
||||
{
|
||||
BundleId = "sha256:abc123",
|
||||
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
|
||||
ManifestRef = new ScoringManifestRef
|
||||
{
|
||||
ScoringVersion = "v2026-01-18-1",
|
||||
ManifestDigest = "sha256:manifest123"
|
||||
},
|
||||
Inputs = new VerdictInputs
|
||||
{
|
||||
Cvss = new CvssInput
|
||||
{
|
||||
BaseScore = 7.5,
|
||||
Version = "3.1",
|
||||
Source = "nvd",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
Epss = new EpssInput
|
||||
{
|
||||
Probability = 0.42,
|
||||
Source = "first.org",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
Reachability = new ReachabilityInputRecord
|
||||
{
|
||||
Level = "function",
|
||||
Value = 0.7,
|
||||
Source = "stella-scanner",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
ExploitMaturity = new ExploitMaturityInput
|
||||
{
|
||||
Level = "poc",
|
||||
Value = 0.5,
|
||||
Source = "nvd",
|
||||
CapturedAt = computedAt
|
||||
},
|
||||
PatchProof = new PatchProofInput
|
||||
{
|
||||
Confidence = 0.3,
|
||||
Source = "stella-verifier",
|
||||
CapturedAt = computedAt
|
||||
}
|
||||
},
|
||||
Normalization = new NormalizationTrace
|
||||
{
|
||||
Dimensions = ImmutableArray.Create(
|
||||
new DimensionNormalization
|
||||
{
|
||||
Dimension = "cvss_base",
|
||||
Symbol = "CVS",
|
||||
RawValue = 7.5,
|
||||
NormalizedValue = 0.75,
|
||||
Method = "linear",
|
||||
Weight = 0.25,
|
||||
Contribution = 0.1875
|
||||
})
|
||||
},
|
||||
RawScore = 0.65,
|
||||
FinalScore = 0.65,
|
||||
Gate = new GateDecision
|
||||
{
|
||||
Action = GateAction.Block,
|
||||
Reason = "Score 0.65 exceeds block threshold 0.65",
|
||||
Threshold = 0.65,
|
||||
MatchedRules = ["block_threshold"],
|
||||
Suggestions = ["Review finding urgently"]
|
||||
},
|
||||
ComputedAt = computedAt,
|
||||
BundleDigest = "sha256:abc123"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
|
||||
// Task: TASK-028-001 - Unit tests for ScoringManifest model
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.DeltaVerdict.Manifest;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Manifest;
|
||||
|
||||
public class ScoringManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScoringManifest_Instantiation_WithAllRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
// Assert
|
||||
manifest.Should().NotBeNull();
|
||||
manifest.SchemaVersion.Should().Be(ScoringManifest.CurrentSchemaVersion);
|
||||
manifest.ScoringVersion.Should().Be("v2026-01-18-1");
|
||||
manifest.Weights.Should().NotBeNull();
|
||||
manifest.Normalizers.Should().NotBeNull();
|
||||
manifest.TrustedVexKeys.Should().HaveCount(2);
|
||||
manifest.CodeHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringWeights_Default_SumsToOne()
|
||||
{
|
||||
// Arrange & Act
|
||||
var weights = ScoringWeights.Default;
|
||||
|
||||
// Assert
|
||||
weights.Sum.Should().BeApproximately(1.0, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringWeights_Validate_ReturnsNoErrors_WhenValid()
|
||||
{
|
||||
// Arrange
|
||||
var weights = ScoringWeights.Default;
|
||||
|
||||
// Act
|
||||
var errors = weights.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringWeights_Validate_ReturnsErrors_WhenWeightOutOfRange()
|
||||
{
|
||||
// Arrange
|
||||
var weights = new ScoringWeights
|
||||
{
|
||||
CvssBase = 1.5, // Invalid: > 1.0
|
||||
Epss = 0.2,
|
||||
Reachability = 0.25,
|
||||
ExploitMaturity = 0.15,
|
||||
PatchProofConfidence = 0.15
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = weights.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("cvss_base") && e.Contains("range"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringWeights_Validate_ReturnsErrors_WhenNaN()
|
||||
{
|
||||
// Arrange
|
||||
var weights = new ScoringWeights
|
||||
{
|
||||
CvssBase = double.NaN,
|
||||
Epss = 0.2,
|
||||
Reachability = 0.25,
|
||||
ExploitMaturity = 0.15,
|
||||
PatchProofConfidence = 0.15
|
||||
};
|
||||
|
||||
// Act
|
||||
var errors = weights.Validate();
|
||||
|
||||
// Assert
|
||||
errors.Should().Contain(e => e.Contains("cvss_base") && e.Contains("valid number"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringNormalizers_Default_HasExpectedRanges()
|
||||
{
|
||||
// Arrange & Act
|
||||
var normalizers = ScoringNormalizers.Default;
|
||||
|
||||
// Assert
|
||||
normalizers.CvssRange.Min.Should().Be(0.0);
|
||||
normalizers.CvssRange.Max.Should().Be(10.0);
|
||||
normalizers.EpssRange.Min.Should().Be(0.0);
|
||||
normalizers.EpssRange.Max.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizerRange_Normalize_ReturnsExpectedValues()
|
||||
{
|
||||
// Arrange
|
||||
var range = new NormalizerRange { Min = 0.0, Max = 10.0 };
|
||||
|
||||
// Act & Assert
|
||||
range.Normalize(0.0).Should().Be(0.0);
|
||||
range.Normalize(5.0).Should().Be(0.5);
|
||||
range.Normalize(10.0).Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizerRange_Normalize_ClampsValues()
|
||||
{
|
||||
// Arrange
|
||||
var range = new NormalizerRange { Min = 0.0, Max = 10.0 };
|
||||
|
||||
// Act & Assert
|
||||
range.Normalize(-5.0).Should().Be(0.0); // Clamped to min
|
||||
range.Normalize(15.0).Should().Be(1.0); // Clamped to max
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizerRange_Normalize_HandlesZeroRange()
|
||||
{
|
||||
// Arrange
|
||||
var range = new NormalizerRange { Min = 5.0, Max = 5.0 };
|
||||
|
||||
// Act
|
||||
var result = range.Normalize(5.0);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0.0); // Zero-width range returns 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RekorLinkage_Instantiation_WithRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var linkage = new RekorLinkage
|
||||
{
|
||||
Uuid = "abc123",
|
||||
LogIndex = 12345,
|
||||
IntegratedTime = 1705593600
|
||||
};
|
||||
|
||||
// Assert
|
||||
linkage.Uuid.Should().Be("abc123");
|
||||
linkage.LogIndex.Should().Be(12345);
|
||||
linkage.IntegratedTime.Should().Be(1705593600);
|
||||
linkage.InclusionProof.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InclusionProof_Instantiation_WithRequiredFields()
|
||||
{
|
||||
// Arrange & Act
|
||||
var proof = new InclusionProof
|
||||
{
|
||||
TreeSize = 1000000,
|
||||
RootHash = "sha256:abc123",
|
||||
Hashes = ImmutableArray.Create("hash1", "hash2", "hash3"),
|
||||
LogId = "sigstore-log-1"
|
||||
};
|
||||
|
||||
// Assert
|
||||
proof.TreeSize.Should().Be(1000000);
|
||||
proof.RootHash.Should().Be("sha256:abc123");
|
||||
proof.Hashes.Should().HaveCount(3);
|
||||
proof.LogId.Should().Be("sigstore-log-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringManifest_IsImmutable_ViaRecordSemantics()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTestManifest();
|
||||
|
||||
// Act - Using 'with' expression creates a new instance
|
||||
var modified = original with { ScoringVersion = "v2026-01-18-2" };
|
||||
|
||||
// Assert
|
||||
original.ScoringVersion.Should().Be("v2026-01-18-1");
|
||||
modified.ScoringVersion.Should().Be("v2026-01-18-2");
|
||||
original.Should().NotBeSameAs(modified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringManifest_TrustedVexKeys_IsImmutableArray()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
// Act
|
||||
var keys = manifest.TrustedVexKeys;
|
||||
|
||||
// Assert
|
||||
keys.Should().BeOfType<ImmutableArray<string>>();
|
||||
keys.IsDefault.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static ScoringManifest CreateTestManifest()
|
||||
{
|
||||
return new ScoringManifest
|
||||
{
|
||||
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
|
||||
ScoringVersion = "v2026-01-18-1",
|
||||
Weights = ScoringWeights.Default,
|
||||
Normalizers = ScoringNormalizers.Default,
|
||||
TrustedVexKeys = ImmutableArray.Create("key-fingerprint-1", "key-fingerprint-2"),
|
||||
CodeHash = "sha256:abc123def456",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
|
||||
// Task: TASK-028-006 - Manifest Version Bump Workflow
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.DeltaVerdict.Manifest;
|
||||
using StellaOps.DeltaVerdict.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Manifest;
|
||||
|
||||
public class ScoringManifestVersionerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ScoringManifestVersioner _versioner;
|
||||
|
||||
public ScoringManifestVersionerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
_versioner = new ScoringManifestVersioner(
|
||||
new ScoringManifestSigningService(),
|
||||
null,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private static ScoringManifest CreateTestManifest(string version = "v2026-01-17-1")
|
||||
{
|
||||
return new ScoringManifest
|
||||
{
|
||||
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
|
||||
ScoringVersion = version,
|
||||
Weights = ScoringWeights.Default,
|
||||
Normalizers = ScoringNormalizers.Default,
|
||||
TrustedVexKeys = ImmutableArray.Create("key1", "key2"),
|
||||
CodeHash = "sha256:abc123def456",
|
||||
CreatedAt = new DateTimeOffset(2026, 1, 17, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
#region Compare Tests
|
||||
|
||||
[Fact]
|
||||
public void Compare_IdenticalManifests_RequiresNoBump()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
var result = _versioner.Compare(manifest, manifest);
|
||||
|
||||
result.RequiresBump.Should().BeFalse();
|
||||
result.Changes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentWeights_RequiresBump()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
Weights = ScoringWeights.Default with { CvssBase = 0.30 }
|
||||
};
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().ContainSingle();
|
||||
result.Changes[0].Field.Should().Be("weights.cvss_base");
|
||||
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.WeightChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_MultipleWeightChanges_ReportsAll()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
Weights = new ScoringWeights
|
||||
{
|
||||
CvssBase = 0.30,
|
||||
Epss = 0.25,
|
||||
Reachability = ScoringWeights.Default.Reachability,
|
||||
ExploitMaturity = ScoringWeights.Default.ExploitMaturity,
|
||||
PatchProofConfidence = ScoringWeights.Default.PatchProofConfidence
|
||||
}
|
||||
};
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().HaveCount(2);
|
||||
result.Changes.Should().Contain(c => c.Field == "weights.cvss_base");
|
||||
result.Changes.Should().Contain(c => c.Field == "weights.epss");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentTrustedKeys_RequiresBump()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
TrustedVexKeys = ImmutableArray.Create("key1", "key2", "key3")
|
||||
};
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().Contain(c => c.Field == "trusted_vex_keys.added");
|
||||
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.TrustChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_RemovedTrustedKey_RequiresBump()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
TrustedVexKeys = ImmutableArray.Create("key1")
|
||||
};
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().Contain(c => c.Field == "trusted_vex_keys.removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentCodeHash_RequiresBump()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().ContainSingle();
|
||||
result.Changes[0].Field.Should().Be("code_hash");
|
||||
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.CodeChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentSchemaVersion_RequiresBump()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { SchemaVersion = "stella-scoring/2.0.0" };
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().ContainSingle();
|
||||
result.Changes[0].Field.Should().Be("schema_version");
|
||||
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.SchemaChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentNormalizerRange_RequiresBump()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
Normalizers = ScoringNormalizers.Default with
|
||||
{
|
||||
CvssRange = new NormalizerRange { Min = 0, Max = 15 }
|
||||
}
|
||||
};
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.RequiresBump.Should().BeTrue();
|
||||
result.Changes.Should().ContainSingle();
|
||||
result.Changes[0].Field.Should().Be("normalizers.cvss_range");
|
||||
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.NormalizerChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_ReturnsDigests()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var result = _versioner.Compare(current, proposed);
|
||||
|
||||
result.CurrentDigest.Should().NotBeNullOrEmpty();
|
||||
result.ProposedDigest.Should().NotBeNullOrEmpty();
|
||||
result.CurrentDigest.Should().NotBe(result.ProposedDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GenerateNextVersion Tests
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_NullVersion_StartsAtOne()
|
||||
{
|
||||
var version = _versioner.GenerateNextVersion(null);
|
||||
|
||||
version.Should().Be("v2026-01-18-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_EmptyVersion_StartsAtOne()
|
||||
{
|
||||
var version = _versioner.GenerateNextVersion("");
|
||||
|
||||
version.Should().Be("v2026-01-18-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_SameDay_IncrementsSequence()
|
||||
{
|
||||
var version = _versioner.GenerateNextVersion("v2026-01-18-1");
|
||||
|
||||
version.Should().Be("v2026-01-18-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_SameDay_MultipleIncrements()
|
||||
{
|
||||
var version1 = _versioner.GenerateNextVersion("v2026-01-18-5");
|
||||
var version2 = _versioner.GenerateNextVersion("v2026-01-18-99");
|
||||
|
||||
version1.Should().Be("v2026-01-18-6");
|
||||
version2.Should().Be("v2026-01-18-100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_DifferentDay_ResetsSequence()
|
||||
{
|
||||
var version = _versioner.GenerateNextVersion("v2026-01-17-5");
|
||||
|
||||
version.Should().Be("v2026-01-18-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_InvalidFormat_StartsAtOne()
|
||||
{
|
||||
var version = _versioner.GenerateNextVersion("invalid");
|
||||
|
||||
version.Should().Be("v2026-01-18-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateNextVersion_UsesTimeProvider()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var version = _versioner.GenerateNextVersion(null);
|
||||
|
||||
version.Should().Be("v2026-06-15-1");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bump Tests
|
||||
|
||||
[Fact]
|
||||
public void Bump_NoChanges_ReturnsNoBumpRequired()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
var result = _versioner.Bump(manifest, manifest, "No reason");
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.BumpRequired.Should().BeFalse();
|
||||
result.BumpedManifest.Should().Be(manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_WithChanges_CreatesNewVersion()
|
||||
{
|
||||
var current = CreateTestManifest("v2026-01-17-1");
|
||||
var proposed = current with
|
||||
{
|
||||
Weights = ScoringWeights.Default with { CvssBase = 0.30 }
|
||||
};
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Updated CVSS weight");
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.BumpRequired.Should().BeTrue();
|
||||
result.BumpedManifest.Should().NotBeNull();
|
||||
result.BumpedManifest!.ScoringVersion.Should().Be("v2026-01-18-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_PreservesProposedChanges()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
Weights = new ScoringWeights
|
||||
{
|
||||
CvssBase = 0.30,
|
||||
Epss = 0.25,
|
||||
Reachability = 0.20,
|
||||
ExploitMaturity = 0.15,
|
||||
PatchProofConfidence = 0.10
|
||||
}
|
||||
};
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Updated all weights");
|
||||
|
||||
result.BumpedManifest!.Weights.CvssBase.Should().Be(0.30);
|
||||
result.BumpedManifest.Weights.Epss.Should().Be(0.25);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_UpdatesCreatedAt()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Updated code");
|
||||
|
||||
result.BumpedManifest!.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_ClearsSignatureAndAnchor()
|
||||
{
|
||||
var current = CreateTestManifest() with
|
||||
{
|
||||
ManifestDigest = "sha256:old",
|
||||
DsseSignature = "old-sig",
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "old-uuid",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = 1234567890
|
||||
}
|
||||
};
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Updated code");
|
||||
|
||||
result.BumpedManifest!.ManifestDigest.Should().BeNull();
|
||||
result.BumpedManifest.DsseSignature.Should().BeNull();
|
||||
result.BumpedManifest.RekorAnchor.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_CreatesHistoryEntry()
|
||||
{
|
||||
var current = CreateTestManifest("v2026-01-17-2");
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Algorithm update");
|
||||
|
||||
result.HistoryEntry.Should().NotBeNull();
|
||||
result.HistoryEntry!.PreviousVersion.Should().Be("v2026-01-17-2");
|
||||
result.HistoryEntry.NewVersion.Should().Be("v2026-01-18-1");
|
||||
result.HistoryEntry.Reason.Should().Be("Algorithm update");
|
||||
result.HistoryEntry.BumpedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
result.HistoryEntry.Changes.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_IncludesComparison()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Algorithm update");
|
||||
|
||||
result.Comparison.Should().NotBeNull();
|
||||
result.Comparison!.RequiresBump.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bump_EmptyReason_Throws()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
|
||||
var act = () => _versioner.Bump(current, proposed, "");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BumpAndSignAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BumpAndSignAsync_SignsManifest()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = testSecret
|
||||
};
|
||||
|
||||
var result = await _versioner.BumpAndSignAsync(current, proposed, "Updated", signingOptions);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.BumpedManifest.Should().NotBeNull();
|
||||
result.BumpedManifest!.ManifestDigest.Should().NotBeNull();
|
||||
result.BumpedManifest.DsseSignature.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BumpAndSignAsync_NoChanges_ReturnsOriginal()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = testSecret
|
||||
};
|
||||
|
||||
var result = await _versioner.BumpAndSignAsync(manifest, manifest, "No changes", signingOptions);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.BumpRequired.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BumpAndSignAsync_WithAnchorService_AnchorsManifest()
|
||||
{
|
||||
var signingService = new ScoringManifestSigningService();
|
||||
var stubRekorClient = new StubRekorSubmissionClient(_timeProvider);
|
||||
var anchorService = new ScoringManifestRekorAnchorService(stubRekorClient, _timeProvider);
|
||||
var versioner = new ScoringManifestVersioner(signingService, anchorService, _timeProvider);
|
||||
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with { CodeHash = "sha256:newcodehash" };
|
||||
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = testSecret
|
||||
};
|
||||
var anchorOptions = new ManifestAnchorOptions
|
||||
{
|
||||
RekorUrl = "https://rekor.sigstore.dev"
|
||||
};
|
||||
|
||||
var result = await versioner.BumpAndSignAsync(current, proposed, "Updated", signingOptions, anchorOptions);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.BumpedManifest.Should().NotBeNull();
|
||||
result.BumpedManifest!.RekorAnchor.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_Bump_Sign_Verify()
|
||||
{
|
||||
var signingService = new ScoringManifestSigningService();
|
||||
var versioner = new ScoringManifestVersioner(signingService, null, _timeProvider);
|
||||
|
||||
var current = CreateTestManifest("v2026-01-17-3");
|
||||
var proposed = current with
|
||||
{
|
||||
Weights = ScoringWeights.Default with { Epss = 0.25 }
|
||||
};
|
||||
|
||||
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = testSecret
|
||||
};
|
||||
|
||||
// Bump and sign
|
||||
var bumpResult = await versioner.BumpAndSignAsync(current, proposed, "Updated EPSS weight", signingOptions);
|
||||
|
||||
bumpResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
// Verify signature
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = testSecret
|
||||
};
|
||||
var verifyResult = await signingService.VerifyAsync(bumpResult.BumpedManifest!, verifyOptions);
|
||||
|
||||
verifyResult.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryEntry_TracksAllChanges()
|
||||
{
|
||||
var current = CreateTestManifest();
|
||||
var proposed = current with
|
||||
{
|
||||
Weights = new ScoringWeights
|
||||
{
|
||||
CvssBase = 0.30,
|
||||
Epss = 0.25,
|
||||
Reachability = 0.20,
|
||||
ExploitMaturity = 0.15,
|
||||
PatchProofConfidence = 0.10
|
||||
},
|
||||
CodeHash = "sha256:newcodehash"
|
||||
};
|
||||
|
||||
var result = _versioner.Bump(current, proposed, "Major update");
|
||||
|
||||
result.HistoryEntry!.Changes.Should().HaveCountGreaterThan(1);
|
||||
result.HistoryEntry.Changes.Should().Contain(c => c.Field == "code_hash");
|
||||
result.HistoryEntry.Changes.Should().Contain(c => c.Field.StartsWith("weights."));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
|
||||
// Task: TASK-028-005 - Scoring Manifest Rekor Anchoring
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.DeltaVerdict.Manifest;
|
||||
using StellaOps.DeltaVerdict.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Signing;
|
||||
|
||||
public class ScoringManifestRekorAnchorServiceTests
|
||||
{
|
||||
private readonly ScoringManifestSigningService _signingService = new();
|
||||
private readonly string _testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
private const string TestKeyId = "test-key-001";
|
||||
private const string TestRekorUrl = "https://rekor.sigstore.dev";
|
||||
|
||||
private static ScoringManifest CreateTestManifest()
|
||||
{
|
||||
return new ScoringManifest
|
||||
{
|
||||
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
|
||||
ScoringVersion = "v2026-01-18-1",
|
||||
Weights = ScoringWeights.Default,
|
||||
Normalizers = ScoringNormalizers.Default,
|
||||
TrustedVexKeys = ImmutableArray.Create("key1", "key2"),
|
||||
CodeHash = "sha256:abc123def456",
|
||||
CreatedAt = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ScoringManifest> CreateSignedManifest()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
return await _signingService.SignAsync(manifest, signingOptions);
|
||||
}
|
||||
|
||||
#region Anchor Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_SignedManifest_Succeeds()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
|
||||
var result = await service.AnchorAsync(signedManifest, options);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.AnchoredManifest.Should().NotBeNull();
|
||||
result.Linkage.Should().NotBeNull();
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_PopulatesRekorLinkage()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
|
||||
var result = await service.AnchorAsync(signedManifest, options);
|
||||
|
||||
result.AnchoredManifest!.RekorAnchor.Should().NotBeNull();
|
||||
result.AnchoredManifest.RekorAnchor!.Uuid.Should().NotBeNullOrEmpty();
|
||||
result.AnchoredManifest.RekorAnchor.LogIndex.Should().BeGreaterThan(0);
|
||||
result.AnchoredManifest.RekorAnchor.IntegratedTime.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_PopulatesInclusionProof()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
|
||||
var result = await service.AnchorAsync(signedManifest, options);
|
||||
|
||||
result.AnchoredManifest!.RekorAnchor!.InclusionProof.Should().NotBeNull();
|
||||
result.AnchoredManifest.RekorAnchor.InclusionProof!.TreeSize.Should().BeGreaterThan(0);
|
||||
result.AnchoredManifest.RekorAnchor.InclusionProof.RootHash.Should().NotBeNullOrEmpty();
|
||||
result.AnchoredManifest.RekorAnchor.InclusionProof.LogId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_UnsignedManifest_Fails()
|
||||
{
|
||||
var unsignedManifest = CreateTestManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
|
||||
var result = await service.AnchorAsync(unsignedManifest, options);
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("signed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_InvalidDsseSignature_Fails()
|
||||
{
|
||||
var manifest = CreateTestManifest() with { DsseSignature = "invalid json {" };
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
|
||||
var result = await service.AnchorAsync(manifest, options);
|
||||
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid DSSE signature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnchorAsync_SameManifest_ProducesSameUuid()
|
||||
{
|
||||
// Deterministic UUID based on content
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
|
||||
var result1 = await service.AnchorAsync(signedManifest, options);
|
||||
|
||||
// Create a new service instance but resubmit same manifest
|
||||
var stubClient2 = new StubRekorSubmissionClient();
|
||||
var service2 = new ScoringManifestRekorAnchorService(stubClient2);
|
||||
var result2 = await service2.AnchorAsync(signedManifest, options);
|
||||
|
||||
// UUIDs should be the same since they're derived from content
|
||||
result1.AnchoredManifest!.RekorAnchor!.Uuid.Should().Be(
|
||||
result2.AnchoredManifest!.RekorAnchor!.Uuid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verify Anchor Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_ValidAnchor_Succeeds()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var anchorOptions = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
var anchorResult = await service.AnchorAsync(signedManifest, anchorOptions);
|
||||
|
||||
var verifyOptions = new ManifestAnchorVerificationOptions
|
||||
{
|
||||
RequireInclusionProof = true,
|
||||
VerifyInclusionProof = true
|
||||
};
|
||||
|
||||
var result = await service.VerifyAnchorAsync(anchorResult.AnchoredManifest!, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.VerifiedUuid.Should().NotBeNullOrEmpty();
|
||||
result.VerifiedLogIndex.Should().BeGreaterThan(0);
|
||||
result.VerifiedIntegratedTime.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_NoAnchor_Fails()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var verifyOptions = new ManifestAnchorVerificationOptions();
|
||||
|
||||
var result = await service.VerifyAnchorAsync(signedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("no Rekor anchor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_NoInclusionProof_FailsWhenRequired()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
// Manually add anchor without inclusion proof
|
||||
var manifestWithAnchor = signedManifest with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 123,
|
||||
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
InclusionProof = null
|
||||
}
|
||||
};
|
||||
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var verifyOptions = new ManifestAnchorVerificationOptions
|
||||
{
|
||||
RequireInclusionProof = true
|
||||
};
|
||||
|
||||
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("no inclusion proof");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_NoInclusionProof_SucceedsWhenNotRequired()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var manifestWithAnchor = signedManifest with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 123,
|
||||
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
InclusionProof = null
|
||||
}
|
||||
};
|
||||
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var verifyOptions = new ManifestAnchorVerificationOptions
|
||||
{
|
||||
RequireInclusionProof = false
|
||||
};
|
||||
|
||||
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_FutureTimestamp_Fails()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var futureTime = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
|
||||
var manifestWithAnchor = signedManifest with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 123,
|
||||
IntegratedTime = futureTime,
|
||||
InclusionProof = new InclusionProof
|
||||
{
|
||||
TreeSize = 100,
|
||||
RootHash = "abc123",
|
||||
Hashes = ImmutableArray<string>.Empty,
|
||||
LogId = "test-log"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var verifyOptions = new ManifestAnchorVerificationOptions();
|
||||
|
||||
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("future");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAnchorAsync_OldAnchor_FailsWhenMaxAgeExceeded()
|
||||
{
|
||||
var signedManifest = await CreateSignedManifest();
|
||||
var oldTime = DateTimeOffset.UtcNow.AddHours(-25).ToUnixTimeSeconds();
|
||||
var manifestWithAnchor = signedManifest with
|
||||
{
|
||||
RekorAnchor = new RekorLinkage
|
||||
{
|
||||
Uuid = "test-uuid",
|
||||
LogIndex = 123,
|
||||
IntegratedTime = oldTime,
|
||||
InclusionProof = new InclusionProof
|
||||
{
|
||||
TreeSize = 100,
|
||||
RootHash = "abc123",
|
||||
Hashes = ImmutableArray<string>.Empty,
|
||||
LogId = "test-log"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var service = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var verifyOptions = new ManifestAnchorVerificationOptions
|
||||
{
|
||||
MaxAgeHours = 24
|
||||
};
|
||||
|
||||
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("older than 24 hours");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_Sign_Anchor_Verify_Succeeds()
|
||||
{
|
||||
// Sign
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedManifest = await _signingService.SignAsync(manifest, signingOptions);
|
||||
|
||||
// Anchor
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var anchorService = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var anchorOptions = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
var anchorResult = await anchorService.AnchorAsync(signedManifest, anchorOptions);
|
||||
anchorResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
// Verify signing
|
||||
var verifySigningOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signatureResult = await _signingService.VerifyAsync(anchorResult.AnchoredManifest!, verifySigningOptions);
|
||||
signatureResult.IsValid.Should().BeTrue();
|
||||
|
||||
// Verify anchor
|
||||
var verifyAnchorOptions = new ManifestAnchorVerificationOptions
|
||||
{
|
||||
RequireInclusionProof = true,
|
||||
VerifyInclusionProof = true
|
||||
};
|
||||
var anchorVerifyResult = await anchorService.VerifyAnchorAsync(anchorResult.AnchoredManifest!, verifyAnchorOptions);
|
||||
anchorVerifyResult.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_PreservesManifestData()
|
||||
{
|
||||
var originalManifest = CreateTestManifest();
|
||||
|
||||
// Sign
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
var signedManifest = await _signingService.SignAsync(originalManifest, signingOptions);
|
||||
|
||||
// Anchor
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var anchorService = new ScoringManifestRekorAnchorService(stubClient);
|
||||
var anchorOptions = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
|
||||
var result = await anchorService.AnchorAsync(signedManifest, anchorOptions);
|
||||
|
||||
// Verify original data is preserved
|
||||
var finalManifest = result.AnchoredManifest!;
|
||||
finalManifest.SchemaVersion.Should().Be(originalManifest.SchemaVersion);
|
||||
finalManifest.ScoringVersion.Should().Be(originalManifest.ScoringVersion);
|
||||
finalManifest.Weights.Should().Be(originalManifest.Weights);
|
||||
finalManifest.Normalizers.Should().Be(originalManifest.Normalizers);
|
||||
finalManifest.TrustedVexKeys.Should().BeEquivalentTo(originalManifest.TrustedVexKeys);
|
||||
finalManifest.CodeHash.Should().Be(originalManifest.CodeHash);
|
||||
finalManifest.CreatedAt.Should().Be(originalManifest.CreatedAt);
|
||||
|
||||
// And adds signing/anchoring data
|
||||
finalManifest.ManifestDigest.Should().NotBeNull();
|
||||
finalManifest.DsseSignature.Should().NotBeNull();
|
||||
finalManifest.RekorAnchor.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stub Client Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StubClient_IncrementsLogIndex()
|
||||
{
|
||||
var stubClient = new StubRekorSubmissionClient();
|
||||
var request = new ManifestRekorSubmissionRequest
|
||||
{
|
||||
PayloadType = "test",
|
||||
PayloadBase64 = "dGVzdA==",
|
||||
Signatures = [],
|
||||
BundleSha256 = "abc123",
|
||||
ArtifactKind = "test",
|
||||
ArtifactSha256 = "def456"
|
||||
};
|
||||
|
||||
var response1 = await stubClient.SubmitAsync(request, TestRekorUrl);
|
||||
var response2 = await stubClient.SubmitAsync(request, TestRekorUrl);
|
||||
|
||||
response1.LogIndex.Should().Be(1);
|
||||
response2.LogIndex.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StubClient_ProducesConsistentUuidForSameContent()
|
||||
{
|
||||
var stubClient1 = new StubRekorSubmissionClient();
|
||||
var stubClient2 = new StubRekorSubmissionClient();
|
||||
var request = new ManifestRekorSubmissionRequest
|
||||
{
|
||||
PayloadType = "test",
|
||||
PayloadBase64 = "dGVzdA==",
|
||||
Signatures = [],
|
||||
BundleSha256 = "abc123",
|
||||
ArtifactKind = "test",
|
||||
ArtifactSha256 = "def456"
|
||||
};
|
||||
|
||||
var response1 = await stubClient1.SubmitAsync(request, TestRekorUrl);
|
||||
var response2 = await stubClient2.SubmitAsync(request, TestRekorUrl);
|
||||
|
||||
response1.Uuid.Should().Be(response2.Uuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StubClient_UsesFakeTimeProvider()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
var stubClient = new StubRekorSubmissionClient(fakeTime);
|
||||
var request = new ManifestRekorSubmissionRequest
|
||||
{
|
||||
PayloadType = "test",
|
||||
PayloadBase64 = "dGVzdA==",
|
||||
Signatures = [],
|
||||
BundleSha256 = "abc123",
|
||||
ArtifactKind = "test",
|
||||
ArtifactSha256 = "def456"
|
||||
};
|
||||
|
||||
var response = await stubClient.SubmitAsync(request, TestRekorUrl);
|
||||
|
||||
response.IntegratedTime.Should().Be(fakeTime.GetUtcNow().ToUnixTimeSeconds());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
|
||||
// Task: TASK-028-004 - Scoring Manifest DSSE Signing
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using StellaOps.DeltaVerdict.Manifest;
|
||||
using StellaOps.DeltaVerdict.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.DeltaVerdict.Tests.Signing;
|
||||
|
||||
public class ScoringManifestSigningServiceTests
|
||||
{
|
||||
private readonly ScoringManifestSigningService _service = new();
|
||||
private readonly string _testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
private const string TestKeyId = "test-key-001";
|
||||
|
||||
private static ScoringManifest CreateTestManifest()
|
||||
{
|
||||
return new ScoringManifest
|
||||
{
|
||||
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
|
||||
ScoringVersion = "v2026-01-18-1",
|
||||
Weights = ScoringWeights.Default,
|
||||
Normalizers = ScoringNormalizers.Default,
|
||||
TrustedVexKeys = ImmutableArray.Create("key1", "key2"),
|
||||
CodeHash = "sha256:abc123def456",
|
||||
CreatedAt = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
#region Sign/Verify Round-Trip Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesValidSignature()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var options = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, options);
|
||||
|
||||
signedManifest.ManifestDigest.Should().NotBeNullOrEmpty();
|
||||
signedManifest.ManifestDigest.Should().StartWith("sha256:");
|
||||
signedManifest.DsseSignature.Should().NotBeNullOrEmpty();
|
||||
signedManifest.DsseSignature.Should().Contain("payloadType");
|
||||
signedManifest.DsseSignature.Should().Contain("payload");
|
||||
signedManifest.DsseSignature.Should().Contain("signatures");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_VerifyAsync_RoundTrip_Succeeds()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.VerifiedKeyId.Should().Be(TestKeyId);
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_ProducesCorrectPayloadType()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var options = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, options);
|
||||
|
||||
signedManifest.DsseSignature.Should().Contain(ScoringManifestSigningService.PayloadType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_IsDeterministic()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var options = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signed1 = await _service.SignAsync(manifest, options);
|
||||
var signed2 = await _service.SignAsync(manifest, options);
|
||||
|
||||
signed1.ManifestDigest.Should().Be(signed2.ManifestDigest);
|
||||
signed1.DsseSignature.Should().Be(signed2.DsseSignature);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tamper Detection Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DetectsTamperedWeight()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
// Tamper with the manifest by changing a weight
|
||||
var tamperedManifest = signedManifest with
|
||||
{
|
||||
Weights = ScoringWeights.Default with { CvssBase = 0.99 }
|
||||
};
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(tamperedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("modified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DetectsTamperedScoringVersion()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
// Tamper with the scoring version
|
||||
var tamperedManifest = signedManifest with
|
||||
{
|
||||
ScoringVersion = "v2026-01-19-1"
|
||||
};
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(tamperedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DetectsTamperedTrustedVexKeys()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
// Tamper by adding a malicious key
|
||||
var tamperedManifest = signedManifest with
|
||||
{
|
||||
TrustedVexKeys = ImmutableArray.Create("key1", "key2", "malicious-key")
|
||||
};
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(tamperedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Wrong Key Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailsWithWrongSecret()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
var wrongSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = wrongSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("verification failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailsWithWrongKeyId()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = "wrong-key-id",
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unsigned Manifest Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailsForUnsignedManifest()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(manifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("not signed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailsForInvalidSignatureJson()
|
||||
{
|
||||
var manifest = CreateTestManifest() with
|
||||
{
|
||||
DsseSignature = "not valid json {"
|
||||
};
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = _testSecret
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(manifest, verifyOptions);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid signature envelope");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Digest Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_ProducesSha256Digest()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
var digest = _service.ComputeDigest(manifest);
|
||||
|
||||
digest.Should().StartWith("sha256:");
|
||||
digest.Should().HaveLength(71); // "sha256:" (7) + 64 hex chars
|
||||
digest.Substring(7).Should().MatchRegex("^[0-9a-f]{64}$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_IsDeterministic()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
var digest1 = _service.ComputeDigest(manifest);
|
||||
var digest2 = _service.ComputeDigest(manifest);
|
||||
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_DiffersForDifferentManifests()
|
||||
{
|
||||
var manifest1 = CreateTestManifest();
|
||||
var manifest2 = CreateTestManifest() with { ScoringVersion = "v2026-01-19-1" };
|
||||
|
||||
var digest1 = _service.ComputeDigest(manifest1);
|
||||
var digest2 = _service.ComputeDigest(manifest2);
|
||||
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Canonical JSON Tests
|
||||
|
||||
[Fact]
|
||||
public void GetCanonicalJson_ProducesValidJson()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
var json = _service.GetCanonicalJson(manifest);
|
||||
|
||||
json.Should().NotBeNullOrEmpty();
|
||||
json.Should().Contain("\"schema_version\"");
|
||||
json.Should().Contain("\"scoring_version\"");
|
||||
json.Should().Contain("\"weights\"");
|
||||
json.Should().Contain("\"normalizers\"");
|
||||
json.Should().Contain("\"trusted_vex_keys\"");
|
||||
json.Should().Contain("\"code_hash\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCanonicalJson_ExcludesSignatureFields()
|
||||
{
|
||||
var manifest = CreateTestManifest() with
|
||||
{
|
||||
ManifestDigest = "sha256:test",
|
||||
DsseSignature = "test-signature"
|
||||
};
|
||||
|
||||
var json = _service.GetCanonicalJson(manifest);
|
||||
|
||||
json.Should().NotContain("manifest_digest");
|
||||
json.Should().NotContain("dsse_signature");
|
||||
json.Should().NotContain("rekor_anchor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCanonicalJson_IsDeterministic_100Iterations()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => _service.GetCanonicalJson(manifest))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
results.Should().ContainSingle("All 100 serializations should produce identical output");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Algorithm Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithSha256Algorithm_Works()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var signingOptions = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.Sha256
|
||||
};
|
||||
|
||||
var signedManifest = await _service.SignAsync(manifest, signingOptions);
|
||||
|
||||
signedManifest.DsseSignature.Should().NotBeNullOrEmpty();
|
||||
|
||||
var verifyOptions = new ManifestVerificationOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.Sha256
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_WithoutSecret_ThrowsForHmac()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var options = new ManifestSigningOptions
|
||||
{
|
||||
KeyId = TestKeyId,
|
||||
Algorithm = ManifestSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = null
|
||||
};
|
||||
|
||||
var act = async () => await _service.SignAsync(manifest, options);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*secret*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -12,5 +12,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -24,4 +24,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -17,4 +17,8 @@
|
||||
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -19,4 +19,8 @@
|
||||
<ProjectReference Include="../../StellaOps.HybridLogicalClock/StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -26,4 +26,8 @@
|
||||
<ProjectReference Include="../../StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -21,4 +21,8 @@
|
||||
<ProjectReference Include="..\..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
Reference in New Issue
Block a user