333 lines
14 KiB
Python
333 lines
14 KiB
Python
"""Disposable-only kernel regressions, called after access_owner_fixture guards.
|
|
|
|
Never invoke against installed accounts/cgroups. The checker barrier is injected
|
|
only in a forked fixture child; production has no barrier or admission bypass.
|
|
"""
|
|
import array
|
|
import ctypes
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
|
|
import c01_access as access
|
|
import c01_access_kernel as kernel
|
|
|
|
|
|
def endpoint_retirement_guards():
|
|
from unittest.mock import patch
|
|
assert os.getpid() == 1 and os.environ.get('C01_ACCESS_FIXTURE') == '1'
|
|
path = access.BASE / 'fixture-stale.sock'
|
|
unrelated = access.BASE / 'fixture-preserved'
|
|
unrelated.write_bytes(b'preserve exact unrelated object')
|
|
def refused(evidence):
|
|
try:
|
|
access.retire_endpoint(evidence)
|
|
except ValueError:
|
|
assert path.exists()
|
|
else:
|
|
raise AssertionError('unowned/live endpoint retired')
|
|
with patch.object(access, 'SOCKET', path):
|
|
path.write_bytes(b'not a socket')
|
|
path.chmod(0o600)
|
|
refused({'endpoint': access.policy_module.identity(path.lstat())})
|
|
path.unlink()
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
listener.bind(str(path))
|
|
path.chmod(0o600)
|
|
listener.listen(1)
|
|
identity = access.policy_module.identity(path.lstat())
|
|
refused({'endpoint': identity})
|
|
listener.close()
|
|
wrong = identity.copy()
|
|
wrong[1] += 1
|
|
refused({'endpoint': wrong})
|
|
access.retire_endpoint({'endpoint': identity})
|
|
assert not path.exists() and unrelated.read_bytes() == b'preserve exact unrelated object'
|
|
unrelated.unlink()
|
|
|
|
|
|
def ssh_route(policy, denied=False):
|
|
"""Real private sshd + fresh admin key login and no-shell managed attempts.
|
|
|
|
Loopback transport only; remote-context semantics are checked separately by
|
|
sshd -T and the global-only grammar. This is not a production reload test.
|
|
"""
|
|
assert os.getpid() == 1 and os.environ.get('C01_ACCESS_FIXTURE') == '1'
|
|
argv = ['/usr/bin/ssh', '-F', '/dev/null', '-i', '/run/fixture_client', '-p', '22222',
|
|
'-oBatchMode=yes', '-oIdentitiesOnly=yes', '-oStrictHostKeyChecking=no',
|
|
'-oUserKnownHostsFile=/dev/null', '-oConnectTimeout=2']
|
|
with Path('/run/fixture-sshd.log').open('wb') as log:
|
|
daemon = subprocess.Popen(['/usr/sbin/sshd', '-D', '-e', '-f', '/etc/ssh/sshd_config'],
|
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
|
try:
|
|
deadline = time.monotonic() + 3
|
|
while True:
|
|
assert daemon.poll() is None and time.monotonic() < deadline
|
|
with socket.socket() as probe:
|
|
if probe.connect_ex(('127.0.0.1', 22222)) == 0:
|
|
break
|
|
time.sleep(.025)
|
|
result = subprocess.run([*argv, 'archipelago-admin@127.0.0.1', '/usr/bin/true'],
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
stderr=log, timeout=5)
|
|
assert result.returncode == 0, 'fresh verified administrator key route failed'
|
|
if denied:
|
|
for name in policy.names:
|
|
result = subprocess.run([*argv, '-N', '-oExitOnForwardFailure=yes',
|
|
'-L127.0.0.1:22223:127.0.0.1:22222', name + '@127.0.0.1'],
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
stderr=log, timeout=3)
|
|
assert result.returncode == 255, 'managed public-key/no-shell SSH session admitted'
|
|
deadline = time.monotonic() + 3
|
|
descendants = Path(f'/proc/{daemon.pid}/task/{daemon.pid}/children')
|
|
while descendants.read_text().strip():
|
|
assert time.monotonic() < deadline, 'private sshd session retirement incomplete'
|
|
time.sleep(.025)
|
|
finally:
|
|
daemon.terminate()
|
|
try:
|
|
daemon.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
daemon.kill()
|
|
daemon.wait(timeout=3)
|
|
|
|
|
|
def generation_race(policy, group):
|
|
assert os.getpid() == 1 and os.environ.get('C01_ACCESS_FIXTURE') == '1'
|
|
control, checker_side = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
barrier, checker_barrier = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
checker_pid = os.fork()
|
|
if checker_pid == 0:
|
|
control.close()
|
|
barrier.close()
|
|
original = access.check_current
|
|
def held_check(value, supervisor):
|
|
first = held_check.first
|
|
held_check.first = False
|
|
if first:
|
|
checker_barrier.send(b'SCANNING')
|
|
assert checker_barrier.recv(16) == b'RELEASE'
|
|
return original(value, supervisor)
|
|
held_check.first = True
|
|
access.check_current = held_check
|
|
access.checker(checker_side, policy)
|
|
os._exit(112)
|
|
checker_side.close()
|
|
checker_barrier.close()
|
|
guard, observer = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
leases = access.Leases(policy, guard)
|
|
leases.last_observed = kernel.now()
|
|
candidate_control, child_control = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
path = Path('/run/access-generation.sock')
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
listener.bind(str(path))
|
|
listener.listen(1)
|
|
candidate = None
|
|
try:
|
|
access.send(control, {'challenge': 'old', 'supervisor': None, 'generation': 0})
|
|
barrier.settimeout(3)
|
|
assert barrier.recv(16) == b'SCANNING'
|
|
candidate = os.fork()
|
|
if candidate == 0:
|
|
try:
|
|
listener.close()
|
|
control.close()
|
|
barrier.close()
|
|
guard.close()
|
|
observer.close()
|
|
candidate_control.close()
|
|
(group / 'cgroup.procs').write_text(str(os.getpid()))
|
|
channel = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
channel.settimeout(5)
|
|
channel.connect(str(path))
|
|
request = {'protocol': 'supervisor-access-v1', 'configuration_sha256': policy.value['launch_sha256'],
|
|
'nonce': os.urandom(32).hex()}
|
|
access.send(channel, request)
|
|
assert json.loads(channel.recv(8192)) == request
|
|
roles = []
|
|
for role, slot in (('manager', 'application'), ('authority', 'authority')):
|
|
target = group / ('c01-' + slot)
|
|
target.mkdir()
|
|
read_fd, write_fd = os.pipe()
|
|
child = os.fork()
|
|
if child == 0:
|
|
channel.close() # Dropped roles never retain the capability.
|
|
child_control.close()
|
|
os.close(read_fd)
|
|
(target / 'cgroup.procs').write_text(str(os.getpid()))
|
|
assert ctypes.CDLL(None).prctl(38, 1, 0, 0, 0) == 0
|
|
account = policy.value['accounts'][role]
|
|
os.setgroups([])
|
|
os.setresgid(*([account['gid']] * 3))
|
|
os.setresuid(*([account['uid']] * 3))
|
|
os.write(write_fd, b'R')
|
|
while True:
|
|
signal.pause()
|
|
os.close(write_fd)
|
|
assert select.select([read_fd], [], [], 2)[0] and os.read(read_fd, 1) == b'R'
|
|
os.close(read_fd)
|
|
roles.append((child, target))
|
|
child_control.send(b'ROLES')
|
|
assert child_control.recv(16) == b'RELEASE'
|
|
for child, target in roles:
|
|
os.kill(child, signal.SIGKILL)
|
|
os.waitpid(child, 0)
|
|
target.rmdir()
|
|
channel.close()
|
|
os._exit(0)
|
|
except BaseException:
|
|
import traceback
|
|
traceback.print_exc()
|
|
os._exit(111)
|
|
child_control.close()
|
|
channel, _ = listener.accept()
|
|
assert select.select([channel], [], [], 2)[0]
|
|
leases.accept(channel, access.receive(channel))
|
|
assert leases.supervisor['pending'] and leases.generation == 1
|
|
assert not select.select([candidate_control], [], [], .1)[0], 'grant escaped before checker generation'
|
|
barrier.send(b'RELEASE')
|
|
assert select.select([control], [], [], 3)[0]
|
|
old = access.receive(control)
|
|
assert not leases.checked(old, None)
|
|
assert not select.select([candidate_control], [], [], .1)[0], 'obsolete scan published candidate'
|
|
reserved = leases.supervisor
|
|
access.send(control, {'challenge': 'candidate', 'supervisor': reserved, 'generation': leases.generation})
|
|
assert select.select([control], [], [], 3)[0]
|
|
assert leases.checked(access.receive(control), reserved)
|
|
assert not select.select([candidate_control], [], [], .1)[0], 'grant escaped before watchdog custody ack'
|
|
raw, ancillary, flags, _ = observer.recvmsg(access.WATCH_PACKET.size, socket.CMSG_SPACE(4))
|
|
kind, generation, expiry, token = access.watch_evidence(raw, 0, 0, kernel.now())
|
|
assert kind == b'HOLD' and generation == 1 and not flags
|
|
for level, kind, data in ancillary:
|
|
assert (level, kind) == (socket.SOL_SOCKET, socket.SCM_RIGHTS)
|
|
rights = array.array('i')
|
|
rights.frombytes(data)
|
|
for fd in rights:
|
|
os.close(fd)
|
|
leases.confirm(token)
|
|
candidate_control.settimeout(3)
|
|
assert candidate_control.recv(16) == b'ROLES'
|
|
kernel.verify_processes(policy, leases.supervisor)
|
|
try:
|
|
kernel.verify_processes(policy, None)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('orphan role children accepted without supervisor')
|
|
active = leases.supervisor
|
|
access.send(control, {'challenge': 'active', 'supervisor': active, 'generation': leases.generation})
|
|
assert select.select([control], [], [], 3)[0]
|
|
assert leases.checked(access.receive(control), active)
|
|
assert not select.select([channel], [], [], 0)[0]
|
|
candidate_control.send(b'RELEASE')
|
|
assert os.waitstatus_to_exitcode(os.waitpid(candidate, 0)[1]) == 0
|
|
candidate = None
|
|
leases.poll()
|
|
retiring = leases.supervisor
|
|
access.send(control, {'challenge': 'retired', 'supervisor': retiring, 'generation': leases.generation})
|
|
assert select.select([control], [], [], 3)[0]
|
|
assert leases.checked(access.receive(control), retiring)
|
|
assert leases.supervisor is None and leases.retiring is None
|
|
finally:
|
|
leases.stop()
|
|
if candidate is not None:
|
|
(group / 'cgroup.kill').write_text('1')
|
|
os.waitpid(candidate, 0)
|
|
os.kill(checker_pid, signal.SIGKILL)
|
|
os.waitpid(checker_pid, 0)
|
|
for channel in (control, barrier, guard, observer, candidate_control, listener):
|
|
channel.close()
|
|
path.unlink()
|
|
|
|
|
|
def enclosing_retirement(root):
|
|
"""Real delegated descendant, root-owned enclosing freeze/kill capabilities.
|
|
|
|
Remap only the cgroup mount prefix in this test to the fixture's own subtree.
|
|
This does not qualify systemd's real user@.service deletion/recreation jobs.
|
|
"""
|
|
from unittest.mock import patch
|
|
assert os.getpid() == 1 and os.environ.get('C01_ACCESS_FIXTURE') == '1'
|
|
mount = root / 'enclosing'
|
|
mount.mkdir()
|
|
parent = mount / 'user.slice'
|
|
parent.mkdir()
|
|
enclosing = parent / 'user-1000.slice'
|
|
enclosing.mkdir()
|
|
delegated = enclosing / 'user@1000.service'
|
|
delegated.mkdir()
|
|
os.chown(delegated, 1000, 1000)
|
|
for name in ('cgroup.procs', 'cgroup.threads', 'cgroup.subtree_control'):
|
|
os.chown(delegated / name, 1000, 1000)
|
|
reader, writer = os.pipe()
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
os.close(reader)
|
|
(delegated / 'cgroup.procs').write_text(str(os.getpid()))
|
|
os.setgroups([])
|
|
os.setresgid(1000, 1000, 1000)
|
|
os.setresuid(1000, 1000, 1000)
|
|
os.write(writer, b'R')
|
|
while True:
|
|
signal.pause()
|
|
os.close(writer)
|
|
assert select.select([reader], [], [], 2)[0] and os.read(reader, 1) == b'R'
|
|
os.close(reader)
|
|
original_process, original_mount = kernel.process, kernel.CGROUP
|
|
start = original_process(pid)['start']
|
|
meta = enclosing.stat()
|
|
spec = {'path': str(enclosing), 'device': meta.st_dev, 'inode': meta.st_ino,
|
|
'members': {str(pid): start}, 'replacement': 'runtime-user-slice'}
|
|
runtime = {'runtime_uid': 1000, 'user_cgroup': '/user.slice/user-1000.slice/user@1000.service'}
|
|
def real_process(number):
|
|
item = original_process(number)
|
|
item['group'] = str(original_mount / Path(item['group']).relative_to(mount))
|
|
return item
|
|
with patch.object(kernel, 'CGROUP', mount), patch.object(kernel, 'process', real_process):
|
|
cohort = kernel.Cohort(spec, runtime)
|
|
try:
|
|
cohort.freeze()
|
|
expiry = time.monotonic() + 2
|
|
while cohort.events().get('frozen') != '1':
|
|
assert time.monotonic() < expiry
|
|
time.sleep(.01)
|
|
cohort.capture()
|
|
cohort.kill()
|
|
expiry = time.monotonic() + 2
|
|
while not cohort.retired():
|
|
assert time.monotonic() < expiry
|
|
time.sleep(.01)
|
|
os.waitpid(pid, 0)
|
|
finally:
|
|
cohort.close()
|
|
try:
|
|
kernel.replacement_tree(spec, runtime)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('same original slice inode accepted')
|
|
delegated.rmdir()
|
|
enclosing.rmdir()
|
|
enclosing.mkdir()
|
|
delegated.mkdir()
|
|
os.chown(delegated, 1000, 1000)
|
|
kernel.replacement_tree(spec, runtime)
|
|
unexpected = enclosing / 'session-1.scope'
|
|
unexpected.mkdir()
|
|
try:
|
|
kernel.replacement_tree(spec, runtime)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('unreviewed replacement session accepted')
|
|
unexpected.rmdir()
|
|
delegated.rmdir()
|
|
enclosing.rmdir()
|
|
parent.rmdir()
|
|
mount.rmdir()
|