diff --git a/.github/workflows/hosted-dev-testflight.yml b/.github/workflows/hosted-dev-testflight.yml index 74c7073..83e0f06 100644 --- a/.github/workflows/hosted-dev-testflight.yml +++ b/.github/workflows/hosted-dev-testflight.yml @@ -50,6 +50,7 @@ jobs: timeout-minutes: 5 permissions: contents: read + actions: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -57,6 +58,8 @@ jobs: persist-credentials: false - name: Check the existing signing environment without secrets timeout-minutes: 1 + env: + KIN_RECHECK_GITHUB_TOKEN: ${{ github.token }} run: | test "$(git rev-parse HEAD)" = "$GITHUB_SHA" test -z "$(git status --porcelain)" @@ -73,6 +76,7 @@ jobs: timeout-minutes: 86 permissions: contents: read + actions: read env: DEVELOPER_DIR: /Applications/Xcode_27.app/Contents/Developer KIN_DEV_SIGNING_ONLY: '1' @@ -85,6 +89,8 @@ jobs: persist-credentials: false - name: Recheck environment before secret-bearing step timeout-minutes: 1 + env: + KIN_RECHECK_GITHUB_TOKEN: ${{ github.token }} run: | test "$(git rev-parse HEAD)" = "$GITHUB_SHA" test -z "$(git status --porcelain)" diff --git a/scripts/check-signing-environment.py b/scripts/check-signing-environment.py index 6f66f6a..fe6147e 100644 --- a/scripts/check-signing-environment.py +++ b/scripts/check-signing-environment.py @@ -5,6 +5,7 @@ attestation. The owner must also verify unsupported/omitted settings in GitHub. """ import json +import os import socket import ssl import urllib.error @@ -33,8 +34,12 @@ def redirect_request(self, *args, **kwargs): def fetch(url): if url not in (BASE, BASE + '/deployment-branch-policies?per_page=100'): raise ValueError('unexpected endpoint') + token = os.environ.get('KIN_RECHECK_GITHUB_TOKEN', '') + if not token or len(token) > 4096 or not token.isascii() or not all(33 <= ord(c) <= 126 for c in token): + raise ValueError('token unavailable') request = urllib.request.Request(url, headers={ - 'Accept': 'application/vnd.github+json', 'User-Agent': 'kin-signing-preflight'}) + 'Accept': 'application/vnd.github+json', 'User-Agent': 'kin-signing-preflight', + 'Authorization': 'Bearer ' + token}) opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()) with opener.open(request, timeout=10) as response: if response.status != 200: diff --git a/scripts/tests/test_signing_environment.py b/scripts/tests/test_signing_environment.py index e2ecef1..b100e9f 100644 --- a/scripts/tests/test_signing_environment.py +++ b/scripts/tests/test_signing_environment.py @@ -1,5 +1,6 @@ import copy import importlib.util +import os from pathlib import Path import unittest from unittest.mock import patch @@ -38,6 +39,7 @@ def test_bad_branch_policy(self): for policies in ({'total_count':0,'branch_policies':[]}, {'total_count':1,'branch_policies':[{'name':'*','type':'branch'}]}, {'total_count':1,'branch_policies':[{'name':'main','type':'tag'}]}, {'total_count':2,'branch_policies':[{'name':'main','type':'branch'}]}, {}): with self.subTest(policies=policies), self.assertRaises(ValueError): p.validate(self.env, policies) + @patch.dict(os.environ, {'KIN_RECHECK_GITHUB_TOKEN': 'synthetic-token'}) def test_fetch_endpoint_and_json_bounds(self): class Response: status = 200 @@ -75,6 +77,41 @@ def test_fixed_failure_categories_and_redaction(self): with self.assertRaises(SystemExit) as cm: p.main() self.assertIn('policy mismatch', str(cm.exception)) + def test_token_is_scoped_to_fixed_endpoints_and_never_redirected(self): + class Response: + status = 200 + def __enter__(self): return self + def __exit__(self, *args): pass + def read(self, size): return b'{}' + class Opener: + def __init__(self): self.requests = [] + def open(self, request, timeout): + self.requests.append((request, timeout)); return Response() + opener = Opener() + with patch.dict(os.environ, {'KIN_RECHECK_GITHUB_TOKEN': 'synthetic-token'}, clear=True), patch.object(p.urllib.request, 'build_opener', return_value=opener): + for url in (p.BASE, p.BASE + '/deployment-branch-policies?per_page=100'): + self.assertEqual(p.fetch(url), {}) + with self.assertRaises(ValueError): p.fetch('https://private.invalid/') + self.assertEqual(len(opener.requests), 2) + for request, timeout in opener.requests: + self.assertEqual(request.get_header('Authorization'), 'Bearer synthetic-token') + self.assertEqual(timeout, 10) + self.assertIsNone(p._NoRedirect().redirect_request(None, None, None, None, None, None)) + + def test_missing_or_malformed_token_never_sends_request(self): + for token in ('', 'bad\ntoken', 'x' * 4097, '💡'): + with self.subTest(token=token[:8]), patch.dict(os.environ, {'KIN_RECHECK_GITHUB_TOKEN': token}, clear=True), patch.object(p.urllib.request, 'build_opener') as opener: + with self.assertRaises(ValueError): p.fetch(p.BASE) + opener.assert_not_called() + + def test_workflow_limits_token_to_recheck_steps(self): + workflow = (Path(__file__).resolve().parents[2] / '.github/workflows/hosted-dev-testflight.yml').read_text() + self.assertEqual(workflow.count('KIN_RECHECK_GITHUB_TOKEN: ${{ github.token }}'), 2) + self.assertEqual(workflow.count(' actions: read'), 2) + self.assertNotIn('actions: write', workflow) + self.assertIn("test \"$TRIAL_MODE\" = signing_only", workflow) + self.assertIn("if: ${{ false && inputs.mode == 'upload_trial' }}", workflow) + def test_cleanup_is_conditioned_on_archive_step_start(self): workflow = (Path(__file__).resolve().parents[2] / '.github/workflows/hosted-dev-testflight.yml').read_text() self.assertIn('id: dev_archive', workflow)