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

_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.
This commit is contained in:
Christian Manivong committed 2026-10-12 22:51:07 +02:00
1 parent 7b37366a44
commit cf8ee38a90
5 files changed
+440 -51

No files matched your search

+63 -27
View File
@@ -69,8 +69,8 @@ logger = logging.getLogger("napalm_linux")
# Package managers in detection order
_PKG_MANAGERS = ["apt", "dnf", "yum", "apk", "pacman"]
#: Printed after a command by ``_sudo_status`` so its exit status survives the
#: trip through an interactive shell. Matched only on a line of its own with a
#: Printed after a command run through the interactive shell (``_run_privileged``
#: callers) so its exit status survives the trip. Matched only on a line of its own with a
#: number after it — an echoed command line carries the literal ``$?`` instead.
_RC_MARKER = "__NETORK_RC="
_RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
@@ -142,6 +142,16 @@ _PRIVILEGED_STDIN = (
"mktemp, cat) and have root install that file."
)
#: Sent before anything else in the interactive session (NetOrk/netork#1039):
#: the shell writes no history file and records nothing more. The leading space
#: keeps this line itself out of a history under ``HISTCONTROL=ignorespace``;
#: a shell without ``set -o history`` (dash) only complains, to /dev/null.
_HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true"
#: First in every script ``_sudo`` runs: a sudo that does not prompt (NOPASSWD,
#: a cached timestamp) leaves the password on stdin, and nothing may read it.
_NO_STDIN = "exec </dev/null; "
#: What to do when sudo wants a password netOrk does not have.
_SUDO_PASSWORD_HINT = (
"sudo requires a password on this device but none is configured in netOrk. "
@@ -307,6 +317,12 @@ class LinuxDriver(
except NetmikoTimeoutException as exc:
raise ConnectionException(str(exc)) from exc
# No history before anything else is typed (NetOrk/netork#1039).
try:
self._device.send_command(_HISTORY_OFF, expect_string=_PROMPT_RE)
except Exception:
logger.debug("Could not switch the shell history off on %s", self.hostname, exc_info=True)
# Prevent PTY from wrapping long output lines (e.g. docker JSON).
try:
self._device.send_command("stty cols 10000 2>/dev/null || true", expect_string=r"[#$>]\s*$")
@@ -350,29 +366,38 @@ class LinuxDriver(
).strip()
def _sudo(self, command: str, read_timeout: float = 100) -> str:
"""Run *command* via sudo, feeding the password via stdin (-S).
"""Run *command* as root and return its output, stdout and stderr together.
Falls back to plain execution when no sudo password is configured.
It runs on an exec channel, not in netmiko's interactive shell
(NetOrk/netork#1039): that shell keeps a history, and ``echo <password> |
sudo -S`` typed into it wrote the password into ``~/.bash_history`` on
every poll, and into the process list while it ran. On the exec channel
the password reaches ``sudo -S`` on stdin (``_as_root``), and there is no
PTY to echo it back into the output.
The whole of *command* runs as root in one ``sh -c``, ``;`` and ``&&``
included. Without a sudo password ``sudo -n`` fails at once; a root
login runs it directly.
"""
if self._sudo_password:
wrapped = f'echo {_shlex_quote(self._sudo_password)} | sudo -S -p "" {command}'
return self._send(wrapped, read_timeout=read_timeout)
return self._send(f'sudo {command}', read_timeout=read_timeout)
return self._sudo_result(command, read_timeout).stdout.strip()
def _sudo_result(self, command: str, timeout: float) -> CommandResult:
line, prefix = self._as_root(f"{_NO_STDIN}{command}")
return run_on_transport(self._transport(), f"{line} 2>&1", stdin=prefix, timeout=timeout)
def _sudo_status(self, command: str, read_timeout: float = 100) -> tuple[str, int | None]:
"""Run *command* via sudo and return ``(output, exit_status)``.
"""Run *command* as root and return ``(output, exit_status)``.
``_sudo`` callers append ``|| true`` so a failing command yields output
instead of an error, which throws the exit status away. This variant
echoes ``$?`` straight after the sudo pipeline instead — sudo passes
the command's status through, and a failed password is non-zero too.
instead of an error, which throws the exit status away. The exec channel
reports the status itself: the command's, or sudo's when it refused.
The status is ``None`` when the marker never arrived (output cut short),
so a caller can tell "unknown" from "succeeded".
The status is ``None`` when the channel closed without one (paramiko's
-1), so a caller can tell "unknown" from "succeeded".
"""
return _split_status(
self._sudo(f"{command}; echo {_RC_MARKER}$?", read_timeout=read_timeout)
)
result = self._sudo_result(command, read_timeout)
status = result.exit_code if result.exit_code >= 0 else None
return result.stdout.strip(), status
def _is_root(self) -> bool:
"""Whether the SSH user is root, asked once per session.
@@ -401,7 +426,7 @@ class LinuxDriver(
if self._is_root():
return self._send(command, read_timeout=timeout)
if self._sudo_password:
return self._sudo(command, read_timeout=timeout)
return self._sudo(command, read_timeout=timeout) # never typed into the shell
return self._send(f"sudo -n {command}", read_timeout=timeout)
# ------------------------------------------------------------------
@@ -425,10 +450,20 @@ class LinuxDriver(
"""
if not privileged or self._is_root():
return command, None
return self._as_root(command)
def _as_root(self, command: str) -> tuple[str, bytes | None]:
"""The ``sh -c`` line that runs *command* as root, and its stdin prefix.
Root runs it directly; with a sudo password, ``sudo -S`` reads it from
stdin; without one, ``sudo -n`` fails at once instead of prompting.
"""
script = _shlex_quote(command)
if self._is_root():
return f"sh -c {script}", None
if self._sudo_password:
line = f"sudo -S -p '' sh -c {_shlex_quote(command)}"
return line, f"{self._sudo_password}\n".encode()
return f"sudo -n sh -c {_shlex_quote(command)}", None
return f"sudo -S -p '' sh -c {script}", f"{self._sudo_password}\n".encode()
return f"sudo -n sh -c {script}", None
def run_command(
self,
@@ -2075,15 +2110,16 @@ class LinuxDriver(
tunnels: dict[str, Any] = {}
# ── Attempt 1: wg show all dump via sudo ─────────────────────────────
# Write output to a fixed temp file to preserve literal tab characters.
# PTY output processing expands tabs to spaces, breaking split("\t").
import hashlib as _hashlib
_tmp = "/tmp/.netork_wg_" + _hashlib.md5(self.hostname.encode()).hexdigest()[:8]
# With a sudo password it runs on the exec channel, which keeps the
# tabs. The PTY expands them to spaces, breaking split("\t"), so
# without one the output goes through a temp file.
if self._sudo_password:
self._sudo(f"wg show all dump > {_tmp} 2>&1 || true")
raw = self._sudo("wg show all dump 2>&1 || true")
else:
import hashlib as _hashlib
_tmp = "/tmp/.netork_wg_" + _hashlib.md5(self.hostname.encode()).hexdigest()[:8]
self._send(f"sudo -n wg show all dump > {_tmp} 2>&1 || true")
raw = self._send(f"cat {_tmp} 2>/dev/null || true; rm -f {_tmp}").strip()
raw = self._send(f"cat {_tmp} 2>/dev/null || true; rm -f {_tmp}").strip()
_perm_errors = (
"operation not permitted", "permission denied",