diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18606e98..ab3b5ada 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,12 @@ jobs: - name: Catalog registry trust floor run: python3 scripts/check-catalog-registry-trust.py + # A stale image literal on the fallback install path deploys an old + # image after the manifest has moved on — how a withdrawn, vulnerable + # release gets installed post-fix. Blocking. + - name: Installer image pins + run: python3 scripts/check-installer-image-pins.py + # Advisory: shows where the release catalog has fallen behind the # manifests in this repo. Not blocking, because the catalog can only be # updated through the signing ceremony, so drift is expected between a diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 0f67436b..59b0f61b 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -52,13 +52,13 @@ { "id": "btcpay-server", "title": "BTCPay Server", - "version": "2.3.9", + "version": "2.4.2", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", "icon": "/assets/img/app-icons/btcpay-server.png", "author": "BTCPay Server Foundation", "category": "commerce", "tier": "core", - "dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9", + "dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2", "repoUrl": "https://github.com/btcpayserver/btcpayserver", "requires": [ "bitcoin-knots" diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index 91bec6e0..fd503af5 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -1076,7 +1076,7 @@ impl RpcHandler { let images = [ &format!("{}/postgres:15.17", REGISTRY), &format!("{}/nbxplorer:2.6.0", REGISTRY), - "docker.io/btcpayserver/btcpayserver:2.3.9", + "docker.io/btcpayserver/btcpayserver:2.4.2", ]; self.set_install_phase("btcpay-server", InstallPhase::PullingImage) .await; @@ -1233,7 +1233,7 @@ impl RpcHandler { "BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true", db_pass ), - "docker.io/btcpayserver/btcpayserver:2.3.9", + "docker.io/btcpayserver/btcpayserver:2.4.2", ]) .output() .await diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index 749765da..be950f00 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -230,9 +230,54 @@ pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option< return None; } + // Never advertise a LOWER version as an update. + // + // Everything upstream of here is a version claim that can go stale: the + // signed catalog, a legacy catalog entry with no manifest, the + // image-versions.sh baseline pin. When one lags behind what a node is + // actually running, a bare `pinned != running` check turns that staleness + // into an "Update" button that rolls the node BACKWARDS — and a rollback + // to a version withdrawn for a vulnerability is precisely the case where + // that must not happen. Observed with BTCPay: 2.4.2 installed, a stale + // 2.3.9 pin, and the UI offering "update" to the exploited release. + // + // Only suppress when both tags parse as comparable version numbers, so + // apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3) + // keep the previous behaviour rather than silently losing updates. + if let (Some(p), Some(r)) = ( + parse_version_parts(&pinned_version), + parse_version_parts(&running_version), + ) { + if p < r { + return None; + } + } + Some(pinned_version) } +/// Numeric components of a version tag, for ordering comparisons only. +/// +/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`), +/// comparing on the dotted numbers alone. Returns None when the tag is not a +/// recognisable dotted-numeric version, which the caller treats as "cannot +/// order these" rather than as equality. +fn parse_version_parts(tag: &str) -> Option> { + let core = tag.strip_prefix('v').unwrap_or(tag); + // Drop a pre-release/build suffix: 0.18.4-beta -> 0.18.4 + let core = core.split(['-', '+', '_']).next().unwrap_or(core); + if core.is_empty() { + return None; + } + let parts: Vec<&str> = core.split('.').collect(); + let mut out = Vec::with_capacity(parts.len()); + for part in parts { + // Any non-numeric component makes the whole tag unorderable. + out.push(part.parse::().ok()?); + } + Some(out) +} + /// Extract version tag from a full image reference. /// e.g. "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta" /// Returns "latest" if no tag or tag is empty. @@ -417,4 +462,63 @@ NOT_AN_IMAGE="something" ); assert_eq!(image_var_for_app("unknown-app"), None); } + + /// The BTCPay case that prompted the guard: 2.4.2 shipped for an actively + /// exploited 2FA bypass, a stale 2.3.9 pin left in a legacy catalog entry, + /// and the UI offering the withdrawn release as an "update". + #[test] + fn never_advertises_a_downgrade_as_an_update() { + let stale = "docker.io/btcpayserver/btcpayserver:2.3.9"; + let running = "docker.io/btcpayserver/btcpayserver:2.4.2"; + assert_eq!(available_update_for_images(stale, running), None); + } + + #[test] + fn still_advertises_a_genuine_upgrade() { + let pinned = "docker.io/btcpayserver/btcpayserver:2.4.2"; + let running = "docker.io/btcpayserver/btcpayserver:2.3.9"; + assert_eq!( + available_update_for_images(pinned, running), + Some("2.4.2".to_string()) + ); + } + + #[test] + fn equal_versions_offer_nothing() { + let same = "docker.io/btcpayserver/btcpayserver:2.4.2"; + assert_eq!(available_update_for_images(same, same), None); + } + + #[test] + fn prerelease_suffixes_compare_on_their_numbers() { + let older = "example.test/lfg2025/lnd:v0.18.3-beta"; + let newer = "example.test/lfg2025/lnd:v0.18.4-beta"; + assert_eq!(available_update_for_images(older, newer), None); + assert_eq!( + available_update_for_images(newer, older), + Some("v0.18.4-beta".to_string()) + ); + } + + /// Opaque tags stay on the old behaviour: we cannot order them, so a + /// difference is still reported rather than silently swallowed. + #[test] + fn unorderable_tags_keep_previous_behaviour() { + let a = "example.test/lfg2025/minio:RELEASE.2024-11-07T00-52-20Z"; + let b = "example.test/lfg2025/minio:RELEASE.2024-10-01T00-00-00Z"; + assert_eq!( + available_update_for_images(a, b), + Some("RELEASE.2024-11-07T00-52-20Z".to_string()) + ); + } + + #[test] + fn parse_version_parts_rejects_non_numeric() { + assert_eq!(parse_version_parts("2.4.2"), Some(vec![2, 4, 2])); + assert_eq!(parse_version_parts("v0.18.4-beta"), Some(vec![0, 18, 4])); + assert_eq!(parse_version_parts("28.4"), Some(vec![28, 4])); + assert_eq!(parse_version_parts("RELEASE.2024-11-07T00-52-20Z"), None); + assert_eq!(parse_version_parts("14-vectorchord0.4.3"), Some(vec![14])); + assert_eq!(parse_version_parts("latest"), None); + } } diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 0f67436b..59b0f61b 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -52,13 +52,13 @@ { "id": "btcpay-server", "title": "BTCPay Server", - "version": "2.3.9", + "version": "2.4.2", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", "icon": "/assets/img/app-icons/btcpay-server.png", "author": "BTCPay Server Foundation", "category": "commerce", "tier": "core", - "dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9", + "dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2", "repoUrl": "https://github.com/btcpayserver/btcpayserver", "requires": [ "bitcoin-knots" diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 67edc76d..cee22849 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -84,7 +84,7 @@ export function getCuratedAppList(): MarketplaceApp[] { return [ { id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' }, { id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' }, - { id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' }, + { id: 'btcpay-server', title: 'BTCPay Server', version: '2.4.2', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2', repoUrl: 'https://github.com/btcpayserver/btcpayserver' }, { id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.png', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' }, { id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' }, { id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' }, diff --git a/neode-ui/src/views/marketplace/marketplaceData.ts b/neode-ui/src/views/marketplace/marketplaceData.ts index 7e7c72ca..99e54e96 100644 --- a/neode-ui/src/views/marketplace/marketplaceData.ts +++ b/neode-ui/src/views/marketplace/marketplaceData.ts @@ -155,11 +155,11 @@ export function getCuratedAppList(): MarketplaceApp[] { { id: 'btcpay-server', title: 'BTCPay Server', - version: '2.3.9', + version: '2.4.2', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', - dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', + dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2', manifestUrl: undefined, repoUrl: 'https://github.com/btcpayserver/btcpayserver' }, diff --git a/scripts/check-installer-image-pins.py b/scripts/check-installer-image-pins.py new file mode 100755 index 00000000..9c9dca37 --- /dev/null +++ b/scripts/check-installer-image-pins.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Fail when a hardcoded installer image tag disagrees with the app manifest. + +The legacy stack installers in core/archipelago/src/api/rpc/package/stacks.rs +carry image references as string literals. Those literals are a second source of +truth for a version, sitting behind the manifest and the signed catalog, and +nothing keeps them in step. + +That is not cosmetic. BTCPay shipped 2.4.2 for an actively exploited 2FA bypass +on 2026-08-07 while the legacy installer still named 2.3.9, so the fallback +install path would have deployed the withdrawn release. The same shape applies +to any app whose installer literal is left behind. + +The rule enforced here: if an installer literal names the same image repository +as an app manifest, the tags must match. Repositories with no manifest are +ignored, and so are floating tags, which carry no version claim. + +Usage: + scripts/check-installer-image-pins.py + scripts/check-installer-image-pins.py --show +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import yaml + +# Files that pin images as literals on an install path. Test modules inside them +# are stripped before scanning: fixtures deliberately name old versions. +INSTALLER_SOURCES = [ + "core/archipelago/src/api/rpc/package/stacks.rs", +] + +FLOATING_TAGS = {"latest", "stable", "release", "main", "edge"} + +IMAGE_RE = re.compile(r'"([a-z0-9][a-z0-9._-]*(?:\.[a-z]+|:[0-9]+)?/[a-z0-9._/-]+:[A-Za-z0-9._-]+)"') + + +def strip_test_modules(text: str) -> str: + """Remove #[cfg(test)] modules so fixture literals are not treated as pins.""" + marker = "#[cfg(test)]" + idx = text.find(marker) + return text if idx == -1 else text[:idx] + + +def repo_of(image: str) -> str: + """Image repository without registry host or tag.""" + without_tag = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image + head, _, rest = without_tag.partition("/") + if "." in head or ":" in head or head == "localhost": + return rest + return without_tag + + +def tag_of(image: str) -> str: + last = image.rsplit("/", 1)[-1] + return last.rsplit(":", 1)[1] if ":" in last else "latest" + + +def manifest_images(repo_root: Path) -> dict[str, tuple[str, str]]: + """{image repo: (tag, manifest path)} across apps/*/manifest.yml.""" + out: dict[str, tuple[str, str]] = {} + for path in sorted((repo_root / "apps").glob("*/manifest.yml")): + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + continue + app = (data or {}).get("app") + if not isinstance(app, dict): + continue + image = (app.get("container") or {}).get("image") + if isinstance(image, str) and image: + out[repo_of(image)] = (tag_of(image), str(path.relative_to(repo_root))) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--repo", default=".") + parser.add_argument("--show", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo) + manifests = manifest_images(repo_root) + problems: list[str] = [] + checked = 0 + + for rel in INSTALLER_SOURCES: + path = repo_root / rel + if not path.exists(): + continue + text = strip_test_modules(path.read_text(encoding="utf-8")) + for line_no, line in enumerate(text.splitlines(), start=1): + for image in IMAGE_RE.findall(line): + repo = repo_of(image) + if repo not in manifests: + continue + tag = tag_of(image) + manifest_tag, manifest_path = manifests[repo] + checked += 1 + if args.show: + print(f" {rel}:{line_no} {repo}:{tag} (manifest {manifest_tag})") + if tag in FLOATING_TAGS or manifest_tag in FLOATING_TAGS: + continue + if tag != manifest_tag: + problems.append( + f"{rel}:{line_no}\n" + f" installer pins {repo}:{tag}\n" + f" manifest wants {repo}:{manifest_tag} ({manifest_path})" + ) + + if problems: + print("") + print("Installer image pins disagree with their app manifests:") + for problem in problems: + print(f"\n {problem}") + print("") + print("An installer literal left behind deploys the older image on the") + print("fallback install path — which is how a withdrawn, vulnerable") + print("release gets installed after it has supposedly been replaced.") + print("Update the literal to match the manifest.") + return 1 + + print(f"OK: {checked} installer image pin(s) agree with their app manifests.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 95425ba7..f1e3c24b 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -25,7 +25,7 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.1" MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10" # BTCPay -BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.3.9" +BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2" NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0" POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17" BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17" diff --git a/tests/lifecycle/remote-lifecycle.sh b/tests/lifecycle/remote-lifecycle.sh index a9b1a2f4..a761d106 100755 --- a/tests/lifecycle/remote-lifecycle.sh +++ b/tests/lifecycle/remote-lifecycle.sh @@ -135,7 +135,7 @@ image_for() { case "$1" in bitcoin-knots) echo "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest" ;; bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;; - btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.3.9" ;; + btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.4.2" ;; lnd) echo "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta" ;; mempool) echo "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0" ;; homeassistant) echo "source.archipelago-foundation.org/lfg2025/home-assistant:2024.1" ;;