Skip to content

Commit

Permalink
Feature: allow command with specific parameters (Closes: #168) (#233)
Browse files Browse the repository at this point in the history
* Feature: allow command with specific parameters (Closes: #168)
  • Loading branch information
ghantoos authored Oct 26, 2024
1 parent 488e848 commit aaf76a4
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 35 deletions.
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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':
Expand Down
1 change: 1 addition & 0 deletions etc/lshell.conf
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ loglevel : 2
## thus easily escaping lshell. See variable 'path_noexec' to use an alternative
## path to library.
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'
Expand Down
45 changes: 27 additions & 18 deletions lshell/sec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lshell/shellcmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lshell/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
18 changes: 14 additions & 4 deletions man/lshell.1
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
83 changes: 74 additions & 9 deletions test/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

0 comments on commit aaf76a4

Please sign in to comment.