From 5bcfb496c2f05fb477e0b4221c18e65c67db69dd Mon Sep 17 00:00:00 2001 From: ckunki Date: Tue, 5 Mar 2024 11:23:14 +0100 Subject: [PATCH] Fixed non-root-user access --- doc/changes/changes_1.1.0.md | 2 +- .../ds/sandbox/lib/dss_docker/create_image.py | 4 +- .../runtime/ansible/general_setup_tasks.yml | 1 + .../roles/entrypoint/files/entrypoint.py | 65 +++++++++++++++++-- test/unit/entrypoint/test_main.py | 35 ++++++++-- .../entrypoint/test_start_jupyter_server.py | 1 + test/unit/entrypoint/test_user_class.py | 44 +++++++++++++ test/unit/test_dss_docker_image.py | 3 + 8 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 test/unit/entrypoint/test_user_class.py diff --git a/doc/changes/changes_1.1.0.md b/doc/changes/changes_1.1.0.md index 0557f20b..c8fefa70 100644 --- a/doc/changes/changes_1.1.0.md +++ b/doc/changes/changes_1.1.0.md @@ -22,7 +22,7 @@ n/a ## Bug Fixes -n/a +* #241: Fixed non-root-user access ## Documentation diff --git a/exasol/ds/sandbox/lib/dss_docker/create_image.py b/exasol/ds/sandbox/lib/dss_docker/create_image.py index 2b5d2cd8..d4cecb41 100644 --- a/exasol/ds/sandbox/lib/dss_docker/create_image.py +++ b/exasol/ds/sandbox/lib/dss_docker/create_image.py @@ -42,9 +42,11 @@ def jupyter(): return [] port = get_fact(facts, "jupyter", "port") user_name = get_fact(facts, "jupyter", "user") + user_home = get_fact(facts, "jupyter", "home") password = get_fact(facts, "jupyter", "password") logfile = get_fact(facts, "jupyter", "logfile") return [ + "--home", user_home, "--jupyter-server", command, "--port", port, "--user", user_name, @@ -162,7 +164,7 @@ def _commit_container( notebook_folder_initial = get_fact(facts, "notebook_folder", "initial") conf = { "Entrypoint": entrypoint(facts), - "User": get_fact(facts, "jupyter", "user"), + # "User": get_fact(facts, "jupyter", "user"), "Cmd": [], "Volumes": {notebook_folder_final: {}, }, "ExposedPorts": {f"{port}/tcp": {}}, diff --git a/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml b/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml index 88380567..1892c84e 100644 --- a/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml +++ b/exasol/ds/sandbox/runtime/ansible/general_setup_tasks.yml @@ -9,6 +9,7 @@ command: "{{ jupyter_virtualenv }}/bin/jupyter-lab" port: "49494" user: "{{ user_name }}" + home: "{{ user_home }}" password: "{{ lookup('ansible.builtin.env', 'JUPYTER_LAB_PASSWORD', default='ailab') }}" logfile: "{{ user_home }}/jupyter-server.log" notebook_folder: diff --git a/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py b/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py index 96873c8b..0a31422d 100644 --- a/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py +++ b/exasol/ds/sandbox/runtime/ansible/roles/entrypoint/files/entrypoint.py @@ -1,6 +1,7 @@ import argparse import logging import os +import pwd import re import resource import shutil @@ -10,6 +11,7 @@ from inspect import cleandoc from pathlib import Path +from typing import List, TextIO _logger = logging.getLogger(__name__) @@ -39,6 +41,10 @@ def arg_parser(): "--user", type=str, help="user name for running jupyter server", ) + parser.add_argument( + "--home", type=str, + help="home directory of user running jupyter server", + ) parser.add_argument( "--password", type=str, help="initial default password for Jupyter server", @@ -54,7 +60,20 @@ def arg_parser(): return parser +def start_subprocess_as_user( + command_line: List[str], + logfile: str, + home_directory: str, +) -> subprocess.Popen: + env = os.environ.copy() + env["HOME"] = home_directory + with open(logfile, "w") as f: + p = subprocess.Popen(command_line, stdout=f, stderr=f, env=env) + return p + + def start_jupyter_server( + home_directory: str, binary_path: str, port: int, notebook_dir: str, @@ -81,10 +100,13 @@ def exit_on_error(rc): binary_path, f"--notebook-dir={notebook_dir}", "--no-browser", - "--allow-root", + "--allow-root" ] + + env = os.environ.copy() + env["HOME"] = home_directory with open(logfile, "w") as f: - p = subprocess.Popen(command_line, stdout=f, stderr=f) + p = subprocess.Popen(command_line, stdout=f, stderr=f, env=env) url = "http://:" localhost_url = url.replace("", "localhost").replace("", str(port)) @@ -153,13 +175,42 @@ def disable_core_dumps(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) -def sleep_inifinity(): +def sleep_infinity(): while True: time.sleep(1) +class User: + def __init__(self, name: str): + self.name = name + self._id = None + + @property + def id(self): + if self._id is None: + self._id = pwd.getpwnam(self.name).pw_uid + return self._id + + def own(self, path: str): + if Path(path).exists(): + unchanged_gid = -1 + os.chown(path, self.id, unchanged_gid) + return self + + def switch_to(self): + uid = self.id + os.setresuid(uid, uid, uid) + return self + + def main(): args = arg_parser().parse_args() + if args.user: + ( + User(args.user) + .own("/var/run/docker.sock") + .switch_to() + ) if args.notebook_defaults and args.notebooks: copy_rec( args.notebook_defaults, @@ -167,13 +218,15 @@ def main(): args.warning_as_error, ) disable_core_dumps() - if (args.jupyter_server + if (args.user + and args.jupyter_server and args.notebooks and args.jupyter_logfile - and args.user + and args.home and args.password ): start_jupyter_server( + args.home, args.jupyter_server, args.port, args.notebooks, @@ -182,7 +235,7 @@ def main(): args.password, ) else: - sleep_inifinity() + sleep_infinity() if __name__ == "__main__": diff --git a/test/unit/entrypoint/test_main.py b/test/unit/entrypoint/test_main.py index 8e6768e9..94280860 100644 --- a/test/unit/entrypoint/test_main.py +++ b/test/unit/entrypoint/test_main.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import MagicMock, create_autospec from exasol.ds.sandbox.runtime.ansible.roles.entrypoint.files import entrypoint from pathlib import Path @@ -13,9 +14,21 @@ def entrypoint_method(name: str) -> str: def test_no_args(mocker): mocker.patch("sys.argv", ["app"]) - mocker.patch(entrypoint_method("sleep_inifinity")) + mocker.patch(entrypoint_method("sleep_infinity")) entrypoint.main() - assert entrypoint.sleep_inifinity.called + assert entrypoint.sleep_infinity.called + + +def test_user_arg(mocker): + mocker.patch("sys.argv", ["app", "--user", "jennifer"]) + user = create_autospec(entrypoint.User) + user.own.return_value = user + mocker.patch(entrypoint_method("User"), return_value=user) + mocker.patch(entrypoint_method("sleep_infinity")) + entrypoint.main() + assert user.own.called + assert user.own.call_args == mocker.call("/var/run/docker.sock") + assert user.switch_to.called @pytest.mark.parametrize("warning_as_error", [True, False]) @@ -27,7 +40,7 @@ def test_copy_args_valid(mocker, warning_as_error ): "--notebooks", "destination", ] + extra_args) mocker.patch(entrypoint_method("copy_rec")) - mocker.patch(entrypoint_method("sleep_inifinity")) + mocker.patch(entrypoint_method("sleep_infinity")) entrypoint.main() assert entrypoint.copy_rec.called expected = mocker.call(Path("source"), Path("destination"), warning_as_error) @@ -41,6 +54,7 @@ def test_jupyter(mocker): logfile = Path("/root/jupyter-server.log") mocker.patch("sys.argv", [ "app", + "--home", "home-directory", "--notebooks", str(notebook_folder), "--jupyter-server", jupyter, "--port", port, @@ -48,10 +62,19 @@ def test_jupyter(mocker): "--password", "pwd", "--jupyter-logfile", str(logfile), ]) + mocker.patch(entrypoint_method("User")) mocker.patch(entrypoint_method("start_jupyter_server")) - mocker.patch(entrypoint_method("sleep_inifinity")) + mocker.patch(entrypoint_method("sleep_infinity")) entrypoint.main() assert entrypoint.start_jupyter_server.called - expected = mocker.call(jupyter, int(port), notebook_folder, logfile, "usr", "pwd") + expected = mocker.call( + "home-directory", + jupyter, + int(port), + notebook_folder, + logfile, + "usr", + "pwd", + ) assert entrypoint.start_jupyter_server.call_args == expected - assert not entrypoint.sleep_inifinity.called + assert not entrypoint.sleep_infinity.called diff --git a/test/unit/entrypoint/test_start_jupyter_server.py b/test/unit/entrypoint/test_start_jupyter_server.py index 0f569f77..7758d90d 100644 --- a/test/unit/entrypoint/test_start_jupyter_server.py +++ b/test/unit/entrypoint/test_start_jupyter_server.py @@ -33,6 +33,7 @@ def create_script(self, before="", after=""): def run(self): entrypoint.start_jupyter_server( + "home", self.script, "port", "notebooks", diff --git a/test/unit/entrypoint/test_user_class.py b/test/unit/entrypoint/test_user_class.py new file mode 100644 index 00000000..a50cc17c --- /dev/null +++ b/test/unit/entrypoint/test_user_class.py @@ -0,0 +1,44 @@ +import os +import pwd +import pytest + +from unittest.mock import MagicMock +from exasol.ds.sandbox.runtime.ansible.roles.entrypoint.files import entrypoint + + +@pytest.fixture +def user(): + return entrypoint.User("jennifer") + + +@pytest.fixture +def user_with_id(mocker, user): + mocker.patch("pwd.getpwnam", return_value=MagicMock(pw_uid=123)) + return user + + +def test_id(mocker, user): + mocker.patch("pwd.getpwnam") + user.id + assert pwd.getpwnam.called + assert pwd.getpwnam.call_args == mocker.call("jennifer") + + +def test_chown_file_absent(mocker, user): + mocker.patch("os.chown") + user.own("/non/existing/path") + assert not os.chown.called + + +def test_chown_file_exists(mocker, tmp_path, user_with_id): + mocker.patch("os.chown") + user_with_id.own(tmp_path) + assert os.chown.called + assert os.chown.call_args == mocker.call(tmp_path, 123, -1) + + +def test_switch_to(mocker, user_with_id): + mocker.patch("os.setresuid") + user_with_id.switch_to() + assert os.setresuid.called + assert os.setresuid.call_args == mocker.call(123, 123, 123) diff --git a/test/unit/test_dss_docker_image.py b/test/unit/test_dss_docker_image.py index e9cf9dcc..c0a0f3c3 100644 --- a/test/unit/test_dss_docker_image.py +++ b/test/unit/test_dss_docker_image.py @@ -81,6 +81,7 @@ def test_entrypoint_with_copy_args(): initial = "/path/to/initial" final = "/path/to/final" user = "jupyter-user-name" + home = "/home/user" password = "jupyter-default-password" logfile = "/path/to/jupyter-server.log" facts = { @@ -89,6 +90,7 @@ def test_entrypoint_with_copy_args(): "command": jupyter, "port": port, "user": user, + "home": home, "password": password, "logfile": logfile, }, @@ -102,6 +104,7 @@ def test_entrypoint_with_copy_args(): entrypoint, "--notebook-defaults", initial, "--notebooks", final, + "--home", home, "--jupyter-server", jupyter, "--port", port, "--user", user,