1310 lines
55 KiB
Python
1310 lines
55 KiB
Python
"""Tests des fonctions de production ; aucune création native n'est permise."""
|
|
import contextlib
|
|
import copy
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import resource
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import run
|
|
|
|
INERT_POPEN = subprocess.Popen
|
|
gate = run.gate
|
|
UID = os.getuid()
|
|
PID = os.getpid()
|
|
BASE = f"/user.slice/user-{UID}.slice/user@{UID}.service"
|
|
APP = BASE + "/app.slice"
|
|
SPEC = gate.policy("a" * 32)
|
|
gate = run.gate.module # Patch the production module globals, not the lazy proxy.
|
|
GROUP = APP + "/" + SPEC["unit"]
|
|
|
|
|
|
def native_spawn_forbidden(*args, **kwargs):
|
|
raise AssertionError("native_spawn_forbidden")
|
|
|
|
|
|
_native_sentinel = None
|
|
|
|
|
|
def setUpModule():
|
|
global _native_sentinel
|
|
_native_sentinel = patch.object(
|
|
subprocess, "Popen", side_effect=native_spawn_forbidden)
|
|
_native_sentinel.start()
|
|
|
|
|
|
def tearDownModule():
|
|
_native_sentinel.stop()
|
|
|
|
|
|
class FakeProc:
|
|
def __init__(self):
|
|
self.identities = {
|
|
PID: {"pid": PID, "uid": UID, "startticks": 111,
|
|
"cgroup": APP + "/launcher.scope"},
|
|
42: {"pid": 42, "uid": UID, "startticks": 222,
|
|
"cgroup": BASE + "/init.scope"},
|
|
43: {"pid": 43, "uid": UID, "startticks": 333, "cgroup": GROUP},
|
|
}
|
|
self.limits = (4096, 4096)
|
|
self.load = 1126
|
|
self.writers = []
|
|
|
|
def identity(self, pid):
|
|
return dict(self.identities[pid])
|
|
|
|
def uid_threads(self, uid):
|
|
raise AssertionError("diagnostic PID interdit dans admission")
|
|
|
|
def global_tasks(self):
|
|
return self.load
|
|
|
|
def nproc(self):
|
|
return self.limits
|
|
|
|
def pidfd(self, pid):
|
|
reader, writer = os.pipe()
|
|
self.writers.append(writer)
|
|
return reader
|
|
|
|
def close(self):
|
|
for writer in self.writers:
|
|
os.close(writer)
|
|
|
|
|
|
class FakeCgroups:
|
|
def __init__(self):
|
|
self.mount_id = (1, "0:30")
|
|
self.controllers = (["pids"], ["pids"], True)
|
|
self.present = False
|
|
self.is_empty = True
|
|
self.record = {"inode": 5, "device": 30, "uid": UID,
|
|
"pids_max": "256", "pids_current": 1, "procs": [43],
|
|
"children": []}
|
|
self.ancestor = {**self.record, "pids_max": "4096", "pids_current": 3}
|
|
|
|
def mount(self):
|
|
return self.mount_id
|
|
|
|
def snapshot(self, group):
|
|
return dict(self.record if group == GROUP else self.ancestor)
|
|
|
|
def available(self, group):
|
|
return self.controllers
|
|
|
|
def exists(self, group):
|
|
return self.present
|
|
|
|
def empty(self, group):
|
|
return self.is_empty
|
|
|
|
|
|
class FakeProcess:
|
|
def __init__(self):
|
|
self.returncode = None
|
|
self.stdout = io.BytesIO()
|
|
self.stderr = io.BytesIO()
|
|
self.killed = False
|
|
self.wait_error = None
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
self.returncode = -9
|
|
|
|
def wait(self, timeout):
|
|
if self.wait_error:
|
|
raise self.wait_error
|
|
self.returncode = 0 if self.returncode is None else self.returncode
|
|
return self.returncode
|
|
|
|
|
|
class FakeManager:
|
|
def __init__(self, cgroups):
|
|
self.cgroups = cgroups
|
|
self.created = 0
|
|
self.stopped = []
|
|
self.owner = {"Id": f"user@{UID}.service", "Delegate": "yes",
|
|
"ActiveState": "active",
|
|
"ControlGroup": BASE, "InvocationID": "b" * 32, "MainPID": "42"}
|
|
self.app = {"Id": "app.slice", "ActiveState": "active", "ControlGroup": APP}
|
|
self.scope = {"Id": SPEC["unit"], "Description": SPEC["description"],
|
|
"InvocationID": "c" * 32, "ControlGroup": GROUP, **gate.POLICY}
|
|
self.process = FakeProcess()
|
|
self.create_error = None
|
|
self.stop_error = None
|
|
|
|
def show(self, unit, deadline, system=False):
|
|
if unit == f"user@{UID}.service":
|
|
return dict(self.owner)
|
|
if unit == "app.slice":
|
|
return dict(self.app)
|
|
return dict(self.scope) if self.cgroups.present else {"LoadState": "not-found"}
|
|
|
|
def create(self, spec, argv):
|
|
self.created += 1
|
|
if self.create_error:
|
|
raise self.create_error
|
|
self.cgroups.present = True
|
|
return self.process
|
|
|
|
def stop(self, unit, deadline):
|
|
if self.stop_error:
|
|
raise self.stop_error
|
|
self.stopped.append(unit)
|
|
self.cgroups.present = False
|
|
|
|
|
|
class FakeBarrier:
|
|
def __init__(self, *args):
|
|
self.released = 0
|
|
self.closed = False
|
|
self.error = None
|
|
|
|
def accept(self):
|
|
if self.error:
|
|
raise self.error
|
|
return 43
|
|
|
|
def release(self):
|
|
self.released += 1
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
class UIDThreadTests(unittest.TestCase):
|
|
"""Faux procfs sur disque ; exerce le vrai compteur, sans enfant natif."""
|
|
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name)
|
|
self.proc = gate.ProcFS(self.root)
|
|
self.add_process(101)
|
|
|
|
def add_process(self, pid, uids=(1000, 1000, 1000, 1000), threads=2):
|
|
directory = self.root / str(pid)
|
|
directory.mkdir()
|
|
fields = ["S"] + ["0"] * 18 + ["123"]
|
|
(directory / "stat").write_text(f"{pid} (fixture) " + " ".join(fields))
|
|
(directory / "cgroup").write_text("0::/fixture.scope\n")
|
|
(directory / "status").write_text(
|
|
"Uid:\t" + " ".join(map(str, uids)) + f"\nThreads:\t{threads}\n")
|
|
(directory / "task").mkdir()
|
|
for tid in range(pid, pid + threads):
|
|
(directory / "task" / str(tid)).mkdir()
|
|
return directory
|
|
|
|
def change_before_second_sample(self, action):
|
|
"""Injecte une mutation entre les deux lectures réelles du compteur."""
|
|
original = Path.read_text
|
|
reads = 0
|
|
|
|
def read(path, *args, **kwargs):
|
|
nonlocal reads
|
|
if path == self.root / "101" / "status":
|
|
reads += 1
|
|
if reads == 2:
|
|
action()
|
|
return original(path, *args, **kwargs)
|
|
|
|
return patch.object(Path, "read_text", read)
|
|
|
|
def test_homogeneous_uid_count(self):
|
|
self.assertEqual(self.proc.uid_threads(1000), 2)
|
|
|
|
def test_mixed_uid_count_does_not_call_identity(self):
|
|
status = self.root / "101" / "status"
|
|
status.write_text("Uid: 1000 0 0 0\nThreads: 2\n")
|
|
original = Path.stat
|
|
|
|
def root_owned(path, *args, **kwargs):
|
|
info = original(path, *args, **kwargs)
|
|
if path == self.root / "101":
|
|
values = list(info)
|
|
values[4] = 0 # A setuid helper's proc directory may be root-owned.
|
|
return os.stat_result(values)
|
|
return info
|
|
|
|
with patch.object(Path, "stat", root_owned):
|
|
with patch.object(self.proc, "identity", side_effect=AssertionError):
|
|
self.assertEqual(self.proc.uid_threads(1000), 2)
|
|
|
|
def test_multiple_processes_and_foreign_real_uid(self):
|
|
self.add_process(201, (1000, 0, 0, 0), 3)
|
|
self.add_process(301, (2000, 1000, 1000, 1000), 4)
|
|
self.assertEqual(self.proc.uid_threads(1000), 5)
|
|
|
|
def test_identity_still_refuses_mixed_credentials_for_owned_roles(self):
|
|
(self.root / "101" / "status").write_text(
|
|
"Uid: 1000 0 0 0\nThreads: 2\n")
|
|
for role in ("supervisor", "bootstrap", "manager"):
|
|
with self.subTest(role=role):
|
|
with self.assertRaisesRegex(gate.Refusal, "UID process divergent"):
|
|
self.proc.identity(101)
|
|
|
|
def test_uid_startticks_cgroup_and_threads_drifts_refuse(self):
|
|
directory = self.root / "101"
|
|
changes = {
|
|
"uid": ("status", "Uid: 1000 0 0 0\nThreads: 2\n"),
|
|
"real_uid": ("status", "Uid: 2000 0 0 0\nThreads: 2\n"),
|
|
"threads": ("status", "Uid: 1000 1000 1000 1000\nThreads: 1\n"),
|
|
"startticks": ("stat", "101 (fixture) " +
|
|
" ".join(["S"] + ["0"] * 18 + ["456"])),
|
|
"pid": ("stat", "999 (fixture) " +
|
|
" ".join(["S"] + ["0"] * 18 + ["123"])),
|
|
"cgroup": ("cgroup", "0::/other.scope\n"),
|
|
}
|
|
for name, (filename, text) in changes.items():
|
|
target = directory / filename
|
|
original = target.read_text()
|
|
with self.subTest(field=name):
|
|
with self.change_before_second_sample(lambda: target.write_text(text)):
|
|
with self.assertRaises(gate.Refusal):
|
|
self.proc.uid_threads(1000)
|
|
target.write_text(original)
|
|
|
|
def test_inode_and_device_drifts_refuse(self):
|
|
original = Path.stat
|
|
for field in ("st_ino", "st_dev"):
|
|
calls = 0
|
|
|
|
def stat(path, *args, **kwargs):
|
|
nonlocal calls
|
|
info = original(path, *args, **kwargs)
|
|
if path == self.root / "101":
|
|
calls += 1
|
|
if calls >= 3:
|
|
values = list(info)
|
|
values[1 if field == "st_ino" else 2] += 1
|
|
return os.stat_result(values)
|
|
return info
|
|
|
|
with self.subTest(field=field), patch.object(Path, "stat", stat):
|
|
with self.assertRaises(gate.Refusal):
|
|
self.proc.uid_threads(1000)
|
|
|
|
def test_disappearance_refuses(self):
|
|
target = self.root / "101" / "status"
|
|
with self.change_before_second_sample(target.unlink):
|
|
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
|
|
self.proc.uid_threads(1000)
|
|
|
|
def test_unreadable_task_directory_refuses(self):
|
|
original = Path.iterdir
|
|
|
|
def entries(path):
|
|
if path.name == "task":
|
|
raise PermissionError("fixture task unreadable")
|
|
return original(path)
|
|
|
|
with patch.object(Path, "iterdir", entries):
|
|
with self.assertRaisesRegex(gate.Refusal, "fixture task unreadable"):
|
|
self.proc.uid_threads(1000)
|
|
|
|
def test_threads_contradiction_refuses(self):
|
|
(self.root / "101" / "task" / "102").rmdir()
|
|
with self.assertRaisesRegex(gate.Refusal, "compte threads incohérent"):
|
|
self.proc.uid_threads(1000)
|
|
|
|
def test_equal_count_task_replacement_refuses(self):
|
|
def replace_task():
|
|
(self.root / "101" / "task" / "102").rename(
|
|
self.root / "101" / "task" / "103")
|
|
|
|
with self.change_before_second_sample(replace_task):
|
|
with self.assertRaisesRegex(gate.Refusal, "task drift"):
|
|
self.proc.uid_threads(1000)
|
|
|
|
def test_process_creation_and_end_refuse(self):
|
|
original = Path.iterdir
|
|
for creation in (True, False):
|
|
calls = 0
|
|
|
|
def entries(path):
|
|
nonlocal calls
|
|
if path == self.root:
|
|
calls += 1
|
|
if calls == 2:
|
|
if creation:
|
|
self.add_process(201)
|
|
else:
|
|
return iter(())
|
|
return original(path)
|
|
|
|
with self.subTest(creation=creation):
|
|
with patch.object(Path, "iterdir", entries):
|
|
with self.assertRaisesRegex(gate.Refusal, "process creation/end"):
|
|
self.proc.uid_threads(1000)
|
|
|
|
|
|
class GlobalHeadroomTests(unittest.TestCase):
|
|
"""Vrai parseur/qualificateur, entrées simulées ; aucun proc hôte lu."""
|
|
|
|
SAMPLE = "0.00 1.23 12.34 2/1415 1234\n"
|
|
|
|
def setUp(self):
|
|
self.proc = gate.ProcFS()
|
|
self.context = patch.object(self.proc, "host_context", return_value=(1, 2))
|
|
self.context.start()
|
|
self.addCleanup(self.context.stop)
|
|
self.reader = patch.object(self.proc, "bounded_text", return_value=self.SAMPLE)
|
|
self.read = self.reader.start()
|
|
self.addCleanup(self.reader.stop)
|
|
|
|
def test_actual_controller_observed_example_and_resource_injection(self):
|
|
with patch.object(resource, "getrlimit", return_value=(60706, 60706)) as limits:
|
|
self.assertEqual(gate.nproc_headroom(self.proc)["global_tasks"], 1415)
|
|
limits.assert_called_once_with(resource.RLIMIT_NPROC)
|
|
|
|
def test_malformed_truncated_extra_or_inconsistent_loadavg_refused(self):
|
|
invalid = ("", "0.00 1.23 12.34", self.SAMPLE.rstrip(),
|
|
self.SAMPLE + "extra\n", self.SAMPLE.replace("2/1415", "x/1415"),
|
|
self.SAMPLE.replace("2/1415", "-1/1415"),
|
|
self.SAMPLE.replace("2/1415", "0/0"),
|
|
self.SAMPLE.replace("2/1415", "1416/1415"),
|
|
self.SAMPLE.replace("2/1415", "2/" + "9" * 30))
|
|
for text in invalid:
|
|
with self.subTest(text=text):
|
|
self.read.return_value = text
|
|
with self.assertRaises(gate.Refusal):
|
|
self.proc.global_tasks()
|
|
|
|
def test_zero_runnable_is_valid(self):
|
|
self.read.return_value = self.SAMPLE.replace("2/1415", "0/1415")
|
|
self.assertEqual(self.proc.global_tasks(), 1415)
|
|
|
|
def test_missing_unreadable_nonascii_or_changed_context_refuses(self):
|
|
for error in (FileNotFoundError("missing"), PermissionError("denied"),
|
|
UnicodeError("non ascii")):
|
|
with self.subTest(error=error), patch.object(
|
|
self.proc, "bounded_text", side_effect=error):
|
|
with self.assertRaises(gate.Refusal):
|
|
self.proc.global_tasks()
|
|
with patch.object(self.proc, "host_context", side_effect=[(1,), (2,)]):
|
|
with self.assertRaisesRegex(gate.Refusal, "contexte"):
|
|
self.proc.global_tasks()
|
|
|
|
def test_finite_minimum_requires_strict_full_reserve(self):
|
|
for limits in ((1671, 60706), (60706, 1671), (1415, 1415), (0, 0),
|
|
(resource.RLIM_INFINITY, 1671), (1671, resource.RLIM_INFINITY)):
|
|
with self.subTest(limits=limits), patch.object(
|
|
self.proc, "nproc", return_value=limits):
|
|
with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC"):
|
|
gate.nproc_headroom(self.proc)
|
|
for limits in ((1672, 60706), (resource.RLIM_INFINITY, 1672),
|
|
(resource.RLIM_INFINITY, resource.RLIM_INFINITY)):
|
|
with self.subTest(limits=limits), patch.object(
|
|
self.proc, "nproc", return_value=limits):
|
|
self.assertEqual(gate.nproc_headroom(self.proc)["nproc"], limits)
|
|
|
|
def test_bounded_fd_reader_rejects_symlink_and_oversize(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
proc = gate.ProcFS(root)
|
|
(root / "data").write_text("x" * 257)
|
|
with self.assertRaises(gate.Refusal):
|
|
proc.bounded_text("data", 256)
|
|
(root / "link").symlink_to(root / "data")
|
|
with self.assertRaises(OSError):
|
|
proc.bounded_text("link", 256)
|
|
|
|
def test_fd_reader_rejects_writable_foreign_or_replaced_source(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
path = root / "data"
|
|
path.write_text(self.SAMPLE)
|
|
proc = gate.ProcFS(root)
|
|
path.chmod(0o666)
|
|
with self.assertRaisesRegex(gate.Refusal, "modifiable"):
|
|
proc.bounded_text("data", 256)
|
|
path.chmod(0o444)
|
|
info = path.stat()
|
|
foreign = list(info)
|
|
foreign[4] = 1001
|
|
with patch.object(gate.os, "fstat", return_value=os.stat_result(foreign)):
|
|
with self.assertRaisesRegex(gate.Refusal, "non root"):
|
|
proc.bounded_text("data", 256, root_owned=True)
|
|
replaced = list(info)
|
|
replaced[1] += 1
|
|
with patch.object(Path, "stat", return_value=os.stat_result(replaced)):
|
|
with self.assertRaisesRegex(gate.Refusal, "remplacée"):
|
|
proc.bounded_text("data", 256)
|
|
|
|
def test_invalid_inherited_limits_refuse(self):
|
|
for limits in ((-2, 4096), ("4096", 4096), (4096,)):
|
|
with self.subTest(limits=limits), patch.object(
|
|
self.proc, "nproc", return_value=limits):
|
|
with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC invalide"):
|
|
gate.nproc_headroom(self.proc)
|
|
|
|
def test_actual_host_qualification_refuses_untrusted_contexts(self):
|
|
# Stop parser-level mocks: exercise the actual context qualification.
|
|
self.context.stop()
|
|
device = os.makedev(0, 5)
|
|
info = os.stat_result((0o40555, 10, device, 1, 0, 0, 0, 0, 0, 0))
|
|
mount = "1 0 0:5 / /proc rw - proc proc rw\n"
|
|
|
|
def text(name, *_args, **_kwargs):
|
|
if name == "self/mountinfo":
|
|
return mount
|
|
if name == "self/status":
|
|
return f"Name: fixture\nNSpid: {PID}\n"
|
|
return "0 0 4294967295\n"
|
|
|
|
def namespace(path):
|
|
return ("pid:[4026531836]" if path.name == "pid"
|
|
else "user:[4026531837]")
|
|
|
|
with (patch.object(Path, "stat", return_value=info),
|
|
patch.object(Path, "resolve", lambda path: path),
|
|
patch.object(self.proc, "bounded_text", side_effect=text),
|
|
patch.object(gate.os, "readlink", side_effect=namespace)):
|
|
self.proc.host_context()
|
|
for bad in (mount.replace(" - proc proc", " - fuse.lxcfs lxcfs"),
|
|
mount + mount,
|
|
mount.replace(" / /proc ", " /sub /proc "),
|
|
mount + "2 1 0:5 / /proc/loadavg rw - proc proc rw\n"):
|
|
with self.subTest(mount=bad):
|
|
original, mount = mount, bad
|
|
with self.assertRaises(gate.Refusal):
|
|
self.proc.host_context()
|
|
mount = original
|
|
with patch.object(gate.os, "readlink", return_value="pid:[123]"):
|
|
with self.assertRaisesRegex(gate.Refusal, "namespace"):
|
|
self.proc.host_context()
|
|
with patch.object(gate.os, "readlink", side_effect=PermissionError("self")):
|
|
with self.assertRaises(PermissionError):
|
|
self.proc.host_context()
|
|
for nspid in ("", f"NSpid: {PID} 1\n", f"NSpid: {PID + 1}\n",
|
|
f"NSpid: {PID}\nNSpid: {PID}\n"):
|
|
with self.subTest(nspid=nspid), patch.object(
|
|
self.proc, "bounded_text", side_effect=lambda name, *_a:
|
|
nspid if name == "self/status" else text(name)):
|
|
with self.assertRaisesRegex(gate.Refusal, "vue PID"):
|
|
self.proc.host_context()
|
|
with patch.object(self.proc, "bounded_text", side_effect=lambda name, *_a:
|
|
mount if name == "self/mountinfo" else "0 1000 1\n"):
|
|
with self.assertRaisesRegex(gate.Refusal, "UID map"):
|
|
self.proc.host_context()
|
|
with self.assertRaisesRegex(gate.Refusal, "chemin hôte"):
|
|
gate.ProcFS(Path("/tmp/proc")).host_context()
|
|
|
|
|
|
class AdmissionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.sentinel = patch.object(
|
|
subprocess, "Popen", side_effect=native_spawn_forbidden)
|
|
self.sentinel.start()
|
|
self.addCleanup(self.sentinel.stop)
|
|
self.proc = FakeProc()
|
|
self.addCleanup(self.proc.close)
|
|
self.cgroups = FakeCgroups()
|
|
self.manager = FakeManager(self.cgroups)
|
|
self.barrier = FakeBarrier()
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.lab = Path(self.temporary.name)
|
|
self.manifest = {"scope": SPEC, "kind": "benign",
|
|
"qualification_mode": "nominal",
|
|
"argv": run.benign_command("nominal")}
|
|
self.reservations = []
|
|
self.journal = []
|
|
|
|
def preflight(self):
|
|
return gate.preflight(self.proc, self.cgroups, self.manager,
|
|
UID, PID, time.monotonic() + 1)
|
|
|
|
def admitted(self):
|
|
evidence = self.preflight()
|
|
self.cgroups.present = True
|
|
return gate.admit(SPEC, evidence, 43, self.proc, self.cgroups, self.manager,
|
|
time.monotonic() + 1)
|
|
|
|
def execute(self, collector=None, reserve=None, journal=None):
|
|
def collect(process, deadline):
|
|
process.returncode = 0
|
|
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
|
|
return gate.run_scope(
|
|
self.lab, self.manifest, reserve or self.reservations.append,
|
|
journal or (lambda result: self.journal.append(copy.deepcopy(result))),
|
|
self.proc, self.cgroups, self.manager,
|
|
barrier_factory=lambda *args: self.barrier, collector=collector or collect)
|
|
|
|
def test_success_uses_production_orchestrator_and_single_release(self):
|
|
result, output = self.execute()
|
|
self.assertEqual(result["cause"], "exit")
|
|
self.assertEqual(self.barrier.released, 1)
|
|
self.assertEqual(len(self.reservations), 1)
|
|
self.assertTrue(self.barrier.closed)
|
|
self.assertEqual(self.manager.stopped, [SPEC["unit"]])
|
|
self.assertEqual([event["phase"] for event in result["phases"]],
|
|
["PRECHECK", "SCOPE_CREATED", "ADMITTED", "RELEASED",
|
|
"COLLECTED",
|
|
"PAYLOAD_STARTED", "CLEANED"])
|
|
self.assertEqual(self.proc.limits, (4096, 4096))
|
|
|
|
def test_global_load_fluctuation_does_not_require_pid_census(self):
|
|
with patch.object(self.proc, "global_tasks", side_effect=[1415, 1500]):
|
|
result, _ = self.execute()
|
|
self.assertEqual(result["cause"], "exit")
|
|
self.assertEqual(self.barrier.released, 1)
|
|
self.assertEqual(self.reservations[0]["global_tasks"], 1415)
|
|
|
|
def test_rising_global_load_refuses_release(self):
|
|
with patch.object(self.proc, "global_tasks", side_effect=[1415, 3840]):
|
|
result, _ = self.execute()
|
|
self.assertIn("RLIMIT_NPROC", result["error"])
|
|
self.assertEqual(self.barrier.released, 0)
|
|
self.assertEqual(len(self.reservations), 1)
|
|
|
|
def test_failed_capacity_preflight_has_no_attempt_or_native_spawn(self):
|
|
self.proc.load = 4096
|
|
result, _ = self.execute()
|
|
self.assertIn("RLIMIT_NPROC", result["error"])
|
|
self.assertEqual(list(self.lab.iterdir()), [])
|
|
self.assertEqual(self.reservations, [])
|
|
self.assertEqual(self.journal, [])
|
|
self.assertEqual(self.manager.created, 0)
|
|
|
|
def test_changed_inherited_nproc_before_release_refuses(self):
|
|
evidence = self.preflight()
|
|
self.cgroups.present = True
|
|
self.proc.limits = (5000, 5000)
|
|
with self.assertRaisesRegex(gate.Refusal, "hérité divergent"):
|
|
gate.admit(SPEC, evidence, 43, self.proc, self.cgroups, self.manager,
|
|
time.monotonic() + 1)
|
|
|
|
def test_session_scope_refused_before_reservation(self):
|
|
self.proc.identities[PID]["cgroup"] = (
|
|
f"/user.slice/user-{UID}.slice/session-6.scope")
|
|
result, _ = self.execute()
|
|
self.assertIn("session scope", result["error"])
|
|
self.assertEqual(self.reservations, [])
|
|
self.assertEqual(self.manager.created, 0)
|
|
|
|
def test_nproc_256_under_uid1126_refused_and_never_raised(self):
|
|
self.proc.limits = (256, 256)
|
|
result, _ = self.execute()
|
|
self.assertIn("RLIMIT_NPROC", result["error"])
|
|
self.assertEqual(self.proc.limits, (256, 256))
|
|
self.assertEqual(self.manager.created, 0)
|
|
|
|
def test_infinite_inherited_nproc_is_preserved(self):
|
|
self.proc.limits = (resource.RLIM_INFINITY, resource.RLIM_INFINITY)
|
|
self.assertEqual(self.preflight()["nproc"], self.proc.limits)
|
|
self.assertFalse(any(arg.startswith("--nproc")
|
|
for arg in run.command(self.lab)))
|
|
|
|
def test_manager_delegation_identity_and_app_refusals(self):
|
|
cases = (("owner", "Delegate", "no"), ("owner", "Id", "foreign"),
|
|
("owner", "InvocationID", ""), ("owner", "ControlGroup", "/"),
|
|
("app", "ActiveState", "inactive"), ("app", "ControlGroup", "/"))
|
|
for target, key, bad in cases:
|
|
with self.subTest(key=key, bad=bad):
|
|
original = getattr(self.manager, target)[key]
|
|
getattr(self.manager, target)[key] = bad
|
|
with self.assertRaises(gate.Refusal):
|
|
self.preflight()
|
|
getattr(self.manager, target)[key] = original
|
|
|
|
def test_controllers_and_writable_ancestor(self):
|
|
for controllers in (([], ["pids"], True), (["pids"], [], True),
|
|
(["pids"], ["pids"], False)):
|
|
self.cgroups.controllers = controllers
|
|
with self.assertRaises(gate.Refusal):
|
|
self.preflight()
|
|
|
|
def test_ancestor_headroom_and_inconsistent_current(self):
|
|
for maximum, current in (("1200", 1126), ("4096", -1), ("broken", 0)):
|
|
self.cgroups.ancestor.update(pids_max=maximum, pids_current=current)
|
|
with self.assertRaises(gate.Refusal):
|
|
self.preflight()
|
|
|
|
def test_scope_collision_no_spawn(self):
|
|
self.cgroups.present = True
|
|
result, _ = self.execute()
|
|
self.assertIn("occupé", result["error"])
|
|
self.assertEqual(self.manager.created, 0)
|
|
self.assertEqual(self.reservations, [])
|
|
|
|
def test_uid_divergence_before_release(self):
|
|
self.proc.identities[43]["uid"] = UID + 1
|
|
result, _ = self.execute()
|
|
self.assertEqual(self.barrier.released, 0)
|
|
self.assertEqual(result["cause"], "CLEANUP_UNPROVEN")
|
|
self.assertEqual(self.manager.stopped, [])
|
|
|
|
def test_kernel_budget_membership_and_shape_refusals(self):
|
|
for key, bad in (("pids_max", "max"), ("pids_max", "257"), ("pids_current", 0),
|
|
("pids_current", 2), ("procs", [43, 44]),
|
|
("children", ["foreign"]),
|
|
("uid", UID + 1)):
|
|
with self.subTest(key=key, bad=bad):
|
|
original = self.cgroups.record[key]
|
|
self.cgroups.record[key] = bad
|
|
with self.assertRaises(gate.Refusal):
|
|
self.admitted()
|
|
self.cgroups.record[key] = original
|
|
|
|
def test_scope_manager_properties_are_exact(self):
|
|
for key, bad in (("Id", "foreign.scope"), ("Description", "other"),
|
|
("InvocationID", ""), ("ControlGroup", "/wrong"),
|
|
("TasksMax", "infinity"), ("Delegate", "no"),
|
|
("RuntimeMaxUSec", "infinity"), ("KillMode", "process")):
|
|
with self.subTest(key=key):
|
|
original = self.manager.scope[key]
|
|
self.manager.scope[key] = bad
|
|
with self.assertRaises(gate.Refusal):
|
|
self.admitted()
|
|
self.manager.scope[key] = original
|
|
|
|
def test_startticks_change_during_admission(self):
|
|
original = self.proc.identity
|
|
calls = 0
|
|
def changing(pid):
|
|
nonlocal calls
|
|
value = original(pid)
|
|
if pid == 43:
|
|
calls += 1
|
|
if calls > 1:
|
|
value["startticks"] += 1
|
|
return value
|
|
self.proc.identity = changing
|
|
with self.assertRaisesRegex(gate.Refusal, "startticks"):
|
|
self.admitted()
|
|
|
|
def test_dead_pidfd_refused(self):
|
|
def dead(pid):
|
|
reader, writer = os.pipe()
|
|
os.close(writer)
|
|
return reader
|
|
self.proc.pidfd = dead
|
|
with self.assertRaisesRegex(gate.Refusal, "mort"):
|
|
self.admitted()
|
|
|
|
def test_identity_drift_never_stops_foreign_scope(self):
|
|
for key, bad in (("Id", "foreign.scope"), ("Description", "other"),
|
|
("InvocationID", "d" * 32), ("ControlGroup", "/foreign")):
|
|
with self.subTest(key=key):
|
|
self.manager.scope = {"Id": SPEC["unit"],
|
|
"Description": SPEC["description"],
|
|
"InvocationID": "c" * 32, "ControlGroup": GROUP,
|
|
**gate.POLICY}
|
|
receipt = self.admitted()
|
|
self.manager.scope[key] = bad
|
|
cleaned = gate.cleanup(SPEC, receipt, None, self.proc, self.cgroups,
|
|
self.manager, time.monotonic() + .2)
|
|
self.assertFalse(cleaned["proved"])
|
|
self.assertEqual(self.manager.stopped, [])
|
|
|
|
def test_inode_drift_and_recycled_pid_never_stop(self):
|
|
receipt = self.admitted()
|
|
self.cgroups.record["inode"] += 1
|
|
self.assertFalse(gate.cleanup(SPEC, receipt, None, self.proc, self.cgroups,
|
|
self.manager, time.monotonic() + .2)["proved"])
|
|
self.assertEqual(self.manager.stopped, [])
|
|
self.cgroups.record["inode"] -= 1
|
|
receipt = self.admitted()
|
|
self.proc.identities[43]["startticks"] += 1
|
|
self.assertFalse(gate.cleanup(SPEC, receipt, None, self.proc, self.cgroups,
|
|
self.manager, time.monotonic() + .2)["proved"])
|
|
self.assertEqual(self.manager.stopped, [])
|
|
|
|
def test_eof_and_timeout_before_release_never_exec(self):
|
|
for error in (EOFError("child EOF"), TimeoutError("barrier timeout")):
|
|
self.barrier.error = error
|
|
result, _ = self.execute()
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertEqual(self.barrier.released, 0)
|
|
|
|
def test_failures_in_preflight_reservation_admission_and_drain(self):
|
|
for error in (OSError("read failure"), KeyboardInterrupt()):
|
|
with patch.object(self.proc, "global_tasks", side_effect=error):
|
|
result, _ = self.execute()
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertEqual(self.manager.created, 0)
|
|
def fail(*args):
|
|
raise OSError("mock failure")
|
|
result, _ = self.execute(reserve=fail)
|
|
self.assertEqual(self.manager.created, 0)
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
result, _ = self.execute(collector=fail)
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertTrue(self.manager.process.stdout.closed)
|
|
self.assertTrue(self.manager.process.stderr.closed)
|
|
|
|
def test_spawn_failure_consumes_once_no_cleanup_exception(self):
|
|
self.manager.create_error = OSError("Popen failure")
|
|
result, _ = self.execute()
|
|
self.assertEqual(len(self.reservations), 1)
|
|
self.assertIn("Popen failure", result["error"])
|
|
self.assertEqual(self.manager.stopped, [])
|
|
|
|
def test_cleanup_stop_collection_and_events_failures_never_pass(self):
|
|
for mode in ("stop", "collection", "populated"):
|
|
with self.subTest(mode=mode):
|
|
self.manager = FakeManager(self.cgroups)
|
|
self.cgroups.present = False
|
|
self.cgroups.is_empty = mode != "populated"
|
|
if mode == "stop":
|
|
self.manager.stop_error = OSError("stop failed")
|
|
if mode == "collection":
|
|
self.manager.process.wait_error = subprocess.TimeoutExpired(
|
|
"mock", .1)
|
|
result, _ = self.execute()
|
|
self.assertEqual(result["cause"], "CLEANUP_UNPROVEN")
|
|
|
|
def test_journal_failure_does_not_abandon_child(self):
|
|
calls = 0
|
|
def journal(result):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls >= 4:
|
|
raise OSError("journal full")
|
|
result, _ = self.execute(journal=journal)
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertTrue(self.manager.process.stdout.closed)
|
|
self.assertTrue(self.barrier.closed)
|
|
|
|
def test_no_egl_measurement_when_only_exec_requested(self):
|
|
self.manifest["kind"] = "egl"
|
|
result, _ = self.execute()
|
|
self.assertEqual(result["runtime"], "EGL_RESULT_UNINTERPRETED")
|
|
self.assertNotIn("PAYLOAD_STARTED",
|
|
[event["phase"] for event in result["phases"]])
|
|
|
|
def test_parent_death_is_separate_sealed_case(self):
|
|
self.manifest["qualification_mode"] = "parent-death"
|
|
with patch.object(gate.os, "_exit", side_effect=SystemExit(71)) as exit_process:
|
|
result, _ = self.execute()
|
|
exit_process.assert_called_once_with(71)
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertIn("PARENT_DEATH_TEST",
|
|
[event["phase"] for event in result["phases"]])
|
|
|
|
def test_empty_observed_before_unit_disappears_allows_collection(self):
|
|
def collect(process, deadline):
|
|
self.cgroups.is_empty = True
|
|
self.cgroups.record.update(pids_current=0, procs=[])
|
|
time.sleep(.01) # Give the production watcher a real observation.
|
|
self.cgroups.present = False
|
|
process.returncode = 0
|
|
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
|
|
self.cgroups.is_empty = False
|
|
result, _ = self.execute(collector=collect)
|
|
self.assertEqual(result["cause"], "exit")
|
|
self.assertTrue(result["cleanup"]["empty_observed"])
|
|
self.assertEqual(self.manager.stopped, [])
|
|
|
|
def test_disappeared_without_empty_proof_never_passes(self):
|
|
def collect(process, deadline):
|
|
self.cgroups.present = False
|
|
process.returncode = 0
|
|
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
|
|
self.cgroups.is_empty = False
|
|
result, _ = self.execute(collector=collect)
|
|
self.assertEqual(result["cause"], "CLEANUP_UNPROVEN")
|
|
self.assertEqual(self.manager.stopped, [])
|
|
|
|
def test_drain_failure_keeps_captured_prefix_in_orchestrator(self):
|
|
def collect(process, deadline):
|
|
raise gate.DrainFailure("overflow", [b"prefix", b"diagnostic"])
|
|
result, channels = self.execute(collector=collect)
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertEqual(channels, [b"prefix", b"diagnostic"])
|
|
|
|
|
|
def test_approved_main_uses_production_gate_and_durable_single_budget(self):
|
|
def execute(lab, manifest, reserve, journal):
|
|
def collect(process, deadline):
|
|
process.returncode = 0
|
|
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
|
|
return gate.run_scope(
|
|
lab, manifest, reserve, journal, self.proc, self.cgroups, self.manager,
|
|
barrier_factory=lambda *args: self.barrier, collector=collect)
|
|
|
|
argv = ["run.py", "--lab", str(self.lab), "--manifest-sha256", "reviewed",
|
|
"--approve-benign-gate"]
|
|
with (
|
|
patch.object(sys, "argv", argv),
|
|
patch.object(run, "verify", return_value=self.manifest),
|
|
patch.object(run.gate, "run_scope", side_effect=execute),
|
|
contextlib.redirect_stdout(io.StringIO()),
|
|
contextlib.redirect_stderr(io.StringIO()),
|
|
):
|
|
self.assertEqual(run.main(), 0)
|
|
self.assertEqual(run.main(), 20)
|
|
self.assertEqual(self.manager.created, 1)
|
|
receipt = json.loads((self.lab / "result.json").read_text())
|
|
self.assertEqual(receipt["returncode"], 0)
|
|
self.assertTrue(receipt["cleanup"]["proved"])
|
|
self.assertTrue((self.lab / "ATTEMPT").exists())
|
|
|
|
def test_missing_benign_payload_marker_cannot_pass(self):
|
|
def collect(process, deadline):
|
|
process.returncode = 0
|
|
return [b"", b""]
|
|
result, _ = self.execute(collector=collect)
|
|
self.assertNotEqual(result["cause"], "exit")
|
|
self.assertIn("marqueur", result["error"])
|
|
|
|
|
|
|
|
class FilesystemTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
self.fs = gate.CgroupFS(self.root)
|
|
self.group = self.root / "scope"
|
|
self.group.mkdir()
|
|
contents = {"cgroup.type": "domain", "pids.max": "256", "pids.current": "1",
|
|
"cgroup.procs": "43", "cgroup.events": "populated 0\n",
|
|
"cgroup.threads": ""}
|
|
for name, value in contents.items():
|
|
(self.group / name).write_text(value)
|
|
|
|
def test_threaded_and_symlink_rejected_by_production_fs(self):
|
|
(self.group / "cgroup.type").write_text("threaded")
|
|
with self.assertRaisesRegex(gate.Refusal, "domain"):
|
|
self.fs.snapshot("/scope")
|
|
|
|
|
|
(self.root / "link").symlink_to(self.group)
|
|
with self.assertRaisesRegex(gate.Refusal, "symlink"):
|
|
self.fs.snapshot("/link")
|
|
|
|
def test_cgroup_v1_mount_and_nonroot_mount_rejected(self):
|
|
for text in (f"1 0 0:30 / {self.root} rw - cgroup cgroup rw\n",
|
|
f"1 0 0:30 /subtree {self.root} rw - cgroup2 cgroup rw\n"):
|
|
with patch.object(Path, "read_text", return_value=text):
|
|
with self.assertRaises(gate.Refusal):
|
|
self.fs.mount()
|
|
|
|
def test_recursive_populated_threads_and_symlink(self):
|
|
(self.group / "cgroup.procs").write_text("")
|
|
(self.group / "pids.current").write_text("0")
|
|
self.assertTrue(self.fs.empty("/scope"))
|
|
(self.group / "cgroup.threads").write_text("44")
|
|
self.assertFalse(self.fs.empty("/scope"))
|
|
(self.group / "cgroup.threads").write_text("")
|
|
(self.group / "foreign").symlink_to(self.root)
|
|
with self.assertRaises(gate.Refusal):
|
|
self.fs.empty("/scope")
|
|
|
|
def test_proc_v1_and_uid_drift_refused(self):
|
|
directory = self.root / "43"
|
|
directory.mkdir()
|
|
(directory / "stat").write_text("43 (name) " + " ".join(["1"] * 20))
|
|
(directory / "status").write_text(
|
|
f"Uid:\t{UID} {UID} {UID} {UID}\nThreads:\t1\n")
|
|
(directory / "cgroup").write_text("1:name:/legacy\n")
|
|
proc = gate.ProcFS(self.root)
|
|
with self.assertRaisesRegex(gate.Refusal, "v1"):
|
|
proc.identity(43)
|
|
(directory / "cgroup").write_text("0::/scope\n")
|
|
(directory / "status").write_text(
|
|
f"Uid:\t{UID} {UID + 1} {UID} {UID}\nThreads:\t1\n")
|
|
with self.assertRaisesRegex(gate.Refusal, "UID"):
|
|
proc.identity(43)
|
|
|
|
def test_proc_uid_thread_diagnostic_rejects_drift_and_disappearance(self):
|
|
directory = self.root / "43"
|
|
directory.mkdir()
|
|
(directory / "stat").write_text("43 (name) " + " ".join(["1"] * 20))
|
|
status = f"Uid:\t{UID} {UID} {UID} {UID}\nThreads:\t1\n"
|
|
(directory / "status").write_text(status)
|
|
(directory / "cgroup").write_text("0::/scope\n")
|
|
(directory / "task/43").mkdir(parents=True)
|
|
proc = gate.ProcFS(self.root)
|
|
self.assertEqual(proc.uid_threads(UID), 1)
|
|
(directory / "task/44").mkdir()
|
|
with self.assertRaisesRegex(gate.Refusal, "threads incohérent"):
|
|
proc.uid_threads(UID)
|
|
(directory / "task/44").rmdir()
|
|
original = Path.read_text
|
|
reads = 0
|
|
|
|
def changed(path, *args, **kwargs):
|
|
nonlocal reads
|
|
if path == directory / "status":
|
|
reads += 1
|
|
if reads > 1:
|
|
return status.replace("Threads:\t1", "Threads:\t2")
|
|
return original(path, *args, **kwargs)
|
|
|
|
with patch.object(Path, "read_text", changed):
|
|
with self.assertRaisesRegex(gate.Refusal, "threads incohérent"):
|
|
proc.uid_threads(UID)
|
|
(directory / "status").unlink()
|
|
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
|
|
proc.uid_threads(UID)
|
|
|
|
|
|
|
|
class BenignCommandTests(unittest.TestCase):
|
|
def test_parent_death_payload_sleeps_after_broken_pipe(self):
|
|
sleeps = []
|
|
|
|
def broken_write(_fd, _data):
|
|
raise BrokenPipeError("supervisor closed stdout")
|
|
|
|
fake_os = type("FakeOS", (), {"write": staticmethod(broken_write)})
|
|
fake_time = type("FakeTime", (), {
|
|
"sleep": staticmethod(sleeps.append),
|
|
})
|
|
script = run.benign_command("parent-death")[-1]
|
|
|
|
with patch.dict(sys.modules, {"os": fake_os, "time": fake_time}):
|
|
with self.assertRaises(BrokenPipeError):
|
|
exec(compile(script, "<benign-parent-death>", "exec"), {})
|
|
|
|
self.assertEqual(sleeps, [30])
|
|
self.assertIn("time.sleep(0.2)", run.benign_command("nominal")[-1])
|
|
|
|
|
|
class BarrierTests(unittest.TestCase):
|
|
"""Sockets locaux et threads inertes ; aucun fork/exec/systemd."""
|
|
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.path = Path(self.temp.name) / "gate.sock"
|
|
|
|
|
|
def peer(self, message, reply=None):
|
|
errors = []
|
|
def client():
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) as channel:
|
|
channel.settimeout(1)
|
|
channel.connect(str(self.path))
|
|
channel.sendall(message)
|
|
if reply is not None:
|
|
received = channel.recv(4096)
|
|
if received != b"RELEASE " + SPEC["nonce"].encode():
|
|
errors.append("wrong release")
|
|
channel.sendall(reply)
|
|
except BaseException as error:
|
|
errors.append(error)
|
|
thread = threading.Thread(target=client)
|
|
thread.start()
|
|
self.addCleanup(lambda: thread.join(timeout=2))
|
|
return thread, errors
|
|
|
|
def barrier(self, seconds=1):
|
|
barrier = gate.Barrier(self.path, SPEC["nonce"], UID, PID,
|
|
time.monotonic() + seconds)
|
|
self.addCleanup(barrier.close)
|
|
return barrier
|
|
|
|
def test_real_channel_peer_credentials_and_single_release(self):
|
|
barrier = self.barrier()
|
|
message = json.dumps({"nonce": SPEC["nonce"], "pid": PID}).encode()
|
|
thread, errors = self.peer(message, b"EXEC_REQUESTED " + SPEC["nonce"].encode())
|
|
self.assertEqual(barrier.accept(), PID)
|
|
barrier.release()
|
|
with self.assertRaisesRegex(gate.Refusal, "replay"):
|
|
barrier.accept()
|
|
with self.assertRaisesRegex(gate.Refusal, "seconde"):
|
|
barrier.release()
|
|
thread.join(timeout=2)
|
|
self.assertEqual(errors, [])
|
|
|
|
def test_wrong_nonce_and_pid_refuse(self):
|
|
messages = ({"nonce": "bad", "pid": PID},
|
|
{"nonce": SPEC["nonce"], "pid": PID + 1})
|
|
for data in messages:
|
|
barrier = self.barrier()
|
|
thread, _ = self.peer(json.dumps(data).encode())
|
|
with self.assertRaisesRegex(gate.Refusal, "nonce/PID"):
|
|
barrier.accept()
|
|
thread.join(timeout=2)
|
|
barrier.close()
|
|
# Remove registered cleanup because close is intentionally idempotent.
|
|
|
|
def test_eof_and_accept_timeout(self):
|
|
barrier = self.barrier(.05)
|
|
with self.assertRaises(TimeoutError):
|
|
barrier.accept()
|
|
barrier.close()
|
|
barrier = self.barrier()
|
|
thread, _ = self.peer(b"")
|
|
with self.assertRaises((ValueError, gate.Refusal)):
|
|
barrier.accept()
|
|
thread.join(timeout=2)
|
|
|
|
def test_bootstrap_no_exec_on_eof_wrong_nonce_parentdeath_timeout(self):
|
|
for action in ("eof", "wrong", "death", "timeout"):
|
|
with self.subTest(action=action):
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
listener.bind(str(self.path))
|
|
listener.listen(1)
|
|
reader, writer = os.pipe()
|
|
class Proc:
|
|
def identity(self, pid):
|
|
return {"pid": PID, "uid": UID, "startticks": 123}
|
|
def pidfd(self, pid):
|
|
return reader
|
|
def server():
|
|
with listener.accept()[0] as channel:
|
|
channel.recv(4096)
|
|
if action == "wrong":
|
|
channel.sendall(b"RELEASE wrong")
|
|
elif action == "death":
|
|
os.close(writer)
|
|
# The pidfd EOF itself proves parent death to bootstrap.
|
|
elif action == "timeout":
|
|
time.sleep(.05)
|
|
thread = threading.Thread(target=server)
|
|
thread.start()
|
|
with patch.object(gate, "select", wraps=gate.select) as select_module:
|
|
if action == "timeout":
|
|
select_module.select.return_value = ([], [], [])
|
|
with self.assertRaises((gate.Refusal, OSError)):
|
|
gate.bootstrap(self.path, SPEC["nonce"], PID, 123,
|
|
["forbidden"], Proc(),
|
|
execute=native_spawn_forbidden)
|
|
thread.join(timeout=2)
|
|
listener.close()
|
|
self.path.unlink()
|
|
if action != "death":
|
|
os.close(writer)
|
|
|
|
def test_bootstrap_exec_boundary_is_clean_and_closes_control_fds(self):
|
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
self.addCleanup(listener.close)
|
|
listener.bind(str(self.path))
|
|
listener.listen(1)
|
|
reader, writer = os.pipe()
|
|
self.addCleanup(lambda: os.close(writer))
|
|
observations = []
|
|
errors = []
|
|
|
|
class Proc:
|
|
def identity(self, pid):
|
|
return {"pid": PID, "uid": UID, "startticks": 123}
|
|
def pidfd(self, pid):
|
|
return reader
|
|
|
|
def server():
|
|
try:
|
|
with listener.accept()[0] as channel:
|
|
channel.recv(4096)
|
|
channel.sendall(b"RELEASE " + SPEC["nonce"].encode())
|
|
observations.append(channel.recv(4096))
|
|
except BaseException as error:
|
|
errors.append(error)
|
|
|
|
thread = threading.Thread(target=server)
|
|
thread.start()
|
|
self.addCleanup(lambda: thread.join(timeout=2))
|
|
executed = []
|
|
with patch.object(gate.os, "closerange") as closed, \
|
|
patch.object(gate.os, "chdir") as chdir:
|
|
with self.assertRaisesRegex(gate.Refusal, "exec retourné"):
|
|
gate.bootstrap(self.path, SPEC["nonce"], PID, 123, ["fixed", "payload"],
|
|
Proc(), execute=lambda *args: executed.append(args))
|
|
thread.join(timeout=2)
|
|
self.assertEqual(errors, [])
|
|
self.assertEqual(executed, [("fixed", ["fixed", "payload"], gate.CLEAN_ENV)])
|
|
self.assertEqual(observations, [b"EXEC_REQUESTED " + SPEC["nonce"].encode()])
|
|
self.assertEqual(closed.call_args.args[0], 3)
|
|
chdir.assert_called_once_with("/tmp")
|
|
with self.assertRaises(OSError):
|
|
os.fstat(reader)
|
|
|
|
|
|
|
|
class ManagerTests(unittest.TestCase):
|
|
def test_actual_argv_and_environment_with_injected_popen(self):
|
|
seen = []
|
|
manager = gate.SystemManager(
|
|
UID, popen=lambda argv, **kwargs: seen.append((argv, kwargs)))
|
|
manager.create(SPEC, ["fixed-bootstrap"])
|
|
argv, kwargs = seen[0]
|
|
self.assertIn("--scope", argv)
|
|
self.assertIn("--property=TasksMax=256", argv)
|
|
self.assertIn("--property=RuntimeMaxSec=12s", argv)
|
|
self.assertIn("--property=TimeoutStopSec=2s", argv)
|
|
self.assertIn("--property=KillMode=control-group", argv)
|
|
self.assertEqual(argv[-1], "fixed-bootstrap")
|
|
self.assertTrue(kwargs["close_fds"])
|
|
self.assertEqual(kwargs["env"]["XDG_RUNTIME_DIR"], f"/run/user/{UID}")
|
|
self.assertNotIn("pass_fds", kwargs)
|
|
self.assertNotIn("DBUS_SESSION_BUS_ADDRESS", gate.CLEAN_ENV)
|
|
|
|
|
|
class ProductionDrainTests(unittest.TestCase):
|
|
"""Pipes réels à taille minuscule, sans enfant ni exécutable."""
|
|
|
|
def pipes(self, stdout=b"", stderr=b""):
|
|
process = FakeProcess()
|
|
for name, data in (("stdout", stdout), ("stderr", stderr)):
|
|
reader, writer = os.pipe()
|
|
os.write(writer, data)
|
|
os.close(writer)
|
|
stream = os.fdopen(reader, "rb")
|
|
setattr(process, name, stream)
|
|
self.addCleanup(stream.close)
|
|
return process
|
|
|
|
def test_exact_bound_and_both_channels(self):
|
|
process = self.pipes(b"x" * 16, b"y" * 16)
|
|
channels = gate.drain(process, time.monotonic() + .1, limit=16)
|
|
self.assertEqual(channels, [b"x" * 16, b"y" * 16])
|
|
self.assertEqual(process.returncode, 0)
|
|
|
|
def test_overflow_preserves_bounded_prefix(self):
|
|
process = self.pipes(b"x" * 17)
|
|
with self.assertRaises(gate.DrainFailure) as context:
|
|
gate.drain(process, time.monotonic() + .1, limit=16)
|
|
self.assertIn("overflow", str(context.exception))
|
|
self.assertEqual(context.exception.channels[0], b"x" * 16)
|
|
|
|
def test_timeout_and_read_failure_are_bounded(self):
|
|
process = self.pipes(b"x")
|
|
with self.assertRaises(gate.DrainFailure):
|
|
gate.drain(process, time.monotonic() - .1)
|
|
with patch.object(gate.os, "read", side_effect=OSError("mock read error")):
|
|
with self.assertRaisesRegex(gate.DrainFailure, "mock read"):
|
|
gate.drain(process, time.monotonic() + .1)
|
|
|
|
def test_manager_call_failure_collects_only_its_child(self):
|
|
process = self.pipes(b"x" * 17)
|
|
manager = gate.SystemManager(UID, popen=lambda *args, **kwargs: process)
|
|
with patch.object(gate, "drain", side_effect=OSError("manager drain crash")):
|
|
with self.assertRaisesRegex(OSError, "manager drain"):
|
|
manager.call(["injected"], time.monotonic() + .2)
|
|
self.assertTrue(process.killed)
|
|
self.assertTrue(process.stdout.closed)
|
|
self.assertTrue(process.stderr.closed)
|
|
|
|
|
|
class ElfPreparationTests(unittest.TestCase):
|
|
"""Analyse ELF de production avec capture passive simulée."""
|
|
|
|
HEADER = ("Class: ELF64\nMachine: Advanced Micro Devices X86-64\n"
|
|
"Build ID: abc123\n")
|
|
|
|
def test_systemd_direct_runpath_is_recorded_and_not_inherited(self):
|
|
import prepare
|
|
tool = Path("/usr/bin/systemctl")
|
|
library = Path("/usr/lib/systemd/libsystemd-test.so")
|
|
libc = Path("/usr/lib/libc.so.6")
|
|
data = {
|
|
tool: self.HEADER + "(RUNPATH) [/usr/lib/systemd]\n"
|
|
"(NEEDED) [libsystemd-test.so]\n",
|
|
library: self.HEADER + "(NEEDED) [libc.so.6]\n",
|
|
libc: self.HEADER,
|
|
}
|
|
# A private namesake must not capture a transitive libc edge.
|
|
candidates = {library, libc, Path("/usr/lib/systemd/libc.so.6")}
|
|
with (
|
|
patch.object(prepare, "capture", side_effect=lambda argv: data[Path(argv[-1])].encode()),
|
|
patch.object(Path, "is_file", lambda path: path in candidates),
|
|
patch.object(prepare, "trusted_system_path") as trusted,
|
|
):
|
|
closure = prepare.elf_closure([tool])
|
|
self.assertEqual(closure[str(tool)]["runpath"], ["/usr/lib/systemd"])
|
|
self.assertEqual(closure[str(library)]["runpath"], [])
|
|
self.assertEqual(set(closure), {str(path) for path in data})
|
|
trusted.assert_any_call(Path("/usr/lib/systemd"))
|
|
|
|
def test_unapproved_runpaths_and_rpath_are_refused(self):
|
|
import prepare
|
|
for tag, value in (("RUNPATH", "$ORIGIN"), ("RUNPATH", "relative"),
|
|
("RUNPATH", "/tmp"),
|
|
("RUNPATH", "/usr/lib/systemd:/usr/lib"),
|
|
("RUNPATH", "/usr/lib/systemd:"),
|
|
("RPATH", "/usr/lib/systemd")):
|
|
with self.subTest(tag=tag, value=value), patch.object(
|
|
prepare, "capture", return_value=(
|
|
self.HEADER + f"({tag}) [{value}]\n").encode()):
|
|
with self.assertRaises(ValueError):
|
|
prepare.elf_closure([Path("/usr/bin/systemctl")])
|
|
with patch.object(prepare, "capture", return_value=(
|
|
self.HEADER + "(RUNPATH) [/usr/lib/systemd]\n").encode()):
|
|
with self.assertRaises(ValueError):
|
|
prepare.elf_closure([Path("/usr/bin/other")])
|
|
|
|
def test_writable_system_component_is_refused(self):
|
|
import prepare
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
with self.assertRaisesRegex(ValueError, "modifiable"):
|
|
prepare.trusted_system_path(Path(temporary))
|
|
|
|
|
|
class PreparationTests(unittest.TestCase):
|
|
def test_benign_preparation_uses_no_compiler_and_seals_actual_scripts(self):
|
|
import prepare
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
lab = Path(temporary) / "fresh"
|
|
argv = ["prepare.py", "--lab", str(lab), "--benign"]
|
|
with (
|
|
patch.object(sys, "argv", argv),
|
|
patch.object(prepare, "capture", side_effect=native_spawn_forbidden),
|
|
patch.object(prepare, "elf_closure", return_value={}) as closure,
|
|
contextlib.redirect_stdout(io.StringIO()),
|
|
):
|
|
prepare.main()
|
|
manifest = json.loads((lab / "manifest.json").read_text())
|
|
closure.assert_called_once()
|
|
self.assertEqual(manifest["kind"], "benign")
|
|
self.assertEqual(manifest["argv"], run.benign_command("nominal"))
|
|
self.assertEqual(manifest["scope"], gate.policy(manifest["scope"]["nonce"]))
|
|
self.assertIn(str(lab / "fixture/cgroup_gate.py"), manifest["files"])
|
|
self.assertFalse((lab / "ATTEMPT").exists())
|
|
self.assertFalse((lab / "bin/probe").exists())
|
|
|
|
|
|
def test_exact_isolated_passive_cli_creates_no_cache_or_process_payload(self):
|
|
import prepare
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
lab = Path(temporary) / "fresh"
|
|
preparation_argv = ["prepare.py", "--lab", str(lab), "--benign"]
|
|
with (
|
|
patch.object(sys, "argv", preparation_argv),
|
|
patch.object(prepare, "capture", side_effect=native_spawn_forbidden),
|
|
patch.object(prepare, "elf_closure", return_value={}),
|
|
contextlib.redirect_stdout(io.StringIO()),
|
|
):
|
|
prepare.main()
|
|
manifest_hash = run.digest(lab / "manifest.json")
|
|
argv = ["/usr/bin/python3", "-I", str(lab / "fixture/run.py"),
|
|
"--lab", str(lab), "--manifest-sha256", manifest_hash]
|
|
before = sorted(str(path.relative_to(lab)) for path in lab.rglob("*"))
|
|
|
|
def inert_only(command, *args, **kwargs):
|
|
if command != argv:
|
|
return native_spawn_forbidden(command, *args, **kwargs)
|
|
return INERT_POPEN(command, *args, **kwargs)
|
|
|
|
# This exact source-only Python command is permitted; every other
|
|
# executable/argument boundary remains forbidden by the sentinel.
|
|
with patch.object(subprocess, "Popen", side_effect=inert_only):
|
|
process = subprocess.run(argv, env=run.HOST_ENV, capture_output=True,
|
|
timeout=3, check=False)
|
|
self.assertEqual(process.returncode, 0, process.stderr.decode())
|
|
self.assertEqual(json.loads(process.stdout)["runtime"], "NOT_RUN")
|
|
self.assertEqual(before, sorted(str(path.relative_to(lab))
|
|
for path in lab.rglob("*")))
|
|
self.assertFalse((lab / "ATTEMPT").exists())
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|