diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 91f905b68..e1935de1d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -156,6 +156,9 @@ jobs: REQUIRED_ORCHESTRATOR_PUBLIC_IP: ${{ secrets.ORCHESTRATOR_PUBLIC_IP }} REQUIRED_GH_ORG: ${{ secrets.GH_ORG }} REQUIRED_WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }} + # GitHub refuses secret names starting with GITHUB_, so the API's + # GITHUB_WEBHOOK_SECRET is stored as GH_WEBHOOK_SECRET, like GH_ORG. + REQUIRED_GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} # Policy, not a credential, so it is an environment variable. Compose # refuses to start without it; failing here stops before the host is touched. REQUIRED_DEFAULT_AGENT_TYPE: ${{ vars.DEFAULT_AGENT_TYPE }} @@ -176,6 +179,7 @@ jobs: REQUIRED_ORCHESTRATOR_PUBLIC_IP \ REQUIRED_GH_ORG \ REQUIRED_WORKER_BROKER_INTERNAL_TOKEN \ + REQUIRED_GH_WEBHOOK_SECRET \ REQUIRED_DEFAULT_AGENT_TYPE do if [ -z "${!name}" ]; then @@ -187,6 +191,14 @@ jobs: "${{ inputs.environment }}" "${missing[*]}" >&2 exit 1 fi + # The webhook secret is written verbatim into the server .env, so only one + # token that neither the remote heredoc nor Compose's env-file parser can + # reinterpret is accepted. Hex, base64 and base64url qualify. + if [[ ! "${REQUIRED_GH_WEBHOOK_SECRET}" =~ ^[A-Za-z0-9._~+/=-]+$ ]]; then + printf 'GH_WEBHOOK_SECRET for %s must be one token of letters, digits and ._~+/=- (for example openssl rand -hex 32); its value is not shown\n' \ + "${{ inputs.environment }}" >&2 + exit 1 + fi - name: Validate production provider secrets if: ${{ inputs.environment == 'production' }} @@ -338,6 +350,9 @@ jobs: # GitHub GITHUB_APP_ID=${{ secrets.GH_APP_ID }} GITHUB_ORG=${{ secrets.GH_ORG }} + # Required by the API. Validate required secrets refused any value that is + # not one safe token, so it lands here as data. + GITHUB_WEBHOOK_SECRET=${{ secrets.GH_WEBHOOK_SECRET }} # Telegram # Same inheritance, worse consequence: two long-polling clients on one diff --git a/.github/workflows/stand-e2e.yml b/.github/workflows/stand-e2e.yml index fdcf50085..60a02bc5a 100644 --- a/.github/workflows/stand-e2e.yml +++ b/.github/workflows/stand-e2e.yml @@ -190,6 +190,8 @@ jobs: TELETHON_API_HASH: ${{ secrets.TELETHON_API_HASH }} TELETHON_SESSION: ${{ secrets.TELETHON_SESSION }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + # GitHub refuses secret names starting with GITHUB_; the deploy stores it the same way. + GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} run: | set -euo pipefail python3 -m scripts.stand_credentials --suite "${SUITE}" @@ -636,6 +638,7 @@ jobs: GRAFANA_DB_PASSWORD: ${{ secrets.GRAFANA_DB_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }} + GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} STAND_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} STAND_CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT }} HOST_CODEX_HOME: /opt/secrets/stand-codex @@ -678,7 +681,7 @@ jobs: "REGISTRY_USER", "REGISTRY_PASSWORD", "REGISTRY_PASSWORD_HASH", "LOKI_PUSH_USER", "LOKI_PUSH_PASSWORD", "LOKI_PUSH_PASSWORD_HASH", "LOKI_URL", "ADMIN_USER", "ADMIN_PASSWORD", "GRAFANA_DB_USER", "GRAFANA_DB_PASSWORD", "GRAFANA_ADMIN_PASSWORD", - "WORKER_BROKER_INTERNAL_TOKEN", "STAND_CLAUDE_CODE_OAUTH_TOKEN", "STAND_CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT", "HOST_CODEX_HOME", "HOST_CODEX_VALIDATION_PATH", "PO_LLM_MODEL", + "WORKER_BROKER_INTERNAL_TOKEN", "GITHUB_WEBHOOK_SECRET", "STAND_CLAUDE_CODE_OAUTH_TOKEN", "STAND_CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT", "HOST_CODEX_HOME", "HOST_CODEX_VALIDATION_PATH", "PO_LLM_MODEL", "PO_LLM_BASE_URL", "PO_LLM_API_KEY", "ARCHITECT_LLM_MODEL", "ARCHITECT_LLM_BASE_URL", "ARCHITECT_LLM_API_KEY", "SUMMARIZATION_MODEL", "LIVE_CONTOUR", "DEFAULT_AGENT_TYPE", "TELEGRAM_MAX_CONCURRENT_UPDATES", @@ -925,6 +928,8 @@ jobs: # The QA session lives in the qa-worker env file, not in the api # environment, so the redactor is handed it by name: -e NAME takes # the value from this shell, never from a command line. + # The api environment must carry GITHUB_WEBHOOK_SECRET; without it the + # redaction path fails closed. qa_env=./.qa-worker.env TELETHON_API_HASH=; TELETHON_SESSION= if [ -f "${qa_env}" ]; then @@ -932,13 +937,13 @@ jobs: TELETHON_SESSION=$(sed -n "s/^TELETHON_SESSION=//p" "${qa_env}") fi export TELETHON_API_HASH TELETHON_SESSION - protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION" + protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN GITHUB_WEBHOOK_SECRET PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION" services_out="${diagnostics_dir}/provisioning-services.log" services_partial="${services_out}.partial" rm -f "${services_partial}" if ${COMPOSE} logs --no-color --tail 300 infra-service scheduler-infrastructure 2>&1 | \ ${COMPOSE} exec -T -e STAND_DIAGNOSTIC_SECRET_NAMES="${protected_names}" -e TELETHON_API_HASH -e TELETHON_SESSION api \ - python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \ + python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); os.environ.get(\"GITHUB_WEBHOOK_SECRET\", \"\").strip() or sys.exit(\"missing redaction value: GITHUB_WEBHOOK_SECRET\"); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \ > "${services_partial}"; then mv "${services_partial}" "${services_out}" else @@ -1206,6 +1211,8 @@ jobs: # The QA session lives in the qa-worker env file, not in the api # environment, so the redactor is handed it by name: -e NAME takes # the value from this shell, never from a command line. + # The api environment must carry GITHUB_WEBHOOK_SECRET; without it the + # redaction path fails closed. qa_env=./.qa-worker.env TELETHON_API_HASH=; TELETHON_SESSION= if [ -f "${qa_env}" ]; then @@ -1213,7 +1220,7 @@ jobs: TELETHON_SESSION=$(sed -n "s/^TELETHON_SESSION=//p" "${qa_env}") fi export TELETHON_API_HASH TELETHON_SESSION - protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION" + protected_names="INTERNAL_API_KEY POSTGRES_PASSWORD SECRETS_ENCRYPTION_KEY LK_JWT_SECRET REGISTRY_PASSWORD REGISTRY_PASSWORD_HASH LOKI_PUSH_PASSWORD LOKI_PUSH_PASSWORD_HASH ADMIN_PASSWORD GRAFANA_DB_PASSWORD GRAFANA_ADMIN_PASSWORD WORKER_BROKER_INTERNAL_TOKEN GITHUB_WEBHOOK_SECRET PO_LLM_API_KEY ARCHITECT_LLM_API_KEY TELETHON_API_HASH TELETHON_SESSION" out=/root/e2e-runs/suite-services.log partial="${out}.partial" rm -f "${partial}" @@ -1225,7 +1232,7 @@ jobs: engineering-worker worker-manager worker-broker api \ qa-worker deploy-worker 2>&1 | \ ${COMPOSE} exec -T -e STAND_DIAGNOSTIC_SECRET_NAMES="${protected_names}" -e TELETHON_API_HASH -e TELETHON_SESSION api \ - python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \ + python -c "import os,sys; from shared.diagnostics import redact_diagnostic; names=os.environ.get(\"STAND_DIAGNOSTIC_SECRET_NAMES\", \"\").split(); os.environ.get(\"GITHUB_WEBHOOK_SECRET\", \"\").strip() or sys.exit(\"missing redaction value: GITHUB_WEBHOOK_SECRET\"); secrets=tuple(os.environ.get(name, \"\") for name in names); sys.stdout.write(\"\".join(redact_diagnostic(line, secrets=secrets) for line in sys.stdin))" \ > "${partial}"; then mv "${partial}" "${out}" else @@ -1306,6 +1313,7 @@ jobs: GRAFANA_DB_PASSWORD: ${{ secrets.GRAFANA_DB_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }} + GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} STAND_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} PO_LLM_API_KEY: ${{ secrets.PO_LLM_API_KEY }} ARCHITECT_LLM_API_KEY: ${{ secrets.ARCHITECT_LLM_API_KEY }} @@ -1452,6 +1460,7 @@ jobs: GRAFANA_DB_PASSWORD: ${{ secrets.GRAFANA_DB_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} WORKER_BROKER_INTERNAL_TOKEN: ${{ secrets.WORKER_BROKER_INTERNAL_TOKEN }} + GITHUB_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} STAND_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} PO_LLM_API_KEY: ${{ secrets.PO_LLM_API_KEY }} ARCHITECT_LLM_API_KEY: ${{ secrets.ARCHITECT_LLM_API_KEY }} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 75c2d5afa..6d984f553 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ See the CHANGELOG rule in [AGENTS.md](../AGENTS.md). ## 2026-10-18 +- Deploy and `stand-e2e.yml` write the `GH_WEBHOOK_SECRET` Environment secret into the API's `.env` + as `GITHUB_WEBHOOK_SECRET`, refuse a missing or unsafe value first, and redact it from stand evidence. - `POST /api/webhooks/github` publishes a `pr_ci` wake hint for an HMAC-signed `workflow_run` completion; the API now requires `GITHUB_WEBHOOK_SECRET`, and public routing is not active yet. - `POST /stories/{id}/pr_review` publishes a `pr_ci` wake hint after its commit, so the PR/CI sweep diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index f3602f6dc..90416c127 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -139,7 +139,8 @@ authentication. ### Ephemeral stand authentication Before the lifecycle preflight can create a machine, the ephemeral BitLaunch -stand validates Telethon material, the bootstrap private key, and the +stand validates Telethon material, the bootstrap private key, the API's webhook +secret (`GH_WEBHOOK_SECRET`, see [GitHub Integration](#github-integration)), and the operator-supplied, timezone-aware `CLAUDE_CODE_OAUTH_TOKEN_EXPIRES_AT` metadata for the opaque annual Claude OAuth token. Missing, malformed, expired, or near-expiry Claude material refuses without rendering credentials. The GitHub @@ -382,11 +383,18 @@ either `openrouter_balance` (the balance and whether an alert was sent) or | `GH_APP_PRIVATE_KEY` | GitHub App private key PEM (written to `/opt/secrets/github_app.pem`) | | `GH_ORG` | GitHub organization name | | `GHCR_TOKEN` | GitHub token with `packages:read` scope (for pulling worker images) | -| `GITHUB_WEBHOOK_SECRET` | Random secret GitHub signs webhook deliveries with; required by the API, which refuses to start without a nonblank value. No other key derives it | - -The deploy workflow does not write `GITHUB_WEBHOOK_SECRET` into the server `.env` yet, so a -deploy of the API needs that wiring and the provisioned secret first. Activation of the webhook is -described in [GitHub workflow completion](contracts/scheduler-wake-hints.md#github-workflow-completion). +| `GH_WEBHOOK_SECRET` | Random secret GitHub signs webhook deliveries with, written into the server `.env` as `GITHUB_WEBHOOK_SECRET`. The API refuses to start without a nonblank value. No other key derives it | + +GitHub refuses secret names that start with `GITHUB_`, so the API's `GITHUB_WEBHOOK_SECRET` is +the `GH_WEBHOOK_SECRET` secret of each GitHub Environment that runs the API: the operator +provisions a dedicated value separately in `production` and in `stand`. Use 32 random bytes as hex +(`openssl rand -hex 32`); a value must be one token of letters, digits and `._~+/=-`. Keep it out of +model-visible text, issues, logs and artifacts. Deploy refuses a missing or unsafe value by name, +without printing it, before it writes `.env`. `stand-e2e.yml` refuses it the same way before any +paid step, renders it into the stand API's `.env`, and holds it as a protected value of its +service-tail redaction and both artifact admissions. Provisioning it activates no webhook; what +activation still needs is in +[GitHub workflow completion](contracts/scheduler-wake-hints.md#github-workflow-completion). `GH_ORG` is the name of the *secret* only. The rename stopped at the secret: the deploy workflow writes it into the server `.env` as `GITHUB_ORG=${{ secrets.GH_ORG }}` diff --git a/docs/contracts/scheduler-wake-hints.md b/docs/contracts/scheduler-wake-hints.md index f871dbf3d..f029a3023 100644 --- a/docs/contracts/scheduler-wake-hints.md +++ b/docs/contracts/scheduler-wake-hints.md @@ -221,14 +221,18 @@ path, and the awakened cycle decides from the same durable Story/Task/Run state socket error is logged as `pr_ci_wake_failed` with the error type only and the delivery is still answered 204, so GitHub does not redeliver a hint the interval covers. Zero receivers is a published hint. Cancellation propagates. There is no retry. -- No GitHub delivery reaches this route yet. Activation needs, in order: a random secret generated - and provisioned as `GITHUB_WEBHOOK_SECRET` (for example `openssl rand -hex 32`, held only in the - deployment secret store); the deploy workflow writing it into the API's environment, without - which the API refuses to start; public HTTPS routing of exactly `POST /api/webhooks/github` to the - API that preserves the body and headers; the GitHub App's webhook URL and that secret, its +- No GitHub delivery reaches this route yet. The deploy and stand workflows write the + `GH_WEBHOOK_SECRET` secret of their GitHub Environment into the API's environment as + `GITHUB_WEBHOOK_SECRET` and refuse to proceed without it + ([GitHub Integration](../DEPLOY.md#github-integration)). Activation still needs, in order: a + dedicated random value (32 random bytes as hex, `openssl rand -hex 32`) provisioned as + `GH_WEBHOOK_SECRET` in each Environment that runs the API, held only there and never in + model-visible text or artifacts; public HTTPS routing of exactly `POST /api/webhooks/github` to + the API that preserves the body and headers; the GitHub App's webhook URL and that secret, its `workflow_run` event subscription and its Actions read permission; and evidence of a delivery answered 204 followed by an awakened `pr_ci` sweep. To disable it, remove the route from the - public proxy or the App's subscription; the interval sweep keeps recovering every completion. + public proxy or the App's subscription and keep the secret provisioned, since the API still + requires it; the interval sweep keeps recovering every completion. ## Listener diff --git a/scripts/stand_acceptance.py b/scripts/stand_acceptance.py index 64ed55dd5..41d13e6c2 100644 --- a/scripts/stand_acceptance.py +++ b/scripts/stand_acceptance.py @@ -94,6 +94,7 @@ "GRAFANA_DB_PASSWORD", "GRAFANA_ADMIN_PASSWORD", "WORKER_BROKER_INTERNAL_TOKEN", + "GITHUB_WEBHOOK_SECRET", "STAND_CLAUDE_CODE_OAUTH_TOKEN", "PO_LLM_API_KEY", "ARCHITECT_LLM_API_KEY", diff --git a/scripts/stand_credentials.py b/scripts/stand_credentials.py index 2ae19a0b7..3982e62b8 100644 --- a/scripts/stand_credentials.py +++ b/scripts/stand_credentials.py @@ -14,6 +14,7 @@ from collections.abc import Mapping from datetime import UTC, datetime import os +import re from shared.stand_credentials import ( CLAUDE_MINIMUM_TTL, # noqa: F401 - retained script-level validator API @@ -24,6 +25,10 @@ TELETHON_ENV_VARS = ("TELETHON_API_ID", "TELETHON_API_HASH", "TELETHON_SESSION") PRIVATE_KEY_MINIMUM_LINES = 3 +# Every suite starts the API, which refuses to start without this secret. The +# rendered .stand.env line keeps only one token Compose cannot reinterpret. +WEBHOOK_SECRET_ENV_VAR = "GITHUB_WEBHOOK_SECRET" # noqa: S105 - a variable name +_WEBHOOK_SECRET_SHAPE = re.compile(r"[A-Za-z0-9._~+/=-]+") def _ssh_private_key_looks_usable(value: str | None) -> bool: @@ -70,6 +75,14 @@ def validate_precreate_credentials( "Telethon session", f"is unavailable: missing {', '.join(missing_telethon)}" ) ) + if not _WEBHOOK_SECRET_SHAPE.fullmatch(environment.get(WEBHOOK_SECRET_ENV_VAR, "")): + failures.append( + CredentialFailure( + "GitHub webhook secret", + f"is missing or not one token of letters, digits and ._~+/=-: " + f"{WEBHOOK_SECRET_ENV_VAR} (stand environment secret GH_WEBHOOK_SECRET)", + ) + ) if not _ssh_private_key_looks_usable(environment.get("SSH_PRIVATE_KEY")): failures.append( CredentialFailure("SSH material", "is missing or not a usable private-key document") diff --git a/scripts/tests/test_stand_acceptance.py b/scripts/tests/test_stand_acceptance.py index 1268d42c6..70a5ae679 100644 --- a/scripts/tests/test_stand_acceptance.py +++ b/scripts/tests/test_stand_acceptance.py @@ -10,6 +10,7 @@ from scripts.stand_acceptance import ( ADMISSION_MARKER, PROTECTED_STAND_SECRET_NAMES, + ProtectedEnvironmentError, _emit_admission_diagnostics, admit_artifact, admit_artifact_from_environment, @@ -1307,6 +1308,42 @@ def test_admission_refuses_a_missing_protected_environment_value_with_a_named_sa } +def test_a_leaked_webhook_secret_is_refused_and_its_name_alone_is_admissible(tmp_path): + webhook_secret = "9e3a" * 16 + environment = {**_protected_environment(), "GITHUB_WEBHOOK_SECRET": webhook_secret} + protected_values = protected_values_from_environment(environment, suite="mega-noop") + leaked = tmp_path / "leaked" + _final_candidate(leaked, f"api | hmac key {webhook_secret} in a traceback\n") + leaked_status = tmp_path / "leaked.json" + safe = tmp_path / "safe" + _final_candidate( + safe, + "api | GITHUB_WEBHOOK_SECRET is configured\n" + "api | github_webhook_refused reason=signature_mismatch status=403\n" + "ORCHESTRATOR_HOSTNAME=stand.example.test\n", + ) + + assert not admit_artifact(leaked, status_path=leaked_status, protected_values=protected_values) + assert admit_artifact( + safe, status_path=tmp_path / "safe.json", protected_values=protected_values + ) + assert json.loads(leaked_status.read_text(encoding="utf-8"))["issues"] == [ + {"path": "run.log", "reason": "candidate contains a supplied protected value"} + ] + assert webhook_secret not in leaked_status.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("suite", ["mega-noop", "mega-live"]) +def test_admission_requires_the_webhook_secret_for_every_suite(suite): + environment = _protected_environment() + environment["GITHUB_WEBHOOK_SECRET"] = "" + + with pytest.raises(ProtectedEnvironmentError) as refused: + protected_values_from_environment(environment, suite=suite) + + assert refused.value.missing == ("GITHUB_WEBHOOK_SECRET",) + + def test_rejected_admission_emits_only_safe_reason_and_relative_path_to_the_summary( tmp_path, capsys ): diff --git a/scripts/tests/test_stand_credentials.py b/scripts/tests/test_stand_credentials.py index f454bdd8e..68412326a 100644 --- a/scripts/tests/test_stand_credentials.py +++ b/scripts/tests/test_stand_credentials.py @@ -2,11 +2,15 @@ from datetime import UTC, datetime, timedelta +import pytest + from scripts.stand_credentials import ( CLAUDE_MINIMUM_TTL, validate_precreate_credentials, ) +WEBHOOK_SECRET = "5f0c" * 16 # noqa: S105 - synthetic 32 bytes as hex + def _environment(now: datetime) -> dict[str, str]: return { @@ -20,6 +24,7 @@ def _environment(now: datetime) -> dict[str, str]: "SSH_PRIVATE_KEY": ( "-----BEGIN OPENSSH PRIVATE KEY-----\ndGVzdA==\n-----END OPENSSH PRIVATE KEY-----" ), + "GITHUB_WEBHOOK_SECRET": WEBHOOK_SECRET, } @@ -110,3 +115,33 @@ def test_opaque_claude_token_refuses_missing_and_expired_expiry_metadata(): assert [(failure.name, failure.detail) for failure in failures] == [ ("Claude token", "is expired") ] + + +@pytest.mark.parametrize("suite", ["mega-noop", "mega-live", None]) +@pytest.mark.parametrize( + "value", ["", " ", "two words", "one\nINJECTED=1", "$(touch pwned)", "quoted'value"] +) +def test_every_suite_refuses_a_missing_or_unsafe_webhook_secret_by_name_only(suite, value): + now = datetime.now(UTC) + environment = {**_environment(now), "GITHUB_WEBHOOK_SECRET": value} + if value == "": + del environment["GITHUB_WEBHOOK_SECRET"] + + failures = validate_precreate_credentials(environment, now=now, suite=suite) + + assert [failure.name for failure in failures] == ["GitHub webhook secret"] + assert "GITHUB_WEBHOOK_SECRET" in failures[0].detail + assert "GH_WEBHOOK_SECRET" in failures[0].detail + if value.strip(): + assert value not in failures[0].detail + + +@pytest.mark.parametrize("value", [WEBHOOK_SECRET, "aGVsbG8tc3RhbmQ=", "a-b_c.d~e+f/g="]) +def test_a_suite_without_model_sessions_still_accepts_a_one_token_webhook_secret(value): + now = datetime.now(UTC) + environment = { + "SSH_PRIVATE_KEY": _environment(now)["SSH_PRIVATE_KEY"], + "GITHUB_WEBHOOK_SECRET": value, + } + + assert validate_precreate_credentials(environment, now=now, suite="mega-noop") == [] diff --git a/services/worker-manager/tests/unit/test_noop_stand_workflow.py b/services/worker-manager/tests/unit/test_noop_stand_workflow.py index c7c3ad247..fbd4e0811 100644 --- a/services/worker-manager/tests/unit/test_noop_stand_workflow.py +++ b/services/worker-manager/tests/unit/test_noop_stand_workflow.py @@ -360,12 +360,14 @@ def test_image_identity_and_auth_selection_execute_real_workflow(tmp_path, comma def test_noop_precreate_without_sessions_still_requires_ssh(tmp_path): script = steps()["Validate pre-create credentials"]["run"] key = "-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----" - result = run(script, tmp_path, SUITE="mega-noop", SSH_PRIVATE_KEY=key) + # Every suite starts the API, so even the no-model path needs its webhook secret. + api = {"GITHUB_WEBHOOK_SECRET": "ab" * 32} + result = run(script, tmp_path, SUITE="mega-noop", SSH_PRIVATE_KEY=key, **api) assert result.returncode == 0, result.stderr - result = run(script, tmp_path, SUITE="mega-noop") + result = run(script, tmp_path, SUITE="mega-noop", **api) assert result.returncode != 0 assert "SSH material" in result.stdout - result = run(script, tmp_path, SUITE="mega-live", SSH_PRIVATE_KEY=key) + result = run(script, tmp_path, SUITE="mega-live", SSH_PRIVATE_KEY=key, **api) assert result.returncode != 0 assert "Claude token" in result.stdout assert "Telethon session" in result.stdout diff --git a/tests/unit/test_deploy_workflow_release_wait.py b/tests/unit/test_deploy_workflow_release_wait.py index 916ad5476..838d032ff 100644 --- a/tests/unit/test_deploy_workflow_release_wait.py +++ b/tests/unit/test_deploy_workflow_release_wait.py @@ -172,14 +172,17 @@ def test_steps_that_change_the_host_keep_their_single_shot_behaviour(name: str): FAKE_SSH = """#!/usr/bin/env bash set -uo pipefail echo call >> "${FAKE_SSH_DIR}/calls" +printf '%s\\0' "$@" >> "${FAKE_SSH_DIR}/args" remote="${@: -1}" sh -c "${remote}" """ -def _render(script: str, deploy_path: Path) -> str: +def _render(script: str, deploy_path: Path, values: dict[str, str] | None = None) -> str: def value(match: re.Match) -> str: expression = match.group(1) + if values and expression in values: + return values[expression] if expression == "env.DEPLOY_PATH": return str(deploy_path) if expression == "env.RELEASE_PENDING": @@ -209,8 +212,10 @@ def host(tmp_path: Path) -> Path: return tmp_path -def _run_step(name: str, host: Path) -> subprocess.CompletedProcess[str]: - script = _render(_steps()[name]["run"], host / "deploy") +def _run_step( + name: str, host: Path, values: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + script = _render(_steps()[name]["run"], host / "deploy", values) return subprocess.run( ["bash", "-c", script], cwd=REPO_ROOT, @@ -251,6 +256,73 @@ def test_the_env_file_is_written_whole_and_private(host: Path): assert _calls(host) == 1 +WEBHOOK_SECRET = "c0ffee" * 10 + "c0ff" # noqa: S105 - synthetic 32 bytes as hex + + +def test_the_webhook_secret_reaches_the_api_env_exactly_and_never_the_output(host: Path): + result = _run_step("Write .env to server", host, {"secrets.GH_WEBHOOK_SECRET": WEBHOOK_SECRET}) + + assert result.returncode == 0, result.stderr + lines = (host / "deploy" / ".env").read_text().splitlines() + assert [line for line in lines if line.startswith("GITHUB_WEBHOOK_SECRET=")] == [ + f"GITHUB_WEBHOOK_SECRET={WEBHOOK_SECRET}" + ] + assert WEBHOOK_SECRET not in result.stdout + result.stderr + assert WEBHOOK_SECRET not in (host / "args").read_text() + + +def _validate_required_secrets( + tmp_path: Path, contour: str, webhook_secret: str +) -> subprocess.CompletedProcess[str]: + step = _steps()["Validate required secrets"] + environment = {name: f"value-of-{name}" for name in step["env"]} + environment["REQUIRED_GH_WEBHOOK_SECRET"] = webhook_secret + return subprocess.run( + ["bash", "-c", _render(step["run"], tmp_path, {"inputs.environment": contour})], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + env={"PATH": "/usr/bin:/bin", **environment}, + ) + + +@pytest.mark.parametrize("contour", ["production", "stand"]) +def test_each_contour_accepts_a_one_token_webhook_secret_without_printing_it( + tmp_path: Path, contour: str +): + result = _validate_required_secrets(tmp_path, contour, WEBHOOK_SECRET) + + assert result.returncode == 0, result.stderr + assert WEBHOOK_SECRET not in result.stdout + result.stderr + + +@pytest.mark.parametrize("contour", ["production", "stand"]) +def test_a_missing_webhook_secret_is_named_before_any_host_write(tmp_path: Path, contour: str): + result = _validate_required_secrets(tmp_path, contour, "") + + assert result.returncode == 1 + assert f"Missing required secrets or variables for {contour}: GH_WEBHOOK_SECRET" in ( + result.stderr + ) + + +@pytest.mark.parametrize( + "unsafe", + [" ", "two words", "token\nENVEOF\nINJECTED=1", "$(touch executed)", "a#b", "it's"], + ids=["blank", "space", "heredoc-break", "command", "comment", "quote"], +) +def test_an_unsafe_webhook_secret_is_refused_by_name_and_never_run(tmp_path: Path, unsafe: str): + result = _validate_required_secrets(tmp_path, "stand", unsafe) + + assert result.returncode == 1 + assert "GH_WEBHOOK_SECRET for stand must be one token" in result.stderr + assert "its value is not shown" in result.stderr + if unsafe.strip(): + assert unsafe not in result.stdout + result.stderr + assert not (tmp_path / "executed").exists() + + def test_the_written_env_passes_its_contour_check(host: Path): assert _run_step("Write .env to server", host).returncode == 0 diff --git a/tests/unit/test_stand_e2e_workflow.py b/tests/unit/test_stand_e2e_workflow.py index 16702bd41..f675a7ef0 100644 --- a/tests/unit/test_stand_e2e_workflow.py +++ b/tests/unit/test_stand_e2e_workflow.py @@ -1898,6 +1898,7 @@ def test_the_suite_step_hands_the_runner_the_override_bring_up_generated(tmp_pat } QA_WORKER_ENV = "/opt/codegen_orchestrator/.qa-worker.env" TELETHON_NAMES = ("TELETHON_API_ID", "TELETHON_API_HASH", "TELETHON_SESSION") +WEBHOOK_SECRET = "4b1d" * 16 # synthetic 32 bytes as hex def _render_script(step: dict) -> str: @@ -1950,6 +1951,7 @@ def test_every_validated_credential_is_rendered_for_the_stand_or_named_as_not_ne "SSH_PRIVATE_KEY": ( "-----BEGIN OPENSSH PRIVATE KEY-----\ndGVzdA==\n-----END OPENSSH PRIVATE KEY-----" ), + "GITHUB_WEBHOOK_SECRET": WEBHOOK_SECRET, } validated = steps["Validate pre-create credentials"]["env"] assert set(valid) == set(validated) - {"SUITE"} @@ -2267,6 +2269,7 @@ def test_the_service_tail_redaction_removes_a_session_looking_value(tmp_path, wh "PATH": os.environ["PATH"], "PYTHONPATH": str(WORKFLOW.parents[2]), "STAND_DIAGNOSTIC_SECRET_NAMES": names, + "GITHUB_WEBHOOK_SECRET": WEBHOOK_SECRET, "TELETHON_API_HASH": handed_hash, "TELETHON_SESSION": handed_session, }, @@ -2287,6 +2290,215 @@ def test_the_qa_session_is_a_protected_value_of_every_artifact_admission(): assert "TELETHON_API_ID" not in PROTECTED_STAND_SECRET_NAMES +def _api_protected_environment(names: str) -> dict[str, str]: + """Synthetic api-container values for every named credential but the QA pair.""" + return {name: f"protected-{name}" for name in names.split() if not name.startswith("TELETHON_")} + + +@pytest.mark.parametrize("suite", ["mega-noop", "mega-brief", "mega-live"]) +def test_the_precreate_step_stops_every_suite_on_a_missing_webhook_secret(tmp_path, suite): + step = _steps()["Validate pre-create credentials"] + environment = {name: f"value-of-{name}" for name in step["env"]} + environment.update( + SUITE=suite, + GITHUB_WEBHOOK_SECRET="", + SSH_PRIVATE_KEY=( + "-----BEGIN OPENSSH PRIVATE KEY-----\ndGVzdA==\n-----END OPENSSH PRIVATE KEY-----" + ), + ) + result = subprocess.run( # noqa: S603 + ["bash", "-c", step["run"]], + cwd=WORKFLOW.parents[2], + env={"PATH": f"{Path(sys.executable).parent}:/usr/bin:/bin", **environment}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 2 + assert "FAIL GitHub webhook secret" in result.stdout + assert "GITHUB_WEBHOOK_SECRET" in result.stdout + assert step["env"]["GITHUB_WEBHOOK_SECRET"] == "${{ secrets.GH_WEBHOOK_SECRET }}" # noqa: S105 + + +@pytest.mark.parametrize("model_sessions", ["true", "false"]) +def test_the_rendered_stand_env_hands_the_api_the_exact_webhook_secret(tmp_path, model_sessions): + result = _run_render( + tmp_path, + qa_telethon="false", + MODEL_SESSIONS=model_sessions, + GITHUB_WEBHOOK_SECRET=WEBHOOK_SECRET, + ) + + assert result.returncode == 0, result.stderr + lines = (tmp_path / ".stand.env").read_text().splitlines() + assert [line for line in lines if line.startswith("GITHUB_WEBHOOK_SECRET=")] == [ + f"GITHUB_WEBHOOK_SECRET={WEBHOOK_SECRET}" + ] + assert WEBHOOK_SECRET not in (tmp_path / ".stand-qa-worker.env").read_text() + assert WEBHOOK_SECRET not in result.stdout + result.stderr + + +def test_the_renderer_refuses_a_missing_webhook_secret_before_writing_anything(tmp_path): + result = _run_render( + tmp_path, qa_telethon="false", MODEL_SESSIONS="false", GITHUB_WEBHOOK_SECRET="" + ) + + assert result.returncode != 0 + assert "missing required stand configuration: GITHUB_WEBHOOK_SECRET" in result.stderr + assert not (tmp_path / ".stand.env").exists() + + +FAKE_STAND_HOST_DOCKER = """#!/usr/bin/env bash +# `compose logs` prints the canned tail; `compose exec SERVICE CMD` runs CMD with the +# api container's environment plus each -e NAME[=VALUE] the caller handed it. +set -euo pipefail +shift +while [ "$1" = "-f" ]; do shift 2; done +case "$1" in + logs) cat "${FAKE_SERVICE_TAIL}" ;; + exec) + shift + handed=() + while [ "$#" -gt 0 ]; do + case "$1" in + -T) shift ;; + -e) + name="$2" + case "${name}" in + *=*) handed+=("${name}") ;; + *) handed+=("${name}=${!name-}") ;; + esac + shift 2 + ;; + *) break ;; + esac + done + shift + mapfile -t api < "${FAKE_API_ENVIRONMENT}" + exec env -i PATH="${PATH}" PYTHONPATH="${FAKE_PYTHONPATH}" "${api[@]}" "${handed[@]}" "$@" + ;; +esac +""" + + +def _service_tail_collection(script: str, out_dir: Path) -> str: + """The stand-host shell from the tail collection's compose line to its fallback.""" + lines = script.splitlines() + qa_env = next(i for i, line in enumerate(lines) if line.strip() == "qa_env=./.qa-worker.env") + start = max(i for i in range(qa_env) if lines[i].strip().startswith('COMPOSE="docker ')) + collect = next( + i for i in range(qa_env, len(lines)) if lines[i].strip().startswith("if ${COMPOSE} logs") + ) + indent = lines[collect][: len(lines[collect]) - len(lines[collect].lstrip())] + end = next(i for i in range(collect + 1, len(lines)) if lines[i] == f"{indent}fi") + fragment = textwrap.dedent("\n".join(lines[start : end + 1])) + return f"diagnostics_dir={out_dir}\nwait_status=0\n" + fragment.replace( + "/root/e2e-runs", str(out_dir) + ) + + +def _collect_service_tails(tmp_path: Path, which: int, api_environment: dict[str, str]): + binaries = tmp_path / "bin" + _write_executable(binaries / "docker", FAKE_STAND_HOST_DOCKER) + _write_executable(binaries / "python", f'#!/bin/sh\nexec "{sys.executable}" "$@"\n') + out_dir = tmp_path / "e2e-runs" + out_dir.mkdir() + tail = tmp_path / "tail.log" + tail.write_text( + f"api | webhook hmac key {WEBHOOK_SECRET} in a traceback\n" + "api | GITHUB_WEBHOOK_SECRET is configured\n" + "api | github_webhook_refused reason=signature_mismatch status=403\n" + "api | ORCHESTRATOR_HOSTNAME=stand.example.test\n" + ) + (tmp_path / "api.env").write_text( + "".join(f"{name}={value}\n" for name, value in api_environment.items()) + ) + script = _service_tail_collection(_remote_redaction_scripts()[which], out_dir) + result = subprocess.run( # noqa: S603 + ["bash", "-c", f"set -euo pipefail\n{script}"], + cwd=tmp_path, + env={ + "PATH": f"{binaries}:/usr/bin:/bin", + "FAKE_SERVICE_TAIL": str(tail), + "FAKE_API_ENVIRONMENT": str(tmp_path / "api.env"), + "FAKE_PYTHONPATH": str(WORKFLOW.parents[2]), + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + (collected,) = (path for path in out_dir.iterdir() if path.name.endswith("services.log")) + assert not list(out_dir.glob("*.partial")) + return collected.read_text(), result + + +def _api_environment_with_webhook_secret(which: int) -> dict[str, str]: + script = _remote_redaction_scripts()[which] + names = re.search(r'protected_names="([^"]*)"', script).group(1) + assert "GITHUB_WEBHOOK_SECRET" in names.split() + return {**_api_protected_environment(names), "GITHUB_WEBHOOK_SECRET": WEBHOOK_SECRET} + + +@pytest.mark.parametrize("which", [0, 1], ids=["provisioning-tails", "suite-failure-tails"]) +def test_the_service_tails_redact_the_webhook_secret_and_keep_safe_lines(tmp_path, which): + collected, result = _collect_service_tails( + tmp_path, which, _api_environment_with_webhook_secret(which) + ) + + assert WEBHOOK_SECRET not in collected + result.stdout + result.stderr + assert "api | webhook hmac key [redacted] in a traceback\n" in collected + assert "api | GITHUB_WEBHOOK_SECRET is configured\n" in collected + assert "github_webhook_refused reason=signature_mismatch status=403" in collected + assert "ORCHESTRATOR_HOSTNAME=stand.example.test" in collected + + +@pytest.mark.parametrize("webhook_secret", [None, " "], ids=["absent", "blank"]) +@pytest.mark.parametrize("which", [0, 1], ids=["provisioning-tails", "suite-failure-tails"]) +def test_service_tails_without_the_webhook_secret_publish_only_the_refusal( + tmp_path, which, webhook_secret +): + environment = _api_environment_with_webhook_secret(which) + if webhook_secret is None: + del environment["GITHUB_WEBHOOK_SECRET"] + else: + environment["GITHUB_WEBHOOK_SECRET"] = webhook_secret + + collected, result = _collect_service_tails(tmp_path, which, environment) + + assert WEBHOOK_SECRET not in collected + assert "the redaction path did not complete" in collected + assert "github_webhook_refused" not in collected + assert "missing redaction value: GITHUB_WEBHOOK_SECRET" in result.stderr + + +@pytest.mark.parametrize("which", [0, 1], ids=["provisioning-tails", "suite-failure-tails"]) +def test_service_tails_without_another_protected_value_still_redact_the_webhook_secret( + tmp_path, which +): + environment = _api_environment_with_webhook_secret(which) + del environment["INTERNAL_API_KEY"] + + collected, result = _collect_service_tails(tmp_path, which, environment) + + assert "the redaction path did not complete" not in collected + assert WEBHOOK_SECRET not in collected + result.stdout + result.stderr + assert "api | webhook hmac key [redacted] in a traceback\n" in collected + assert "github_webhook_refused reason=signature_mismatch status=403" in collected + + +def test_the_webhook_secret_is_a_protected_value_of_every_artifact_admission(): + workflow = _workflow() + final_admission = {step["name"]: step for step in workflow["jobs"]["cleanup"]["steps"]}[ + "Admit final artifact" + ] + + assert "GITHUB_WEBHOOK_SECRET" in PROTECTED_STAND_SECRET_NAMES + for step in (_steps()["Admit cleanup handoff"], final_admission): + assert step["env"]["GITHUB_WEBHOOK_SECRET"] == "${{ secrets.GH_WEBHOOK_SECRET }}" # noqa: S105 + + def _run_codex_auth_persist(tmp_path: Path, step: dict, gh_failures: int) -> tuple: runner_temp = tmp_path / "runner" profile = runner_temp / "stand-codex-profile"