diff --git a/grapharc/harness/executor.py b/grapharc/harness/executor.py index 2c5a67d..eb107ef 100644 --- a/grapharc/harness/executor.py +++ b/grapharc/harness/executor.py @@ -383,6 +383,84 @@ def fork_exec(*args: Any, **kwargs: Any) -> Any: module.fork_exec = fork_exec +#: Every stdlib function that accepts a directory-descriptor keyword uses one +#: of exactly three spellings (`dir_fd`, `src_dir_fd`, `dst_dir_fd`), so +#: `_install_dir_fd_guard` below checks all three by name rather than needing +#: a table of which function takes which — a table would go stale the way the +#: `fork_exec` arity above already did once. `readlink` is excluded: it has +#: its own dedicated guard above, for a different reason (its *result*, not +#: just its argument, has to be resolved and checked). +_DIR_FD_MUTATORS = ( + "chmod", + "chown", + "link", + "mkdir", + "mkfifo", + "mknod", + "open", + "remove", + "rename", + "replace", + "rmdir", + "symlink", + "unlink", + "utime", +) + + +def _install_dir_fd_guard() -> None: + """Refuse `dir_fd=`/`src_dir_fd=`/`dst_dir_fd=` on every mutating call that + accepts one — a real, verified escape from the workspace grant. + + `check_path` resolves a call's path argument against the *process's cwd*, + pinned to the workspace by the `os.chdir` below. A directory-descriptor + keyword makes the underlying syscall resolve that same string against an + open file descriptor instead; cwd never enters into it. A tool legitimately + reading a directory inside the read grant — site-packages, say — gets a + descriptor for it, and `os.open("evil.pth", O_CREAT | O_WRONLY, + dir_fd=that_fd)` then writes there while `check_path` is validating a path + that has nothing to do with where the write actually lands. Confirmed by + running it end to end: the planted file executes on the next interpreter + start in that environment — the exact site-packages `.pth`-drop escape the + read/write grant split exists to close (see the module docstring above), + reopened through a syscall shape the audit-event path check never + considered a path argument for at all. + + Like the `os.readlink` guard, this refuses the capability outright rather + than trying to resolve and check it: there is no reliable way to turn a + directory descriptor back into the path it names, so there is nothing to + validate against the grant. It is a wrapper, not an audit hook, and carries + the same documented caveat for the same reason — the original stays + reachable through the closure, so it closes the accident, not a + determined adversary already unwrapping functions from inside. + """ + posix = sys.modules.get("posix") or sys.modules.get("nt") + + def make_wrapper(original: Any, name: str) -> Any: + def wrapper(*args: Any, **kwargs: Any) -> Any: + for kw in ("dir_fd", "src_dir_fd", "dst_dir_fd"): + if kwargs.get(kw) is not None: + raise SandboxViolation( + f"os.{name} through a directory descriptor ({kw}) resolves " + "outside the process's cwd and cannot be checked against the " + "workspace grant, so it is refused" + ) + return original(*args, **kwargs) + + wrapper.__name__ = name + wrapper.__qualname__ = name + return wrapper + + for name in _DIR_FD_MUTATORS: + original = getattr(os, name, None) + if original is None: # pragma: no cover - platform-dependent (mkfifo/mknod on Windows) + continue + wrapped = make_wrapper(original, name) + setattr(os, name, wrapped) + if posix is not None and hasattr(posix, name): + setattr(posix, name, wrapped) + + def _is_sqlite_uri(database: Any) -> bool: """sqlite re-reads a `file:` name itself — percent-decoding it and honouring an authority and query string — so `realpath` does not name the file that @@ -531,6 +609,7 @@ def hook(event: str, hook_args: tuple[Any, ...]) -> None: pass _install_readlink_guard(check_read) _install_fork_exec_guard(spec.name) + _install_dir_fd_guard() sys.addaudithook(hook) try: result = spec.fn(**args) diff --git a/tests/test_harness_gate.py b/tests/test_harness_gate.py index d0dcf7f..c502d49 100644 --- a/tests/test_harness_gate.py +++ b/tests/test_harness_gate.py @@ -737,6 +737,69 @@ def unplant(target: str) -> str: assert os.path.exists(site_packages_probe), "a site-packages file was deleted from the sandbox" +@_posix +@pytest.mark.parametrize( + "op", + ["os.open", "os.mkdir", "os.remove", "os.rename"], +) +def test_gate_dot_pth_cannot_be_planted_via_dir_fd(tmp_path, op, site_packages_probe): + """The same escape as `test_gate_dot_pth_cannot_be_planted_in_site_packages`, + reached through `dir_fd=` instead of a plain path. + + `check_path` resolves the path argument of a call against the process's + cwd (pinned to the workspace). Every one of these calls also accepts a + directory-descriptor keyword that makes the real syscall resolve the same + string against an *open file descriptor* instead — cwd never enters into + it. A tool legitimately opens site-packages for reading (it is in the read + grant, or the import machinery could not work), gets a descriptor for it, + and then `os.open("evil.pth", O_CREAT | O_WRONLY, dir_fd=that_fd)` writes + there while `check_path` validates a path with nothing to do with where + the write actually lands. Verified end to end before this test existed: + the planted `.pth` ran on the very next interpreter start in that + environment — this is not a theoretical gap. + """ + workspace = tmp_path / "ws" + workspace.mkdir() + pth_name = f"grapharc_pwned_dirfd_{uuid.uuid4().hex}.pth" + + def plant_via_dir_fd(operation: str, name: str, victim: str) -> str: + import os as _os + + dfd = _os.open(_SITE_PACKAGES, _os.O_RDONLY) + try: + if operation == "os.open": + fd = _os.open(name, _os.O_CREAT | _os.O_WRONLY, dir_fd=dfd) + _os.close(fd) + elif operation == "os.mkdir": + _os.mkdir(name, dir_fd=dfd) + elif operation == "os.remove": + _os.remove(_os.path.basename(victim), dir_fd=dfd) + elif operation == "os.rename": + _os.rename( + _os.path.basename(victim), name, src_dir_fd=dfd, dst_dir_fd=dfd + ) + return "escaped" + finally: + _os.close(dfd) + + harness = _sandbox(workspace, plant_via_dir_fd=plant_via_dir_fd) + try: + with pytest.raises(SandboxViolation, match="directory descriptor"): + harness.call( + "plant_via_dir_fd", + {"operation": op, "name": pth_name, "victim": site_packages_probe}, + ) + assert not os.path.exists( + os.path.join(_SITE_PACKAGES, pth_name) + ), "a file was planted in site-packages via dir_fd" + if op in ("os.remove", "os.rename"): + assert os.path.exists( + site_packages_probe + ), "a site-packages file was removed/renamed via dir_fd" + finally: + _scrub("grapharc_pwned_dirfd_") # a regression really does leave one behind + + @_posix @pytest.mark.parametrize( "op",