the-custodian/docs/changes/CUST-WP-0080/verify_forge_lifecycle.py
codex e570433060
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: verify Forgejo write paths and isolate lifecycle coverage gaps
2026-10-13 13:40:26 +02:00

316 lines
17 KiB
Python

#!/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()