700 lines
34 KiB
Python
700 lines
34 KiB
Python
"""Kernel retirement and ongoing credential/view evidence for the access owner.
|
|
|
|
Only root-reviewed cgroup/inode inventories can be terminated. No kill-by-UID,
|
|
PID lookup followed by an unpinned signal, service discovery, or shell execution.
|
|
"""
|
|
import errno
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import stat
|
|
import time
|
|
import c01_network
|
|
|
|
from c01_access_policy import require, canonical, open_path, identity, protected, STATE, atomic
|
|
|
|
CGROUP = Path('/sys/fs/cgroup')
|
|
|
|
|
|
def now():
|
|
return time.clock_gettime_ns(time.CLOCK_BOOTTIME)
|
|
|
|
|
|
def process(pid):
|
|
root = Path('/proc') / str(pid)
|
|
raw = (root / 'stat').read_text()
|
|
start = int(raw[raw.rindex(')') + 2:].split()[19])
|
|
status = dict(row.split(':', 1) for row in (root / 'status').read_text().splitlines() if ':' in row)
|
|
group = (root / 'cgroup').read_text().splitlines()
|
|
require(len(group) == 1 and group[0].startswith('0::/'), 'unified process hierarchy required')
|
|
return {'pid': pid, 'ppid': int(status['PPid']), 'start': start, 'uids': list(map(int, status['Uid'].split())),
|
|
'gids': list(map(int, status['Gid'].split())), 'groups': list(map(int, status['Groups'].split())),
|
|
'caps': {key: int(status[key], 16) for key in ('CapEff', 'CapPrm', 'CapInh', 'CapAmb')},
|
|
'nnp': int(status['NoNewPrivs']), 'group': str(CGROUP / group[0][3:].lstrip('/')),
|
|
'namespaces': {kind: os.stat(root / 'ns' / kind).st_ino for kind in ('mnt', 'net', 'user')}}
|
|
|
|
|
|
def all_processes():
|
|
for path in Path('/proc').iterdir():
|
|
if path.name.isdecimal():
|
|
try:
|
|
yield process(int(path.name))
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
continue
|
|
|
|
|
|
def members(path):
|
|
result = set()
|
|
directories = [path]
|
|
while directories:
|
|
directory = directories.pop()
|
|
result.update(map(int, (directory / 'cgroup.procs').read_text().split()))
|
|
require(len(result) <= 8192, 'retirement cohort task bound')
|
|
directories.extend(p for p in directory.iterdir() if p.is_dir() and not p.is_symlink())
|
|
return result
|
|
|
|
|
|
def enclosing_cohort(spec, runtime):
|
|
"""Only the original UID's exact slice may enclose a delegated old manager.
|
|
|
|
Root owns the migration boundary; the reviewed PID/start inventory authorizes
|
|
the entire slice, including any old sessions. Never infer a kill inventory.
|
|
The coordinator must remove the emptied old slice before recreating it.
|
|
"""
|
|
base = {'path', 'device', 'inode', 'members'}
|
|
require(set(spec) in (base, base | {'replacement'}), 'cohort inventory schema')
|
|
if 'replacement' not in spec:
|
|
return False
|
|
group = CGROUP / runtime['user_cgroup'].lstrip('/')
|
|
require(spec['replacement'] == 'runtime-user-slice' and runtime['runtime_uid'] == 1000 and
|
|
group == CGROUP / 'user.slice/user-1000.slice/user@1000.service' and
|
|
canonical(spec['path']) == group.parent, 'unsupported enclosing cohort replacement')
|
|
return True
|
|
|
|
|
|
def migration_boundary(path):
|
|
protected(path, directory=True)
|
|
# A delegated descendant cannot migrate out without write access to the
|
|
# common ancestor's cgroup.procs. Pin every enclosing migration boundary.
|
|
for directory in (path, *path.parents):
|
|
if not directory.is_relative_to(CGROUP):
|
|
break
|
|
for name in ('cgroup.procs', 'cgroup.threads'):
|
|
protected(directory / name)
|
|
|
|
|
|
def replacement_tree(spec, runtime):
|
|
path = canonical(spec['path'])
|
|
if not path.exists():
|
|
return
|
|
meta = path.lstat()
|
|
require((meta.st_dev, meta.st_ino) != (spec['device'], spec['inode']),
|
|
'legacy cgroup retirement not completed by original owner')
|
|
group = CGROUP / runtime['user_cgroup'].lstrip('/')
|
|
if enclosing_cohort(spec, runtime):
|
|
migration_boundary(path)
|
|
require(not (path / 'cgroup.procs').read_text().strip(), 'replacement slice has direct tasks')
|
|
for child in path.iterdir():
|
|
require(not child.is_symlink(), 'replacement slice alias')
|
|
if child.is_dir():
|
|
require(child == group, 'unapproved replacement slice subtree')
|
|
else:
|
|
require(path == group, 'unapproved legacy restart hierarchy reappeared')
|
|
|
|
|
|
class Cohort:
|
|
def __init__(self, spec, runtime=None):
|
|
enclosing = enclosing_cohort(spec, runtime or {})
|
|
self.path = canonical(spec['path'])
|
|
require(self.path.is_relative_to(CGROUP) and len(self.path.parts) >= 5,
|
|
'cannot own a global hierarchy')
|
|
require(not Path(process(os.getpid())['group']).is_relative_to(self.path), 'cannot retire own service')
|
|
meta = protected(self.path, directory=True)
|
|
if enclosing:
|
|
migration_boundary(self.path)
|
|
require((meta.st_dev, meta.st_ino) == (spec['device'], spec['inode']), 'cohort identity replaced')
|
|
self.fd = open_path(self.path, True)
|
|
self.kill_fd = self.events_fd = self.freeze_fd = None
|
|
self.pidfds = {}
|
|
self.spec = spec
|
|
try:
|
|
require((os.fstat(self.fd).st_dev, os.fstat(self.fd).st_ino) == (spec['device'], spec['inode']),
|
|
'opened cohort identity changed')
|
|
self.kill_fd = os.open('cgroup.kill', os.O_WRONLY | os.O_CLOEXEC, dir_fd=self.fd)
|
|
self.events_fd = os.open('cgroup.events', os.O_RDONLY | os.O_CLOEXEC, dir_fd=self.fd)
|
|
self.freeze_fd = os.open('cgroup.freeze', os.O_WRONLY | os.O_CLOEXEC, dir_fd=self.fd)
|
|
for fd in (self.kill_fd, self.freeze_fd):
|
|
control = os.fstat(fd)
|
|
require(control.st_uid == 0 and not control.st_mode & 0o022,
|
|
'mutable cohort retirement control')
|
|
self.preflight()
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def write(self, fd, value):
|
|
require((self.path.stat().st_dev, self.path.stat().st_ino) ==
|
|
(self.spec['device'], self.spec['inode']), 'cohort locator changed')
|
|
os.lseek(fd, 0, 0)
|
|
require(os.write(fd, value) == len(value), 'cohort control write failed')
|
|
|
|
def events(self):
|
|
os.lseek(self.events_fd, 0, 0)
|
|
raw = os.read(self.events_fd, 4096)
|
|
require(len(raw) < 4096, 'cohort events bound')
|
|
return dict(row.split() for row in raw.decode().splitlines())
|
|
|
|
def freeze(self):
|
|
self.write(self.freeze_fd, b'1')
|
|
|
|
def preflight(self):
|
|
expected = {int(pid): start for pid, start in self.spec['members'].items()}
|
|
require(len(expected) == len(self.spec['members']) and members(self.path) == set(expected),
|
|
'cohort membership differs from guarded inventory')
|
|
for pid, start in expected.items():
|
|
item = process(pid)
|
|
require(item['start'] == start and Path(item['group']).is_relative_to(self.path),
|
|
'cohort preflight identity changed')
|
|
|
|
def capture(self):
|
|
require(self.events().get('frozen') == '1', 'cohort not frozen')
|
|
expected = {int(pid): start for pid, start in self.spec['members'].items()}
|
|
require(members(self.path) == set(expected), 'cohort membership differs from guarded inventory')
|
|
evidence = []
|
|
for pid, start in expected.items():
|
|
fd = os.pidfd_open(pid)
|
|
self.pidfds[pid] = fd
|
|
item = process(pid)
|
|
require(item['start'] == start and Path(item['group']).is_relative_to(self.path) and
|
|
not select.select([fd], [], [], 0)[0], 'old writer changed or exited during capture')
|
|
evidence.append(item)
|
|
return evidence
|
|
|
|
def kill(self):
|
|
# PID1 may complete the requested stop and remove the original group
|
|
# before our explicit kill. Never address a replacement at that name.
|
|
# An unlinked retained kernel directory PLUS every captured pidfd exit
|
|
# is retirement evidence; a missing pathname alone is not.
|
|
if self.retired():
|
|
return
|
|
try:
|
|
self.write(self.kill_fd, b'1')
|
|
except OSError:
|
|
if not self.retired():
|
|
raise
|
|
|
|
def retired(self):
|
|
# pidfd exit plus empty kernel hierarchy: no potentially signing task is
|
|
# left. These are not our direct children; systemd/their parent reaps them.
|
|
captured = len(self.pidfds) == len(self.spec['members'])
|
|
exited = captured and all(select.select([fd], [], [], 0)[0] for fd in self.pidfds.values())
|
|
if self.unlinked():
|
|
return exited
|
|
try:
|
|
return self.events().get('populated') == '0' and exited
|
|
except OSError:
|
|
if self.unlinked():
|
|
return exited
|
|
raise
|
|
|
|
def unlinked(self):
|
|
# kernfs may retain a cached nonzero link count, or return ENODEV from
|
|
# getattr/read after PID1 removes a cgroup. procfs's exact '(deleted)'
|
|
# suffix on OUR still-open directory is kernel dentry evidence, unlike
|
|
# absence of an unpinned pathname. Keep every pidfd until retirement too.
|
|
try:
|
|
if os.fstat(self.fd).st_nlink == 0:
|
|
return True
|
|
except OSError as error:
|
|
if error.errno not in (errno.ENOENT, errno.ENODEV):
|
|
raise
|
|
return os.readlink(f'/proc/self/fd/{self.fd}') == str(self.path) + ' (deleted)'
|
|
|
|
def close(self):
|
|
for fd in [self.kill_fd, self.events_fd, self.freeze_fd, *self.pidfds.values(), self.fd]:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
self.kill_fd = self.events_fd = self.freeze_fd = self.fd = None
|
|
self.pidfds = {}
|
|
|
|
|
|
def quarantine(policy, resume=False):
|
|
"""Move specifically inventoried copies; do not read, overwrite or erase keys.
|
|
|
|
All old credential-bearing cohorts must already have exited. A successful
|
|
rename is only disposition of this inode, never proof about unknown copies.
|
|
Cross-filesystem moves refuse; the coordinator must provision same-filesystem
|
|
private custody or resolve that case before authorizing a transition.
|
|
"""
|
|
destination = STATE / 'quarantine'
|
|
protected(destination, private=True, directory=True)
|
|
evidence = []
|
|
seen = set()
|
|
for index, item in enumerate(policy.value['copies']):
|
|
fields={'source', 'identity', 'disposition'}
|
|
if policy.value['version']==3 and 'custody' in item:fields.add('custody')
|
|
require(set(item) == fields and
|
|
item['disposition'] in ('quarantine-known-copy', 'retained-exclusive-backing'),
|
|
'unresolved/copied/external signing authority blocks admission')
|
|
source = canonical(item['source'])
|
|
require(source not in seen, 'duplicate signing-copy locator')
|
|
seen.add(source)
|
|
if resume and item['disposition']=='quarantine-known-copy':
|
|
import c01_migration_layout
|
|
target=c01_migration_layout.custody(policy,index,item)
|
|
if not os.path.lexists(source):
|
|
protected(target.parent,private=True,directory=True)
|
|
fd=open_path(target)
|
|
try:
|
|
meta=os.fstat(fd)
|
|
require(identity(meta)==item['identity'] and stat.S_ISREG(meta.st_mode) and meta.st_nlink==1,
|
|
'partial quarantine custody differs')
|
|
finally:os.close(fd)
|
|
evidence.append({'index':index,'operation':'quarantined-known-copy','identity':identity(meta)})
|
|
continue
|
|
fd = open_path(source)
|
|
parent = open_path(source.parent, True)
|
|
try:
|
|
meta = os.fstat(fd)
|
|
require(identity(meta) == item['identity'] and stat.S_ISREG(meta.st_mode) and meta.st_nlink == 1,
|
|
'known signing-copy metadata/alias differs')
|
|
if item['disposition'] == 'retained-exclusive-backing':
|
|
allowed = [Path(policy.launch[k]) for k in ('manager_backing', 'authority_backing')]
|
|
require(any(source.is_relative_to(root) for root in allowed), 'copy outside exclusive backing')
|
|
for root in allowed:
|
|
protected(root.parent, private=True, directory=True)
|
|
evidence.append({'index': index, 'operation': 'verified-exclusive-backing', 'identity': identity(meta)})
|
|
else:
|
|
require(os.stat(source.name, dir_fd=parent, follow_symlinks=False) == meta, 'copy locator changed')
|
|
if policy.value['version']==3:
|
|
import c01_migration_layout
|
|
target=c01_migration_layout.custody(policy,index,item)
|
|
else:target = destination / str(index)
|
|
protected(target.parent,private=True,directory=True)
|
|
require(target.parent.stat().st_dev==meta.st_dev,'quarantine requires same-filesystem private custody')
|
|
require(not target.exists() and not target.is_symlink(), 'copy custody already exists')
|
|
if policy.value['version']==3:
|
|
target_fd=c01_migration_layout.custody_directory(policy,index,item)
|
|
source_fd=c01_migration_layout.readable(source.parent)
|
|
try:
|
|
if c01_migration_layout.LIBC.renameat2(source_fd,os.fsencode(source.name),
|
|
target_fd,os.fsencode(target.name),1):
|
|
code=c01_migration_layout.ctypes.get_errno();raise OSError(code,os.strerror(code))
|
|
os.fsync(source_fd);os.fsync(target_fd)
|
|
finally:os.close(source_fd);os.close(target_fd)
|
|
else:os.rename(source.name, target, src_dir_fd=parent)
|
|
require(identity(target.lstat()) == item['identity'], 'copy custody differs')
|
|
# Root-private ancestor is the access control; chmod does NOT
|
|
# revoke old FDs or caches. Process retirement occurred first.
|
|
evidence.append({'index': index, 'operation': 'quarantined-known-copy', 'identity': identity(meta)})
|
|
finally:
|
|
os.close(parent)
|
|
os.close(fd)
|
|
return evidence
|
|
|
|
|
|
def verify_copies(policy):
|
|
for index, item in enumerate(policy.value['copies']):
|
|
if item['disposition'] == 'quarantine-known-copy':
|
|
require(not os.path.lexists(item['source']), 'legacy signing copy reappeared')
|
|
if policy.value['version']==3:
|
|
import c01_migration_layout
|
|
path=c01_migration_layout.custody(policy,index,item)
|
|
else:path = STATE / 'quarantine' / str(index)
|
|
protected(path.parent, private=True, directory=True)
|
|
else:
|
|
require(item['disposition'] == 'retained-exclusive-backing', 'unresolved signing copy')
|
|
path = Path(item['source'])
|
|
fd = open_path(path)
|
|
try:
|
|
require(identity(os.fstat(fd)) == item['identity'] and os.fstat(fd).st_nlink == 1,
|
|
'signing-copy custody changed')
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def verify_private(policy):
|
|
for key in ('manager_backing', 'snapshot_backing', 'authority_backing'):
|
|
root = Path(policy.launch[key])
|
|
protected(root.parent, private=True, directory=True)
|
|
fd = open_path(root, True)
|
|
os.close(fd)
|
|
verify_copies(policy)
|
|
if policy.value['version']==3:
|
|
import c01_migration_layout
|
|
c01_migration_layout.verify_live(policy)
|
|
|
|
|
|
class TaskReadRetired(ValueError):
|
|
"""Only a missing read through this exact task's proc-directory capability."""
|
|
def __init__(self, task, field):
|
|
super().__init__('pinned task exited during proc read: '+field)
|
|
self.task=task
|
|
self.field=field
|
|
|
|
|
|
class UnobservedTaskExit(ValueError):
|
|
"""A pidfd confirmed exit before a bound task observation existed."""
|
|
|
|
|
|
class TaskEvidenceChanged(ValueError):
|
|
def __init__(self, task, fields):
|
|
super().__init__('pinned task credentials/cgroup/namespace incarnation changed: pid='+str(task.pid)+' fields='+','.join(fields))
|
|
self.task=task
|
|
|
|
|
|
class PinnedTask:
|
|
def __init__(self, pid):
|
|
self.pid=pid
|
|
try:self.pidfd=os.pidfd_open(pid)
|
|
except ProcessLookupError as error:raise UnobservedTaskExit('task absent before pidfd observation') from error
|
|
self.procfd=None
|
|
self.dependencies=[];self.parents=[];self.task_validations=[]
|
|
try:
|
|
# While this pidfd is live, its numeric PID cannot be reused. Check
|
|
# both sides of opening procfs; never adopt a successor directory.
|
|
if self.exited():raise UnobservedTaskExit('task exited before proc binding')
|
|
procroot=os.open('/proc',os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
|
try:
|
|
self.bind_pidfd(procroot)
|
|
self.procfd=self.task_read('directory',lambda:os.open(str(pid),os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=procroot))
|
|
require(os.fstat(self.procfd).st_dev==os.fstat(procroot).st_dev,'foreign proc directory filesystem')
|
|
self.start=self.start_ticks()
|
|
self.bind_pidfd(procroot)
|
|
finally:os.close(procroot)
|
|
if self.exited():raise UnobservedTaskExit('task exited during proc binding')
|
|
except BaseException:self.close();raise
|
|
|
|
def exited(self):return bool(select.select([self.pidfd],[],[],0)[0])
|
|
|
|
def bind_pidfd(self, procroot):
|
|
# fdinfo renders the retained pidfd's target in THIS procfs PID namespace.
|
|
# This also rejects a proc mount from a different PID namespace; timing
|
|
# and matching start ticks alone are not a binding between the handles.
|
|
fd=os.open('self/fdinfo/'+str(self.pidfd),os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=procroot)
|
|
with os.fdopen(fd,'rb') as stream:raw=stream.read(4097)
|
|
require(len(raw)<=4096,'pidfd metadata bound')
|
|
fields=dict(line.split(':',1) for line in raw.decode().splitlines() if ':' in line)
|
|
actual=int(fields['Pid'])
|
|
if actual==-1 and self.exited():raise UnobservedTaskExit('task exited during pidfd binding')
|
|
require(actual==self.pid,'pidfd/proc PID namespace binding differs')
|
|
|
|
def task_read(self, field, operation):
|
|
# This catch encloses ONE identified proc operation, never policy,
|
|
# capability validation, filesystem-view inspection or a parent check.
|
|
try:return operation()
|
|
except (FileNotFoundError,ProcessLookupError):
|
|
# Linux can drop nsproxy before publishing whole-process pidfd exit.
|
|
# Wait at most one bounded kernel poll, issuing NO evidence meanwhile.
|
|
# This does not renew any caller/owner/watch deadline. A task still
|
|
# alive after the poll is a fatal unreadable view, never an admission.
|
|
if not self.exited():select.select([self.pidfd],[],[],0.02)
|
|
require(self.exited(),'live pinned task read missing: '+field)
|
|
raise TaskReadRetired(self,field) from None
|
|
|
|
def text(self, name):
|
|
require(name in ('stat','status','cgroup'),'fixed proc text field')
|
|
def read():
|
|
fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=self.procfd)
|
|
with os.fdopen(fd,'rb') as stream:raw=stream.read(1024*1024+1)
|
|
require(len(raw)<=1024*1024,'proc text bound')
|
|
return raw.decode()
|
|
return self.task_read(name,read)
|
|
|
|
def start_ticks(self):
|
|
raw=self.text('stat')
|
|
require(int(raw.split(' ',1)[0])==self.pid,'proc directory PID differs from pidfd')
|
|
return int(raw[raw.rindex(')')+2:].split()[19])
|
|
|
|
def namespace(self, kind):
|
|
require(kind in ('mnt','net','user'),'fixed task namespace')
|
|
fd=self.task_read('ns/'+kind,lambda:os.open('ns/'+kind,os.O_RDONLY|os.O_CLOEXEC,dir_fd=self.procfd))
|
|
try:
|
|
if kind=='net':return c01_network.identity(fd)
|
|
m=os.fstat(fd);return m.st_dev,m.st_ino
|
|
finally:os.close(fd)
|
|
|
|
def executable(self):
|
|
return Path(self.task_read('exe',lambda:os.readlink('exe',dir_fd=self.procfd)))
|
|
|
|
def expect_executable(self, path):
|
|
meta=protected(path) # independent named capability, never task_read
|
|
expected=(meta.st_dev,meta.st_ino)
|
|
def check():
|
|
actual=self.task_read('exe',lambda:os.stat('exe',dir_fd=self.procfd))
|
|
require((actual.st_dev,actual.st_ino)==expected and self.executable()==path,'mapping executable identity changed')
|
|
def independent():
|
|
current=protected(path)
|
|
require((current.st_dev,current.st_ino)==expected,'mapping executable capability changed')
|
|
self.dependencies.append(independent);self.task_validations.append(check)
|
|
check()
|
|
|
|
def root(self):
|
|
return self.task_read('root',lambda:os.open('root',os.O_RDONLY|os.O_DIRECTORY|os.O_CLOEXEC,dir_fd=self.procfd))
|
|
|
|
def record(self):
|
|
start=self.start_ticks()
|
|
status=dict(row.split(':',1) for row in self.text('status').splitlines() if ':' in row)
|
|
group=self.text('cgroup').splitlines()
|
|
require(len(group)==1 and group[0].startswith('0::/'),'unified process hierarchy required')
|
|
return {'pid':self.pid,'ppid':int(status['PPid']),'start':start,
|
|
'uids':list(map(int,status['Uid'].split())),'gids':list(map(int,status['Gid'].split())),
|
|
'groups':list(map(int,status['Groups'].split())),
|
|
'caps':{key:int(status[key],16) for key in ('CapEff','CapPrm','CapInh','CapAmb')},
|
|
'nnp':int(status['NoNewPrivs']),'group':str(CGROUP/group[0][3:].lstrip('/')),
|
|
'namespaces':{kind:self.namespace(kind)[1] for kind in ('mnt','net','user')}}
|
|
|
|
def finish(self, expected):
|
|
if self.exited():return False
|
|
current=self.record()
|
|
require(current['pid']==self.pid and current['start']==self.start,'pinned task PID incarnation changed')
|
|
if current!=expected:
|
|
raise TaskEvidenceChanged(self,sorted(key for key in current if current[key]!=expected[key]))
|
|
return not self.exited()
|
|
|
|
def independent(self):
|
|
for check in self.dependencies:check()
|
|
|
|
def close(self):
|
|
for parent in self.parents:parent.close()
|
|
self.parents=[]
|
|
if self.procfd is not None:os.close(self.procfd);self.procfd=None
|
|
if self.pidfd is not None:os.close(self.pidfd);self.pidfd=None
|
|
|
|
def __enter__(self):return self
|
|
def __exit__(self,*_):self.close()
|
|
|
|
|
|
def network_appointment(role):
|
|
expected=c01_network.appointed(role)
|
|
require(expected!=c01_network.process_namespace(1),'host network/abstract IPC namespace cannot be admitted')
|
|
return expected
|
|
|
|
|
|
def require_task_network(task, role):
|
|
expected=network_appointment(role) # independent errors must propagate
|
|
def revalidate():require(network_appointment(role)==expected,'appointed namespace changed')
|
|
task.dependencies.append(revalidate)
|
|
require(task.namespace('net')==expected,'process differs from appointed network namespace')
|
|
return expected
|
|
|
|
|
|
def inspect_task(task, item, inspect):
|
|
try:
|
|
require(item['pid']==task.pid and item['start']==task.start,'task PID incarnation changed')
|
|
result=inspect()
|
|
for check in task.task_validations:check()
|
|
if task.finish(item):return result
|
|
return {'pid':task.pid,'start':task.start,'retired_pidfd':True}
|
|
except TaskReadRetired as error:
|
|
# A missing read of another task (notably a mapping parent) is NOT
|
|
# evidence that the inspected child may be ignored.
|
|
require(error.task is task and task.exited(),'independent task lifetime lost')
|
|
return {'pid':task.pid,'start':task.start,'retired_pidfd':True}
|
|
finally:
|
|
task.independent() # also on retirement: no independent failure hidden
|
|
|
|
|
|
def view_evidence(task, policy):
|
|
network_appointment('runtime')
|
|
require(isinstance(task,PinnedTask),'retained proc/pidfd capability required')
|
|
item=task.record()
|
|
_,result=stable_task_evidence(task,item,lambda current:live_view_evidence(current,policy,task))
|
|
return result
|
|
|
|
|
|
def stable_task_evidence(task, item, inspect):
|
|
# Discard an incoherent sample and re-run ALL predicates once using a fresh
|
|
# record from the SAME retained task. No PID reopening, cached result, policy
|
|
# failure suppression, deadline reset or unbounded retry is permitted.
|
|
for attempt in range(2):
|
|
try:return item,inspect_task(task,item,lambda:inspect(item))
|
|
except TaskEvidenceChanged as error:
|
|
if error.task is not task or attempt:raise
|
|
try:item=task.record()
|
|
except TaskReadRetired as retired:
|
|
require(retired.task is task and task.exited(),'independent task lifetime lost')
|
|
task.independent()
|
|
return item,{'pid':task.pid,'start':task.start,'retired_pidfd':True}
|
|
raise AssertionError('bounded task sampling exhausted')
|
|
|
|
|
|
def view_parent(root, text):
|
|
"""Resolve only directories beneath the retained task root, without aliases."""
|
|
parts=Path(text).parts[1:];require(parts,'nonempty view locator')
|
|
fd=os.dup(root)
|
|
try:
|
|
for component in parts[:-1]:
|
|
require(component not in ('','.','..'),'canonical view component')
|
|
child=os.open(component,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=fd)
|
|
os.close(fd);fd=child
|
|
return fd,parts[-1]
|
|
except BaseException:os.close(fd);raise
|
|
|
|
|
|
def view_stat(root, text):
|
|
parent,name=view_parent(root,text)
|
|
try:return os.stat(name,dir_fd=parent,follow_symlinks=False)
|
|
finally:os.close(parent)
|
|
|
|
|
|
def view_flags(root, text):
|
|
parent,name=view_parent(root,text)
|
|
try:
|
|
fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=parent)
|
|
try:return os.fstatvfs(fd).f_flag
|
|
finally:os.close(fd)
|
|
finally:os.close(parent)
|
|
|
|
|
|
def live_view_evidence(item, policy, task):
|
|
"""Check actual runtime worker/user-manager view, not mount config text.
|
|
|
|
Nested rootless container namespaces are separately admitted only beneath the
|
|
qualified user-manager hierarchy; their host authority is bounded by its
|
|
original user namespace maps and protected namespace/IPC launch policy.
|
|
"""
|
|
r = policy.runtime
|
|
pid = item['pid']
|
|
require_task_network(task, 'runtime')
|
|
require(item['namespaces']['mnt'] != os.stat('/proc/self/ns/mnt').st_ino and
|
|
item['namespaces']['net'] != os.stat('/proc/self/ns/net').st_ino,
|
|
'runtime requires private mount AND routed private network namespace')
|
|
backing = os.stat(r['home_backing'])
|
|
def backing_check():
|
|
require(identity(os.stat(r['home_backing']))==identity(backing),'runtime home backing changed')
|
|
task.dependencies.append(backing_check)
|
|
root=task.root()
|
|
try:return inspect_runtime_root(root,item,policy,backing)
|
|
finally:os.close(root)
|
|
|
|
|
|
def inspect_runtime_root(root, item, policy, backing):
|
|
r=policy.runtime
|
|
home = view_stat(root,r['home'])
|
|
require((home.st_dev, home.st_ino) == (backing.st_dev, backing.st_ino), 'uncurated runtime home')
|
|
# Inaccessible objects must be missing or a root-owned inaccessible mount.
|
|
# Merely hiding environment variables or checking .ssh at startup is not enough.
|
|
denied = ['/run/dbus/system_bus_socket', '/run/systemd/private', '/run/sudo',
|
|
'/run/polkit-1', '/root', '/home/archipelago-admin',
|
|
'/usr/bin/sudo', '/usr/bin/pkexec', '/usr/bin/su', '/usr/bin/doas',
|
|
'/usr/bin/chsh', '/usr/bin/chfn', '/usr/bin/passwd', '/usr/bin/crontab', '/usr/bin/at']
|
|
denied += r['private_parents']
|
|
for text in denied:
|
|
try:
|
|
meta = view_stat(root,text)
|
|
except FileNotFoundError:
|
|
continue
|
|
require(meta.st_uid == 0 and stat.S_IMODE(meta.st_mode) == 0,
|
|
'runtime privileged IPC/operator path exposed: ' + text)
|
|
for name in ('.ssh','.gnupg','.pki'):
|
|
try:view_stat(root,r['home']+'/'+name)
|
|
except FileNotFoundError:continue
|
|
raise ValueError('operator credential directory exposed')
|
|
for text in ('/opt/archipelago', '/usr/local/bin'):
|
|
require(view_flags(root,text) & os.ST_RDONLY,
|
|
'runtime retains a writable protected package view: ' + text)
|
|
return {'pid': item['pid'], 'start': item['start'], 'namespaces': item['namespaces'], 'home': [home.st_dev, home.st_ino]}
|
|
|
|
|
|
def verify_supervisor(item, policy):
|
|
require(Path(item['group']) == Path(policy.value['supervisor_cgroup']) / 'supervisor' and
|
|
item['uids'] == [0] * 4 and item['gids'] == [0] * 4 and item['nnp'] == 1,
|
|
'supervisor must own the exact delegated lifetime with NNP')
|
|
# Mandatory owner checks never convert exit into benign task retirement.
|
|
network_appointment('managed')
|
|
with PinnedTask(item['pid']) as task:
|
|
require(task.start==item['start'] and task.record()==item,'supervisor incarnation/evidence changed')
|
|
result=require_task_network(task,'managed')
|
|
task.independent()
|
|
require(task.finish(item),'mandatory supervisor exited')
|
|
return result
|
|
|
|
|
|
def verify_processes(policy, supervisor=None):
|
|
r, v = policy.runtime, policy.value
|
|
runtime_group = CGROUP / r['user_cgroup'].lstrip('/')
|
|
observed = []
|
|
host_userns = os.stat('/proc/self/ns/user').st_ino
|
|
# Mandatory appointments are independent of whether any sampled task exits.
|
|
network_appointment('managed');network_appointment('runtime')
|
|
for task,item in pinned_processes({a['uid'] for a in v['accounts'].values()}):
|
|
item,evidence=stable_task_evidence(task,item,lambda current:verify_process_item(current,policy,supervisor,host_userns,runtime_group,task))
|
|
runtime_account=v['accounts'].get('runtime')
|
|
if (evidence is not None and runtime_account is not None and
|
|
item['uids']==[runtime_account['uid']]*4 and item['namespaces']['user']==host_userns):observed.append(evidence)
|
|
return observed
|
|
|
|
|
|
def pinned_processes(uids):
|
|
for path in Path('/proc').iterdir():
|
|
if not path.name.isdecimal():continue
|
|
try:task=PinnedTask(int(path.name))
|
|
except (TaskReadRetired,UnobservedTaskExit):continue # no observation published
|
|
try:
|
|
status=dict(row.split(':',1) for row in task.text('status').splitlines() if ':' in row)
|
|
if not uids.intersection(map(int,status['Uid'].split())):continue
|
|
item=task.record()
|
|
require(item['start']==task.start,'task changed during snapshot')
|
|
if not task.exited():yield task,item
|
|
except TaskReadRetired as error:
|
|
require(error.task is task and task.exited(),'unbound task read disappearance')
|
|
finally:task.close()
|
|
|
|
|
|
def verify_process_item(item, policy, supervisor, host_userns, runtime_group, task):
|
|
"""All per-task procfs reads run beneath the same pinned-exit guard.
|
|
|
|
This includes managed-role namespace inspection and mapping-helper ancestry,
|
|
not just the ordinary runtime view. A missing path while the captured task
|
|
is still alive continues to revoke the lease.
|
|
"""
|
|
v=policy.value
|
|
for role, account in v['accounts'].items():
|
|
if account['uid'] not in item['uids']:
|
|
continue
|
|
if role == 'runtime' and item['uids'] != [account['uid']] * 4:
|
|
# Rootless mappings require these two fixed setuid helpers. An
|
|
# arbitrary root executable or changed argv cannot substitute:
|
|
# inspect the actual executable inode and its unprivileged
|
|
# parent, namespace and owned runtime hierarchy.
|
|
executable = task.executable()
|
|
require(executable in (Path('/usr/bin/newuidmap'), Path('/usr/bin/newgidmap')),
|
|
'unapproved privileged runtime helper')
|
|
task.expect_executable(executable)
|
|
require(str(executable) in policy.value['trees'], 'mapping helper not in reviewed policy inventory')
|
|
try:
|
|
parent_task=PinnedTask(item['ppid']);task.parents.append(parent_task)
|
|
parent=parent_task.record()
|
|
except (ProcessLookupError,TaskReadRetired,UnobservedTaskExit) as error:
|
|
raise ValueError('mapping parent lifetime unavailable') from error
|
|
require(parent['uids'] == [account['uid']] * 4 and not any(parent['caps'].values()) and
|
|
item['uids'] == [account['uid'], 0, 0, 0] and
|
|
item['gids'] == [account['gid']] * 4 and set(item['groups']) <= {account['gid']} and
|
|
item['namespaces'] == parent['namespaces'] and
|
|
Path(item['group']).is_relative_to(runtime_group) and
|
|
Path(parent['group']).is_relative_to(runtime_group), 'mapping helper provenance refused')
|
|
def parent_check():
|
|
try:
|
|
parent_task.independent()
|
|
require(parent_task.finish(parent),'mapping parent lifetime lost')
|
|
except TaskReadRetired as error:raise ValueError('mapping parent lifetime lost') from error
|
|
task.dependencies.append(parent_check)
|
|
try:live_view_evidence(parent,policy,parent_task)
|
|
except TaskReadRetired as error:raise ValueError('mapping parent view lost') from error
|
|
continue
|
|
require(item['uids'] == [account['uid']] * 4 and item['gids'] == [account['gid']] * 4 and
|
|
set(item['groups']) <= {account['gid']}, 'managed process retained credentials')
|
|
group = Path(item['group'])
|
|
if role == 'runtime':
|
|
require(group.is_relative_to(runtime_group), 'runtime process outside exclusive user manager')
|
|
if item['namespaces']['user'] == host_userns:
|
|
require(not any(item['caps'].values()), 'runtime retained host capabilities')
|
|
return live_view_evidence(item, policy, task)
|
|
else:
|
|
require(supervisor is not None and group.is_relative_to(Path(v['supervisor_cgroup'])) and
|
|
not any(item['caps'].values()) and item['nnp'] == 1,
|
|
'managed role lacks live exclusive supervisor ownership')
|
|
require_task_network(task, 'managed')
|
|
return None
|