|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Run CI tests in a fresh Linux network/PID/mount namespace, never host fallback.""" |
| 3 | +import json |
| 4 | +import os |
| 5 | +from pathlib import Path |
| 6 | +import shutil |
| 7 | +import select |
| 8 | +import signal |
| 9 | +import time |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | +import tempfile |
| 13 | + |
| 14 | +# Preserve toolchain/instrumentation, not proxy credentials or host agent sockets. |
| 15 | +ENV_KEYS = ( |
| 16 | + 'PATH', 'CARGO_HOME', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'CARGO_TARGET_DIR', |
| 17 | + 'CARGO_TERM_COLOR', 'RUST_BACKTRACE', 'RUSTFLAGS', 'RUSTDOCFLAGS', |
| 18 | + 'CARGO_ENCODED_RUSTFLAGS', 'CARGO_ENCODED_RUSTDOCFLAGS', 'RUSTC', 'RUSTDOC', |
| 19 | + 'RUSTC_WRAPPER', 'RUSTC_WORKSPACE_WRAPPER', 'LLVM_PROFILE_FILE', |
| 20 | + 'CARGO_INCREMENTAL', 'CARGO_LLVM_COV', 'CARGO_LLVM_COV_TARGET_DIR', 'CARGO_LLVM_COV_BUILD_DIR', |
| 21 | + 'LLVM_COV', 'LLVM_PROFDATA', |
| 22 | +) |
| 23 | + |
| 24 | + |
| 25 | +def checked(*args): |
| 26 | + return subprocess.check_output(args, text=True).strip() |
| 27 | + |
| 28 | + |
| 29 | +def namespace_state(parent): |
| 30 | + current = os.readlink('/proc/self/ns/net') |
| 31 | + if current == parent: |
| 32 | + raise RuntimeError('network namespace did not change') |
| 33 | + links = json.loads(checked('/usr/sbin/ip', '-j', 'link')) |
| 34 | + if [link['ifname'] for link in links] != ['lo']: |
| 35 | + raise RuntimeError(f'foreign interfaces: {links}') |
| 36 | + routes = {family: json.loads(checked('/usr/sbin/ip', family, '-j', 'route', |
| 37 | + 'show', 'table', 'all')) |
| 38 | + for family in ('-4', '-6')} |
| 39 | + if any(row.get('dev') != 'lo' or row.get('dst') == 'default' or 'gateway' in row |
| 40 | + for rows in routes.values() for row in rows): |
| 41 | + raise RuntimeError(f'foreign route: {routes}') |
| 42 | + return dict(namespace=current, links=links, routes=routes) |
| 43 | + |
| 44 | + |
| 45 | +def admitted(config): |
| 46 | + state = namespace_state(config['parent_netns']) |
| 47 | + status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines() |
| 48 | + if ':' in line) |
| 49 | + if os.getuid() != config['uid'] or os.geteuid() == 0 or os.getgroups(): |
| 50 | + raise RuntimeError('runtime did not drop to the unprivileged owner') |
| 51 | + for key in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'): |
| 52 | + if int(status[key].strip(), 16): |
| 53 | + raise RuntimeError(f'{key} is not empty') |
| 54 | + if status['NoNewPrivs'].strip() != '1': |
| 55 | + raise RuntimeError('no_new_privs is not set') |
| 56 | + state.update(uid=os.getuid(), gid=os.getgid(), capabilities={ |
| 57 | + key: status[key].strip() for key in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb')}, |
| 58 | + no_new_privs=1) |
| 59 | + (Path(config['evidence']) / 'admission.json').write_text(json.dumps(state, indent=2) + '\n') |
| 60 | + result = subprocess.run(config['command'], env=config['env'], close_fds=True) |
| 61 | + (Path(config['evidence']) / 'exit.json').write_text(json.dumps({'exit': result.returncode}) + '\n') |
| 62 | + return result.returncode if result.returncode >= 0 else 128 - result.returncode |
| 63 | + |
| 64 | + |
| 65 | +def setup(config_file): |
| 66 | + config = json.loads(Path(config_file).read_text()) |
| 67 | + if os.getuid() != 0: |
| 68 | + raise RuntimeError('namespace setup requires root') |
| 69 | + namespace_state(config['parent_netns']) |
| 70 | + subprocess.run(['/usr/bin/mount', '--make-rprivate', '/'], check=True) |
| 71 | + subprocess.run(['/usr/bin/mount', '-t', 'tmpfs', '-o', 'mode=1777,nosuid,nodev', |
| 72 | + 'tmpfs', '/tmp'], check=True) |
| 73 | + for name in ('/tmp/x0x-nextest-home', '/tmp/x0x-runtime-home', '/tmp/x0x-runtime-tmp'): |
| 74 | + Path(name).mkdir(mode=0o700) |
| 75 | + os.chown(name, config['uid'], config['gid']) |
| 76 | + subprocess.run(['/usr/sbin/ip', 'link', 'set', 'lo', 'up'], check=True) |
| 77 | + namespace_state(config['parent_netns']) |
| 78 | + os.execv('/usr/bin/setpriv', [ |
| 79 | + 'setpriv', f"--reuid={config['uid']}", f"--regid={config['gid']}", '--clear-groups', |
| 80 | + '--bounding-set=-all', '--inh-caps=-all', '--ambient-caps=-all', '--no-new-privs', |
| 81 | + '/usr/bin/python3', str(Path(__file__).resolve()), '--admitted', config_file]) |
| 82 | + |
| 83 | + |
| 84 | +def supervise(config_file): |
| 85 | + """Root monitor: caller pipe EOF, deadline or signals cancel our child only.""" |
| 86 | + config = json.loads(Path(config_file).read_text()) |
| 87 | + if os.getuid() != 0: |
| 88 | + raise RuntimeError('supervisor requires root') |
| 89 | + cancelled = [] |
| 90 | + for signum in (signal.SIGTERM, signal.SIGINT): |
| 91 | + signal.signal(signum, lambda number, _frame: cancelled.append(number)) |
| 92 | + child = subprocess.Popen([ |
| 93 | + '/usr/bin/unshare', '--net', '--mount', '--pid', '--fork', '--kill-child', |
| 94 | + '--mount-proc', '/usr/bin/python3', str(Path(__file__).resolve()), |
| 95 | + '--setup', config_file], stdin=subprocess.DEVNULL, close_fds=True, start_new_session=True) |
| 96 | + started = time.monotonic() |
| 97 | + reason = None |
| 98 | + try: |
| 99 | + while child.poll() is None: |
| 100 | + if cancelled: |
| 101 | + reason = 'signal' |
| 102 | + break |
| 103 | + if time.monotonic() - started >= config['timeout_seconds']: |
| 104 | + reason = 'deadline' |
| 105 | + break |
| 106 | + if select.select([sys.stdin], [], [], 0.1)[0] and not os.read(0, 1): |
| 107 | + reason = 'caller-pipe-closed' |
| 108 | + break |
| 109 | + finally: |
| 110 | + # poll() may reap an already-exited leader. Never signal it afterward. |
| 111 | + # While unreaped, its PID/session identity cannot be reused. Namespace |
| 112 | + # init inherits this session; killing init removes all its descendants. |
| 113 | + if child.poll() is None: |
| 114 | + os.killpg(child.pid, signal.SIGTERM) |
| 115 | + try: |
| 116 | + child.wait(timeout=5) |
| 117 | + except subprocess.TimeoutExpired: |
| 118 | + os.killpg(child.pid, signal.SIGKILL) |
| 119 | + child.wait() |
| 120 | + else: |
| 121 | + child.wait() |
| 122 | + receipt = Path(config['evidence']) / 'supervisor.json' |
| 123 | + receipt.write_text(json.dumps(dict(reason=reason, child_pid=child.pid, |
| 124 | + child_exit=child.returncode, child_reaped=True, |
| 125 | + seconds=time.monotonic() - started)) + '\n') |
| 126 | + os.chown(receipt, config['uid'], config['gid']) |
| 127 | + if reason == 'deadline': |
| 128 | + return 124 |
| 129 | + if reason is not None: |
| 130 | + return 125 |
| 131 | + return child.returncode if child.returncode >= 0 else 128 - child.returncode |
| 132 | + |
| 133 | + |
| 134 | +def caller(config_file): |
| 135 | + """Own sudo's lifetime; pipe EOF also covers uncatchable caller SIGKILL.""" |
| 136 | + interrupted = [] |
| 137 | + for signum in (signal.SIGTERM, signal.SIGINT): |
| 138 | + signal.signal(signum, lambda number, _frame: interrupted.append(number)) |
| 139 | + child = subprocess.Popen([ |
| 140 | + '/usr/bin/sudo', '-n', '/usr/bin/python3', str(Path(__file__).resolve()), |
| 141 | + '--supervise', str(config_file)], stdin=subprocess.PIPE, close_fds=True) |
| 142 | + try: |
| 143 | + while child.poll() is None: |
| 144 | + if interrupted: |
| 145 | + break |
| 146 | + time.sleep(0.1) |
| 147 | + finally: |
| 148 | + child.stdin.close() |
| 149 | + # The privileged monitor owns escalation; do not signal root/reused PIDs. |
| 150 | + child.wait(timeout=15) |
| 151 | + return 128 + interrupted[0] if interrupted else child.returncode |
| 152 | + |
| 153 | + |
| 154 | +def main(): |
| 155 | + if len(sys.argv) == 3 and sys.argv[1] == '--supervise': |
| 156 | + return supervise(sys.argv[2]) |
| 157 | + if len(sys.argv) == 3 and sys.argv[1] == '--setup': |
| 158 | + setup(sys.argv[2]) |
| 159 | + if len(sys.argv) == 3 and sys.argv[1] == '--admitted': |
| 160 | + return admitted(json.loads(Path(sys.argv[2]).read_text())) |
| 161 | + if sys.platform != 'linux' or os.getuid() == 0: |
| 162 | + raise RuntimeError('requires a Linux unprivileged runner with sudo for namespace setup') |
| 163 | + command = sys.argv[1:] |
| 164 | + if not command: |
| 165 | + raise RuntimeError('a command is required') |
| 166 | + for tool in ('/usr/bin/sudo', '/usr/bin/unshare', '/usr/bin/setpriv', '/usr/sbin/ip', '/usr/bin/mount'): |
| 167 | + if not Path(tool).is_file(): |
| 168 | + raise RuntimeError(f'missing namespace prerequisite: {tool}') |
| 169 | + # Resolve once before changing HOME/PATH; no shell evaluation of test arguments. |
| 170 | + command[0] = shutil.which(command[0]) or command[0] |
| 171 | + env = {key: os.environ[key] for key in ENV_KEYS if key in os.environ} |
| 172 | + env.setdefault('CARGO_HOME', str(Path.home() / '.cargo')) |
| 173 | + env.setdefault('RUSTUP_HOME', str(Path.home() / '.rustup')) |
| 174 | + env.update(HOME='/tmp/x0x-runtime-home', X0X_HOME='/tmp/x0x-runtime-home', |
| 175 | + TMPDIR='/tmp/x0x-runtime-tmp', CARGO_NET_OFFLINE='true', RUST_MIN_STACK='16777216') |
| 176 | + evidence = Path(tempfile.mkdtemp(prefix='x0x-isolation-', dir=os.environ['RUNNER_TEMP'])).resolve() |
| 177 | + if evidence.is_relative_to('/tmp'): |
| 178 | + raise RuntimeError('RUNNER_TEMP must remain visible outside private /tmp') |
| 179 | + config = dict(command=command, env=env, uid=os.getuid(), gid=os.getgid(), |
| 180 | + parent_netns=os.readlink('/proc/self/ns/net'), evidence=str(evidence), |
| 181 | + timeout_seconds=int(os.environ.get('X0X_RUNTIME_TIMEOUT_SECONDS', '21600'))) |
| 182 | + if not 1 <= config['timeout_seconds'] <= 21600: |
| 183 | + raise RuntimeError('runtime deadline must be1..21600 seconds') |
| 184 | + config_file = evidence / 'runtime.json' |
| 185 | + config_file.write_text(json.dumps(config, indent=2) + '\n') |
| 186 | + print(f'Isolation evidence: {evidence}', flush=True) |
| 187 | + return caller(config_file) |
| 188 | + |
| 189 | + |
| 190 | +if __name__ == '__main__': |
| 191 | + sys.exit(main()) |
0 commit comments