fix: run privileged commands on the exec channel, never typed into the shell #25

Merged
christianmanivong merged 2 commits from fix/sudo-not-in-command-1039 into master 2026-10-12 21:00:59 +00:00
Owner

Fixes the napalm-linux half of NetOrk/netork#1039. The netOrk half and #1074 come in a separate NetOrk PR.

_sudo typed echo '<password>' | sudo -S -p "" <command> into netmiko's interactive shell. Two consequences:

  • bash kept the line in its history, so every poll wrote the sudo password into the login user's ~/.bash_history (121 lines on one host);
  • while the command ran, the password was in the process list for every local user.

Change

  • _sudo, _sudo_status, and _run_privileged with a password now run on an exec channel, like run_command(privileged=True):
    • the password reaches sudo -S on stdin, and there is no PTY to echo it;
    • the script starts with exec </dev/null, so a non-prompting sudo leaves the password unread.
  • The whole command runs as root in one sh -c. Before, only the part up to the first ;, && or | did, so e.g. mv … && chown root:root failed its chown.
  • The exit status comes from the channel; paramiko's -1 maps to None ("unknown").
  • The interactive session switches its history off before its first command: unset HISTFILE; set +o history.
  • get_vpn_tunnels with a password reads wg show all dump from the exec channel. The temp file that briefly held the private keys is no longer used.
  • Without a sudo password nothing changes: sudo -n still goes through the PTY, and it carries no secret.

Callers checked: every _sudo command is POSIX sh (no bashisms). napalm-openmediavault and napalm-qnap-qts inherit the change and pass their suites against it.

Tests: new tests/test_sudo_off_the_command_line.py (19 tests), which checks that the history-off line comes first and uses a fake channel that refuses any line containing the password. Full suite: 160 passed.

Root writes no longer touch a fixed /tmp path (second commit). With the whole line running as root, mv /tmp/netork_snmpd.conf … && chown root:root … && chmod 644 … would have let a local user plant a symlink at the fixed path and make root's chown and chmod follow it, e.g. to /etc/shadow. The same applied to 00proxy.

  • _install_file_as_root stages the content unprivileged in a fresh mktemp file (umask 077) and checks the path it gets back.
  • Root then runs install -m 644 -o root -g root into a new file and mvs it over the target. No chown/chmod runs, nothing follows a symlink, and nothing runs as root when staging failed.
  • get_vpn_tunnels always reads from the exec channel. The predictable /tmp/.netork_wg_<hash> file is gone.
  • New tests/test_staged_root_writes.py (14 tests, including planted symlinks at the old paths). Full suite: 174 passed; OMV and QNAP suites pass against it.
Fixes the napalm-linux half of NetOrk/netork#1039. The netOrk half and #1074 come in a separate NetOrk PR. `_sudo` typed `echo '<password>' | sudo -S -p "" <command>` into netmiko's interactive shell. Two consequences: - bash kept the line in its history, so every poll wrote the sudo password into the login user's `~/.bash_history` (121 lines on one host); - while the command ran, the password was in the process list for every local user. **Change** - `_sudo`, `_sudo_status`, and `_run_privileged` with a password now run on an exec channel, like `run_command(privileged=True)`: - the password reaches `sudo -S` on stdin, and there is no PTY to echo it; - the script starts with `exec </dev/null`, so a non-prompting sudo leaves the password unread. - The whole command runs as root in one `sh -c`. Before, only the part up to the first `;`, `&&` or `|` did, so e.g. `mv … && chown root:root` failed its chown. - The exit status comes from the channel; paramiko's -1 maps to `None` ("unknown"). - The interactive session switches its history off before its first command: ` unset HISTFILE; set +o history`. - `get_vpn_tunnels` with a password reads `wg show all dump` from the exec channel. The temp file that briefly held the private keys is no longer used. - Without a sudo password nothing changes: `sudo -n` still goes through the PTY, and it carries no secret. **Callers checked:** every `_sudo` command is POSIX `sh` (no bashisms). napalm-openmediavault and napalm-qnap-qts inherit the change and pass their suites against it. **Tests:** new `tests/test_sudo_off_the_command_line.py` (19 tests), which checks that the history-off line comes first and uses a fake channel that refuses any line containing the password. Full suite: 160 passed. **Root writes no longer touch a fixed /tmp path** (second commit). With the whole line running as root, `mv /tmp/netork_snmpd.conf … && chown root:root … && chmod 644 …` would have let a local user plant a symlink at the fixed path and make root's chown and chmod follow it, e.g. to `/etc/shadow`. The same applied to 00proxy. - `_install_file_as_root` stages the content unprivileged in a fresh `mktemp` file (`umask 077`) and checks the path it gets back. - Root then runs `install -m 644 -o root -g root` into a new file and `mv`s it over the target. No `chown`/`chmod` runs, nothing follows a symlink, and nothing runs as root when staging failed. - `get_vpn_tunnels` always reads from the exec channel. The predictable `/tmp/.netork_wg_<hash>` file is gone. - New `tests/test_staged_root_writes.py` (14 tests, including planted symlinks at the old paths). Full suite: 174 passed; OMV and QNAP suites pass against it.
christianmanivong added 1 commit 2026-10-12 20:51:20 +00:00
fix: run privileged commands on the exec channel, never typed into the shell
CI / test (3.10) (push) Successful in 56s
CI / test (3.11) (push) Successful in 50s
CI / test (3.12) (push) Successful in 44s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 44s
CI / test (3.12) (pull_request) Successful in 53s
cf8ee38a90
_sudo typed `echo '<password>' | sudo -S -p "" <command>` into netmiko's
interactive shell. bash kept it in its history, so every poll wrote the sudo
password into the login user's ~/.bash_history, and while the command ran it
was in the process list for every local user (NetOrk/netork#1039).

_sudo, _sudo_status and _run_privileged with a password now run on an exec
channel like run_command(privileged=True): the password reaches sudo -S on
stdin, there is no PTY to echo it, and the script starts with
`exec </dev/null` so a non-prompting sudo leaves it unread. The whole
command runs as root in one sh -c - before, only the part up to the first
`;`, `&&` or `|` did. The exit status comes from the channel.

The interactive session switches its history off before its first command.
get_vpn_tunnels with a password reads `wg show all dump` from the exec
channel instead of a temp file that held the private keys.
christianmanivong added 1 commit 2026-10-12 20:56:07 +00:00
fix: stage root writes in a mktemp file, never at a fixed /tmp path
CI / test (3.10) (push) Successful in 46s
CI / test (3.11) (push) Successful in 39s
CI / test (3.12) (push) Successful in 53s
CI / test (3.10) (pull_request) Successful in 44s
CI / test (3.11) (pull_request) Successful in 47s
CI / test (3.12) (pull_request) Successful in 47s
108f6fb3ca
Now that a whole _sudo line runs as root, `mv /tmp/netork_snmpd.conf
/etc/snmp/snmpd.conf && chown root:root … && chmod 644 …` would have let a
local user plant a symlink at the fixed /tmp path and have root's chown and
chmod follow it (e.g. to /etc/shadow). The same held for 00proxy.

_install_file_as_root stages the content unprivileged in a fresh mktemp file
(umask 077), checks the path it got back, and root installs it as a new
regular file next to the target and renames it over: nothing root runs
follows a symlink or changes the owner or mode of an existing path. Nothing
runs as root when staging failed.

get_vpn_tunnels always reads `wg show all dump` from the exec channel; the
predictable /tmp/.netork_wg_<hash> file holding the private keys is gone.
christianmanivong merged commit 81b2df9d71 into master 2026-10-12 21:00:59 +00:00
christianmanivong deleted branch fix/sudo-not-in-command-1039 2026-10-12 21:00:59 +00:00
Sign in to join this conversation.