#!/usr/bin/env python3 """Isolated exact-image Forgejo acceptance; no host mounts or external network. Synthetic identities, SQLite database and fixtures exist only in an owned Docker container. All child output is captured; receipts contain statuses, never finding contents, credentials, configuration or server logs. No production changes. """ import argparse import base64 import datetime import hashlib import json from pathlib import Path import re import subprocess import time import uuid IMAGE = 'code.forgejo.org/forgejo/forgejo@sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba' ROOT = '/tmp/forge-lifecycle' VERSION = '/data/security/receive-secret-scan/20261012-57a0642b6251' REPOS = ROOT + '/repositories/probe' CONFIG = ROOT + '/app.ini' PROFILE = Path(__file__).with_name('receive-hook-proposal.json').read_bytes() if hashlib.sha256(PROFILE).hexdigest() != '9c8ea3b7fbe16a997359432af7bcf09f429881fd23cc567188c21129e80ccf8d': raise SystemExit('Original admitted profile revision changed.') BINARIES = {item['name']: item['sha256'] for item in json.loads(PROFILE)['artifacts'] if item['name'] in ('receive-secret-scan', 'gitleaks')} class Probe: def __init__(self, receipt): self.name = 'custodian-forge-lifecycle-' + uuid.uuid4().hex[:12] self.cid = None self.password = None self.receipt = receipt self.phase = 'prepare' self.evidence = {'observed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'image': IMAGE, 'result': 'unverified', 'checks': {}, 'scope': 'Disposable local exact-image instance, SQLite, network none, no host mounts. ' 'Real Forgejo HTTP and repository services; no production credentials or data. ' 'Not deployed PostgreSQL, SSO, ingress or production lifecycle acceptance.'} def run(self, argv, payload=None, allowed=(0,), timeout=90): r = subprocess.run(argv, input=payload, capture_output=True, timeout=timeout) if r.returncode not in allowed: raise ValueError(self.phase + ':child-command-refused') return r def execute(self, *args, payload=None, allowed=(0,), timeout=90): return self.run(['docker', 'exec', '-i', self.cid, *args], payload, allowed, timeout) def put(self, path, content, mode='600'): self.execute('sh', '-c', 'umask 077; cat > "$1" && chmod "$2" "$1"', 'sh', path, mode, payload=content) def check(self, name, condition): self.evidence['checks'][name] = bool(condition) if not condition: raise ValueError(self.phase + ':' + name) print('Verified:', name, flush=True) def api(self, method, path, data=None, authenticated=True): lines = ['silent', 'show-error', 'max-time = 40', 'request = ' + json.dumps(method), 'url = ' + json.dumps('http://127.0.0.1:3000' + path), 'write-out = "\\n%{http_code}"'] if authenticated: lines.append('user = ' + json.dumps('probe:' + self.password)) if data is not None: lines += ['header = "Content-Type: application/json"', 'data-binary = ' + json.dumps(json.dumps(data))] raw = self.execute('curl', '--config', '-', payload=('\n'.join(lines) + '\n').encode()).stdout body, status = raw.rsplit(b'\n', 1) return int(status), body def start_web(self): self.execute('sh', '-c', 'nohup /usr/local/bin/gitea web --config "$1" --work-path "$2" > "$2/server.log" 2>&1 < /dev/null &', 'sh', CONFIG, ROOT) for _ in range(80): r = self.execute('curl', '-fsS', '--max-time', '2', 'http://127.0.0.1:3000/api/healthz', allowed=(0, 7, 22, 28)) if r.returncode == 0: return time.sleep(.5) raise ValueError('isolated-server-not-ready') def git(self, *args, allowed=(0,)): return self.execute('git', '-C', ROOT + '/client', *args, allowed=allowed) def push(self, ref, expected): r = self.git('push', 'http://127.0.0.1:3000/probe/coverage.git', ref, allowed=(0, 1, 128)) statuses = [] for line in (r.stdout + r.stderr).splitlines(): match = re.search(rb'\{"status":.*\}', line) if match: statuses.append(json.loads(match.group()).get('status')) self.check('http_push_' + expected, statuses == [expected] and ((r.returncode == 0) if expected == 'clean' else (r.returncode != 0))) def profile(self, repo): return REPOS + '/' + repo + '.git/hooks/pre-receive.d/10-custodian-secret-scan' def scanner_events(self): logs = self.execute('cat', ROOT + '/server.log').stdout return re.findall(rb'"status"\s*:\s*"(clean|blocked|unverified)"', logs) def run_probe(self, artifact_dir): for name, digest in BINARIES.items(): if hashlib.sha256((artifact_dir / name).read_bytes()).hexdigest() != digest: raise ValueError('unadmitted-test-binary') hook = Path(__file__).with_name('receive-profile-hook').read_bytes() if hashlib.sha256(hook).hexdigest() != 'b1d7cf8cd13feafe44e45876b1de3fdd9c88339d8d45791fa2b9f72ca832932b': raise ValueError('unadmitted-test-hook') self.cid = self.run(['docker', 'run', '-d', '--name', self.name, '--label', 'custodian.probe=' + self.name, '--network', 'none', '--cpus', '0.75', '--memory', '768m', '--pids-limit', '192', '--user', '1000:1000', '--env', 'GITEA_WORK_DIR=' + ROOT, '--env', 'GITEA_CUSTOM=' + ROOT + '/custom', '--entrypoint', 'sleep', IMAGE, '1200']).stdout.decode().strip() # The live Helm profile supplies /data; the rootless image alone does not. self.run(['docker', 'exec', '--user', '0:0', self.cid, 'mkdir', '-p', VERSION]) self.run(['docker', 'exec', '--user', '0:0', self.cid, 'chown', '-R', '1000:1000', '/data']) self.execute('mkdir', '-p', ROOT, ROOT + '/git-home', ROOT + '/client', '/tmp/gitea') for name in BINARIES: self.put(VERSION + '/' + name, (artifact_dir / name).read_bytes(), '755') config = f'''APP_NAME = Disposable acceptance probe RUN_USER = git RUN_MODE = prod WORK_PATH = {ROOT} [server] PROTOCOL = http HTTP_ADDR = 127.0.0.1 HTTP_PORT = 3000 ROOT_URL = http://127.0.0.1:3000/ LOCAL_ROOT_URL = http://127.0.0.1:3000/ DISABLE_SSH = true APP_DATA_PATH = {ROOT}/data [database] DB_TYPE = sqlite3 PATH = {ROOT}/forgejo.db [repository] ROOT = {ROOT}/repositories [git] HOME_PATH = {ROOT}/git-home [security] INSTALL_LOCK = true [service] DISABLE_REGISTRATION = true [log] MODE = console LEVEL = Error [queue] TYPE = channel [mailer] ENABLED = false [actions] ENABLED = false [packages] ENABLED = false [cron] ENABLED = false ''' self.put(CONFIG, config.encode()) self.phase = 'startup' self.start_web() r = self.execute('/usr/local/bin/gitea', 'admin', 'user', 'create', '--config', CONFIG, '--work-path', ROOT, '--username', 'probe', '--email', 'probe@example.test', '--random-password', '--random-password-length', '40', '--must-change-password=false', '--admin') match = re.search(rb"generated random password is '([^']+)'", r.stdout) if not match: raise ValueError('synthetic-identity-not-created') self.password = match.group(1).decode() self.phase = 'baseline' status, body = self.api('GET', '/api/v1/user') self.evidence['identity_http_status'] = status self.check('synthetic_identity_authenticated', status == 200) status, body = self.api('POST', '/api/v1/user/repos', {'name': 'coverage', 'private': True, 'auto_init': True, 'default_branch': 'main'}) self.evidence['repository_create_http_status'] = status self.evidence['repository_create_error_kinds'] = [value for value in ['CSRF', 'Unauthorized', 'Forbidden', 'PushRejected', '500', 'git init', 'pre-receive', '422', 'email'] if value.encode().lower() in body.lower()] self.check('native_repository_created', status == 201) r = self.execute('test', '-e', self.profile('coverage'), allowed=(0, 1)) self.check('new_repository_missing_profile_without_template', r.returncode == 1) self.put(self.profile('coverage'), hook, '755') self.phase = 'http' auth = base64.b64encode(('probe:' + self.password).encode()).decode() self.git('init', '-q', '-b', 'main') self.put(ROOT + '/client/.git/config', ('[core]\nrepositoryformatversion = 0\nbare = false\n' '[user]\nname = Disposable probe\nemail = probe@example.test\n' '[http "http://127.0.0.1:3000/"]\nextraHeader = Authorization: Basic ' + auth + '\n').encode()) self.git('fetch', '-q', 'http://127.0.0.1:3000/probe/coverage.git', 'main') self.git('reset', '--hard', 'FETCH_HEAD') self.put(ROOT + '/client/clean.txt', b'Ordinary disposable source.\n') self.git('add', 'clean.txt'); self.git('commit', '-qm', 'clean transport probe') self.push('HEAD:refs/heads/main', 'clean') self.execute('ssh-keygen', '-q', '-t', 'rsa', '-b', '2048', '-N', '', '-f', ROOT + '/synthetic-key') fixture = self.execute('cat', ROOT + '/synthetic-key').stdout self.put(ROOT + '/client/synthetic.txt', fixture) self.git('add', 'synthetic.txt'); self.git('commit', '-qm', 'disposable detection fixture') self.push('HEAD:refs/heads/refused', 'blocked') oid = self.git('rev-parse', 'HEAD').stdout.strip() result = self.execute('git', '--git-dir=' + REPOS + '/coverage.git', 'cat-file', '--batch-check', payload=oid + b'\n').stdout.strip() self.check('http_quarantine_discarded', result == oid + b' missing') self.phase = 'api-write' for name, content, expected in [('accepted', b'Clean API source.\n', 201), ('refused', fixture, 500)]: before = len(self.scanner_events()) status, body = self.api('POST', '/api/v1/repos/probe/coverage/contents/' + name + '.txt', {'content': base64.b64encode(content).decode(), 'message': 'API content probe', 'branch': 'main'}) self.evidence['checks']['api_' + name + '_http_status'] = status self.check('api_' + name, status == expected) if name == 'refused': self.check('api_refusal_has_scanner_detection', b'blocked' in self.scanner_events()[before:]) status, body = self.api('GET', '/api/v1/repos/probe/coverage/contents/refused.txt') self.check('rejected_api_content_absent', status == 404) self.phase = 'pull-request' status, body = self.api('POST', '/api/v1/repos/probe/coverage/branches', {'new_branch_name': 'feature', 'old_branch_name': 'main'}) self.check('feature_branch_created', status == 201) status, body = self.api('POST', '/api/v1/repos/probe/coverage/contents/feature.txt', {'content': base64.b64encode(b'Clean feature.\n').decode(), 'message': 'clean feature', 'branch': 'feature'}) self.check('feature_content_created', status == 201) status, body = self.api('POST', '/api/v1/repos/probe/coverage/pulls', {'head': 'feature', 'base': 'main', 'title': 'Disposable merge probe'}) self.check('pull_request_created', status == 201) index = json.loads(body)['number'] for _ in range(40): status, body = self.api('GET', f'/api/v1/repos/probe/coverage/pulls/{index}') if status == 200 and json.loads(body).get('mergeable') is True: break time.sleep(.5) else: raise ValueError('pull-request-never-mergeable') # A secret in the newly generated merge message must invoke the scanner. before = len(self.scanner_events()) status, body = self.api('POST', f'/api/v1/repos/probe/coverage/pulls/{index}/merge', {'Do': 'merge', 'MergeTitleField': 'disposable merge', 'MergeMessageField': fixture.decode(), 'force_merge': False}) self.evidence['checks']['merge_refusal_status'] = status self.check('merge_message_secret_refused', status == 409) message = json.loads(body).get('message', '') response_detection = re.search(r'"status"\s*:\s*"blocked"', message) is not None self.check('merge_refusal_has_scanner_detection', response_detection or b'blocked' in self.scanner_events()[before:]) status, body = self.api('GET', f'/api/v1/repos/probe/coverage/pulls/{index}') self.check('rejected_merge_not_applied', status == 200 and not json.loads(body)['merged']) status, body = self.api('POST', f'/api/v1/repos/probe/coverage/pulls/{index}/merge', {'Do': 'merge', 'MergeTitleField': 'Clean merge', 'MergeMessageField': 'Clean merge proof.'}) self.check('clean_merge_accepted', status in (200, 201)) self.phase = 'regeneration' self.execute('/usr/local/bin/gitea', 'admin', 'regenerate', 'hooks', '--config', CONFIG, '--work-path', ROOT) observed = self.execute('sha256sum', self.profile('coverage')).stdout.split()[0].decode() self.check('regeneration_preserves_exact_profile', observed == hashlib.sha256(hook).hexdigest()) self.phase = 'template' self.execute('mkdir', '-p', ROOT + '/template/hooks/pre-receive.d') self.put(ROOT + '/template/hooks/pre-receive.d/10-custodian-secret-scan', hook, '755') self.execute('git', 'config', '--file', ROOT + '/git-home/.gitconfig', 'init.templateDir', ROOT + '/template') status, body = self.api('POST', '/api/v1/user/repos', {'name': 'templated', 'private': True, 'auto_init': True}) self.check('native_repository_with_template_created', status == 201) observed = self.execute('sha256sum', self.profile('templated')).stdout.split()[0].decode() self.check('new_repository_inherits_exact_profile', observed == hashlib.sha256(hook).hexdigest()) self.phase = 'restart' self.run(['docker', 'restart', self.cid]) self.start_web() for repo in ['coverage', 'templated']: observed = self.execute('sha256sum', self.profile(repo)).stdout.split()[0].decode() self.check('restart_preserves_' + repo + '_profile', observed == hashlib.sha256(hook).hexdigest()) before = len(self.scanner_events()) status, body = self.api('POST', '/api/v1/repos/probe/templated/contents/refused.txt', {'content': base64.b64encode(fixture).decode(), 'message': 'Restart refusal probe'}) self.check('inherited_profile_blocks_after_restart', status == 500 and b'blocked' in self.scanner_events()[before:]) self.evidence['result'] = 'verified' def close(self): if self.cid: if self.evidence['result'] != 'verified': logs = self.execute('cat', ROOT + '/server.log', allowed=(0, 1)).stdout self.evidence['failure_diagnostics'] = [value for value in [ 'permission denied', 'database is locked', 'no such file or directory', 'dubious ownership', 'unable to auto-detect email address', 'connection refused', 'CreateRepository', 'initRepository', 'PushRejected', 'INTERNAL_TOKEN', 'app.ini', 'cannot run', 'not found', 'authentication', 'timeout', 'exit status 128', 'exit status 1', 'git push', 'git clone', 'git commit', 'Permission denied', 'Author identity unknown', 'ambiguous argument', 'default branch', 'signing', 'unknown revision'] if value.encode() in logs] identity = self.run(['docker', 'inspect', '--format', '{{ index .Config.Labels "custodian.probe" }}', self.cid]).stdout.decode().strip() if identity != self.name: raise ValueError('cleanup-owner-mismatch') self.run(['docker', 'rm', '-f', '-v', self.cid]) self.evidence['isolated_container_and_volumes_removed'] = True self.evidence['last_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 main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--artifact-dir', type=Path, required=True) p.add_argument('--receipt', type=Path, required=True) args = p.parse_args() probe = Probe(args.receipt) try: probe.run_probe(args.artifact_dir) except (ValueError, OSError, KeyError, subprocess.SubprocessError) as error: # Exception text from child tools and response bodies is never emitted. probe.evidence['failure'] = probe.phase + ':' + type(error).__name__ raise SystemExit('Isolated acceptance incomplete at phase ' + probe.phase + '; see value-free receipt.') finally: probe.close() if __name__ == '__main__': main()