#!/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 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) for _ in range(40): code, body = self.api('GET', suffix) if code == 200 and json.loads(body).get('mergeable') is True: break time.sleep(.5) else: raise ValueError('pull-not-mergeable') 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.api('POST', suffix + '/merge', {'Do': 'merge', 'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean proof.'})[0] 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())