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