the-custodian/docs/changes/CUST-WP-0080/accept_forge_routes.py
codex c12c087b89
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Reject generated-only commits / generated-commit-guard (push) Successful in 7s
security: prepare and rehearse contained Forgejo HTTP acceptance
2026-10-13 18:26:47 +02:00

316 lines
16 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 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())