Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/hosted-dev-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,16 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.sha }}
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)"
Expand All @@ -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'
Expand All @@ -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)"
Expand Down
7 changes: 6 additions & 1 deletion scripts/check-signing-environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions scripts/tests/test_signing_environment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
import importlib.util
import os
from pathlib import Path
import unittest
from unittest.mock import patch
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading