Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ba8d936
feat(secswitch): scaffold plugin and test project
Kukks Aug 25, 2026
c3c8d37
fix(secswitch): correct BouncyCastle scaffold-test guarantee
Kukks Aug 25, 2026
bc9d10a
feat(secswitch): advisory model and fail-closed parser
Kukks Aug 25, 2026
207ee0b
fix(secswitch): close three fail-open gaps in advisory parser
Kukks Aug 25, 2026
eda7d06
fix(secswitch): replace severity numeric guard with an explicit allow…
Kukks Aug 25, 2026
b143e4e
feat(secswitch): fail-closed advisory applicability matching
Kukks Aug 25, 2026
dd0ef29
fix(secswitch): fail closed on null input, case-insensitive plugin match
Kukks Aug 25, 2026
bb070a9
fix(secswitch): treat a null resolved version as indeterminate, not f…
Kukks Aug 25, 2026
7811fb2
feat(secswitch): PGP detached signature verification with distinct-ke…
Kukks Aug 25, 2026
8574ae6
fix(secswitch): brute-force trusted-key verification, drop KeyId lookup
Kukks Aug 25, 2026
e607ba4
fix(secswitch): validate subkey bindings, cap parse cost, mark claime…
Kukks Aug 25, 2026
3a96fbb
fix(secswitch): cross-check trusted rings against their label, fix nu…
Kukks Aug 25, 2026
45d0f0c
test(secswitch): pin non-vacuity for dual-entry poisoning, fix zero-b…
Kukks Aug 25, 2026
eeb6f37
feat(secswitch): quorum-gated trust store key rotation
Kukks Aug 26, 2026
63d0f22
fix(secswitch): share trust-store admission check with AdvisoryVerifier
Kukks Aug 26, 2026
99db731
feat(secswitch): settings, ledger models, and pure policy resolver
Kukks Aug 26, 2026
e7382f5
fix(secswitch): null-safe policy resolver, structured intended action
Kukks Aug 26, 2026
84b160b
feat(secswitch): persisted advisory ledger with suppression
Kukks Aug 26, 2026
349986d
fix(secswitch): case-insensitive ledger keys, static gate, graceful n…
Kukks Aug 26, 2026
f621b36
feat(secswitch): GitHub Pages advisory feed fetcher
Kukks Aug 26, 2026
3fb7d01
fix(secswitch): count successes not attempts, bound total requests, c…
Kukks Aug 26, 2026
a217566
fix(secswitch): require https feed URL, correct scheme-check doc clai…
Kukks Aug 26, 2026
389b2b3
feat(secswitch): add SignaturesComplete to FetchedAdvisory
Kukks Aug 26, 2026
12e894a
fix(secswitch): treat a literal-null signature index as incomplete, n…
Kukks Aug 26, 2026
009615e
feat(secswitch): action executor over BTCPayServer primitives
Kukks Aug 26, 2026
b963be9
fix(secswitch): resolve plugin identifiers to on-disk casing before d…
Kukks Aug 26, 2026
3b0ad52
fix(secswitch): stop sending SIGTERM to a dispatched core update on t…
Kukks Aug 26, 2026
d04029c
fix(secswitch): dispose the SSH client if RunBash throws synchronousl…
Kukks Aug 26, 2026
b4c8185
feat(secswitch): admin notification type
Kukks Aug 26, 2026
cc83cf4
feat(secswitch): advisory processing orchestration
Kukks Aug 26, 2026
8ab0ae9
fix(secswitch): gate re-action on terminal ledger status, not any record
Kukks Aug 26, 2026
f28dcb6
fix(secswitch): withhold content hash unless the fetch was complete A…
Kukks Aug 26, 2026
48b61da
docs(secswitch): correct two overclaims in the R1 hash-caching doc co…
Kukks Aug 26, 2026
f9abed1
feat(secswitch): admin settings, audit log, and signature verify UI
Kukks Aug 26, 2026
fa9e01c
fix(secswitch): address Task 12 admin UI review findings (I1-I4, M1-M…
Kukks Aug 26, 2026
1bf47a6
test(secswitch): fix two tautological suppress-then-assert-acted-on f…
Kukks Aug 26, 2026
7376f01
feat(secswitch): periodic advisory polling, trust bootstrap, and aler…
Kukks Aug 26, 2026
1dc19ab
fix(secswitch): address Task 13 review findings (C1, I1-I4, M1-M2, M4)
Kukks Aug 26, 2026
e38af7c
feat(secswitch): advisory repository template and Pages workflow
Kukks Aug 26, 2026
ccfd6df
fix(secswitch): mark build-index.sh executable in the advisory repo t…
Kukks Aug 26, 2026
db8f6f1
fix(secswitch): make the SSH-verification deferral loud (Task 13 revi…
Kukks Aug 26, 2026
a78d837
fix(secswitch): unblock first-time signing and correct README accurac…
Kukks Aug 26, 2026
95506f3
fix(secswitch): final whole-branch review fix wave
Kukks Aug 26, 2026
448b4ed
fix(secswitch): address PR #151 CodeRabbit review findings
Kukks Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
518 changes: 518 additions & 0 deletions BTCPayServer.Plugins.SecSwitch.Tests/ActionExecutorTests.cs

Large diffs are not rendered by default.

220 changes: 220 additions & 0 deletions BTCPayServer.Plugins.SecSwitch.Tests/AdvisoryApplicabilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using BTCPayServer.Plugins.SecSwitch.Models;
using BTCPayServer.Plugins.SecSwitch.Services;
using Xunit;

namespace BTCPayServer.Plugins.SecSwitch.Tests;

public class AdvisoryApplicabilityTests
{
static Advisory Adv(string identifier, string affected) => new()
{
Id = "x", Identifier = identifier, AffectedVersions = affected,
Severity = AdvisorySeverity.High, Title = "t"
};

static InstanceState State(string pluginId, string pluginVersion, string coreVersion = "2.4.2") =>
new(new Dictionary<string, Version> { [pluginId] = Version.Parse(pluginVersion) },
Version.Parse(coreVersion), CanUseSsh: false);

[Fact]
public void Applicable_when_installed_version_is_in_range()
{
var ok = AdvisoryApplicability.IsApplicable(
Adv("Plug", ">=1.0.0 && <1.2.3"), State("Plug", "1.1.0"), out var installed, out _);
Assert.True(ok);
Assert.Equal(Version.Parse("1.1.0"), installed);
}

[Fact]
public void Not_applicable_when_installed_version_is_outside_range()
{
var ok = AdvisoryApplicability.IsApplicable(
Adv("Plug", ">=1.0.0 && <1.2.3"), State("Plug", "1.2.3"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("not affected", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Not_applicable_when_plugin_is_not_installed()
{
var ok = AdvisoryApplicability.IsApplicable(
Adv("Other", ">=1.0.0"), State("Plug", "1.1.0"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("not installed", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Core_advisories_match_against_the_core_version()
{
var ok = AdvisoryApplicability.IsApplicable(
Adv("BTCPayServer", "<2.5.0"), State("Plug", "1.0.0", coreVersion: "2.4.2"), out var installed, out _);
Assert.True(ok);
Assert.Equal(Version.Parse("2.4.2"), installed);
}

[Fact]
public void Malformed_version_condition_fails_closed()
{
// ">=1.0.0 <1.2.3" (space-separated) is NOT valid VersionCondition syntax — core's parser
// splits only on && and ||. It must be rejected, never treated as an unconditional match.
var ok = AdvisoryApplicability.IsApplicable(
Adv("Plug", ">=1.0.0 <1.2.3"), State("Plug", "1.1.0"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("could not be parsed", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Empty_condition_does_not_silently_match_everything()
{
// VersionCondition.TryParse("") returns a `Yes` that matches ALL versions. An advisory with a
// blank range must be rejected upstream by the parser, but assert defence in depth here too.
var ok = AdvisoryApplicability.IsApplicable(
Adv("Plug", " "), State("Plug", "1.1.0"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.NotEmpty(reason);
}

[Fact]
public void Core_identifier_match_is_case_insensitive()
{
// Guards against a regression to an ordinal (case-sensitive) comparison for the Core
// match: only "BTCPayServer" is used elsewhere in this file, which would still pass
// even if OrdinalIgnoreCase were dropped.
var ok = AdvisoryApplicability.IsApplicable(
Adv("btcpayserver", "<2.5.0"), State("Plug", "1.0.0", coreVersion: "2.4.2"), out var installed, out _);
Assert.True(ok);
Assert.Equal(Version.Parse("2.4.2"), installed);
}

[Fact]
public void Plugin_lookup_is_case_insensitive_with_a_case_sensitive_dictionary()
{
// State(...) builds a plain Dictionary, which uses the case-sensitive default comparer.
// The advisory identifier here differs in case from the installed key to prove
// IsApplicable performs its own case-insensitive comparison rather than depending on
// whatever comparer the caller happened to build the dictionary with.
var ok = AdvisoryApplicability.IsApplicable(
Adv("plug", ">=1.0.0 && <1.2.3"), State("Plug", "1.1.0"), out var installed, out _);
Assert.True(ok);
Assert.Equal(Version.Parse("1.1.0"), installed);
}

[Fact]
public void Plugin_lookup_is_case_insensitive_with_an_ordinal_ignore_case_dictionary()
{
// Mirrors how BTCPayServer core itself builds the installed-plugin dictionary
// (PluginService.cs uses StringComparer.OrdinalIgnoreCase). Included alongside the
// case-sensitive-dictionary test above to show the match holds either way.
var state = new InstanceState(
new Dictionary<string, Version>(StringComparer.OrdinalIgnoreCase) { ["Plug"] = Version.Parse("1.1.0") },
Version.Parse("2.4.2"), CanUseSsh: false);
var ok = AdvisoryApplicability.IsApplicable(
Adv("PLUG", ">=1.0.0 && <1.2.3"), state, out var installed, out _);
Assert.True(ok);
Assert.Equal(Version.Parse("1.1.0"), installed);
}

[Fact]
public void Null_advisory_fails_closed_without_throwing()
{
var ok = AdvisoryApplicability.IsApplicable(null!, State("Plug", "1.1.0"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.NotEmpty(reason);
}

[Fact]
public void Null_state_fails_closed_without_throwing()
{
var ok = AdvisoryApplicability.IsApplicable(Adv("Plug", ">=1.0.0"), null!, out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.NotEmpty(reason);
}

[Fact]
public void Null_installed_plugins_map_fails_closed_without_throwing()
{
var state = new InstanceState(null!, Version.Parse("2.4.2"), CanUseSsh: false);
var ok = AdvisoryApplicability.IsApplicable(Adv("Plug", ">=1.0.0"), state, out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.NotEmpty(reason);
}

[Fact]
public void Null_identifier_fails_closed_without_throwing()
{
var ok = AdvisoryApplicability.IsApplicable(
Adv(null!, ">=1.0.0"), State("Plug", "1.1.0"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.NotEmpty(reason);
}

[Fact]
public void Blank_identifier_fails_closed_without_throwing()
{
var ok = AdvisoryApplicability.IsApplicable(
Adv(" ", ">=1.0.0"), State("Plug", "1.1.0"), out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.NotEmpty(reason);
}

[Fact]
public void Null_core_version_with_a_would_otherwise_fail_open_operator_is_indeterminate()
{
// Without the dedicated guard, System.Version's operators treat null as "less than
// everything", so null >= 1.0.0 evaluates to false - reading as an ordinary, silent
// "not affected" outcome rather than the indeterminate state it actually is.
var state = new InstanceState(new Dictionary<string, Version>(), null!, CanUseSsh: false);
var ok = AdvisoryApplicability.IsApplicable(
Adv("BTCPayServer", ">=1.0.0"), state, out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("could not be determined", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Null_core_version_with_a_would_otherwise_be_a_misleading_match_is_indeterminate()
{
// Without the dedicated guard, null < 2.5.0 evaluates to true, which would make
// IsApplicable report ok=true while installedVersion is null - "applicable, but we
// don't know the version" is not a real answer.
var state = new InstanceState(new Dictionary<string, Version>(), null!, CanUseSsh: false);
var ok = AdvisoryApplicability.IsApplicable(
Adv("BTCPayServer", "<2.5.0"), state, out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("could not be determined", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Null_plugin_version_with_a_would_otherwise_fail_open_operator_is_indeterminate()
{
var state = new InstanceState(
new Dictionary<string, Version> { ["Plug"] = null! }, Version.Parse("2.4.2"), CanUseSsh: false);
var ok = AdvisoryApplicability.IsApplicable(
Adv("Plug", ">=1.0.0"), state, out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("could not be determined", reason, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Null_plugin_version_with_a_would_otherwise_be_a_misleading_match_is_indeterminate()
{
var state = new InstanceState(
new Dictionary<string, Version> { ["Plug"] = null! }, Version.Parse("2.4.2"), CanUseSsh: false);
var ok = AdvisoryApplicability.IsApplicable(
Adv("Plug", "<1.2.3"), state, out var installed, out var reason);
Assert.False(ok);
Assert.Null(installed);
Assert.Contains("could not be determined", reason, StringComparison.OrdinalIgnoreCase);
}
}
Loading
Loading