From c0e95a0bf637f155ad72cf032dae71a5d1c22afa Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Fri, 25 Oct 2024 23:42:34 -0400 Subject: [PATCH 1/2] Feature: allow command with specific parameters (Closes: #168) --- README.md | 23 +++++++++++- etc/lshell.conf | 2 +- lshell/sec.py | 45 +++++++++++++--------- lshell/shellcmd.py | 2 +- lshell/variables.py | 2 +- man/lshell.1 | 18 +++++++-- test/test_functional.py | 83 ++++++++++++++++++++++++++++++++++++----- 7 files changed, 139 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 0be965b..bc4d42b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,24 @@ You might need to ensure that lshell is listed in /etc/shells. ### lshell.conf -lshell.conf presents a template configuration file. See etc/lshell.conf or man file for more information. +#### Allowed list +lshell.conf presents a template configuration file. See `etc/lshell.conf` or the man file for more information. + +You can allow commands specifying commands with exact arguments in the `allowed` list. This means you can define specific commands along with their arguments that are permitted. Commands without arguments can also be specified, allowing any arguments to be passed. + +For example: +``` +allowed: ['ls', 'echo asd', 'telnet localhost'] +``` + +This will: +- Allow the `ls` command with any arguments. +- Allow `echo asd` but will reject `echo` with any other arguments (e.g., `echo qwe` will be rejected). +- Allow `telnet localhost`, but not `telnet` with other hosts (e.g., `telnet 192.168.0.1` will be rejected). + +Commands that do not include arguments (e.g., `ls`) can be used with any arguments, while commands specified with arguments (e.g., `echo asd`) must be used exactly as specified. + +#### User profiles A [default] profile is available for all users using lshell. Nevertheless, you can create a [username] section or a [grp:groupname] section to customize users' preferences. @@ -92,7 +109,9 @@ Order of priority when loading preferences is the following: 3. Default configuration The primary goal of lshell, is to be able to create shell accounts with ssh access and restrict their environment to a couple a needed commands and path. - + +#### Example + For example User 'foo' and user 'bar' both belong to the 'users' UNIX group: - User 'foo': diff --git a/etc/lshell.conf b/etc/lshell.conf index b443cd1..a4181be 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -42,7 +42,7 @@ loglevel : 2 ## will allow users to execute code (e.g. /bin/sh) from within the application, ## thus easily escaping lshell. See variable 'path_noexec' to use an alternative ## path to library. -allowed : ['ls', 'echo','ll'] +allowed : ['ls', 'll', 'echo test'] ## A list of the allowed commands that are permitted to execute other ## programs (e.g. shell scripts with exec(3)). Setting this variable to 'all' diff --git a/lshell/sec.py b/lshell/sec.py index 87ec056..21990b7 100644 --- a/lshell/sec.py +++ b/lshell/sec.py @@ -229,32 +229,41 @@ def check_secure(line, conf, strict=None, ssh=None): separate_line = re.sub(r"\)$", "", separate_line) separate_line = " ".join(separate_line.split()) splitcmd = separate_line.strip().split(" ") + + # Extract the command and its arguments command = splitcmd[0] - if len(splitcmd) > 1: - cmdargs = splitcmd - else: - cmdargs = None + command_args_list = splitcmd[1:] + command_args_string = " ".join(command_args_list) + full_command = f"{command} {command_args_string}".strip() # in case of a sudo command, check in sudo_commands list if allowed - if command == "sudo": - if isinstance(cmdargs, list): - # allow the -u (user) flag - if cmdargs[1] == "-u" and cmdargs: - sudocmd = cmdargs[3] - else: - sudocmd = cmdargs[1] - if sudocmd not in conf["sudo_commands"] and cmdargs: - ret, conf = warn_count( - "sudo command", oline, conf, strict=strict, ssh=ssh - ) - return ret, conf + if command == "sudo" and command_args_list: + # allow the -u (user) flag + if command_args_list[0] == "-u" and command_args_list: + sudocmd = command_args_list[2] + else: + sudocmd = command_args_list[0] + if sudocmd not in conf["sudo_commands"] and command_args_list: + ret, conf = warn_count( + "sudo command", oline, conf, strict=strict, ssh=ssh + ) + return ret, conf # if over SSH, replaced allowed list with the one of overssh if ssh: conf["allowed"] = conf["overssh"] - # for all other commands check in allowed list - if command not in conf["allowed"] and command: + # # for all other commands check in allowed list + # if command not in conf["allowed"] and command: + # ret, conf = warn_count("command", command, conf, strict=strict, ssh=ssh) + # return ret, conf + + # Check if the full command (with arguments) or just the command is allowed + if ( + full_command not in conf["allowed"] + and command not in conf["allowed"] + and command + ): ret, conf = warn_count("command", command, conf, strict=strict, ssh=ssh) return ret, conf diff --git a/lshell/shellcmd.py b/lshell/shellcmd.py index 60e1095..e6870f3 100644 --- a/lshell/shellcmd.py +++ b/lshell/shellcmd.py @@ -121,7 +121,7 @@ def __getattr__(self, attr): ): utils.exec_cmd(f'echo "WinSCP: this is end-of-file: {self.retcode}"') return object.__getattribute__(self, attr) - if self.g_cmd in self.conf["allowed"]: + if self.g_cmd in self.conf["allowed"] or self.g_line in self.conf["allowed"]: if self.conf["timer"] > 0: self.mytimer(0) self.g_arg = re.sub("^~$|^~/", f"{self.conf['home_path']}/", self.g_arg) diff --git a/lshell/variables.py b/lshell/variables.py index 9c8df3d..e2a44c3 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.10.2" +__version__ = "0.10.3-rc1" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] diff --git a/man/lshell.1 b/man/lshell.1 index 7f540d1..eca9fb6 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -1,7 +1,7 @@ .\" .\" Man page for the Limited Shell (lshell) project. .\" -.TH lshell 1 "October, 2024" "v0.10.2" +.TH lshell 1 "October, 2024" "v0.10.3-rc1" .SH NAME lshell \- Limited Shell @@ -150,9 +150,19 @@ using the -exec flag. command aliases list (similar to bash's alias directive) .TP .I allowed -a list of the allowed commands or set to 'all' to allow all commands in user's \ -PATH - +A list of allowed commands, or set to 'all' to allow all commands in the user's PATH. +.RS +.BR \ \ \ \ - +If a command is specified without arguments (e.g., 'echo'), the command is allowed to run with any arguments. +.RE +.RS +.BR \ \ \ \ - +If a command is specified with exact arguments (e.g., 'echo asd'), only that specific command with those arguments will be allowed. +.RE +.RS +.BR \ \ \ \ - +To allow all commands in the user's PATH, use the value 'all'. +.RE if sudo(8) is installed and sudo_noexec.so is available, it will be loaded before running every command, preventing it from running further commands itself. If not available, beware of commands like vim/find/more/etc. that will diff --git a/test/test_functional.py b/test/test_functional.py index 5ea7253..d4111ea 100644 --- a/test/test_functional.py +++ b/test/test_functional.py @@ -507,7 +507,7 @@ def test_31_security_echo_freedom_and_help(self): # Verify the combined output self.assertEqual(expected_output, result) - def test_31_security_echo_freedom_and_cd(self): + def test_32_security_echo_freedom_and_cd(self): """F32 | test echo FREEDOM! && cd () bash && cd ~/""" self.child = pexpect.spawn( f"{TOPDIR}/bin/lshell " f"--config {TOPDIR}/etc/lshell.conf " @@ -534,7 +534,7 @@ def test_31_security_echo_freedom_and_cd(self): # Verify the combined output self.assertEqual(expected_output, result) - def test_32_ls_non_existing_directory_and_echo(self): + def test_33_ls_non_existing_directory_and_echo(self): """Test: ls non_existing_directory && echo nothing""" self.child = pexpect.spawn( f"{TOPDIR}/bin/lshell --config {TOPDIR}/etc/lshell.conf" @@ -548,7 +548,7 @@ def test_32_ls_non_existing_directory_and_echo(self): # Since ls fails, echo nothing shouldn't run self.assertNotIn("nothing", output) - def test_33_ls_and_echo_ok(self): + def test_34_ls_and_echo_ok(self): """Test: ls && echo OK""" self.child = pexpect.spawn( f"{TOPDIR}/bin/lshell --config {TOPDIR}/etc/lshell.conf" @@ -562,7 +562,7 @@ def test_33_ls_and_echo_ok(self): # ls succeeds, echo OK should run self.assertIn("OK", output) - def test_34_ls_non_existing_directory_or_echo_ok(self): + def test_35_ls_non_existing_directory_or_echo_ok(self): """Test: ls non_existing_directory || echo OK""" self.child = pexpect.spawn( f"{TOPDIR}/bin/lshell --config {TOPDIR}/etc/lshell.conf" @@ -576,7 +576,7 @@ def test_34_ls_non_existing_directory_or_echo_ok(self): # ls fails, echo OK should run self.assertIn("OK", output) - def test_35_ls_or_echo_nothing(self): + def test_36_ls_or_echo_nothing(self): """Test: ls || echo nothing""" self.child = pexpect.spawn( f"{TOPDIR}/bin/lshell --config {TOPDIR}/etc/lshell.conf" @@ -590,7 +590,7 @@ def test_35_ls_or_echo_nothing(self): # ls succeeds, echo nothing should not run self.assertNotIn("nothing", output) - def test_36_env_vars_file_not_found(self): + def test_37_env_vars_file_not_found(self): """Test missing environment variable file""" missing_file_path = "/path/to/missing/file" @@ -613,7 +613,7 @@ def test_36_env_vars_file_not_found(self): # Check the error message in the output self.assertIn(expected, self.child.before.decode("utf8")) - def test_37_load_env_vars_from_file(self): + def test_38_load_env_vars_from_file(self): """Test loading environment variables from file""" # Create a temporary file to store environment variables @@ -641,7 +641,7 @@ def test_37_load_env_vars_from_file(self): # Cleanup the temporary file os.remove(temp_env_file_path) - def test_38_script_execution_with_template(self): + def test_39_script_execution_with_template(self): """Test executing script after modifying shebang and clean up afterward""" template_path = f"{TOPDIR}/test/template.lsh" @@ -693,7 +693,7 @@ def test_38_script_execution_with_template(self): if os.path.exists(test_script_path): os.remove(test_script_path) - def test_39_script_execution_with_template_strict(self): + def test_40_script_execution_with_template_strict(self): """Test executing script after modifying shebang and clean up afterward""" template_path = f"{TOPDIR}/test/template.lsh" @@ -750,3 +750,68 @@ def test_39_script_execution_with_template_strict(self): os.remove(test_script_path) if os.path.exists(wrapper_path): os.remove(wrapper_path) + + def test_41_multicmd_with_wrong_arg_should_fail(self): + """F20 | Allowing 'echo asd': Test 'echo qwe' should fail""" + self.child = pexpect.spawn( + f"{TOPDIR}/bin/lshell " + f"--config {TOPDIR}/etc/lshell.conf " + "--allowed \"['echo asd']\"" + ) + self.child.expect(f"{self.user}:~\\$") + + expected = "*** forbidden command: echo" + + self.child.sendline("echo qwe") + self.child.expect(f"{self.user}:~\\$") + result = self.child.before.decode("utf8").split("\n")[1].strip() + self.assertEqual(expected, result) + + def test_42_multicmd_with_near_exact_arg_should_fail(self): + """F41 | Allowing 'echo asd': Test 'echo asds' should fail""" + self.child = pexpect.spawn( + f"{TOPDIR}/bin/lshell " + f"--config {TOPDIR}/etc/lshell.conf " + "--allowed \"['echo asd']\"" + ) + self.child.expect(f"{self.user}:~\\$") + + expected = "*** forbidden command: echo" + + self.child.sendline("echo asds") + self.child.expect(f"{self.user}:~\\$") + result = self.child.before.decode("utf8").split("\n")[1].strip() + self.assertEqual(expected, result) + + def test_43_multicmd_without_arg_should_fail(self): + """F42 | Allowing 'echo asd': Test 'echo' should fail""" + self.child = pexpect.spawn( + f"{TOPDIR}/bin/lshell " + f"--config {TOPDIR}/etc/lshell.conf " + "--allowed \"['echo asd']\"" + ) + self.child.expect(f"{self.user}:~\\$") + + expected = "*** forbidden command: echo" + + self.child.sendline("echo") + self.child.expect(f"{self.user}:~\\$") + result = self.child.before.decode("utf8").split("\n")[1].strip() + self.assertEqual(expected, result) + + def test_44_multicmd_asd_should_pass(self): + """F43 | Allowing 'echo asd': Test 'echo asd' should pass""" + + self.child = pexpect.spawn( + f"{TOPDIR}/bin/lshell " + f"--config {TOPDIR}/etc/lshell.conf " + "--allowed \"['echo asd']\"" + ) + self.child.expect(f"{self.user}:~\\$") + + expected = "asd" + + self.child.sendline("echo asd") + self.child.expect(f"{self.user}:~\\$") + result = self.child.before.decode("utf8").split("\n")[1].strip() + self.assertEqual(expected, result) From faa8afeb8c7cb7e16533a8d724767e42bb228160 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Fri, 25 Oct 2024 23:47:16 -0400 Subject: [PATCH 2/2] Fix lshell.conf/tests --- etc/lshell.conf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/etc/lshell.conf b/etc/lshell.conf index a4181be..4ffa2ad 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -42,7 +42,8 @@ loglevel : 2 ## will allow users to execute code (e.g. /bin/sh) from within the application, ## thus easily escaping lshell. See variable 'path_noexec' to use an alternative ## path to library. -allowed : ['ls', 'll', 'echo test'] +allowed : ['ls', 'echo','ll'] +#allowed : ['echo test'] # this will allow only the command 'echo test' ## A list of the allowed commands that are permitted to execute other ## programs (e.g. shell scripts with exec(3)). Setting this variable to 'all'