From 2fb4bf9eee2eea8934c3e8b38c00dc2078470698 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:57:17 +0100 Subject: [PATCH] Bind release browser qualification to the narrow CI identity Use a dedicated Auth Keys write client restricted to tag:ci-deploy, never the broader demo registration. Reject missing configuration and validate the actual running Self tag and DNS before Serve. Keep exact-source, denial-stop, no-replay and cleanup contracts, with connected offline registration and Serve controls. Change-source: pulse-maintainer --- .github/workflows/README.md | 22 +++ .../qualify-browser-update-release.yml | 28 ++- .../subsystems/deployment-installability.md | 26 ++- scripts/release_browser_tailnet.py | 47 +++++ scripts/tests/test_release_browser_packet.py | 10 +- scripts/tests/test_release_browser_tailnet.py | 164 ++++++++++++++++++ 6 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 scripts/release_browser_tailnet.py create mode 100644 scripts/tests/test_release_browser_tailnet.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f28df761e2..6d2244bf50 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -371,6 +371,28 @@ non-mutating wrapper that verifies the current committed stable target. All stable demo writes require an exact stable tag and activation marker and run from `release-convergence.yml` under the global customer-promotion lease. +## Published-RC browser qualification registration + +`qualify-browser-update-release.yml` must **not** reuse the demo binding above. +It uses the separate repository secrets **TS_CI_DEPLOY_OAUTH_CLIENT_ID** and +**TS_CI_DEPLOY_OAUTH_SECRET** for a dedicated business-tailnet OAuth client with +only **Auth Keys write**, restricted to **tag:ci-deploy**. These names must not +alias/copy the `tag:infra` demo client or a full-access administrative client. +The Tailnet/credential owner verifies that client scope and tag restriction when +configuring the pair; source inspection and the node's observed tag do not prove +OAuth scope. No secret value belongs in source, logs or receipts. + +The workflow fails before joining when either dedicated secret is unavailable; +there is no demo-credential, auth-key or caller-input fallback. It checks the +actual running node's **Self.Tags == ["tag:ci-deploy"]** and its own business +DNS name before starting Serve. Only its own `127.0.0.1:7655` is served over +HTTPS, with no peer selection, SSH, routes or private demo/production target. +This journey needs no expansion of CI access to private services. Cleanup resets +that new Serve route even if route setup fails after the successful join; the +pinned Tailscale action and fresh hosted VM own node teardown. A configured +binding, source tests or a joined node is not native update/recovery acceptance. + + ## Helm CI **File**: `helm-ci.yml` diff --git a/.github/workflows/qualify-browser-update-release.yml b/.github/workflows/qualify-browser-update-release.yml index 2e1d1f9f24..6759814cf9 100644 --- a/.github/workflows/qualify-browser-update-release.yml +++ b/.github/workflows/qualify-browser-update-release.yml @@ -112,21 +112,33 @@ jobs: npx --no-install playwright install --with-deps chromium sudo apt-get update -qq sudo apt-get install -y --no-install-recommends jq curl openssh-client - - name: Register a fresh ephemeral project CI node + - name: Require the dedicated narrow CI registration binding + env: + CI_CLIENT_ID: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }} + CI_CLIENT_SECRET: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }} + run: | + set -euo pipefail + if [[ -z "$CI_CLIENT_ID" || -z "$CI_CLIENT_SECRET" ]]; then + echo 'Dedicated tag:ci-deploy OAuth binding is not configured; no demo credential fallback is permitted.' >&2 + exit 2 + fi + - name: Register a fresh ephemeral narrow CI node + id: tailnet uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3 with: - oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} - oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} - tags: tag:infra + oauth-client-id: ${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }} + tags: tag:ci-deploy version: '1.94.2' - name: Serve only this disposable install id: serve run: | set -euo pipefail - # Never enumerate peer/private targets or use a supplied hostname. - origin=$(tailscale status --json | python3 -c 'import json,re,sys; n=json.load(sys.stdin)["Self"]["DNSName"].rstrip("."); assert re.fullmatch(r"[a-z0-9-]+\.tawny-powan\.ts\.net", n); print("https://"+n)') + # Validate the actual Self tag before Serve. Never select a peer or + # fall back to the demo client's broader tag:infra identity. + origin=$(tailscale status --json | python3 scripts/release_browser_tailnet.py) tailscale serve --bg --https=443 http://127.0.0.1:7655 - echo "origin=$origin" >> "$GITHUB_OUTPUT" + python3 scripts/write_github_output.py origin "$origin" - name: Exercise native browser update and documented recovery env: FROM: ${{ steps.pair.outputs.from }} @@ -142,7 +154,7 @@ jobs: PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/browser-release/browsers" \ bash scripts/release_lifecycle_rehearsal.sh --hosted-browser --from "$FROM" --to "$TAG" - name: Close ephemeral Serve route - if: always() && steps.serve.outcome == 'success' + if: always() && steps.tailnet.outcome == 'success' run: tailscale serve reset - name: Retain bounded identity and terminal receipts, never credentials if: always() diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 4e70640145..a04ff4da33 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6441,10 +6441,20 @@ collision-safe writer. Each installed binary hash must match its signed archive, rather than just a reported version. No rebuilt main binary, frozen-product insertion, candidate re-cut or stable promotion is performed. -A pinned Tailscale action registers an ephemeral project CI node; Serve exposes -only its own loopback Pulse install through its derived `tawny-powan.ts.net` -HTTPS identity. No caller-selected endpoint, peer or previous stopped target is -used. Registration alone receives the existing CI OAuth credential; acceptance +A pinned Tailscale action registers an ephemeral project CI node using only +`tag:ci-deploy` and the dedicated `TS_CI_DEPLOY_OAUTH_CLIENT_ID` / +`TS_CI_DEPLOY_OAUTH_SECRET` binding. Its OAuth client has only Auth Keys write, +restricted to that tag; it must not alias the `tag:infra` demo or full-access +administrative identity. The credential owner verifies that configured scope; +source and Self observations are not proof of OAuth/ACL authority. Missing +dedicated registration configuration stops before join, with no credential, +auth-key or caller-input fallback. `release_browser_tailnet.py` rejects anything +except a running, online Self node with exactly that one tag and its own valid +business DNS name before Serve or origin output. No peers or raw status enter +receipts. Serve exposes only its own loopback Pulse install through that derived +`tawny-powan.ts.net` HTTPS identity; no private-service ACL expansion is needed. +No caller-selected endpoint, peer or previous stopped target is used. Only the +fixed prerequisite/registration steps receive the dedicated OAuth pair; acceptance commands receive a scrubbed environment with no provider, production or publishing credential. State is synthetic, disabled resources and throwaway admin/API authentication, with the existing telemetry opt-out. @@ -6473,7 +6483,8 @@ original auto-update/timer intent after forward update and documented CLI then checks the restored baseline. This is CLI recovery plus browser readback, **not** the separate history-based UI rollback action. Cleanup stops the throwaway service/timer, records its result and fails the process on failure; -the workflow resets the new Serve route and its hosted VM is disposed of. +the workflow resets the new Serve route after any successful join, including +partial route setup failure, and its hosted VM is disposed of. Bounded control, signed packet, installer/binary, phase, browser, cleanup and redacted settings receipts are retained for seven days. Successful screenshots @@ -6488,6 +6499,11 @@ Verification: `scripts/tests/test_release_browser_packet.py` executes immutable release/asset/binary identity rejection, worker-mode exclusion, scrubbed command environment and cleanup exit controls. `test_release_lifecycle_rehearsal.py` executes the recovery-access stop before another browser process can start. +`scripts/tests/test_release_browser_tailnet.py` executes the actual prerequisite +and Serve shell with fixed offline node observations: absent dedicated +configuration cannot borrow the demo credential, broad/extra/missing tags and +foreign/unsafe Self identities cannot start Serve or write an origin. Its binding +controls reject broad demo secret reuse and caller-selected registration inputs. `tests/integration/scripts/release-browser-journey.test.cjs` exercises the real observer against offline Chromium success/recovery, anonymous auth probes, restart transport gaps, wrong-target, silent-SSE and stuck-modal fixtures, plus diff --git a/scripts/release_browser_tailnet.py b/scripts/release_browser_tailnet.py new file mode 100644 index 0000000000..9068d0f96a --- /dev/null +++ b/scripts/release_browser_tailnet.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Validate only the joined CI node's identity before exposing its loopback UI. + +Reads local `tailscale status --json`; makes no network request and never emits +peer inventory, raw status or credentials. OAuth/ACL scope is configured by the +Tailnet owner, not inferred from a DNS name or a tag observation. +""" + +import json +import re +import sys + +MAX_STATUS_BYTES = 1024 * 1024 +ERROR = "Release browser requires a running Self node with only tag:ci-deploy and a business-tailnet DNS name" + + +def self_origin(status): + if not isinstance(status, dict) or status.get("BackendState") != "Running": + raise ValueError(ERROR) + node = status.get("Self") + if (not isinstance(node, dict) or node.get("Online") is not True + or node.get("Tags") != ["tag:ci-deploy"]): + raise ValueError(ERROR) + name = node.get("DNSName") + if not isinstance(name, str): + raise ValueError(ERROR) + name = name.removesuffix(".") + if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.tawny-powan\.ts\.net", name): + raise ValueError(ERROR) + return "https://" + name + + +def main(): + try: + raw = sys.stdin.buffer.read(MAX_STATUS_BYTES + 1) + if len(raw) > MAX_STATUS_BYTES: + raise ValueError(ERROR) + origin = self_origin(json.loads(raw)) + except (ValueError, UnicodeError): + print(ERROR, file=sys.stderr) + return 2 + print(origin) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_release_browser_packet.py b/scripts/tests/test_release_browser_packet.py index c3763d6b6c..2f1e1f3fe7 100644 --- a/scripts/tests/test_release_browser_packet.py +++ b/scripts/tests/test_release_browser_packet.py @@ -138,10 +138,13 @@ class BrowserPacketTest(unittest.TestCase): function = source.split('cexec() {', 1)[1].split('\nprint_diagnostics()', 1)[0] script = 'HOSTED_BROWSER=true\ncexec() {' + function + '\ncexec env EXTRA=fixture\n' result = subprocess.run(['bash', '-c', script], env={'PATH': '/usr/bin:/bin', - 'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret'}, + 'GH_TOKEN': 'synthetic-secret', 'TS_OAUTH_SECRET': 'synthetic-secret', + 'TS_CI_DEPLOY_OAUTH_CLIENT_ID': 'synthetic-narrow-id', + 'TS_CI_DEPLOY_OAUTH_SECRET': 'synthetic-narrow-secret'}, capture_output=True, text=True) self.assertEqual(0, result.returncode, result.stderr) self.assertNotIn('synthetic-secret', result.stdout) + self.assertNotIn('synthetic-narrow', result.stdout) self.assertNotIn('PULSE_INSTALL_ALLOW_DOCKER', result.stdout) self.assertIn('EXTRA=fixture', result.stdout) @@ -152,11 +155,12 @@ class BrowserPacketTest(unittest.TestCase): self.assertNotIn('ignoreHTTPSErrors', (ROOT / 'tests/integration/scripts/release-browser-journey.cjs').read_text()) self.assertIn('test "$GITHUB_WORKFLOW_SHA" = "$EXPECTED_CONTROL"', source) self.assertIn('runs-on: ubuntu-24.04', source) - self.assertIn('"Self"]["DNSName"]', source) + self.assertIn('tailscale status --json | python3 scripts/release_browser_tailnet.py', source) self.assertIn('ssh-keygen -Y verify', source) acceptance = source.split('- name: Exercise native browser update', 1)[1].split('- name: Close', 1)[0] self.assertIn('sudo env -i', acceptance) - for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'): + for name in ('GH_TOKEN', 'TS_OAUTH_SECRET', 'TS_CI_DEPLOY_OAUTH_CLIENT_ID', + 'TS_CI_DEPLOY_OAUTH_SECRET', 'PULSE_ALLOW_DOCKER_UPDATES', 'ignoreHTTPSErrors'): self.assertNotIn(name, acceptance) artifact = source.split('- name: Retain bounded', 1)[1] for private in ('browser-auth.json', 'private-install.log', 'private-updater.log', 'datadir.'): diff --git a/scripts/tests/test_release_browser_tailnet.py b/scripts/tests/test_release_browser_tailnet.py new file mode 100644 index 0000000000..8c7f64d8f1 --- /dev/null +++ b/scripts/tests/test_release_browser_tailnet.py @@ -0,0 +1,164 @@ +"""Offline controls for the actual browser workflow's narrow registration route.""" + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = ROOT / "scripts/release_browser_tailnet.py" +WORKFLOW = ROOT / ".github/workflows/qualify-browser-update-release.yml" +SPEC = importlib.util.spec_from_file_location("release_browser_tailnet", HELPER) +tailnet = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(tailnet) + + +def status(): + return {"BackendState": "Running", "Self": {"Online": True, + "Tags": ["tag:ci-deploy"], "DNSName": "ci-browser.tawny-powan.ts.net."}, + "Peer": {"private": {"DNSName": "private-peer", "private_data": "not-for-receipts"}}} + + +def workflow_step(name): + step = WORKFLOW.read_text().split(" - name: " + name + "\n", 1)[1].split(" - name:", 1)[0] + return textwrap.dedent(step.split(" run: |\n", 1)[1]) + + +def assert_registration_binding(case, source): + # Closed, literal action inputs: no demo fallback or caller-selected tag/key. + action = source.split("uses: tailscale/github-action@", 1)[1].split(" - name:", 1)[0] + expected = { + "oauth-client-id": "${{ secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID }}", + "oauth-secret": "${{ secrets.TS_CI_DEPLOY_OAUTH_SECRET }}", + "tags": "tag:ci-deploy", + "version": "'1.94.2'", + } + actual = dict(line.strip().split(": ", 1) for line in action.split(" with:\n", 1)[1].splitlines() if line.strip()) + case.assertEqual(expected, actual) + for broad in ("secrets.TS_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_SECRET", "tags: tag:infra"): + case.assertNotIn(broad, source) + + +class SelfIdentityTest(unittest.TestCase): + def test_reads_only_the_running_narrow_self_identity(self): + self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(status())) + observed = status() + observed["Peer"] = "untrusted and deliberately not a peer inventory" + self.assertEqual("https://ci-browser.tawny-powan.ts.net", tailnet.self_origin(observed)) + observed["Self"]["DNSName"] = "a.tawny-powan.ts.net" + self.assertEqual("https://a.tawny-powan.ts.net", tailnet.self_origin(observed)) + + def test_rejects_broad_extra_missing_and_malformed_tags(self): + for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], [], None, + "tag:ci-deploy", ["tag:ci-deploy", "tag:ci-deploy"], ["tag:production"]): + with self.subTest(tags=tags), self.assertRaisesRegex(ValueError, "only tag:ci-deploy"): + observed = status() + observed["Self"]["Tags"] = tags + tailnet.self_origin(observed) + + def test_rejects_non_self_wrong_tailnet_and_unsafe_dns(self): + for name in (None, 1, "private-peer", "ci.other.ts.net", "https://ci.tawny-powan.ts.net", + "ci.tawny-powan.ts.net\nsecret", "ci.tawny-powan.ts.net..", + "-ci.tawny-powan.ts.net", "ci-.tawny-powan.ts.net", + "a" * 64 + ".tawny-powan.ts.net"): + with self.subTest(name=name), self.assertRaises(ValueError): + observed = status() + observed["Self"]["DNSName"] = name + tailnet.self_origin(observed) + for observed in (None, [], {}, status() | {"Self": []}, status() | {"BackendState": "NeedsLogin"}): + with self.subTest(observed=observed), self.assertRaises(ValueError): + tailnet.self_origin(observed) + for online in (False, None, 1, "true"): + with self.subTest(online=online), self.assertRaises(ValueError): + observed = status() + observed["Self"]["Online"] = online + tailnet.self_origin(observed) + + def test_cli_errors_never_return_raw_status_or_peers(self): + invalid = status() + invalid["Self"]["Tags"] = ["tag:infra"] + for raw in (json.dumps(invalid).encode(), b"private diagnostic is not JSON", b"\xff", + b" " * (tailnet.MAX_STATUS_BYTES + 1)): + with self.subTest(bytes=len(raw)): + result = subprocess.run(["python3", HELPER], input=raw, capture_output=True) + self.assertEqual(2, result.returncode) + self.assertEqual(b"", result.stdout) + self.assertEqual((tailnet.ERROR + "\n").encode(), result.stderr) + + +class WorkflowBindingTest(unittest.TestCase): + def test_only_dedicated_tag_scoped_oauth_binding_is_declared(self): + source = WORKFLOW.read_text() + assert_registration_binding(self, source) + self.assertIn("id: tailnet\n", source) + self.assertIn("if: always() && steps.tailnet.outcome == 'success'", source) + self.assertNotIn("steps.serve.outcome == 'success'", source) + + def test_rejects_demo_credentials_and_broad_or_caller_selected_registration(self): + source = WORKFLOW.read_text() + for before, after in ( + ("secrets.TS_CI_DEPLOY_OAUTH_CLIENT_ID", "secrets.TS_OAUTH_CLIENT_ID"), + ("secrets.TS_CI_DEPLOY_OAUTH_SECRET", "secrets.TS_OAUTH_SECRET"), + ("tags: tag:ci-deploy", "tags: tag:infra"), + ("tags: tag:ci-deploy", "tags: tag:ci-deploy,tag:infra"), + ("tags: tag:ci-deploy", "tags: ${{ inputs.tags }}"), + ("tags: tag:ci-deploy", "authkey: ${{ secrets.TS_OAUTH_SECRET }}"), + ): + with self.subTest(after=after), self.assertRaises(AssertionError): + assert_registration_binding(self, source.replace(before, after)) + + def test_missing_narrow_binding_stops_even_when_demo_credentials_exist(self): + script = workflow_step("Require the dedicated narrow CI registration binding") + for client_id, secret in (("", ""), ("present", ""), ("", "present"), ("present", "present")): + env = {"PATH": os.environ["PATH"], "CI_CLIENT_ID": client_id, "CI_CLIENT_SECRET": secret, + "TS_OAUTH_CLIENT_ID": "broad-id-not-to-be-used", "TS_OAUTH_SECRET": "broad-secret-not-to-be-used"} + result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + self.assertEqual(0 if client_id and secret else 2, result.returncode) + self.assertEqual("", result.stdout) + self.assertNotIn("broad-id-not-to-be-used", result.stderr) + self.assertNotIn("broad-secret-not-to-be-used", result.stderr) + + def test_actual_serve_step_stops_before_serve_and_output_on_wrong_identity(self): + script = workflow_step("Serve only this disposable install") + fixtures = [status()] + for tags in (["tag:infra"], ["tag:ci-deploy", "tag:infra"], []): + observed = status() + observed["Self"]["Tags"] = tags + fixtures.append(observed) + observed = status() + observed["Self"]["DNSName"] = "private-peer.other.ts.net" + fixtures.append(observed) + fixtures.append({}) + for i, observed in enumerate(fixtures): + with self.subTest(fixture=i), tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + (work / "status.json").write_text(json.dumps(observed)) + fake = work / "tailscale" + fake.write_text('#!/bin/bash\nset -eu\n' + 'if [[ "$*" == "status --json" ]]; then cat "$FIXTURE/status.json"; ' + 'else printf "%s\\n" "$*" >> "$FIXTURE/serve-calls"; fi\n') + fake.chmod(0o700) + env = {"PATH": str(work) + ":" + os.environ["PATH"], "FIXTURE": tmp, + "GITHUB_OUTPUT": str(work / "output")} + result = subprocess.run(["bash", "-c", script], cwd=ROOT, env=env, capture_output=True, text=True) + self.assertEqual(0 if i == 0 else 2, result.returncode, result.stderr) + self.assertEqual("", result.stdout) + self.assertNotIn("private-peer", result.stderr) + self.assertNotIn("not-for-receipts", result.stderr) + if i == 0: + self.assertEqual("serve --bg --https=443 http://127.0.0.1:7655\n", (work / "serve-calls").read_text()) + header, value, delimiter = (work / "output").read_text().splitlines() + self.assertEqual("origin<<" + delimiter, header) + self.assertTrue(delimiter.startswith("pulse_output_")) + self.assertEqual("https://ci-browser.tawny-powan.ts.net", value) + else: + self.assertFalse((work / "serve-calls").exists()) + self.assertFalse((work / "output").exists()) + + +if __name__ == "__main__": + unittest.main()