164 lines
10 KiB
Python
164 lines
10 KiB
Python
"""Actual scripts in private PID/network namespaces and tmpfs; no remote writes."""
|
|
from pathlib import Path
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
|
|
def sha(raw): return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def run(argv, data=None, success=True):
|
|
result = subprocess.run(argv, input=data, capture_output=True, timeout=180)
|
|
if (result.returncode == 0) != success:
|
|
# Inputs are public or disposable synthetic material. Do not print raw diagnostics.
|
|
phases = []
|
|
for line in result.stderr.splitlines():
|
|
try:
|
|
value = json.loads(line)
|
|
if isinstance(value, dict) and value.get('result') == 'unverified': phases.append(value.get('probe_phase', 'unknown'))
|
|
except (ValueError, UnicodeError): pass
|
|
raise AssertionError('unexpected-script-status:' + str(result.returncode) + ';phase=' + ','.join(phases))
|
|
return result
|
|
|
|
|
|
def inside(case):
|
|
c = json.loads(Path('/fixtures/control.json').read_text())
|
|
old, new = Path(c['old_directory']), Path(c['new_directory'])
|
|
old.mkdir(parents=True)
|
|
for name, src in [('receive-secret-scan', '/old-adapter'), ('gitleaks', '/provider')]:
|
|
shutil.copyfile(src, old / name); (old / name).chmod(0o775)
|
|
new.mkdir()
|
|
for name, src in [('receive-secret-scan', '/new-adapter'), ('gitleaks', '/provider')]:
|
|
shutil.copyfile(src, new / name); (new / name).chmod(0o775)
|
|
def guard(command, success=True): return run(command, success=success)
|
|
def clean_new(): shutil.rmtree(new)
|
|
def stage(success=True, payload=None):
|
|
return run(['sh', '/scripts/stage.sh', str(new), str(old), c['candidate_sha256'], c['provider_sha256'], 'offline-fixture'],
|
|
data=Path('/new-adapter').read_bytes() if payload is None else payload, success=success)
|
|
if case == 'old-single-guard-counterexample':
|
|
guard(c['single_command'])
|
|
assert all((new / n).stat().st_mode & 0o777 == 0o755 for n in ['receive-secret-scan', 'gitleaks'])
|
|
assert all((old / n).stat().st_mode & 0o777 == 0o775 for n in ['receive-secret-scan', 'gitleaks'])
|
|
elif case == 'both-version-guard':
|
|
guard(c['combined_command'])
|
|
assert all((p / n).stat().st_mode & 0o777 == 0o755 for p in [old, new] for n in ['receive-secret-scan', 'gitleaks'])
|
|
elif case in ['guard-old-drift', 'guard-new-drift', 'guard-symlink', 'guard-unexpected-mode']:
|
|
if case == 'guard-old-drift': (old / 'gitleaks').write_bytes(b'changed')
|
|
if case == 'guard-new-drift': (new / 'receive-secret-scan').write_bytes(b'changed')
|
|
if case == 'guard-symlink':
|
|
(new / 'gitleaks').unlink(); (new / 'gitleaks').symlink_to(old / 'gitleaks')
|
|
if case == 'guard-unexpected-mode': (new / 'gitleaks').chmod(0o777)
|
|
guard(c['combined_command'], success=False)
|
|
elif case == 'stage-roundtrip':
|
|
clean_new(); stage(); stage()
|
|
assert sha((new / 'receive-secret-scan').read_bytes()) == c['candidate_sha256']
|
|
assert not list(new.parent.glob(new.name + '.staging-*'))
|
|
elif case == 'stage-bad-hash-cleanup':
|
|
clean_new(); stage(success=False, payload=b'incomplete-transfer')
|
|
assert not new.exists() and not list(new.parent.glob(new.name + '.staging-*'))
|
|
elif case == 'stage-existing-unowned':
|
|
(new / 'unrelated').write_bytes(b'preserve-me')
|
|
stage(success=False)
|
|
assert (new / 'unrelated').read_bytes() == b'preserve-me'
|
|
elif case == 'stage-symlink-refused':
|
|
clean_new(); new.symlink_to(old)
|
|
stage(success=False)
|
|
assert (old / 'receive-secret-scan').stat().st_mode & 0o777 == 0o775
|
|
elif case.startswith('hooks-'):
|
|
area = Path('/tmp/control'); area.mkdir()
|
|
before, after = b'#!/bin/sh\nexit 0\n', b'#!/bin/sh\nexit 2\n'
|
|
(area / 'before').write_bytes(before); (area / 'after').write_bytes(after)
|
|
paths = [Path('/data/git/gitea-repositories/test/' + name + '.git/hooks/pre-receive.d/10-custodian-secret-scan') for name in ['first', 'second']]
|
|
for path in paths:
|
|
path.parent.mkdir(parents=True); path.write_bytes(before); path.chmod(0o775)
|
|
(path.parent / 'protected').write_bytes(b'unchanged authorization')
|
|
(area / 'paths').write_text(''.join(str(p) + '\n' for p in paths))
|
|
def hooks(direction, success=True): return run(['sh', '/scripts/hooks.sh', str(area), direction, sha(before), sha(after)], success=success)
|
|
if case == 'hooks-roundtrip':
|
|
hooks('apply')
|
|
assert all(p.read_bytes() == after and p.stat().st_mode & 0o777 == 0o775 for p in paths)
|
|
hooks('rollback')
|
|
assert all(p.read_bytes() == before and p.stat().st_mode & 0o777 == 0o775 for p in paths)
|
|
elif case == 'hooks-refuse-complete-scope-before-writing':
|
|
paths[1].write_bytes(b'unexpected owner change'); hooks('apply', success=False)
|
|
assert paths[0].read_bytes() == before
|
|
assert paths[1].read_bytes() == b'unexpected owner change'
|
|
elif case == 'hooks-partial-failure-recovers':
|
|
paths[1].parent.chmod(0o555); hooks('apply', success=False)
|
|
assert all(p.read_bytes() == before for p in paths)
|
|
assert all(p.stat().st_mode & 0o777 == 0o775 for p in paths)
|
|
elif case == 'hooks-symlink-refused':
|
|
paths[1].unlink(); paths[1].symlink_to(paths[0]); hooks('apply', success=False)
|
|
assert paths[0].read_bytes() == before
|
|
elif case == 'hooks-rollback-unknown-drift-retained':
|
|
hooks('apply'); paths[1].write_bytes(b'unknown-drift'); hooks('rollback', success=False)
|
|
assert paths[0].read_bytes() == after and paths[1].read_bytes() == b'unknown-drift'
|
|
assert all((p.parent / 'protected').read_bytes() == b'unchanged authorization' for p in paths)
|
|
assert not list(Path('/data').rglob('.custodian-*'))
|
|
elif case == 'real-receive-and-quarantine':
|
|
guard(c['combined_command'])
|
|
result = json.loads(run(['sh', '/scripts/probe.sh', str(new), '/dispatcher']).stdout)
|
|
assert all(value is True for value in result.values())
|
|
assert not list(Path('/dev/shm').iterdir())
|
|
else: raise ValueError('unknown-case')
|
|
print(json.dumps({'case': case, 'passed': True, 'native_acceptance': False}))
|
|
|
|
|
|
def outside():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(); parser.add_argument('--attempt', required=True)
|
|
args = parser.parse_args()
|
|
if not args.attempt.isalnum(): raise ValueError('unsafe-attempt')
|
|
here = Path(__file__).resolve().parent
|
|
receipt = here / ('filesystem-' + args.attempt + '.json')
|
|
with receipt.open('x') as f: f.write('{"result":"unverified"}\n')
|
|
cfg = json.loads((here / 'inputs.json').read_bytes())
|
|
fixtures = Path('/tmp/sync-scanner-native-fixtures'); fixtures.mkdir(exist_ok=True)
|
|
guard = cfg['new_guard_command'].copy()
|
|
guard[2] = guard[2][len(cfg['guard_command'][2]) + 1:]
|
|
control = {'old_directory': cfg['old_dir'], 'new_directory': cfg['new_dir'],
|
|
'candidate_sha256': cfg['new_adapter_sha256'], 'provider_sha256': cfg['scanner_sha256'],
|
|
'combined_command': guard, 'single_command': guard}
|
|
(fixtures / 'control.json').write_text(json.dumps(control))
|
|
(fixtures / 'passwd').write_text('gitea:x:1000:1000:Offline fixture:/tmp:/bin/sh\n')
|
|
(fixtures / 'group').write_text('gitea:x:1000:\n')
|
|
binaries = {'old-adapter': Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'),
|
|
'new-adapter': Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'),
|
|
'provider': Path('/home/worsch/.local/lib/repo-manager/gitleaks/8.30.1/gitleaks'),
|
|
'dispatcher': here / 'fixture-dispatcher.sh'}
|
|
assert sha(binaries['new-adapter'].read_bytes()) == cfg['new_adapter_sha256']
|
|
assert sha(binaries['provider'].read_bytes()) == cfg['scanner_sha256']
|
|
prefix = ['bwrap', '--unshare-net', '--unshare-pid', '--die-with-parent', '--new-session']
|
|
for path in ['/usr', '/lib', '/lib64']: prefix += ['--ro-bind', path, path]
|
|
prefix += ['--symlink', 'usr/bin', '/bin', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/dev/shm', '--tmpfs', '/tmp', '--tmpfs', '/data',
|
|
'--ro-bind', str(here), '/scripts', '--ro-bind', str(fixtures), '/fixtures', '--dir', '/etc',
|
|
'--ro-bind', str(fixtures / 'passwd'), '/etc/passwd', '--ro-bind', str(fixtures / 'group'), '/etc/group']
|
|
for name, path in binaries.items(): prefix += ['--ro-bind', str(path), '/' + name]
|
|
prefix += ['--clearenv', '--setenv', 'PATH', '/usr/bin:/bin', '--setenv', 'HOME', '/tmp', '--chdir', '/', '--', 'python3', '/scripts/test_filesystem.py', '--inside']
|
|
cases = ['guard-new-drift', 'guard-symlink', 'guard-unexpected-mode',
|
|
'stage-roundtrip', 'stage-bad-hash-cleanup', 'stage-existing-unowned', 'stage-symlink-refused',
|
|
'hooks-roundtrip', 'hooks-refuse-complete-scope-before-writing', 'hooks-partial-failure-recovers',
|
|
'hooks-symlink-refused', 'hooks-rollback-unknown-drift-retained', 'real-receive-and-quarantine']
|
|
results = []
|
|
try:
|
|
for case in cases:
|
|
started = time.monotonic(); completed = subprocess.run(prefix + [case], capture_output=True, timeout=200)
|
|
failure = [line for line in completed.stderr.decode(errors='replace').splitlines() if line.startswith('AssertionError: unexpected-script-status:')]
|
|
results.append({'case': case, 'returncode': completed.returncode, 'seconds': round(time.monotonic()-started, 2), **({'failure': failure[-1]} if failure else {})})
|
|
receipt.write_text(json.dumps({'result': 'unverified', 'cases': results}, indent=2) + '\n')
|
|
if completed.returncode: raise ValueError('offline-case-failed:' + case)
|
|
receipt.write_text(json.dumps({'result': 'passed', 'cases': results, 'native_acceptance': False, 'real_remote_refs_changed': False,
|
|
'scope': 'Exact new guard, stage/hook scripts, real candidate/Gitleaks receive probes; original guard bytes unchanged.'}, indent=2) + '\n')
|
|
finally:
|
|
print(json.dumps({'receipt': str(receipt), 'passed': sum(x['returncode'] == 0 for x in results), 'total': len(cases)}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if len(sys.argv) == 3 and sys.argv[1] == '--inside': inside(sys.argv[2])
|
|
else: outside()
|