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