diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5330c50f..dce91e40 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,9 +17,9 @@ permissions: contents: read env: - GCP_PROJECT_ID: binancequant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/677468735457/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: binance-platform-runtime@binancequant.iam.gserviceaccount.com + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} @@ -42,6 +42,29 @@ jobs: contents: write id-token: write steps: + - name: 0. Validate deployment identity configuration + shell: bash + run: | + set -euo pipefail + for name in GCP_PROJECT_ID GCP_WORKLOAD_IDENTITY_PROVIDER GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT; do + if [ -z "${!name:-}" ]; then + echo "::error::Required repository variable ${name} is not configured." + exit 1 + fi + done + readonly EXPECTED_OIDC_IDENTITY_SHA256="68e87a5dc1bbe2e41af33d526514034246c6487fb7b716596cc75fe1c739a6b9" + actual_oidc_identity_sha256="$( + printf '%s\0%s\0%s' \ + "$GCP_PROJECT_ID" \ + "$GCP_WORKLOAD_IDENTITY_PROVIDER" \ + "$GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT" | + sha256sum | + awk '{print $1}' + )" + if [ "$actual_oidc_identity_sha256" != "$EXPECTED_OIDC_IDENTITY_SHA256" ]; then + echo "::error::Repository OIDC identity variables do not match the reviewed identity contract." + exit 1 + fi - name: 1. Checkout latest code uses: actions/checkout@v6 with: diff --git a/.github/workflows/watchdog.yml b/.github/workflows/watchdog.yml index 682451a3..4058f692 100644 --- a/.github/workflows/watchdog.yml +++ b/.github/workflows/watchdog.yml @@ -10,9 +10,9 @@ permissions: contents: read env: - GCP_PROJECT_ID: binancequant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/677468735457/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: binance-platform-runtime@binancequant.iam.gserviceaccount.com + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} WATCHDOG_MAX_AGE_SECONDS: ${{ vars.WATCHDOG_MAX_AGE_SECONDS || '4500' }} jobs: @@ -20,6 +20,29 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Validate deployment identity configuration + shell: bash + run: | + set -euo pipefail + for name in GCP_PROJECT_ID GCP_WORKLOAD_IDENTITY_PROVIDER GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT; do + if [ -z "${!name:-}" ]; then + echo "::error::Required repository variable ${name} is not configured." + exit 1 + fi + done + readonly EXPECTED_OIDC_IDENTITY_SHA256="68e87a5dc1bbe2e41af33d526514034246c6487fb7b716596cc75fe1c739a6b9" + actual_oidc_identity_sha256="$( + printf '%s\0%s\0%s' \ + "$GCP_PROJECT_ID" \ + "$GCP_WORKLOAD_IDENTITY_PROVIDER" \ + "$GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT" | + sha256sum | + awk '{print $1}' + )" + if [ "$actual_oidc_identity_sha256" != "$EXPECTED_OIDC_IDENTITY_SHA256" ]; then + echo "::error::Repository OIDC identity variables do not match the reviewed identity contract." + exit 1 + fi - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: diff --git a/tests/test_runtime_workflow_shared_config.sh b/tests/test_runtime_workflow_shared_config.sh index 21429159..ac44f56f 100644 --- a/tests/test_runtime_workflow_shared_config.sh +++ b/tests/test_runtime_workflow_shared_config.sh @@ -11,6 +11,18 @@ grep -Fq "STRATEGY_PROFILE: \${{ vars.STRATEGY_PROFILE || 'crypto_live_pool_rota grep -Fq 'id-token: write' "$workflow_file" grep -Fq 'workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}' "$workflow_file" grep -Fq 'service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$workflow_file" +grep -Fq 'GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}' "$workflow_file" +grep -Fq 'GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}' "$workflow_file" +grep -Fq 'GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$workflow_file" +grep -Fq 'for name in GCP_PROJECT_ID GCP_WORKLOAD_IDENTITY_PROVIDER GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT; do' "$workflow_file" +grep -Fq 'echo "::error::Required repository variable ${name} is not configured."' "$workflow_file" +grep -Eq 'readonly EXPECTED_OIDC_IDENTITY_SHA256="[0-9a-f]{64}"' "$workflow_file" +grep -Fq "printf '%s\\0%s\\0%s'" "$workflow_file" +preflight_line="$(grep -nF -- '- name: 0. Validate deployment identity configuration' "$workflow_file" | cut -d: -f1)" +checkout_line="$(grep -nF -- '- name: 1. Checkout latest code' "$workflow_file" | cut -d: -f1)" +auth_line="$(grep -nF -- '- name: 2. Authenticate to Google Cloud' "$workflow_file" | cut -d: -f1)" +test "$preflight_line" -lt "$checkout_line" +test "$preflight_line" -lt "$auth_line" grep -Fq '7. Notify Telegram on runtime workflow failure' "$workflow_file" grep -Fq "if: \${{ failure() && github.event.inputs.validate_only != 'true' }}" "$workflow_file" grep -Fq 'reports/execution_report.json' "$workflow_file" diff --git a/tests/test_watchdog_workflow.py b/tests/test_watchdog_workflow.py index 4d060ce2..5a045421 100644 --- a/tests/test_watchdog_workflow.py +++ b/tests/test_watchdog_workflow.py @@ -1,5 +1,9 @@ from __future__ import annotations +import hashlib +import os +import re +import subprocess import tomllib import unittest from pathlib import Path @@ -11,6 +15,46 @@ PYPROJECT = ROOT / "pyproject.toml" LOCK = ROOT / "uv.lock" QSL = ROOT / "qsl.toml" +IDENTITY_NAMES = ( + "GCP_PROJECT_ID", + "GCP_WORKLOAD_IDENTITY_PROVIDER", + "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT", +) +EXPECTED_DIGEST_PATTERN = re.compile(r'EXPECTED_OIDC_IDENTITY_SHA256="([0-9a-f]{64})"') + + +def _identity_digest(values: dict[str, str]) -> str: + canonical = b"\0".join(values[name].encode("utf-8") for name in IDENTITY_NAMES) + return hashlib.sha256(canonical).hexdigest() + + +def _preflight_script(workflow_text: str) -> str: + step = workflow_text.index("Validate deployment identity configuration") + run_marker = " run: |\n" + start = workflow_text.index(run_marker, step) + len(run_marker) + lines: list[str] = [] + for line in workflow_text[start:].splitlines(): + if line.startswith(" - "): + break + if line.startswith(" "): + lines.append(line[10:]) + elif not line: + lines.append("") + else: + break + return "\n".join(lines) + + +def _run_preflight(script: str, values: dict[str, str]) -> subprocess.CompletedProcess[str]: + env = {"PATH": os.environ.get("PATH", "")} + env.update(values) + return subprocess.run( + ["bash", "-c", script], + env=env, + capture_output=True, + text=True, + check=False, + ) def _project_dependencies() -> list[str]: @@ -30,25 +74,114 @@ class WatchdogWorkflowTests(unittest.TestCase): def setUpClass(cls) -> None: cls.workflow_text = WORKFLOW.read_text(encoding="utf-8") - def test_watchdog_uses_binance_runtime_identity(self) -> None: + def test_watchdog_uses_repository_variables_before_remote_actions(self) -> None: text = self.workflow_text self.assertIn("id-token: write", text) + for name in ( + "GCP_PROJECT_ID", + "GCP_WORKLOAD_IDENTITY_PROVIDER", + "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT", + ): + self.assertIn(f"{name}: ${{{{ vars.{name} }}}}", text) self.assertIn( - "GCP_WORKLOAD_IDENTITY_PROVIDER: " - "projects/677468735457/locations/global/workloadIdentityPools/github-actions/providers/github-main", + "for name in GCP_PROJECT_ID GCP_WORKLOAD_IDENTITY_PROVIDER " + "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT; do", text, ) self.assertIn( - "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: " - "binance-platform-runtime@binancequant.iam.gserviceaccount.com", + 'echo "::error::Required repository variable ${name} is not configured."', text, ) + preflight = text.index("- name: Validate deployment identity configuration") + checkout = text.index("- uses: actions/checkout@v6") + auth = text.index("- name: Authenticate to Google Cloud") + self.assertLess(preflight, checkout) + self.assertLess(preflight, auth) self.assertIn("uses: google-github-actions/auth@v3", text) self.assertIn("workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}", text) self.assertIn("service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}", text) self.assertIn("WATCHDOG_MAX_AGE_SECONDS: ${{ vars.WATCHDOG_MAX_AGE_SECONDS || '4500' }}", text) + def test_oidc_identity_digest_is_fixed_and_shared(self) -> None: + scripts = ( + _preflight_script(RUNTIME_WORKFLOW.read_text(encoding="utf-8")), + _preflight_script(self.workflow_text), + ) + digests: list[str] = [] + + for script in scripts: + matches = EXPECTED_DIGEST_PATTERN.findall(script) + self.assertEqual(len(matches), 1) + digests.extend(matches) + self.assertIn("readonly EXPECTED_OIDC_IDENTITY_SHA256=", script) + self.assertIn("set -euo pipefail", script) + self.assertNotIn("set -x", script) + self.assertIn(r"printf '%s\0%s\0%s'", script) + self.assertLess(script.index('"$GCP_PROJECT_ID"'), script.index('"$GCP_WORKLOAD_IDENTITY_PROVIDER"')) + self.assertLess( + script.index('"$GCP_WORKLOAD_IDENTITY_PROVIDER"'), + script.index('"$GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT"'), + ) + + self.assertEqual(len(set(digests)), 1) + + def test_oidc_identity_digest_fails_closed_without_value_output(self) -> None: + baseline = { + "GCP_PROJECT_ID": "synthetic-project", + "GCP_WORKLOAD_IDENTITY_PROVIDER": "synthetic-provider", + "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT": "synthetic-service-account", + } + expected = _identity_digest(baseline) + + for workflow in (RUNTIME_WORKFLOW, WORKFLOW): + script = _preflight_script(workflow.read_text(encoding="utf-8")) + script, replacements = EXPECTED_DIGEST_PATTERN.subn( + f'EXPECTED_OIDC_IDENTITY_SHA256="{expected}"', + script, + ) + self.assertEqual(replacements, 1) + baseline_result = _run_preflight(script, baseline) + self.assertEqual(baseline_result.returncode, 0) + baseline_output = baseline_result.stdout + baseline_result.stderr + self.assertNotIn(expected, baseline_output) + for value in baseline.values(): + self.assertNotIn(value, baseline_output) + + candidates: list[dict[str, str]] = [] + for name in IDENTITY_NAMES: + empty = dict(baseline) + empty[name] = "" + candidates.append(empty) + + modified = dict(baseline) + modified[name] = f"{modified[name]}-modified" + candidates.append(modified) + + for left, right in ((0, 1), (0, 2), (1, 2)): + swapped = dict(baseline) + swapped[IDENTITY_NAMES[left]], swapped[IDENTITY_NAMES[right]] = ( + swapped[IDENTITY_NAMES[right]], + swapped[IDENTITY_NAMES[left]], + ) + candidates.append(swapped) + candidates.append( + { + IDENTITY_NAMES[0]: baseline[IDENTITY_NAMES[0]] + baseline[IDENTITY_NAMES[1]], + IDENTITY_NAMES[1]: baseline[IDENTITY_NAMES[2]], + IDENTITY_NAMES[2]: baseline[IDENTITY_NAMES[0]], + } + ) + + for candidate in candidates: + result = _run_preflight(script, candidate) + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertNotIn(expected, output) + for value in candidate.values(): + if value: + self.assertNotIn(value, output) + def test_watchdog_installs_locked_internal_dependency(self) -> None: text = self.workflow_text