fix(probe): count mixed-credential real UID threads
This commit is contained in:
parent
79c5af1853
commit
fcb0ea7f82
5 changed files with 294 additions and 29 deletions
|
|
@ -9,6 +9,10 @@ records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE /
|
||||||
OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real
|
OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real
|
||||||
manager and new EGL runtime remain NOT_RUN. Separate approval is required for
|
manager and new EGL runtime remain NOT_RUN. Separate approval is required for
|
||||||
benign qualification, then for any new EGL attempt; no retry is authorized.
|
benign qualification, then for any new EGL attempt; no retry is authorized.
|
||||||
|
The first benign nominal invocation refused in real-UID accounting before
|
||||||
|
ATTEMPT or scope creation. The narrowly scoped UID count correction and its
|
||||||
|
offline/read-only validation are recorded in that protocol; native admission
|
||||||
|
and parent-death cleanup remain unqualified.
|
||||||
|
|
||||||
[Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single
|
[Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single
|
||||||
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,
|
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,58 @@ benign qualification and EGL execution each need separate conversational approva
|
||||||
The historical validation section below retains its original provenance and
|
The historical validation section below retains its original provenance and
|
||||||
does not describe current implementation coverage.
|
does not describe current implementation coverage.
|
||||||
|
|
||||||
|
## First benign preflight refusal and real-UID accounting — 2026-10-17
|
||||||
|
|
||||||
|
The single nominal invocation refused with `UID process divergent` inside
|
||||||
|
`ProcFS.uid_threads()`, before ATTEMPT, scope creation or payload execution.
|
||||||
|
The two historical benign LABs remain unconsumed and must not be reused.
|
||||||
|
Native cgroup qualification has not passed; parent-death remains NOT_RUN.
|
||||||
|
|
||||||
|
Resource accounting must include every thread belonging to the requested real
|
||||||
|
UID, including legitimate mixed credentials such as `(1000, 0, 0, 0)` used by
|
||||||
|
a privileged portal helper. This diagnostic is not a graphical-service admission
|
||||||
|
scan. Sample PID/startticks, proc directory identity, all four UIDs, cgroup,
|
||||||
|
Threads and task entries before/after; unreadability, disappearance or observed
|
||||||
|
drift refuses rather than undercounting. The snapshot is not an atomic reservation.
|
||||||
|
|
||||||
|
Authentication of the supervisor, manager and bootstrap remains separate:
|
||||||
|
`ProcFS.identity()` still requires homogeneous real/effective/saved/filesystem
|
||||||
|
UIDs matching proc ownership, with strict lifetime and cgroup checks. The count
|
||||||
|
fix must not relax that predicate or introduce process-name/privilege exceptions.
|
||||||
|
Only source correction, inert regression tests, read-only diagnostics and fresh
|
||||||
|
LAB preparation are authorized here; any real benign execution requires fresh
|
||||||
|
conversational approval.
|
||||||
|
|
||||||
|
### UID count correction validation — 2026-10-17
|
||||||
|
|
||||||
|
The actual counter now uses two observations and compares the PID set, process
|
||||||
|
lifetime, proc inode/device, all four UIDs, cgroup, Threads and exact task names.
|
||||||
|
Foreign real UIDs contribute zero; their credentials and directory identity are
|
||||||
|
compared across observations. No whitelist, privilege bypass or partial total
|
||||||
|
is accepted. The strict `identity()` source is unchanged.
|
||||||
|
|
||||||
|
The offline suite passes **86 tests** (75 existing plus 11 new regressions).
|
||||||
|
The unchanged private parent-death observer passes its **14 deterministic
|
||||||
|
tests**. Two fresh 0700 LABs have distinct nonces and sealed manifests; both
|
||||||
|
isolated passive CLI plans pass via Desktop Commander on ArchASP under
|
||||||
|
app.slice, without approval flags, ATTEMPT or payload execution.
|
||||||
|
|
||||||
|
One live counter diagnostic refused on a precisely identified disappearing
|
||||||
|
proc entry (ENOENT); it sampled the legitimate mixed-UID helper and its one
|
||||||
|
thread without the historical homogeneous-UID refusal. A separate read-only
|
||||||
|
preflight refused a changed process set. Neither was repeated to obtain PASS.
|
||||||
|
These are expected fail-closed outcomes, not a measured complete UID total or
|
||||||
|
a successful real preflight. Snapshots cannot detect every transient change or
|
||||||
|
reserve task capacity. The next approved invocation can still refuse on drift.
|
||||||
|
Native admission and parent-death cleanup remain **NOT_RUN / NOT QUALIFIED**;
|
||||||
|
no cgroup runtime PASS, Mesa capacity or STEP20C readiness follows.
|
||||||
|
|
||||||
|
Bounded independent delta review is ACCEPTED; the single final audit is PASS
|
||||||
|
with no blocking finding and independently reproduces 86/86 plus 14/14 tests,
|
||||||
|
manifest/fixture hashes and historical preservation. Disposition:
|
||||||
|
**CGROUP_UID_COUNT_FIX_READY_FOR_BENIGN_APPROVAL**, source-only. Publication of
|
||||||
|
the correction does not authorize the three future native qualification commands.
|
||||||
|
|
||||||
## Evidence, guarantees and admission
|
## Evidence, guarantees and admission
|
||||||
|
|
||||||
| Route / evidence | Risk | Bound actually supplied | Admission condition / disposition |
|
| Route / evidence | Risk | Bound actually supplied | Admission condition / disposition |
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ ne prouvent aucun lancement sur le manager réel et ne valident pas le budget
|
||||||
Mesa. L'exécution bénigne et une éventuelle exécution EGL demandent chacune une
|
Mesa. L'exécution bénigne et une éventuelle exécution EGL demandent chacune une
|
||||||
nouvelle autorisation conversationnelle avec leur propre LAB et manifeste.
|
nouvelle autorisation conversationnelle avec leur propre LAB et manifeste.
|
||||||
|
|
||||||
|
Le premier essai bénin nominal du 17 octobre 2026 a refusé avant ATTEMPT,
|
||||||
|
création de scope ou payload : `UID process divergent` dans le diagnostic
|
||||||
|
`ProcFS.uid_threads()`. Les deux LABs bénins historiques restent non consommés
|
||||||
|
et ne doivent pas être réutilisés. Le comptage corrigé inclut les threads de
|
||||||
|
tous les processus dont l'UID réel correspond, même avec des UID effectif,
|
||||||
|
sauvé ou filesystem différents. Il refuse toute incohérence observée ; ce
|
||||||
|
snapshot ne réserve aucune capacité. `ProcFS.identity()` conserve ses contrôles
|
||||||
|
stricts pour le superviseur, le manager et le bootstrap. Les régressions restent
|
||||||
|
hors ligne ; cette correction n'établit aucun PASS cgroup natif.
|
||||||
|
|
||||||
`probe.c` crée seulement un event loop, un backend headless non démarré et EGL
|
`probe.c` crée seulement un event loop, un backend headless non démarré et EGL
|
||||||
via le constructeur interne pinned `wlr_egl_create_with_drm_fd(-1)`. Il interroge
|
via le constructeur interne pinned `wlr_egl_create_with_drm_fd(-1)`. Il interroge
|
||||||
**le device associé à ce display**, exige `EGL_MESA_device_software` et refuse
|
**le device associé à ce display**, exige `EGL_MESA_device_software` et refuse
|
||||||
|
|
|
||||||
|
|
@ -90,33 +90,66 @@ class ProcFS:
|
||||||
return {"pid": pid, "uid": before.st_uid, "startticks": int(fields[19]),
|
return {"pid": pid, "uid": before.st_uid, "startticks": int(fields[19]),
|
||||||
"cgroup": lines[0][3:]}
|
"cgroup": lines[0][3:]}
|
||||||
|
|
||||||
|
def _thread_snapshot(self, directory, uid):
|
||||||
|
"""Observe une durée de vie sans authentifier ses credentials mixtes."""
|
||||||
|
before = directory.stat()
|
||||||
|
status = dict(line.split(":", 1) for line in
|
||||||
|
(directory / "status").read_text().splitlines()
|
||||||
|
if ":" in line)
|
||||||
|
uids = tuple(map(int, status["Uid"].split()))
|
||||||
|
require(len(uids) == 4, "UID diagnostic incomplet")
|
||||||
|
# Foreign real UIDs contribute nothing; compare their credentials and
|
||||||
|
# directory lifetime across passes to detect a transition into this UID.
|
||||||
|
if uids[0] != uid:
|
||||||
|
return (int(directory.name), None, before.st_dev, before.st_ino,
|
||||||
|
uids, None, None, ())
|
||||||
|
raw_stat = (directory / "stat").read_text()
|
||||||
|
pid = int(raw_stat.split("(", 1)[0].strip())
|
||||||
|
fields = raw_stat.rsplit(")", 1)[1].split()
|
||||||
|
require(pid == int(directory.name), "PID divergent pendant diagnostic")
|
||||||
|
lines = (directory / "cgroup").read_text().splitlines()
|
||||||
|
require(len(lines) == 1 and lines[0].startswith("0::/"),
|
||||||
|
"cgroup v1/hybride pendant diagnostic")
|
||||||
|
threads = int(status["Threads"])
|
||||||
|
tasks = tuple(sorted(entry.name for entry in
|
||||||
|
(directory / "task").iterdir()))
|
||||||
|
require(all(name.isdecimal() for name in tasks)
|
||||||
|
and threads > 0 and threads == len(tasks),
|
||||||
|
f"PID {pid}: compte threads incohérent")
|
||||||
|
after = directory.stat()
|
||||||
|
require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino),
|
||||||
|
"proc identity drift pendant diagnostic")
|
||||||
|
return (pid, int(fields[19]), before.st_dev, before.st_ino,
|
||||||
|
uids, lines[0][3:], threads, tasks)
|
||||||
|
|
||||||
def uid_threads(self, uid):
|
def uid_threads(self, uid):
|
||||||
total = 0
|
"""Compte le real UID ; toute incertitude observée refuse le diagnostic."""
|
||||||
# Every disappearing/unreadable process refuses; a snapshot never reserves.
|
try:
|
||||||
for directory in self.root.iterdir():
|
directories = {entry.name: entry for entry in self.root.iterdir()
|
||||||
if not directory.name.isdecimal():
|
if entry.name.isdecimal()}
|
||||||
continue
|
# RLIMIT_NPROC counts real-UID threads, including privileged helpers.
|
||||||
status = dict(line.split(":", 1) for line in
|
# Authentication remains exclusively in the unchanged identity().
|
||||||
(directory / "status").read_text().splitlines()
|
before = {name: self._thread_snapshot(directory, uid)
|
||||||
if ":" in line)
|
for name, directory in directories.items()}
|
||||||
observed_uid = int(status["Uid"].split()[0])
|
after = {name: self._thread_snapshot(directory, uid)
|
||||||
if observed_uid == uid:
|
for name, directory in directories.items()}
|
||||||
before = self.identity(int(directory.name))
|
final_names = {entry.name for entry in self.root.iterdir()
|
||||||
require(before["uid"] == uid, "UID drift pendant diagnostic")
|
if entry.name.isdecimal()}
|
||||||
threads = int(status["Threads"])
|
require(final_names == set(directories),
|
||||||
tasks = list((directory / "task").iterdir())
|
"process creation/end pendant diagnostic")
|
||||||
require(threads > 0 and threads == len(tasks),
|
fields = ("pid", "startticks", "device", "inode", "UID",
|
||||||
"compte threads incohérent")
|
"cgroup", "Threads", "task")
|
||||||
after_status = dict(line.split(":", 1) for line in
|
for name, sample in before.items():
|
||||||
(directory / "status").read_text().splitlines()
|
changed = [field for field, old, new in
|
||||||
if ":" in line)
|
zip(fields, sample, after[name]) if old != new]
|
||||||
require(status["Uid"] == after_status["Uid"]
|
require(not changed,
|
||||||
and status["Threads"] == after_status["Threads"]
|
f"PID {name}: {','.join(changed)} drift pendant diagnostic")
|
||||||
and self.identity(int(directory.name)) == before,
|
total = sum(sample[6] for sample in after.values()
|
||||||
"UID/count/startticks drift pendant diagnostic")
|
if sample[4][0] == uid)
|
||||||
total += threads
|
require(total > 0, "charge UID inconnue")
|
||||||
require(total > 0, "charge UID inconnue")
|
return total
|
||||||
return total
|
except (OSError, ValueError, KeyError, IndexError) as error:
|
||||||
|
raise Refusal(f"proc diagnostic illisible/disparu: {error}") from error
|
||||||
|
|
||||||
def nproc(self):
|
def nproc(self):
|
||||||
return resource.getrlimit(resource.RLIMIT_NPROC)
|
return resource.getrlimit(resource.RLIMIT_NPROC)
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,172 @@ class FakeBarrier:
|
||||||
self.closed = True
|
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 AdmissionTests(unittest.TestCase):
|
class AdmissionTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.sentinel = patch.object(
|
self.sentinel = patch.object(
|
||||||
|
|
@ -589,15 +755,15 @@ class FilesystemTests(unittest.TestCase):
|
||||||
nonlocal reads
|
nonlocal reads
|
||||||
if path == directory / "status":
|
if path == directory / "status":
|
||||||
reads += 1
|
reads += 1
|
||||||
if reads > 2:
|
if reads > 1:
|
||||||
return status.replace("Threads:\t1", "Threads:\t2")
|
return status.replace("Threads:\t1", "Threads:\t2")
|
||||||
return original(path, *args, **kwargs)
|
return original(path, *args, **kwargs)
|
||||||
|
|
||||||
with patch.object(Path, "read_text", changed):
|
with patch.object(Path, "read_text", changed):
|
||||||
with self.assertRaisesRegex(gate.Refusal, "drift"):
|
with self.assertRaisesRegex(gate.Refusal, "threads incohérent"):
|
||||||
proc.uid_threads(UID)
|
proc.uid_threads(UID)
|
||||||
(directory / "status").unlink()
|
(directory / "status").unlink()
|
||||||
with self.assertRaises(FileNotFoundError):
|
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
|
||||||
proc.uid_threads(UID)
|
proc.uid_threads(UID)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue