the-custodian/docs/changes/CUST-WP-0080/accept_forge_routes.py
codex 81f2f59649 security: verify authenticated Forgejo routes live (FORGE6-ROUTES-001 r3)
Revision 2 live run passed 24/25; the clean merge hit Forgejo's 405 during
the mergeability recheck. Revision 3 waits for mergeable, retries only 405
(max 3) and records status codes. 7 boundary tests, isolated rehearsal
(27 checks) and the attended live run (26/26, verified, cleanup confirmed)
pass. Revision 2 proposal and receipts retained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:33:46 +02:00

331 lines
17 KiB
Python

#!/usr/bin/env python3
"""Contained HTTP/API acceptance on one owned private disposable repository.
Production entrypoint accepts API_TOKEN only from the protected child environment.
No token, finding body, raw response or child diagnostic is written to receipts.
The same Suite runs against the pinned isolated image before live admission.
"""
import argparse
import base64
import datetime
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
HERE = Path(__file__).parent
ORIGIN = 'https://forgejo.coulomb.social'
PROFILE = json.loads((HERE / 'receive-hook-proposal.json').read_text())
def detected(body):
# Only parse the control's fixed status; never preserve response text.
return re.search(rb'"status"\s*:\s*"blocked"', body) is not None
class Suite:
def __init__(self, backend, receipt, name):
if not re.fullmatch(r'custodian-routes-[a-f0-9]{24}', name):
raise ValueError('invalid-probe-name')
self.b, self.receipt, self.name = backend, receipt, name
self.path = '/api/v1/repos/' + backend.user + '/' + name
self.created_id = None
self.phase = 'preflight'
self.evidence = {'result': 'unverified', 'repository_name': name,
'observed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'checks': {}, 'scope': backend.scope}
def save(self):
self.evidence['phase'] = self.phase
self.evidence['executor_sha256'] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
self.receipt.write_text(json.dumps(self.evidence, indent=2) + '\n')
def check(self, key, value):
self.evidence['checks'][key] = bool(value)
self.save()
if not value:
raise ValueError('acceptance-incomplete')
def api(self, method, suffix='', data=None, authenticated=True):
return self.b.api(method, self.path + suffix, data, authenticated)
def head(self):
code, body = self.api('GET', '/branches/main')
if code != 200:
raise ValueError('branch-read-refused')
oid = json.loads(body)['commit']['id']
if not re.fullmatch(r'[a-f0-9]{40}', oid):
raise ValueError('invalid-object-id')
return oid
def wait_mergeable(self, suffix):
for _ in range(40):
code, body = self.api('GET', suffix)
if code == 200 and json.loads(body).get('mergeable') is True:
return
time.sleep(.5)
raise ValueError('pull-not-mergeable')
def clean_merge(self, suffix):
# A refused merge makes Forgejo recheck mergeability; until that finishes
# it answers 405. Wait again and retry only that status, a bounded few times.
statuses = self.evidence['clean_merge_status_codes'] = []
for _ in range(3):
self.wait_mergeable(suffix)
code = self.api('POST', suffix + '/merge', {'Do': 'merge',
'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean proof.'})[0]
statuses.append(code)
if code != 405:
break
time.sleep(1)
return statuses[-1]
def content(self, name, content, branch='main'):
return self.api('POST', '/contents/' + name,
{'content': base64.b64encode(content).decode(),
'message': 'Disposable route acceptance', 'branch': branch})
def run(self):
code, body = self.b.api('GET', '/api/v1/user')
self.check('expected_authenticated_caller', code == 200 and
json.loads(body).get('login') == self.b.user)
self.check('target_does_not_preexist', self.api('GET')[0] == 404)
self.phase = 'create'
self.evidence['creation_attempted'] = True
self.save() # An interrupted POST is ambiguous; never replay automatically.
code, body = self.b.api('POST', '/api/v1/user/repos',
{'name': self.name, 'private': True, 'auto_init': True,
'default_branch': 'main', 'description': self.name})
if code == 201:
data = json.loads(body)
if (type(data.get('id')) is int and data.get('name') == self.name and
data.get('private') is True and data.get('owner', {}).get('login') == self.b.user):
self.created_id = data['id']
self.evidence['created_repository_id'] = self.created_id
self.save()
self.check('owned_private_repository_created', self.created_id is not None)
self.check('anonymous_private_read_denied', self.api('GET', authenticated=False)[0] in (401, 403, 404))
self.b.profile(self.name)
self.check('new_repository_inherits_exact_hook', True)
self.phase = 'http-git'
remote = self.b.origin + '/' + self.b.user + '/' + self.name + '.git'
self.b.git('init', '-q', '-b', 'main')
self.b.git('config', 'core.hooksPath', '/dev/null')
self.b.git('config', 'user.name', 'Disposable route probe')
self.b.git('config', 'user.email', 'probe@example.test')
self.b.git('fetch', '-q', remote, 'main')
self.b.git('reset', '--hard', 'FETCH_HEAD')
self.b.put('clean.txt', b'Ordinary disposable source.\n')
self.b.git('add', 'clean.txt'); self.b.git('commit', '-qm', 'Clean HTTP proof')
r = self.b.git('push', remote, 'HEAD:refs/heads/main', allow_failure=True)
self.check('http_clean_push_accepted', r.returncode == 0 and
re.search(rb'"status"\s*:\s*"clean"', r.stdout + r.stderr) is not None)
clean_head = self.head()
self.check('http_clean_ref_matches', self.b.git('rev-parse', 'HEAD').stdout.decode().strip() == clean_head)
fixture = self.b.fixture()
self.b.put('synthetic.txt', fixture)
self.b.git('add', 'synthetic.txt'); self.b.git('commit', '-qm', 'Unbound synthetic detection fixture')
oid = self.b.git('rev-parse', 'HEAD').stdout.decode().strip()
r = self.b.git('push', remote, 'HEAD:refs/heads/refused', allow_failure=True)
self.check('http_secret_push_detected_and_refused', r.returncode != 0 and detected(r.stdout + r.stderr))
self.check('http_refused_ref_absent', self.api('GET', '/branches/refused')[0] == 404)
self.check('http_rejection_preserves_main', self.head() == clean_head)
self.b.absent_object(self.name, oid)
self.check('http_quarantine_object_absent', True)
self.phase = 'content-api'
self.check('api_clean_content_accepted', self.content('accepted.txt', b'Clean API source.\n')[0] == 201)
before = self.head()
code, body = self.content('refused.txt', fixture)
self.check('api_secret_content_refused', code == 500)
# API error bodies encode the hook message as JSON; decode privately.
message = json.loads(body).get('message', '').encode()
self.check('api_refusal_has_detector_evidence', detected(message))
self.check('api_refusal_preserves_main', self.head() == before)
self.check('api_refused_content_absent', self.api('GET', '/contents/refused.txt')[0] == 404)
self.phase = 'merge-api'
self.check('feature_branch_created', self.api('POST', '/branches',
{'new_branch_name': 'feature', 'old_branch_name': 'main'})[0] == 201)
self.check('feature_content_accepted', self.content('feature.txt', b'Clean feature.\n', 'feature')[0] == 201)
code, body = self.api('POST', '/pulls', {'head': 'feature', 'base': 'main', 'title': 'Disposable merge proof'})
self.check('pull_request_created', code == 201)
number = json.loads(body)['number']
if type(number) is not int:
raise ValueError('invalid-pull-number')
suffix = '/pulls/' + str(number)
self.wait_mergeable(suffix)
before = self.head()
code, body = self.api('POST', suffix + '/merge', {'Do': 'merge',
'MergeTitleField': 'Disposable merge', 'MergeMessageField': fixture.decode(),
'force_merge': False})
self.check('merge_secret_message_detected_and_refused', code == 409 and
detected(json.loads(body).get('message', '').encode()))
code, body = self.api('GET', suffix)
self.check('rejected_merge_not_applied', code == 200 and json.loads(body).get('merged') is False and self.head() == before)
self.check('clean_merge_accepted', self.clean_merge(suffix) in (200, 201))
code, body = self.api('GET', suffix)
self.check('clean_merge_applied', code == 200 and json.loads(body).get('merged') is True and self.head() != before)
self.evidence['acceptance_passed'] = True
def close(self):
self.phase = 'cleanup'
if self.created_id is not None:
code, body = self.api('GET')
data = json.loads(body) if code == 200 else {}
self.check('cleanup_identity_matches', code == 200 and data.get('id') == self.created_id and
data.get('name') == self.name and data.get('private') is True and
data.get('description') == self.name and data.get('owner', {}).get('login') == self.b.user)
self.check('owned_disposable_repository_deleted', self.api('DELETE')[0] == 204)
self.check('deleted_repository_absent', self.api('GET')[0] == 404)
elif self.evidence.get('creation_attempted'):
self.evidence['creation_outcome_requires_reconciliation'] = True
if self.evidence.get('acceptance_passed') and self.evidence['checks'].get('deleted_repository_absent'):
self.evidence['result'] = 'verified'
self.save()
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
class Production:
user = 'tegwick'
origin = ORIGIN
scope = ('Deployed HTTPS Git, repository creation, content API and merge API on one owned private '
'disposable repository. No browser/SSO, CI, import/mirror or finding-response conformance inferred.')
def __init__(self, token, directory):
if not token or '\n' in token or '\r' in token:
raise ValueError('injected-token-absent-or-invalid')
self.token, self.directory = token, directory
spec = importlib.util.spec_from_file_location('template', HERE / 'activate_forge_template.py')
self.native = importlib.util.module_from_spec(spec); spec.loader.exec_module(self.native)
self.pod = self.native.target_pod(self.native.deployment(), PROFILE)
self.native.check_artifacts(self.pod)
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
def api(self, method, path, data=None, authenticated=True):
if not path.startswith('/api/v1/') or '://' in path:
raise ValueError('invalid-api-path')
headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
if authenticated:
headers['Authorization'] = 'token ' + self.token
request = urllib.request.Request(self.origin + path, method=method, headers=headers,
data=None if data is None else json.dumps(data).encode())
try:
response = self.opener.open(request, timeout=120)
except urllib.error.HTTPError as error:
response = error
with response:
body = response.read(1024 * 1024 + 1)
if len(body) > 1024 * 1024:
raise ValueError('response-too-large')
return response.code, body
def git(self, *args, allow_failure=False):
auth = base64.b64encode((self.user + ':' + self.token).encode()).decode()
settings = {'credential.helper': '', 'http.followRedirects': 'false', 'http.proxy': '',
'http.sslVerify': 'true', 'http.' + self.origin + '/.extraHeader': 'Authorization: Basic ' + auth}
env = {'PATH': '/usr/bin:/bin', 'HOME': str(self.directory), 'LANG': 'C',
'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_TERMINAL_PROMPT': '0',
'GIT_ALLOW_PROTOCOL': 'https', 'GIT_CONFIG_COUNT': str(len(settings))}
for i, (key, value) in enumerate(settings.items()):
env['GIT_CONFIG_KEY_' + str(i)] = key; env['GIT_CONFIG_VALUE_' + str(i)] = value
result = subprocess.run(['git', '-C', str(self.directory), *args], env=env, capture_output=True, timeout=120)
if result.returncode and not allow_failure:
raise ValueError('git-command-refused')
return result
def put(self, name, content):
(self.directory / name).write_bytes(content)
def fixture(self):
result = subprocess.run(['ssh-keygen', '-q', '-t', 'rsa', '-b', '2048', '-N', '',
'-f', str(self.directory / 'unbound-key')], capture_output=True, timeout=30)
if result.returncode:
raise ValueError('fixture-generation-failed')
return (self.directory / 'unbound-key').read_bytes()
def profile(self, name):
root = '/data/git/gitea-repositories/' + self.user + '/' + name + '.git'
hook = root + '/hooks/pre-receive.d/' + PROFILE['hook_name']
self.native.native.execute(self.pod, 'sh', '-c',
'test -f "$1" && test ! -L "$1" && test -x "$1"', 'sh', hook)
observed = self.native.native.execute(self.pod, 'sha256sum', hook).decode().split()[0]
mode = self.native.native.execute(self.pod, 'stat', '-c', '%a %u %g', hook).decode().strip()
if observed != PROFILE['hook_sha256'] or mode not in ('555 1000 1000', '755 1000 1000'):
raise ValueError('inherited-profile-mismatch')
def absent_object(self, name, oid):
if not re.fullmatch(r'[a-f0-9]{40}', oid):
raise ValueError('invalid-object-id')
root = '/data/git/gitea-repositories/' + self.user + '/' + name + '.git'
raw = self.native.native.execute(self.pod, 'git', '--git-dir=' + root, 'cat-file',
'--batch-check', payload=(oid + '\n').encode()).decode().strip()
if raw != oid + ' missing':
raise ValueError('quarantine-retained')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--receipt', type=Path, required=True)
parser.add_argument('--name', required=True)
parser.add_argument('--proposal', type=Path, required=True)
parser.add_argument('--cleanup-only', action='store_true')
args = parser.parse_args()
# Remove injection before invoking SSH or other unrelated child tools.
token = os.environ.pop('API_TOKEN', '')
if args.receipt.exists() and not args.cleanup_only:
return 2
suite = None
try:
proposal_bytes = args.proposal.read_bytes()
proposal = json.loads(proposal_bytes)
if (proposal['repository_name'] != args.name or proposal['origin'] != ORIGIN or
proposal['user'] != Production.user):
raise ValueError('proposal-target-mismatch')
for name, digest in proposal['source_sha256'].items():
if Path(name).name != name or hashlib.sha256((HERE / name).read_bytes()).hexdigest() != digest:
raise ValueError('proposal-source-mismatch')
if 'accept_forge_routes.py' not in proposal['source_sha256']:
raise ValueError('proposal-executor-unbound')
with tempfile.TemporaryDirectory(prefix='forge-routes-') as directory:
backend = Production(token, Path(directory))
suite = Suite(backend, args.receipt, args.name)
if args.cleanup_only:
previous = json.loads(args.receipt.read_text())
if (previous.get('repository_name') != args.name or
previous.get('proposal_sha256') != hashlib.sha256(proposal_bytes).hexdigest() or
type(previous.get('created_repository_id')) is not int):
raise ValueError('cleanup-ownership-unverified')
suite.evidence = previous
suite.created_id = previous['created_repository_id']
suite.close()
return 0 if suite.evidence['checks'].get('deleted_repository_absent') else 1
suite.evidence['proposal_sha256'] = hashlib.sha256(proposal_bytes).hexdigest()
try:
suite.run()
except Exception:
suite.evidence['failure_phase'] = suite.phase
finally:
try:
suite.close()
except Exception:
suite.evidence['cleanup_unverified'] = True
suite.save()
return 0 if suite.evidence['result'] == 'verified' else 1
except Exception:
if not args.receipt.exists():
args.receipt.write_text(json.dumps({'result': 'unverified', 'failure_phase': 'preflight'}) + '\n')
return 1
if __name__ == '__main__':
raise SystemExit(main())