Skip to content

Latest commit

 

History

History
298 lines (259 loc) · 14.2 KB

README.md

File metadata and controls

298 lines (259 loc) · 14.2 KB

rootsh (Python rewrite)

A from-scratch Python rewrite of rootsh -- a pty-wrapping shell logger, originally written in C in 2004 and last touched around 2013 -- written in 2026 by Zareh Kasparian, 13 years after development had stopped (see ../AUTHORS). This directory is a companion to, not a replacement of, the original C source at the repository root; see the root README for the original project's history.

Linux only. Requires Python 3.11+; the installer needs nothing beyond that and Bash.

Install

./install.sh

Run with no flags, this is an interactive wizard: it asks whether to install system-wide (/etc/rootsh, /var/log/rootsh, /usr/local/bin -- needs root, and is the real deployment mode, since rootsh is meant to run via sudo against a shared, root-owned audit trail) or per-user (under your home directory, for trying it out), then walks through log locations, syslog settings, an initial reviewer list, and an optional /etc/sudoers.d/rootsh drop-in. It creates a virtualenv, installs the two commands, writes the config file from your answers, and runs a smoke test at the end.

sudo ./install.sh                                   # system-wide, full wizard
./install.sh --mode=user                             # try it out under $HOME
sudo ./install.sh --yes --reviewers=alice,bob         # unattended, defaults + flags
sudo ./install.sh --uninstall                          # remove commands + venv

Every prompt has a --flag equivalent (./install.sh --help lists them all), and --yes accepts the default -- or whatever a flag supplied -- for anything it would otherwise ask, so the same script works unattended in a provisioning pipeline. Uninstalling removes the installed commands and virtualenv only; the config file, reviewer list, and session/review logs are audit data and are left in place.

Prefer to do it by hand? See Manual install below.

Usage

Flags on rootsh mirror the original: -i/--login, -u/--user, -f/--logfile, -d/--logdir (standalone only -- deliberately ignored under sudo so an escalated user can't redirect their own audit trail, a security property carried over from the original), -x/--no-logfile, -y/--no-syslog, plus --config (new) and a trailing command exactly like the original's -c/bare-args handling.

sudo rootsh                          # a logged root shell
sudo rootsh -u oracle                # a logged shell as user oracle
rootsh -f mysession.log --no-syslog  # log your own session to a file, standalone

Reviewing a session log (only for usernames listed in /etc/rootsh/reviewers):

rootsh-review --list
rootsh-review alice.20260101120000.01234.closed

That replay is itself fully re-recorded to its own tagged log under the configured review-log directory -- see What changed vs. the original for why.

Deployment: logging every command without anyone typing rootsh

Everything above requires a user to explicitly run sudo rootsh (or rootsh standalone). If you instead want every command a user runs -- every login, every ssh user@host command -- captured automatically, with the user never typing rootsh at all, set rootsh as their login shell. This is the same mechanism the original C rootsh supported (setupshell()'s self-detection): when login/sshd exec a user whose /etc/passwd shell is rootsh, rootsh notices it would otherwise be about to exec itself as its own child -- which would recurse forever, forking a new process and opening a new log file at every level until the machine falls over -- and transparently substitutes shell.default_shell from the config instead, while still logging the whole session as normal.

This changes how a user logs in, system-wide, immediately. A mistake here (wrong path, a typo) can leave that user with no working shell at all -- including over SSH, where there's no easy way to override it externally. Test on one non-critical account first, and keep a second, already-authenticated root session open in another window while you do, so you have a way back in if something's wrong.

Use sudo ./install.sh --assign-login-shell=someuser for this, don't just chsh/usermod by hand. Set as a login shell, rootsh runs at that user's own uid -- login(1)/sshd already dropped privilege before exec'ing it -- so it can't write into /var/log/rootsh (root-owned, mode 0700) on its own. install.sh builds and capability-enables a small privileged helper (helper/rootsh-logopen.c, see its header comment for exactly what it does and why) that bridges that one gap, wires its path into rootsh.toml's logging.privileged_helper, and only then runs a pre-flight check (does a real unprivileged session actually open its log file?) before touching the user's shell, plus a post-flight check through the real login path after, with automatic revert if either one fails. Doing it by hand with chsh/usermod skips all of that -- if you do, the account will look like this walkthrough describes, but every login will crash immediately with a PermissionError opening the log file until you separately build and setcap the helper yourself and add its path to the config.

# 1. rootsh must be listed in /etc/shells -- install.sh does this for
#    you automatically in system mode. To do it by hand:
echo /usr/local/bin/rootsh | sudo tee -a /etc/shells

# 2. make sure shell.default_shell in rootsh.toml is the shell you
#    actually want wrapped users to get (it applies to every
#    login-shell-wrapped user -- there's currently no per-user
#    override). Check the target user's current shell first:
getent passwd someuser   # note the shell field, e.g. /bin/bash

# 3. the guided, safety-netted way (recommended):
sudo ./install.sh --assign-login-shell=someuser

#    or by hand, if you really want to (see the warning above --
#    you're also on the hook for building+setcapping the helper and
#    setting privileged_helper in rootsh.toml yourself):
sudo chsh -s /usr/local/bin/rootsh someuser
# or: sudo usermod -s /usr/local/bin/rootsh someuser

# 4. verify it actually works BEFORE closing your other session:
ssh someuser@thishost 'whoami; echo still alive'
ls -la /var/log/rootsh/   # should show a new someuser.* session

# to revert:
sudo chsh -s /bin/bash someuser   # or whatever their shell was

The helper is compiled with the log directory's path baked in (-DROOTSH_LOG_DIR, from [logging] dir at install time) and carries only cap_dac_override -- not setuid, not root, just enough to bypass that one directory's permission check. It refuses to open or finalize any session-log name that doesn't start with the calling process's own username (checked via getuid(), never anything the caller supplies), so even though it's installed world-executable, one local user can never touch another's session log through it.

install.sh does not automate step 3 (assigning it to specific users) -- that's a deliberate choice given the blast radius. It does handle step 1 for you in system mode.

Manual install

If you'd rather not run install.sh:

python3 -m venv /opt/rootsh/venv
/opt/rootsh/venv/bin/pip install .
ln -s /opt/rootsh/venv/bin/rootsh /usr/local/bin/rootsh
ln -s /opt/rootsh/venv/bin/rootsh-review /usr/local/bin/rootsh-review

Then create /etc/rootsh/rootsh.toml (see etc/rootsh.toml for every field and its default) and /etc/rootsh/reviewers (see etc/reviewers). Note that a bare symlink only works if you keep the config at the compiled-in default path (/etc/rootsh/rootsh.toml) or always set $ROOTSH_CONFIG yourself -- install.sh avoids that trap by generating a small wrapper script that pins ROOTSH_CONFIG to wherever it actually wrote the config, which is why it's the recommended path for anything other than the default system layout.

What changed vs. the original

  • Plain text logs by default. The original's local logfile was the raw pty byte stream, ANSI escape codes and all -- only its syslog path stripped them. Here both destinations go through the same escape-stripping, line-splitting transcript writer (rootsh/transcript.py), so the local file is an actually human-readable, greppable text file.
  • A reviewer/audit workflow that didn't exist before. rootsh-review lets specific users (/etc/rootsh/reviewers) replay session logs, and fully re-records that review session to its own tagged log -- so "who reviewed what, when, and what they did while reviewing" is itself an auditable record, not a blind spot. rootsh recognizes when it's specifically launching rootsh-review for an authorized reviewer and skips its own content logging for that one invocation, so the same content isn't captured twice. This is the Python equivalent of the sudoers trick the original maintainer adopted instead of continuing rootsh's development (see the root README):
    %admin ALL=(ALL) LOG_OUTPUT:LOG_INPUT: ALL
    %admin ALL=(ALL) NOLOG_OUTPUT:NOLOG_INPUT: /usr/bin/sudoreplay
    
  • Content-based tamper evidence. The original compared the logfile's inode/device at close time against what it recorded at open time (rootsh.c: beginlogging/endlogging) -- which only catches the file being replaced, not edited in place. Here, rootsh/integrity.py maintains a SHA-256 hash chain over every transcript line as it's written, checkpoints it to syslog periodically, and writes the final digest to a .sha256 sidecar file when the session closes -- so editing any earlier line is detectable (verify_file() recomputes the chain and compares).
  • ~4,100 lines of C collapsed to a small stdlib-only package. os.forkpty()/tty/termios absorb what the original hand-rolled: its own forkpty() BSD emulation with SysV STREAMS ioctl(I_PUSH "ptem"/"ldterm"/"ttcompat") fallbacks for old Solaris/HP-UX/AIX, its own getopt/getopt1/gnugetopt.h, its own getusershell.c and basename.c. None of that exists here -- this rewrite is Linux-only by design (see the questions this was scoped against).
  • Two bugs found in the original during the port, fixed here, not reproduced:
    • execShell()'s login-shell argv[0] construction did strdup(shell) then replaced the first / character with -, turning /bin/bash into -bin/bash instead of -bash. Harmless in practice (bash only checks that argv[0] starts with -), but not what a login shell's process name is supposed to look like. Fixed in cli.py:build_command().
    • The runAsUser && useLoginShell branches of execShell() passed the literal string "-" as argv[0] instead of the intended dash-prepended shell name (dashShell), a dead variable in those branches. Not reproduced here.
    • --with-maxlogfilesize/MAXLOGFILESIZE was defined by configure.in but never referenced anywhere in rootsh.c -- configured, documented nowhere, dead. Not carried forward; no max-size enforcement exists in either version.
  • A bug of this rewrite's own, found and fixed post-release: cli.py originally attributed every session to pwd.getpwuid(os.geteuid()).pw_name -- the process's effective identity. sudo switches both real and effective uid to the target identity (root, by default) before rootsh even starts, so under plain sudo rootsh that lookup returns root for every user, regardless of who actually ran it: every log filename collapsed to root.*, the "session opened for" message always said root, and -- more seriously -- the reviewers-file authorization check in _delegates_to_review() was keying off root too. review_cli.py already got this right ($SUDO_USER or ...); cli.py didn't share that logic. Fixed by pulling identity resolution into rootsh/identity.py, used by both entry points now: invoking_user() (prefers $SUDO_USER, for attribution/authorization) vs. effective_identity() (the actual process uid, for "who the shell runs as" -- still correctly root for a plain session).

Layout

install.sh          the installer wizard
src/rootsh/
  cli.py             rootsh entry point (argparse; forkpty + logging wiring)
  review_cli.py      rootsh-review entry point
  session.py         PtySession: the forkpty()/select() relay loop
  transcript.py      ANSI-stripping, line-splitting, timestamped text writer
  integrity.py       HashChain tamper-evidence
  syslogsink.py       syslog forwarding
  identity.py          invoking user vs. effective identity (see above)
  reviewers.py           /etc/rootsh/reviewers loader
  config.py                TOML config loader
etc/
  rootsh.toml         example config (install.sh writes its own from your answers)
  reviewers            example reviewer list (ditto)
tests/                 pytest suite (31 tests: transcript, integrity, reviewers,
                        identity, config, and real forkpty-based session smoke tests)

Development

python3 -m venv .venv
.venv/bin/pip install -e . pytest
.venv/bin/pytest -q

install.sh --editable does the equivalent for a real install (source stays live-editable instead of being copied into the venv).

Known limitations (by design, carried over from the C version, or

inherent to the approach)

  • No purely local design defeats a determined root user. Someone root inside the recorded shell can kill -9 the recorder or edit files on disk. The syslog checkpoint forwarding exists specifically to get evidence off-box in near-real time; it's only as strong as wherever syslog is configured to ship those messages (see the original README's remote-syslog suggestion -- unchanged here, that's a syslog.conf/journald concern, not this tool's).
  • The rootsh->rootsh-review delegation only covers directly launching rootsh-review as the command (rootsh -u reviewer -c 'rootsh-review ...'), not someone typing rootsh-review by hand inside an otherwise-logged interactive shell. This matches the scope of the sudoers precedent it's modeled on.
  • Not a full terminal emulator. Escape-sequence stripping is regex-based (same order of operations as the original's write2syslog.c: split into lines first, then strip escapes per line), not a real VT100 state machine. Good enough for a readable audit trail; don't expect e.g. vim full-screen redraws to produce a sensible line-oriented log -- no design in this space does.