hardening
hardening
¶
Process self-hardening — shrink what a leaked address space can reveal.
A process that holds secret material (a vault DB session key, an SSH private key) wants four cheap kernel-level guarantees the moment it starts, before it opens anything sensitive:
- No ptrace / no debugger attach —
prctl(PR_SET_DUMPABLE, 0)clears the dumpable flag, so another process in the same user (a compromised sibling) cannotptracethe address space and cannot read/proc/<pid>/mem. It also stops the kernel writing a core dump for this process. - No core dumps —
setrlimit(RLIMIT_CORE, 0)belt-and-braces the dumpable clear: even a SIGSEGV can't spill the heap (keys included) to a file on disk. - No swap-out —
mlockall(MCL_CURRENT | MCL_FUTURE)pins the pages into RAM so secret bytes never land in the swap file where they outlive the process. This one is best-effort: it needsCAP_IPC_LOCKor a generousRLIMIT_MEMLOCKand legitimately fails in a locked-down rootless container — a failure is reported, never raised. - No privilege gain on exec —
prctl(PR_SET_NO_NEW_PRIVS, 1)bars this process and every descendant from ever gaining privilege through a setuid/setgid bit or file capabilities onexec. A child that is compromised while shelling out (the gate service runsgit) cannot re-execa setuid helper to climb out. It also unlocks installing an unprivilegedseccompfilter, which the kernel permits only once no-new-privs is set. Unlike the dumpable clear, it never impedes a debugger attaching, so it holds even in debug mode.
harden_self applies all four to
the current process and returns a
HardeningReport of what took —
the floor every isolated child process (terok-sandbox's split supervisor
children) applies at start-up.
confine_filesystem is the
companion filesystem floor. It uses Landlock to pin the process to its
lane: read and execute the shared runtime, read and write its own data,
touch nothing else. A bug in a spawned binary then cannot read a secret
outside the lane and cannot write a payload outside the lane. The kernel
gates unprivileged Landlock on the no_new_privs that harden_self
sets, so confine_filesystem runs second.
__all__ = ['HardeningReport', 'LandlockReport', 'confine_filesystem', 'harden_self']
module-attribute
¶
HardeningReport(no_dump, no_core, memory_locked, no_new_privs)
dataclass
¶
What harden_self managed to apply.
Each field is True only when the corresponding guarantee is in
force for the current process. no_dump, no_core, and
no_new_privs are expected to succeed; memory_locked routinely
comes back False in a rootless container without CAP_IPC_LOCK
and that is not an error — the caller decides whether to log it.
LandlockReport(confined, reason, partially_confined=False)
dataclass
¶
Whether confine_filesystem took hold.
confined is True only when the complete requested policy covers
every thread in the process. An ABI 2 kernel enforces every right it
supports but cannot deny truncation; it reports partially_confined=True.
When both fields are False, the process is unchanged. reason
always fits a diagnostic log line.
harden_self(*, allow_debugger=False)
¶
Apply the process-hardening floor to the current process.
Idempotent and side-effecting: clears the dumpable flag, zeroes the
core-dump limit, and locks memory — each independently, so a failure
of one (typically mlockall for lack of privilege) still lets the
others take. Never raises; the returned
HardeningReport says what
held.
Call this as early as possible in a process that will hold secret material — before opening the credential store or binding a socket — so the sensitive bytes are only ever mapped under the guarantees.
allow_debugger leaves the dumpable flag set so a debugger, py-spy,
or strace can attach — the escape hatch for running a task in debug
mode. It trades away only the no-ptrace guarantee (no_dump reports
False); the core-dump, swap-out, and no-new-privileges guarantees
still apply (the last never impedes a debugger attaching).
Source code in src/terok_util/hardening.py
confine_filesystem(read_exec, read_write)
¶
Pin the whole process and its descendants to the given filesystem lane.
After this call the process reads and executes only under read_exec.
It creates, modifies, and removes only under read_write. A directory
grant covers the directory's whole hierarchy. A non-directory grant
covers that exact object — this permits a writable /dev/null without
a writable /dev. Landlock denies every other path, even for reading.
The kernel gates unprivileged Landlock on no_new_privs, so call
harden_self first.
Call this before starting threads on Landlock ABI 1–7. Those kernels restrict only the calling thread, so the call leaves an already-multithreaded process unchanged and reports it as unconfined. ABI 8 applies the ruleset to all threads atomically.
Best-effort and irreversible: the call never raises. A kernel or build
without Landlock changes nothing and returns confined=False. ABI 1
cannot allow cross-directory rename, so it also changes nothing rather
than break read-write lane semantics. ABI 2 receives its supported
subset and reports partially_confined=True because it cannot deny
truncation. The call skips a path that does not exist: a parent grant
covers its later creation. When it cannot grant an existing path, it
installs no ruleset. Pathname unix sockets are intentionally outside
this filesystem policy.