222 lines
10 KiB
Python
222 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Apply/verify or roll back the exact reviewed local hook proposal.
|
|
|
|
No production credential inputs, repository source/index writes, Git config
|
|
changes or remote calls. Synthetic probes use alternate indexes/object stores.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
def digest(path):
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
|
|
|
|
def safe_regular(path):
|
|
path = Path(path)
|
|
if path.is_symlink() or not path.is_file(): raise Refused('file-shape-refused')
|
|
if path.stat().st_uid != os.getuid(): raise Refused('file-owner-refused')
|
|
return path
|
|
|
|
|
|
def atomic(path, data, mode):
|
|
path = Path(path)
|
|
fd, temporary = tempfile.mkstemp(prefix='.hook-activation-', dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, 'wb') as output:
|
|
output.write(data); output.flush(); os.fsync(output.fileno())
|
|
os.chmod(temporary, mode)
|
|
os.replace(temporary, path)
|
|
finally:
|
|
if os.path.exists(temporary): os.unlink(temporary)
|
|
|
|
|
|
def run(args, cwd=None, env=None, payload=None):
|
|
result = subprocess.run(args, cwd=cwd, env=env, input=payload, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=60, check=False)
|
|
if result.returncode: raise Refused('preparation-command-failed')
|
|
return result.stdout
|
|
|
|
|
|
def clean_git_env():
|
|
env = dict(os.environ)
|
|
for key in ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
'GIT_ALTERNATE_OBJECT_DIRECTORIES'):
|
|
env.pop(key, None)
|
|
return env
|
|
|
|
|
|
def route(root):
|
|
env = clean_git_env()
|
|
actual = run(['git', 'rev-parse', '--show-toplevel'], root, env).decode().strip()
|
|
if actual != root: raise Refused('checkout-root-changed')
|
|
name = run(['git', 'rev-parse', '--git-path', 'hooks/pre-commit'], root, env).decode().strip()
|
|
path = Path(name)
|
|
return str(path if path.is_absolute() else Path(root) / path)
|
|
|
|
|
|
def validate(proposal, applied=False):
|
|
if digest(__file__) != proposal['executor_sha256']:
|
|
raise Refused('executor-revision-changed')
|
|
expected = {row['repo']: row['effective_pre_commit'] for row in proposal['original_routes']}
|
|
for root in proposal['scope_roots']:
|
|
if route(root) != expected[Path(root).name]: raise Refused('hook-route-changed')
|
|
for item in proposal['hooks']:
|
|
safe_regular(item['path'])
|
|
field = 'after_sha256' if applied else 'before_sha256'
|
|
if digest(item['path']) != item[field]: raise Refused('hook-revision-changed')
|
|
if digest(item['backup']) != item['before_sha256']: raise Refused('backup-changed')
|
|
for item in proposal['protected_files']:
|
|
if digest(item['path']) != item['sha256']: raise Refused('dependency-or-other-hook-changed')
|
|
safe_regular(proposal['hook_source'])
|
|
if digest(proposal['hook_source']) != proposal['hooks'][0]['after_sha256']:
|
|
raise Refused('candidate-hook-changed')
|
|
binary = proposal['binary']
|
|
if digest(safe_regular(binary['source'])) != binary['sha256']:
|
|
raise Refused('scanner-source-changed')
|
|
target = Path(binary['target'])
|
|
for ancestor in (target, *target.parents):
|
|
if ancestor.is_symlink(): raise Refused('scanner-path-redirected')
|
|
if target.exists() and digest(safe_regular(target)) != binary['sha256']:
|
|
raise Refused('scanner-target-conflict')
|
|
|
|
|
|
def probe(proposal):
|
|
results = []
|
|
scanner = Path(proposal['binary']['target'])
|
|
original_mode = stat.S_IMODE(scanner.stat().st_mode)
|
|
with tempfile.TemporaryDirectory(prefix='rmgr-hook-proof-') as temporary:
|
|
area = Path(temporary)
|
|
key = area / 'disposable-key'
|
|
run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(key)])
|
|
synthetic = key.read_bytes()
|
|
cases = []
|
|
for number, root in enumerate(proposal['scope_roots']):
|
|
local = area / str(number); local.mkdir(); objects = local / 'objects'; objects.mkdir()
|
|
env = clean_git_env()
|
|
original_objects = run(['git', 'rev-parse', '--git-path', 'objects'], root, env).decode().strip()
|
|
if not Path(original_objects).is_absolute(): original_objects = str(Path(root) / original_objects)
|
|
env.update(GIT_INDEX_FILE=str(local / 'index'), GIT_OBJECT_DIRECTORY=str(objects),
|
|
GIT_ALTERNATE_OBJECT_DIRECTORIES=original_objects)
|
|
hook = route(root)
|
|
def stage(data, name='__rmgr_security_synthetic_probe__.txt'):
|
|
run(['git', 'read-tree', 'HEAD'], root, env)
|
|
oid = run(['git', 'hash-object', '-w', '--stdin'], root, env, data).decode().strip()
|
|
run(['git', 'update-index', '--add', '--cacheinfo', '100644,' + oid + ',' + name], root, env)
|
|
def invoke():
|
|
result = subprocess.run([hook], cwd=root, env=env, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=60, check=False)
|
|
if synthetic.strip() in result.stdout + result.stderr:
|
|
raise Refused('synthetic-content-disclosed')
|
|
return result
|
|
stage(b'print("synthetic clean input")\n')
|
|
clean = invoke()
|
|
if clean.returncode != 0 or json.loads(clean.stdout).get('status') != 'clean':
|
|
raise Refused('clean-native-probe-failed')
|
|
stage(synthetic)
|
|
blocked = invoke()
|
|
if blocked.returncode != 1 or json.loads(blocked.stdout).get('status') != 'blocked':
|
|
raise Refused('secret-native-probe-failed')
|
|
stage(b'synthetic generated-only change\n', '.custodian-brief.md')
|
|
generated = invoke()
|
|
if generated.returncode != 1 or b'Generated-only commits rejected' not in generated.stderr:
|
|
raise Refused('generated-guard-native-probe-failed')
|
|
stage(b'print("synthetic clean input")\n')
|
|
cases.append((root, hook, env))
|
|
results.append({'repo': Path(root).name, 'clean_exit': 0, 'secret_exit': 1,
|
|
'generated_only_exit': 1, 'hook_sha256': digest(hook)})
|
|
# Brief, reviewed failure injection; restore even on SIGTERM/INT/HUP.
|
|
try:
|
|
scanner.chmod(0o644)
|
|
for row, (root, hook, env) in zip(results, cases):
|
|
result = subprocess.run([hook], cwd=root, env=env, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=60, check=False)
|
|
body = json.loads(result.stdout)
|
|
if result.returncode != 2 or body.get('status') != 'unverified':
|
|
raise Refused('scanner-failure-native-probe-failed')
|
|
row['scanner_failure_exit'] = 2
|
|
row['scanner_failure_reason'] = body.get('reason')
|
|
# An unreviewed checkout keeps its existing generated-only behavior.
|
|
outside = area / 'outside-profile'; outside.mkdir()
|
|
env = clean_git_env()
|
|
run(['git', 'init', '-q'], outside, env)
|
|
(outside / 'source.py').write_text('print("clean outside profile")\n')
|
|
run(['git', 'add', 'source.py'], outside, env)
|
|
path = run(['git', 'rev-parse', '--git-path', 'hooks/pre-commit'], outside, env).decode().strip()
|
|
result = subprocess.run([path], cwd=outside, env=env, capture_output=True, timeout=30, check=False)
|
|
if result.returncode != 0: raise Refused('unreviewed-checkout-behavior-changed')
|
|
finally:
|
|
scanner.chmod(original_mode)
|
|
return {'repositories': results, 'unreviewed_checkout_preserved': True,
|
|
'indexes_and_objects': 'temporary alternate stores only',
|
|
'scanner_mode_restored': stat.S_IMODE(scanner.stat().st_mode) == original_mode}
|
|
|
|
|
|
def restore(proposal):
|
|
for item in proposal['hooks']:
|
|
current = digest(safe_regular(item['path']))
|
|
if current not in (item['before_sha256'], item['after_sha256']):
|
|
raise Refused('rollback-target-changed')
|
|
if digest(item['backup']) != item['before_sha256']: raise Refused('backup-changed')
|
|
for item in proposal['hooks']:
|
|
atomic(item['path'], Path(item['backup']).read_bytes(), item['before_mode'])
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--proposal', type=Path, required=True)
|
|
parser.add_argument('--receipt', type=Path, required=True)
|
|
parser.add_argument('--apply', action='store_true')
|
|
parser.add_argument('--rollback', action='store_true')
|
|
args = parser.parse_args()
|
|
proposal = json.loads(args.proposal.read_text())
|
|
if args.rollback:
|
|
restore(proposal)
|
|
print('Restored exact prior hooks; Git routing unchanged.')
|
|
return
|
|
validate(proposal)
|
|
if not args.apply:
|
|
print('Proposal hashes, dependencies, backups and exact checkout routes verified.')
|
|
return
|
|
changed = False
|
|
receipt = {'proposal_id': proposal['proposal_id'], 'proposal_sha256': digest(args.proposal),
|
|
'executor_sha256': digest(__file__), 'result': 'unverified', 'cost_eur': 0}
|
|
try:
|
|
target = Path(proposal['binary']['target'])
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if not target.exists(): atomic(target, Path(proposal['binary']['source']).read_bytes(), 0o755)
|
|
for item in proposal['hooks']:
|
|
changed = True
|
|
atomic(item['path'], Path(proposal['hook_source']).read_bytes(), 0o755)
|
|
validate(proposal, applied=True)
|
|
receipt['verification'] = probe(proposal)
|
|
validate(proposal, applied=True)
|
|
receipt['result'] = 'verified'
|
|
except BaseException:
|
|
if changed:
|
|
restore(proposal)
|
|
receipt['rollback'] = 'prior hooks restored'
|
|
raise
|
|
finally:
|
|
args.receipt.write_text(json.dumps(receipt, indent=2) + '\n')
|
|
print(f"Verified local scanner enforcement for {len(proposal['scope_roots'])} scoped checkouts; no Git routing changed.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
def interrupted(*_): raise Refused('interrupted')
|
|
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): signal.signal(sig, interrupted)
|
|
try:
|
|
main()
|
|
except (Refused, OSError, ValueError, subprocess.SubprocessError):
|
|
raise SystemExit('Activation refused or failed; inspect the value-free receipt and exact rollback state.')
|