├── .flake8 ├── .github └── workflows │ ├── pylint.yml │ ├── pypi-publish.yml │ └── pytest.yml ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── COPYING ├── Dockerfile ├── MANIFEST.in ├── Makefile ├── README.md ├── SECURITY.md ├── bin └── lshell ├── debian ├── changelog ├── compat ├── control ├── copyright ├── lshell-makejail.conf ├── lshell.dirs ├── lshell.docs ├── lshell.examples ├── lshell.postinst ├── lshell.postrm ├── pyversions ├── rules ├── source │ └── format └── watch ├── docker-compose.yml ├── etc ├── logrotate.d │ └── lshell └── lshell.conf ├── lshell ├── __init__.py ├── builtincmd.py ├── checkconfig.py ├── completion.py ├── sec.py ├── shellcmd.py ├── utils.py └── variables.py ├── man └── lshell.1 ├── pytest.ini ├── requirements.txt ├── rpm ├── lshell.spec ├── postinstall ├── postuninstall └── preinstall ├── setup.py ├── source └── test ├── __init__.py ├── template.lsh ├── test_builtins.py ├── test_command_execution.py ├── test_completion.py ├── test_config.py ├── test_env_vars.py ├── test_exit.py ├── test_file_extension.py ├── test_path.py ├── test_ps2.py ├── test_regex.py ├── test_scripts.py ├── test_security.py ├── test_signals.py ├── test_ssh.py ├── test_unit.py ├── test_utils.py └── testfiles ├── test.conf ├── test_51_grep_valid_log_entry.log ├── test_52_grep_invalid_date_format.log ├── test_53_grep_missing_uid.log ├── test_54_grep_special_characters_in_uid.log └── test_60_allowed_extension_success.log /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | ignore = E501,W503,C901,E203,E701 4 | exclude = build/ -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Set up Python path 18 | run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pylint 23 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 24 | - name: Analysing the code with pylint 25 | run: | 26 | pylint $(git ls-files '*.py') 27 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 📦 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.x" 19 | - name: Install pypa/build 20 | run: >- 21 | python3 -m 22 | pip install 23 | build 24 | --user 25 | - name: Build a binary wheel and a source tarball 26 | run: python3 -m build 27 | - name: Store the distribution packages 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: python-package-distributions 31 | path: dist/ 32 | 33 | publish-to-pypi: 34 | name: >- 35 | Publish Python 🐍 distribution 📦 to PyPI 36 | needs: 37 | - build 38 | runs-on: ubuntu-latest 39 | 40 | environment: 41 | name: pypi 42 | url: https://pypi.org/p/limited-shell 43 | permissions: 44 | id-token: write # IMPORTANT: mandatory for trusted publishing 45 | 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish distribution 📦 to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Pytest 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Set up Python path 27 | run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install flake8 pytest 32 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 33 | - name: Install the lshell package 34 | run: pip install . 35 | - name: Lint with flake8 36 | run: | 37 | # stop the build if there are Python syntax errors or undefined names 38 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 39 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 40 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 41 | - name: Test with pytest 42 | run: | 43 | pytest 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | .coverage 4 | .tox/ 5 | *.egg-info/ 6 | etc/test.conf 7 | build/ 8 | dist/ 9 | test.lsh 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # LSHELL - Limited Shell - CHANGELOG 2 | 3 | Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) 4 | [https://github.com/ghantoos/lshell](https://github.com/ghantoos/lshell) 5 | 6 | ### v0.10.10 25/11/2024 7 | - Added path-only completion with completion from allowed paths. 8 | - Added prompt color support using ANSI codes. 9 | 10 | ### v0.10.9 19/11/2024 11 | - Fixed SFTP bug that caused connection failure. 12 | 13 | ### v0.10.8 16/11/2024 14 | - Added background job management with `fg` and `jobs` commands, including support for background execution using `&`. 15 | - Added Docker tests for Debian, Ubuntu, Fedora, and Alpine. 16 | 17 | ### v0.10.7 06/11/2024 18 | - Added source command 19 | - Added support for `LPS1` and `PS2` similar to bash `PS1` and `PS2` 20 | - Minor test updates and bug fixes 21 | 22 | ### v0.10.6 30/10/2024 23 | - Updated lexer/parser for handling complex regex and quoted strings. 24 | - Added the ability to restrict file extensions by providing a list of `allowed_file_extensions`. 25 | - Fixed configuration interpretation when using `'all' - ['command']` or similar configuration structures. 26 | - Added more tests. 27 | 28 | ### v0.10.5 26/10/2024 29 | - Fixed parsing and testing of the over SSH commands. Corrected return codes over SSH. 30 | 31 | ### v0.10.4 26/10/2024 32 | - Feature: Allow commands with specific parameters, e.g., `telnet localhost`. Adding `telnet localhost` to the `allowed` configuration will not permit the `telnet` command by itself but will only allow the exact match `telnet localhost`. 33 | 34 | ### v0.10.2 24/10/2024 35 | - Make env_path have precedence over the OS path ($env_path:$PATH) 36 | - Test auto-release via Pypi 37 | 38 | ### v0.10.1 24/10/2024 39 | - Add the ability to write and execute a lshell script (`#!/usr/bin/lshell`) 40 | - Added Pypi package 41 | - Code cleanup and testing 42 | 43 | ### v0.10 17/10/2024 44 | - Fixed security issues CVE-2016-6902, CVE-2016-6903 45 | - [CVE-2016-6902](https://nvd.nist.gov/vuln/detail/CVE-2016-6902) 46 | - [CVE-2016-6903](https://nvd.nist.gov/vuln/detail/CVE-2016-6903) 47 | - Fixed parser to better support operators like `||` and `&&` 48 | - Added support for `ctrl-z`: lshell no longer runs in the background when `ctrl-z` is used 49 | - Added `env_vars_files` feature: enables adding environment variables in a file and loading it at login 50 | - Improved Python3 compatibility & general code refresh 51 | - Removed the `help_help()` function 52 | 53 | ### v0.9.18 24/02/2016 54 | - Corrected exit codes of built-in commands 55 | - Added default support for `sudo_noexec.so` in `LD_PRELOAD`. This feature comes with a new variable `allowed_shell_escape` to allow admins to escape the new default behavior. Thank you Luca Berra for this contribution! 56 | - Added Python3 compatibility. Thank you Tristan Cacqueray for your help! 57 | - Added restricted environment variables that cannot be updated by user. Thank you Tristan Cacqueray for this contribution! 58 | - Added `export` command in built-ins 59 | - Added WinSCP support. Thank you @faberge-eggs for this contribution! 60 | - Added tox testing. Thank you Tristan Cacqueray for your contribution! 61 | - Correct logrotate configuration. Thank you Rune Schjellerup Philosof for your patch suggestion. 62 | - Code cleanup (More information in the git commit log) 63 | 64 | ### v0.9.17 14/08/2015 65 | - Added `include_dir` directive to include split configuration files from a directory. 66 | - Added possibility of using 'all' for sudo commands 67 | - Replaced `os.system` by `subprocess` (python) 68 | - Added support for `sudo -u` 69 | - Corrected shell variable expansion 70 | - Corrected bugs in aliases support 71 | - Fixed timer (idle session) 72 | - Added exit code support 73 | - Fixed wrong group reference for logging 74 | - Replaced Python `os.system` with `subprocess` 75 | 76 | ### v0.9.16 14/08/2013 77 | - Added support to login script. Thank you Laurent Debacker for the patch. 78 | - Fixed auto-complete failing with "-" 79 | - Fixed bug where forbidden commands still execute if `strict=1` 80 | - Fixed auto-completion complete of forbidden paths 81 | - Fixed wrong parsing `&`, `|` or `;` characters 82 | - Added `urandom` function definition for python 2.3 compat 83 | - Corrected env variable expansion 84 | - Add support for `cd` command in aliases 85 | - Split `lshellmodule` in multiple files under the `lshell` directory 86 | - Fixed `check_secure` function to ignore quoted text 87 | - Fixed multiple spaces escaping forbidden filtering 88 | - Fixed log file permissions `644 -> 600` 89 | - Added possibility to override config file option via command-line 90 | - Enabled job control when executing command 91 | - Code cleanup 92 | 93 | ### v0.9.15.2 08/05/2012 94 | - Corrected mismatch in `aliaskey` variable. 95 | 96 | ### v0.9.15.1 15/03/2012 97 | - Corrected security bug allowing user to get out of the restricted shell. Thank you bui from NBS System for reporting this grave issue! 98 | 99 | ### v0.9.15 13/03/2012 100 | - Set the hostname to the "short hostname" in the prompt. 101 | - Corrected traceback when "sudo" command was entered alone. Thank you Kiran Reddy for reporting this. 102 | - Added support for python2.3 as `subprocess` is not included by default. 103 | - Corrected the `strict` behavior when entering a forbidden path. 104 | - Added short path prompt support using the `prompt_short` variable. 105 | - Corrected stacktrace when group did not exist. 106 | - Add support for empty prompt. 107 | - Fixed bugs when using `$()` and ``. 108 | - Corrected strict behavior to apply to forbidden path. 109 | - Added support for wildcard `*` when using `cd`. 110 | - Added support for `cd -` to return to previous directory. 111 | - Updated security issue with non-printable characters permitting user to get out of the limited shell. 112 | - Now lshell automatically reloads its configuration if the configuration file is modified. 113 | - Added possibility to have no "intro" when user logs in (by setting the intro configuration field to ""). 114 | - Corrected multiple commands over ssh, and aliases interpretation. 115 | - Added possibility to use wildcards in path definitions. 116 | - Finally corrected the alias replacement loop. 117 | 118 | ### v0.9.14 27/10/2010 119 | - Corrected `get_aliases` function, as it was looping when aliases were "recursive" (e.g. `ls:ls --color=auto`) 120 | - Added `lsudo` built-in command to list allowed sudo commands. 121 | - Corrected completion function when 2 strings collided (e.g. `ls` and `lsudo`) 122 | - Corrected the README's installation part (adding `--prefix`). 123 | - Added possibility to log via syslog. 124 | - Corrected warning counter (was counting minus 1). 125 | - Added the possibility to disable the counter, and just warn the user (without kicking him). 126 | - Added possibility to configure prompt. Thank you bapt for the patch. 127 | - Added possibility to set environment variables to users. Thank you bapt for the patch. 128 | - Added the `history` built-in function. 129 | 130 | ### v0.9.13 02/09/2010 131 | - Switched from deprecated `popen2` to `subprocess` to be python2.6 compatible. Thank you Greg Orlowski for the patch. 132 | - Added missing built-in commands when `allowed` list was set to `all`. For example, the `cd` command was then missing. 133 | - Added the `export` built-in function to export shell variables. Thank you Chris for reporting this issue. 134 | 135 | ### v0.9.12 04/05/2010 136 | - A minor bug was inserted in version 0.9.11 with the sudo command. It has been corrected in this version. 137 | 138 | ### v0.9.11 27/04/2010 139 | - Corrects traceback when executing a command that had a python homonym (e.g. `print foo` or `set`). (Closes: SF#2969631) 140 | - Corrected completion error when using `~/`. Thanks to Piotr Minkina for reporting this. 141 | - Corrected the `get_aliases` function. 142 | - Corrected interpretation of `~user`. Thank you Adrien Urban for reporting this. 143 | - The `home_path` variable is being deprecated from this version and on. Please use your system's tools to set a user's home directory. It will be completely removed in the next version of lshell. 144 | - Corrected shell variable and wildcards expansions when checking a command. Thank you Adrien Urban for reporting this. 145 | - Added possibility to allow/forbid scp upload/download using `scp_upload` and `scp_download` variables. 146 | - Corrected bug when using the `command=` in openSSH's `authorized_keys`. lshell now takes into account the `SSH_ORIGINAL_COMMAND` environment variable. Thank you Jason Heiss for reporting this. 147 | - Corrected traceback when aliases is not defined in configuration, and command is sent over SSH. Thank you Jason Heiss for reporting this. 148 | 149 | ### v0.9.10 08/03/2010 150 | - Corrected minor bug in the aliases function that appeared in the previous version. Thank you Piotr Minkina for reporting this. 151 | 152 | ### v0.9.9 07/03/2010 153 | - Added the possibility to configure introduction prompt. 154 | - Replaced "joker" by "warnings" (more elegant) 155 | - Possibility of limiting the history file size. 156 | - Added `lpath` built-in command to list allowed and denied path. Thanks to Adrien Urban. 157 | - Corrected bug when using `~` was not parsed as "home directory" when used in a command other than `cd`. Thank you Adrien Urban for finding this. 158 | - Corrected minor typo when warning for a forbidden path. 159 | - If `$(foo)` is present in the line, check if `foo` is allowed before executing the line. Thank you Adrien Urban for pointing this out! 160 | - Added the possibility to list commands allowed to be executed using sudo. The new configuration field is `sudo_commands`. 161 | - Added the `clear(1)` command as a built-in command. 162 | - Added `$(` and `${` in the forbidden list by default in the configuration file. 163 | - Now check the content of curly braces with variables `${}`. Thank you Adrien Urban for reporting this. 164 | - Added possibility to set history file name using `history_file` in the configuration file. 165 | - Corrected the bug when using `|`, `&` or `;` over ssh. Over ssh forbidden characters refer now to the list provided in the `forbidden` field. Thank you Jools Wills for reporting this! 166 | - It now possible to use `&&` and `||` even if `&` and/or `|` are in the forbidden list. In order to forbid them too, you must add them explicitly in the forbidden list. Thank you Adrien Urban for this suggestion. 167 | - Fixed aliases bug that replaced part of commands rendering them unusable. e.g. alias `vi:vim` replaced the `view` command by `vimew`. 168 | - Added a logrotate file for lshell log files. 169 | - Corrected parsing of commands over ssh to be checked by the same function used by the lshell CLI. 170 | 171 | Thank you Adrien Urban for your security audit and excellent ideas! 172 | 173 | ### v0.9.8 30/11/2009 174 | - Major bug fix. lshell did not launch on python 2.4 and 2.5 ([sourceforge](https://sourceforge.net/projects/lshell/forums/forum/778301/topic/3474668)) 175 | - Added aliases for commands over SSH. 176 | 177 | ### v0.9.7 25/11/2009 178 | - Cleaned up the Python code 179 | - Corrected crash when directory permission denied ([sourceforge](https://sourceforge.net/tracker/?func=detail&aid=2875374&group_id=215792&atid=1035093)) 180 | - Added possibility to set the `home_path` option using the `%u` flag (e.g. `/var/chroot/%u` where `%u` will be replaced by the user's username) 181 | - Now replaces `~` by user's home directory. 182 | 183 | ### v0.9.6 9/09/2009 184 | - Major security fix. User had access to all files located in forbidden directories ([sourceforge](https://sourceforge.net/tracker/?func=detail&aid=2838542&group_id=215792&atid=1035093)) 185 | - Corrects RPM generation bug ([sourceforge](https://sourceforge.net/tracker/index.php?func=detail&aid=2838283&group_id=215792&atid=1035093)) 186 | - lshell exits gracefully when user home directory doesn't exist 187 | 188 | ### v0.9.5 28/07/2009 189 | - Minor release 190 | - Changed lshell's group from `lshellg` to `lshell` (this should not have an impact on older installations) 191 | - Minor typo correction in the `lshell.py` code 192 | 193 | ### v0.9.4 09/06/2009 194 | - Log file name is now configurable using `logfilename` variable inside the configuration file 195 | - Corrected aliases in `lshell.conf` to work with *BSD 196 | 197 | ### v0.9.3 13/04/2009 198 | - Corrected major bug (alias related) 199 | 200 | ### v0.9.2 05/04/2009 201 | - Added Force SCP directory feature 202 | - Added command alias feature 203 | 204 | ### v0.9.1 24/03/2009 205 | - `loglevel` can now be defined on global, group or user level 206 | - Corrected sftp support (broken since in 0.9.0) 207 | 208 | ### v0.9.0 20/03/2009 209 | - As lshell has reached the point where it can be considered as a nearly stable software. I decided to make a version jump to 0.9.0 210 | - Corrected bug in case `PATH` does not exist and `allowed` set to `all` 211 | - Added support for UNIX groups in configuration file 212 | - Cleaned up code 213 | - Corrected major security bug 214 | - Corrected path completion, to complete only allowed path simplified the `check_secure` and `check_path` functions 215 | - Added escape code handling (tested with ftp, gdb, vi) 216 | - Added flexible +/- possibilities in configuration file 217 | - Now supports completion after `|`, `;` and `&` 218 | - Command tests are also done after `|`, `;` and `&` 219 | - Doesn't list hidden directories by default 220 | - There are now 4 logging levels (4: logs absolutely everything user types) 221 | - Added `strict` behavior. If set to 1, any unknown command is considered as forbidden, as warning counter is decreased. 222 | 223 | ### v0.2.6 02/03/2009 224 | - Added `all` to allow all commands to a user 225 | - Added backticks in `lshell.conf` 226 | - Changes made to `setup.py` in version 0.2.5 were undone + added classifiers 227 | 228 | ### v0.2.5 15/02/2009 229 | - Corrected import readline [bug] 230 | - Added log directory instead of a logfile 231 | - Created log levels (0 to 3) 232 | - `setup.py` is now BSD compatible (using `--install-data` flag) 233 | 234 | ### v0.2.4 27/01/2009 235 | - NEW: `overssh` in config file. Allows to set commands allowed to execute over ssh (e.g. rsync) 236 | - Fixed timer 237 | - Added python logging method 238 | - Cleaned code 239 | - Cleaner "over ssh commands" support (e.g. scp, sftp, rsync, etc.) 240 | 241 | ### v0.2.3 03/12/2008 242 | - Corrected completion 243 | - Added `[global]` section in configuration file 244 | 245 | ### v0.2.2 29/10/2008 246 | - Corrected SCP functionality 247 | - Added SFTP support 248 | - `passwd` is not mandatory in configuration file (deprecated) 249 | - lshell is now added to `/etc/shells` using `add-shell` 250 | 251 | ### v0.2.1 20/10/2008 252 | - Corrected rpm & deb builds 253 | - Added a manpage 254 | 255 | ### v0.2 18/10/2008 256 | - Initial debian packaging 257 | 258 | ### v0.2 17/10/2008 259 | - Added config and log option on command line (`-c|--config` and `-l|--log`) 260 | - Initial source packaging using distutils 261 | - Initial rpm packaging using distutils 262 | 263 | ### v0.2 07/10/2008 264 | - Added file completion 265 | - Added a history file per user 266 | - Added a logging for warnings and log in/out 267 | - Added prompt update when user changes directory (bash like) 268 | - Corrected the `check_path` function 269 | - Changed user setting from global variable to dict 270 | - Added a default profile used when a parameter is not set for a user 271 | 272 | ### 06/05/2008 273 | - Added a shell script useful to install and manage lshell users 274 | 275 | ### 08/04/2008 276 | - Added environment path (`env_path`) update support 277 | - Added home path (`home_path`) variable 278 | 279 | ### 29/03/2008 280 | - Corrected class declaration bug and configuration file location 281 | - Updated the README file with another usage of lshell 282 | 283 | ### 05/02/2008 284 | - Added a path variable to restrict the user's geographic actions 285 | - MAJOR: added SCP support (also configurable through the config file) 286 | 287 | ### 31/01/2008 288 | - MAJOR: Added the `help` method 289 | - Did some code cleanup 290 | 291 | ### 28/01/2008 292 | - Initial release of lshell 293 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Define the base image dynamically using build argument 2 | ARG DISTRO=ubuntu:latest 3 | FROM ${DISTRO} AS base 4 | 5 | # Install dependencies based on the distribution 6 | RUN \ 7 | # For Debian/Ubuntu 8 | if [ -f /etc/debian_version ]; then \ 9 | apt-get update && \ 10 | apt-get install -y python3 python3-pip git flake8 pylint python3-pytest python3-pexpect python3-setuptools vim procps && \ 11 | apt-get clean; \ 12 | useradd -m -d /home/testuser -s /bin/bash testuser; \ 13 | # For Fedora 14 | elif [ -f /etc/fedora-release ]; then \ 15 | dnf install -y python3 python3-pip python3-pytest git flake8 pylint python3-pexpect python3-setuptools vim; \ 16 | useradd -m -d /home/testuser -s /bin/bash testuser; \ 17 | # For CentOS 18 | elif [ -f /etc/centos-release ]; then \ 19 | # Update CentOS repository to use vault.centos.org 20 | sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-* && \ 21 | sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-* && \ 22 | yum install -y python3 python3-pip python3-pytest git vim && \ 23 | yum install -y python3-devel gcc && \ 24 | python3 -m pip install flake8 pylint pexpect setuptools; \ 25 | yum clean all; \ 26 | useradd -m -d /home/testuser -s /bin/bash testuser; \ 27 | # For Alpine 28 | elif [ -f /etc/alpine-release ]; then \ 29 | apk add --no-cache --upgrade python3 py3-pip py3-pytest py3-flake8 py3-pylint py3-pexpect py3-setuptools grep vim; \ 30 | addgroup -S testuser && adduser -S testuser -G testuser; \ 31 | fi 32 | 33 | # Set permissions for the user to access /app 34 | RUN mkdir /home/testuser/lshell && chown -R testuser:testuser /home/testuser/lshell 35 | 36 | # Set working directory to /home/testuser 37 | WORKDIR /home/testuser/lshell 38 | 39 | # Set PYTHONPATH to the current working directory 40 | ENV PYTHONPATH=/home/testuser/lshell 41 | 42 | # Copy the code and requirements 43 | COPY . /home/testuser/lshell 44 | 45 | # Install lshell from the source 46 | RUN python3 setup.py install 47 | 48 | # Switch to `testuser` 49 | USER testuser 50 | 51 | # Entry point for interactive lshell (overridden in Docker Compose for tests) 52 | CMD ["lshell"] 53 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include COPYING 2 | include README.md 3 | include CHANGELOG.md 4 | include etc/lshell.conf 5 | include etc/logrotate.d/lshell 6 | include man/lshell.1 7 | include MANIFEST.in 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Limited Shell (lshell) Makefile 2 | # 3 | # $Id: Makefile,v 1.16 2010-03-06 23:11:38 ghantoos Exp $ 4 | # 5 | 6 | PYTHON=`which python` 7 | DESTDIR=/ 8 | BUILDIR=$(CURDIR)/debian/lshell 9 | PROJECT=lshell 10 | 11 | all: 12 | @echo "make source - Create source package" 13 | @echo "make sourcedeb - Create source package (.orig.tar.gz)" 14 | @echo "make install - Install on local system" 15 | @echo "make buildrpm - Generate a rpm package" 16 | @echo "make builddeb - Generate a deb package" 17 | @echo "make clean - Get rid of scratch and byte files" 18 | 19 | source: 20 | $(PYTHON) setup.py sdist 21 | 22 | sourcedeb: 23 | $(PYTHON) setup.py sdist --dist-dir=../ --prune 24 | rename -f 's/$(PROJECT)-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../* 25 | 26 | install: 27 | $(PYTHON) setup.py install --root=$(DESTDIR) --no-compile 28 | 29 | buildrpm: 30 | $(PYTHON) setup.py bdist_rpm --pre-install=rpm/preinstall --post-install=rpm/postinstall --post-uninstall=rpm/postuninstall 31 | 32 | builddeb: 33 | # build the source package in the parent directory 34 | # then rename it to project_version.orig.tar.gz 35 | $(PYTHON) setup.py sdist --dist-dir=../ --prune 36 | rename -f 's/$(PROJECT)-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../* 37 | # build the package 38 | dpkg-buildpackage -i -I -rfakeroot 39 | 40 | clean: 41 | $(PYTHON) setup.py clean 42 | rm -rf build/ MANIFEST dist/ 43 | find . -name '*.pyc' -delete 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PyPI - Version](https://img.shields.io/pypi/v/limited-shell?link=https%3A%2F%2Fpypi.org%2Fproject%2Flimited-shell%2F) 2 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/limited-shell) 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ghantoos/lshell/pytest.yml?branch=master&label=pytest&link=https%3A%2F%2Fgithub.com%2Fghantoos%2Flshell%2Factions%2Fworkflows%2Fpytest.yml) 4 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ghantoos/lshell/pylint.yml?branch=master&label=pylint&link=https%3A%2F%2Fgithub.com%2Fghantoos%2Flshell%2Factions%2Fworkflows%2Fpylint.yml) 5 | 6 | # lshell 7 | 8 | lshell is a limited shell coded in Python, that lets you restrict a user's environment to limited sets of commands, choose to enable/disable any command over SSH (e.g. SCP, SFTP, rsync, etc.), log user's commands, implement timing restriction, and more. 9 | 10 | 11 | ## Installation 12 | 13 | ### Install via pip 14 | 15 | To install `limited-shell` directly via `pip`, use the following command: 16 | 17 | ```bash 18 | pip install limited-shell 19 | ``` 20 | 21 | This will install limited-shell from PyPI along with all its dependencies. 22 | 23 | To uninstall, you can run: 24 | 25 | ```bash 26 | pip uninstall limited-shell 27 | ``` 28 | 29 | ### Build from source and install locally 30 | 31 | If you'd like to build and install limited-shell from the source code (useful if you're making modifications or testing new features), you can follow these steps: 32 | 33 | ``` 34 | python3 -m pip install build --user 35 | python3 -m build 36 | pip install . --break-system-packages 37 | ``` 38 | 39 | ### Uninstall lshell 40 | 41 | To uninstall, you can run: 42 | 43 | ```bash 44 | pip uninstall limited-shell 45 | ``` 46 | 47 | ## Usage 48 | ### Via binary 49 | To launch lshell, just execute lshell specifying the location of your configuration file: 50 | 51 | ```bash 52 | lshell --config /path/to/configuration/file 53 | ``` 54 | 55 | ### Using `lshell` in Scripts 56 | 57 | You can use `lshell` directly within a script by specifying the lshell path in the shebang. Ensure your script has a `.lsh` extension to indicate it is for lshell, and make sure to include the shebang `#!/usr/bin/lshell` at the top of your script. 58 | 59 | For example: 60 | 61 | ```bash 62 | #!/usr/bin/lshell 63 | echo "test" 64 | ``` 65 | 66 | 67 | ## Configuration 68 | ### User shell configuration 69 | In order to log a user, you will have to add them to the lshell group: 70 | 71 | ```bash 72 | usermod -aG lshell username 73 | ``` 74 | 75 | In order to configure a user account to use lshell by default, you must: 76 | 77 | ```bash 78 | chsh -s /usr/bin/lshell user_name 79 | ``` 80 | 81 | You might need to ensure that lshell is listed in /etc/shells. 82 | 83 | ### lshell.conf 84 | 85 | #### Allowed list 86 | lshell.conf presents a template configuration file. See `etc/lshell.conf` or the man file for more information. 87 | 88 | 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. 89 | 90 | For example: 91 | ``` 92 | allowed: ['ls', 'echo asd', 'telnet localhost'] 93 | ``` 94 | 95 | This will: 96 | - Allow the `ls` command with any arguments. 97 | - Allow `echo asd` but will reject `echo` with any other arguments (e.g., `echo qwe` will be rejected). 98 | - Allow `telnet localhost`, but not `telnet` with other hosts (e.g., `telnet 192.168.0.1` will be rejected). 99 | 100 | 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. 101 | 102 | #### User profiles 103 | 104 | 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. 105 | 106 | Order of priority when loading preferences is the following: 107 | 108 | 1. User configuration 109 | 2. Group configuration 110 | 3. Default configuration 111 | 112 | 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. 113 | 114 | #### Example 115 | 116 | For example User 'foo' and user 'bar' both belong to the 'users' UNIX group: 117 | 118 | - User 'foo': 119 | - must be able to access /usr and /var but not /usr/local 120 | - use all commands in their PATH except 'su' 121 | - has a warning counter set to 5 122 | - has their home path set to '/home/users' 123 | 124 | - User 'bar': 125 | - must be able to access /etc and /usr but not /usr/local 126 | - is allowed default commands plus 'ping' minus 'ls' 127 | - strictness is set to 1 (meaning he is not allowed to type an unknown command) 128 | 129 | In this case, my configuration file will look something like this: 130 | 131 | # CONFIGURATION START 132 | [global] 133 | logpath : /var/log/lshell/ 134 | loglevel : 2 135 | 136 | [default] 137 | allowed : ['ls','pwd'] 138 | forbidden : [';', '&', '|'] 139 | warning_counter : 2 140 | timer : 0 141 | path : ['/etc', '/usr'] 142 | env_path : ':/sbin:/usr/foo' 143 | scp : 1 # or 0 144 | sftp : 1 # or 0 145 | overssh : ['rsync','ls'] 146 | aliases : {'ls':'ls --color=auto','ll':'ls -l'} 147 | 148 | [grp:users] 149 | warning_counter : 5 150 | overssh : - ['ls'] 151 | 152 | [foo] 153 | allowed : 'all' - ['su'] 154 | path : ['/var', '/usr'] - ['/usr/local'] 155 | home_path : '/home/users' 156 | 157 | [bar] 158 | allowed : + ['ping'] - ['ls'] 159 | path : - ['/usr/local'] 160 | strict : 1 161 | scpforce : '/home/bar/uploads/' 162 | # CONFIGURATION END 163 | 164 | ## More information 165 | 166 | More information can be found in the manpage: `man -l man/lshell.1` or `man lshell`. 167 | 168 | 169 | ## Running Tests in Docker Containers 170 | 171 | You can run the tests in parallel across multiple Linux distributions using Docker Compose. This is helpful for ensuring compatibility and consistency across environments. The following command will launch test services for Ubuntu, Debian, Fedora, and Alpine distributions simultaneously: 172 | 173 | ```bash 174 | docker-compose up ubuntu_tests debian_tests fedora_tests alpine_tests 175 | ``` 176 | 177 | Each service will run in parallel and execute the `pytest`, `pylint`, and `flake8` tests specified in the docker-compose.yml. 178 | 179 | ## Contributions 180 | 181 | To contribute, open an issue or send a pull request. 182 | 183 | Please use github for all requests: https://github.com/ghantoos/lshell/issues 184 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting Security Issues 4 | 5 | The **lshell** team takes security vulnerabilities seriously and appreciates responsible disclosure. To report a security issue, please contact us via **ghantoos@ghantoos.org**. 6 | 7 | We will acknowledge receipt of your report and provide updates on our progress. You may be asked for additional information or assistance as we work toward a resolution. -------------------------------------------------------------------------------- /bin/lshell: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (C) 2008-2024 Ignace Mouzannar 4 | # 5 | # This file is part of lshell 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | 20 | """ calls lshell function """ 21 | 22 | import os 23 | import sys 24 | import signal 25 | 26 | from lshell.checkconfig import CheckConfig 27 | from lshell.shellcmd import ShellCmd, LshellTimeOut 28 | 29 | 30 | def main(): 31 | """main function""" 32 | # set SHELL and get LSHELL_ARGS env variables 33 | os.environ["SHELL"] = os.path.realpath(sys.argv[0]) 34 | if "LSHELL_ARGS" in os.environ: 35 | args = sys.argv[1:] + eval(os.environ["LSHELL_ARGS"]) 36 | else: 37 | args = sys.argv[1:] 38 | 39 | userconf = CheckConfig(args).returnconf() 40 | 41 | def disable_ctrl_z(signum, frame): 42 | pass # Do nothing when Ctrl+Z is pressed 43 | 44 | signal.signal(signal.SIGTSTP, disable_ctrl_z) 45 | 46 | try: 47 | cli = ShellCmd(userconf, args) 48 | cli.cmdloop() 49 | 50 | except (KeyboardInterrupt, EOFError): 51 | sys.stdout.write("\nExited on user request\n") 52 | sys.exit(0) 53 | except LshellTimeOut: 54 | userconf["logpath"].error("Timer expired") 55 | sys.stdout.write("\nTime is up.\n") 56 | 57 | 58 | if __name__ == "__main__": 59 | main() 60 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | lshell (0.9.18-3) UNRELEASED; urgency=medium 2 | 3 | * debian/watch: 4 | - Corrected to work with lshell versioning on github. 5 | 6 | -- Ignace Mouzannar Sat, 27 Feb 2016 12:29:25 -0500 7 | 8 | lshell (0.9.18-2) unstable; urgency=medium 9 | 10 | * debian/rules: 11 | - Set build system to pybuild to resolve the FTBFS due to default build 12 | system being for Python 2 (distutils) 13 | See: https://lists.debian.org/debian-python/2014/03/msg00091.html 14 | 15 | -- Ignace Mouzannar Fri, 26 Feb 2016 18:27:56 -0500 16 | 17 | lshell (0.9.18-1) unstable; urgency=medium 18 | 19 | * New upstream release (Closes: #668776) 20 | * debian/control: 21 | - Bumped Standards-Version to 3.9.7 22 | - Now builds against Python3: 23 | - Replaced X-Python-Version variable with X-Python3-Version, as lshell 24 | is now compatible with Python >=3.4 25 | - Modified ${python:Depends} to ${python3:Depends} 26 | - Modified Build-Depends to use python3 27 | * debian/rules: 28 | - Build for Python3. Replaced --with-python2 with --with-python3 29 | - Added dh_auto_clean override to remove all build files when cleaning 30 | * debian/watch: 31 | - Updated to point to github instead of deprecated sf.net repo 32 | 33 | -- Ignace Mouzannar Thu, 25 Feb 2016 11:10:59 -0500 34 | 35 | lshell (0.9.17-1) unstable; urgency=medium 36 | 37 | * New upstream release. 38 | * debian/control: 39 | - Bumped Standards-Version to 3.9.6. 40 | * debian/lshell.docs: 41 | - Modified README to README.md. 42 | * Removed deprecated debian/pycompat. 43 | 44 | -- Ignace Mouzannar Fri, 14 Aug 2015 07:26:28 -0400 45 | 46 | lshell (0.9.16-1) unstable; urgency=low 47 | 48 | * New upstream release (Closes: #668776). 49 | * debian/control: 50 | - Bumped Standards-Version to 3.9.4. 51 | - Remove obsolete DM-Upload-Allowed field. 52 | * debian/rules: 53 | - Added override for dh_auto_install. 54 | 55 | -- Ignace Mouzannar Thu, 15 Aug 2013 18:09:39 +0400 56 | 57 | lshell (0.9.15.1-1) unstable; urgency=low 58 | 59 | * New upstream release. 60 | * debian/control: 61 | - Bumped Standards-Version to 3.9.3. 62 | - Updated debhelper version dependency to (>= 9). 63 | - Removed dependency on python-support. 64 | - Updated python version dependency to (>= 2.6.6-3~). 65 | - Added X-Python-Version field. 66 | - Added DM-Upload-Allowed. 67 | - Added Homepage field. 68 | * debian/rules: 69 | - Added python2 to dh's --with argument. 70 | * debian/copyright: 71 | - Updated to follow dep5. 72 | * debian/compat: 73 | - Set to 9. 74 | * Deleted debian/pyversions and debian/pycompat. 75 | 76 | -- Ignace Mouzannar Tue, 10 Apr 2012 04:28:05 +0400 77 | 78 | lshell (0.9.14-1) unstable; urgency=low 79 | 80 | * New upstream release: 81 | - Corrects multiple minor bugs. 82 | - Add new features as lsudo, history and logging via syslog. 83 | * debian/control: 84 | - Bumped Standards-Version to 3.9.1. 85 | 86 | -- Ignace Mouzannar Sun, 27 Feb 2011 19:49:11 +0100 87 | 88 | lshell (0.9.12-1) unstable; urgency=low 89 | 90 | * New upstream release: 91 | - Corrects a major security bug (overriding path restrictions using 92 | variable expansion). 93 | - Adds the ability to allow/forbid upload and/or downloads via SCP. 94 | 95 | -- Ignace Mouzannar Wed, 05 May 2010 23:12:05 +0200 96 | 97 | lshell (0.9.10-1) unstable; urgency=low 98 | 99 | * New upstream release: 100 | - Corrects major security bugs. (Closes: #572144) 101 | - Adds a logrotate file. 102 | - Adds new features. 103 | * debian/control: 104 | - Bumped Standards-Version to 3.8.4. 105 | 106 | -- Ignace Mouzannar Sun, 07 Mar 2010 01:52:57 +0000 107 | 108 | lshell (0.9.8-1) unstable; urgency=low 109 | 110 | * New upstream version: 111 | - Corrects crash when directory permission denied. 112 | - Adds minor features. 113 | * Updated packaging format to "3.0 (quilt)". 114 | * debian/rules: 115 | - Updated to use dh7 features. (Closes: #557826) 116 | * debian/control: 117 | - Bumped debhelper dependency to (>= 7.0.50~) for dh7 features support. 118 | - Moved Build-Depends-Indep dependencies to Build-Depends. 119 | - Replaced python-all-dev dependency by python. 120 | 121 | -- Ignace Mouzannar Tue, 01 Dec 2009 19:07:01 +0100 122 | 123 | lshell (0.9.6-1) unstable; urgency=low 124 | 125 | * Major security fix. User had access to all files located in forbidden 126 | directories. 127 | * lshell exits gracefully when user home directory doesn't exist. 128 | * Added makejail(8) configuration example to help create a chroot with all 129 | the required files to run lshell. 130 | * Updated the debian/watch file. 131 | * Standards-Version bumped to 3.8.3. 132 | 133 | -- Ignace Mouzannar Wed, 09 Sep 2009 20:17:45 +0200 134 | 135 | lshell (0.9.5-2) unstable; urgency=low 136 | 137 | * Added a dependency on adduser as addgroup(8) is used is the post 138 | installation. 139 | * Corrected the debian/rules file to install only one copy of upstream's 140 | changelog. 141 | 142 | -- Ignace Mouzannar Wed, 19 Aug 2009 22:43:22 +0200 143 | 144 | lshell (0.9.5-1) unstable; urgency=low 145 | 146 | * Initial release. (Closes: #503437) 147 | 148 | -- Ignace Mouzannar Tue, 28 Jul 2009 19:50:43 +0200 149 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: lshell 2 | Section: shells 3 | Priority: optional 4 | Maintainer: Ignace Mouzannar 5 | Build-Depends: debhelper (>= 9), python3 (>= 3.4) 6 | X-Python3-Version: >= 3.4 7 | Standards-Version: 3.9.7 8 | Homepage: https://github.com/ghantoos/lshell 9 | 10 | Package: lshell 11 | Architecture: all 12 | Homepage: http://lshell.ghantoos.org/ 13 | Depends: ${misc:Depends}, ${python3:Depends}, adduser 14 | XB-Python-Version: ${python3:Versions} 15 | Description: restricts a user's shell environment to limited sets of commands 16 | lshell is a shell coded in Python that lets you restrict a user's environment 17 | to limited sets of commands, choose to enable/disable any command over SSH 18 | (e.g. SCP, SFTP, rsync, etc.), log user's commands, implement timing 19 | restrictions, and more. 20 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: lshell 3 | Upstream-Contact: Ignace Mouzannar 4 | Source: https://sourceforge.net/projects/lshell/files/latest/download 5 | 6 | Files: * 7 | Copyright: 8 | 2009-2012, Ignace Mouzannar 9 | License: GPL-3 10 | This package is free software; you can redistribute it and/or modify 11 | it under the terms of the GNU General Public License as published by 12 | the Free Software Foundation; either version 3 of the License, or 13 | (at your option) any later version. 14 | . 15 | This package is distributed in the hope that it will be useful, 16 | but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | GNU General Public License for more details. 19 | . 20 | You should have received a copy of the GNU General Public License 21 | along with this package; if not, see . 22 | . 23 | On Debian systems, the complete text of the GNU General 24 | Public License can be found in `/usr/share/common-licenses/GPL-3'. 25 | -------------------------------------------------------------------------------- /debian/lshell-makejail.conf: -------------------------------------------------------------------------------- 1 | # Makejail configuration file for lshell(1) 2 | # 3 | # $Id: lshell-makejail.conf,v 1.1 2009/09/11 15:01:12 ghantoos Exp $ 4 | 5 | # Path to the chroot environment 6 | chroot = '/var/chroot/lshell' 7 | 8 | # Path to lshell binary and list of allowed all the allowed commands 9 | testCommandsInsideJail = ['/usr/bin/lshell'] 10 | 11 | # Process name / names, its a python list 12 | processNames = ['lshell'] 13 | 14 | # Replace/add the usernames you want to grant access to the chroot 15 | users = ['foo'] 16 | groups = ['lshell', 'bar'] 17 | 18 | # Force copy of python PATH files, and lshell's default logging directory 19 | # You must change this directory, if you modify the 'logpath' variable in 20 | # lshell's configuration file 21 | forceCopy = ['/usr/lib/python2.5/site-packages/*.pth', '/var/log/lshell/'] 22 | 23 | # Changes to do to jail lshell: 24 | # 25 | # 1- Create the chroot directory (e.g. mkdir /var/chroot/lshell) 26 | # 27 | # 2- Add the allowed commands list to the 'testCommandsInsideJail' 28 | # variable above e.g: ['/usr/bin/lshell', 'ls', 'echo', 'cat'] 29 | # 30 | # 3- Add the list of users/groups that are allowed inside the chroot. 31 | # NOTE: you have to add _all_ the groups your users belong to. 32 | # Another, less secure, option is putting ['*'] in the 'groups' field. 33 | # 34 | # 4- If needed, change the log directory in the 'forceCopy' variable above 35 | # 36 | # 5- Run the following as root: 37 | # $ /usr/sbin/makejail /path/to/lshell-makejail.conf 38 | # in order to copy all the necessary files inside the chroot. 39 | # 40 | # 6- Create the home directories of your users. 41 | # 42 | # This should be it. 43 | -------------------------------------------------------------------------------- /debian/lshell.dirs: -------------------------------------------------------------------------------- 1 | var/log/lshell/ 2 | -------------------------------------------------------------------------------- /debian/lshell.docs: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /debian/lshell.examples: -------------------------------------------------------------------------------- 1 | debian/lshell-makejail.conf 2 | -------------------------------------------------------------------------------- /debian/lshell.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # postinst script for lshell 3 | # 4 | # $Id: lshell.postinst,v 1.5 2009/07/28 18:46:43 ghantoos Exp $ 5 | # 6 | 7 | #DEBHELPER# 8 | 9 | set -e 10 | 11 | case "$1" in 12 | configure) 13 | # add lshell group, and make it own the default log file 14 | if ! getent group lshell 2>&1 > /dev/null; then 15 | addgroup --system lshell 16 | fi 17 | 18 | chown root:lshell /var/log/lshell/ 19 | chmod -R 770 /var/log/lshell/ 20 | 21 | add-shell /usr/bin/lshell 22 | ;; 23 | 24 | abort-upgrade|abort-remove|abort-deconfigure) 25 | ;; 26 | 27 | *) 28 | echo "postinst called with unknown argument \`$1'" >&2 29 | exit 1 30 | ;; 31 | esac 32 | 33 | exit 0 34 | -------------------------------------------------------------------------------- /debian/lshell.postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # postrm script for lshell 3 | # 4 | # see: dh_installdeb(1) 5 | # 6 | # $Id: lshell.postrm,v 1.4 2009/07/28 18:14:54 ghantoos Exp $ 7 | 8 | set -e 9 | 10 | case "$1" in 11 | remove|purge|disappear) 12 | remove-shell /usr/bin/lshell 13 | ;; 14 | upgrade|failed-upgrade|abort-upgrade|abort-install) 15 | # do nothing 16 | ;; 17 | 18 | *) 19 | echo "postrm called with unknown argument \`$1'" >&2 20 | exit 1 21 | ;; 22 | esac 23 | 24 | # dh_installdeb will replace this with shell code automatically 25 | # generated by other debhelper scripts. 26 | 27 | #DEBHELPER# 28 | 29 | exit 0 30 | -------------------------------------------------------------------------------- /debian/pyversions: -------------------------------------------------------------------------------- 1 | 2.4- 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | export DH_ALWAYS_EXCLUDE=COPYING:CHANGES 5 | export PYTHON=`which python3` 6 | 7 | %: 8 | dh $@ --with python3 --buildsystem=pybuild 9 | 10 | override_dh_auto_install: 11 | $(PYTHON) setup.py install --root=$(CURDIR)/debian/lshell --install-layout=deb 12 | 13 | override_dh_installchangelogs: 14 | dh_installchangelogs CHANGES 15 | 16 | override_dh_auto_clean: 17 | $(PYTHON) setup.py clean -a 18 | dh_auto_clean 19 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /debian/watch: -------------------------------------------------------------------------------- 1 | version=3 2 | https://github.com/ghantoos/lshell/releases .*/lshell_v?(\d\S*)\.tar\.gz 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ################################################# 3 | ## Run interactive lshellfor each distribution ## 4 | ################################################# 5 | ubuntu: 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | args: 10 | DISTRO: "ubuntu:latest" 11 | image: lshell-ubuntu 12 | container_name: lshell-ubuntu 13 | entrypoint: ["lshell"] 14 | command: "" 15 | volumes: 16 | - .:/app 17 | environment: 18 | - PYTHONPATH=/app 19 | 20 | debian: 21 | build: 22 | context: . 23 | dockerfile: Dockerfile 24 | args: 25 | DISTRO: "debian:latest" 26 | image: lshell-debian 27 | container_name: lshell-debian 28 | entrypoint: ["lshell"] 29 | command: "" 30 | volumes: 31 | - .:/app 32 | environment: 33 | - PYTHONPATH=/app 34 | 35 | fedora: 36 | build: 37 | context: . 38 | dockerfile: Dockerfile 39 | args: 40 | DISTRO: "fedora:latest" 41 | image: lshell-fedora 42 | container_name: lshell-fedora 43 | entrypoint: ["lshell"] 44 | command: "" 45 | volumes: 46 | - .:/app 47 | environment: 48 | - PYTHONPATH=/app 49 | 50 | centos: 51 | build: 52 | context: . 53 | dockerfile: Dockerfile 54 | args: 55 | DISTRO: "centos:8" 56 | image: lshell-centos 57 | container_name: lshell-centos 58 | entrypoint: ["lshell"] 59 | command: "" 60 | volumes: 61 | - .:/app 62 | environment: 63 | - PYTHONPATH=/app 64 | 65 | alpine: 66 | build: 67 | context: . 68 | dockerfile: Dockerfile 69 | args: 70 | DISTRO: "alpine:latest" 71 | image: lshell-alpine 72 | container_name: lshell-alpine 73 | entrypoint: ["lshell"] 74 | command: "" 75 | volumes: 76 | - .:/app 77 | environment: 78 | - PYTHONPATH=/app 79 | 80 | 81 | ################################################# 82 | ## Run linting and tests for each distribution ## 83 | ################################################# 84 | ubuntu_tests: 85 | build: 86 | context: . 87 | dockerfile: Dockerfile 88 | args: 89 | DISTRO: "ubuntu:latest" 90 | user: "testuser" 91 | working_dir: /home/testuser/lshell 92 | container_name: ubuntu_tests 93 | command: "sh -c 'pytest && pylint lshell && flake8 lshell'" 94 | volumes: 95 | - .:/home/testuser/lshell 96 | environment: 97 | - PYTHONPATH=/home/testuser/lshell 98 | 99 | debian_tests: 100 | build: 101 | context: . 102 | dockerfile: Dockerfile 103 | args: 104 | DISTRO: "debian:latest" 105 | container_name: debian_tests 106 | command: "sh -c 'pytest; pylint lshell; flake8 lshell'" 107 | volumes: 108 | - .:/app 109 | environment: 110 | - PYTHONPATH=/app 111 | 112 | fedora_tests: 113 | build: 114 | context: . 115 | dockerfile: Dockerfile 116 | args: 117 | DISTRO: "fedora:latest" 118 | container_name: fedora_tests 119 | command: "pytest && pylint lshell && flake8 lshell" 120 | volumes: 121 | - .:/app 122 | environment: 123 | - PYTHONPATH=/app 124 | 125 | centos_tests: 126 | build: 127 | context: . 128 | dockerfile: Dockerfile 129 | args: 130 | DISTRO: "centos:8" 131 | container_name: centos_tests 132 | command: "sh -c 'pytest-3 && pylint lshell && pyflake lshell'" 133 | volumes: 134 | - .:/app 135 | environment: 136 | - PYTHONPATH=/app 137 | 138 | alpine_tests: 139 | build: 140 | context: . 141 | dockerfile: Dockerfile 142 | args: 143 | DISTRO: "alpine:latest" 144 | container_name: alpine_tests 145 | command: "sh -c 'pytest && pylint lshell && flake8 lshell'" 146 | volumes: 147 | - .:/app 148 | environment: 149 | - PYTHONPATH=/app 150 | -------------------------------------------------------------------------------- /etc/logrotate.d/lshell: -------------------------------------------------------------------------------- 1 | # $Id: lshell,v 1.1 2010-03-02 00:05:58 ghantoos Exp $ 2 | 3 | /var/log/lshell/*.log { 4 | su root lshell 5 | rotate 12 6 | weekly 7 | compress 8 | missingok 9 | notifempty 10 | } 11 | -------------------------------------------------------------------------------- /etc/lshell.conf: -------------------------------------------------------------------------------- 1 | # lshell.py configuration file 2 | # 3 | # $Id: lshell.conf,v 1.27 2010-10-18 19:05:17 ghantoos Exp $ 4 | 5 | [global] 6 | ## log directory (default /var/log/lshell/ ) 7 | logpath : /var/log/lshell/ 8 | ## set log level to 0, 1, 2, 3 or 4 (0: no logs, 1: least verbose, 9 | ## 4: log all commands) 10 | loglevel : 2 11 | ## configure log file name (default is %u i.e. username.log) 12 | #logfilename : %y%m%d-%u 13 | #logfilename : syslog 14 | 15 | ## in case you are using syslog, you can choose your logname 16 | #syslogname : lshell 17 | 18 | ## Set path to sudo noexec library. This path is usually autodetected, only 19 | ## set this variable to use alternate path. If set and the shared object is 20 | ## not found, lshell will exit immediately. Otherwise, please check your logs 21 | ## to verify that a standard path is detected. 22 | ## 23 | ## while this should not be a common practice, setting this variable to an empty 24 | ## string will disable LD_PRELOAD prepend of the commands. This is done at your 25 | ## own risk, as lshell becomes easily breached using some commands like find(1) 26 | ## using the -exec flag. 27 | #path_noexec : '/usr/libexec/sudo_noexec.so' 28 | 29 | ## include a directory containing multiple configuration files. These files 30 | ## can only contain default/user/group configuration. The global configuration will 31 | ## only be loaded from the default configuration file. 32 | ## e.g. splitting users into separate files 33 | #include_dir : /etc/lshell.d/*.conf 34 | 35 | [default] 36 | ## a list of the allowed commands without execution privileges or 'all' to 37 | ## allow all commands in user's PATH 38 | ## 39 | ## if sudo(8) is installed and sudo_noexec.so is available, it will be loaded 40 | ## before running every command, preventing it from running further commands 41 | ## itself. If not available, beware of commands like vim/find/more/etc. that 42 | ## will allow users to execute code (e.g. /bin/sh) from within the application, 43 | ## thus easily escaping lshell. See variable 'path_noexec' to use an alternative 44 | ## path to library. 45 | allowed : ['ls','echo','ll','vim','tail','sleep','touch','mkdir'] 46 | #allowed : ['echo test'] # this will allow only the command 'echo test' 47 | 48 | ## A list of the allowed commands that are permitted to execute other 49 | ## programs (e.g. shell scripts with exec(3)). Setting this variable to 'all' 50 | ## is NOT allowed. Warning do not put here any command that can execute 51 | ## arbitrary commands (e.g. find, vim, xargs) 52 | ## 53 | ## Important: commands defined in 'allowed_shell_escape' override their 54 | ## definition in the 'allowed' variable 55 | #allowed_shell_escape : ['man','zcat'] 56 | 57 | ## A list of allowed file extensions that can be provided in the command line. 58 | ## If a list of allowed extensions is provided, all other file extensions will be disallowed. 59 | #allowed_file_extensions : ['.tmp', '.log'] 60 | 61 | ## a list of forbidden character or commands 62 | forbidden : [';','&', '|','`','>','<', '$(','${'] 63 | 64 | ## a list of allowed command to use with sudo(8) 65 | ## if set to ´all', all the 'allowed' commands will be accessible through sudo(8) 66 | #sudo_commands : ['ls', 'more'] 67 | 68 | ## number of warnings when user enters a forbidden value before getting 69 | ## exited from lshell, set to -1 to disable. 70 | warning_counter : 2 71 | 72 | ## command aliases list (similar to bash’s alias directive) 73 | aliases : {'ll':'ls -l'} 74 | 75 | ## introduction text to print (when entering lshell) 76 | #intro : "== My personal intro ==\nWelcome to lshell\nType '?' or 'help' to get the list of allowed commands" 77 | 78 | ## configure your prompt using %u or %h (default: username) 79 | # prompt : "%u@%h" 80 | ## colored prompt using ANSI escape codes colors (light red user, light cyan host) 81 | prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" 82 | 83 | 84 | ## set sort prompt current directory update (default: 0) 85 | #prompt_short : 0 86 | 87 | ## a value in seconds for the session timer 88 | #timer : 5 89 | 90 | ## list of path to restrict the user "geographicaly" 91 | ## warning: many commands like vi and less allow to break this restriction 92 | #path : ['/etc','/var/log','/var/lib'] 93 | 94 | ## set the home folder of your user. If not specified the home_path is set to 95 | ## the $HOME environment variable 96 | #home_path : '/home/bla/' 97 | 98 | ## update the environment variable $PATH of the user 99 | #env_path : '/usr/local/bin:/usr/sbin' 100 | 101 | ## a list of path; all executable files inside these path will be allowed 102 | #allowed_cmd_path: ['/home/bla/bin','/home/bla/stuff/libexec'] 103 | 104 | ## add environment variables 105 | #env_vars : {'foo':1, 'bar':'helloworld'} 106 | 107 | ## add environment variables from file 108 | ## file format is: export key=value, one per line 109 | #env_vars_files : ['$HOME/.lshell.env'] 110 | 111 | ## allow or forbid the use of scp (set to 1 or 0) 112 | #scp : 1 113 | 114 | ## forbid scp upload 115 | #scp_upload : 0 116 | 117 | ## forbid scp download 118 | #scp_download : 0 119 | 120 | ## allow of forbid the use of sftp (set to 1 or 0) 121 | ## this option will not work if you are using OpenSSH's internal-sftp service 122 | #sftp : 1 123 | 124 | ## list of command allowed to execute over ssh (e.g. rsync, rdiff-backup, etc.) 125 | #overssh : ['ls', 'rsync'] 126 | 127 | ## logging strictness. If set to 1, any unknown command is considered as 128 | ## forbidden, and user's warning counter is increased. If set to 0, command is 129 | ## considered as unknown, and user is only warned (i.e. *** unknown syntax) 130 | strict : 0 131 | 132 | ## force files sent through scp to a specific directory 133 | #scpforce : '/home/bla/uploads/' 134 | 135 | ## Enable support for WinSCP with scp mode (NOT sftp) 136 | ## When enabled, the following parameters will be overridden: 137 | ## - scp_upload: 1 (uses scp(1) from within session) 138 | ## - scp_download: 1 (uses scp(1) from within session) 139 | ## - scpforce - Ignore (uses scp(1) from within session) 140 | ## - forbidden: -[';'] 141 | ## - allowed: +['scp', 'env', 'pwd', 'groups', 'unset', 'unalias'] 142 | #winscp: 0 143 | 144 | ## history file maximum size 145 | #history_size : 100 146 | 147 | ## set history file name (default is /home/%u/.lhistory) 148 | #history_file : "/home/%u/.lshell_history" 149 | 150 | ## define the script to run at user login 151 | #login_script : "/path/to/myscript.sh" 152 | 153 | ## disable user exit, this could be useful when lshell is spawned from another 154 | ## none-restricted shell (e.g. bash) 155 | #disable_exit : 0 156 | -------------------------------------------------------------------------------- /lshell/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Limited command Shell (lshell) 3 | # 4 | # Copyright (C) 2008-2024 Ignace Mouzannar 5 | # 6 | # This file is part of lshell 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program. If not, see . 20 | -------------------------------------------------------------------------------- /lshell/builtincmd.py: -------------------------------------------------------------------------------- 1 | """ This module contains the built-in commands of lshell """ 2 | 3 | import glob 4 | import sys 5 | import os 6 | import readline 7 | import signal 8 | 9 | # import lshell specifics 10 | from lshell import variables 11 | from lshell import utils 12 | 13 | 14 | # Store background jobs 15 | BACKGROUND_JOBS = [] 16 | 17 | 18 | def cmd_lpath(conf): 19 | """lists allowed and forbidden path""" 20 | if conf["path"][0]: 21 | sys.stdout.write("Allowed:\n") 22 | lpath_allowed = conf["path"][0].split("|") 23 | lpath_allowed.sort() 24 | for path in lpath_allowed: 25 | if path: 26 | sys.stdout.write(f" {path}\n") 27 | if conf["path"][1]: 28 | sys.stdout.write("Denied:\n") 29 | lpath_denied = conf["path"][1].split("|") 30 | lpath_denied.sort() 31 | for path in lpath_denied: 32 | if path: 33 | sys.stdout.write(f" {path}\n") 34 | return 0 35 | 36 | 37 | def cmd_lsudo(conf): 38 | """lists allowed sudo commands""" 39 | if "sudo_commands" in conf and len(conf["sudo_commands"]) > 0: 40 | sys.stdout.write("Allowed sudo commands:\n") 41 | for command in conf["sudo_commands"]: 42 | sys.stdout.write(f" - {command}\n") 43 | return 0 44 | 45 | sys.stdout.write("No sudo commands allowed\n") 46 | return 1 47 | 48 | 49 | def cmd_history(conf, log): 50 | """print the commands history""" 51 | try: 52 | try: 53 | readline.write_history_file(conf["history_file"]) 54 | except IOError: 55 | log.error(f"WARN: couldn't write history to file {conf['history_file']}\n") 56 | return 1 57 | with open(conf["history_file"], "r", encoding="utf-8") as history_file: 58 | i = 1 59 | for item in history_file.readlines(): 60 | sys.stdout.write(f"{i}: {item}") 61 | i += 1 62 | except ( 63 | OSError, 64 | IOError, 65 | FileNotFoundError, 66 | ) as exception: # Catch specific exceptions 67 | log.critical(f"** Unable to read the history file: {exception}") 68 | return 1 69 | return 0 70 | 71 | 72 | def cmd_export(args): 73 | """export environment variables""" 74 | # if command contains at least 1 space 75 | if args.count(" "): 76 | env = args.split(" ", 1)[1] 77 | # if it contains the equal sign, consider only the first one 78 | if env.count("="): 79 | var, value = env.split(" ")[0].split("=")[0:2] 80 | # disallow dangerous variable 81 | if var in variables.FORBIDDEN_ENVIRON: 82 | return 1, var 83 | # Strip the quotes from the value if it begins and ends with quotes (single or double) 84 | if (value.startswith('"') and value.endswith('"')) or ( 85 | value.startswith("'") and value.endswith("'") 86 | ): 87 | value = value[1:-1] 88 | os.environ.update({var: value}) 89 | return 0, None 90 | 91 | 92 | def cmd_source(envfile): 93 | """Source a file in the current shell context""" 94 | envfile = os.path.expandvars(envfile) 95 | try: 96 | with open(envfile, encoding="utf-8") as env_vars: 97 | for env_var in env_vars.readlines(): 98 | if env_var.split(" ", 1)[0] == "export": 99 | cmd_export(env_var.strip()) 100 | except (OSError, IOError): 101 | sys.stderr.write(f"ERROR: Unable to read environment file: {envfile}\n") 102 | return 1 103 | return 0 104 | 105 | 106 | def cmd_cd(directory, conf): 107 | """implementation of the "cd" command""" 108 | # expand user's ~ 109 | directory = os.path.expanduser(directory) 110 | 111 | # remove quotes if present 112 | directory = directory.strip("'").strip('"') 113 | 114 | if len(directory) >= 1: 115 | # add wildcard completion support to cd 116 | if directory.find("*"): 117 | # get all files and directories matching wildcard 118 | wildall = glob.glob(directory) 119 | wilddir = [] 120 | # filter to only directories 121 | for item in wildall: 122 | if os.path.isdir(item): 123 | wilddir.append(item) 124 | # sort results 125 | wilddir.sort() 126 | # if any results are returned, pick first one 127 | if len(wilddir) >= 1: 128 | directory = wilddir[0] 129 | # go previous directory 130 | if directory == "-": 131 | directory = conf["oldpwd"] 132 | 133 | # store current directory in oldpwd variable 134 | conf["oldpwd"] = os.getcwd() 135 | 136 | # change directory 137 | try: 138 | os.chdir(os.path.realpath(directory)) 139 | conf["promptprint"] = utils.updateprompt(os.getcwd(), conf) 140 | except OSError as excp: 141 | sys.stdout.write(f"lshell: {directory}: {excp.strerror}\n") 142 | return excp.errno, conf 143 | else: 144 | os.chdir(conf["home_path"]) 145 | conf["promptprint"] = utils.updateprompt(os.getcwd(), conf) 146 | 147 | return 0, conf 148 | 149 | 150 | def check_background_jobs(): 151 | """Check the status of background jobs and print a completion message if done.""" 152 | global BACKGROUND_JOBS 153 | updated_jobs = [] 154 | for idx, job in enumerate(BACKGROUND_JOBS, start=1): 155 | if job.poll() is None: 156 | # Process is still running 157 | updated_jobs.append((idx, job.args, job.pid)) 158 | else: 159 | # Process has finished 160 | status = "Done" if job.returncode == 0 else "Failed" 161 | args = " ".join(job.args) 162 | # only print if the job has not been interrupted by the user 163 | if job.returncode != -2: 164 | print(f"[{idx}]+ {status} {args}") 165 | 166 | # Remove the job from the list of background jobs 167 | BACKGROUND_JOBS.pop(idx - 1) 168 | 169 | 170 | def get_job_status(job): 171 | """Return the status of a background job.""" 172 | if job.poll() is None: 173 | status = "Stopped" 174 | elif job.poll() == 0: 175 | status = "Completed" # Process completed successfully 176 | else: 177 | status = "Killed" # Process was killed or terminated with a non-zero code 178 | return status 179 | 180 | 181 | def jobs(): 182 | """Return a list of background jobs.""" 183 | global BACKGROUND_JOBS 184 | joblist = [] 185 | for idx, job in enumerate(BACKGROUND_JOBS, start=1): 186 | status = get_job_status(job) 187 | if status in ["Stopped", "Killed"]: 188 | if job.poll() is not None: 189 | BACKGROUND_JOBS.pop(idx - 1) 190 | continue 191 | cmd = " ".join(job.args) 192 | joblist.append([idx, status, cmd]) 193 | return joblist 194 | 195 | 196 | def cmd_jobs(): 197 | """List all backgrounded jobs.""" 198 | joblist = jobs() 199 | job_count = len(joblist) 200 | 201 | try: 202 | for i, job in enumerate(joblist, start=1): 203 | idx, status, cmd = job 204 | # Add '+' symbol for the most recent job 205 | job_symbol = "+" 206 | if job_count > 1: 207 | if i == job_count - 1: 208 | # Add '-' for the second-to-last job 209 | job_symbol = "-" 210 | elif i < job_count: 211 | # No symbol for other jobs 212 | job_symbol = " " 213 | print(f"[{idx}]{job_symbol} {status} {cmd}") 214 | except IndexError: 215 | return 1 216 | return 0 217 | 218 | 219 | def cmd_bg_fg(job_type, job_id): 220 | """Resume a backgrounded job.""" 221 | 222 | global BACKGROUND_JOBS 223 | if job_type == "bg": 224 | print("lshell: bg not supported") 225 | return 1 226 | 227 | if job_id: 228 | # Check if job ID is valid 229 | try: 230 | job_id = int(job_id) 231 | except ValueError: 232 | print("Invalid job ID.") 233 | return 1 234 | else: 235 | # Use the last job if no specific job_id is provided 236 | if BACKGROUND_JOBS: 237 | job_id = len(BACKGROUND_JOBS) 238 | else: 239 | print(f"lshell: {job_type}: current: no such job") 240 | return 1 241 | 242 | if 0 < job_id <= len(BACKGROUND_JOBS): 243 | job = BACKGROUND_JOBS[job_id - 1] 244 | if job.poll() is None: 245 | if job_type == "fg": 246 | try: 247 | print(" ".join(job.args)) 248 | # Bring it to the foreground and wait 249 | os.killpg(os.getpgid(job.pid), signal.SIGCONT) 250 | job.wait() 251 | # Remove the job from the list if it has completed 252 | if job.poll() is not None: 253 | BACKGROUND_JOBS.pop(job_id - 1) 254 | return 0 255 | except KeyboardInterrupt: 256 | os.killpg(os.getpgid(job.pid), signal.SIGINT) 257 | BACKGROUND_JOBS.pop(job_id - 1) 258 | return 130 259 | # bg not supported at the moment 260 | # elif job_type == "bg": 261 | # print(f"lshell: bg not supported") 262 | # return 1 263 | else: 264 | print(f"lshell: {job_type}: {job_id}: no such job") 265 | return 1 266 | else: 267 | print(f"lshell: {job_type}: {job_id}: no such job") 268 | return 1 269 | -------------------------------------------------------------------------------- /lshell/completion.py: -------------------------------------------------------------------------------- 1 | """Completion functions for lshell""" 2 | 3 | import os 4 | import re 5 | from lshell import sec 6 | 7 | 8 | def completedefault(*ignored): 9 | """Method called to complete an input line when no command-specific 10 | complete_*() method is available. 11 | 12 | By default, it returns an empty list. 13 | 14 | """ 15 | return [] 16 | 17 | 18 | def completenames(conf, text, line, *ignored): 19 | """This method is meant to override the original completenames method 20 | to overload it's output with the command available in the 'allowed' 21 | variable. This is useful when typing 'tab-tab' in the command prompt 22 | """ 23 | commands = conf["allowed"] 24 | if line.startswith("./"): 25 | return [cmd[2:] for cmd in commands if cmd.startswith(f"./{text}")] 26 | else: 27 | return [cmd for cmd in commands if cmd.startswith(text)] 28 | 29 | 30 | def complete_sudo(conf, text, line, begidx, endidx): 31 | """complete sudo command""" 32 | return [a for a in conf["sudo_commands"] if a.startswith(text)] 33 | 34 | 35 | def complete_change_dir(conf, text, line, begidx, endidx): 36 | """complete directories""" 37 | dirs_to_return = [] 38 | tocomplete = line.split(" ")[1] 39 | # replace "~" with home path 40 | tocomplete = re.sub("^~", conf["home_path"], tocomplete) 41 | 42 | # Detect relative vs absolute paths 43 | if not tocomplete.startswith("/"): 44 | # Resolve relative paths based on current working directory 45 | base_path = os.getcwd() 46 | tocomplete = os.path.normpath(os.path.join(base_path, tocomplete)) 47 | try: 48 | directory = os.path.realpath(tocomplete) 49 | except OSError: 50 | directory = os.getcwd() 51 | 52 | # if directory doesn't exist, take the parent directory 53 | if not os.path.isdir(directory): 54 | directory = directory.rsplit("/", 1)[0] 55 | if directory == "": 56 | directory = "/" 57 | 58 | directory = os.path.normpath(directory) 59 | 60 | # check path security 61 | ret_check_path, conf = sec.check_path(directory, conf, completion=1) 62 | 63 | # if path is secure, list subdirectories and files 64 | if ret_check_path == 0: 65 | for instance in os.listdir(directory): 66 | if os.path.isdir(os.path.join(directory, instance)): 67 | if instance.startswith(text): 68 | dirs_to_return.append(f"{instance}/") 69 | 70 | # if path is not secure, add completion based on allowed path 71 | else: 72 | allowed_paths = conf["path"][0].split("|") 73 | for instance in allowed_paths: 74 | # Check if the directory matches or is a parent of the allowed path 75 | if instance.startswith(directory) and instance.startswith(tocomplete): 76 | # Extract the next unmatched segment of the allowed path 77 | remaining_path = instance[len(directory) :].lstrip("/") 78 | if "/" in remaining_path: 79 | next_segment = remaining_path.split("/", 1)[0] + "/" 80 | else: 81 | next_segment = remaining_path + "/" 82 | 83 | # Add unique suggestions 84 | if next_segment and next_segment not in dirs_to_return: 85 | dirs_to_return.append(next_segment) 86 | 87 | return dirs_to_return 88 | 89 | 90 | def complete_list_dir(conf, text, line, begidx, endidx): 91 | """complete with files and directories""" 92 | results = [] 93 | tocomplete = line.split()[-1] 94 | # replace "~" with home path 95 | tocomplete = re.sub("^~", conf["home_path"], tocomplete) 96 | try: 97 | directory = os.path.realpath(tocomplete) 98 | except OSError: 99 | directory = os.getcwd() 100 | 101 | if not os.path.isdir(directory): 102 | directory = directory.rsplit("/", 1)[0] 103 | if directory == "": 104 | directory = "/" 105 | if not os.path.isdir(directory): 106 | directory = os.getcwd() 107 | 108 | ret_check_path, conf = sec.check_path(directory, conf, completion=1) 109 | if ret_check_path == 0: 110 | # if path is secure, list subdirectories and files 111 | list_dir = os.listdir(directory) 112 | for instance in list_dir: 113 | if os.path.isdir(os.path.join(directory, instance)): 114 | instance = instance + "/" 115 | else: 116 | instance = instance + " " 117 | results.append(instance) 118 | return results 119 | else: 120 | # if path is not secure, return nothing 121 | return None 122 | -------------------------------------------------------------------------------- /lshell/sec.py: -------------------------------------------------------------------------------- 1 | """ This module is used to check the security of the commands entered by the 2 | user. It checks if the command is allowed, if the path is allowed, if the 3 | command contains forbidden characters, etc. 4 | """ 5 | 6 | import sys 7 | import re 8 | import os 9 | import shlex 10 | import glob 11 | 12 | # import lshell specifics 13 | from lshell import utils 14 | 15 | 16 | def warn_count(messagetype, command, conf, strict=None, ssh=None): 17 | """Update the warning_counter, log and display a warning to the user""" 18 | 19 | log = conf["logpath"] 20 | 21 | if ssh: 22 | return 1, conf 23 | 24 | if strict: 25 | conf["warning_counter"] -= 1 26 | if conf["warning_counter"] < 0: 27 | log.critical(f'*** forbidden {messagetype} -> "{command}"') 28 | log.critical("*** Kicked out") 29 | sys.exit(1) 30 | else: 31 | log.critical(f'*** forbidden {messagetype} -> "{command}"') 32 | sys.stderr.write( 33 | f"*** You have {conf['warning_counter']} warning(s) left," 34 | " before getting kicked out.\n" 35 | ) 36 | log.error(f"*** User warned, counter: {conf['warning_counter']}") 37 | sys.stderr.write("This incident has been reported.\n") 38 | elif not conf["quiet"]: 39 | log.critical(f"*** forbidden {messagetype}: {command}") 40 | 41 | # Return 1 to indicate a warning was triggered. 42 | return 1, conf 43 | 44 | 45 | def tokenize_command(command): 46 | """Tokenize the command line into separate commands based on the operators""" 47 | 48 | try: 49 | lexer = shlex.shlex(command, posix=True) 50 | lexer.whitespace_split = True 51 | lexer.commenters = "" 52 | tokens = list(lexer) 53 | except ValueError: 54 | # Handle the exception and return an appropriate message or handle as needed 55 | return [] 56 | return tokens 57 | 58 | 59 | def expand_shell_wildcards(item): 60 | """Expand shell wildcards in the item and return the expanded path""" 61 | 62 | # Expand shell variables like $HOME 63 | item = os.path.expanduser(item) 64 | item = os.path.expandvars(item) 65 | item = os.path.realpath(item) # this is a hack - needs to be reviewed 66 | # test if item is a directory 67 | expanded_items = glob.glob(item, recursive=True) 68 | if expanded_items: 69 | # Return all matches instead of just the first one 70 | item = expanded_items[0] 71 | 72 | return item 73 | 74 | 75 | def check_path(line, conf, completion=None, ssh=None, strict=None): 76 | """Check if a path is entered in the line. If so, it checks if user 77 | are allowed to see this path. If user is not allowed, it calls 78 | warn_count. In case of completion, it only returns 0 or 1. 79 | """ 80 | allowed_path_re = str(conf["path"][0]) 81 | denied_path_re = str(conf["path"][1][:-1]) 82 | 83 | line = tokenize_command(line) 84 | 85 | for item in line: 86 | tomatch = expand_shell_wildcards(item) 87 | if os.path.isdir(tomatch) and tomatch[-1] != "/": 88 | tomatch += "/" 89 | match_allowed = re.findall(allowed_path_re, tomatch) 90 | if denied_path_re: 91 | match_denied = re.findall(denied_path_re, tomatch) 92 | else: 93 | match_denied = None 94 | 95 | # if path not allowed 96 | # case path executed: warn, and return 1 97 | # case completion: return 1 98 | if not match_allowed or match_denied: 99 | if not completion: 100 | ret, conf = warn_count("path", tomatch, conf, strict=strict, ssh=ssh) 101 | return 1, conf 102 | 103 | if not completion: 104 | if not re.findall(allowed_path_re, os.getcwd() + "/"): 105 | ret, conf = warn_count("path", tomatch, conf, strict=strict, ssh=ssh) 106 | os.chdir(conf["home_path"]) 107 | conf["promptprint"] = utils.updateprompt(os.getcwd(), conf) 108 | return 1, conf 109 | return 0, conf 110 | 111 | 112 | def check_secure(line, conf, strict=None, ssh=None): 113 | """This method is used to check the content on the typed command. 114 | Its purpose is to forbid the user to user to override the lshell 115 | command restrictions. 116 | The forbidden characters are placed in the 'forbidden' variable. 117 | Feel free to update the list. Emptying it would be quite useless..: ) 118 | 119 | A warning counter has been added, to kick out of lshell a user if he 120 | is warned more than X time (X being the 'warning_counter' variable). 121 | """ 122 | 123 | # store original string 124 | oline = line 125 | 126 | # strip all spaces/tabs 127 | line = line.strip() 128 | 129 | # init return code 130 | returncode = 0 131 | 132 | # This logic is kept crudely simple on purpose. 133 | # At most we might match the same stanza twice 134 | # (for e.g. "'a'", 'a') but the converse would 135 | # require detecting single quotation stanzas 136 | # nested within double quotes and vice versa 137 | relist = re.findall(r"[^=]\"(.+)\"", line) 138 | relist2 = re.findall(r"[^=]\'(.+)\'", line) 139 | relist = relist + relist2 140 | for item in relist: 141 | if os.path.exists(item): 142 | ret_check_path, conf = check_path(item, conf, strict=strict) 143 | returncode += ret_check_path 144 | 145 | # parse command line for control characters, and warn user 146 | if re.findall(r"[\x01-\x1F\x7F]", oline): 147 | ret, conf = warn_count("control char", oline, conf, strict=strict, ssh=ssh) 148 | return ret, conf 149 | 150 | for item in conf["forbidden"]: 151 | # allow '&&' and '||' even if singles are forbidden 152 | if item in ["&", "|"]: 153 | if re.findall(rf"[^\{item}]\{item}[^\{item}]", line): 154 | ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh) 155 | return ret, conf 156 | else: 157 | if item in line: 158 | ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh) 159 | return ret, conf 160 | 161 | # check if the line contains $(foo) executions, and check them 162 | executions = re.findall(r"\$\([^)]+[)]", line) 163 | for item in executions: 164 | # recurse on check_path 165 | ret_check_path, conf = check_path(item[2:-1].strip(), conf, strict=strict) 166 | returncode += ret_check_path 167 | 168 | # recurse on check_secure 169 | ret_check_secure, conf = check_secure(item[2:-1].strip(), conf, strict=strict) 170 | returncode += ret_check_secure 171 | 172 | # check for executions using back quotes '`' 173 | executions = re.findall(r"\`[^`]+[`]", line) 174 | for item in executions: 175 | ret_check_secure, conf = check_secure(item[1:-1].strip(), conf, strict=strict) 176 | returncode += ret_check_secure 177 | 178 | # check if the line contains ${foo=bar}, and check them 179 | curly = re.findall(r"\$\{[^}]+[}]", line) 180 | for item in curly: 181 | # split to get variable only, and remove last character "}" 182 | if re.findall(r"=|\+|\?|\-", item): 183 | variable = re.split(r"=|\+|\?|\-", item, 1) 184 | else: 185 | variable = item 186 | ret_check_path, conf = check_path(variable[1][:-1], conf, strict=strict) 187 | returncode += ret_check_path 188 | 189 | # if unknown commands where found, return 1 and don't execute the line 190 | if returncode > 0: 191 | return 1, conf 192 | # in case the $(foo) or `foo` command passed the above tests 193 | elif line.startswith("$(") or line.startswith("`"): 194 | return 0, conf 195 | 196 | lines = utils.split_commands(line) 197 | 198 | for separate_line in lines: 199 | # remove trailing parenthesis 200 | separate_line = re.sub(r"\)$", "", separate_line) 201 | separate_line = " ".join(separate_line.split()) 202 | splitcmd = separate_line.strip().split(" ") 203 | 204 | # Extract the command and its arguments 205 | command = splitcmd[0] 206 | command_args_list = splitcmd[1:] 207 | command_args_string = " ".join(command_args_list) 208 | full_command = f"{command} {command_args_string}".strip() 209 | 210 | # in case of a sudo command, check in sudo_commands list if allowed 211 | if command == "sudo" and command_args_list: 212 | # allow the -u (user) flag 213 | if command_args_list[0] == "-u" and command_args_list: 214 | sudocmd = command_args_list[2] 215 | else: 216 | sudocmd = command_args_list[0] 217 | if sudocmd not in conf["sudo_commands"] and command_args_list: 218 | ret, conf = warn_count( 219 | "sudo command", oline, conf, strict=strict, ssh=ssh 220 | ) 221 | return ret, conf 222 | 223 | # if over SSH, replaced allowed list with the one of overssh 224 | if ssh: 225 | conf["allowed"] = conf["overssh"] 226 | 227 | # # for all other commands check in allowed list 228 | # if command not in conf["allowed"] and command: 229 | # ret, conf = warn_count("command", command, conf, strict=strict, ssh=ssh) 230 | # return ret, conf 231 | 232 | # Check if the full command (with arguments) or just the command is allowed 233 | if ( 234 | full_command not in conf["allowed"] 235 | and command not in conf["allowed"] 236 | and command 237 | ): 238 | ret, conf = warn_count("command", command, conf, strict=strict, ssh=ssh) 239 | return ret, conf 240 | 241 | # Check if the command contains any forbidden extensions 242 | if conf.get("allowed_file_extensions"): 243 | allowed_extensions = conf["allowed_file_extensions"] 244 | check_extensions, disallowed_extensions = check_allowed_file_extensions( 245 | full_command, allowed_extensions 246 | ) 247 | if check_extensions is False: 248 | ret, conf = warn_count( 249 | f"file extension {disallowed_extensions}", 250 | full_command, 251 | conf, 252 | strict=strict, 253 | ssh=ssh, 254 | ) 255 | return ret, conf 256 | 257 | return 0, conf 258 | 259 | 260 | def check_allowed_file_extensions(command_line, allowed_extensions): 261 | """Checks if any file extensions in the command line are allowed.""" 262 | # Split the command using shlex to handle quotes and escape characters 263 | try: 264 | tokens = shlex.split(command_line) 265 | except ValueError as exception: 266 | # Log error or provide user feedback on the invalid input 267 | print(f"Error parsing command line: {exception}") 268 | return True, [] 269 | 270 | # Extract file extensions from tokens 271 | extensions_in_command = [] 272 | for token in tokens: 273 | match = re.search(r"\.\w+", token) 274 | if match: 275 | extensions_in_command.append(match.group()) 276 | 277 | # Check each extension against the allowed_extensions list 278 | disallowed_extensions = [ 279 | ext for ext in extensions_in_command if ext not in allowed_extensions 280 | ] 281 | 282 | # if len(disallowed_extensions) == 1: 283 | # disallowed_extensions = disallowed_extensions[0] 284 | 285 | if disallowed_extensions: 286 | return False, disallowed_extensions 287 | return True, None 288 | -------------------------------------------------------------------------------- /lshell/utils.py: -------------------------------------------------------------------------------- 1 | """ Utils for lshell """ 2 | 3 | import re 4 | import subprocess 5 | import os 6 | import sys 7 | import random 8 | import string 9 | import shlex 10 | from getpass import getuser 11 | from time import strftime, gmtime 12 | import signal 13 | 14 | # import lshell specifics 15 | from lshell import variables 16 | from lshell import builtincmd 17 | 18 | 19 | def usage(exitcode=1): 20 | """Prints the usage""" 21 | sys.stderr.write(variables.USAGE) 22 | sys.exit(exitcode) 23 | 24 | 25 | def version(): 26 | """Prints the version""" 27 | sys.stderr.write(f"lshell-{variables.__version__} - Limited Shell\n") 28 | sys.exit(0) 29 | 30 | 31 | def random_string(length): 32 | """generate a random string""" 33 | randstring = "" 34 | for char in range(length): 35 | char = random.choice(string.ascii_letters + string.digits) 36 | randstring += char 37 | 38 | return randstring 39 | 40 | 41 | def get_aliases(line, aliases): 42 | """Replace all configured aliases in the line""" 43 | 44 | for item in aliases.keys(): 45 | escaped_item = re.escape(item) 46 | reg1 = rf"(^|;|&&|\|\||\|)\s*{escaped_item}([ ;&\|]+|$)(.*)" 47 | reg2 = rf"(^|;|&&|\|\||\|)\s*{escaped_item}([ ;&\|]+|$)" 48 | 49 | # in case alias begins with the same command 50 | # (this is until i find a proper regex solution..) 51 | aliaskey = random_string(10) 52 | 53 | while re.findall(reg1, line): 54 | (before, after, rest) = re.findall(reg1, line)[0] 55 | linesave = line 56 | 57 | line = re.sub(reg2, f"{before} {aliaskey}{after}", line, count=1) 58 | 59 | # if line does not change after sub, exit loop 60 | if linesave == line: 61 | break 62 | 63 | # replace the key by the actual alias 64 | line = line.replace(aliaskey, aliases[item]) 65 | 66 | for char in [";"]: 67 | # remove all remaining double char 68 | line = line.replace(f"{char}{char}", f"{char}") 69 | return line 70 | 71 | 72 | def split_commands(line): 73 | """Split the command line into separate commands based on the operators""" 74 | # in case ';', '|' or '&' are not forbidden, check if in line 75 | lines = [] 76 | 77 | # Variable to track if we're inside quotes 78 | in_quotes = False 79 | 80 | # Starting position of the command segment 81 | if line[0] in ["&", "|", ";"]: 82 | start = 1 83 | else: 84 | start = 0 85 | 86 | # Iterate over the command line 87 | for i in range(1, len(line)): 88 | # Check for quotes to ignore splitting inside quoted strings 89 | if line[i] in ['"', "'"] and (i == 0 or line[i - 1] != "\\"): 90 | in_quotes = not in_quotes 91 | # Only split if we are not inside quotes and the current character 92 | # is an unescaped operator 93 | if line[i] in ["&", "|", ";"] and line[i - 1] != "\\" and not in_quotes: 94 | if start != i: 95 | lines.append(line[start:i]) 96 | start = i + 1 97 | 98 | # Append the last segment of the command 99 | if start != len(line): 100 | lines.append(line[start:]) 101 | 102 | return lines 103 | 104 | 105 | def split_command_args(line): 106 | """Split the command line into cmd and args""" 107 | # Use shlex to split the command into parts 108 | tokens = shlex.split(line) 109 | 110 | if tokens: 111 | # The first token is the command 112 | cmd = tokens[0] 113 | # The rest are the arguments 114 | args = " ".join(tokens[1:]) 115 | else: 116 | # If there are no tokens, return None for both 117 | cmd, args = "", "" 118 | 119 | return cmd, args 120 | 121 | 122 | def replace_exit_code(line, retcode): 123 | """Replace the exit code in the command line. Replaces all occurrences of 124 | $? with the exit code.""" 125 | if re.search(r"[;&\|]", line): 126 | pattern = re.compile(r"(\s|^)(\$\?)([\s|$]?[;&|].*)") 127 | else: 128 | pattern = re.compile(r"(\s|^)(\$\?)(\s|$)") 129 | 130 | line = pattern.sub(rf" {retcode} \3", line) 131 | 132 | return line 133 | 134 | 135 | def cmd_parse_execute(command_line, shell_context=None): 136 | """Parse and execute a shell command line""" 137 | # Split command line by shell grammar: '&&', '||', and ';;' 138 | cmd_split = re.split(r"(;|&&|\|\|)", command_line) 139 | 140 | # Initialize return code 141 | retcode = 0 142 | 143 | # Iterate over commands and operators 144 | for i in range(0, len(cmd_split), 2): 145 | command = cmd_split[i].strip() 146 | operator = cmd_split[i - 1].strip() if i > 0 else None 147 | 148 | # Skip empty commands 149 | if not command: 150 | continue 151 | 152 | # Only execute commands based on the previous operator and return code 153 | if operator == "&&" and retcode != 0: 154 | continue 155 | elif operator == "||" and retcode == 0: 156 | continue 157 | 158 | # Get the executable command 159 | try: 160 | executable, argument = re.split(r"\s+", command, maxsplit=1) 161 | except ValueError: 162 | executable, argument = command, "" 163 | 164 | # Check if command is in built-ins list or execute it via exec_cmd 165 | if executable in variables.builtins_list: 166 | if executable == "help": 167 | shell_context.do_help(command) 168 | elif executable == "exit": 169 | shell_context.do_exit(command) 170 | elif executable == "history": 171 | builtincmd.cmd_history(shell_context.conf, shell_context.log) 172 | elif executable == "cd": 173 | retcode = builtincmd.cmd_cd(argument, shell_context.conf) 174 | else: 175 | retcode = getattr(builtincmd, executable)(shell_context.conf) 176 | else: 177 | if "path_noexec" in shell_context.conf: 178 | os.environ["LD_PRELOAD"] = shell_context.conf["path_noexec"] 179 | command = replace_exit_code(command, retcode) 180 | retcode = exec_cmd(command) 181 | 182 | return retcode 183 | 184 | 185 | def exec_cmd(cmd): 186 | """Execute a command exactly as entered, with support for backgrounding via Ctrl+Z.""" 187 | 188 | class CtrlZException(Exception): 189 | """Custom exception to handle Ctrl+Z (SIGTSTP).""" 190 | 191 | pass 192 | 193 | def handle_sigtstp(signum, frame): 194 | """Handle SIGTSTP (Ctrl+Z) by sending the process to the background.""" 195 | if proc and proc.poll() is None: # Ensure process is running 196 | proc.send_signal(signal.SIGSTOP) # Stop the process 197 | builtincmd.BACKGROUND_JOBS.append(proc) # Add process to background jobs 198 | job_id = len(builtincmd.BACKGROUND_JOBS) 199 | sys.stdout.write(f"\n[{job_id}]+ Stopped {cmd}\n") 200 | sys.stdout.flush() 201 | raise CtrlZException() # Raise custom exception for SIGTSTP handling 202 | 203 | def handle_sigcont(signum, frame): 204 | """Handle SIGCONT to resume a stopped job in the foreground.""" 205 | if proc and proc.poll() is None: 206 | proc.send_signal(signal.SIGCONT) 207 | 208 | # Check if the command is to be run in the background 209 | background = cmd.strip().endswith("&") 210 | if background: 211 | # Remove '&' and strip any extra spaces 212 | cmd = cmd[:-1].strip() 213 | 214 | try: 215 | # Register SIGTSTP (Ctrl+Z) and SIGCONT (resume) signal handlers 216 | signal.signal(signal.SIGTSTP, handle_sigtstp) 217 | signal.signal(signal.SIGCONT, handle_sigcont) 218 | cmd_args = shlex.split(cmd) 219 | if background: 220 | with open(os.devnull, "r") as devnull_in: 221 | proc = subprocess.Popen( 222 | cmd_args, 223 | stdin=devnull_in, # Redirect input to /dev/null 224 | stdout=sys.stdout, 225 | stderr=sys.stderr, 226 | preexec_fn=os.setsid, 227 | ) 228 | # add to background jobs and return 229 | builtincmd.BACKGROUND_JOBS.append(proc) 230 | job_id = len(builtincmd.BACKGROUND_JOBS) 231 | print(f"[{job_id}] {cmd} (pid: {proc.pid})") 232 | retcode = 0 233 | else: 234 | proc = subprocess.Popen(cmd_args, preexec_fn=os.setsid) 235 | proc.communicate() 236 | retcode = proc.returncode if proc.returncode is not None else 0 237 | 238 | except FileNotFoundError: 239 | sys.stderr.write( 240 | f"Command '{cmd_args[0]}' not found in $PATH or not installed on the system.\n" 241 | ) 242 | retcode = 127 243 | except CtrlZException: # Handle Ctrl+Z 244 | retcode = 0 245 | except KeyboardInterrupt: # Handle Ctrl+C 246 | if proc and proc.poll() is None: 247 | os.killpg(os.getpgid(proc.pid), signal.SIGINT) 248 | retcode = 130 249 | 250 | return retcode 251 | 252 | 253 | def parse_ps1(ps1): 254 | """Parse and format $PS1-style prompt with lshell-compatible values""" 255 | user = getuser() 256 | host = os.uname()[1] 257 | cwd = os.getcwd() 258 | home = os.path.expanduser("~") 259 | prompt_symbol = "#" if os.geteuid() == 0 else "$" 260 | 261 | # Define LPS1 replacement mappings 262 | replacements = { 263 | r"\u": user, 264 | r"\h": host.split(".")[0], 265 | r"\H": host, 266 | r"\w": cwd.replace(home, "~", 1) if cwd.startswith(home) else cwd, 267 | r"\W": os.path.basename(cwd), 268 | r"\$": prompt_symbol, 269 | r"\\": "\\", 270 | r"\t": strftime("%H:%M:%S", gmtime()), 271 | r"\T": strftime("%I:%M:%S", gmtime()), 272 | r"\A": strftime("%H:%M", gmtime()), 273 | r"\@": strftime("%I:%M:%S%p", gmtime()), 274 | r"\d": strftime("%a %b %d", gmtime()), 275 | } 276 | # Replace each placeholder with its corresponding value 277 | for placeholder, value in replacements.items(): 278 | ps1 = ps1.replace(placeholder, value) 279 | 280 | return ps1 281 | 282 | 283 | def getpromptbase(conf): 284 | """Get the base prompt structure, using $PS1 or defaulting to config-based prompt""" 285 | ps1_env = os.getenv("LPS1") 286 | if ps1_env: 287 | # Use $LPS1 with placeholders if defined 288 | promptbase = parse_ps1(ps1_env) 289 | else: 290 | # Fallback to configured prompt if no $PS1 is defined 291 | promptbase = conf.get("prompt", "%u") 292 | promptbase = promptbase.replace("%u", getuser()) 293 | promptbase = promptbase.replace("%h", os.uname()[1].split(".")[0]) 294 | 295 | return promptbase 296 | 297 | 298 | def updateprompt(path, conf): 299 | """Set the prompt with updated path and user privilege level, supporting $LPS1 format""" 300 | promptbase = getpromptbase(conf) 301 | prompt_symbol = "# " if os.geteuid() == 0 else "$ " 302 | 303 | # Determine dynamic path display if $LPS1 is not defined 304 | if os.getenv("LPS1"): 305 | prompt = promptbase 306 | else: 307 | if path == conf["home_path"]: 308 | current_path = "~" 309 | elif conf.get("prompt_short") == 1: 310 | current_path = os.path.basename(path) 311 | elif conf.get("prompt_short") == 2: 312 | current_path = path 313 | elif path.startswith(conf["home_path"]): 314 | current_path = f"~{path[len(conf['home_path']):]}" 315 | else: 316 | current_path = path 317 | prompt = f"{promptbase}:{current_path}{prompt_symbol}" 318 | 319 | return prompt 320 | -------------------------------------------------------------------------------- /lshell/variables.py: -------------------------------------------------------------------------------- 1 | """ This file contains all the variables used in lshell """ 2 | 3 | import sys 4 | import os 5 | 6 | __version__ = "0.10.10" 7 | 8 | # Required config variable list per user 9 | required_config = ["allowed", "forbidden", "warning_counter"] 10 | 11 | # set configuration file path depending on sys.exec_prefix 12 | # on *Linux sys.exec_prefix = '/usr' and default path must be in '/etc' 13 | # on *BSD sys.exec_prefix = '/usr/{pkg,local}/' and default path 14 | # is '/usr/{pkg,local}/etc' 15 | lshell_path = os.path.abspath(__file__).split("/lib/")[0] 16 | if sys.exec_prefix != "/usr": 17 | # for *BSD 18 | CONF_PREFIX = sys.exec_prefix 19 | elif lshell_path == "/home/ghantoos/.local": 20 | # for *Linux user install 21 | CONF_PREFIX = lshell_path 22 | else: 23 | # for *Linux system-wide install 24 | CONF_PREFIX = "" 25 | configfile = CONF_PREFIX + "/etc/lshell.conf" 26 | 27 | # history file 28 | HISTORY_FILE = ".lhistory" 29 | 30 | # help text 31 | USAGE = f"""Usage: lshell [OPTIONS] 32 | --config : Config file location (default {configfile}) 33 | -- : where is *any* config file parameter 34 | -h, --help : Show this help message 35 | --version : Show version 36 | """ 37 | 38 | # Intro Text 39 | INTRO = """You are in a limited shell. 40 | Type '?' or 'help' to get the list of allowed commands""" 41 | 42 | # configuration parameters 43 | configparams = [ 44 | "config=", 45 | "help", 46 | "version", 47 | "quiet=", 48 | "log=", 49 | "logpath=", 50 | "loglevel=", 51 | "logfilename=", 52 | "syslogname=", 53 | "allowed=", 54 | "allowed_file_extensions=", 55 | "forbidden=", 56 | "sudo_commands=", 57 | "warning_counter=", 58 | "aliases=", 59 | "intro=", 60 | "prompt=", 61 | "prompt_short=", 62 | "timer=", 63 | "path=", 64 | "home_path=", 65 | "env_path=", 66 | "allowed_cmd_path=", 67 | "env_vars=", 68 | "env_vars_files=", 69 | "scp=", 70 | "scp_upload=", 71 | "scp_download=", 72 | "sftp=", 73 | "overssh=", 74 | "strict=", 75 | "scpforce=", 76 | "history_size=", 77 | "history_file=", 78 | "path_noexec=", 79 | "allowed_shell_escape=", 80 | "winscp=", 81 | "disable_exit=", 82 | "include_dir=", 83 | ] 84 | 85 | builtins_list = [ 86 | "cd", 87 | "clear", 88 | "exit", 89 | "export", 90 | "history", 91 | "lpath", 92 | "lsudo", 93 | "help", 94 | "fg", 95 | "bg", 96 | "jobs", 97 | ] 98 | 99 | FORBIDDEN_ENVIRON = ( 100 | "LD_AOUT_LIBRARY_PATH", 101 | "LD_AOUT_PRELOAD", 102 | "LD_LIBRARY_PATH", 103 | "LD_PRELOAD", 104 | "LD_ORIGIN_PATH", 105 | "LD_DEBUG_OUTPUT", 106 | "LD_PROFILE", 107 | "GCONV_PATH", 108 | "HOSTALIASES", 109 | "LOCALDOMAIN", 110 | "LOCPATH", 111 | "MALLOC_TRACE", 112 | "NLSPATH", 113 | "RESOLV_HOST_CONF", 114 | "RES_OPTIONS", 115 | "TMPDIR", 116 | "TZDIR", 117 | "LD_USE_LOAD_BIAS", 118 | "LD_DEBUG", 119 | "LD_DYNAMIC_WEAK", 120 | "LD_SHOW_AUXV", 121 | "GETCONF_DIR", 122 | "LD_AUDIT", 123 | "NIS_PATH", 124 | "PATH", 125 | ) 126 | -------------------------------------------------------------------------------- /man/lshell.1: -------------------------------------------------------------------------------- 1 | .\" 2 | .\" Man page for the Limited Shell (lshell) project. 3 | .\" 4 | .TH lshell 1 "October, 2024" "v0.10.10" 5 | 6 | .SH NAME 7 | lshell \- Limited Shell 8 | 9 | .SH SYNOPSIS 10 | .B lshell 11 | [\fIOPTIONS\fR] 12 | 13 | .SH DESCRIPTION 14 | \fBlshell\fR provides a limited shell configured per user. The configuration is done 15 | quite simply using a configuration file. You can also use lshell within scripts by 16 | adding the appropriate shebang (#!/usr/bin/lshell) to your script file. 17 | 18 | Coupled with ssh's 19 | .I authorized_keys 20 | or with 21 | .I /etc/shells 22 | and 23 | .I /etc/passwd 24 | , it becomes very easy to restrict user's access to a limited set of command. 25 | 26 | .SH OPTIONS 27 | .TP 28 | .B \--config \fI\fR 29 | Specify config file 30 | .TP 31 | .B \--log \fI\fR 32 | Specify the log directory 33 | .TP 34 | .B \-- \fI\fR 35 | where is *any* config file parameter 36 | .TP 37 | .B \-h, --help 38 | Show help message 39 | .TP 40 | .B \--version 41 | Show version 42 | 43 | .SH CONFIGURATION 44 | You can configure lshell through its configuration file: 45 | .RS 46 | .ft 3 47 | .nf 48 | .sp 49 | On Linux \-> /etc/lshell.conf 50 | On *BSD \-> /usr/{pkg,local}/etc/lshell.conf 51 | .ft 52 | .LP 53 | .RE 54 | .fi 55 | The configuration is dynamically reloaded. Which means that you can edit 56 | the configuration, and all the connected users will automatically load it. In 57 | case you are using multiple configuration files (see include_dir), you will 58 | need to refresh the main configuration's timestamp, in order to reload the 59 | configuration: 60 | .RS 61 | .ft 3 62 | .nf 63 | .sp 64 | touch /path/to/lshell.conf 65 | .ft 66 | .LP 67 | .RE 68 | .fi 69 | \fBlshell\fR configuration has 4 types of sections: 70 | .RS 71 | .ft 3 72 | .nf 73 | .sp 74 | [global] -> lshell system configuration (only 1) 75 | [default] -> lshell default user configuration (only 1) 76 | [foo] -> UNIX username "foo" specific configuration 77 | [grp:bar] -> UNIX groupname "bar" specific configuration 78 | .ft 79 | .LP 80 | .RE 81 | .fi 82 | Order of priority when loading preferences is the following: 83 | .RS 84 | .ft 3 85 | .nf 86 | .sp 87 | 1- User configuration 88 | 2- Group configuration 89 | 3- Default configuration 90 | .ft 91 | .LP 92 | .RE 93 | .fi 94 | .SS [global] 95 | .TP 96 | .I logpath 97 | config path (default is /var/log/lshell/) 98 | .TP 99 | .I loglevel 100 | 0, 1, 2, 3 or 4 (0: no logs -> 4: logs everything) 101 | .TP 102 | .I logfilename 103 | \- set to \fBsyslog\fR in order to log to syslog 104 | .RS 105 | \- set log file name, e.g. %u-%y%m%d (i.e foo-20091009.log): 106 | .BR \ \ \ \ %u 107 | -> username 108 | .RE 109 | .RS 110 | .BR \ \ \ \ %d 111 | -> day [1..31] 112 | .RE 113 | .RS 114 | .BR \ \ \ \ %m 115 | -> month [1..12] 116 | .RE 117 | .RS 118 | .BR \ \ \ \ %y 119 | -> year [00..99] 120 | .RE 121 | .RS 122 | .BR \ \ \ \ %h 123 | -> time [00:00..23:59] 124 | .RE 125 | .TP 126 | .I syslogname 127 | in case you are using syslog, set your logname (default: lshell) 128 | .TP 129 | .I include_dir 130 | include a directory containing multiple configuration files. 131 | These files can only contain default/user/group configuration. The 132 | global configuration will only be loaded from the default configuration 133 | file. This variable will be expanded (e.g. /path/*.conf). 134 | .TP 135 | .I path_noexec 136 | set path to sudo noexec library. This path is usually autodetected, only set 137 | this variable to use alternate path. If set and the shared object is not found, 138 | lshell will exit immediately. Otherwise, please check your logs to verify that 139 | a standard path is detected. 140 | 141 | while this should not be a common practice, setting this variable to an empty 142 | string will disable LD_PRELOAD prepend of the commands. This is done at your 143 | own risk, as lshell becomes easily breached using some commands like find(1) 144 | using the -exec flag. 145 | .RS 146 | .SS [default] and/or [username] and/or [grp:groupname] 147 | .TP 148 | .TP 149 | .I aliases 150 | command aliases list (similar to bash's alias directive) 151 | .TP 152 | .I allowed 153 | A list of allowed commands, or set to 'all' to allow all commands in the user's PATH. 154 | .RS 155 | .BR \ \ \ \ - 156 | If a command is specified without arguments (e.g., 'echo'), the command is allowed to run with any arguments. 157 | .RE 158 | .RS 159 | .BR \ \ \ \ - 160 | If a command is specified with exact arguments (e.g., 'echo asd'), only that specific command with those arguments will be allowed. 161 | .RE 162 | .RS 163 | .BR \ \ \ \ - 164 | To allow all commands in the user's PATH, use the value 'all'. 165 | .RE 166 | if sudo(8) is installed and sudo_noexec.so is available, it will be loaded 167 | before running every command, preventing it from running further commands 168 | itself. If not available, beware of commands like vim/find/more/etc. that will 169 | allow users to execute code (e.g. /bin/sh) from within the application, 170 | thus easily escaping lshell. See variable 'path_noexec' to use an alternative 171 | path to library. 172 | .TP 173 | .I allowed_shell_escape 174 | a list of the allowed commands that are permitted to execute other programs 175 | (e.g. shell scripts with exec(3)). Setting this variable to 'all' is NOT 176 | allowed. Warning: do not put here any command that can execute arbitrary 177 | commands (e.g. find, vim, xargs). 178 | 179 | important: commands defined in 'allowed_shell_escape' override their definition 180 | in the \'allowed\' variable. 181 | .TP 182 | .I allowed_file_extension 183 | a list of allowed file extensions that can be provided in the command line. 184 | If a list of allowed extensions is provided, all other file extensions will be disallowed. 185 | .TP 186 | .I allowed_cmd_path 187 | a list of path; all executable files inside these path will be allowed 188 | .TP 189 | .I disable_exit 190 | disable user exit, this could be useful when lshell is spawned from another 191 | none-restricted shell (e.g. bash) 192 | .TP 193 | .I env_path 194 | update the environment variable $PATH of the user (optional) 195 | .TP 196 | .I env_vars 197 | set environment variables (optional) 198 | .TP 199 | .I env_vars_files 200 | specify a list of files containing environment variables (optional) 201 | .TP 202 | .I forbidden 203 | a list of forbidden characters or commands 204 | .TP 205 | .I history_file 206 | set the history filename. A wildcard can be used: 207 | .RS 208 | .BR \ \ \ \ %u 209 | -> username (e.g. '/home/%u/.lhistory') 210 | .RE 211 | .TP 212 | .I history_size 213 | set the maximum size (in lines) of the history file 214 | .TP 215 | .I home_path (deprecated) 216 | set the home folder of your user. If not specified, the home directory is set \ 217 | to the $HOME environment variable. This variable will be removed in the next \ 218 | version of lshell, please use your system's tools to set a user's home \ 219 | directory. A wildcard can be used: 220 | .RS 221 | .BR \ \ \ \ %u 222 | -> username (e.g. '/home/%u') 223 | .RE 224 | .TP 225 | .I intro 226 | set the introduction to print at login 227 | .TP 228 | .I login_script 229 | define the script to run at user login 230 | .TP 231 | .I passwd 232 | password of specific user (default is empty) 233 | .TP 234 | .I path 235 | list of path to restrict the user geographically. It is possible to use \ 236 | wildcards (e.g. '/var/log/ap*'). 237 | .TP 238 | .I prompt 239 | set the user's prompt format (default: username) 240 | .RS 241 | .BR \ \ \ \ %u 242 | -> username 243 | .RE 244 | .RS 245 | .BR \ \ \ \ %h 246 | -> hostname 247 | .RE 248 | .TP 249 | .I prompt_short 250 | set prompt style for current directory - 0, 1 or 2. Default is 0. 251 | .RS 252 | .BR \ \ \ \ 0 253 | -> will show the current directory as compared to home directory ~/current/dir 254 | .RE 255 | .RS 256 | .BR \ \ \ \ 1 257 | -> will only show the current directory name 258 | .RE 259 | .RS 260 | .BR \ \ \ \ 2 261 | -> will show the complete path to the current directory 262 | .RE 263 | .TP 264 | .I LPS1 265 | LShell supports prompt customization using the \fB$LPS1\fR environment variable, similar to \fB$PS1\fR in bash. If \fB$LPS1\fR is defined, it takes precedence over \fIprompt\fR and allows for dynamic prompt customization using placeholders. Available placeholders need to be prepended with a backslash (\\) and include: 266 | .RS 267 | .B \ \ \ \ u 268 | -> username of the current user 269 | .RE 270 | .RS 271 | .BR \ \ \ \ h 272 | -> short hostname (up to first dot) 273 | .RE 274 | .RS 275 | .BR \ \ \ \ H 276 | -> full hostname 277 | .RE 278 | .RS 279 | .BR \ \ \ \ w 280 | -> current working directory, with home directory replaced by ~ 281 | .RE 282 | .RS 283 | .BR \ \ \ \ W 284 | -> basename of the current directory 285 | .RE 286 | .RS 287 | .BR \ \ \ \ $ 288 | -> prompt symbol (# for root, $ otherwise) 289 | .RE 290 | .RS 291 | .BR \ \ \ \ t 292 | -> current time in HH:MM:SS (24-hour format) 293 | .RE 294 | .RS 295 | .BR \ \ \ \ T 296 | -> current time in HH:MM:SS (12-hour format) 297 | .RE 298 | .RS 299 | .BR \ \ \ \ A 300 | -> current time in HH:MM (24-hour format, without seconds) 301 | .RE 302 | .RS 303 | .BR \ \ \ \ @ 304 | -> current time in HH:MM AM/PM format 305 | .RE 306 | .RS 307 | .BR \ \ \ \ d 308 | -> current date in Weekday Month Day format (e.g., Mon Mar 01) 309 | .RE 310 | .LP 311 | .I overssh 312 | list of command allowed to execute over ssh (e.g. rsync, rdiff-backup, scp, \ 313 | etc.) 314 | .TP 315 | .I scp 316 | allow or forbid the use of scp connection - set to 1 or 0 317 | .TP 318 | .I scpforce 319 | force files sent through scp to a specific directory 320 | .TP 321 | .I scp_download 322 | set to 0 to forbid scp downloads (default is 1) 323 | .TP 324 | .I scp_upload 325 | set to 0 to forbid scp uploads (default is 1) 326 | .TP 327 | .I sftp 328 | allow or forbid the use of sftp connection - set to 1 or 0. 329 | 330 | WARNING: This option will not work if you are using OpenSSH's \ 331 | internal-sftp service (e.g. when configured in chroot) 332 | .TP 333 | .I sudo_commands 334 | a list of the allowed commands that can be used with sudo(8). If set to \ 335 | \'all', all the 'allowed' commands will be accessible through sudo(8). 336 | 337 | It is possible to use the -u sudo flag in order to run a command as a \ 338 | different user than the default root. 339 | .TP 340 | .I timer 341 | a value in seconds for the session timer 342 | .TP 343 | .I strict 344 | logging strictness. If set to 1, any unknown command is considered as \ 345 | forbidden, and user's warning counter is decreased. If set to 0, command is \ 346 | considered as unknown, and user is only warned (i.e. *** unknown synthax) 347 | .TP 348 | .I warning_counter 349 | number of warnings when user enters a forbidden value before getting exited \ 350 | from lshell. Set to \fB\-1\fR to disable the counter, and just warn the user. 351 | .TP 352 | .I winscp 353 | enable support for WinSCP with scp mode (NOT sftp) 354 | 355 | When enabled, the following parameters will be overridden: 356 | .RS 357 | .BR \ \ \ \ scp_upload : 358 | 1 (uses scp(1) from within session) 359 | .RE 360 | .RS 361 | .BR \ \ \ \ scp_download: 362 | 1 (uses scp(1) from within session) 363 | .RE 364 | .RS 365 | .BR \ \ \ \ scpforce : 366 | ignored (uses scp(1) from within session) 367 | .RE 368 | .RS 369 | .BR \ \ \ \ forbidden : 370 | -[';'] 371 | .RE 372 | .RS 373 | .BR \ \ \ \ allowed : 374 | +['scp', 'env', 'pwd', 'groups', 'unset', 'unalias'] 375 | .RE 376 | 377 | .SH SHELL BUILTIN COMMANDS 378 | Here is the set of commands that are always available with lshell: 379 | .TP 380 | .I clear 381 | clears the terminal 382 | .TP 383 | .I export 384 | name of exported shell variable. Disabled by default, enable it by adding it \ 385 | to allowed commands. 386 | .TP 387 | .I help, ? 388 | print the list of allowed commands 389 | .TP 390 | . I history 391 | print the commands history 392 | .TP 393 | . I lpath 394 | lists all allowed and forbidden path 395 | .TP 396 | . I lsudo 397 | lists all sudo allowed commands 398 | 399 | .SH EXAMPLES 400 | .TP 401 | .B $ lshell 402 | .RS 403 | Tries to run lshell using default ${PREFIX}/etc/lshell.conf as configuration \ 404 | file. If it fails a warning is printed and lshell is interrupted. 405 | lshell options are loaded from the configuration file 406 | .RE 407 | .TP 408 | .B $ lshell --config /path/to/myconf.file --log /path/to/mylog.log 409 | .RS 410 | This will override the default options specified for configuration and/or log \ 411 | file 412 | .RE 413 | .TP 414 | .B $ ./test_script.lsh 415 | .RS 416 | If you include lshell in a script with the \fBshebang (e.g. #!/usr/bin/lshell)\fR and use the \fB`.lsh` extension\fR: 417 | .sp 418 | .nf 419 | .ft 3 420 | #!/usr/bin/lshell 421 | echo "Hello World!" 422 | .fi 423 | .ft 424 | 425 | Ensure the script has a \fB.lsh\fR extension to indicate it is intended for lshell. 426 | 427 | This allows for limited shell commands to be executed within the script while maintaining restrictions. 428 | .RE 429 | 430 | .SH USE CASE 431 | The primary goal of lshell, was to be able to create shell accounts \ 432 | with ssh access and restrict their environment to a couple a needed \ 433 | commands. 434 | In this example, User 'foo' and user 'bar' both belong to the 'users' UNIX \ 435 | group: 436 | .TP 437 | .B User foo: 438 | .RS 439 | - must be able to access /usr and /var but not /usr/local 440 | - user all command in his PATH but 'su' 441 | - has a warning counter set to 5 442 | - has his home path set to '/home/users' 443 | .RE 444 | .TP 445 | .B User bar: 446 | .RS 447 | - must be able to access /etc and /usr but not /usr/local 448 | - is allowed default commands plus 'ping' minus 'ls' 449 | - strictness is set to 1 (meaning he is not allowed to type an unknown command) 450 | .RE 451 | 452 | In this case, my configuration file will look something like this: 453 | .RS 454 | .ft 3 455 | .nf 456 | .sp 457 | # CONFIURATION START 458 | [global] 459 | logpath : /var/log/lshell/ 460 | loglevel : 2 461 | 462 | [default] 463 | allowed : ['ls','pwd'] 464 | forbidden : [';', '&', '|'] 465 | warning_counter : 2 466 | timer : 0 467 | path : ['/etc', '/usr'] 468 | env_path : ':/sbin:/usr/bin/' 469 | scp : 1 # or 0 470 | sftp : 1 # or 0 471 | overssh : ['rsync','ls'] 472 | aliases : {'ls':'ls \-\-color=auto','ll':'ls \-l'} 473 | 474 | [grp:users] 475 | warning_counter : 5 476 | overssh : - ['ls'] 477 | 478 | [foo] 479 | allowed : 'all' - ['su'] 480 | path : ['/var', '/usr'] - ['/usr/local'] 481 | home_path : '/home/users' 482 | 483 | [bar] 484 | allowed : + ['ping'] - ['ls'] 485 | path : - ['/usr/local'] 486 | strict : 1 487 | scpforce : '/home/bar/uploads/' 488 | # CONFIURATION END 489 | .ft 490 | .LP 491 | .RE 492 | .fi 493 | 494 | .SH NOTES 495 | .TP 496 | In order to log a user's warnings into the logging directory (default \ 497 | \fI/var/log/lshell/\fR) , you must firt create the folder (if it doesn't \ 498 | exist yet) and chown it to lshell group: 499 | .RS 500 | .ft 3 501 | .nf 502 | .sp 503 | # addgroup \-\-system lshell 504 | # mkdir /var/log/lshell 505 | # chown :lshell /var/log/lshell 506 | # chmod 770 /var/log/lshell 507 | .ft 508 | .LP 509 | .RE 510 | .fi 511 | 512 | then add the user to the \fIlshell\fR group: 513 | .RS 514 | .ft 3 515 | .nf 516 | .sp 517 | # usermod \-aG lshell user_name 518 | .ft 519 | .LP 520 | .RE 521 | .fi 522 | 523 | In order to set lshell as default shell for a user: 524 | .RS 525 | .ft 3 526 | .nf 527 | .sp 528 | On Linux: 529 | # chsh \-s /usr/bin/lshell user_name 530 | 531 | On *BSD: 532 | # chsh \-s /usr/{pkg,local}/bin/lshell user_name 533 | .ft 534 | .LP 535 | .RE 536 | .fi 537 | 538 | .SH AUTHOR 539 | Currently maintained by Ignace Mouzannar (ghantoos) 540 | 541 | .SH EMAIL 542 | Feel free to send me your recommendations at 543 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | cache_dir = /tmp/.lshell_pytest_cache 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | configparser 2 | logging 3 | readline 4 | pexpect -------------------------------------------------------------------------------- /rpm/lshell.spec: -------------------------------------------------------------------------------- 1 | %define name lshell 2 | %define version 0.9.16 3 | %define release 1 4 | %define python_sitelib %(python -c "from distutils.sysconfig import get_python_lib; print get_python_lib()") 5 | 6 | Summary: Limited Shell 7 | Name: %{name} 8 | Version: %{version} 9 | Release: %{release} 10 | Source0: %{name}-%{version}.tar.gz 11 | License: GPL 12 | Group: System Environment/Shells 13 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot 14 | Prefix: %{_prefix} 15 | BuildRequires: python >= 2.4 16 | Requires: python >= 2.4 17 | BuildArch: noarch 18 | Vendor: Ignace Mouzannar (ghantoos) 19 | Url: http://lshell.ghantoos.org 20 | 21 | %description 22 | lshell is a shell coded in Python that lets you restrict a user's environment 23 | to limited sets of commands, choose to enable/disable any command over SSH 24 | (e.g. SCP, SFTP, rsync, etc.), log user's commands, implement timing 25 | restrictions, and more. 26 | 27 | %prep 28 | %setup -q 29 | 30 | %build 31 | %{__python} setup.py build 32 | 33 | %install 34 | %{__python} setup.py install --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES --skip-build 35 | 36 | %clean 37 | rm -rf $RPM_BUILD_ROOT 38 | 39 | %post 40 | #!/bin/sh 41 | # 42 | # $Id: lshell.spec,v 1.14 2010-10-17 15:47:21 ghantoos Exp $ 43 | # 44 | # RPM build postinstall script 45 | 46 | # case of installation 47 | if [ "$1" = "1" ] ; then 48 | if ! getent group lshell 2>&1 > /dev/null; then 49 | # thank you Michael Mansour for your suggestion to use groupadd 50 | # instead of addgroup 51 | groupadd -r lshell 52 | fi 53 | mkdir -p /var/log/lshell/ 54 | chown root:lshell /var/log/lshell/ 55 | chmod -R 770 /var/log/lshell/ 56 | 57 | 58 | ##### 59 | # This part is taken from debian add-shell(8) script 60 | ##### 61 | 62 | lshell=/usr/bin/lshell 63 | file=/etc/shells 64 | tmpfile=${file}.tmp 65 | 66 | set -o noclobber 67 | 68 | trap "rm -f ${tmpfile}" EXIT 69 | 70 | if ! cat ${file} > ${tmpfile} 71 | then 72 | cat 1>&2 <> ${tmpfile} 83 | fi 84 | chmod --reference=${file} ${tmpfile} 85 | chown --reference=${file} ${tmpfile} 86 | 87 | mv ${tmpfile} ${file} 88 | 89 | trap "" EXIT 90 | exit 0 91 | 92 | # case of upgrade 93 | else 94 | mkdir -p /var/log/lshell/ 95 | chown root:lshell /var/log/lshell/ 96 | chmod -R 774 /var/log/lshell/ 97 | 98 | exit 0 99 | 100 | fi 101 | 102 | 103 | 104 | %postun 105 | #!/bin/sh 106 | # 107 | # $Id: lshell.spec,v 1.14 2010-10-17 15:47:21 ghantoos Exp $ 108 | # 109 | # RPM build postuninstall script 110 | 111 | if [ -x /usr/sbin/remove-shell ] && [ -f /etc/shells ]; then 112 | ##### 113 | # This part is taken from debian remove-shell(8) script 114 | ##### 115 | 116 | lshell=/usr/bin/lshell 117 | file=/etc/shells 118 | # I want this to be GUARANTEED to be on the same filesystem as $file 119 | tmpfile=${file}.tmp 120 | otmpfile=${file}.tmp2 121 | 122 | set -o noclobber 123 | 124 | trap "rm -f ${tmpfile} ${otmpfile}" EXIT 125 | 126 | if ! cat ${file} > ${tmpfile} 127 | then 128 | cat 1>&2 < ${otmpfile} || true 137 | mv ${otmpfile} ${tmpfile} 138 | 139 | chmod --reference=${file} ${tmpfile} 140 | chown --reference=${file} ${tmpfile} 141 | 142 | mv ${tmpfile} ${file} 143 | 144 | trap "" EXIT 145 | exit 0 146 | fi 147 | 148 | %files 149 | %defattr(644,root,root,755) 150 | %doc /usr/share/doc/lshell/* 151 | %config(noreplace) %verify(not md5 mtime size) %{_sysconfdir}/* 152 | %attr(755,root,root) %{_bindir}/lshell 153 | %{python_sitelib}/* 154 | %{_mandir}/man1/lshell.1* 155 | -------------------------------------------------------------------------------- /rpm/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # $Id: postinstall,v 1.7 2009-07-28 18:33:02 ghantoos Exp $ 4 | # 5 | # RPM build postinstall script 6 | 7 | # Check if rpm is being _installed_ (as opposed to _upgraded_) 8 | # if installation process, then proceed 9 | # source: http://www.ibm.com/developerworks/library/l-rpm3.html 10 | # case of installation 11 | if [ "$1" = "1" ] ; then 12 | 13 | if ! getent group lshell 2>&1 > /dev/null; then 14 | addgroup --system lshell 15 | fi 16 | 17 | chown root:lshell /var/log/lshell/ 18 | chmod 770 /var/log/lshell/ 19 | 20 | ##### 21 | # This part is taken from debian add-shell(8) script 22 | ##### 23 | 24 | lshell=/usr/bin/lshell 25 | file=/etc/shells 26 | tmpfile=${file}.tmp 27 | 28 | set -o noclobber 29 | 30 | trap "rm -f ${tmpfile}" EXIT 31 | 32 | if ! cat ${file} > ${tmpfile} 33 | then 34 | cat 1>&2 <> ${tmpfile} 45 | fi 46 | chmod --reference=${file} ${tmpfile} 47 | chown --reference=${file} ${tmpfile} 48 | 49 | mv ${tmpfile} ${file} 50 | 51 | trap "" EXIT 52 | exit 0 53 | 54 | # case of upgrade 55 | else 56 | chown root:lshell /var/log/lshell/ 57 | chmod -R 770 /var/log/lshell/ 58 | 59 | mv /etc/lshell.conf /etc/lshell.conf-rpm 60 | mv /etc/lshell.conf-preinstall /etc/lshell.conf 61 | exit 0 62 | 63 | fi 64 | 65 | -------------------------------------------------------------------------------- /rpm/postuninstall: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # $Id: postuninstall,v 1.6 2009-03-09 13:59:40 ghantoos Exp $ 4 | # 5 | # RPM build postuninstall script 6 | 7 | # Check if rpm is being _removed_ (as opposed to _upgraded_) 8 | # if deletion process, then proceed, else, exit 0 9 | # source: http://www.ibm.com/developerworks/library/l-rpm3.html 10 | if [ "$1" != "0" ] ; then 11 | if [ -f "/etc/lshell.conf" ]; then 12 | cp /etc/lshell.conf /etc/lshell.conf-preinstall 13 | fi 14 | exit 0 15 | fi 16 | 17 | #groupdel lshellg 18 | rm -f /etc/lshell.conf-rpm 19 | 20 | ##### 21 | # This part is taken from debian remove-shell(8) script 22 | ##### 23 | 24 | lshell=/usr/bin/lshell 25 | file=/etc/shells 26 | # I want this to be GUARANTEED to be on the same filesystem as $file 27 | tmpfile=${file}.tmp 28 | otmpfile=${file}.tmp2 29 | 30 | set -o noclobber 31 | 32 | trap "rm -f ${tmpfile} ${otmpfile}" EXIT 33 | 34 | if ! cat ${file} > ${tmpfile} 35 | then 36 | cat 1>&2 < ${otmpfile} || true 45 | mv ${otmpfile} ${tmpfile} 46 | 47 | chmod --reference=${file} ${tmpfile} 48 | chown --reference=${file} ${tmpfile} 49 | 50 | mv ${tmpfile} ${file} 51 | 52 | trap "" EXIT 53 | exit 0 54 | 55 | -------------------------------------------------------------------------------- /rpm/preinstall: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # $Id: preinstall,v 1.2 2009-02-15 18:46:58 ghantoos Exp $ 4 | # 5 | # RPM build preinstall script 6 | 7 | # Save the configuration 8 | if [ -f "/etc/lshell.conf" ]; then 9 | cp /etc/lshell.conf /etc/lshell.conf-preinstall 10 | fi 11 | 12 | exit 0 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ Setup script for lshell """ 2 | 3 | import os 4 | import shutil 5 | from setuptools import setup, find_packages 6 | from setuptools.command.install import install 7 | 8 | # import lshell specifics 9 | from lshell.variables import __version__ 10 | 11 | 12 | class CustomInstallCommand(install): 13 | """Customized setuptools install command to handle etc files.""" 14 | 15 | def run(self): 16 | # Call the standard install first 17 | install.run(self) 18 | 19 | # Determine correct configuration paths 20 | if os.geteuid() != 0: # If not root, use ~/.local/etc 21 | etc_install_dir = os.path.join(os.path.expanduser("~"), ".local/etc") 22 | else: # For system-wide install, use /etc 23 | etc_install_dir = "/etc" 24 | 25 | # Create necessary directories if they don't exist 26 | os.makedirs(os.path.join(etc_install_dir, "logrotate.d"), exist_ok=True) 27 | 28 | # Copy configuration files to appropriate directories 29 | shutil.copy("etc/lshell.conf", etc_install_dir) 30 | shutil.copy( 31 | "etc/logrotate.d/lshell", os.path.join(etc_install_dir, "logrotate.d") 32 | ) 33 | 34 | 35 | if __name__ == "__main__": 36 | 37 | with open("README.md", "r") as f: 38 | long_description = f.read() 39 | 40 | setup( 41 | name="limited-shell", 42 | version=__version__, 43 | description="lshell - Limited Shell", 44 | long_description=long_description, 45 | long_description_content_type="text/markdown", 46 | author="Ignace Mouzannar", 47 | author_email="ghantoos@ghantoos.org", 48 | maintainer="Ignace Mouzannar", 49 | maintainer_email="ghantoos@ghantoos.org", 50 | keywords=["limited", "shell", "security", "python"], 51 | url="https://github.com/ghantoos/lshell", 52 | project_urls={ 53 | "GitHub": "https://github.com/ghantoos/lshell", 54 | "Changelog": "https://github.com/ghantoos/lshell/blob/master/CHANGELOG.md", 55 | }, 56 | license="GPL-3", 57 | platforms=["UNIX"], 58 | scripts=["bin/lshell"], 59 | package_dir={"lshell": "lshell"}, 60 | packages=find_packages(exclude=["test", "test.*"]), 61 | include_package_data=True, 62 | data_files=[ 63 | ("etc", ["etc/lshell.conf"]), 64 | ("etc/logrotate.d", ["etc/logrotate.d/lshell"]), 65 | ( 66 | "share/doc/lshell", 67 | ["README.md", "COPYING", "CHANGELOG.md", "SECURITY.md"], 68 | ), 69 | ("share/man/man1/", ["man/lshell.1"]), 70 | ], 71 | classifiers=[ 72 | "Development Status :: 5 - Production/Stable", 73 | "Environment :: Console", 74 | "Intended Audience :: Information Technology", 75 | "Intended Audience :: System Administrators", 76 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 77 | "Operating System :: POSIX", 78 | "Programming Language :: Python :: 3", 79 | "Topic :: Security", 80 | "Topic :: System :: Shells", 81 | "Topic :: System :: System Shells", 82 | "Topic :: System :: Systems Administration", 83 | "Topic :: Terminals", 84 | ], 85 | python_requires=">=3.6", 86 | install_requires=[], 87 | cmdclass={ 88 | "install": CustomInstallCommand, # Use custom install command 89 | }, 90 | ) 91 | -------------------------------------------------------------------------------- /source: -------------------------------------------------------------------------------- 1 | # source me in order to update your python path 2 | # and test using the local lshell files 3 | # this is useful when you have multiple lshell installations 4 | 5 | export PYTHONPATH=$PWD/ 6 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """ This module is used to run all the tests in the test directory. """ 2 | 3 | from test import ( 4 | test_builtins, 5 | test_command_execution, 6 | test_completion, 7 | test_config, 8 | test_env_vars, 9 | test_exit, 10 | test_file_extension, 11 | test_path, 12 | test_ps2, 13 | test_regex, 14 | test_scripts, 15 | test_security, 16 | test_signals, 17 | test_ssh, 18 | test_unit, 19 | ) 20 | 21 | 22 | if __name__ == "__main__": 23 | test_builtins.unittest.main() 24 | test_command_execution.unittest.main() 25 | test_completion.unittest.main() 26 | test_config.unittest.main() 27 | test_env_vars.unittest.main() 28 | test_exit.unittest.main() 29 | test_file_extension.unittest.main() 30 | test_path.unittest.main() 31 | test_ps2.unittest.main() 32 | test_regex.unittest.main() 33 | test_scripts.unittest.main() 34 | test_security.unittest.main() 35 | test_signals.unittest.main() 36 | test_ssh.unittest.main() 37 | test_unit.unittest.main() 38 | -------------------------------------------------------------------------------- /test/template.lsh: -------------------------------------------------------------------------------- 1 | #!SHEBANG 2 | 3 | echo "test" 4 | dig google.com 5 | ls /tmp 6 | echo FREEDOM && help () sh && help 7 | cd / -------------------------------------------------------------------------------- /test/test_builtins.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell built-in commands""" 2 | 3 | import os 4 | import unittest 5 | import subprocess 6 | from getpass import getuser 7 | import pexpect 8 | 9 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 10 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 11 | LSHELL = f"{TOPDIR}/bin/lshell" 12 | USER = getuser() 13 | PROMPT = f"{USER}:~\\$" 14 | 15 | 16 | class TestFunctions(unittest.TestCase): 17 | """Functional tests for lshell""" 18 | 19 | def setUp(self): 20 | """spawn lshell with pexpect and return the child""" 21 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 22 | self.child.expect(PROMPT) 23 | 24 | def tearDown(self): 25 | self.child.close() 26 | 27 | def do_exit(self, child): 28 | """Exit the shell""" 29 | child.sendline("exit") 30 | child.expect(pexpect.EOF) 31 | 32 | def test_01_welcome_message(self): 33 | """F01 | lshell welcome message""" 34 | expected = ( 35 | "You are in a limited shell.\r\nType '?' or 'help' to get" 36 | " the list of allowed commands\r\n" 37 | ) 38 | result = self.child.before.decode("utf8") 39 | self.assertEqual(expected, result) 40 | 41 | def test_02_builtin_ls_command(self): 42 | """F02 | built-in ls command""" 43 | p = subprocess.Popen( 44 | "ls ~", shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE 45 | ) 46 | cout = p.stdout 47 | expected = cout.read(-1) 48 | self.child.sendline("ls") 49 | self.child.expect(PROMPT) 50 | output = self.child.before.decode("utf8").split("ls\r", 1)[1] 51 | self.assertEqual(len(expected.strip().split()), len(output.strip().split())) 52 | 53 | def test_06_builtin_cd_change_dir(self): 54 | """F06 | built-in cd - change directory""" 55 | expected = "" 56 | home = os.path.expanduser("~") 57 | dirpath = None 58 | for path in os.listdir(home): 59 | dirpath = os.path.join(home, path) 60 | if os.path.isdir(dirpath): 61 | break 62 | if dirpath: 63 | self.child.sendline(f"cd {path}") 64 | self.child.expect(f"{USER}:~/{path}\\$") 65 | self.child.sendline("cd ..") 66 | self.child.expect(PROMPT) 67 | result = self.child.before.decode("utf8").split("\n", 1)[1] 68 | self.assertEqual(expected, result) 69 | 70 | def test_07_builtin_cd_tilda(self): 71 | """F07 | built-in cd - tilda bug""" 72 | expected = ( 73 | '*** forbidden path -> "/etc/passwd"\r\n*** You have' 74 | " 1 warning(s) left, before getting kicked out.\r\nThis " 75 | "incident has been reported.\r\n" 76 | ) 77 | self.child.sendline("ls ~/../../etc/passwd") 78 | self.child.expect(PROMPT) 79 | result = self.child.before.decode("utf8").split("\n", 1)[1] 80 | self.assertEqual(expected, result) 81 | 82 | def test_08_builtin_cd_quotes(self): 83 | """F08 | built-in - quotes in cd "/" """ 84 | expected = ( 85 | '*** forbidden path -> "/"\r\n*** You have' 86 | " 1 warning(s) left, before getting kicked out.\r\nThis " 87 | "incident has been reported.\r\n" 88 | ) 89 | self.child.sendline('ls -ld "/"') 90 | self.child.expect(PROMPT) 91 | result = self.child.before.decode("utf8").split("\n", 1)[1] 92 | self.assertEqual(expected, result) 93 | 94 | def test_18_cd_exitcode_with_separator_internal_cmd(self): 95 | """F18 | built-in command exit codes with separator""" 96 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') 97 | child.expect(PROMPT) 98 | 99 | expected = "2" 100 | child.sendline("cd nRVmmn8RGypVneYIp8HxyVAvaEaD55; echo $?") 101 | child.expect(PROMPT) 102 | child.sendline("echo $?") 103 | child.expect(PROMPT) 104 | result = child.before.decode("utf8").split("\n")[1].strip() 105 | self.assertEqual(expected, result) 106 | self.do_exit(child) 107 | 108 | def test_19_cd_exitcode_without_separator_external_cmd(self): 109 | """F19 | built-in exit codes without separator""" 110 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') 111 | child.expect(PROMPT) 112 | 113 | expected = "2" 114 | child.sendline("cd nRVmmn8RGypVneYIp8HxyVAvaEaD55") 115 | child.expect(PROMPT) 116 | child.sendline("echo $?") 117 | child.expect(PROMPT) 118 | result = child.before.decode("utf8").split("\n")[1].strip() 119 | self.assertEqual(expected, result) 120 | self.do_exit(child) 121 | 122 | def test_20_cd_with_cmd_unknwon_dir(self): 123 | """F20 | test built-in cd with command when dir does not exist 124 | Should be returning error, not executing cmd 125 | """ 126 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') 127 | child.expect(PROMPT) 128 | 129 | expected = ( 130 | "lshell: nRVmmn8RGypVneYIp8HxyVAvaEaD55: No such file or " "directory" 131 | ) 132 | 133 | child.sendline("cd nRVmmn8RGypVneYIp8HxyVAvaEaD55; echo $?") 134 | child.expect(PROMPT) 135 | result = child.before.decode("utf8").split("\n")[1].strip() 136 | self.assertEqual(expected, result) 137 | self.do_exit(child) 138 | 139 | def test_68_source_nonexistent_file(self): 140 | """F68 | Test sourcing a nonexistent environment file shows an error""" 141 | 142 | # Define a nonexistent file path 143 | env_file = "does_not_exist" 144 | 145 | # Start lshell and attempt to source the nonexistent file 146 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['source']\"") 147 | child.expect(PROMPT) 148 | 149 | # Source the nonexistent file and check for an error message 150 | child.sendline(f"source {env_file}") 151 | child.expect(PROMPT) 152 | 153 | output = child.before.decode("utf-8").split("\n")[1].strip() 154 | expected_output = f"ERROR: Unable to read environment file: {env_file}" 155 | 156 | assert ( 157 | output == expected_output 158 | ), f"Expected '{expected_output}', got '{output}'" 159 | 160 | # Clean up and end session 161 | self.do_exit(child) 162 | 163 | def test_69_source_valid_file(self): 164 | """F69 | Test sourcing a valid environment file sets variables""" 165 | 166 | # Write a sample environment file 167 | env_file = "random_test_env" 168 | with open(env_file, "w") as file: 169 | file.write("export TEST_VAR='test_value'\n") 170 | 171 | # Start lshell and source the environment file 172 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['source']\"") 173 | child.expect(PROMPT) 174 | 175 | # Source the file and check if the variable is set 176 | child.sendline(f"source {env_file}") 177 | child.expect(PROMPT) 178 | child.sendline("echo $TEST_VAR") 179 | child.expect(PROMPT) 180 | 181 | output = child.before.decode("utf-8").split("\n")[1].strip() 182 | expected_output = "test_value" 183 | 184 | assert ( 185 | output == expected_output 186 | ), f"Expected '{expected_output}', got '{output}'" 187 | 188 | # Clean up and end session 189 | self.do_exit(child) 190 | 191 | def test_70_source_overwrite_variable(self): 192 | """F70 | Test sourcing a file overwrites existing environment variables""" 193 | 194 | # Write a sample environment file 195 | env_file = "test_env_overwrite" 196 | with open(env_file, "w") as file: 197 | file.write("export TEST_VAR='new_value'\n") 198 | 199 | # Start lshell, set initial variable, and source file to overwrite it 200 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['source']\"") 201 | child.expect(PROMPT) 202 | 203 | # Set initial variable and source the file 204 | child.sendline("export TEST_VAR='initial_value'") 205 | child.expect(PROMPT) 206 | child.sendline(f"source {env_file}") 207 | child.expect(PROMPT) 208 | child.sendline("echo $TEST_VAR") 209 | child.expect(PROMPT) 210 | 211 | output = child.before.decode("utf-8").split("\n")[1].strip() 212 | expected_output = "new_value" 213 | 214 | assert ( 215 | output == expected_output 216 | ), f"Expected '{expected_output}', got '{output}'" 217 | 218 | # Clean up and end session 219 | self.do_exit(child) 220 | -------------------------------------------------------------------------------- /test/test_command_execution.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell command execution""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | # pylint: disable=C0411 9 | from test import test_utils 10 | 11 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 12 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 13 | LSHELL = f"{TOPDIR}/bin/lshell" 14 | USER = getuser() 15 | PROMPT = f"{USER}:~\\$" 16 | 17 | 18 | class TestFunctions(unittest.TestCase): 19 | """Functional tests for lshell""" 20 | 21 | def setUp(self): 22 | """spawn lshell with pexpect and return the child""" 23 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 24 | self.child.expect(PROMPT) 25 | 26 | def tearDown(self): 27 | self.child.close() 28 | 29 | def do_exit(self, child): 30 | """Exit the shell""" 31 | child.sendline("exit") 32 | child.expect(pexpect.EOF) 33 | 34 | def test_03_external_echo_command_num(self): 35 | """F03 | external echo number""" 36 | expected = "32" 37 | self.child.sendline("echo 32") 38 | self.child.expect(PROMPT) 39 | result = self.child.before.decode("utf8").split()[2] 40 | self.assertEqual(expected, result) 41 | 42 | def test_04_external_echo_command_string(self): 43 | """F04 | external echo random string""" 44 | expected = "bla blabla 32 blibli! plop." 45 | self.child.sendline(f'echo "{expected}"') 46 | self.child.expect(PROMPT) 47 | result = self.child.before.decode("utf8").split("\n", 1)[1].strip() 48 | self.assertEqual(expected, result) 49 | 50 | def test_16a_exitcode_with_separator_external_cmd(self): 51 | """F16(a) | external command exit codes with separator""" 52 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') 53 | child.expect(PROMPT) 54 | 55 | if test_utils.is_alpine_linux(): 56 | expected_1 = "ls: nRVmmn8RGypVneYIp8HxyVAvaEaD55: No such file or directory" 57 | else: 58 | expected_1 = ( 59 | "ls: cannot access 'nRVmmn8RGypVneYIp8HxyVAvaEaD55': " 60 | "No such file or directory" 61 | ) 62 | expected_2 = "blabla" 63 | expected_3 = "0" 64 | child.sendline("ls nRVmmn8RGypVneYIp8HxyVAvaEaD55; echo blabla; echo $?") 65 | child.expect(PROMPT) 66 | result = child.before.decode("utf8").split("\n") 67 | result_1 = result[1].strip() 68 | result_2 = result[2].strip() 69 | result_3 = result[3].strip() 70 | self.assertEqual(expected_1, result_1) 71 | self.assertEqual(expected_2, result_2) 72 | self.assertEqual(expected_3, result_3) 73 | self.do_exit(child) 74 | 75 | def test_16b_exitcode_with_separator_external_cmd(self): 76 | """F16(b) | external command exit codes with separator""" 77 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') 78 | child.expect(PROMPT) 79 | 80 | if test_utils.is_alpine_linux(): 81 | expected_1 = "ls: nRVmmn8RGypVneYIp8HxyVAvaEaD55: No such file or directory" 82 | expected_2 = "1" 83 | else: 84 | expected_1 = ( 85 | "ls: cannot access 'nRVmmn8RGypVneYIp8HxyVAvaEaD55': " 86 | "No such file or directory" 87 | ) 88 | expected_2 = "2" 89 | child.sendline("ls nRVmmn8RGypVneYIp8HxyVAvaEaD55; echo $?") 90 | child.expect(PROMPT) 91 | result = child.before.decode("utf8").split("\n") 92 | result_1 = result[1].strip() 93 | result_2 = result[2].strip() 94 | self.assertEqual(expected_1, result_1) 95 | self.assertEqual(expected_2, result_2) 96 | self.do_exit(child) 97 | 98 | def test_17_exitcode_without_separator_external_cmd(self): 99 | """F17 | external command exit codes without separator""" 100 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') 101 | child.expect(PROMPT) 102 | 103 | if test_utils.is_alpine_linux(): 104 | expected = "1" 105 | else: 106 | expected = "2" 107 | child.sendline("ls nRVmmn8RGypVneYIp8HxyVAvaEaD55") 108 | child.expect(PROMPT) 109 | child.sendline("echo $?") 110 | child.expect(PROMPT) 111 | result = child.before.decode("utf8").split("\n")[1].strip() 112 | self.assertEqual(expected, result) 113 | self.do_exit(child) 114 | 115 | def test_24_cd_and_command(self): 116 | """F24 | cd && command should not be interpreted by internal function""" 117 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG}") 118 | child.expect(PROMPT) 119 | 120 | expected = "OK" 121 | child.sendline('cd ~ && echo "OK"') 122 | child.expect(PROMPT) 123 | result = child.before.decode("utf8").split("\n")[1].strip() 124 | self.assertEqual(expected, result) 125 | self.do_exit(child) 126 | 127 | def test_33_ls_non_existing_directory_and_echo(self): 128 | """Test: ls non_existing_directory && echo nothing""" 129 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG}") 130 | child.expect(PROMPT) 131 | 132 | child.sendline("ls non_existing_directory && echo nothing") 133 | child.expect(PROMPT) 134 | 135 | output = child.before.decode("utf8").split("\n", 1)[1].strip() 136 | # Since ls fails, echo nothing shouldn't run 137 | self.assertNotIn("nothing", output) 138 | self.do_exit(child) 139 | 140 | def test_34_ls_and_echo_ok(self): 141 | """Test: ls && echo OK""" 142 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG}") 143 | child.expect(PROMPT) 144 | 145 | child.sendline("ls && echo OK") 146 | child.expect(PROMPT) 147 | 148 | output = child.before.decode("utf8").split("\n", 1)[1].strip() 149 | # ls succeeds, echo OK should run 150 | self.assertIn("OK", output) 151 | self.do_exit(child) 152 | 153 | def test_35_ls_non_existing_directory_or_echo_ok(self): 154 | """Test: ls non_existing_directory || echo OK""" 155 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG}") 156 | child.expect(PROMPT) 157 | 158 | child.sendline("ls non_existing_directory || echo OK") 159 | child.expect(PROMPT) 160 | 161 | output = child.before.decode("utf8").split("\n", 1)[1].strip() 162 | # ls fails, echo OK should run 163 | self.assertIn("OK", output) 164 | self.do_exit(child) 165 | 166 | def test_36_ls_or_echo_nothing(self): 167 | """Test: ls || echo nothing""" 168 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG}") 169 | child.expect(PROMPT) 170 | 171 | child.sendline("ls || echo nothing") 172 | child.expect(PROMPT) 173 | 174 | output = child.before.decode("utf8").split("\n", 1)[1].strip() 175 | # ls succeeds, echo nothing should not run 176 | self.assertNotIn("nothing", output) 177 | self.do_exit(child) 178 | 179 | def test_41_multicmd_with_wrong_arg_should_fail(self): 180 | """F20 | Allowing 'echo asd': Test 'echo qwe' should fail""" 181 | child = pexpect.spawn( 182 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" 183 | ) 184 | child.expect(PROMPT) 185 | 186 | expected = "*** forbidden command: echo" 187 | 188 | child.sendline("echo qwe") 189 | child.expect(PROMPT) 190 | result = child.before.decode("utf8").split("\n")[1].strip() 191 | self.assertEqual(expected, result) 192 | self.do_exit(child) 193 | 194 | def test_42_multicmd_with_near_exact_arg_should_fail(self): 195 | """F41 | Allowing 'echo asd': Test 'echo asds' should fail""" 196 | child = pexpect.spawn( 197 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" 198 | ) 199 | child.expect(PROMPT) 200 | 201 | expected = "*** forbidden command: echo" 202 | 203 | child.sendline("echo asds") 204 | child.expect(PROMPT) 205 | result = child.before.decode("utf8").split("\n")[1].strip() 206 | self.assertEqual(expected, result) 207 | self.do_exit(child) 208 | 209 | def test_43_multicmd_without_arg_should_fail(self): 210 | """F42 | Allowing 'echo asd': Test 'echo' should fail""" 211 | child = pexpect.spawn( 212 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" 213 | ) 214 | child.expect(PROMPT) 215 | 216 | expected = "*** forbidden command: echo" 217 | 218 | child.sendline("echo") 219 | child.expect(PROMPT) 220 | result = child.before.decode("utf8").split("\n")[1].strip() 221 | self.assertEqual(expected, result) 222 | self.do_exit(child) 223 | 224 | def test_44_multicmd_asd_should_pass(self): 225 | """F43 | Allowing 'echo asd': Test 'echo asd' should pass""" 226 | 227 | child = pexpect.spawn( 228 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" 229 | ) 230 | child.expect(PROMPT) 231 | 232 | expected = "asd" 233 | 234 | child.sendline("echo asd") 235 | child.expect(PROMPT) 236 | result = child.before.decode("utf8").split("\n")[1].strip() 237 | self.assertEqual(expected, result) 238 | self.do_exit(child) 239 | -------------------------------------------------------------------------------- /test/test_completion.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell completion""" 2 | 3 | import os 4 | import unittest 5 | import subprocess 6 | from getpass import getuser 7 | from test.test_utils import is_alpine_linux 8 | import pexpect 9 | 10 | 11 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 12 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 13 | LSHELL = f"{TOPDIR}/bin/lshell" 14 | USER = getuser() 15 | PROMPT = f"{USER}:~\\$" 16 | 17 | 18 | class TestFunctions(unittest.TestCase): 19 | """Functional tests for lshell""" 20 | 21 | def setUp(self): 22 | """spawn lshell with pexpect and return the child""" 23 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 24 | self.child.expect(PROMPT) 25 | 26 | def tearDown(self): 27 | self.child.close() 28 | 29 | def do_exit(self, child): 30 | """Exit the shell""" 31 | child.sendline("exit") 32 | child.expect(pexpect.EOF) 33 | 34 | def test_15_cmd_completion_tab_tab(self): 35 | """F15 | command completion: tab to list commands""" 36 | expected = ( 37 | "\x07\r\nbg clear exit help jobs lpath lsudo " 38 | "\r\ncd echo fg history ll ls" 39 | ) 40 | self.child.sendline("\t\t") 41 | self.child.expect(PROMPT) 42 | result = self.child.before.decode("utf8").strip() 43 | 44 | self.assertEqual(expected, result) 45 | 46 | def test_14_path_completion_tilda(self): 47 | """F14 | path completion with ~/""" 48 | # Create two random directories in the home directory 49 | home_dir = f"/home/{USER}" 50 | test_num = 14 51 | dir1 = f"{home_dir}/test_{test_num}_dir_1" 52 | dir2 = f"{home_dir}/test_{test_num}_dir_2" 53 | file1 = f"{home_dir}/test_{test_num}_file_1" 54 | file2 = f"{home_dir}/test_{test_num}_file_2" 55 | os.mkdir(dir1) 56 | os.mkdir(dir2) 57 | open(file1, "w").close() 58 | open(file2, "w").close() 59 | 60 | # test dir list 61 | if is_alpine_linux(): 62 | command = "ls -a -d ~/*/" 63 | else: 64 | command = "find . -maxdepth 1 -type d -printf '%f/\n'" 65 | p_dir_list = subprocess.Popen( 66 | command, 67 | shell=True, 68 | stdin=subprocess.PIPE, 69 | stdout=subprocess.PIPE, 70 | ) 71 | stdout_p_dir_list = p_dir_list.stdout 72 | expected = stdout_p_dir_list.read().decode("utf8").strip().split() 73 | # Normalize expected to relative paths 74 | if is_alpine_linux(): 75 | # Remove the `/home//` prefix for Alpine Linux 76 | expected = {os.path.basename(path.rstrip("/")) + "/" for path in expected} 77 | else: 78 | expected = set(expected) 79 | expected = set(expected) 80 | expected.discard("./") 81 | 82 | self.child.sendline("cd ~/\t\t") 83 | self.child.expect(PROMPT) 84 | output = ( 85 | self.child.before.decode("utf8").strip().split("\n", 1)[1].strip().split() 86 | ) 87 | output = set(output) 88 | # github action hackish-fix... 89 | output.discard(".ghcup/") 90 | 91 | self.assertEqual(expected, output) 92 | 93 | # cleanup 94 | os.rmdir(dir1) 95 | os.rmdir(dir2) 96 | os.remove(file1) 97 | os.remove(file2) 98 | 99 | def test_15_file_completion_tilda(self): 100 | """F15 | file completion ls with ~/""" 101 | # Create two random directories in the home directory 102 | home_dir = f"/home/{USER}" 103 | test_num = 15 104 | dir1 = f"{home_dir}/test_{test_num}_dir_1" 105 | dir2 = f"{home_dir}/test_{test_num}_dir_2" 106 | file1 = f"{home_dir}/test_{test_num}_file_1" 107 | file2 = f"{home_dir}/test_{test_num}_file_2" 108 | os.mkdir(dir1) 109 | os.mkdir(dir2) 110 | open(file1, "w").close() 111 | open(file2, "w").close() 112 | 113 | # test file list 114 | if is_alpine_linux(): 115 | command = "ls -a -p ~/" 116 | else: 117 | command = "find . -maxdepth 1 -printf '%P%y\n' | sed 's|d$|/|;s|f$||'" 118 | p_file_list = subprocess.Popen( 119 | command, 120 | shell=True, 121 | stdin=subprocess.PIPE, 122 | stdout=subprocess.PIPE, 123 | ) 124 | stdout_p_file_list = p_file_list.stdout 125 | expected = stdout_p_file_list.read().decode("utf8").strip().split() 126 | expected = set(expected) 127 | expected.discard("/") 128 | # alpine specific because of `ls -a -p` 129 | if is_alpine_linux(): 130 | expected.discard("./") 131 | expected.discard("../") 132 | 133 | self.child.sendline("ls ~/\t\t") 134 | self.child.expect(PROMPT) 135 | output = ( 136 | self.child.before.decode("utf8").strip().split("\n", 1)[1].strip().split() 137 | ) 138 | output = set(output) 139 | # github action hackish-fix... 140 | output.discard(".ghcup/") 141 | if ".ghcupl" in expected: 142 | output.add(".ghcupl") 143 | 144 | self.assertEqual(expected, output) 145 | 146 | # cleanup 147 | os.rmdir(dir1) 148 | os.rmdir(dir2) 149 | os.remove(file1) 150 | os.remove(file2) 151 | 152 | def test_16_file_completion_with_arg(self): 153 | """F15 | file completion ls with ~/""" 154 | # Create two random directories in the home directory 155 | home_dir = f"/home/{USER}" 156 | test_num = 16 157 | dir1 = f"{home_dir}/test_{test_num}_dir_1" 158 | dir2 = f"{home_dir}/test_{test_num}_dir_2" 159 | file1 = f"{home_dir}/test_{test_num}_file_1" 160 | file2 = f"{home_dir}/test_{test_num}_file_2" 161 | os.mkdir(dir1) 162 | os.mkdir(dir2) 163 | open(file1, "w").close() 164 | open(file2, "w").close() 165 | 166 | # test file list 167 | if is_alpine_linux(): 168 | command = "ls -a -p ~/" 169 | else: 170 | command = "find . -maxdepth 1 -printf '%P%y\n' | sed 's|d$|/|;s|f$||'" 171 | p_file_list = subprocess.Popen( 172 | command, 173 | shell=True, 174 | stdin=subprocess.PIPE, 175 | stdout=subprocess.PIPE, 176 | ) 177 | stdout_p_file_list = p_file_list.stdout 178 | expected = stdout_p_file_list.read().decode("utf8").strip().split() 179 | expected = set(expected) 180 | expected.discard("/") 181 | # alpine specific because of `ls -a -p` 182 | if is_alpine_linux(): 183 | expected.discard("./") 184 | expected.discard("../") 185 | 186 | self.child.sendline("ls -l ~/\t\t") 187 | self.child.expect(PROMPT) 188 | output = ( 189 | self.child.before.decode("utf8").strip().split("\n", 1)[1].strip().split() 190 | ) 191 | output = set(output) 192 | # github action hackish-fix... 193 | output.discard(".ghcup/") 194 | if ".ghcupl" in expected: 195 | output.add(".ghcupl") 196 | 197 | self.assertEqual(expected, output) 198 | 199 | # cleanup 200 | os.rmdir(dir1) 201 | os.rmdir(dir2) 202 | os.remove(file1) 203 | os.remove(file2) 204 | 205 | def test_26_cmd_completion_dot_slash(self): 206 | """F26 | command completion: tab to list ./foo1 ./foo2""" 207 | child = pexpect.spawn( 208 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['./foo1', './foo2']\"" 209 | ) 210 | child.expect(PROMPT) 211 | 212 | expected = "./\x07foo\x07\r\nfoo1 foo2" 213 | child.sendline("./\t\t\t") 214 | child.expect(PROMPT) 215 | result = child.before.decode("utf8").strip() 216 | 217 | self.assertEqual(expected, result) 218 | self.do_exit(child) 219 | -------------------------------------------------------------------------------- /test/test_config.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell configuration""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | 9 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 10 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 11 | LSHELL = f"{TOPDIR}/bin/lshell" 12 | USER = getuser() 13 | PROMPT = f"{USER}:~\\$" 14 | 15 | 16 | class TestFunctions(unittest.TestCase): 17 | """Functional tests for lshell""" 18 | 19 | def do_exit(self, child): 20 | """Exit the shell""" 21 | child.sendline("exit") 22 | child.expect(pexpect.EOF) 23 | 24 | def test_55_allowed_all_minus_list(self): 25 | """F55 | allow all commands minus the list""" 26 | 27 | command = "echo 1" 28 | expected = "*** forbidden command: echo" 29 | 30 | child = pexpect.spawn( 31 | f"{LSHELL} --config {CONFIG} " '--allowed \'"all" - ["echo"]' 32 | ) 33 | child.expect(PROMPT) 34 | 35 | child.sendline(command) 36 | child.expect(PROMPT) 37 | output = child.before.decode("utf-8").split("\n")[1].strip() 38 | self.assertEqual(expected, output) 39 | self.do_exit(child) 40 | 41 | def test_56_path_minus_specific_path(self): 42 | """F56 | allow paths except for the specified path""" 43 | 44 | command1 = "cd /usr/" 45 | expected1 = f"{USER}:/usr\\$" 46 | command2 = "cd /usr/local" 47 | expected2 = "*** forbidden path: /usr/local/" 48 | 49 | child = pexpect.spawn( 50 | f"{LSHELL} --config {CONFIG} " 51 | '--path \'["/var", "/usr"] - ["/usr/local"]\'' 52 | ) 53 | child.expect(PROMPT) 54 | 55 | child.sendline(command1) 56 | child.expect(expected1) 57 | child.sendline(command2) 58 | child.expect(expected1) 59 | output = child.before.decode("utf-8").split("\n")[1].strip() 60 | self.assertEqual(expected2, output) 61 | self.do_exit(child) 62 | 63 | def test_57_overssh_all_minus_list(self): 64 | """F57 | overssh minus command list""" 65 | command = "echo 1" 66 | expected = ( 67 | '*** forbidden char/command over SSH: "echo 1"\r\n' 68 | "This incident has been reported." 69 | ) 70 | 71 | # add SSH_CLIENT to environment 72 | if not os.environ.get("SSH_CLIENT"): 73 | os.environ["SSH_CLIENT"] = "random" 74 | 75 | child = pexpect.spawn( 76 | f"{LSHELL} " 77 | f"--config {CONFIG} " 78 | f"--overssh \"['ls','echo'] - ['echo']\" " 79 | f"-c '{command}'" 80 | ) 81 | child.expect(pexpect.EOF) 82 | 83 | output = child.before.decode("utf-8").strip() 84 | self.assertEqual(expected, output) 85 | self.do_exit(child) 86 | 87 | def test_58_allowed_plus_minus_list(self): 88 | """F58 | allow plus list minus list""" 89 | command = "echo 1" 90 | expected = "*** forbidden command: echo" 91 | 92 | child = pexpect.spawn( 93 | f"{LSHELL} " 94 | f"--config {CONFIG} " 95 | f"--allowed \"['ls'] + ['echo'] - ['echo']\" " 96 | ) 97 | child.expect(PROMPT) 98 | 99 | child.sendline(command) 100 | child.expect(PROMPT) 101 | output = child.before.decode("utf-8").split("\n")[1].strip() 102 | self.assertEqual(expected, output) 103 | self.do_exit(child) 104 | 105 | def test_59a_forbidden_remove_one(self): 106 | """F59a | remove all items from forbidden list""" 107 | 108 | command = "echo 1 ; echo 2" 109 | expected = [" echo 1 ; echo 2\r", "1\r", "2\r", ""] 110 | 111 | child = pexpect.spawn( 112 | f"{LSHELL} --config {CONFIG} " '--forbidden \'[";"] - [";"]\'' 113 | ) 114 | child.expect(PROMPT) 115 | 116 | child.sendline(command) 117 | child.expect(PROMPT) 118 | output = child.before.decode("utf-8").split("\n") 119 | self.assertEqual(expected, output) 120 | self.do_exit(child) 121 | 122 | def test_59b_forbidden_remove_one(self): 123 | """F59b | fixed forbidden list""" 124 | 125 | command = "echo 1 ; echo 2" 126 | expected = "*** forbidden character: ;" 127 | 128 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} " "--forbidden '[\";\"]'") 129 | child.expect(PROMPT) 130 | 131 | child.sendline(command) 132 | child.expect(PROMPT) 133 | output = child.before.decode("utf-8").split("\n")[1].strip() 134 | self.assertEqual(expected, output) 135 | self.do_exit(child) 136 | 137 | def test_59c_forbidden_remove_one(self): 138 | """F59c | remove an item from forbidden list""" 139 | 140 | command = "echo 1 ; echo 2" 141 | expected = [" echo 1 ; echo 2\r", "1\r", "2\r", ""] 142 | 143 | child = pexpect.spawn( 144 | f"{LSHELL} --config {CONFIG} " '--forbidden \'[";", "|", "%"] - [";"]\'' 145 | ) 146 | child.expect(PROMPT) 147 | 148 | child.sendline(command) 149 | child.expect(PROMPT) 150 | output = child.before.decode("utf-8").split("\n") 151 | self.assertEqual(expected, output) 152 | self.do_exit(child) 153 | -------------------------------------------------------------------------------- /test/test_env_vars.py: -------------------------------------------------------------------------------- 1 | """Test environment variables in lshell""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import tempfile 7 | import pexpect 8 | 9 | # import lshell specifics 10 | from lshell import utils 11 | 12 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 13 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 14 | LSHELL = f"{TOPDIR}/bin/lshell" 15 | USER = getuser() 16 | PROMPT = f"{USER}:~\\$" 17 | 18 | 19 | class TestFunctions(unittest.TestCase): 20 | """Functional tests for lshell""" 21 | 22 | def setUp(self): 23 | """spawn lshell with pexpect and return the child""" 24 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 25 | self.child.expect(PROMPT) 26 | 27 | def tearDown(self): 28 | self.child.close() 29 | 30 | def do_exit(self, child): 31 | """Exit the shell""" 32 | child.sendline("exit") 33 | child.expect(pexpect.EOF) 34 | 35 | def test_22_expand_env_variables(self): 36 | """F22 | expanding of environment variables""" 37 | child = pexpect.spawn( 38 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['export']\"" 39 | ) 40 | child.expect(PROMPT) 41 | 42 | expected = f"{os.path.expanduser('~')}/test" 43 | child.sendline("export A=test") 44 | child.expect(PROMPT) 45 | child.sendline("echo $HOME/$A") 46 | child.expect(PROMPT) 47 | result = child.before.decode("utf8").split("\n")[1].strip() 48 | self.assertEqual(expected, result) 49 | self.do_exit(child) 50 | 51 | def test_23_expand_env_variables_cd(self): 52 | """F23 | expanding of environment variables when using cd""" 53 | child = pexpect.spawn( 54 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['export']\"" 55 | ) 56 | child.expect(PROMPT) 57 | 58 | random = utils.random_string(32) 59 | 60 | expected = f"lshell: {os.path.expanduser('~')}/random_{random}: No such file or directory" 61 | child.sendline(f"export A=random_{random}") 62 | child.expect(PROMPT) 63 | child.sendline("cd $HOME/$A") 64 | child.expect(PROMPT) 65 | result = child.before.decode("utf8").split("\n")[1].strip() 66 | self.assertEqual(expected, result) 67 | self.do_exit(child) 68 | 69 | def test_37_env_vars_file_not_found(self): 70 | """Test missing environment variable file""" 71 | missing_file_path = "/path/to/missing/file" 72 | 73 | # Inject the environment variable file path 74 | child = pexpect.spawn( 75 | f"{LSHELL} --config {CONFIG} " 76 | f"--env_vars_files \"['{missing_file_path}']\"" 77 | ) 78 | 79 | # Expect the prompt after shell startup 80 | child.expect(PROMPT) 81 | 82 | # Simulate what happens when the environment variable file is missing 83 | expected = ( 84 | f"ERROR: Unable to read environment file: {missing_file_path}\r\n" 85 | "You are in a limited shell.\r\n" 86 | "Type '?' or 'help' to get the list of allowed commands\r\n" 87 | ) 88 | 89 | # Check the error message in the output 90 | self.assertIn(expected, child.before.decode("utf8")) 91 | self.do_exit(child) 92 | 93 | def test_38_load_env_vars_from_file(self): 94 | """Test loading environment variables from file""" 95 | 96 | # Create a temporary file to store environment variables 97 | with tempfile.NamedTemporaryFile( 98 | mode="w", delete=False, dir="/tmp" 99 | ) as temp_env_file: 100 | temp_env_file.write("export bar=helloworld\n") 101 | temp_env_file.flush() # Ensure data is written to disk 102 | temp_env_file_path = temp_env_file.name 103 | 104 | # Set the temp env file path in the config 105 | child = pexpect.spawn( 106 | f"{LSHELL} --config {CONFIG} " 107 | f"--env_vars_files \"['{temp_env_file_path}']\"" 108 | ) 109 | child.expect(PROMPT) 110 | 111 | # Test if the environment variable was loaded 112 | child.sendline("echo $bar") 113 | child.expect(PROMPT) 114 | 115 | result = child.before.decode("utf8").strip().split("\n", 1)[1].strip() 116 | self.assertEqual(result, "helloworld") 117 | 118 | # Cleanup the temporary file 119 | os.remove(temp_env_file_path) 120 | self.do_exit(child) 121 | 122 | def test_47_backticks(self): 123 | """F47 | Forbidden backticks should be reported""" 124 | expected = ( 125 | '*** forbidden character -> "`"\r\n' 126 | "*** You have 1 warning(s) left, before getting kicked out.\r\n" 127 | "This incident has been reported.\r\n" 128 | ) 129 | self.child.sendline("echo `uptime`") 130 | self.child.expect(PROMPT) 131 | result = self.child.before.decode("utf8").split("\n", 1)[1] 132 | self.assertEqual(expected, result) 133 | 134 | def test_48_replace_backticks_with_dollar_parentheses(self): 135 | """F48 | Forbidden syntax $(command) should be reported""" 136 | expected = ( 137 | '*** forbidden character -> "$("\r\n' 138 | "*** You have 1 warning(s) left, before getting kicked out.\r\n" 139 | "This incident has been reported.\r\n" 140 | ) 141 | self.child.sendline("echo $(uptime)") 142 | self.child.expect(PROMPT) 143 | result = self.child.before.decode("utf8").split("\n", 1)[1] 144 | self.assertEqual(expected, result) 145 | 146 | def test_49_env_variable_with_dollar_braces(self): 147 | """F49 | Syntax ${command} should replace with the variable""" 148 | child = pexpect.spawn( 149 | f"{LSHELL} " f"--config {CONFIG} " "--env_vars \"{'foo':'OK'}\"" 150 | ) 151 | child.expect(PROMPT) 152 | 153 | child.sendline("echo ${foo}") 154 | child.expect(PROMPT) 155 | result = child.before.decode("utf8").split("\n", 1)[1] 156 | expected = "OK\r\n" 157 | self.assertEqual(expected, result) 158 | self.do_exit(child) 159 | -------------------------------------------------------------------------------- /test/test_exit.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell for exit command""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | 9 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 10 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 11 | LSHELL = f"{TOPDIR}/bin/lshell" 12 | USER = getuser() 13 | PROMPT = f"{USER}:~\\$" 14 | 15 | 16 | class TestFunctions(unittest.TestCase): 17 | """Functional tests for lshell""" 18 | 19 | def setUp(self): 20 | """spawn lshell with pexpect and return the child""" 21 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 22 | self.child.expect(PROMPT) 23 | 24 | def tearDown(self): 25 | self.child.close() 26 | 27 | def do_exit(self, child): 28 | """Exit the shell""" 29 | child.sendline("exit") 30 | child.expect(pexpect.EOF) 31 | 32 | def test_30_disable_exit(self): 33 | """F31 | test disabled exit command""" 34 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " "--disable_exit 1 ") 35 | child.expect(PROMPT) 36 | 37 | expected = "" 38 | child.sendline("exit") 39 | child.expect(PROMPT) 40 | 41 | result = child.before.decode("utf8").split("\n")[1] 42 | 43 | self.assertIn(expected, result) 44 | 45 | def test_50_warnings_then_kickout(self): 46 | """F50 | kicked out after warning counter""" 47 | child = pexpect.spawn( 48 | f"{LSHELL} --config {CONFIG} --strict 1 --warning_counter 0" 49 | ) 50 | child.sendline("lslsls") 51 | child.sendline("lslsls") 52 | child.expect(pexpect.EOF, timeout=10) 53 | 54 | # Assert that the process exited 55 | self.assertIsNotNone( 56 | child.exitstatus, "The lshell process did not exit as expected." 57 | ) 58 | 59 | # Optionally, you can assert that the exit code is correct 60 | self.assertEqual(child.exitstatus, 1, "The process should exit with code 1.") 61 | self.do_exit(child) 62 | -------------------------------------------------------------------------------- /test/test_file_extension.py: -------------------------------------------------------------------------------- 1 | """Functional tests for file extension restrictions""" 2 | 3 | import os 4 | import unittest 5 | import inspect 6 | from getpass import getuser 7 | import pexpect 8 | 9 | 10 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 11 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 12 | LSHELL = f"{TOPDIR}/bin/lshell" 13 | USER = getuser() 14 | PROMPT = f"{USER}:~\\$" 15 | 16 | 17 | class TestFunctions(unittest.TestCase): 18 | """Functional tests for lshell""" 19 | 20 | def do_exit(self, child): 21 | """Exit the shell""" 22 | child.sendline("exit") 23 | child.expect(pexpect.EOF) 24 | 25 | def test_60_allowed_extension_success(self): 26 | """F60 | allow extension and cat file with similar extension""" 27 | 28 | f_name = inspect.currentframe().f_code.co_name 29 | log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" 30 | command = f"cat {log_file}" 31 | expected = "Hello world!" 32 | 33 | child = pexpect.spawn( 34 | f"{LSHELL} --config {CONFIG} " 35 | "--allowed \"+ ['cat']\" " 36 | "--allowed_file_extensions \"['.log']\"" 37 | ) 38 | child.expect(PROMPT) 39 | 40 | child.sendline(command) 41 | child.expect(PROMPT) 42 | output = child.before.decode("utf-8").split("\n")[1].strip() 43 | self.assertEqual(expected, output) 44 | self.do_exit(child) 45 | 46 | def test_61_allowed_extension_fail(self): 47 | """F61 | allow extension and cat file with different extension""" 48 | 49 | command = f"cat {CONFIG}" 50 | expected = f"*** forbidden file extension ['.conf']: cat {CONFIG}" 51 | 52 | child = pexpect.spawn( 53 | f"{LSHELL} --config {CONFIG} " 54 | "--allowed \"+ ['cat']\" " 55 | "--allowed_file_extensions \"['.log']\"" 56 | ) 57 | child.expect(PROMPT) 58 | 59 | child.sendline(command) 60 | child.expect(PROMPT) 61 | output = child.before.decode("utf-8").split("\n")[1].strip() 62 | self.assertEqual(expected, output) 63 | self.do_exit(child) 64 | 65 | def test_62_allowed_extension_empty(self): 66 | """F62 | allow extension empty and cat any file extension""" 67 | 68 | command = f"cat {CONFIG}" 69 | expected = "[global]" 70 | 71 | child = pexpect.spawn( 72 | f"{LSHELL} --config {CONFIG} " 73 | "--allowed \"+ ['cat']\" " 74 | '--allowed_file_extensions "[]"' 75 | ) 76 | child.expect(PROMPT) 77 | 78 | child.sendline(command) 79 | child.expect(PROMPT) 80 | output = child.before.decode("utf-8").split("\n")[1].strip() 81 | self.assertEqual(expected, output) 82 | self.do_exit(child) 83 | -------------------------------------------------------------------------------- /test/test_path.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell path handling""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | # pylint: disable=C0411 9 | from test import test_utils 10 | 11 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 12 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 13 | LSHELL = f"{TOPDIR}/bin/lshell" 14 | USER = getuser() 15 | PROMPT = f"{USER}:~\\$" 16 | 17 | 18 | class TestFunctions(unittest.TestCase): 19 | """Functional tests for lshell""" 20 | 21 | def setUp(self): 22 | """spawn lshell with pexpect and return the child""" 23 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 24 | self.child.expect(PROMPT) 25 | 26 | def tearDown(self): 27 | self.child.close() 28 | 29 | def do_exit(self, child): 30 | """Exit the shell""" 31 | child.sendline("exit") 32 | child.expect(pexpect.EOF) 33 | 34 | def test_05_external_echo_forbidden_syntax(self): 35 | """F05 | echo forbidden syntax $(bleh)""" 36 | expected = ( 37 | '*** forbidden character -> "$("\r\n*** You ' 38 | "have 1 warning(s) left, before getting kicked out.\r\nThis " 39 | "incident has been reported.\r\n" 40 | ) 41 | self.child.sendline("echo $(uptime)") 42 | self.child.expect(PROMPT) 43 | result = self.child.before.decode("utf8").split("\n", 1)[1] 44 | self.assertEqual(expected, result) 45 | 46 | def test_09_external_forbidden_path(self): 47 | """F09 | external command forbidden path - ls /root""" 48 | expected = ( 49 | '*** forbidden path -> "/root/"\r\n*** You have' 50 | " 1 warning(s) left, before getting kicked out.\r\nThis " 51 | "incident has been reported.\r\n" 52 | ) 53 | self.child.sendline("ls ~root") 54 | self.child.expect(PROMPT) 55 | result = self.child.before.decode("utf8").split("\n", 1)[1] 56 | self.assertEqual(expected, result) 57 | 58 | def test_10_builtin_cd_forbidden_path(self): 59 | """F10 | built-in command forbidden path - cd ~root""" 60 | expected = ( 61 | '*** forbidden path -> "/root/"\r\n*** You have' 62 | " 1 warning(s) left, before getting kicked out.\r\nThis " 63 | "incident has been reported.\r\n" 64 | ) 65 | self.child.sendline("cd ~root") 66 | self.child.expect(PROMPT) 67 | result = self.child.before.decode("utf8").split("\n", 1)[1] 68 | self.assertEqual(expected, result) 69 | 70 | def test_11_etc_passwd_1(self): 71 | """F11 | /etc/passwd: empty variable 'ls "$a"/etc/passwd'""" 72 | if test_utils.is_alpine_linux(): 73 | expected = "ls: $a/etc/passwd: No such file or directory\r\n" 74 | else: 75 | expected = ( 76 | "ls: cannot access '$a/etc/passwd': No such file or directory\r\n" 77 | ) 78 | self.child.sendline('ls "$a"/etc/passwd') 79 | self.child.expect(PROMPT) 80 | result = self.child.before.decode("utf8").split("\n", 1)[1] 81 | self.assertEqual(expected, result) 82 | 83 | def test_12_etc_passwd_2(self): 84 | """F12 | /etc/passwd: empty variable 'ls -l .*./.*./etc/passwd'""" 85 | if test_utils.is_alpine_linux(): 86 | expected = "ls: .*./.*./etc/passwd: No such file or directory\r\n" 87 | else: 88 | expected = ( 89 | "ls: cannot access '.*./.*./etc/passwd': No such file or directory\r\n" 90 | ) 91 | self.child.sendline("ls -l .*./.*./etc/passwd") 92 | self.child.expect(PROMPT) 93 | result = self.child.before.decode("utf8").split("\n", 1)[1] 94 | self.assertEqual(expected, result) 95 | 96 | def test_13a_etc_passwd_3(self): 97 | """F13(a) | /etc/passwd: empty variable 'ls -l .?/.?/etc/passwd'""" 98 | if test_utils.is_alpine_linux(): 99 | expected = "ls: .?/.?/etc/passwd: No such file or directory\r\n" 100 | else: 101 | expected = ( 102 | "ls: cannot access '.?/.?/etc/passwd': No such file or directory\r\n" 103 | ) 104 | self.child.sendline("ls -l .?/.?/etc/passwd") 105 | self.child.expect(PROMPT) 106 | result = self.child.before.decode("utf8").split("\n", 1)[1] 107 | self.assertEqual(expected, result) 108 | 109 | def test_13b_etc_passwd_4(self): 110 | """F13(b) | /etc/passwd: empty variable 'ls -l ../../etc/passwd'""" 111 | expected = ( 112 | '*** forbidden path -> "/etc/passwd"\r\n*** You have' 113 | " 1 warning(s) left, before getting kicked out.\r\nThis " 114 | "incident has been reported.\r\n" 115 | ) 116 | self.child.sendline("ls -l ../../etc/passwd") 117 | self.child.expect(PROMPT) 118 | result = self.child.before.decode("utf8").split("\n", 1)[1] 119 | self.assertEqual(expected, result) 120 | 121 | def test_21_allow_slash(self): 122 | """F21 | user should able to allow / access minus some directory 123 | (e.g. /var) 124 | """ 125 | child = pexpect.spawn( 126 | f"{LSHELL} " f"--config {CONFIG} " "--path \"['/'] - ['/var']\"" 127 | ) 128 | child.expect(PROMPT) 129 | 130 | expected = "*** forbidden path: /var/" 131 | child.sendline("cd /") 132 | child.expect(f"{USER}:/\\$") 133 | child.sendline("cd var") 134 | child.expect(f"{USER}:/\\$") 135 | result = child.before.decode("utf8").split("\n")[1].strip() 136 | self.assertEqual(expected, result) 137 | self.do_exit(child) 138 | -------------------------------------------------------------------------------- /test/test_ps2.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell PS2 prompt""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 9 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 10 | LSHELL = f"{TOPDIR}/bin/lshell" 11 | USER = getuser() 12 | PROMPT = f"{USER}:~\\$" 13 | 14 | 15 | class TestFunctions(unittest.TestCase): 16 | """Functional tests for lshell""" 17 | 18 | def do_exit(self, child): 19 | """Exit the shell""" 20 | child.sendline("exit") 21 | child.expect(pexpect.EOF) 22 | 23 | def test_63_multi_line_command(self): 24 | """F63 | Test multi-line command execution using line continuation""" 25 | 26 | # Start the shell process with lshell config 27 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} ") 28 | child.expect(PROMPT) 29 | 30 | # Send a multi-line command using line continuation 31 | child.sendline("echo 'First line' \\") 32 | child.sendline("'and second line'") 33 | child.expect(PROMPT) 34 | 35 | output = child.before.decode("utf-8").split("\n")[2].strip() 36 | expected_output = "First line and second line" 37 | assert ( 38 | output == expected_output 39 | ), f"Expected '{expected_output}', got '{output}'" 40 | 41 | # Send an exit command to end the shell session 42 | self.do_exit(child) 43 | 44 | def test_64_multi_line_command_with_two_echos(self): 45 | """F64 | Test multi-line command execution with two echo commands""" 46 | 47 | # Start the shell process with lshell config 48 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --forbidden \"-[';']\"") 49 | child.expect(PROMPT) 50 | 51 | # Send two echo commands on two lines 52 | child.sendline("echo 'First line'; echo \\") 53 | child.sendline("'Second line';") 54 | child.expect(PROMPT) 55 | 56 | output = child.before.decode("utf-8").split("\n")[2:4] 57 | expected_output = ["First line\r", "Second line\r"] 58 | assert ( 59 | output == expected_output 60 | ), f"Expected '{expected_output}', got '{output}'" 61 | 62 | # Send an exit command to end the shell session 63 | self.do_exit(child) 64 | 65 | def test_65_multi_line_command_security_echo(self): 66 | """F65 | test help, then echo FREEDOM! && help () sh && help""" 67 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} --forbidden \"-[';']\"") 68 | child.expect(PROMPT) 69 | 70 | # Step 1: Enter `help` command 71 | expected_help_output = ( 72 | "bg cd clear echo exit fg help history jobs ll lpath ls lsudo" 73 | ) 74 | child.sendline("help") 75 | child.expect(PROMPT) 76 | help_output = child.before.decode("utf8").split("\n", 2)[1].strip() 77 | 78 | self.assertEqual(expected_help_output, help_output) 79 | 80 | # Step 2: Enter `echo FREEDOM! && help () sh && help` 81 | expected_output = ( 82 | "1\r\nFREEDOM!\r\n" 83 | "bg cd clear echo exit fg help history jobs ll lpath ls lsudo\r\n" 84 | "bg cd clear echo exit fg help history jobs ll lpath ls lsudo" 85 | ) 86 | child.sendline("echo 1; \\") 87 | child.expect(">") 88 | child.sendline("echo FREEDOM! && help () sh && help") 89 | child.expect(PROMPT) 90 | 91 | result = child.before.decode("utf8").strip().split("\n", 1)[1] 92 | 93 | # Verify the combined output 94 | self.assertEqual(expected_output, result) 95 | self.do_exit(child) 96 | 97 | def test_66_multi_line_command_ctrl_c(self): 98 | """F66 | Test multi-line command then ctrl-c to cancel""" 99 | 100 | # Start the shell process with lshell config 101 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} ") 102 | child.expect(PROMPT) 103 | 104 | # Send a multi-line command using line continuation 105 | child.sendline("echo 1 \\") 106 | child.expect(">") 107 | child.sendcontrol("c") 108 | child.expect(PROMPT) 109 | 110 | output = child.before.decode("utf-8").split("\n")[1].strip() 111 | expected_output = "" 112 | assert ( 113 | output == expected_output 114 | ), f"Expected '{expected_output}', got '{output}'" 115 | 116 | # Send an exit command to end the shell session 117 | self.do_exit(child) 118 | 119 | def test_67_unclosed_quotes_traceback(self): 120 | """F67 | Test that unclsed quotes do not cause a traceback""" 121 | 122 | # Start the shell process with lshell config 123 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} ") 124 | child.expect(PROMPT) 125 | 126 | # Send a multi-line command using line continuation 127 | child.sendline('echo "OK""') 128 | child.expect("> ") 129 | child.sendline('OK"') 130 | child.expect(PROMPT) 131 | 132 | output = child.before.decode("utf-8").split("\n")[1].strip() 133 | expected_output = "OKOK" 134 | assert ( 135 | output == expected_output 136 | ), f"Expected '{expected_output}', got '{output}'" 137 | 138 | # Send an exit command to end the shell session 139 | self.do_exit(child) 140 | -------------------------------------------------------------------------------- /test/test_regex.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell regex handling""" 2 | 3 | import os 4 | import unittest 5 | import inspect 6 | from getpass import getuser 7 | import pexpect 8 | 9 | # import lshell specifics 10 | # pylint: disable=C0411 11 | from test import test_utils 12 | 13 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 14 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 15 | LSHELL = f"{TOPDIR}/bin/lshell" 16 | USER = getuser() 17 | PROMPT = f"{USER}:~\\$" 18 | 19 | 20 | class TestFunctions(unittest.TestCase): 21 | """Functional tests for lshell""" 22 | 23 | def do_exit(self, child): 24 | """Exit the shell""" 25 | child.sendline("exit") 26 | child.expect(pexpect.EOF) 27 | 28 | def test_51_grep_valid_log_entry(self): 29 | """F51 | Test that grep matches a valid log entry format.""" 30 | pattern = ( 31 | r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" 32 | ) 33 | f_name = inspect.currentframe().f_code.co_name 34 | log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" 35 | command = f"grep -P '{pattern}' {log_file}" 36 | 37 | child = pexpect.spawn( 38 | ( 39 | f"{LSHELL} --config {CONFIG} " 40 | f'--allowed "+ [\'grep\']" --forbidden "[]"' 41 | ) 42 | ) 43 | child.expect(PROMPT) 44 | 45 | child.sendline(command) 46 | child.expect(PROMPT) 47 | output = child.before.decode("utf-8") 48 | self.assertIn("user123", output) 49 | self.do_exit(child) 50 | 51 | def test_52_grep_invalid_date_format(self): 52 | """F52 | Test that grep matches a valid log entry format.""" 53 | pattern = ( 54 | r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" 55 | ) 56 | f_name = inspect.currentframe().f_code.co_name 57 | log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" 58 | 59 | if test_utils.is_alpine_linux(): 60 | command = f"grep -E '{pattern}' {log_file}" 61 | else: 62 | command = f"grep -P '{pattern}' {log_file}" 63 | 64 | child = pexpect.spawn( 65 | f"{LSHELL} --config {CONFIG} " '--allowed "+ [\'grep\']" --forbidden "[]"' 66 | ) 67 | child.expect(PROMPT) 68 | 69 | child.sendline(command) 70 | child.expect(PROMPT) 71 | output = child.before.decode("utf-8") 72 | self.assertNotIn("user123", output) 73 | self.do_exit(child) 74 | 75 | def test_53_grep_missing_uid(self): 76 | """F53 | Test that grep matches a valid log entry format.""" 77 | pattern = ( 78 | r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" 79 | ) 80 | f_name = inspect.currentframe().f_code.co_name 81 | log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" 82 | 83 | if test_utils.is_alpine_linux(): 84 | command = f"grep -E '{pattern}' {log_file}" 85 | else: 86 | command = f"grep -P '{pattern}' {log_file}" 87 | 88 | child = pexpect.spawn( 89 | f"{LSHELL} --config {CONFIG} " '--allowed "+ [\'grep\']" --forbidden "[]"' 90 | ) 91 | child.expect(PROMPT) 92 | 93 | child.sendline(command) 94 | child.expect(PROMPT) 95 | output = child.before.decode("utf-8") 96 | self.assertNotIn("user123", output) 97 | self.do_exit(child) 98 | 99 | def test_54_grep_special_characters_in_uid(self): 100 | """F54 | Test that grep matches a valid log entry format.""" 101 | pattern = ( 102 | r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" 103 | ) 104 | f_name = inspect.currentframe().f_code.co_name 105 | log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" 106 | command = f"grep -P '{pattern}' {log_file}" 107 | 108 | child = pexpect.spawn( 109 | f"{LSHELL} --config {CONFIG} " '--allowed "+ [\'grep\']" --forbidden "[]"' 110 | ) 111 | child.expect(PROMPT) 112 | 113 | child.sendline(command) 114 | child.expect(PROMPT) 115 | output = child.before.decode("utf-8") 116 | self.assertIn("user.name", output) 117 | self.do_exit(child) 118 | -------------------------------------------------------------------------------- /test/test_scripts.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell script execution""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import tempfile 7 | import shutil 8 | import pexpect 9 | 10 | 11 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 12 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 13 | LSHELL = f"{TOPDIR}/bin/lshell" 14 | USER = getuser() 15 | PROMPT = f"{USER}:~\\$" 16 | 17 | 18 | class TestFunctions(unittest.TestCase): 19 | """Functional tests for lshell""" 20 | 21 | def setUp(self): 22 | """spawn lshell with pexpect and return the child""" 23 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 24 | self.child.expect(PROMPT) 25 | 26 | def tearDown(self): 27 | self.child.close() 28 | 29 | def do_exit(self, child): 30 | """Exit the shell""" 31 | child.sendline("exit") 32 | child.expect(pexpect.EOF) 33 | 34 | def test_39_script_execution_with_template(self): 35 | """Test executing script after modifying shebang and clean up afterward""" 36 | 37 | template_path = f"{TOPDIR}/test/template.lsh" 38 | test_script_path = "/tmp/test.lsh" 39 | 40 | # Step 1: Create the wrapper script 41 | with tempfile.NamedTemporaryFile(mode="w", delete=False, dir="/tmp") as wrapper: 42 | wrapper.write( 43 | f"""#!/bin/sh 44 | exec {LSHELL} --config {CONFIG} "$@" 45 | """ 46 | ) 47 | wrapper.flush() # Ensure data is written to disk 48 | wrapper_path = wrapper.name 49 | 50 | # Step 2: Copy template.lsh to test.lsh and replace the shebang 51 | shutil.copy(template_path, test_script_path) 52 | 53 | # Make the wrapper executable 54 | os.chmod(wrapper_path, 0o755) 55 | os.chmod(test_script_path, 0o755) 56 | 57 | # Replace the placeholder in the shebang 58 | with open(test_script_path, "r+") as f: 59 | content = f.read() 60 | content = content.replace("#!SHEBANG", f"#!{wrapper_path}") 61 | f.seek(0) 62 | f.write(content) 63 | f.truncate() 64 | 65 | # Spawn a child process to run the test.lsh script using pexpect 66 | child = pexpect.spawn(test_script_path) 67 | 68 | # Expected output 69 | expected_output = """test\r 70 | *** forbidden command: dig\r 71 | *** forbidden path: /tmp/\r 72 | FREEDOM\r 73 | bg cd clear echo exit fg help history jobs ll lpath ls lsudo\r 74 | bg cd clear echo exit fg help history jobs ll lpath ls lsudo\r 75 | *** forbidden path: /""" 76 | 77 | # Wait for the script to finish executing 78 | child.expect(pexpect.EOF) 79 | 80 | # Capture the output and compare with expected output 81 | result = child.before.decode("utf8").strip() 82 | self.assertEqual(result, expected_output) 83 | 84 | # Cleanup: remove the test script after the test 85 | if os.path.exists(test_script_path): 86 | os.remove(test_script_path) 87 | self.do_exit(child) 88 | 89 | def test_40_script_execution_with_template_strict(self): 90 | """Test executing script after modifying shebang and clean up afterward""" 91 | 92 | template_path = f"{TOPDIR}/test/template.lsh" 93 | test_script_path = "/tmp/test.lsh" 94 | 95 | # Step 1: Create the wrapper script 96 | with tempfile.NamedTemporaryFile(mode="w", delete=False, dir="/tmp") as wrapper: 97 | wrapper.write( 98 | f"""#!/bin/sh 99 | exec {LSHELL} --config {CONFIG} --strict 1 "$@" 100 | """ 101 | ) 102 | wrapper.flush() # Ensure data is written to disk 103 | wrapper_path = wrapper.name 104 | 105 | # Step 2: Copy template.lsh to test.lsh and replace the shebang 106 | shutil.copy(template_path, test_script_path) 107 | 108 | # Make the wrapper executable 109 | os.chmod(wrapper_path, 0o755) 110 | os.chmod(test_script_path, 0o755) 111 | 112 | with open(test_script_path, "r+") as f: 113 | content = f.read() 114 | content = content.replace("#!SHEBANG", f"#!{wrapper_path}") 115 | f.seek(0) 116 | f.write(content) 117 | f.truncate() 118 | 119 | # Step 3: Spawn a child process to run the test.lsh script using pexpect 120 | child = pexpect.spawn(test_script_path) 121 | 122 | # Expected output 123 | expected_output = """test\r 124 | *** forbidden command -> "dig"\r 125 | *** You have 1 warning(s) left, before getting kicked out.\r 126 | This incident has been reported.\r 127 | *** forbidden path -> "/tmp/"\r 128 | *** You have 0 warning(s) left, before getting kicked out.\r 129 | This incident has been reported.\r 130 | FREEDOM\r 131 | bg cd clear echo exit fg help history jobs ll lpath ls lsudo\r 132 | bg cd clear echo exit fg help history jobs ll lpath ls lsudo\r 133 | *** forbidden path -> "/"\r 134 | *** Kicked out""" 135 | 136 | # Wait for the script to finish executing 137 | child.expect(pexpect.EOF) 138 | 139 | # Capture the output and compare with expected output 140 | result = child.before.decode("utf8").strip() 141 | self.assertEqual(result, expected_output) 142 | 143 | # Step 5: Cleanup: remove the test script and wrapper after the test 144 | if os.path.exists(test_script_path): 145 | os.remove(test_script_path) 146 | if os.path.exists(wrapper_path): 147 | os.remove(wrapper_path) 148 | self.do_exit(child) 149 | -------------------------------------------------------------------------------- /test/test_security.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell security features""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | # pylint: disable=C0411 9 | from test import test_utils 10 | 11 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 12 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 13 | LSHELL = f"{TOPDIR}/bin/lshell" 14 | USER = getuser() 15 | PROMPT = f"{USER}:~\\$" 16 | 17 | 18 | class TestFunctions(unittest.TestCase): 19 | """Functional tests for lshell""" 20 | 21 | def setUp(self): 22 | """spawn lshell with pexpect and return the child""" 23 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 24 | self.child.expect(PROMPT) 25 | 26 | def tearDown(self): 27 | self.child.close() 28 | 29 | def do_exit(self, child): 30 | """Exit the shell""" 31 | child.sendline("exit") 32 | child.expect(pexpect.EOF) 33 | 34 | def test_31_security_echo_freedom_and_help(self): 35 | """F31 | test help, then echo FREEDOM! && help () sh && help""" 36 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} ") 37 | child.expect(PROMPT) 38 | 39 | # Step 1: Enter `help` command 40 | expected_help_output = ( 41 | "bg cd clear echo exit fg help history jobs ll lpath ls lsudo" 42 | ) 43 | child.sendline("help") 44 | child.expect(PROMPT) 45 | help_output = child.before.decode("utf8").split("\n", 1)[1].strip() 46 | 47 | self.assertEqual(expected_help_output, help_output) 48 | 49 | # Step 2: Enter `echo FREEDOM! && help () sh && help` 50 | expected_output = ( 51 | "FREEDOM!\r\nbg cd clear echo exit fg help history " 52 | "jobs ll lpath ls lsudo\r\n" 53 | "bg cd clear echo exit fg help history jobs ll lpath ls lsudo" 54 | ) 55 | child.sendline("echo FREEDOM! && help () sh && help") 56 | child.expect(PROMPT) 57 | 58 | result = child.before.decode("utf8").strip().split("\n", 1)[1].strip() 59 | 60 | # Verify the combined output 61 | self.assertEqual(expected_output, result) 62 | self.do_exit(child) 63 | 64 | def test_32_security_echo_freedom_and_cd(self): 65 | """F32 | test echo FREEDOM! && cd () bash && cd ~/""" 66 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} ") 67 | child.expect(PROMPT) 68 | 69 | # Step 1: Enter `help` command 70 | expected_help_output = ( 71 | "bg cd clear echo exit fg help history jobs ll lpath ls lsudo" 72 | ) 73 | child.sendline("help") 74 | child.expect(PROMPT) 75 | help_output = child.before.decode("utf8").split("\n", 1)[1].strip() 76 | 77 | self.assertEqual(expected_help_output, help_output) 78 | 79 | # Step 2: Enter `echo FREEDOM! && help () sh && help` 80 | expected_output = "FREEDOM!\r\nlshell: () bash: No such file or directory" 81 | child.sendline("echo FREEDOM! && cd () bash && cd ~/") 82 | child.expect(PROMPT) 83 | 84 | result = child.before.decode("utf8").strip().split("\n", 1)[1].strip() 85 | 86 | # Verify the combined output 87 | self.assertEqual(expected_output, result) 88 | self.do_exit(child) 89 | 90 | def test_27_checksecure_awk(self): 91 | """F27 | checksecure awk script with /bin/sh""" 92 | child = pexpect.spawn( 93 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['awk']\"" 94 | ) 95 | child.expect(PROMPT) 96 | 97 | if test_utils.is_alpine_linux(): 98 | command = "awk 'BEGIN {system(\"/bin/sh\")}'" 99 | expected = "*** forbidden path: /bin/busybox" 100 | else: 101 | command = "awk 'BEGIN {system(\"/usr/bin/bash\")}'" 102 | expected = "*** forbidden path: /usr/bin/bash" 103 | child.sendline(command) 104 | child.expect(PROMPT) 105 | result = child.before.decode("utf8").split("\n")[1].strip() 106 | 107 | self.assertEqual(expected, result) 108 | self.do_exit(child) 109 | -------------------------------------------------------------------------------- /test/test_signals.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell terminal signals""" 2 | 3 | import os 4 | import unittest 5 | import time 6 | from getpass import getuser 7 | import pexpect 8 | 9 | 10 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 11 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 12 | LSHELL = f"{TOPDIR}/bin/lshell" 13 | USER = getuser() 14 | PROMPT = f"{USER}:~\\$" 15 | 16 | 17 | class TestFunctions(unittest.TestCase): 18 | """Functional tests for lshell""" 19 | 20 | def setUp(self): 21 | """spawn lshell with pexpect and return the child""" 22 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 23 | self.child.expect(PROMPT) 24 | 25 | def tearDown(self): 26 | self.child.close() 27 | 28 | def do_exit(self, child): 29 | """Exit the shell""" 30 | child.sendline("exit") 31 | child.expect(pexpect.EOF) 32 | 33 | def test_25_keyboard_interrupt(self): 34 | """F25 | test cat(1) with KeyboardInterrupt, should not exit""" 35 | child = pexpect.spawn( 36 | f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['cat']\"" 37 | ) 38 | child.expect(PROMPT) 39 | 40 | child.sendline("cat") 41 | child.sendline(" foo ") 42 | child.sendcontrol("c") 43 | child.expect(PROMPT) 44 | try: 45 | result = child.before.decode("utf8").split("\n")[1].strip() 46 | # both behaviors are correct 47 | if result.startswith("foo"): 48 | expected = "foo" 49 | elif result.startswith("^C"): 50 | expected = "^C" 51 | else: 52 | expected = "unknown" 53 | except IndexError: 54 | # outputs u' ^C' on Debian 55 | expected = "^C" 56 | result = child.before.decode("utf8").strip() 57 | self.assertIn(expected, result) 58 | self.do_exit(child) 59 | 60 | def test_28_catch_terminal_ctrl_j(self): 61 | """F28 | test ctrl-v ctrl-j then command, forbidden/security""" 62 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} ") 63 | child.expect(PROMPT) 64 | 65 | expected = "*** forbidden control char: echo\r" 66 | child.send("echo") 67 | child.sendcontrol("v") 68 | child.sendcontrol("j") 69 | child.sendline("bash") 70 | child.expect(PROMPT) 71 | 72 | result = child.before.decode("utf8").split("\n") 73 | 74 | self.assertIn(expected, result) 75 | self.do_exit(child) 76 | 77 | def test_29_catch_terminal_ctrl_k(self): 78 | """F29 | test ctrl-v ctrl-k then command, forbidden/security""" 79 | child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} ") 80 | child.expect(PROMPT) 81 | 82 | expected = "*** forbidden control char: echo\x0b() bash && echo\r" 83 | child.send("echo") 84 | child.sendcontrol("v") 85 | child.sendcontrol("k") 86 | child.sendline("() bash && echo") 87 | child.expect(PROMPT) 88 | 89 | result = child.before.decode("utf8").split("\n")[1] 90 | 91 | self.assertIn(expected, result) 92 | self.do_exit(child) 93 | 94 | def test_71_backgrounding_with_ctrl_z(self): 95 | """F71 | est backgrounding a command with Ctrl+Z.""" 96 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") 97 | child.expect(PROMPT) 98 | 99 | # Start a long-running command 100 | for file in ["file1", "file2", "file3"]: 101 | with open(file, "w") as f: 102 | f.write(f"{file} content") 103 | child.sendline(f"tail -f {file}") 104 | time.sleep(1) 105 | child.sendcontrol("z") 106 | # Verify stopped job message 107 | child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=1) 108 | 109 | # Check jobs output 110 | child.expect(PROMPT) 111 | child.sendline("jobs") 112 | child.expect(PROMPT) 113 | output = child.before.decode("utf-8").split("\n", 1)[1].strip() 114 | expected_output = ( 115 | "[1] Stopped tail -f file1\r\n" 116 | "[2]- Stopped tail -f file2\r\n" 117 | "[3]+ Stopped tail -f file3" 118 | ) 119 | 120 | assert ( 121 | output == expected_output 122 | ), f"Expected '{expected_output}', got '{output}'" 123 | 124 | # Resume the stopped job 125 | child.sendline("fg") 126 | child.sendcontrol("c") 127 | child.sendline("fg") 128 | child.sendcontrol("c") 129 | child.sendline("fg") 130 | child.sendcontrol("c") 131 | child.expect(PROMPT) 132 | 133 | def test_72_background_command_with_ampersand(self): 134 | """F72 | Test backgrounding a command with `&`.""" 135 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['sleep']\"") 136 | child.expect(PROMPT) 137 | 138 | # Run a background command with & 139 | child.sendline("sleep 60 &") 140 | child.expect(r"\[\d+\] sleep 60 \(pid: \d+\)", timeout=5) 141 | child.sendline("sleep 60 &") 142 | child.expect(r"\[\d+\] sleep 60 \(pid: \d+\)", timeout=5) 143 | child.sendline("sleep 60 &") 144 | child.expect(r"\[\d+\] sleep 60 \(pid: \d+\)", timeout=5) 145 | 146 | # Verify it's listed in jobs 147 | child.expect(PROMPT) 148 | child.sendline("jobs") 149 | child.expect(PROMPT) 150 | output = child.before.decode("utf-8").split("\n", 1)[1].strip() 151 | expected_output = ( 152 | "[1] Stopped sleep 60\r\n" 153 | "[2]- Stopped sleep 60\r\n" 154 | "[3]+ Stopped sleep 60" 155 | ) 156 | 157 | assert ( 158 | output == expected_output 159 | ), f"Expected '{expected_output}', got '{output}'" 160 | 161 | def test_73_exit_with_stopped_jobs(self): 162 | """F73 | Test exiting with stopped jobs.""" 163 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") 164 | child.expect(PROMPT) 165 | 166 | # Start a long-running command and background it 167 | child.sendline("tail -f") 168 | time.sleep(1) 169 | child.sendcontrol("z") 170 | child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=1) 171 | 172 | # Attempt to exit 173 | child.sendline("exit") 174 | child.expect("There are stopped jobs.", timeout=5) 175 | 176 | # Verify stopped jobs are listed 177 | child.sendline("jobs") 178 | child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=5) 179 | 180 | # Exit again 181 | child.sendline("exit") 182 | child.expect(pexpect.EOF, timeout=5) 183 | 184 | def test_74_resume_stopped_jobs(self): 185 | """F74 | Test resuming stopped jobs.""" 186 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") 187 | child.expect(PROMPT) 188 | 189 | # Start and stop multiple jobs 190 | child.sendline("tail -f") 191 | time.sleep(1) 192 | child.sendcontrol("z") 193 | child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=1) 194 | child.sendline("tail -ff") 195 | time.sleep(1) 196 | child.sendcontrol("z") 197 | child.expect(r"\[\d+\]\+ Stopped tail -ff", timeout=1) 198 | child.sendline("tail -fff") 199 | time.sleep(1) 200 | child.sendcontrol("z") 201 | child.expect(r"\[\d+\]\+ Stopped tail -fff", timeout=1) 202 | child.sendline("tail -ffff") 203 | time.sleep(1) 204 | child.sendcontrol("z") 205 | child.expect(r"\[\d+\]\+ Stopped tail -ffff", timeout=1) 206 | 207 | # Resume the second job 208 | child.sendline("fg 2") 209 | child.expect("tail -ff", timeout=5) 210 | child.sendcontrol("c") # Send Ctrl+C to stop the job 211 | child.expect(PROMPT) 212 | 213 | # Resume the first job 214 | child.sendline("fg 1") 215 | child.expect("tail -f", timeout=5) 216 | child.sendcontrol("c") # Send Ctrl+C to stop the job 217 | child.expect(PROMPT) 218 | 219 | # Resume the last two jobs 220 | child.sendline("fg") 221 | child.expect("tail -ffff", timeout=5) 222 | child.sendcontrol("c") # Send Ctrl+C to stop the job 223 | child.expect(PROMPT) 224 | child.sendline("fg") 225 | child.expect("tail -fff", timeout=5) 226 | child.sendcontrol("c") # Send Ctrl+C to stop the job 227 | child.expect(PROMPT) 228 | 229 | def test_75_interrupt_background_commands(self): 230 | """F75 | Test that `Ctrl+C` does not interrupt background commands.""" 231 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['sleep']\"") 232 | child.expect(PROMPT) 233 | 234 | # Run a background command 235 | child.sendline("sleep 60 &") 236 | child.expect(r"\[\d+\] sleep 60 \(pid: \d+\)", timeout=5) 237 | 238 | # Interrupt the foreground process (should not affect background) 239 | child.sendcontrol("c") 240 | child.expect(PROMPT) 241 | 242 | # Verify the background command is still running 243 | child.sendline("jobs") 244 | child.expect(r"\[\d+\]\+ Stopped sleep 60", timeout=5) 245 | 246 | def test_76_jobs_after_completion(self): 247 | """F76 | Test that completed jobs are removed from the `jobs` list.""" 248 | child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['sleep']\"") 249 | child.expect(PROMPT) 250 | 251 | # Run a short-lived background command 252 | child.sendline("sleep 2 &") 253 | child.expect(r"\[\d+\] sleep 2 \(pid: \d+\)", timeout=5) 254 | 255 | # Wait for the process to complete 256 | time.sleep(3) 257 | 258 | # Verify jobs output is empty 259 | child.sendline("jobs") 260 | child.expect(PROMPT) 261 | output = child.before.decode("utf-8").split("\n", 1)[1].strip() 262 | assert output == "", f"Expected no jobs, got: '{output}'" 263 | 264 | def test_77_mix_background_and_foreground(self): 265 | """F77 | Test mixing background and foreground commands.""" 266 | child = pexpect.spawn( 267 | f"{LSHELL} --config {CONFIG} --allowed \"+['sleep', 'tail']\"" 268 | ) 269 | child.expect(PROMPT) 270 | 271 | # Start a background command 272 | child.sendline("sleep 60 &") 273 | child.expect(r"\[\d+\] sleep 60 \(pid: \d+\)", timeout=5) 274 | 275 | # Start and stop a foreground command 276 | child.sendline("tail -f file1") 277 | time.sleep(1) 278 | child.sendcontrol("z") 279 | child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=1) 280 | 281 | # Verify jobs output 282 | child.expect(PROMPT) 283 | child.sendline("jobs") 284 | child.expect(PROMPT) 285 | output = child.before.decode("utf-8").split("\n", 1)[1].strip() 286 | expected_output = ( 287 | "[1]- Stopped sleep 60\r\n[2]+ Stopped tail -f file1" 288 | ) 289 | 290 | assert ( 291 | output == expected_output 292 | ), f"Expected '{expected_output}', got '{output}'" 293 | -------------------------------------------------------------------------------- /test/test_ssh.py: -------------------------------------------------------------------------------- 1 | """Functional tests for lshell SSH handling""" 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | import pexpect 7 | 8 | 9 | TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) 10 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 11 | LSHELL = f"{TOPDIR}/bin/lshell" 12 | USER = getuser() 13 | PROMPT = f"{USER}:~\\$" 14 | 15 | 16 | class TestFunctions(unittest.TestCase): 17 | """Functional tests for lshell""" 18 | 19 | def setUp(self): 20 | """spawn lshell with pexpect and return the child""" 21 | self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") 22 | self.child.expect(PROMPT) 23 | 24 | def tearDown(self): 25 | self.child.close() 26 | 27 | def do_exit(self, child): 28 | """Exit the shell""" 29 | child.sendline("exit") 30 | child.expect(pexpect.EOF) 31 | 32 | def test_45_overssh_allowed_command_exit_0(self): 33 | """F44 | Test 'ssh -c ls' command should exit 0""" 34 | # add SSH_CLIENT to environment 35 | if not os.environ.get("SSH_CLIENT"): 36 | os.environ["SSH_CLIENT"] = "random" 37 | 38 | self.child = pexpect.spawn( 39 | f"{LSHELL} " f"--config {CONFIG} " f"--overssh \"['ls']\" " f"-c 'ls'" 40 | ) 41 | self.child.expect(pexpect.EOF, timeout=10) 42 | 43 | # Assert that the process exited 44 | self.assertIsNotNone( 45 | self.child.exitstatus, 46 | f"The lshell process did not exit as expected: {self.child.exitstatus}", 47 | ) 48 | 49 | # Optionally, you can assert that the exit code is correct 50 | self.assertEqual( 51 | self.child.exitstatus, 52 | 0, 53 | f"The process should exit with code 0, got {self.child.exitstatus}.", 54 | ) 55 | 56 | def test_46_overssh_allowed_command_exit_1(self): 57 | """F44 | Test 'ssh -c ls' command should exit 1""" 58 | # add SSH_CLIENT to environment 59 | if not os.environ.get("SSH_CLIENT"): 60 | os.environ["SSH_CLIENT"] = "random" 61 | 62 | self.child = pexpect.spawn( 63 | f"{LSHELL} " 64 | f"--config {CONFIG} " 65 | f"--overssh \"['ls']\" " 66 | f"-c 'ls /random'" 67 | ) 68 | self.child.expect(pexpect.EOF, timeout=10) 69 | 70 | # Assert that the process exited 71 | self.assertIsNotNone( 72 | self.child.exitstatus, "The lshell process did not exit as expected." 73 | ) 74 | 75 | # Optionally, you can assert that the exit code is correct 76 | self.assertEqual( 77 | self.child.exitstatus, 78 | 1, 79 | f"The process should exit with code 1, got {self.child.exitstatus}.", 80 | ) 81 | 82 | def test_46_overssh_not_allowed_command_exit_1(self): 83 | """F44 | Test 'ssh -c lss' command should succeed""" 84 | # add SSH_CLIENT to environment 85 | if not os.environ.get("SSH_CLIENT"): 86 | os.environ["SSH_CLIENT"] = "random" 87 | 88 | self.child = pexpect.spawn( 89 | f"{LSHELL} " f"--config {CONFIG} " f"--overssh \"['ls']\" " f"-c 'lss'" 90 | ) 91 | self.child.expect(pexpect.EOF, timeout=10) 92 | 93 | # Assert that the process exited 94 | self.assertIsNotNone( 95 | self.child.exitstatus, "The lshell process did not exit as expected." 96 | ) 97 | 98 | # Optionally, you can assert that the exit code is correct 99 | self.assertEqual( 100 | self.child.exitstatus, 101 | 1, 102 | f"The process should exit with code 1, got {self.child.exitstatus}.", 103 | ) 104 | -------------------------------------------------------------------------------- /test/test_unit.py: -------------------------------------------------------------------------------- 1 | """ Unit tests for lshell """ 2 | 3 | import os 4 | import unittest 5 | from getpass import getuser 6 | from time import strftime, gmtime 7 | from unittest.mock import patch 8 | 9 | # import lshell specifics 10 | from lshell.checkconfig import CheckConfig 11 | from lshell.utils import get_aliases, updateprompt, parse_ps1, getpromptbase 12 | from lshell.variables import builtins_list 13 | from lshell import builtincmd 14 | from lshell import sec 15 | 16 | TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" 17 | CONFIG = f"{TOPDIR}/test/testfiles/test.conf" 18 | 19 | 20 | class TestFunctions(unittest.TestCase): 21 | """Unit tests for lshell""" 22 | 23 | args = [f"--config={CONFIG}", "--quiet=1"] 24 | userconf = CheckConfig(args).returnconf() 25 | 26 | def test_03_checksecure_doublepipe(self): 27 | """U03 | double pipes should be allowed, even if pipe is forbidden""" 28 | args = self.args + ["--forbidden=['|']"] 29 | userconf = CheckConfig(args).returnconf() 30 | input_command = "ls || ls" 31 | return self.assertEqual(sec.check_secure(input_command, userconf)[0], 0) 32 | 33 | def test_04_checksecure_forbiddenpipe(self): 34 | """U04 | forbid pipe, should return 1""" 35 | args = self.args + ["--forbidden=['|']"] 36 | userconf = CheckConfig(args).returnconf() 37 | input_command = "ls | ls" 38 | return self.assertEqual(sec.check_secure(input_command, userconf)[0], 1) 39 | 40 | def test_05_checksecure_forbiddenchar(self): 41 | """U05 | forbid character, should return 1""" 42 | args = self.args + ["--forbidden=['l']"] 43 | userconf = CheckConfig(args).returnconf() 44 | input_command = "ls" 45 | return self.assertEqual(sec.check_secure(input_command, userconf)[0], 1) 46 | 47 | def test_06_checksecure_sudo_command(self): 48 | """U06 | quoted text should not be forbidden""" 49 | input_command = "sudo ls" 50 | return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) 51 | 52 | def test_07_checksecure_notallowed_command(self): 53 | """U07 | forbidden command, should return 1""" 54 | args = self.args + ["--allowed=['ls']"] 55 | userconf = CheckConfig(args).returnconf() 56 | input_command = "ll" 57 | return self.assertEqual(sec.check_secure(input_command, userconf)[0], 1) 58 | 59 | def test_08_checkpath_notallowed_path(self): 60 | """U08 | forbidden command, should return 1""" 61 | args = self.args + ["--path=['/home', '/var']"] 62 | userconf = CheckConfig(args).returnconf() 63 | input_command = "cd /tmp" 64 | return self.assertEqual(sec.check_path(input_command, userconf)[0], 1) 65 | 66 | def test_09_checkpath_notallowed_path_completion(self): 67 | """U09 | forbidden command, should return 1""" 68 | args = self.args + ["--path=['/home', '/var']"] 69 | userconf = CheckConfig(args).returnconf() 70 | input_command = "cd /tmp/" 71 | return self.assertEqual( 72 | sec.check_path(input_command, userconf, completion=1)[0], 1 73 | ) 74 | 75 | def test_10_checkpath_dollarparenthesis(self): 76 | """U10 | when $() is allowed, return 0 if path allowed""" 77 | args = self.args + ["--forbidden=[';', '&', '|','`','>','<', '${']"] 78 | userconf = CheckConfig(args).returnconf() 79 | input_command = "echo $(echo aze)" 80 | return self.assertEqual(sec.check_path(input_command, userconf)[0], 0) 81 | 82 | def test_11_checkconfig_configoverwrite(self): 83 | """U12 | forbid ';', then check_secure should return 1""" 84 | args = [f"--config={CONFIG}", "--strict=123"] 85 | userconf = CheckConfig(args).returnconf() 86 | return self.assertEqual(userconf["strict"], 123) 87 | 88 | def test_13_multiple_aliases_with_separator(self): 89 | """U13 | multiple aliases using &&, || and ; separators""" 90 | # enable &, | and ; characters 91 | aliases = {"foo": "foo -l", "bar": "open"} 92 | input_command = "foo; fooo ;bar&&foo && foo | bar||bar || foo" 93 | return self.assertEqual( 94 | get_aliases(input_command, aliases), 95 | " foo -l; fooo ; open&& foo -l " "&& foo -l | open|| open || foo -l", 96 | ) 97 | 98 | def test_14_sudo_all_commands_expansion(self): 99 | """U14 | sudo_commands set to 'all' is equal to allowed variable""" 100 | args = self.args + ["--sudo_commands=all"] 101 | userconf = CheckConfig(args).returnconf() 102 | # exclude internal and sudo(8) commands 103 | exclude = builtins_list + ["sudo"] 104 | allowed = [x for x in userconf["allowed"] if x not in exclude] 105 | # sort lists to compare 106 | userconf["sudo_commands"].sort() 107 | allowed.sort() 108 | return self.assertEqual(allowed, userconf["sudo_commands"]) 109 | 110 | def test_16_allowed_ld_preload_builtin(self): 111 | """U16 | builtin commands should NOT be prepended with LD_PRELOAD""" 112 | args = self.args + ["--allowed=['echo','export']"] 113 | userconf = CheckConfig(args).returnconf() 114 | # verify that export is not automatically added to the aliases (i.e. 115 | # prepended with LD_PRELOAD) 116 | return self.assertNotIn("export", userconf["aliases"]) 117 | 118 | def test_17_allowed_exec_cmd(self): 119 | """U17 | allowed_shell_escape should NOT be prepended with LD_PRELOAD 120 | The command should not be added to the aliases variable 121 | """ 122 | args = self.args + ["--allowed_shell_escape=['echo']"] 123 | userconf = CheckConfig(args).returnconf() 124 | # sort lists to compare 125 | return self.assertNotIn("echo", userconf["aliases"]) 126 | 127 | def test_18_forbidden_environment(self): 128 | """U18 | unsafe environment are forbidden""" 129 | input_command = "export LD_PRELOAD=/lib64/ld-2.21.so" 130 | args = input_command 131 | retcode = builtincmd.cmd_export(args)[0] 132 | return self.assertEqual(retcode, 1) 133 | 134 | def test_19_allowed_environment(self): 135 | """U19 | other environment are accepted""" 136 | input_command = "export MY_PROJECT_VERSION=43" 137 | args = input_command 138 | retcode = builtincmd.cmd_export(args)[0] 139 | return self.assertEqual(retcode, 0) 140 | 141 | def test_20_winscp_allowed_commands(self): 142 | """U20 | when winscp is enabled, new allowed commands are automatically 143 | added (see man). 144 | """ 145 | args = self.args + ["--allowed=[]", "--winscp=1"] 146 | userconf = CheckConfig(args).returnconf() 147 | # sort lists to compare, except 'export' 148 | exclude = list(set(builtins_list) - set(["export"])) 149 | expected = exclude + ["scp", "env", "pwd", "groups", "unset", "unalias"] 150 | expected.sort() 151 | allowed = userconf["allowed"] 152 | allowed.sort() 153 | return self.assertEqual(allowed, expected) 154 | 155 | def test_21_winscp_allowed_semicolon(self): 156 | """U21 | when winscp is enabled, use of semicolon is allowed""" 157 | args = self.args + ["--forbidden=[';']", "--winscp=1"] 158 | userconf = CheckConfig(args).returnconf() 159 | # sort lists to compare 160 | return self.assertNotIn(";", userconf["forbidden"]) 161 | 162 | def test_22_prompt_short_0(self): 163 | """U22 | short_prompt = 0 should show dir compared to home dir""" 164 | expected = f"{getuser()}:~/foo$ " 165 | args = self.args + ["--prompt_short=0"] 166 | userconf = CheckConfig(args).returnconf() 167 | currentpath = f"{userconf['home_path']}/foo" 168 | prompt = updateprompt(currentpath, userconf) 169 | # sort lists to compare 170 | return self.assertEqual(prompt, expected) 171 | 172 | def test_23_prompt_short_1(self): 173 | """U23 | short_prompt = 1 should show only current dir""" 174 | expected = f"{getuser()}:foo$ " 175 | args = self.args + ["--prompt_short=1"] 176 | userconf = CheckConfig(args).returnconf() 177 | currentpath = f"{userconf['home_path']}/foo" 178 | prompt = updateprompt(currentpath, userconf) 179 | # sort lists to compare 180 | return self.assertEqual(prompt, expected) 181 | 182 | def test_24_prompt_short_2(self): 183 | """U24 | short_prompt = 2 should show full dir path""" 184 | expected = f"{getuser()}:{os.getcwd()}/foo$ " 185 | args = self.args + ["--prompt_short=2"] 186 | userconf = CheckConfig(args).returnconf() 187 | currentpath = f"{userconf['home_path']}/foo" 188 | prompt = updateprompt(currentpath, userconf) 189 | # sort lists to compare 190 | return self.assertEqual(prompt, expected) 191 | 192 | def test_25_disable_ld_preload(self): 193 | """U25 | empty path_noexec should disable LD_PRELOAD""" 194 | args = self.args + ["--allowed=['echo','export']", "--path_noexec=''"] 195 | userconf = CheckConfig(args).returnconf() 196 | # verify that no alias was created containing LD_PRELOAD 197 | return self.assertNotIn("echo", userconf["aliases"]) 198 | 199 | def test_26_checksecure_quoted_command(self): 200 | """U26 | quoted command should be parsed""" 201 | input_command = 'echo 1 && "bash"' 202 | return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) 203 | 204 | def test_27_checksecure_quoted_command(self): 205 | """U27 | quoted command should be parsed""" 206 | input_command = '"bash" && echo 1' 207 | return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) 208 | 209 | def test_28_checksecure_quoted_command(self): 210 | """U28 | quoted command should be parsed""" 211 | input_command = "echo'/1.sh'" 212 | return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) 213 | 214 | def test_29_env_path_updates_path_variable(self): 215 | """U29 | Test that --env_path updates the PATH environment variable.""" 216 | # store the original $PATH 217 | original_path = os.environ["PATH"] 218 | 219 | # Simulate passing the --env_path argument 220 | random_path = "/usr/random:/this_is_a_test" 221 | args = self.args + [ 222 | f"--env_path='{random_path}'", 223 | ] 224 | CheckConfig(args).returnconf() 225 | 226 | # Verify that the $PATH has been updated correctly 227 | expected_path = f"{random_path}:{original_path}" 228 | 229 | # Assuming CheckConfig sets the environment variable 230 | self.assertEqual(os.environ["PATH"], expected_path) 231 | 232 | # Reset the PATH environment variable 233 | os.environ["PATH"] = original_path 234 | 235 | @patch("sys.exit") # Mock sys.exit to prevent exiting the test on failure 236 | def test_30_invalid_new_path(self, mock_exit): 237 | """U30 | Test that an invalid new PATH triggers an error and sys.exit.""" 238 | original_path = os.environ["PATH"] 239 | random_path = "/usr/random:/invalid$path" 240 | args = self.args + [ 241 | f"--env_path='{random_path}'", 242 | ] 243 | 244 | # Simulate passing the --env_path argument 245 | CheckConfig(args).returnconf() 246 | 247 | # Check that sys.exit was called due to invalid path 248 | mock_exit.assert_called_once_with(1) 249 | 250 | # The PATH should not have been changed 251 | self.assertEqual(os.environ["PATH"], original_path) 252 | 253 | @patch("sys.exit") 254 | def test_31_new_path_starts_with_colon(self, mock_exit): 255 | """U31 | Test that a new PATH starting with a colon triggers an error.""" 256 | original_path = os.environ["PATH"] 257 | random_path = ":/usr/random:/this_is_a_test" 258 | args = self.args + [ 259 | f"--env_path='{random_path}'", 260 | ] 261 | 262 | # Simulate passing the --env_path argument 263 | CheckConfig(args).returnconf() 264 | 265 | # Check that sys.exit was called due to invalid path 266 | mock_exit.assert_called_once() 267 | 268 | # The PATH should not have been changed 269 | self.assertEqual(os.environ["PATH"], original_path) 270 | 271 | def test_32_lps1_user_host_time(self): 272 | r"""U32 | LPS1 using \u@\h - \t> format""" 273 | os.environ["LPS1"] = r"\u@\h - \t> " 274 | expected = f"{getuser()}@{os.uname()[1].split('.')[0]} - {strftime('%H:%M:%S', gmtime())}> " 275 | prompt = parse_ps1(os.getenv("LPS1")) 276 | self.assertEqual(prompt, expected) 277 | del os.environ["LPS1"] 278 | 279 | def test_33_lps1_with_cwd(self): 280 | r"""U33 | LPS1 should replace cwd with \w format""" 281 | os.environ["LPS1"] = r"\u:\w$ " 282 | expected = f"{getuser()}:{os.getcwd().replace(os.path.expanduser('~'), '~')}$ " 283 | prompt = parse_ps1(os.getenv("LPS1")) 284 | self.assertEqual(prompt, expected) 285 | del os.environ["LPS1"] 286 | 287 | def test_34_prompt_default_user_host(self): 288 | """U34 | Default config-based prompt should replace %u and %h""" 289 | userconf = CheckConfig(self.args).returnconf() 290 | userconf["prompt"] = "%u@%h" 291 | expected = f"{getuser()}@{os.uname()[1].split('.')[0]}" 292 | prompt = getpromptbase(userconf) 293 | self.assertEqual(prompt, expected) 294 | 295 | def test_35_updateprompt_lps1_defined(self): 296 | """U35 | LPS1 environment variable should override config-based prompt""" 297 | os.environ["LPS1"] = r"\u@\H \W$ " 298 | expected = f"{getuser()}@{os.uname()[1]} {os.path.basename(os.getcwd())}$ " 299 | userconf = CheckConfig(self.args).returnconf() 300 | prompt = updateprompt(os.getcwd(), userconf) 301 | self.assertEqual(prompt, expected) 302 | del os.environ["LPS1"] 303 | 304 | def test_36_updateprompt_home_path(self): 305 | """U36 | Prompt path should use '~' for home directory""" 306 | userconf = CheckConfig(self.args).returnconf() 307 | currentpath = userconf["home_path"] 308 | expected = f"{getuser()}:~$ " 309 | prompt = updateprompt(currentpath, userconf) 310 | self.assertEqual(prompt, expected) 311 | 312 | def test_37_updateprompt_short_prompt_level_1(self): 313 | """U37 | short_prompt = 1 should show only last directory in path""" 314 | userconf = CheckConfig(self.args).returnconf() 315 | userconf["prompt_short"] = 1 316 | currentpath = f"{userconf['home_path']}/foo/bar" 317 | expected = f"{getuser()}:bar$ " 318 | prompt = updateprompt(currentpath, userconf) 319 | self.assertEqual(prompt, expected) 320 | 321 | def test_38_updateprompt_short_prompt_level_2(self): 322 | """U38 | short_prompt = 2 should show full directory path""" 323 | userconf = CheckConfig(self.args).returnconf() 324 | userconf["prompt_short"] = 2 325 | currentpath = f"{userconf['home_path']}/foo/bar" 326 | expected = f"{getuser()}:{currentpath}$ " 327 | prompt = updateprompt(currentpath, userconf) 328 | self.assertEqual(prompt, expected) 329 | 330 | def test_39_updateprompt_path_inside_home(self): 331 | """U39 | Path inside home directory should start with '~'""" 332 | userconf = CheckConfig(self.args).returnconf() 333 | currentpath = f"{userconf['home_path']}/projects" 334 | expected = f"{getuser()}:~{currentpath[len(userconf['home_path']):]}$ " 335 | prompt = updateprompt(currentpath, userconf) 336 | self.assertEqual(prompt, expected) 337 | 338 | def test_40_updateprompt_absolute_path_outside_home(self): 339 | """U40 | Absolute path outside home should display fully in prompt""" 340 | userconf = CheckConfig(self.args).returnconf() 341 | currentpath = "/etc" 342 | expected = f"{getuser()}:{currentpath}$ " 343 | prompt = updateprompt(currentpath, userconf) 344 | self.assertEqual(prompt, expected) 345 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | """ Utils for the test suite. """ 2 | 3 | import os 4 | 5 | 6 | def is_alpine_linux(): 7 | """Check if the system is running Alpine Linux.""" 8 | if os.path.exists("/etc/os-release"): 9 | with open("/etc/os-release") as f: 10 | return any("ID=alpine" in line for line in f) 11 | return False 12 | -------------------------------------------------------------------------------- /test/testfiles/test.conf: -------------------------------------------------------------------------------- 1 | [global] 2 | logpath : /var/log/lshell/ 3 | loglevel : 2 4 | 5 | [default] 6 | allowed : ['ls', 'echo','ll'] 7 | forbidden : [';', '&', '|','`','>','<', '$(', '${'] 8 | warning_counter : 2 9 | aliases : {'ll':'ls -l'} 10 | strict : 0 11 | -------------------------------------------------------------------------------- /test/testfiles/test_51_grep_valid_log_entry.log: -------------------------------------------------------------------------------- 1 | [28/Oct/2024:22:15:30 +0000] UID=user123 2 | -------------------------------------------------------------------------------- /test/testfiles/test_52_grep_invalid_date_format.log: -------------------------------------------------------------------------------- 1 | [28/2024/Oct:22:15:30 +0000] UID=user123 2 | -------------------------------------------------------------------------------- /test/testfiles/test_53_grep_missing_uid.log: -------------------------------------------------------------------------------- 1 | [12/Oct/2023:22:15:30 +0000] 2 | -------------------------------------------------------------------------------- /test/testfiles/test_54_grep_special_characters_in_uid.log: -------------------------------------------------------------------------------- 1 | [12/Oct/2023:22:15:30 +0000] UID=user.name 2 | -------------------------------------------------------------------------------- /test/testfiles/test_60_allowed_extension_success.log: -------------------------------------------------------------------------------- 1 | Hello world! 2 | --------------------------------------------------------------------------------