the-custodian/docs/changes/CUST-WP-0080/activate_receive_hooks.py
codex eb93eeedfd
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: preserve verified Forgejo scanner activation and reconcile recovery
2026-10-13 11:31:06 +02:00

237 lines
14 KiB
Python

#!/usr/bin/env python3
"""Activate exact reviewed additive Forgejo hook profile and verify SSH refusals.
Synthetic pushes are always vetoed by a temporary independent hook. Existing
Forgejo hooks are hash-checked and preserved. No real credential is read and no
synthetic ref is retained. Native frontend/HTTP/new-repo coverage stays separate.
"""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def helper(proposal):
path = Path(__file__).with_name('verify_receive_native.py')
if sha(__file__) != proposal['executor_sha256'] or sha(path) != proposal['native_helper_sha256']:
raise ValueError('executor-revision-changed')
spec = importlib.util.spec_from_file_location('native_receive_support', path)
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module
def shell(native, proposal, script, payload=None, timeout=60):
return native.execute(proposal, 'bash', '-c', 'set -euo pipefail\n' + script, payload=payload, timeout=timeout)
def repo_script(proposal, body):
names = proposal['repositories']
if len(names) != 22 or len(set(names)) != len(names) or any(not re.fullmatch(r'[a-z0-9-]+', n) for n in names):
raise ValueError('scope-refused')
return 'for name in ' + ' '.join(names) + '; do\nrepo=/data/git/gitea-repositories/coulomb/$name.git\n' + body + '\ndone\n'
def validate(proposal):
for name in [proposal['hook_name'], proposal['veto_name']]:
if not re.fullmatch(r'[a-z0-9-]+', name): raise ValueError('unsafe-hook-name')
if not re.fullmatch(r'/data/security/receive-secret-scan/[a-z0-9-]+', proposal['version_directory']):
raise ValueError('unsafe-version-directory')
if not re.fullmatch(r'refs/heads/custodian-g07-probe-[a-z0-9-]+', proposal['probe_ref']):
raise ValueError('unsafe-probe-ref')
def inspect(native, proposal, applied=False, sentinel=False):
validate(proposal)
prefix = proposal['hook_name']; veto = proposal['veto_name']
expected = dict(proposal['existing_hook_hashes'])
if applied: expected['pre-receive.d/' + prefix] = proposal['hook_sha256']
if sentinel: expected['pre-receive.d/' + veto] = proposal['veto_sha256']
body = '''test -d "$repo" && test ! -L "$repo" && test ! -L "$repo/hooks" && test ! -L "$repo/hooks/pre-receive.d"
test -z "$(find "$repo/hooks" -maxdepth 2 -type l -print -quit)"
printf 'REPO %s\n' "$name"
find "$repo/hooks" -maxdepth 2 -type f ! -name '*.sample' -exec sha256sum {} \\;
'''
if applied:
body += 'test -x "$repo/hooks/pre-receive.d/' + proposal['hook_name'] + '"\n'
if sentinel:
body += 'test -x "$repo/hooks/pre-receive.d/' + proposal['veto_name'] + '"\n'
raw = shell(native, proposal, repo_script(proposal, body)).decode()
rows = {}; current = None
for line in raw.splitlines():
if line.startswith('REPO '): current = line[5:]; rows[current] = {}
else:
digest, name = line.split(' ', 1)
if not re.fullmatch(r'[a-f0-9]{64}', digest): raise ValueError('invalid-hook-digest')
prefix_path = '/data/git/gitea-repositories/coulomb/' + current + '.git/hooks/'
if not name.startswith(prefix_path): raise ValueError('unexpected-hook-path')
rows[current][name[len(prefix_path):]] = digest
if set(rows) != set(proposal['repositories']) or any(v != expected for v in rows.values()):
raise ValueError('hook-inventory-changed')
return rows
def rollback(native, proposal):
validate(proposal)
for name, digest in [(proposal['hook_name'], proposal['hook_sha256']), (proposal['veto_name'], proposal['veto_sha256'])]:
body = 'path="$repo/hooks/pre-receive.d/' + name + '"\n'
body += 'if test -e "$path"; then test ! -L "$path"; test "$(sha256sum "$path" | cut -d" " -f1)" = ' + shlex.quote(digest) + '; fi\n'
shell(native, proposal, repo_script(proposal, body))
for name in [proposal['hook_name'], proposal['veto_name']]:
shell(native, proposal, repo_script(proposal, 'rm -f -- "$repo/hooks/pre-receive.d/' + name + '"'))
inspect(native, proposal)
def git(directory, args, payload=None, accepted=(0,)):
env = dict(os.environ)
for key in list(env):
if key.startswith('GIT_'): env.pop(key)
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL='/dev/null', GIT_TERMINAL_PROMPT='0',
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=10')
r = subprocess.run(['/usr/bin/git', *args], cwd=directory, env=env, input=payload,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
if r.returncode not in accepted:
raise ValueError('git-probe-command-failed')
return r
def verify_pushes(native, proposal):
fixture = Path(proposal['fixture_key'])
if sha(fixture) != proposal['fixture_sha256']: raise ValueError('fixture-changed')
rows = []
with tempfile.TemporaryDirectory(prefix='forge-receive-push-proof-') as temporary:
directory = Path(temporary)
git(directory, ['init', '-q', '-b', 'main'])
git(directory, ['config', 'core.hooksPath', '/dev/null'])
git(directory, ['config', 'user.name', 'Custodian disposable detector probe'])
git(directory, ['config', 'user.email', 'probe@example.test'])
(directory / 'source.txt').write_text('Disposable detector probe; intended to be rejected by a temporary veto.\n')
git(directory, ['add', 'source.txt']); git(directory, ['commit', '-qm', proposal['proposal_id'] + ' clean probe'])
clean = git(directory, ['rev-parse', 'HEAD']).stdout.decode().strip()
(directory / 'fixture.txt').write_bytes(fixture.read_bytes())
git(directory, ['add', 'fixture.txt']); git(directory, ['commit', '-qm', proposal['proposal_id'] + ' synthetic detector fixture'])
blocked = git(directory, ['rev-parse', 'HEAD']).stdout.decode().strip()
for number, name in enumerate(proposal['repositories']):
remote = 'forgejo-remote:coulomb/' + name + '.git'
if git(directory, ['ls-remote', remote, proposal['probe_ref']]).stdout.strip():
raise ValueError('probe-ref-already-exists')
row = {'repo': name}
for oid, status in [(clean, 'clean'), (blocked, 'blocked')]:
r = git(directory, ['push', '--porcelain', remote, oid + ':' + proposal['probe_ref']], accepted=(0, 1))
output = r.stdout + r.stderr
if r.returncode == 0:
# Only our newly created ref may be removed, with a lease.
git(directory, ['push', '--force-with-lease=' + proposal['probe_ref'] + ':' + oid,
remote, ':' + proposal['probe_ref']])
raise ValueError('independent-veto-failed-ref-removed')
if fixture.read_bytes().strip() in output: raise ValueError('synthetic-content-disclosed')
found = []
for line in output.splitlines():
match = re.search(rb'\{"status":.*\}', line)
if match:
try: found.append(json.loads(match.group()))
except ValueError: raise ValueError('malformed-control-receipt')
if len(found) != 1 or found[0].get('status') != status or b'CUSTODIAN_G07_PROBE_VETO' not in output:
raise ValueError('native-push-proof-incomplete')
row[status + '_scanner_result'] = status
if git(directory, ['ls-remote', remote, proposal['probe_ref']]).stdout.strip():
raise ValueError('unexpected-probe-ref-retained')
raw = native.execute(proposal, '/usr/bin/git', '--git-dir=/data/git/gitea-repositories/coulomb/' + name + '.git',
'cat-file', '--batch-check', payload=(blocked + '\n').encode()).decode().strip()
if raw != blocked + ' missing': raise ValueError('quarantine-object-retained')
row.update(temporary_veto_observed=True, probe_ref_absent=True, quarantined_commit_absent=True)
rows.append(row)
if (number + 1) % 5 == 0: print('Verified SSH refusal probes:', number + 1, 'of', len(proposal['repositories']), flush=True)
return rows
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--proposal', type=Path, required=True); p.add_argument('--receipt', type=Path, required=True)
modes = p.add_mutually_exclusive_group()
modes.add_argument('--apply', action='store_true'); modes.add_argument('--rollback', action='store_true')
args = p.parse_args(); proposal = json.loads(args.proposal.read_text()); native = helper(proposal)
# Reuse only the read-only target/artifact preflight with its own pinned revision.
validate(proposal)
support = dict(proposal); support['executor_sha256'] = proposal['native_helper_sha256']
if args.rollback: support['artifacts'] = []
observation = native.preflight(support)
if args.rollback:
rollback(native, proposal); print('Prior hook inventory restored; admitted tools remain inert.'); return
inspect(native, proposal)
ref = shlex.quote(proposal['probe_ref'])
shell(native, proposal, repo_script(proposal, 'test -z "$(git --git-dir="$repo" for-each-ref --format=\'%(refname)\' ' + ref + ')"'))
if not args.apply:
print(json.dumps({'status': 'preflight-verified', 'repositories': len(proposal['repositories']), **observation})); return
temporary = '/tmp/' + proposal['directory_name']; version = proposal['version_directory']
receipt = {'proposal_id': proposal['proposal_id'], 'proposal_sha256': sha(args.proposal), 'result': 'unverified',
'observation': observation, 'phase': 'stage-tools', 'no_live_credentials_used': True,
'executed_revision': proposal['revision']}
created = changed = False
try:
native.execute(proposal, 'sh', '-c', 'umask 077; test ! -e "$1" && mkdir "$1" && printf "%s" "$2" > "$1/.probe-owner"',
'sh', temporary, proposal['proposal_id']); created = True
native.execute(proposal, 'tar', '-xmf', '-', '-C', temporary, payload=native.archive(proposal), timeout=90)
lines = ['cd ' + shlex.quote(temporary), 'sha256sum -c SHA256SUMS >/dev/null 2>&1']
for parent in ['/data/security', '/data/security/receive-secret-scan', version]:
lines.append('test ! -L ' + shlex.quote(parent))
lines.append('mkdir -p ' + shlex.quote(version))
for artifact in proposal['artifacts']:
if artifact['name'] not in ['receive-secret-scan', 'gitleaks']: continue
target = version + '/' + artifact['name']; quoted = shlex.quote(target)
lines.append('if test -e ' + quoted + '; then test ! -L ' + quoted + '; test "$(sha256sum ' + quoted + ' | cut -d" " -f1)" = ' + shlex.quote(artifact['sha256']) + '; else cp ' + shlex.quote(artifact['name']) + ' ' + quoted + '; fi')
lines.append('chmod 755 ' + quoted)
shell(native, proposal, '\n'.join(lines))
receipt['phase'] = 'install-hooks'; changed = True
body = ''
for field, source in [('veto_name', 'probe-veto'), ('hook_name', 'profile-hook')]:
dest = '"$repo/hooks/pre-receive.d/' + proposal[field] + '"'
body += 'test ! -e ' + dest + ' && test ! -L ' + dest + '\n'
body += 'staged=$(mktemp \"$repo/hooks/.custodian-install-XXXXXX\")\n'
body += 'cp ' + shlex.quote(temporary + '/' + source) + ' \"$staged\"\nchmod 755 \"$staged\"\nmv -n \"$staged\" ' + dest + '\n'
body += 'test ! -e \"$staged\"\n'
shell(native, proposal, repo_script(proposal, body))
inspect(native, proposal, applied=True, sentinel=True)
receipt['phase'] = 'verify-veto'
body = 'set +e\noutput=$(printf "%040d %040d %s\\n" 0 1 ' + ref + ' | "$repo/hooks/pre-receive.d/' + proposal['veto_name'] + '" 2>&1)\ncode=$?\nset -e\ntest "$code" = 1 && test "$output" = CUSTODIAN_G07_PROBE_VETO'
shell(native, proposal, repo_script(proposal, body))
receipt['phase'] = 'verify-ssh-pushes'
receipt['repositories'] = verify_pushes(native, proposal)
receipt['phase'] = 'remove-temporary-veto'
body = 'path="$repo/hooks/pre-receive.d/' + proposal['veto_name'] + '"\ntest "$(sha256sum "$path" | cut -d" " -f1)" = ' + shlex.quote(proposal['veto_sha256']) + '\nrm -- "$path"'
shell(native, proposal, repo_script(proposal, body))
inspect(native, proposal, applied=True)
native.execute(proposal, 'wget', '-qO', '/dev/null', 'http://127.0.0.1:3000/api/healthz')
receipt.update(result='verified', phase='active', temporary_veto_removed=True, health_after=True,
verification_limit='Synthetic pushes deliberately vetoed; scanner clean/detection results and quarantine absence verified. Normal clean acceptance needs the subsequent authorized source push; HTTP/frontend/lifecycle/outside-scope remain separate.')
except BaseException:
if changed:
try: rollback(native, proposal); receipt['rollback'] = 'prior hook inventory restored'
except BaseException: receipt['rollback'] = 'unverified; inspect exact target hashes'
raise
finally:
if created:
try:
native.execute(proposal, 'sh', '-c', 'test "$(cat "$1/.probe-owner")" = "$2" && rm -rf -- "$1" && test ! -e "$1"',
'sh', temporary, proposal['proposal_id'])
receipt['temporary_directory_removed'] = True
except BaseException:
receipt['temporary_directory_removed'] = False; receipt['result'] = 'unverified'
args.receipt.write_text(json.dumps(receipt, indent=2) + '\n')
if receipt['result'] != 'verified': raise ValueError('cleanup-incomplete')
print('Receive-path profile active for 22 repositories; SSH detector/veto/quarantine proofs verified.')
if __name__ == '__main__':
try: main()
except (ValueError, OSError, KeyError, subprocess.SubprocessError):
raise SystemExit('Activation refused or incomplete; inspect value-free receipt. Raw diagnostics suppressed.')