From e10437fa84771afd3ae1018b573e0dc12d84832c Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 10 Mar 2026 22:50:47 -0400 Subject: [PATCH 01/29] Update README and configuration file with improved descriptions and structure --- README.md | 251 +++++++++++++----------------------------------- etc/lshell.conf | 5 + 2 files changed, 74 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index eae13b4..e1263e2 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,17 @@ # lshell -`lshell` is a Python-based limited shell. It is designed to restrict a user to a defined command set, enforce path and character restrictions, control SSH command behavior (`scp`, `sftp`, `rsync`, ...), and log activity. +`lshell` is a Python-based restricted shell that limits users to a defined set of commands, enforces path and SSH transfer controls (`scp`, `sftp`, `rsync`, ...), logs user activity, supports session/time restrictions, and more. ## Installation -### Install from PyPI +Install from PyPI: ```bash pip install limited-shell ``` -### Build and install from source +Build/install from source: ```bash python3 -m pip install build --user @@ -23,23 +23,34 @@ python3 -m build pip install . --break-system-packages ``` -### Uninstall +Uninstall: ```bash pip uninstall limited-shell ``` -## Usage +## Quick start -### Run lshell +Run `lshell` with an explicit config: ```bash lshell --config /path/to/lshell.conf ``` -### Admin diagnostics (`lshell policy-show`) +Default config location: + +- Linux: `/etc/lshell.conf` +- *BSD: `/usr/{pkg,local}/etc/lshell.conf` + +Set `lshell` as login shell: + +```bash +chsh -s /usr/bin/lshell user_name +``` -Explain effective policy resolution for a target user/group set and a command: +## Policy diagnostics + +Explain the effective policy and decision for a command: ```bash lshell policy-show \ @@ -50,78 +61,40 @@ lshell policy-show \ --command "sudo systemctl restart nginx" ``` -- prints precedence resolution (`default -> groups -> user`) -- lists included config files (`include_dir`) -- shows key-level overrides and final merged policy -- returns command decision (`ALLOW` / `DENY`) with reason - -### Interactive policy builtins - -Inside an `lshell` session: +Inside an interactive session: -- `policy-show []`: show resolved values and optionally check a command -- `policy-path`: show allowed/denied paths -- `policy-sudo`: show allowed sudo commands +- `policy-show []` +- `policy-path` (`lpath` alias) +- `policy-sudo` (`lsudo` alias) -Backward-compatible aliases: - -- `lpath` -> `policy-path` -- `lsudo` -> `policy-sudo` - -You can hide these policy commands from users with: +Hide these built-ins if needed: ```ini policy_commands : 0 ``` -Default config location: - -- Linux: `/etc/lshell.conf` -- *BSD: `/usr/{pkg,local}/etc/lshell.conf` - -You can also override configuration values from CLI: - -```bash -lshell --config /path/to/lshell.conf --log /var/log/lshell --umask 0077 -``` - -### Use lshell in scripts - -Use the lshell shebang and keep the `.lsh` extension: - -```bash -#!/usr/bin/lshell -echo "test" -``` - -## System setup - -### Add user to `lshell` group (for log access) - -```bash -usermod -aG lshell username -``` +## Configuration -### Set lshell as login shell +Primary template: [`etc/lshell.conf`](etc/lshell.conf) -Linux: +Key settings to review: -```bash -chsh -s /usr/bin/lshell user_name -``` +- `allowed` / `forbidden` +- `path` +- `sudo_commands` +- `overssh`, `scp`, `sftp`, `scp_upload`, `scp_download` +- `allowed_shell_escape` +- `allowed_file_extensions` +- `messages` +- `warning_counter`, `strict` +- `umask` -*BSD: +CLI overrides are supported, for example: ```bash -chsh -s /usr/{pkg,local}/bin/lshell user_name +lshell --config /path/to/lshell.conf --log /var/log/lshell --umask 0077 ``` -Make sure lshell is present in `/etc/shells`. - -## Configuration - -The main template is [`etc/lshell.conf`](etc/lshell.conf). Full reference is available in the man page. - ### Best practices - Prefer an explicit `allowed` allow-list instead of `'all'`. @@ -144,100 +117,6 @@ Precedence order: 1. User section 2. Group section 3. Default section - -### `allowed`: exact vs generic commands - -`allowed` accepts command names and exact command lines. - -```ini -allowed: ['ls', 'echo asd', 'telnet localhost'] -``` - -- `ls` allows `ls` with any arguments. -- `echo asd` allows only that exact command line. -- `telnet localhost` allows only `localhost` as host. - -For local executables, add explicit relative paths (for example `./deploy.sh`). - -### `warning_counter` and `strict` - -`warning_counter` is decremented on forbidden command/path/character attempts. -When `strict = 1`, unknown syntax/commands also decrement `warning_counter`. -`strict = 1` is typically preferred for higher-assurance restricted environments. - -### `messages` - -`messages` is an optional dictionary for customizing user-facing shell messages. -Unsupported keys and unsupported placeholders are rejected during config parsing. - -Supported keys and placeholders: - -- `unknown_syntax`: `{command}` -- `forbidden_generic`: `{messagetype}`, `{command}` -- `forbidden_command`: `{command}` -- `forbidden_path`: `{command}` -- `forbidden_character`: `{command}` -- `forbidden_control_char`: `{command}` -- `forbidden_command_over_ssh`: `{message}`, `{command}` -- `forbidden_scp_over_ssh`: `{message}` -- `warning_remaining`: `{remaining}`, `{violation_label}` -- `session_terminated`: no placeholders -- `incident_reported`: no placeholders - -Example: - -```ini -messages : { - 'unknown_syntax': 'lshell: unknown syntax: {command}', - 'forbidden_generic': 'lshell: forbidden {messagetype}: "{command}"', - 'forbidden_command': 'lshell: forbidden command: "{command}"', - 'forbidden_path': 'lshell: forbidden path: "{command}"', - 'forbidden_character': 'lshell: forbidden character: "{command}"', - 'forbidden_control_char': 'lshell: forbidden control char: "{command}"', - 'forbidden_command_over_ssh': 'lshell: forbidden {message}: "{command}"', - 'forbidden_scp_over_ssh': 'lshell: forbidden {message}', - 'warning_remaining': '*** You have {remaining} warning(s) left, before getting kicked out.', - 'session_terminated': 'lshell: session terminated: warning limit exceeded', - 'incident_reported': 'This incident has been reported.' -} -``` - -### Security-related settings - -- `path_noexec`: if available, lshell uses `sudo_noexec.so` to reduce command escape vectors. -- `allowed_shell_escape`: explicit list of commands allowed to run child programs. Do not set it to `'all'`. -- `allowed_file_extensions`: optional allow-list for file extensions passed in command lines. - -### Prompt accessibility - -- Keep the default prompt text-based and readable in monochrome terminals. -- If you use ANSI colors in `prompt` or `$LPS1`, avoid color-only meaning (for example, include separators like `user@host:path`). -- Verify contrast and readability over SSH clients commonly used in your environment. - -### `umask` - -Set a persistent session umask in config: - -```ini -umask : 0002 -``` - -- `0002` -> files `664`, directories `775` -- `0022` -> files `644`, directories `755` -- `0077` -> files `600`, directories `700` - -`umask` must be octal (`0000` to `0777`). -If you set umask in `login_script`, it does not persist because `login_script` runs in a child shell. - -Quick check inside an lshell session: - -```bash -umask -touch test_file -mkdir test_dir -ls -ld test_file test_dir -``` - ### Example configuration For users `foo` and `bar` in UNIX group `users`: @@ -252,19 +131,6 @@ loglevel : 2 allowed : ['ls','pwd'] forbidden : [';', '&', '|'] warning_counter : 2 -messages : { - 'unknown_syntax': 'lshell: unknown syntax: {command}', - 'forbidden_generic': 'lshell: forbidden {messagetype}: "{command}"', - 'forbidden_command': 'lshell: forbidden command: "{command}"', - 'forbidden_path': 'lshell: forbidden path: "{command}"', - 'forbidden_character': 'lshell: forbidden character: "{command}"', - 'forbidden_control_char': 'lshell: forbidden control char: "{command}"', - 'forbidden_command_over_ssh': 'lshell: forbidden {message}: "{command}"', - 'forbidden_scp_over_ssh': 'lshell: forbidden {message}', - 'warning_remaining': 'lshell: warning: {remaining} {violation_label} remaining before session termination', - 'session_terminated': 'lshell: session terminated: warning limit exceeded', - 'incident_reported': 'This incident has been reported.' - } timer : 0 path : ['/etc', '/usr'] env_path : '/sbin:/usr/foo' @@ -290,32 +156,53 @@ scpforce : '/home/bar/uploads/' # CONFIGURATION END ``` -## Testing with Docker Compose +For full option details, use: + +- `man lshell` +- `man ./man/lshell.1` -Run tests on multiple distributions in parallel: +## Testing + +Run test services directly: ```bash docker compose up ubuntu_tests debian_tests fedora_tests ``` -This runs `pytest`, `pylint`, and `flake8` in the configured test services. - -Run full local validation (including real SSH end-to-end scenarios configured with Ansible): +Run full validation: ```bash just test-all ``` -Run only real SSH end-to-end checks: +Run only SSH end-to-end checks: ```bash -just ssh-e2e +just test-ssh-e2e ``` -## Documentation +### Justfile usage -- `man lshell` (installed) -- `man ./man/lshell.1` (from repository) +List commands: + +```bash +just --list +``` + +Run distro-specific tests: + +```bash +just test-debian +just test-ubuntu +just test-fedora +``` + +Run sample configs interactively: + +```bash +just sample-list +just sample-ubuntu 01_baseline_allowlist.conf +``` ## Contributing diff --git a/etc/lshell.conf b/etc/lshell.conf index 4e45261..f90be88 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -32,6 +32,11 @@ loglevel : 2 ## e.g. splitting users into separate files #include_dir : /etc/lshell.d/*.conf +## section precedence reminder (highest to lowest): +## 1) [username] +## 2) [grp:groupname] +## 3) [default] + [default] ## a list of allowed commands, or 'all' to allow every command in user's PATH ## best practice: prefer an explicit allow-list instead of 'all' From ce28808d88c937f384d736491f90b41576bb6475 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 09:31:23 -0400 Subject: [PATCH 02/29] Add PyPI project page link to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e1263e2..11db5ff 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ `lshell` is a Python-based restricted shell that limits users to a defined set of commands, enforces path and SSH transfer controls (`scp`, `sftp`, `rsync`, ...), logs user activity, supports session/time restrictions, and more. +PyPI project page: https://pypi.org/project/limited-shell/ + ## Installation Install from PyPI: From ba48fd811fdd0346378238656396173ca2d5cc23 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 13:08:51 -0400 Subject: [PATCH 03/29] Update RPM packaging for lshell with Fedora scripts and configuration --- justfile | 32 +++++ rpm/lshell.rpm-test.conf | 43 ++++++ rpm/lshell.spec | 172 +++++------------------- rpm/postinstall | 70 ++-------- rpm/postuninstall | 56 ++------ rpm/preinstall | 13 +- rpm/scripts/fedora-rpm-build.sh | 30 +++++ rpm/scripts/fedora-rpm-install-smoke.sh | 19 +++ rpm/scripts/fedora-rpm-run.sh | 66 +++++++++ 9 files changed, 257 insertions(+), 244 deletions(-) create mode 100644 rpm/lshell.rpm-test.conf create mode 100644 rpm/scripts/fedora-rpm-build.sh create mode 100644 rpm/scripts/fedora-rpm-install-smoke.sh create mode 100644 rpm/scripts/fedora-rpm-run.sh diff --git a/justfile b/justfile index 22d3792..2fe68c2 100644 --- a/justfile +++ b/justfile @@ -103,6 +103,38 @@ fedora: test-fedora: just run fedora_tests +# Build an RPM from the current workspace using Fedora tooling +[private] +rpm-build-fedora: + {{compose}} run --build --rm --user root --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-build.sh + +# Install latest built RPM in Fedora container and verify CLI smoke checks +[private] +rpm-install-fedora: + {{compose}} run --build --rm --user root --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-install-smoke.sh + +# Run against installed RPM in two modes: +# - mode=tests (default): run full /app/test suite as testuser +# - mode=login: open root shell with testuser configured as /usr/bin/lshell +[private] +rpm-run-fedora mode='tests': + {{compose}} run --build --rm --user root -e MODE={{mode}} --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-run.sh + +# Run full tests against installed RPM +[private] +rpm-test-fedora: + just rpm-run-fedora tests + +# Open an interactive root shell with testuser preconfigured +rpm-login-fedora: + just rpm-run-fedora login + +# Full RPM flow: build, install verification, and installed-package tests +rpm-check-fedora: + just rpm-build-fedora + just rpm-install-fedora + just rpm-test-fedora + test-fedora-pypi: just run fedora-pypi diff --git a/rpm/lshell.rpm-test.conf b/rpm/lshell.rpm-test.conf new file mode 100644 index 0000000..5f886f5 --- /dev/null +++ b/rpm/lshell.rpm-test.conf @@ -0,0 +1,43 @@ +[global] +logpath : /var/log/lshell/ +loglevel : 2 + +[default] +allowed : ['ls', 'pwd', 'echo', 'cat', 'grep', 'id'] +forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] +warning_counter : 2 +strict : 1 +path : ['/home', '/tmp'] +overssh : ['rsync', 'scp'] +allowed_file_extensions : ['.conf', '.log', '.txt'] +aliases : {'ll': 'ls -l', 'la': 'ls -la'} +env_vars : {'LSHELL_LAYER': 'default', 'LSHELL_ENV': 'rpm-test'} +prompt_short : 1 +intro : "\033[1;95mRPM Test Profile: layered default/group/user policy\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\ndate\nll\ncat /home/testuser/lshell/test/testfiles/test.conf\npolicy-show\npolicy-show cat /home/testuser/lshell/test/testfiles/test.conf\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\ncat /etc/passwd\ncat /tmp/test.log\n" +messages : { + 'unknown_syntax': 'rpm-test: unknown syntax -> {command}', + 'forbidden_command': 'rpm-test: command blocked -> "{command}"', + 'forbidden_path': 'rpm-test: path blocked -> "{command}"', + 'forbidden_character': 'rpm-test: forbidden character -> "{command}"', + 'warning_remaining': 'rpm-test: {remaining} {violation_label} left before termination', + 'session_terminated': 'rpm-test: session terminated (warning limit reached)', + 'incident_reported': 'rpm-test: incident reported' + } + +[grp:lshell] +# Group layer modifies defaults for users in UNIX group "lshell". +allowed : + ['whoami', 'date'] - ['cat'] +path : + ['/var/log'] - ['/tmp'] +overssh : + ['sftp'] - ['scp'] +warning_counter : 3 +sudo_commands : ['journalctl', 'systemctl status sshd'] +env_vars : {'LSHELL_LAYER': 'group'} + +[testuser] +# User layer applies on top of default + group for testuser. +allowed : + ['cat', 'head', 'tail', 'policy-show'] - ['echo'] +path : + ['/home/testuser/lshell/test/testfiles'] - ['/home'] +overssh : + ['ls'] - ['rsync'] +allowed_file_extensions : + ['.yaml'] - ['.txt'] +env_vars : {'LSHELL_LAYER': 'user-testuser', 'RPM_TEST_PROFILE': '1'} +prompt_short : 2 diff --git a/rpm/lshell.spec b/rpm/lshell.spec index 4339866..91014c8 100644 --- a/rpm/lshell.spec +++ b/rpm/lshell.spec @@ -1,154 +1,54 @@ -%define name lshell -%define version 0.9.16 -%define release 1 -%define python_sitelib %(python -c "from distutils.sysconfig import get_python_lib; print get_python_lib()") - -Summary: Limited Shell -Name: %{name} -Version: %{version} -Release: %{release} -Source0: %{name}-%{version}.tar.gz -License: GPL -Group: System Environment/Shells -BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot -Prefix: %{_prefix} -BuildRequires: python >= 2.4 -Requires: python >= 2.4 -BuildArch: noarch -Vendor: Ignace Mouzannar (ghantoos) -Url: http://lshell.ghantoos.org +Name: lshell +Version: 0.11.0 +Release: 1%{?dist} +Summary: Limited shell implementation in Python + +License: GPL-3.0-or-later +URL: https://github.com/ghantoos/lshell +Source0: %{name}-%{version}.tar.gz +Source1: preinstall +Source2: postinstall +Source3: postuninstall + +BuildArch: noarch +BuildRequires: python3-devel +BuildRequires: python3-setuptools +Requires: python3 +Requires: python3-pyparsing >= 3.0.0 %description lshell is a 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 +(e.g. SCP, SFTP, rsync, etc.), log user commands, implement timing restrictions, and more. %prep %setup -q %build -%{__python} setup.py build +%{__python3} setup.py build %install -%{__python} setup.py install --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES --skip-build - -%clean -rm -rf $RPM_BUILD_ROOT - -%post -#!/bin/sh -# -# $Id: lshell.spec,v 1.14 2010-10-17 15:47:21 ghantoos Exp $ -# -# RPM build postinstall script - -# case of installation -if [ "$1" = "1" ] ; then - if ! getent group lshell 2>&1 > /dev/null; then - # thank you Michael Mansour for your suggestion to use groupadd - # instead of addgroup - groupadd -r lshell - fi - mkdir -p /var/log/lshell/ - chown root:lshell /var/log/lshell/ - chmod -R 770 /var/log/lshell/ - - - ##### - # This part is taken from debian add-shell(8) script - ##### - - lshell=/usr/bin/lshell - file=/etc/shells - tmpfile=${file}.tmp - - set -o noclobber - - trap "rm -f ${tmpfile}" EXIT - - if ! cat ${file} > ${tmpfile} - then - cat 1>&2 <> ${tmpfile} - fi - chmod --reference=${file} ${tmpfile} - chown --reference=${file} ${tmpfile} - - mv ${tmpfile} ${file} +rm -rf %{buildroot} +%{__python3} setup.py install --skip-build --root %{buildroot} - trap "" EXIT - exit 0 +%pre -f %{SOURCE1} -# case of upgrade -else - mkdir -p /var/log/lshell/ - chown root:lshell /var/log/lshell/ - chmod -R 774 /var/log/lshell/ +%post -f %{SOURCE2} - exit 0 - -fi - - - -%postun -#!/bin/sh -# -# $Id: lshell.spec,v 1.14 2010-10-17 15:47:21 ghantoos Exp $ -# -# RPM build postuninstall script - - if [ -x /usr/sbin/remove-shell ] && [ -f /etc/shells ]; then -##### -# This part is taken from debian remove-shell(8) script -##### - -lshell=/usr/bin/lshell -file=/etc/shells -# I want this to be GUARANTEED to be on the same filesystem as $file -tmpfile=${file}.tmp -otmpfile=${file}.tmp2 - -set -o noclobber - -trap "rm -f ${tmpfile} ${otmpfile}" EXIT - -if ! cat ${file} > ${tmpfile} -then - cat 1>&2 < ${otmpfile} || true -mv ${otmpfile} ${tmpfile} - -chmod --reference=${file} ${tmpfile} -chown --reference=${file} ${tmpfile} - -mv ${tmpfile} ${file} - -trap "" EXIT -exit 0 - fi +%postun -f %{SOURCE3} %files -%defattr(644,root,root,755) -%doc /usr/share/doc/lshell/* -%config(noreplace) %verify(not md5 mtime size) %{_sysconfdir}/* -%attr(755,root,root) %{_bindir}/lshell -%{python_sitelib}/* +%license COPYING +%doc %{_datadir}/doc/lshell/* +%{_bindir}/lshell +%config(noreplace) %{_prefix}/etc/lshell.conf +%config(noreplace) %{_prefix}/etc/logrotate.d/lshell +%{python3_sitelib}/lshell/ +%{python3_sitelib}/limited_shell-*.egg-info %{_mandir}/man1/lshell.1* + +%changelog +* Wed Mar 11 2026 lshell maintainers - 0.11.0-1 +- Refresh spec for Python 3 packaging and current project metadata +- Use external pre/post install hooks from rpm/ diff --git a/rpm/postinstall b/rpm/postinstall index 9a1e3e4..bba442f 100644 --- a/rpm/postinstall +++ b/rpm/postinstall @@ -1,64 +1,22 @@ #!/bin/sh -# -# $Id: postinstall,v 1.7 2009-07-28 18:33:02 ghantoos Exp $ -# -# RPM build postinstall script +# RPM post-install hook for lshell -# Check if rpm is being _installed_ (as opposed to _upgraded_) -# if installation process, then proceed -# source: http://www.ibm.com/developerworks/library/l-rpm3.html -# case of installation -if [ "$1" = "1" ] ; then +set -eu - if ! getent group lshell 2>&1 > /dev/null; then - addgroup --system lshell - fi - - chown root:lshell /var/log/lshell/ - chmod 770 /var/log/lshell/ - - ##### - # This part is taken from debian add-shell(8) script - ##### - - lshell=/usr/bin/lshell - file=/etc/shells - tmpfile=${file}.tmp - - set -o noclobber - - trap "rm -f ${tmpfile}" EXIT - - if ! cat ${file} > ${tmpfile} - then - cat 1>&2 </dev/null 2>&1; then + groupadd -r lshell +fi +mkdir -p /var/log/lshell +chown root:lshell /var/log/lshell +chmod 0770 /var/log/lshell - if ! grep -q "^${lshell}" ${tmpfile} - then - echo ${lshell} >> ${tmpfile} +# On fresh install, add lshell to /etc/shells if needed. +if [ "${1:-0}" = "1" ] && [ -f /etc/shells ]; then + if ! grep -q '^/usr/bin/lshell$' /etc/shells; then + printf '%s\n' '/usr/bin/lshell' >> /etc/shells fi - chmod --reference=${file} ${tmpfile} - chown --reference=${file} ${tmpfile} - - mv ${tmpfile} ${file} - - trap "" EXIT - exit 0 - -# case of upgrade -else - chown root:lshell /var/log/lshell/ - chmod -R 770 /var/log/lshell/ - - mv /etc/lshell.conf /etc/lshell.conf-rpm - mv /etc/lshell.conf-preinstall /etc/lshell.conf - exit 0 - fi +exit 0 diff --git a/rpm/postuninstall b/rpm/postuninstall index e5d6ba9..a82e9ee 100644 --- a/rpm/postuninstall +++ b/rpm/postuninstall @@ -1,54 +1,20 @@ #!/bin/sh -# -# $Id: postuninstall,v 1.6 2009-03-09 13:59:40 ghantoos Exp $ -# -# RPM build postuninstall script +# RPM post-uninstall hook for lshell -# Check if rpm is being _removed_ (as opposed to _upgraded_) -# if deletion process, then proceed, else, exit 0 -# source: http://www.ibm.com/developerworks/library/l-rpm3.html -if [ "$1" != "0" ] ; then - if [ -f "/etc/lshell.conf" ]; then - cp /etc/lshell.conf /etc/lshell.conf-preinstall - fi +set -eu + +# Only run cleanup on erase (0), not upgrade. +if [ "${1:-1}" != "0" ]; then exit 0 fi -#groupdel lshellg -rm -f /etc/lshell.conf-rpm - -##### -# This part is taken from debian remove-shell(8) script -##### - -lshell=/usr/bin/lshell -file=/etc/shells -# I want this to be GUARANTEED to be on the same filesystem as $file -tmpfile=${file}.tmp -otmpfile=${file}.tmp2 - -set -o noclobber - -trap "rm -f ${tmpfile} ${otmpfile}" EXIT - -if ! cat ${file} > ${tmpfile} -then - cat 1>&2 < "${tmpfile}" || true + cat "${tmpfile}" > /etc/shells + rm -f "${tmpfile}" fi -# this is supposed to be reliable, not pretty -grep -v "^${lshell}$" ${tmpfile} > ${otmpfile} || true -mv ${otmpfile} ${tmpfile} - -chmod --reference=${file} ${tmpfile} -chown --reference=${file} ${tmpfile} - -mv ${tmpfile} ${file} +rm -f /etc/lshell.conf-rpm -trap "" EXIT exit 0 - diff --git a/rpm/preinstall b/rpm/preinstall index f576370..5b081de 100644 --- a/rpm/preinstall +++ b/rpm/preinstall @@ -1,12 +1,11 @@ #!/bin/sh -# -# $Id: preinstall,v 1.2 2009-02-15 18:46:58 ghantoos Exp $ -# -# RPM build preinstall script +# RPM pre-install hook for lshell -# Save the configuration -if [ -f "/etc/lshell.conf" ]; then - cp /etc/lshell.conf /etc/lshell.conf-preinstall +set -eu + +# Keep a backup of an existing config prior to upgrade/install. +if [ -f /etc/lshell.conf ]; then + cp -a /etc/lshell.conf /etc/lshell.conf-preinstall fi exit 0 diff --git a/rpm/scripts/fedora-rpm-build.sh b/rpm/scripts/fedora-rpm-build.sh new file mode 100644 index 0000000..ab8cb43 --- /dev/null +++ b/rpm/scripts/fedora-rpm-build.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +dnf install -y rpm-build python3-devel python3-setuptools +git config --global --add safe.directory /app + +VERSION="$(python3 -c "from lshell.variables import __version__; print(__version__)")" +TOPDIR="/tmp/rpmbuild" +OUTDIR="/app/build/rpm" + +rm -rf "${TOPDIR}" "${OUTDIR}" +mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} +mkdir -p "${OUTDIR}" + +git -C /app archive \ + --format=tar.gz \ + --prefix="lshell-${VERSION}/" \ + -o "${TOPDIR}/SOURCES/lshell-${VERSION}.tar.gz" \ + HEAD + +cp /app/rpm/lshell.spec "${TOPDIR}/SPECS/" +cp /app/rpm/preinstall /app/rpm/postinstall /app/rpm/postuninstall "${TOPDIR}/SOURCES/" + +rpmbuild -ba --define "_topdir ${TOPDIR}" "${TOPDIR}/SPECS/lshell.spec" + +cp -a "${TOPDIR}/RPMS" "${OUTDIR}/" +cp -a "${TOPDIR}/SRPMS" "${OUTDIR}/" + +ls -lah "${OUTDIR}/RPMS" "${OUTDIR}/SRPMS" diff --git a/rpm/scripts/fedora-rpm-install-smoke.sh b/rpm/scripts/fedora-rpm-install-smoke.sh new file mode 100644 index 0000000..f41a5d5 --- /dev/null +++ b/rpm/scripts/fedora-rpm-install-smoke.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RPM_FILE="$(ls -1t /app/build/rpm/RPMS/noarch/lshell-*.rpm | head -n1)" + +dnf install -y "${RPM_FILE}" + +install -m 0644 /app/rpm/lshell.rpm-test.conf /etc/lshell.rpm-test.conf + +lshell --version +lshell --config /etc/lshell.conf --help >/dev/null + +# Validate the dedicated RPM test profile parses and resolves policy layers. +lshell policy-show \ + --config /etc/lshell.rpm-test.conf \ + --user testuser \ + --group lshell \ + --command "cat /home/testuser/lshell/test/testfiles/test.conf" >/dev/null diff --git a/rpm/scripts/fedora-rpm-run.sh b/rpm/scripts/fedora-rpm-run.sh new file mode 100644 index 0000000..26035e6 --- /dev/null +++ b/rpm/scripts/fedora-rpm-run.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MODE="${1:-${MODE:-tests}}" +MODE="${MODE#mode=}" +RPM_FILE="$(ls -1t /app/build/rpm/RPMS/noarch/lshell-*.rpm | head -n1)" + +dnf install -y "${RPM_FILE}" + +if [ -f /usr/etc/lshell.conf ] && [ ! -f /etc/lshell.conf ]; then + cp -a /usr/etc/lshell.conf /etc/lshell.conf +fi + +install -m 0644 /app/rpm/lshell.rpm-test.conf /etc/lshell.rpm-test.conf + +# Prepare users and groups used by RPM test/login flows. +# Use a single identity everywhere: testuser +usermod -s /usr/bin/lshell testuser +echo "testuser:password" | chpasswd +usermod -aG lshell testuser || true + +if [ "${MODE}" = "tests" ]; then + # Force test invocations to use the installed RPM binary. + ln -sf /usr/bin/lshell /home/testuser/lshell/bin/lshell + + runuser -u testuser -- bash -lc \ + "cd /home/testuser/lshell && python3 -m pytest -q /home/testuser/lshell/test" + exit 0 +fi + +if [ "${MODE}" = "login" ]; then + # For manual login testing, make the layered RPM test profile the active default. + cp -f /etc/lshell.rpm-test.conf /etc/lshell.conf + + printf "%s\n" \ + "============================================================" \ + "Fedora RPM test shell is ready" \ + "============================================================" \ + "Installed RPM: ${RPM_FILE}" \ + "" \ + "Accounts:" \ + " - root (current shell)" \ + " - testuser / password: password (login shell: /usr/bin/lshell, group: lshell)" \ + "" \ + "Main RPM test config (layered): /etc/lshell.rpm-test.conf" \ + "Layers included in this file:" \ + " - [default]" \ + " - [grp:lshell]" \ + " - [testuser]" \ + "" \ + "Suggested checks:" \ + " 1) rpm -qi lshell" \ + " 2) lshell --version" \ + " 3) lshell policy-show --config /etc/lshell.rpm-test.conf --user testuser --group lshell --command \"cat /home/testuser/lshell/test/testfiles/test.conf\"" \ + " 4) su -s /bin/bash -c \"lshell --config /etc/lshell.rpm-test.conf\" testuser" \ + " 5) su - testuser" \ + " 6) runuser -u testuser -- bash -lc \"cd /home/testuser/lshell && python3 -m pytest -q /home/testuser/lshell/test\"" \ + "" \ + "Type \"exit\" to leave the container." \ + "============================================================" + exec bash +fi + +echo "Unknown mode: ${MODE}. Use MODE=tests or MODE=login." >&2 +exit 2 From 6912eb1975eab9fdd4a6c65264e7a07be10d5f13 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 13:18:06 -0400 Subject: [PATCH 04/29] Add command not found message handling and tests --- lshell/messages.py | 2 + lshell/utils.py | 98 ++++++++++++++++++++++++++++++++++ test/test_command_execution.py | 20 +++++++ test/test_messages_unit.py | 15 ++++++ 4 files changed, 135 insertions(+) diff --git a/lshell/messages.py b/lshell/messages.py index 052092c..be50ef6 100644 --- a/lshell/messages.py +++ b/lshell/messages.py @@ -5,6 +5,7 @@ DEFAULT_MESSAGES = { "unknown_syntax": "lshell: unknown syntax: {command}", + "command_not_found": 'lshell: command not found: "{command}"', "forbidden_generic": 'lshell: forbidden {messagetype}: "{command}"', "forbidden_command": 'lshell: forbidden command: "{command}"', "forbidden_path": 'lshell: forbidden path: "{command}"', @@ -22,6 +23,7 @@ MESSAGE_FIELDS = { "unknown_syntax": {"command"}, + "command_not_found": {"command"}, "forbidden_generic": {"messagetype", "command"}, "forbidden_command": {"command"}, "forbidden_path": {"command"}, diff --git a/lshell/utils.py b/lshell/utils.py index f6f4658..c7a64ee 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -7,6 +7,7 @@ import random import string import shlex +import shutil from getpass import getuser from time import strftime, gmtime import signal @@ -15,6 +16,7 @@ from lshell import variables from lshell import builtincmd from lshell import sec +from lshell import messages def usage(exitcode=1): @@ -244,6 +246,68 @@ def replace_exit_code(line, retcode): _ENV_VAR_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +_SHELL_BUILTINS = { + ".", + ":", + "alias", + "bg", + "bind", + "break", + "builtin", + "caller", + "cd", + "command", + "compgen", + "complete", + "compopt", + "continue", + "declare", + "dirs", + "disown", + "echo", + "enable", + "eval", + "exec", + "exit", + "export", + "false", + "fc", + "fg", + "getopts", + "hash", + "help", + "history", + "jobs", + "kill", + "let", + "local", + "logout", + "mapfile", + "popd", + "printf", + "pushd", + "pwd", + "read", + "readonly", + "return", + "set", + "shift", + "shopt", + "source", + "suspend", + "test", + "times", + "trap", + "true", + "type", + "typeset", + "ulimit", + "umask", + "unalias", + "unset", + "wait", + "[", +} def _expand_braced_parameter(expr, support_advanced=True): @@ -398,6 +462,20 @@ def _is_allowed_command(executable, command, conf): return executable in conf["allowed"] or command in conf["allowed"] +def _command_exists(executable): + """Return True when command token resolves to a runnable command.""" + if not executable: + return False + + if executable in _SHELL_BUILTINS: + return True + + if "/" in executable: + return os.path.isfile(executable) and os.access(executable, os.X_OK) + + return shutil.which(executable) is not None + + def handle_builtin_command(full_command, executable, argument, shell_context): """ Handle built-in commands like cd, lpath, lsudo, etc. @@ -648,6 +726,26 @@ def _handle_unknown_syntax(unknown_command): and _is_allowed_command(executable_name, part, shell_context.conf) for (executable_name, _, _, _), part in zip(parsed_parts, pipeline_parts) ): + if not skip_policy_checks: + missing_executable = next( + ( + executable_name + for executable_name, _, _, _ in parsed_parts + if executable_name + and executable_name not in builtincmd.builtins_list + and not _command_exists(executable_name) + ), + None, + ) + if missing_executable: + command_not_found_message = messages.get_message( + shell_context.conf, + "command_not_found", + command=missing_executable, + ) + shell_context.log.critical(command_not_found_message) + return 127 + extra_env = None allowed_shell_escape = set(shell_context.conf.get("allowed_shell_escape", [])) uses_shell_escape = any( diff --git a/test/test_command_execution.py b/test/test_command_execution.py index 53d09b8..de8df77 100644 --- a/test/test_command_execution.py +++ b/test/test_command_execution.py @@ -256,6 +256,26 @@ def test_46_redirection_is_shell_compatible(self): self.assertIn("does_not_exist", result) self.do_exit(child) + def test_47_allowed_missing_binary_uses_lshell_error(self): + """F47 | Allowed command missing on PATH should use lshell error format.""" + random_suffix = random.randint(100000, 999999) + missing_cmd = f"lshell_missing_cmd_{random_suffix}" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} " + f"--allowed \"+['{missing_cmd}']\" " + '--forbidden "[]"' + ) + child.expect(PROMPT) + + child.sendline(missing_cmd) + child.expect(PROMPT) + output = child.before.decode("utf8").split("\n", 1)[1] + expected = f'lshell: command not found: "{missing_cmd}"' + self.assertIn(expected, output) + self.assertEqual(output.count(expected), 1) + self.assertNotIn("bash:", output) + self.do_exit(child) + def test_69_operator_matrix_fuzz(self): """F69 | Operator and expansion matrix should remain stable.""" child = pexpect.spawn( diff --git a/test/test_messages_unit.py b/test/test_messages_unit.py index d2c40a0..3c15fda 100644 --- a/test/test_messages_unit.py +++ b/test/test_messages_unit.py @@ -52,6 +52,20 @@ def test_forbidden_generic_message(self): "blocked sudo command: sudo cat /etc/passwd", ) + def test_command_not_found_message(self): + """Render the command-not-found message from default and custom config.""" + conf = {} + self.assertEqual( + messages.get_message(conf, "command_not_found", command="catt"), + 'lshell: command not found: "catt"', + ) + + custom_conf = {"messages": {"command_not_found": "missing: {command}"}} + self.assertEqual( + messages.get_message(custom_conf, "command_not_found", command="catt"), + "missing: catt", + ) + def test_forbidden_command_message(self): """Render the forbidden command message from default and custom config.""" conf = {} @@ -252,6 +266,7 @@ def test_validate_messages_config_accepts_all_supported_keys(self): """Accept a messages dictionary containing every supported key.""" overrides = { "unknown_syntax": "a {command}", + "command_not_found": "a {command}", "forbidden_generic": "a {messagetype} {command}", "forbidden_command": "a {command}", "forbidden_path": "a {command}", From 350e819cedd26954d8d169d5c9f29ebf5de2efb4 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 17:28:24 -0400 Subject: [PATCH 05/29] Update .deb packaging for lshell --- debian/compat | 1 - debian/control | 3 +- debian/lshell.deb-test.conf | 43 +++++++++++++++ debian/lshell.postinst | 2 +- debian/rules | 14 +++-- debian/scripts/debian-deb-build.sh | 37 +++++++++++++ debian/scripts/debian-deb-install-smoke.sh | 20 +++++++ debian/scripts/debian-deb-run.sh | 62 ++++++++++++++++++++++ justfile | 54 +++++++++++++++---- 9 files changed, 217 insertions(+), 19 deletions(-) delete mode 100644 debian/compat create mode 100644 debian/lshell.deb-test.conf create mode 100644 debian/scripts/debian-deb-build.sh create mode 100644 debian/scripts/debian-deb-install-smoke.sh create mode 100644 debian/scripts/debian-deb-run.sh diff --git a/debian/compat b/debian/compat deleted file mode 100644 index ec63514..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -9 diff --git a/debian/control b/debian/control index d3fd223..8cbfc73 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: lshell Section: shells Priority: optional Maintainer: Ignace Mouzannar -Build-Depends: debhelper (>= 9), python3 (>= 3.4) +Build-Depends: debhelper-compat (= 13), python3 (>= 3.4) X-Python3-Version: >= 3.4 Standards-Version: 3.9.7 Homepage: https://github.com/ghantoos/lshell @@ -11,7 +11,6 @@ Package: lshell Architecture: all Homepage: http://lshell.ghantoos.org/ Depends: ${misc:Depends}, ${python3:Depends}, adduser -XB-Python-Version: ${python3:Versions} Description: restricts a user's shell environment to limited sets of commands lshell is a 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 diff --git a/debian/lshell.deb-test.conf b/debian/lshell.deb-test.conf new file mode 100644 index 0000000..5125f41 --- /dev/null +++ b/debian/lshell.deb-test.conf @@ -0,0 +1,43 @@ +[global] +logpath : /var/log/lshell/ +loglevel : 2 + +[default] +allowed : ['ls', 'pwd', 'echo', 'cat', 'grep', 'id'] +forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] +warning_counter : 2 +strict : 1 +path : ['/home', '/tmp'] +overssh : ['rsync', 'scp'] +allowed_file_extensions : ['.conf', '.log', '.txt'] +aliases : {'ll': 'ls -l', 'la': 'ls -la'} +env_vars : {'LSHELL_LAYER': 'default', 'LSHELL_ENV': 'deb-test'} +prompt_short : 1 +intro : "\033[1;95mDEB Test Profile: layered default/group/user policy\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\ndate\nll\ncat /home/testuser/lshell/test/testfiles/test.conf\npolicy-show\npolicy-show cat /home/testuser/lshell/test/testfiles/test.conf\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\ncat /etc/passwd\ncat /tmp/test.log\n" +messages : { + 'unknown_syntax': 'deb-test: unknown syntax -> {command}', + 'forbidden_command': 'deb-test: command blocked -> "{command}"', + 'forbidden_path': 'deb-test: path blocked -> "{command}"', + 'forbidden_character': 'deb-test: forbidden character -> "{command}"', + 'warning_remaining': 'deb-test: {remaining} {violation_label} left before termination', + 'session_terminated': 'deb-test: session terminated (warning limit reached)', + 'incident_reported': 'deb-test: incident reported' + } + +[grp:lshell] +# Group layer modifies defaults for users in UNIX group "lshell". +allowed : + ['whoami', 'date'] - ['cat'] +path : + ['/var/log'] - ['/tmp'] +overssh : + ['sftp'] - ['scp'] +warning_counter : 3 +sudo_commands : ['journalctl', 'systemctl status sshd'] +env_vars : {'LSHELL_LAYER': 'group'} + +[testuser] +# User layer applies on top of default + group for testuser. +allowed : + ['cat', 'head', 'tail', 'policy-show'] - ['echo'] +path : + ['/home/testuser/lshell/test/testfiles'] - ['/home'] +overssh : + ['ls'] - ['rsync'] +allowed_file_extensions : + ['.yaml'] - ['.txt'] +env_vars : {'LSHELL_LAYER': 'user-testuser', 'DEB_TEST_PROFILE': '1'} +prompt_short : 2 diff --git a/debian/lshell.postinst b/debian/lshell.postinst index 75e166f..ea34639 100644 --- a/debian/lshell.postinst +++ b/debian/lshell.postinst @@ -16,7 +16,7 @@ case "$1" in fi chown root:lshell /var/log/lshell/ - chmod -R 770 /var/log/lshell/ + chmod 0770 /var/log/lshell/ add-shell /usr/bin/lshell ;; diff --git a/debian/rules b/debian/rules index 2951451..cba05a7 100755 --- a/debian/rules +++ b/debian/rules @@ -3,16 +3,22 @@ export DH_ALWAYS_EXCLUDE=COPYING:CHANGES export PYTHON=`which python3` +# Keep Debian build logs clean from setuptools deprecation warnings. +export PYTHONWARNINGS=ignore %: dh $@ --with python3 --buildsystem=pybuild override_dh_auto_install: $(PYTHON) setup.py install --root=$(CURDIR)/debian/lshell --install-layout=deb + # setuptools data_files are currently staged under /usr/etc; move them to /etc. + if [ -d "$(CURDIR)/debian/lshell/usr/etc" ]; then \ + mkdir -p "$(CURDIR)/debian/lshell/etc"; \ + cp -a "$(CURDIR)/debian/lshell/usr/etc/." "$(CURDIR)/debian/lshell/etc/"; \ + rm -rf "$(CURDIR)/debian/lshell/usr/etc"; \ + fi + # dh_installchangelogs already installs changelog.gz; avoid duplicate changelog. + rm -f "$(CURDIR)/debian/lshell/usr/share/doc/lshell/CHANGELOG.md" override_dh_installchangelogs: dh_installchangelogs CHANGES - -override_dh_auto_clean: - $(PYTHON) setup.py clean -a - dh_auto_clean diff --git a/debian/scripts/debian-deb-build.sh b/debian/scripts/debian-deb-build.sh new file mode 100644 index 0000000..48c85c3 --- /dev/null +++ b/debian/scripts/debian-deb-build.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y \ + build-essential \ + debhelper \ + dh-python \ + dpkg-dev \ + fakeroot \ + lintian \ + python3-all \ + python3-setuptools + +WORKDIR="/tmp/lshell-deb-src" +OUTDIR="/app/build/deb" + +rm -rf "${WORKDIR}" "${OUTDIR}" +mkdir -p "${OUTDIR}" +cp -a /app "${WORKDIR}" + +cd "${WORKDIR}" + +# Legacy Debian rules expect a CHANGES file. Keep it local to build workspace. +if [ ! -f CHANGES ] && [ -f CHANGELOG.md ]; then + cp CHANGELOG.md CHANGES +fi + +DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -us -uc -b + +find /tmp -maxdepth 1 -type f \( -name 'lshell_*.deb' -o -name 'lshell_*.changes' -o -name 'lshell_*.buildinfo' \) -exec cp -a {} "${OUTDIR}/" \; + +# Treat lintian warnings as failures so package quality stays strict. +lintian --fail-on warning "${OUTDIR}"/lshell_*.deb + +ls -lah "${OUTDIR}" diff --git a/debian/scripts/debian-deb-install-smoke.sh b/debian/scripts/debian-deb-install-smoke.sh new file mode 100644 index 0000000..c44ac2f --- /dev/null +++ b/debian/scripts/debian-deb-install-smoke.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DEB_FILE="$(ls -1t /app/build/deb/lshell_*_all.deb | head -n1)" + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y "${DEB_FILE}" + +install -m 0644 /app/debian/lshell.deb-test.conf /etc/lshell.deb-test.conf + +lshell --version +lshell --config /etc/lshell.conf --help >/dev/null + +# Validate the dedicated Debian test profile parses and resolves policy layers. +lshell policy-show \ + --config /etc/lshell.deb-test.conf \ + --user testuser \ + --group lshell \ + --command "cat /home/testuser/lshell/test/testfiles/test.conf" >/dev/null diff --git a/debian/scripts/debian-deb-run.sh b/debian/scripts/debian-deb-run.sh new file mode 100644 index 0000000..4687cf6 --- /dev/null +++ b/debian/scripts/debian-deb-run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MODE="${1:-${MODE:-tests}}" +MODE="${MODE#mode=}" +DEB_FILE="$(ls -1t /app/build/deb/lshell_*_all.deb | head -n1)" + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y "${DEB_FILE}" + +install -m 0644 /app/debian/lshell.deb-test.conf /etc/lshell.deb-test.conf + +# Use a single identity everywhere: testuser +usermod -s /usr/bin/lshell testuser +echo "testuser:password" | chpasswd +usermod -aG lshell testuser || true + +if [ "${MODE}" = "tests" ]; then + # Force test invocations to use the installed Debian package binary. + ln -sf /usr/bin/lshell /home/testuser/lshell/bin/lshell + + runuser -u testuser -- bash -lc \ + "cd /home/testuser/lshell && python3 -m pytest -q /home/testuser/lshell/test" + exit 0 +fi + +if [ "${MODE}" = "login" ]; then + # For manual login testing, make the layered Debian test profile the active default. + cp -f /etc/lshell.deb-test.conf /etc/lshell.conf + + printf "%s\n" \ + "============================================================" \ + "Debian package test shell is ready" \ + "============================================================" \ + "Installed DEB: ${DEB_FILE}" \ + "" \ + "Accounts:" \ + " - root (current shell)" \ + " - testuser / password: password (login shell: /usr/bin/lshell, group: lshell)" \ + "" \ + "Main DEB test config (layered): /etc/lshell.deb-test.conf" \ + "Layers included in this file:" \ + " - [default]" \ + " - [grp:lshell]" \ + " - [testuser]" \ + "" \ + "Suggested checks:" \ + " 1) dpkg -s lshell" \ + " 2) lshell --version" \ + " 3) lshell policy-show --config /etc/lshell.deb-test.conf --user testuser --group lshell --command \"cat /home/testuser/lshell/test/testfiles/test.conf\"" \ + " 4) su -s /bin/bash -c \"lshell --config /etc/lshell.deb-test.conf\" testuser" \ + " 5) su - testuser" \ + " 6) runuser -u testuser -- bash -lc \"cd /home/testuser/lshell && python3 -m pytest -q /home/testuser/lshell/test\"" \ + "" \ + "Type \"exit\" to leave the container." \ + "============================================================" + exec bash +fi + +echo "Unknown mode: ${MODE}. Use MODE=tests or MODE=login." >&2 +exit 2 diff --git a/justfile b/justfile index 2fe68c2..1e6266f 100644 --- a/justfile +++ b/justfile @@ -83,6 +83,38 @@ test-debian-pypi: test-debian-pypi-pre: just run debian-pypi-pre +# Build a Debian package from the current workspace using Debian tooling +[private] +pkg-deb-build-debian: + {{compose}} run --build --rm --user root --entrypoint bash debian /app/debian/scripts/debian-deb-build.sh + +# Install latest built Debian package and verify CLI smoke checks +[private] +pkg-deb-install-debian: + {{compose}} run --build --rm --user root --entrypoint bash debian /app/debian/scripts/debian-deb-install-smoke.sh + +# Run against installed Debian package in two modes: +# - mode=tests (default): run full /app/test suite as testuser +# - mode=login: open root shell with testuser configured as /usr/bin/lshell +[private] +pkg-deb-run-debian-mode mode='tests': + {{compose}} run --build --rm --user root -e MODE={{mode}} --entrypoint bash debian /app/debian/scripts/debian-deb-run.sh + +# Run full tests against installed Debian package +[private] +pkg-deb-test-debian: + just pkg-deb-run-debian-mode tests + +# Full Debian flow: build, install verification, and installed-package tests +pkg-deb-build: + just pkg-deb-build-debian + just pkg-deb-install-debian + just pkg-deb-test-debian + +# Open an interactive root shell with testuser preconfigured +pkg-deb-run-debian: + just pkg-deb-run-debian-mode login + # Ubuntu ubuntu: just run ubuntu @@ -105,35 +137,35 @@ test-fedora: # Build an RPM from the current workspace using Fedora tooling [private] -rpm-build-fedora: +pkg-rpm-build-fedora: {{compose}} run --build --rm --user root --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-build.sh # Install latest built RPM in Fedora container and verify CLI smoke checks [private] -rpm-install-fedora: +pkg-rpm-install-fedora: {{compose}} run --build --rm --user root --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-install-smoke.sh # Run against installed RPM in two modes: # - mode=tests (default): run full /app/test suite as testuser # - mode=login: open root shell with testuser configured as /usr/bin/lshell [private] -rpm-run-fedora mode='tests': +pkg-rpm-run-fedora-mode mode='tests': {{compose}} run --build --rm --user root -e MODE={{mode}} --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-run.sh # Run full tests against installed RPM [private] -rpm-test-fedora: - just rpm-run-fedora tests +pkg-rpm-test-fedora: + just pkg-rpm-run-fedora-mode tests # Open an interactive root shell with testuser preconfigured -rpm-login-fedora: - just rpm-run-fedora login +pkg-rpm-run-fedora: + just pkg-rpm-run-fedora-mode login # Full RPM flow: build, install verification, and installed-package tests -rpm-check-fedora: - just rpm-build-fedora - just rpm-install-fedora - just rpm-test-fedora +pkg-rpm-build: + just pkg-rpm-build-fedora + just pkg-rpm-install-fedora + just pkg-rpm-test-fedora test-fedora-pypi: just run fedora-pypi From 650c46a3b8d232f09ed4b2253c10c5925d54d44d Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 17:38:04 -0400 Subject: [PATCH 06/29] Update version to 0.11.1rc1 and document command not found message handling in CHANGELOG --- CHANGELOG.md | 3 +++ lshell/variables.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47b510..6aa1b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) [https://github.com/ghantoos/lshell](https://github.com/ghantoos/lshell) +### v0.11.1rc1 11/03/2026 +- Added handling for `command not found` messages, with dedicated test coverage. + ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. - Added policy diagnostics and built-ins: `policy-show`, `policy-path`, and `policy-sudo`. diff --git a/lshell/variables.py b/lshell/variables.py index 077bc8f..bb1c9ef 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.11.0" +__version__ = "0.11.1rc1" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] From eb7d5345e6891f49d8a09fc5d44ffc08cbf2bd2a Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 22:54:59 -0400 Subject: [PATCH 07/29] Atheris fuzz (#272) * Atheris fuzz initial commit * Fix pylint * Update payload alphabet to exclude backtick and dollar characters * Refactor fuzzing commands and update Dockerfile for dependency installation * Refactor GitHub Actions workflow to separate testing and linting steps, update dependencies installation, and improve readability * Separate GA workflows for fuzzing, linting, and SSH end-to-end testing in different files * Merge all tests into a single file --- .../{pytest.yml => lshell-tests.yml} | 56 ++- .github/workflows/pylint.yml | 26 -- .gitignore | 1 + Dockerfile | 7 +- README.md | 15 + fuzz/fuzz_parser_policy.py | 117 ++++++ justfile | 5 + lshell/sec.py | 56 ++- requirements-fuzz.txt | 2 + requirements.txt | 1 + ...test_security_attack_surface_unit_part2.py | 37 ++ test/test_security_property_based_unit.py | 384 ++++++++++++++++++ 12 files changed, 660 insertions(+), 47 deletions(-) rename .github/workflows/{pytest.yml => lshell-tests.yml} (54%) delete mode 100644 .github/workflows/pylint.yml create mode 100644 fuzz/fuzz_parser_policy.py create mode 100644 requirements-fuzz.txt create mode 100644 test/test_security_property_based_unit.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/lshell-tests.yml similarity index 54% rename from .github/workflows/pytest.yml rename to .github/workflows/lshell-tests.yml index 6c696e4..7e0cbe5 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/lshell-tests.yml @@ -1,7 +1,4 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Pytest +name: Lshell Tests on: push: @@ -12,8 +9,8 @@ permissions: contents: read jobs: - build: - + pytest: + name: Pytest Unit/Integration Tests runs-on: ubuntu-latest steps: @@ -27,31 +24,64 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest + pip install pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Install the lshell package run: pip install . - - name: Lint with flake8 + - name: Test with pytest run: | + pytest + + lint: + name: Lint + Flake8 + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Set up Python path + run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint flake8 + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Analyse with pylint and flake8 + run: | + pylint $(git ls-files '*.py') # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest + + fuzz-security-parser: + name: Fuzz Security Parser/Policy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install just + uses: taiki-e/install-action@just + - name: Fuzz security parser/policy + timeout-minutes: 45 run: | - pytest + just test-fuzz-security-parser 20000 ssh-e2e: - name: SSH E2E (Docker + Ansible) + name: SSH End-to-End (Docker + Ansible) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Run SSH end-to-end tests run: | docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from ansible-runner ansible-runner - - name: Cleanup SSH E2E stack if: always() run: | diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml deleted file mode 100644 index 9613649..0000000 --- a/.github/workflows/pylint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Pylint - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Set up Python path - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Analysing the code with pylint - run: | - pylint $(git ls-files '*.py') diff --git a/.gitignore b/.gitignore index 851029f..2354b86 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ test.lsh .pylint_cache/ .pylint.d/ +.hypothesis/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 413a960..fbae2ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN \ # For Debian/Ubuntu if [ -f /etc/debian_version ]; then \ apt-get update && \ - apt-get install -y python3 python3-pip git flake8 pylint python3-pytest python3-pexpect python3-setuptools python3-pyparsing vim procps sudo && \ + apt-get install -y python3 python3-pip python3-dev build-essential clang libclang-rt-dev git flake8 pylint python3-pytest python3-pexpect python3-setuptools python3-pyparsing vim procps sudo && \ apt-get clean; \ groupadd -f testuser; \ useradd -m -d /home/testuser -s /bin/bash -g testuser testuser; \ @@ -50,6 +50,11 @@ ENV PYTHONPATH=/home/testuser/lshell # Copy the code and requirements COPY . /home/testuser/lshell +# Install test/runtime Python dependencies from the repository requirements. +# Debian/Ubuntu images may require --break-system-packages (PEP 668). +RUN python3 -m pip install --no-cache-dir -r /home/testuser/lshell/requirements.txt \ + || python3 -m pip install --break-system-packages --no-cache-dir -r /home/testuser/lshell/requirements.txt + # Install lshell from the source RUN python3 setup.py install diff --git a/README.md b/README.md index 11db5ff..631ea1c 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,21 @@ just sample-list just sample-ubuntu 01_baseline_allowlist.conf ``` +### Fuzzing parser/policy checks + +Run Atheris fuzzing in Debian Docker (dependencies installed in-container): + +```bash +just test-fuzz-security-parser 20000 +``` + +Optional local run (if you want to fuzz outside Docker): + +```bash +pip install -r requirements-fuzz.txt +python3 fuzz/fuzz_parser_policy.py -runs=20000 +``` + ## Contributing Open an issue or pull request: https://github.com/ghantoos/lshell/issues diff --git a/fuzz/fuzz_parser_policy.py b/fuzz/fuzz_parser_policy.py new file mode 100644 index 0000000..7a31150 --- /dev/null +++ b/fuzz/fuzz_parser_policy.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Atheris fuzz target for parser/policy security primitives.""" + +import sys +import tempfile + +try: + import atheris +except ImportError as exc: # pragma: no cover - optional dependency + raise SystemExit( + "atheris is not installed. Install fuzz deps with: pip install -r requirements-fuzz.txt" + ) from exc + +with atheris.instrument_imports(): + from lshell import parser as lshell_parser + from lshell import policy + from lshell import sec + from lshell import utils + + +class _NullLog: + """Minimal logger required by security helpers during fuzzing.""" + + def critical(self, _message): + """Discard critical log messages during fuzzing.""" + return None + + def error(self, _message): + """Discard error log messages during fuzzing.""" + return None + + def warning(self, _message): + """Discard warning log messages during fuzzing.""" + return None + + def info(self, _message): + """Discard info log messages during fuzzing.""" + return None + + +_FUZZ_TMP = tempfile.mkdtemp(prefix="lshell-fuzz-") +_FUZZ_PARSER = lshell_parser.LshellParser() + + +def _base_conf(): + """Build an isolated, permissive config for parser/policy fuzz entrypoints.""" + return { + "allowed": [ + "echo", + "printf", + "cat", + "ls", + "pwd", + "true", + "false", + "cd", + "sudo", + ], + "allowed_file_extensions": [], + "forbidden": [";", "&", "|", "`", ">", "<", "$(", "${"], + "sudo_commands": ["ls"], + "overssh": ["ls", "pwd", "echo"], + "warning_counter": 64, + "path": ["/|", ""], + "home_path": _FUZZ_TMP, + "promptprint": "", + "logpath": _NullLog(), + } + + +def _fuzz_one_line(line): + """Exercise parser and security check surfaces on one fuzzed command line.""" + conf = _base_conf() + runtime_policy = { + "forbidden": conf["forbidden"], + "allowed": conf["allowed"], + "strict": 0, + "sudo_commands": conf["sudo_commands"], + "allowed_file_extensions": conf["allowed_file_extensions"], + "path": conf["path"], + } + try: + parsed = _FUZZ_PARSER.parse(line) + if parsed is not None: + _FUZZ_PARSER.validate_command(parsed) + + utils.split_command_sequence(line) + utils.split_commands(line) + utils.expand_vars_quoted(line, support_advanced_braced=True) + utils.expand_vars_quoted(line, support_advanced_braced=False) + sec._path_tokens_from_line(line) + sec.check_forbidden_chars(line, conf, strict=0) + sec.check_path(line, conf, completion=1, strict=0) + sec.check_secure(line, conf, strict=0) + sec.check_allowed_file_extensions(line, [".txt", ".log"]) + policy.policy_command_decision(line, runtime_policy) + except SystemExit: + # check_secure/check_path may terminate on warning exhaustion; ignore. + pass + + +def test_one_input(data): + """Atheris entrypoint.""" + line = data.decode("latin-1") + if not line: + return + _fuzz_one_line(line[:512]) + + +def main(): + """Run Atheris fuzzing loop.""" + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/justfile b/justfile index 1e6266f..7be38e4 100644 --- a/justfile +++ b/justfile @@ -188,6 +188,10 @@ test-lint-flake8: pylint $(git ls-files '*.py') flake8 lshell test +# Run Atheris fuzzing in Debian Docker container (host deps not required) +test-fuzz-security-parser runs='20000': + {{compose}} run --build --rm --entrypoint bash debian -lc "CLANG_BIN=clang python3 -m pip install --user --break-system-packages -r /app/requirements-fuzz.txt && PYTHONPATH=/app python3 /app/fuzz/fuzz_parser_policy.py -runs={{runs}}" + # Full local validation in one command test-all: just test-lint-flake8 @@ -197,4 +201,5 @@ test-all: {{compose}} down -v --remove-orphans; \ exit $rc\ ' + just test-fuzz-security-parser just test-ssh-e2e diff --git a/lshell/sec.py b/lshell/sec.py index 567692a..c7fea55 100644 --- a/lshell/sec.py +++ b/lshell/sec.py @@ -14,6 +14,7 @@ from lshell import utils EXTENSION_RESTRICTION_EXEMPT_COMMANDS = {"cd", "clear", "fg", "bg", "ls"} +MAX_WILDCARD_MATCHES = 4096 def _is_assignment_word(word): @@ -109,20 +110,50 @@ def tokenize_command(command): return tokens +def _safe_realpath(path): + """Resolve canonical path and ignore malformed/unresolvable inputs.""" + try: + return os.path.realpath(path) + except (OSError, TypeError, ValueError): + return None + + +def _safe_expand_path(path): + """Expand user/env path fragments and reject malformed values.""" + try: + expanded = os.path.expanduser(path) + return os.path.expandvars(expanded) + except (TypeError, ValueError): + return None + + def expand_shell_wildcards(item): """Expand shell wildcards and return all candidate filesystem paths.""" # Expand shell variables like $HOME first. - expanded_item = os.path.expanduser(item) - expanded_item = os.path.expandvars(expanded_item) + expanded_item = _safe_expand_path(item) + if expanded_item is None: + return [] # Expand wildcard patterns against the filesystem and validate all matches. - expanded_items = glob.glob(expanded_item, recursive=True) + # Fail closed if expansion fans out too much to avoid memory abuse. + try: + expanded_items = [] + for match in glob.iglob(expanded_item, recursive=True): + resolved = _safe_realpath(match) + if resolved: + expanded_items.append(resolved) + if len(expanded_items) > MAX_WILDCARD_MATCHES: + return [] + except (OSError, RuntimeError, ValueError, re.error): + return [] + if expanded_items: - return [os.path.realpath(match) for match in expanded_items] + return expanded_items # If no glob match exists, still validate the canonical target path. - return [os.path.realpath(expanded_item)] + resolved_item = _safe_realpath(expanded_item) + return [resolved_item] if resolved_item else [] def _split_path_acl_entries(path_acl): @@ -135,7 +166,9 @@ def _split_path_acl_entries(path_acl): candidate = token.strip() if not candidate: continue - entries.append(os.path.realpath(candidate)) + resolved = _safe_realpath(candidate) + if resolved: + entries.append(resolved) return entries @@ -254,6 +287,11 @@ def check_path(line, conf, completion=None, ssh=None, strict=None): for item in path_tokens: candidates = expand_shell_wildcards(item) + if not candidates: + if not completion: + ret, conf = warn_count("path", item, conf, strict=strict, ssh=ssh) + return 1, conf + for candidate in candidates: if not _is_path_allowed(candidate, allowed_roots, denied_roots): if not completion: @@ -488,7 +526,11 @@ def check_allowed_file_extensions(command_line, allowed_extensions): extension = os.path.splitext(basename)[1] # Existing directories are valid SCP/SFTP targets and do not # represent file-extension risk on their own. - is_existing_dir = os.path.isdir(os.path.realpath(os.path.expanduser(value))) + expanded_value = _safe_expand_path(value) + resolved_value = ( + _safe_realpath(expanded_value) if expanded_value is not None else None + ) + is_existing_dir = bool(resolved_value and os.path.isdir(resolved_value)) has_path_markers = any( char in value for char in ["/", "\\", "*", "?", "[", "]"] ) or value.startswith(("~", ".")) diff --git a/requirements-fuzz.txt b/requirements-fuzz.txt new file mode 100644 index 0000000..6388feb --- /dev/null +++ b/requirements-fuzz.txt @@ -0,0 +1,2 @@ +-r requirements.txt +atheris>=2.3.0; platform_system != "Windows" diff --git a/requirements.txt b/requirements.txt index 4ef1751..e54daaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pexpect pyparsing +hypothesis>=6.0.0 diff --git a/test/test_security_attack_surface_unit_part2.py b/test/test_security_attack_surface_unit_part2.py index 69909de..7e8cfd2 100644 --- a/test/test_security_attack_surface_unit_part2.py +++ b/test/test_security_attack_surface_unit_part2.py @@ -398,6 +398,31 @@ def test_check_path_should_expand_brace_operands_like_shell(self): ), ) + def test_check_path_rejects_nul_byte_path_without_crashing(self): + """Malformed NUL-byte path operands should fail closed without exceptions.""" + conf = CheckConfig( + self.args + ["--path=['/tmp']", "--strict=0"] + ).returnconf() + ret, _conf = sec.check_path("ls /tmp/\x00crash", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + def test_check_path_rejects_nul_byte_tilde_path_without_crashing(self): + """Malformed tilde-prefixed NUL paths should fail closed without exceptions.""" + conf = CheckConfig( + self.args + ["--path=['/tmp']", "--strict=0"] + ).returnconf() + ret, _conf = sec.check_path("ls ~\x00crash", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + @patch("lshell.sec._safe_realpath", side_effect=lambda path: path) + @patch("lshell.sec.glob.iglob") + def test_expand_shell_wildcards_rejects_excessive_matches(self, mock_iglob, _mock_realpath): + """Massive wildcard expansions should fail closed instead of exhausting memory.""" + mock_iglob.return_value = ( + f"/tmp/match-{index}" for index in range(sec.MAX_WILDCARD_MATCHES + 1) + ) + self.assertEqual(sec.expand_shell_wildcards("/tmp/**"), []) + @patch("lshell.utils.exec_cmd") def test_cmd_parse_execute_trusted_protocol_blocks_non_protocol_chained_command( self, mock_exec @@ -464,6 +489,18 @@ def test_check_allowed_file_extensions_allows_existing_directory_target(self): self.assertTrue(allowed) self.assertIsNone(blocked) + def test_check_allowed_file_extensions_handles_nul_byte_path_without_crashing(self): + """Malformed NUL-byte values should be processed safely.""" + allowed, blocked = sec.check_allowed_file_extensions("cat /tmp/\x00bad", [".txt"]) + self.assertFalse(allowed) + self.assertEqual(blocked, [""]) + + def test_check_allowed_file_extensions_handles_nul_byte_tilde_without_crashing(self): + """Malformed tilde-prefixed NUL values should be processed safely.""" + allowed, blocked = sec.check_allowed_file_extensions("cat ~\x00bad", [".txt"]) + self.assertFalse(allowed) + self.assertEqual(blocked, [""]) + def test_config_rejects_message_override_with_unknown_placeholder(self): """Fail closed when a custom message references unsupported placeholders.""" with self.assertRaises(SystemExit): diff --git a/test/test_security_property_based_unit.py b/test/test_security_property_based_unit.py new file mode 100644 index 0000000..3bab35a --- /dev/null +++ b/test/test_security_property_based_unit.py @@ -0,0 +1,384 @@ +"""Property-based security tests for parser, expansion, and path ACL checks.""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from lshell import policy +from lshell import sec +from lshell import utils + +try: + from hypothesis import HealthCheck + from hypothesis import assume + from hypothesis import given + from hypothesis import settings + from hypothesis import strategies as st +except ImportError: # pragma: no cover - environment-dependent skip + class _DummyStrategy: + """Placeholder strategy object used when Hypothesis is unavailable.""" + + class _DummyStrategies: + """Minimal strategy API shim so tests can be collected and skipped.""" + + @staticmethod + def characters(*_args, **_kwargs): + """Return placeholder `characters` strategy.""" + return _DummyStrategy() + + @staticmethod + def from_regex(*_args, **_kwargs): + """Return placeholder `from_regex` strategy.""" + return _DummyStrategy() + + @staticmethod + def integers(*_args, **_kwargs): + """Return placeholder `integers` strategy.""" + return _DummyStrategy() + + @staticmethod + def text(*_args, **_kwargs): + """Return placeholder `text` strategy.""" + return _DummyStrategy() + + @staticmethod + def sampled_from(*_args, **_kwargs): + """Return placeholder `sampled_from` strategy.""" + return _DummyStrategy() + + @staticmethod + def composite(_function): + """Return placeholder composite-decorator wrapper.""" + def _wrapper(*_args, **_kwargs): + return _DummyStrategy() + + return _wrapper + + class HealthCheck: # pragma: no cover - shim only + """Fallback shim exposing Hypothesis health-check constants.""" + + too_slow = "too_slow" + + def assume(_condition): + """No-op assume shim used only when tests are skipped.""" + return None + + def settings(*_args, **_kwargs): + """Pass-through decorator when Hypothesis is unavailable.""" + + def _decorator(function): + return function + + return _decorator + + def given(*_args, **_kwargs): + """Skip decorated tests when Hypothesis is unavailable.""" + + def _decorator(function): + return unittest.skip("hypothesis is not installed")(function) + + return _decorator + + st = _DummyStrategies() + + +_OPERATOR_TOKENS = ["&&", "||", "|", ";", "&"] +# Keep payloads free of expansion/backtick metacharacters so the generated +# lines stay within this test's "known-valid quoting" scope. +_PAYLOAD_ALPHABET = st.characters( + blacklist_characters=['"', "'", "\\", "\n", "\r", "`", "$"], + min_codepoint=32, + max_codepoint=126, +) +_NAME_STRATEGY = st.from_regex(r"[a-z]{3,8}", fullmatch=True) + + +@st.composite +def _quoted_operator_sequence(draw): + """Build `echo "payload"` command chains with explicit operator tokens.""" + command_count = draw(st.integers(min_value=1, max_value=4)) + chunks = [] + expected = [] + + for index in range(command_count): + payload = draw(st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=12)) + command = f'echo "{payload}"' + chunks.append(command) + expected.append(command) + if index < command_count - 1: + operator = draw(st.sampled_from(_OPERATOR_TOKENS)) + chunks.append(operator) + expected.append(operator) + + return " ".join(chunks), expected + + +def _path_conf(allowed_paths, denied_paths=None): + """Create a minimal path-only security config for `sec.check_path`.""" + if isinstance(allowed_paths, str): + allowed_paths = [allowed_paths] + allowed = allowed_paths or [] + denied = denied_paths or [] + allow_acl = "".join(f"{os.path.realpath(path)}|" for path in allowed) + deny_acl = "".join(f"{os.path.realpath(path)}|" for path in denied) + return {"path": [allow_acl, deny_acl]} + + +class TestSecurityPropertyBased(unittest.TestCase): + """Property-driven tests for parser/auth hardening invariants.""" + + @settings(max_examples=100, deadline=None) + @given(sequence=_quoted_operator_sequence()) + def test_split_command_sequence_round_trips_known_operator_sequences(self, sequence): + """Parser should preserve explicit command/operator structure.""" + line, expected = sequence + self.assertEqual(utils.split_command_sequence(line), expected) + + @settings(max_examples=100, deadline=None) + @given(sequence=_quoted_operator_sequence()) + def test_split_commands_matches_non_operator_tokens_for_valid_sequences(self, sequence): + """`split_commands` should keep only command segments from valid sequences.""" + line, expected = sequence + expected_commands = [item for item in expected if item not in _OPERATOR_TOKENS] + self.assertEqual(utils.split_commands(line), expected_commands) + + @settings(max_examples=100, deadline=None) + @given( + left=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=12), + operator_a=st.sampled_from(_OPERATOR_TOKENS), + operator_b=st.sampled_from(_OPERATOR_TOKENS), + right=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=12), + ) + def test_split_command_sequence_rejects_adjacent_operators( + self, left, operator_a, operator_b, right + ): + """Two consecutive top-level operators should fail closed.""" + line = f'echo "{left}" {operator_a} {operator_b} echo "{right}"' + self.assertIsNone(utils.split_command_sequence(line)) + + @settings(max_examples=100, deadline=None) + @given(payload=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=24)) + def test_split_command_sequence_does_not_split_operators_inside_single_quotes( + self, payload + ): + """Top-level split must ignore operators that are inside single quotes.""" + line = f"echo '{payload}' && echo done" + self.assertEqual( + utils.split_command_sequence(line), + [f"echo '{payload}'", "&&", "echo done"], + ) + + @settings(max_examples=100, deadline=None) + @given(payload=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=24)) + def test_split_command_sequence_does_not_split_operators_inside_double_quotes( + self, payload + ): + """Top-level split must ignore operators that are inside double quotes.""" + line = f'echo "{payload}" || echo done' + self.assertEqual( + utils.split_command_sequence(line), + [f'echo "{payload}"', "||", "echo done"], + ) + + @settings(max_examples=80, deadline=None) + @given( + payload=st.text( + st.characters( + blacklist_characters=["'", ")", "\\", "\n", "\r"], + min_codepoint=32, + max_codepoint=126, + ), + min_size=1, + max_size=16, + ) + ) + def test_split_command_sequence_does_not_split_operators_inside_substitution( + self, payload + ): + """Top-level split must ignore operators inside command substitution.""" + line = f"echo $(printf '%s' '{payload}') | wc -c" + self.assertEqual( + utils.split_command_sequence(line), + [f"echo $(printf '%s' '{payload}')", "|", "wc -c"], + ) + + @settings(max_examples=100, deadline=None) + @given( + variable=st.from_regex(r"[A-Z_][A-Z0-9_]{0,9}", fullmatch=True), + value=st.text( + st.characters( + blacklist_characters=["\\", "'", '"', "\n", "\r", "$"], + min_codepoint=32, + max_codepoint=126, + ), + min_size=0, + max_size=20, + ), + ) + def test_expand_vars_quoted_keeps_single_quoted_variable_literal(self, variable, value): + """Single-quoted `$VAR` must remain literal while unquoted `$VAR` expands.""" + line = f"echo '${variable}' ${variable}" + with patch.dict(os.environ, {variable: value}, clear=False): + expanded = utils.expand_vars_quoted(line) + self.assertEqual(expanded, f"echo '${variable}' {value}") + + @settings(max_examples=100, deadline=None) + @given( + variable=st.from_regex(r"[A-Z_][A-Z0-9_]{0,9}", fullmatch=True), + value=st.text( + st.characters( + blacklist_characters=["\\", "'", '"', "\n", "\r", "$"], + min_codepoint=32, + max_codepoint=126, + ), + min_size=0, + max_size=20, + ), + ) + def test_expand_vars_quoted_keeps_backslash_escaped_dollar_literal( + self, variable, value + ): + """Escaped dollars must remain literal and not trigger expansion.""" + line = rf"echo \${variable} ${variable}" + with patch.dict(os.environ, {variable: value}, clear=False): + expanded = utils.expand_vars_quoted(line) + self.assertEqual(expanded, rf"echo \${variable} {value}") + + @settings(max_examples=60, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + allowed_name=_NAME_STRATEGY, + sibling_suffix=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + ) + def test_check_path_blocks_sibling_prefix_confusion_property( + self, allowed_name, sibling_suffix + ): + """Allowing `/x/allow` must not allow sibling `/x/allow-*` paths.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + allowed_dir = os.path.join(tempdir, allowed_name) + sibling_dir = os.path.join(tempdir, f"{allowed_name}-{sibling_suffix}") + assume(os.path.realpath(allowed_dir) != os.path.realpath(sibling_dir)) + os.makedirs(allowed_dir, exist_ok=True) + os.makedirs(sibling_dir, exist_ok=True) + + conf = _path_conf(allowed_dir) + ret, _ = sec.check_path(f"ls {sibling_dir}", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + @settings(max_examples=60, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + allowed_name=_NAME_STRATEGY, + child_a=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + child_b=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + ) + def test_check_path_allows_nested_descendants_property( + self, allowed_name, child_a, child_b + ): + """Paths nested under an allow root should pass ACL checks.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + allowed_dir = os.path.join(tempdir, allowed_name) + nested_dir = os.path.join(allowed_dir, child_a, child_b) + os.makedirs(nested_dir, exist_ok=True) + + conf = _path_conf(allowed_dir) + ret, _ = sec.check_path(f"ls {nested_dir}", conf, completion=1, strict=0) + self.assertEqual(ret, 0) + + @settings(max_examples=40, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + allowed_name=_NAME_STRATEGY, + denied_name=st.from_regex(r"[a-z]{3,8}", fullmatch=True), + ) + def test_check_path_glob_fails_closed_when_any_match_is_denied( + self, allowed_name, denied_name + ): + """Glob checks should fail closed if any expanded target is outside allow roots.""" + assume(allowed_name != denied_name) + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + allowed_dir = os.path.join(tempdir, allowed_name) + denied_dir = os.path.join(tempdir, denied_name) + os.makedirs(allowed_dir, exist_ok=True) + os.makedirs(denied_dir, exist_ok=True) + + conf = _path_conf(allowed_dir) + ret, _ = sec.check_path(f"ls {tempdir}/*", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + @settings(max_examples=60, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + root_name=_NAME_STRATEGY, + deny_name=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + reallow_name=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + leaf_name=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + ) + def test_check_path_uses_specificity_with_reallow_over_broader_deny( + self, root_name, deny_name, reallow_name, leaf_name + ): + """Most-specific ACL prefix should win for deny/re-allow path chains.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + root_dir = os.path.join(tempdir, root_name) + denied_root = os.path.join(root_dir, deny_name) + reallowed_root = os.path.join(denied_root, reallow_name) + denied_leaf = os.path.join(denied_root, "blocked") + reallowed_leaf = os.path.join(reallowed_root, leaf_name) + os.makedirs(denied_leaf, exist_ok=True) + os.makedirs(reallowed_leaf, exist_ok=True) + + conf = _path_conf([root_dir, reallowed_root], [denied_root]) + denied_ret, _ = sec.check_path( + f"ls {denied_leaf}", conf, completion=1, strict=0 + ) + allowed_ret, _ = sec.check_path( + f"ls {reallowed_leaf}", conf, completion=1, strict=0 + ) + self.assertEqual(denied_ret, 1) + self.assertEqual(allowed_ret, 0) + + @settings(max_examples=80, deadline=None) + @given( + command=st.from_regex(r"[a-z]{3,10}", fullmatch=True), + strict=st.sampled_from([0, 1]), + ) + def test_policy_command_decision_unknown_command_reason_reflects_strict_mode( + self, command, strict + ): + """Policy decision reasons should differ between strict/non-strict modes.""" + assume(command != "echo") + runtime_policy = { + "forbidden": [], + "allowed": ["echo"], + "strict": strict, + "sudo_commands": [], + "allowed_file_extensions": [], + "path": ["", ""], + } + + decision = policy.policy_command_decision(f"{command} arg", runtime_policy) + self.assertFalse(decision["allowed"]) + if strict: + self.assertIn("forbidden command", decision["reason"]) + else: + self.assertIn("unknown syntax", decision["reason"]) + + @settings(max_examples=80, deadline=None) + @given( + variable=st.from_regex(r"[A-Z_][A-Z0-9_]{0,7}", fullmatch=True), + value=st.from_regex(r"[A-Za-z0-9_]{0,8}", fullmatch=True), + ) + def test_policy_command_decision_allows_assignment_prefix_for_allowlisted_full_command( + self, variable, value + ): + """Allowlist checks should still pass when command uses assignment prefixes.""" + runtime_policy = { + "forbidden": [], + "allowed": ["echo ok"], + "strict": 1, + "sudo_commands": [], + "allowed_file_extensions": [], + "path": ["", ""], + } + + decision = policy.policy_command_decision( + f"{variable}={value} echo ok", runtime_policy + ) + self.assertTrue(decision["allowed"]) From 62d3ef3137f1a8494b0de3c7349f84b0580b7e91 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 23:01:59 -0400 Subject: [PATCH 08/29] Fix GitHub Actions workflow status badges --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 631ea1c..9a2834e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ ![PyPI - Version](https://img.shields.io/pypi/v/limited-shell?link=https%3A%2F%2Fpypi.org%2Fproject%2Flimited-shell%2F) ![PyPI - Downloads](https://img.shields.io/pypi/dm/limited-shell) -![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) -![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) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ghantoos/lshell/lshell-tests.yml?branch=master&label=tests&link=https%3A%2F%2Fgithub.com%2Fghantoos%2Flshell%2Factions%2Fworkflows%2Flshell-tests.yml) # lshell From d466bc69384ee874e5095e162db08a437082f3b5 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Fri, 13 Mar 2026 14:58:30 -0400 Subject: [PATCH 09/29] Update to pyproject.toml / PEP 517 (#273) --- .github/workflows/lshell-tests.yml | 2 +- Dockerfile | 6 +- Makefile | 19 +++---- bin/lshell | 47 ++------------- debian/rules | 2 +- docker-compose.yml | 8 +-- justfile | 2 +- lshell/cli.py | 47 +++++++++++++++ pyproject.toml | 51 +++++++++++++++++ rpm/lshell.spec | 10 +++- setup.py | 91 ------------------------------ test/conftest.py | 13 +++++ 12 files changed, 143 insertions(+), 155 deletions(-) create mode 100644 lshell/cli.py create mode 100644 pyproject.toml delete mode 100755 setup.py create mode 100644 test/conftest.py diff --git a/.github/workflows/lshell-tests.yml b/.github/workflows/lshell-tests.yml index 7e0cbe5..3008ebd 100644 --- a/.github/workflows/lshell-tests.yml +++ b/.github/workflows/lshell-tests.yml @@ -54,7 +54,7 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Analyse with pylint and flake8 run: | - pylint $(git ls-files '*.py') + pylint lshell test # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide diff --git a/Dockerfile b/Dockerfile index fbae2ad..fa7db71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,10 @@ COPY . /home/testuser/lshell RUN python3 -m pip install --no-cache-dir -r /home/testuser/lshell/requirements.txt \ || python3 -m pip install --break-system-packages --no-cache-dir -r /home/testuser/lshell/requirements.txt -# Install lshell from the source -RUN python3 setup.py install +# Install lshell from source via PEP 517/pyproject.toml. +# Debian/Ubuntu images may require --break-system-packages (PEP 668). +RUN python3 -m pip install --no-cache-dir --no-deps --no-build-isolation /home/testuser/lshell \ + || python3 -m pip install --break-system-packages --no-cache-dir --no-deps --no-build-isolation /home/testuser/lshell # Switch to `testuser` USER testuser diff --git a/Makefile b/Makefile index 9f51835..972d53d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # $Id: Makefile,v 1.16 2010-03-06 23:11:38 ghantoos Exp $ # -PYTHON=`which python` +PYTHON=`which python3` DESTDIR=/ BUILDIR=$(CURDIR)/debian/lshell PROJECT=lshell @@ -17,27 +17,26 @@ all: @echo "make clean - Get rid of scratch and byte files" source: - $(PYTHON) setup.py sdist + $(PYTHON) -m build --sdist sourcedeb: - $(PYTHON) setup.py sdist --dist-dir=../ --prune - rename -f 's/$(PROJECT)-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../* + $(PYTHON) -m build --sdist --outdir=../ + rename -f 's/limited_shell-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../limited_shell-*.tar.gz install: - $(PYTHON) setup.py install --root=$(DESTDIR) --no-compile + $(PYTHON) -m pip install --no-deps --no-build-isolation --root=$(DESTDIR) --no-compile . buildrpm: - $(PYTHON) setup.py bdist_rpm --pre-install=rpm/preinstall --post-install=rpm/postinstall --post-uninstall=rpm/postuninstall + rpmbuild -ba rpm/lshell.spec builddeb: # build the source package in the parent directory # then rename it to project_version.orig.tar.gz - $(PYTHON) setup.py sdist --dist-dir=../ --prune - rename -f 's/$(PROJECT)-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../* + $(PYTHON) -m build --sdist --outdir=../ + rename -f 's/limited_shell-(.*)\.tar\.gz/$(PROJECT)_$$1\.orig\.tar\.gz/' ../limited_shell-*.tar.gz # build the package dpkg-buildpackage -i -I -rfakeroot clean: - $(PYTHON) setup.py clean - rm -rf build/ MANIFEST dist/ + rm -rf build/ MANIFEST dist/ *.egg-info find . -name '*.pyc' -delete diff --git a/bin/lshell b/bin/lshell index 1d4e2bd..b5c33fe 100755 --- a/bin/lshell +++ b/bin/lshell @@ -17,53 +17,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -""" calls lshell function """ +"""Thin wrapper for the lshell CLI.""" import os import sys -import signal -from lshell.checkconfig import CheckConfig -from lshell import policy as policy_mode -from lshell.shellcmd import ShellCmd, LshellTimeOut +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if TOPDIR not in sys.path: + sys.path.insert(0, TOPDIR) - -def main(): - """main function""" - if len(sys.argv) > 1 and sys.argv[1] == "policy-show": - sys.exit(policy_mode.main(sys.argv[2:])) - - # set SHELL and get LSHELL_ARGS env variables - os.environ["SHELL"] = os.path.realpath(sys.argv[0]) - if "LSHELL_ARGS" in os.environ: - args = sys.argv[1:] + eval(os.environ["LSHELL_ARGS"]) - else: - args = sys.argv[1:] - - userconf = CheckConfig(args).returnconf() - - def disable_ctrl_z(signum, frame): - pass # Do nothing when Ctrl+Z is pressed - - signal.signal(signal.SIGTSTP, disable_ctrl_z) - - cli = ShellCmd(userconf, args) - try: - while True: - try: - cli.cmdloop() - break - except KeyboardInterrupt: - # Keep interactive sessions alive when Ctrl+C races outside - # command-specific handlers. - sys.stdout.write("\n") - continue - except EOFError: - sys.stdout.write("\nExited on user request\n") - sys.exit(0) - except LshellTimeOut: - userconf["logpath"].error("Timer expired") - sys.stdout.write("\nTime is up.\n") +from lshell.cli import main if __name__ == "__main__": diff --git a/debian/rules b/debian/rules index cba05a7..cd74670 100755 --- a/debian/rules +++ b/debian/rules @@ -10,7 +10,7 @@ export PYTHONWARNINGS=ignore dh $@ --with python3 --buildsystem=pybuild override_dh_auto_install: - $(PYTHON) setup.py install --root=$(CURDIR)/debian/lshell --install-layout=deb + dh_auto_install --buildsystem=pybuild # setuptools data_files are currently staged under /usr/etc; move them to /etc. if [ -d "$(CURDIR)/debian/lshell/usr/etc" ]; then \ mkdir -p "$(CURDIR)/debian/lshell/etc"; \ diff --git a/docker-compose.yml b/docker-compose.yml index fecc41e..772a395 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -215,7 +215,7 @@ services: user: "testuser" working_dir: /home/testuser/lshell container_name: ubuntu_tests - command: "sh -c 'pylint lshell test setup.py && flake8 lshell && pytest'" + command: "sh -c 'pylint lshell test && flake8 lshell && pytest'" volumes: - .:/home/testuser/lshell environment: @@ -228,7 +228,7 @@ services: args: DISTRO: "debian:latest" container_name: debian_tests - command: "sh -c 'pylint lshell test setup.py && flake8 lshell && pytest'" + command: "sh -c 'pylint lshell test && flake8 lshell && pytest'" volumes: - .:/app environment: @@ -241,7 +241,7 @@ services: args: DISTRO: "fedora:latest" container_name: fedora_tests - command: "sh -c 'pylint lshell test setup.py && flake8 lshell && pytest'" + command: "sh -c 'pylint lshell test && flake8 lshell && pytest'" volumes: - .:/app environment: @@ -254,7 +254,7 @@ services: args: DISTRO: "centos:8" container_name: centos_tests - command: "sh -c 'pylint lshell test setup.py; pyflake lshell; pytest-3'" + command: "sh -c 'pylint lshell test; pyflake lshell; pytest-3'" volumes: - .:/app environment: diff --git a/justfile b/justfile index 7be38e4..3377265 100644 --- a/justfile +++ b/justfile @@ -185,7 +185,7 @@ test-ssh-e2e: # Lint Python sources test-lint-flake8: - pylint $(git ls-files '*.py') + pylint lshell test flake8 lshell test # Run Atheris fuzzing in Debian Docker container (host deps not required) diff --git a/lshell/cli.py b/lshell/cli.py new file mode 100644 index 0000000..ba7f478 --- /dev/null +++ b/lshell/cli.py @@ -0,0 +1,47 @@ +"""CLI entry points for lshell.""" + +import os +import signal +import sys + +from lshell import policy as policy_mode +from lshell.checkconfig import CheckConfig +from lshell.shellcmd import LshellTimeOut, ShellCmd + + +def main(): + """Main CLI entry point.""" + if len(sys.argv) > 1 and sys.argv[1] == "policy-show": + sys.exit(policy_mode.main(sys.argv[2:])) + + # Set SHELL and process LSHELL_ARGS env variables. + os.environ["SHELL"] = os.path.realpath(sys.argv[0]) + if "LSHELL_ARGS" in os.environ: + args = sys.argv[1:] + eval(os.environ["LSHELL_ARGS"]) + else: + args = sys.argv[1:] + + userconf = CheckConfig(args).returnconf() + + def disable_ctrl_z(_signum, _frame): + return None + + signal.signal(signal.SIGTSTP, disable_ctrl_z) + + cli = ShellCmd(userconf, args) + try: + while True: + try: + cli.cmdloop() + break + except KeyboardInterrupt: + # Keep interactive sessions alive when Ctrl+C races outside + # command-specific handlers. + sys.stdout.write("\n") + continue + except EOFError: + sys.stdout.write("\nExited on user request\n") + sys.exit(0) + except LshellTimeOut: + userconf["logpath"].error("Timer expired") + sys.stdout.write("\nTime is up.\n") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..99147c8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "limited-shell" +description = "lshell - Limited Shell" +readme = "README.md" +requires-python = ">=3.6" +license = { text = "GPL-3.0-or-later" } +keywords = ["limited", "shell", "security", "python"] +authors = [{ name = "Ignace Mouzannar", email = "ghantoos@ghantoos.org" }] +maintainers = [{ name = "Ignace Mouzannar", email = "ghantoos@ghantoos.org" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Topic :: Security", + "Topic :: System :: Shells", + "Topic :: System :: System Shells", + "Topic :: System :: Systems Administration", + "Topic :: Terminals", +] +dependencies = ["pyparsing>=3.0.0"] +dynamic = ["version"] + +[project.urls] +GitHub = "https://github.com/ghantoos/lshell" +Changelog = "https://github.com/ghantoos/lshell/blob/master/CHANGELOG.md" + +[project.scripts] +lshell = "lshell.cli:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +exclude = ["test", "test.*"] + +[tool.setuptools.dynamic] +version = { attr = "lshell.variables.__version__" } + +[tool.setuptools.data-files] +"etc" = ["etc/lshell.conf"] +"etc/logrotate.d" = ["etc/logrotate.d/lshell"] +"share/doc/lshell" = ["README.md", "COPYING", "CHANGELOG.md", "SECURITY.md"] +"share/man/man1" = ["man/lshell.1"] diff --git a/rpm/lshell.spec b/rpm/lshell.spec index 91014c8..4279977 100644 --- a/rpm/lshell.spec +++ b/rpm/lshell.spec @@ -13,6 +13,7 @@ Source3: postuninstall BuildArch: noarch BuildRequires: python3-devel BuildRequires: python3-setuptools +BuildRequires: pyproject-rpm-macros Requires: python3 Requires: python3-pyparsing >= 3.0.0 @@ -25,12 +26,15 @@ restrictions, and more. %prep %setup -q +%generate_buildrequires +%pyproject_buildrequires + %build -%{__python3} setup.py build +%pyproject_wheel %install rm -rf %{buildroot} -%{__python3} setup.py install --skip-build --root %{buildroot} +%pyproject_install %pre -f %{SOURCE1} @@ -45,7 +49,7 @@ rm -rf %{buildroot} %config(noreplace) %{_prefix}/etc/lshell.conf %config(noreplace) %{_prefix}/etc/logrotate.d/lshell %{python3_sitelib}/lshell/ -%{python3_sitelib}/limited_shell-*.egg-info +%{python3_sitelib}/limited_shell-*.dist-info %{_mandir}/man1/lshell.1* %changelog diff --git a/setup.py b/setup.py deleted file mode 100755 index cc2ee57..0000000 --- a/setup.py +++ /dev/null @@ -1,91 +0,0 @@ -""" Setup script for lshell """ - -import os -import shutil -from setuptools import setup, find_packages -from setuptools.command.install import install - -# import lshell specifics -from lshell.variables import __version__ - - -class CustomInstallCommand(install): - """Customized setuptools install command to handle etc files.""" - - def run(self): - """Install package files and copy default config under the target etc path.""" - # Call the standard install first - install.run(self) - - # Determine correct configuration paths - if os.geteuid() != 0: # If not root, use ~/.local/etc - etc_install_dir = os.path.join(os.path.expanduser("~"), ".local/etc") - else: # For system-wide install, use /etc - etc_install_dir = "/etc" - - # Create necessary directories if they don't exist - os.makedirs(os.path.join(etc_install_dir, "logrotate.d"), exist_ok=True) - - # Copy configuration files to appropriate directories - shutil.copy("etc/lshell.conf", etc_install_dir) - shutil.copy( - "etc/logrotate.d/lshell", os.path.join(etc_install_dir, "logrotate.d") - ) - - -if __name__ == "__main__": - - with open("README.md", "r") as f: - long_description = f.read() - - setup( - name="limited-shell", - version=__version__, - description="lshell - Limited Shell", - long_description=long_description, - long_description_content_type="text/markdown", - author="Ignace Mouzannar", - author_email="ghantoos@ghantoos.org", - maintainer="Ignace Mouzannar", - maintainer_email="ghantoos@ghantoos.org", - keywords=["limited", "shell", "security", "python"], - url="https://github.com/ghantoos/lshell", - project_urls={ - "GitHub": "https://github.com/ghantoos/lshell", - "Changelog": "https://github.com/ghantoos/lshell/blob/master/CHANGELOG.md", - }, - license="GPL-3", - platforms=["UNIX"], - scripts=["bin/lshell"], - package_dir={"lshell": "lshell"}, - packages=find_packages(exclude=["test", "test.*"]), - include_package_data=True, - data_files=[ - ("etc", ["etc/lshell.conf"]), - ("etc/logrotate.d", ["etc/logrotate.d/lshell"]), - ( - "share/doc/lshell", - ["README.md", "COPYING", "CHANGELOG.md", "SECURITY.md"], - ), - ("share/man/man1/", ["man/lshell.1"]), - ], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Information Technology", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Operating System :: POSIX", - "Programming Language :: Python :: 3", - "Topic :: Security", - "Topic :: System :: Shells", - "Topic :: System :: System Shells", - "Topic :: System :: Systems Administration", - "Topic :: Terminals", - ], - python_requires=">=3.6", - install_requires=["pyparsing>=3.0.0"], - cmdclass={ - "install": CustomInstallCommand, # Use custom install command - }, - ) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..9320442 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,13 @@ +"""Pytest test-suite configuration.""" + +try: + from hypothesis import settings +except ImportError: + settings = None + + +if settings is not None: + # Avoid filesystem writes for Hypothesis' example database in locked-down CI + # containers where the project workspace may be read-only for the test user. + settings.register_profile("lshell_ci", settings(database=None)) + settings.load_profile("lshell_ci") From 3eea38a78c560b6368b932393b4ead851c1f3efb Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Fri, 13 Mar 2026 14:59:01 -0400 Subject: [PATCH 10/29] Bump version to 0.11.1rc2 --- lshell/variables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lshell/variables.py b/lshell/variables.py index bb1c9ef..af30b51 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.11.1rc1" +__version__ = "0.11.1rc2" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] From 1ed062cc520f7b6399fbb46a1a98093f5bb93f7c Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Fri, 13 Mar 2026 15:19:28 -0400 Subject: [PATCH 11/29] Enhance CLI argument handling by safely parsing LSHELL_ARGS from environment variables and adding unit tests for validation --- lshell/cli.py | 11 +++++++- test/test_cli_unit.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 test/test_cli_unit.py diff --git a/lshell/cli.py b/lshell/cli.py index ba7f478..ecfb8a8 100644 --- a/lshell/cli.py +++ b/lshell/cli.py @@ -1,5 +1,6 @@ """CLI entry points for lshell.""" +import ast import os import signal import sys @@ -17,7 +18,15 @@ def main(): # Set SHELL and process LSHELL_ARGS env variables. os.environ["SHELL"] = os.path.realpath(sys.argv[0]) if "LSHELL_ARGS" in os.environ: - args = sys.argv[1:] + eval(os.environ["LSHELL_ARGS"]) + try: + parsed_args = ast.literal_eval(os.environ["LSHELL_ARGS"]) + except (ValueError, SyntaxError): + parsed_args = [] + if not isinstance(parsed_args, (list, tuple)) or not all( + isinstance(item, str) for item in parsed_args + ): + parsed_args = [] + args = sys.argv[1:] + list(parsed_args) else: args = sys.argv[1:] diff --git a/test/test_cli_unit.py b/test/test_cli_unit.py new file mode 100644 index 0000000..a1ff6c4 --- /dev/null +++ b/test/test_cli_unit.py @@ -0,0 +1,64 @@ +"""Unit tests for lshell.cli argument handling.""" + +import os +import unittest +from unittest.mock import MagicMock, patch + +from lshell import cli + + +class _DummyShell: + """Minimal shell stub that exits loop immediately.""" + + def __init__(self, _userconf, _args): + pass + + def cmdloop(self): + """Terminate the loop immediately via EOF handling.""" + raise EOFError + + +class TestCliArgs(unittest.TestCase): + """Validate CLI argument parsing from environment variables.""" + + def _run_main_and_capture_args(self, env_value): + captured = {} + + class _DummyCheckConfig: + def __init__(self, args): + captured["args"] = args + + def returnconf(self): + """Return the minimal config expected by cli.main().""" + return {"logpath": MagicMock()} + + env_patch = {} + if env_value is not None: + env_patch["LSHELL_ARGS"] = env_value + + with patch.dict(os.environ, env_patch, clear=False): + with patch("lshell.cli.CheckConfig", _DummyCheckConfig): + with patch("lshell.cli.ShellCmd", _DummyShell): + with patch("lshell.cli.sys.argv", ["lshell", "--quiet=1"]): + with patch("lshell.cli.sys.exit", side_effect=SystemExit): + with self.assertRaises(SystemExit): + cli.main() + + return captured["args"] + + def test_main_appends_valid_lshell_args_from_env(self): + """Append safely parsed list arguments from LSHELL_ARGS env var.""" + args = self._run_main_and_capture_args("['--config', '/tmp/lshell.conf']") + self.assertEqual(args, ["--quiet=1", "--config", "/tmp/lshell.conf"]) + + def test_main_ignores_invalid_or_unsafe_lshell_args_env(self): + """Ignore malformed, non-sequence, or non-string entries in LSHELL_ARGS.""" + invalid_values = [ + "__import__('os').system('id')", + "'--config'", + "['--config', 123]", + ] + for value in invalid_values: + with self.subTest(value=value): + args = self._run_main_and_capture_args(value) + self.assertEqual(args, ["--quiet=1"]) From bc0303a49dd687d759b70851e779c28873b127f2 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 08:38:19 -0400 Subject: [PATCH 12/29] Add ECS JSON security audit logging, setup-system CLI, and stable DEB/RPM packaging workflows (#274) * Implement structured security audit logging with JSON output and ECS alignment * add setup-system command and tests * extract compose and test helper scripts * stabilize deb/rpm packaging flow and versioning * test: add functional coverage for setup-system, audit JSON, and parser module * bump version to 0.11.1rc3 --- README.md | 13 ++ debian/changelog | 2 +- debian/control | 2 +- debian/scripts/debian-deb-build.sh | 11 ++ debian/scripts/debian-deb-run.sh | 7 +- docker/scripts/distro-login-shell.sh | 65 +++++++++ etc/lshell.conf | 5 + justfile | 102 +++++++------- lshell/audit.py | 123 +++++++++++++++++ lshell/checkconfig.py | 29 +++- lshell/cli.py | 6 + lshell/configschema.py | 1 + lshell/sec.py | 5 + lshell/shellcmd.py | 36 +++++ lshell/systemsetup.py | 190 ++++++++++++++++++++++++++ lshell/utils.py | 77 +++++++++++ lshell/variables.py | 12 +- pyproject.toml | 1 + rpm/scripts/fedora-rpm-build.sh | 30 ++-- rpm/scripts/fedora-rpm-run.sh | 8 +- scripts/compose-distro-login-shell.sh | 17 +++ scripts/compose-sample-lshell.sh | 25 ++++ scripts/pkg-ensure-built.sh | 18 +++ scripts/pkg-write-source-stamp.sh | 6 + scripts/test-all.sh | 11 ++ scripts/test-ssh-e2e.sh | 12 ++ test/test_audit_functional.py | 81 +++++++++++ test/test_audit_unit.py | 104 ++++++++++++++ test/test_cli_unit.py | 10 ++ test/test_parser_module_unit.py | 44 ++++++ test/test_systemsetup_functional.py | 128 +++++++++++++++++ test/test_systemsetup_unit.py | 46 +++++++ 32 files changed, 1143 insertions(+), 84 deletions(-) create mode 100755 docker/scripts/distro-login-shell.sh create mode 100644 lshell/audit.py create mode 100644 lshell/systemsetup.py create mode 100755 scripts/compose-distro-login-shell.sh create mode 100755 scripts/compose-sample-lshell.sh create mode 100755 scripts/pkg-ensure-built.sh create mode 100755 scripts/pkg-write-source-stamp.sh create mode 100755 scripts/test-all.sh create mode 100755 scripts/test-ssh-e2e.sh create mode 100644 test/test_audit_functional.py create mode 100644 test/test_audit_unit.py create mode 100644 test/test_parser_module_unit.py create mode 100644 test/test_systemsetup_functional.py create mode 100644 test/test_systemsetup_unit.py diff --git a/README.md b/README.md index 9a2834e..07c6de5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ Install from PyPI: pip install limited-shell ``` +Prepare system resources (run as root once per host): + +```bash +lshell setup-system --group lshell --log-dir /var/log/lshell --owner root --mode 2770 +``` + Build/install from source: ```bash @@ -49,6 +55,12 @@ Set `lshell` as login shell: chsh -s /usr/bin/lshell user_name ``` +For automated setup (including `/etc/shells` registration + user shell assignment): + +```bash +lshell setup-system --set-shell-user user_name --add-group-user user_name +``` + ## Policy diagnostics Explain the effective policy and decision for a command: @@ -103,6 +115,7 @@ lshell --config /path/to/lshell.conf --log /var/log/lshell --umask 0077 - Use `allowed_file_extensions` when users are expected to work with a known set of file types. - Keep `warning_counter` enabled (avoid `-1` unless you intentionally want warning-only behavior). - Use `policy-show` during reviews to validate effective policy before assigning it to users. +- For pip installs, do not rely on installation side effects for system setup. Use `lshell setup-system` (or distro package post-install hooks) to create groups, `/var/log/lshell`, and login-shell registration. ### Section model and precedence diff --git a/debian/changelog b/debian/changelog index daafc83..d2c45e2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -lshell (0.9.18-3) UNRELEASED; urgency=medium +lshell (0.11.1rc2-1) UNRELEASED; urgency=medium * debian/watch: - Corrected to work with lshell versioning on github. diff --git a/debian/control b/debian/control index 8cbfc73..5ce8dc5 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: lshell Section: shells Priority: optional Maintainer: Ignace Mouzannar -Build-Depends: debhelper-compat (= 13), python3 (>= 3.4) +Build-Depends: debhelper-compat (= 13), python3 (>= 3.4), pybuild-plugin-pyproject X-Python3-Version: >= 3.4 Standards-Version: 3.9.7 Homepage: https://github.com/ghantoos/lshell diff --git a/debian/scripts/debian-deb-build.sh b/debian/scripts/debian-deb-build.sh index 48c85c3..7ec6c66 100644 --- a/debian/scripts/debian-deb-build.sh +++ b/debian/scripts/debian-deb-build.sh @@ -10,6 +10,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y \ dpkg-dev \ fakeroot \ lintian \ + pybuild-plugin-pyproject \ python3-all \ python3-setuptools @@ -27,6 +28,16 @@ if [ ! -f CHANGES ] && [ -f CHANGELOG.md ]; then cp CHANGELOG.md CHANGES fi +PY_VERSION="$(python3 -c 'import lshell.variables as v; print(v.__version__)')" +DEB_REVISION="${DEB_REVISION:-1}" +DEB_VERSION="${PY_VERSION}-${DEB_REVISION}" +CURRENT_CHANGELOG_VERSION="$(dpkg-parsechangelog -S Version)" + +if [ "${CURRENT_CHANGELOG_VERSION}" != "${DEB_VERSION}" ]; then + sed -E -i "1s/^lshell \\([^)]*\\) /lshell (${DEB_VERSION}) /" debian/changelog + echo "Updated debian/changelog version: ${CURRENT_CHANGELOG_VERSION} -> ${DEB_VERSION}" +fi + DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -us -uc -b find /tmp -maxdepth 1 -type f \( -name 'lshell_*.deb' -o -name 'lshell_*.changes' -o -name 'lshell_*.buildinfo' \) -exec cp -a {} "${OUTDIR}/" \; diff --git a/debian/scripts/debian-deb-run.sh b/debian/scripts/debian-deb-run.sh index 4687cf6..77704d3 100644 --- a/debian/scripts/debian-deb-run.sh +++ b/debian/scripts/debian-deb-run.sh @@ -11,11 +11,6 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y "${DEB_FILE}" install -m 0644 /app/debian/lshell.deb-test.conf /etc/lshell.deb-test.conf -# Use a single identity everywhere: testuser -usermod -s /usr/bin/lshell testuser -echo "testuser:password" | chpasswd -usermod -aG lshell testuser || true - if [ "${MODE}" = "tests" ]; then # Force test invocations to use the installed Debian package binary. ln -sf /usr/bin/lshell /home/testuser/lshell/bin/lshell @@ -37,7 +32,7 @@ if [ "${MODE}" = "login" ]; then "" \ "Accounts:" \ " - root (current shell)" \ - " - testuser / password: password (login shell: /usr/bin/lshell, group: lshell)" \ + " - testuser / password: password" \ "" \ "Main DEB test config (layered): /etc/lshell.deb-test.conf" \ "Layers included in this file:" \ diff --git a/docker/scripts/distro-login-shell.sh b/docker/scripts/distro-login-shell.sh new file mode 100755 index 0000000..fc2a865 --- /dev/null +++ b/docker/scripts/distro-login-shell.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DISTRO="${1:-}" +PKG_CMD="${2:-}" +CFG="${3:-/app/etc/lshell.conf}" + +if [[ "${DISTRO}" != "debian" && "${DISTRO}" != "ubuntu" && "${DISTRO}" != "fedora" ]]; then + echo "Unsupported distro: ${DISTRO}" >&2 + echo "Allowed values: debian, ubuntu, fedora" >&2 + exit 1 +fi + +if [[ -z "${PKG_CMD}" ]]; then + echo "Missing package check command." >&2 + exit 1 +fi + +if [[ ! -f "${CFG}" ]]; then + echo "Config file does not exist: ${CFG}" >&2 + exit 1 +fi + +LSHELL_BIN="$(command -v lshell || true)" +if [[ -z "${LSHELL_BIN}" ]]; then + echo "lshell is not installed in this container." >&2 + exit 1 +fi + +lshell setup-system \ + --group lshell \ + --log-dir /var/log/lshell \ + --owner root \ + --mode 2770 \ + --shell-path "${LSHELL_BIN}" \ + --set-shell-user testuser \ + --add-group-user testuser + +# `su - testuser` starts lshell with its default config path (/etc/lshell.conf). +if [[ ! -f /etc/lshell.conf ]] || ! cmp -s "${CFG}" /etc/lshell.conf; then + install -m 0644 "${CFG}" /etc/lshell.conf +fi + +printf "%s\n" \ + "============================================================" \ + "Interactive ${DISTRO} root shell is ready" \ + "============================================================" \ + "Current account: root" \ + "" \ + "lshell binary: ${LSHELL_BIN}" \ + "testuser login shell is now set to lshell." \ + "active lshell config: /etc/lshell.conf (source: ${CFG})" \ + "" \ + "Suggested checks:" \ + " 1) ${PKG_CMD}" \ + " 2) lshell --version" \ + " 3) lshell policy-show --config ${CFG} --user testuser --group lshell --command \"cat /home/testuser/lshell/test/testfiles/test.conf\"" \ + " 4) su -s /bin/bash -c \"lshell --config ${CFG}\" testuser" \ + " 5) su - testuser" \ + "" \ + "Type \"exit\" to leave the container." \ + "============================================================" + +exec bash diff --git a/etc/lshell.conf b/etc/lshell.conf index f90be88..e26af75 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -15,6 +15,11 @@ loglevel : 2 ## in case you are using syslog, you can choose your logname #syslogname : lshell +## structured security audit events in JSON (ECS-aligned fields): +## includes session.id, source.ip, process.command_line, and event.reason +## set to 1 to enable SIEM-ready command authorization records +security_audit_json : 0 + ## Set path to sudo noexec library. This path is usually autodetected, only ## set this variable to use alternate path. If set and the shared object is ## not found, lshell will exit immediately. Otherwise, please check your logs diff --git a/justfile b/justfile index 3377265..3d05f74 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,7 @@ up service: {{compose}} up --build {{service}} # Build one or more services, e.g. `just build ubuntu_tests ubuntu` +[private] build +services: {{compose}} build {{services}} @@ -42,23 +43,7 @@ sample-list: # Example: just sample-lshell debian 04_sudo_and_aliases.conf [private] sample-lshell distro sample='01_baseline_allowlist.conf': - @bash -ceu '\ - sample="{{sample}}"; \ - sample="${sample#sample=}"; \ - distro="{{distro}}"; \ - if [[ ! -f "test/samples/${sample}" ]]; then \ - echo "Unknown sample: ${sample}"; \ - echo "Use: just sample-list"; \ - exit 1; \ - fi; \ - if [[ "${distro}" != "debian" && "${distro}" != "ubuntu" && "${distro}" != "fedora" ]]; then \ - echo "Unsupported distro: ${distro}"; \ - echo "Allowed values: debian, ubuntu, fedora"; \ - exit 1; \ - fi; \ - echo "Starting interactive lshell on ${distro} with test/samples/${sample}"; \ - {{compose}} run --build --rm --entrypoint lshell "${distro}" --config "/app/test/samples/${sample}" \ - ' + COMPOSE="{{compose}}" ./scripts/compose-sample-lshell.sh "{{distro}}" "{{sample}}" # User-friendly distro shortcuts: sample-debian sample='01_baseline_allowlist.conf': @@ -70,10 +55,18 @@ sample-ubuntu sample='01_baseline_allowlist.conf': sample-fedora sample='01_baseline_allowlist.conf': just sample-lshell fedora {{sample}} +# Open a distro container as root with package-style suggested checks. +[private] +distro-login-shell distro pkg_cmd cfg='/app/etc/lshell.conf': + COMPOSE="{{compose}}" ./scripts/compose-distro-login-shell.sh "{{distro}}" "{{pkg_cmd}}" "{{cfg}}" + # Debian -debian: +run-debian: just run debian +run-debian-root: + just distro-login-shell debian "dpkg -s lshell" + test-debian: just run debian_tests @@ -98,27 +91,34 @@ pkg-deb-install-debian: # - mode=login: open root shell with testuser configured as /usr/bin/lshell [private] pkg-deb-run-debian-mode mode='tests': - {{compose}} run --build --rm --user root -e MODE={{mode}} --entrypoint bash debian /app/debian/scripts/debian-deb-run.sh + {{compose}} run --rm --user root -e MODE={{mode}} --entrypoint bash debian /app/debian/scripts/debian-deb-run.sh -# Run full tests against installed Debian package [private] -pkg-deb-test-debian: - just pkg-deb-run-debian-mode tests +pkg-deb-ensure-built: + ./scripts/pkg-ensure-built.sh build/deb/.source-state.sha256 'build/deb/lshell_*_all.deb' pkg-deb-build 'Debian package' -# Full Debian flow: build, install verification, and installed-package tests +# Build Debian package artifacts from current workspace pkg-deb-build: just pkg-deb-build-debian - just pkg-deb-install-debian - just pkg-deb-test-debian + ./scripts/pkg-write-source-stamp.sh build/deb/.source-state.sha256 + +# Build if needed, then run installed-package test suite +pkg-deb-test: + just pkg-deb-ensure-built + just pkg-deb-run-debian-mode tests -# Open an interactive root shell with testuser preconfigured -pkg-deb-run-debian: +# Build if needed, then open interactive login flow +pkg-deb-run: + just pkg-deb-ensure-built just pkg-deb-run-debian-mode login # Ubuntu -ubuntu: +run-ubuntu: just run ubuntu +run-ubuntu-root: + just distro-login-shell ubuntu "dpkg -s lshell" + test-ubuntu: just run ubuntu_tests @@ -129,9 +129,12 @@ test-ubuntu-pypi-pre: just run ubuntu-pypi-pre # Fedora -fedora: +run-fedora: just run fedora +run-fedora-root: + just distro-login-shell fedora "rpm -qi lshell" + test-fedora: just run fedora_tests @@ -150,22 +153,26 @@ pkg-rpm-install-fedora: # - mode=login: open root shell with testuser configured as /usr/bin/lshell [private] pkg-rpm-run-fedora-mode mode='tests': - {{compose}} run --build --rm --user root -e MODE={{mode}} --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-run.sh + {{compose}} run --rm --user root -e MODE={{mode}} --entrypoint bash fedora /app/rpm/scripts/fedora-rpm-run.sh -# Run full tests against installed RPM [private] -pkg-rpm-test-fedora: - just pkg-rpm-run-fedora-mode tests - -# Open an interactive root shell with testuser preconfigured -pkg-rpm-run-fedora: - just pkg-rpm-run-fedora-mode login +pkg-rpm-ensure-built: + ./scripts/pkg-ensure-built.sh build/rpm/.source-state.sha256 'build/rpm/RPMS/noarch/lshell-*.rpm' pkg-rpm-build 'RPM package' -# Full RPM flow: build, install verification, and installed-package tests +# Build RPM package artifacts from current workspace pkg-rpm-build: just pkg-rpm-build-fedora - just pkg-rpm-install-fedora - just pkg-rpm-test-fedora + ./scripts/pkg-write-source-stamp.sh build/rpm/.source-state.sha256 + +# Build if needed, then run installed-package test suite +pkg-rpm-test: + just pkg-rpm-ensure-built + just pkg-rpm-run-fedora-mode tests + +# Build if needed, then open interactive login flow +pkg-rpm-run: + just pkg-rpm-ensure-built + just pkg-rpm-run-fedora-mode login test-fedora-pypi: just run fedora-pypi @@ -175,13 +182,7 @@ test-fedora-pypi-pre: # Real SSH end-to-end tests with Docker + Ansible only test-ssh-e2e: - @bash -ceu '\ - rc=0; \ - {{e2e_compose}} up --build -d lshell-ssh-target; \ - {{e2e_compose}} run --build --rm ansible-runner || rc=$?; \ - {{e2e_compose}} down -v --remove-orphans; \ - exit $rc\ - ' + ./scripts/test-ssh-e2e.sh "{{e2e_compose}}" # Lint Python sources test-lint-flake8: @@ -195,11 +196,6 @@ test-fuzz-security-parser runs='20000': # Full local validation in one command test-all: just test-lint-flake8 - @bash -ceu '\ - rc=0; \ - {{compose}} up --build ubuntu_tests debian_tests fedora_tests || rc=$?; \ - {{compose}} down -v --remove-orphans; \ - exit $rc\ - ' + ./scripts/test-all.sh "{{compose}}" just test-fuzz-security-parser just test-ssh-e2e diff --git a/lshell/audit.py b/lshell/audit.py new file mode 100644 index 0000000..48e849c --- /dev/null +++ b/lshell/audit.py @@ -0,0 +1,123 @@ +"""Structured security audit logging helpers.""" + +import json +import logging +import os +from datetime import datetime, timezone + + +ECS_VERSION = "8.11.0" +LAST_REASON_KEY = "_last_security_decision_reason" + + +def _now_utc_iso(): + """Return UTC ISO8601 timestamp with millisecond precision.""" + return ( + datetime.now(timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +def _source_ip(): + """Extract source IP from SSH context, falling back to localhost.""" + if os.environ.get("SSH_CLIENT"): + return os.environ["SSH_CLIENT"].split()[0] + if os.environ.get("SSH_CONNECTION"): + return os.environ["SSH_CONNECTION"].split()[0] + if os.environ.get("REMOTE_ADDR"): + return os.environ["REMOTE_ADDR"] + return "127.0.0.1" + + +def enabled(conf): + """Return True when structured audit logging is enabled.""" + return bool(conf.get("security_audit_json") and conf.get("logpath")) + + +class EcsJsonFormatter(logging.Formatter): + """Format log records as ECS-aligned JSON lines.""" + + def format(self, record): + payload = { + "@timestamp": _now_utc_iso(), + "ecs.version": ECS_VERSION, + "log.level": record.levelname.lower(), + "message": record.getMessage(), + "session.id": str( + getattr(record, "session_id", "") + or os.environ.get("LSHELL_SESSION_ID", "") + ), + "source.ip": str(getattr(record, "source_ip", "") or _source_ip()), + "user.name": str( + getattr(record, "username", "") + or os.environ.get("LOGNAME") + or os.environ.get("USER") + or "" + ), + } + + event_kind = getattr(record, "event_kind", None) + if event_kind: + payload["event.kind"] = event_kind + event_category = getattr(record, "event_category", None) + if event_category: + payload["event.category"] = event_category + event_type = getattr(record, "event_type", None) + if event_type: + payload["event.type"] = event_type + event_action = getattr(record, "event_action", None) + if event_action: + payload["event.action"] = event_action + event_outcome = getattr(record, "event_outcome", None) + if event_outcome: + payload["event.outcome"] = event_outcome + event_reason = getattr(record, "event_reason", None) + if event_reason: + payload["event.reason"] = event_reason + process_command_line = getattr(record, "process_command_line", None) + if process_command_line: + payload["process.command_line"] = process_command_line + + allowed = getattr(record, "lshell_security_allowed", None) + if allowed is not None: + payload["lshell.security.allowed"] = bool(allowed) + + return json.dumps(payload, sort_keys=True) + + +def set_decision_reason(conf, reason): + """Store latest decision reason in session config.""" + conf[LAST_REASON_KEY] = reason + + +def pop_decision_reason(conf, default="policy evaluation failed"): + """Return and clear latest decision reason from session config.""" + return conf.pop(LAST_REASON_KEY, default) + + +def log_command_event(conf, command, allowed, reason, level=None): + """Emit one ECS-aligned command authorization event.""" + if not enabled(conf): + return + + logger = conf["logpath"] + log_method = str(level or ("info" if allowed else "warning")).lower() + log_level = getattr(logging, log_method.upper(), logging.INFO) + logger.log( + log_level, + "lshell command authorization decision", + extra={ + "session_id": str(conf.get("session_id", "")), + "source_ip": _source_ip(), + "username": str(conf.get("username", "")), + "event_kind": "event", + "event_category": ["authentication", "process"], + "event_type": ["access"], + "event_action": "command_authorization", + "event_outcome": "success" if allowed else "failure", + "event_reason": str(reason), + "process_command_line": str(command), + "lshell_security_allowed": bool(allowed), + }, + ) diff --git a/lshell/checkconfig.py b/lshell/checkconfig.py index 5e7831b..e082fbd 100644 --- a/lshell/checkconfig.py +++ b/lshell/checkconfig.py @@ -19,6 +19,7 @@ from lshell import variables from lshell import builtincmd from lshell import configschema +from lshell import audit class CheckConfig: @@ -208,11 +209,6 @@ def check_log(self): for logfilter in logger.filters: logger.removeFilter(logfilter) - formatter = logging.Formatter(f"%(asctime)s ({getuser()}): %(message)s") - syslogformatter = logging.Formatter( - f"{logname}[{os.getpid()}]: {getuser()}: %(message)s" - ) - logger.setLevel(logging.DEBUG) # set log to output error on stderr @@ -233,6 +229,20 @@ def check_log(self): elif self.conf["loglevel"] < 0: self.conf["loglevel"] = 0 + try: + structured_audit_enabled = int(self.conf.get("security_audit_json", 0)) == 1 + except (TypeError, ValueError): + structured_audit_enabled = False + + if structured_audit_enabled: + formatter = audit.EcsJsonFormatter() + syslogformatter = audit.EcsJsonFormatter() + else: + formatter = logging.Formatter(f"%(asctime)s ({getuser()}): %(message)s") + syslogformatter = logging.Formatter( + f"{logname}[{os.getpid()}]: {getuser()}: %(message)s" + ) + # read logfilename is exists, and set logfilename if self.conf.get("logfilename"): try: @@ -251,6 +261,8 @@ def check_log(self): else: logfilename = getuser() + log_directory = self.conf["logpath"] + if self.conf["loglevel"] > 0: try: if logfilename == "syslog": @@ -260,12 +272,14 @@ def check_log(self): logger.addHandler(syslog) else: # if log file is writable add new log file handler - logfile = os.path.join(self.conf["logpath"], logfilename + ".log") + logfile = os.path.join(log_directory, logfilename + ".log") # create log file if it does not exist, and set permissions with open(logfile, "a", encoding="utf-8"): pass try: - os.chmod(logfile, 0o600) + # Group-writable logs support shared operational access + # when /var/log/lshell is managed with group ownership. + os.chmod(logfile, 0o660) except OSError: pass # set logging handler @@ -553,6 +567,7 @@ def get_config_user(self): "disable_exit", "policy_commands", "quiet", + "security_audit_json", ]: try: if len(self.conf_raw[item]) == 0: diff --git a/lshell/cli.py b/lshell/cli.py index ecfb8a8..830e2d6 100644 --- a/lshell/cli.py +++ b/lshell/cli.py @@ -4,8 +4,10 @@ import os import signal import sys +import uuid from lshell import policy as policy_mode +from lshell import systemsetup as system_setup from lshell.checkconfig import CheckConfig from lshell.shellcmd import LshellTimeOut, ShellCmd @@ -14,6 +16,8 @@ def main(): """Main CLI entry point.""" if len(sys.argv) > 1 and sys.argv[1] == "policy-show": sys.exit(policy_mode.main(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "setup-system": + sys.exit(system_setup.main(sys.argv[2:])) # Set SHELL and process LSHELL_ARGS env variables. os.environ["SHELL"] = os.path.realpath(sys.argv[0]) @@ -31,6 +35,8 @@ def main(): args = sys.argv[1:] userconf = CheckConfig(args).returnconf() + userconf["session_id"] = os.environ.get("LSHELL_SESSION_ID", uuid.uuid4().hex) + os.environ["LSHELL_SESSION_ID"] = userconf["session_id"] def disable_ctrl_z(_signum, _frame): return None diff --git a/lshell/configschema.py b/lshell/configschema.py index a032e63..84e4a86 100644 --- a/lshell/configschema.py +++ b/lshell/configschema.py @@ -41,6 +41,7 @@ "policy_commands", "quiet", "loglevel", + "security_audit_json", } DICT_VALUE_KEYS = {"aliases", "env_vars", "messages"} STRING_VALUE_KEYS = { diff --git a/lshell/sec.py b/lshell/sec.py index c7fea55..7a637d7 100644 --- a/lshell/sec.py +++ b/lshell/sec.py @@ -12,6 +12,7 @@ # import lshell specifics from lshell import messages from lshell import utils +from lshell import audit EXTENSION_RESTRICTION_EXEMPT_COMMANDS = {"cd", "clear", "fg", "bg", "ls"} MAX_WILDCARD_MATCHES = 4096 @@ -56,6 +57,9 @@ def warn_count(messagetype, command, conf, strict=None, ssh=None): ) else: primary_message = messages.get_forbidden_message(conf, messagetype, command) + audit.set_decision_reason( + conf, f"forbidden {messagetype}: {str(command).strip()}" + ) if ssh: return 1, conf @@ -91,6 +95,7 @@ def warn_unknown_syntax(command, conf, strict=None, ssh=None): log = conf["logpath"] log.warning(f'INFO: unknown syntax -> "{command}"') + audit.set_decision_reason(conf, f"unknown syntax: {command}") # Keep legacy UX: unknown syntax is always printed to stderr. sys.stderr.write(messages.get_message(conf, "unknown_syntax", command=command) + "\n") return 1, conf diff --git a/lshell/shellcmd.py b/lshell/shellcmd.py index 7578dd7..264e7b7 100644 --- a/lshell/shellcmd.py +++ b/lshell/shellcmd.py @@ -20,6 +20,7 @@ from lshell import completion from lshell import variables from lshell import policy as policy_mode +from lshell import audit class ShellCmd(cmd.Cmd, object): @@ -90,9 +91,12 @@ def __getattr__(self, attr): # in case the configuration file has been modified, reload it if self.conf["config_mtime"] != os.path.getmtime(self.conf["configfile"]): + session_id = self.conf.get("session_id") self.conf = CheckConfig( ["--config", self.conf["configfile"]], refresh=1 ).returnconf() + if session_id: + self.conf["session_id"] = session_id self.conf["promptprint"] = utils.updateprompt(os.getcwd(), self.conf) self.log = self.conf["logpath"] @@ -186,6 +190,12 @@ def _aliases_for_ssh_command(): sys.exit(retcode) else: self.log.error("*** forbidden SFTP connection") + audit.log_command_event( + self.conf, + self.conf["ssh"], + allowed=False, + reason="forbidden SFTP connection", + ) sys.exit(1) # check if scp is requested and allowed @@ -202,6 +212,12 @@ def _aliases_for_ssh_command(): self.log.error( f'SCP: download forbidden: "{self.conf["ssh"]}"' ) + audit.log_command_event( + self.conf, + self.conf["ssh"], + allowed=False, + reason="forbidden SCP download", + ) sys.exit(1) elif " -t " in self.conf["ssh"]: # case scp upload is allowed @@ -223,6 +239,12 @@ def _aliases_for_ssh_command(): self.log.error( f'SCP: upload forbidden: "{self.conf["ssh"]}"' ) + audit.log_command_event( + self.conf, + self.conf["ssh"], + allowed=False, + reason="forbidden SCP upload", + ) sys.exit(1) _validate_ssh_command() retcode = _execute_trusted_ssh_protocol(trusted_protocol=False) @@ -262,6 +284,14 @@ def _aliases_for_ssh_command(): ) if ret_check_secure: self.log.error(f'*** forbidden shell escape: "{self.conf["ssh"]}"') + audit.log_command_event( + self.conf, + self.conf["ssh"], + allowed=False, + reason=audit.pop_decision_reason( + self.conf, "forbidden shell escape" + ), + ) sys.exit(1) self.log.error(f'Shell escape: "{self.conf["ssh"]}"') @@ -274,6 +304,12 @@ def _aliases_for_ssh_command(): def ssh_warn(self, message, command="", key=""): """log and warn if forbidden action over SSH""" + audit.log_command_event( + self.conf, + command, + allowed=False, + reason=f"forbidden over SSH: {message}", + ) if key == "scp": self.log.critical( messages.get_message(self.conf, "forbidden_scp_over_ssh", message=message) diff --git a/lshell/systemsetup.py b/lshell/systemsetup.py new file mode 100644 index 0000000..04935b7 --- /dev/null +++ b/lshell/systemsetup.py @@ -0,0 +1,190 @@ +"""System bootstrap helpers for pip-based lshell installs.""" + +import argparse +import grp +import os +import pwd +import shutil +import subprocess +import sys + + +def _ensure_root(): + if os.name != "posix": + raise RuntimeError("lshell setup-system is supported on POSIX systems only.") + if os.geteuid() != 0: + raise RuntimeError("lshell setup-system must be run as root.") + + +def _create_group(group_name): + """Best-effort system group creation across common Linux/BSD tools.""" + candidates = [ + ["groupadd", "-r", group_name], + ["groupadd", group_name], + ["addgroup", "--system", group_name], + ["addgroup", group_name], + ["pw", "groupadd", group_name], + ] + for cmd in candidates: + if shutil.which(cmd[0]) is None: + continue + try: + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + except subprocess.CalledProcessError: + continue + raise RuntimeError( + f"Unable to create group '{group_name}'. Create it manually and retry." + ) + + +def _ensure_group(group_name): + try: + return grp.getgrnam(group_name).gr_gid + except KeyError: + _create_group(group_name) + return grp.getgrnam(group_name).gr_gid + + +def _resolve_uid(user_name): + try: + return pwd.getpwnam(user_name).pw_uid + except KeyError as exception: + raise RuntimeError(f"Owner user '{user_name}' does not exist.") from exception + + +def _resolve_lshell_path(requested): + if requested and requested != "auto": + return os.path.realpath(requested) + + discovered = shutil.which("lshell") + if not discovered: + raise RuntimeError("lshell binary not found in PATH.") + return os.path.realpath(discovered) + + +def _ensure_shell_entry(shell_path): + shells_file = os.environ.get("LSHELL_SHELLS_FILE", "/etc/shells") + if os.path.exists(shells_file): + with open(shells_file, "r", encoding="utf-8") as stream: + entries = [line.strip() for line in stream if line.strip()] + if shell_path not in entries: + with open(shells_file, "a", encoding="utf-8") as stream: + stream.write(f"{shell_path}\n") + else: + with open(shells_file, "w", encoding="utf-8") as stream: + stream.write(f"{shell_path}\n") + + +def _set_user_shell(user_name, shell_path): + if shutil.which("usermod"): + subprocess.run(["usermod", "-s", shell_path, user_name], check=True) + return + if shutil.which("chsh"): + subprocess.run(["chsh", "-s", shell_path, user_name], check=True) + return + raise RuntimeError("Neither 'usermod' nor 'chsh' is available to set user shells.") + + +def _add_user_to_group(user_name, group_name): + if shutil.which("usermod"): + subprocess.run(["usermod", "-aG", group_name, user_name], check=True) + return + if shutil.which("gpasswd"): + subprocess.run(["gpasswd", "-a", user_name, group_name], check=True) + return + raise RuntimeError("Neither 'usermod' nor 'gpasswd' is available to manage group membership.") + + +def _ensure_log_directory(path, owner_uid, group_gid, mode): + os.makedirs(path, exist_ok=True) + os.chown(path, owner_uid, group_gid) + os.chmod(path, mode) + + +def main(argv=None): + """Prepare system-level resources needed by lshell.""" + parser = argparse.ArgumentParser( + prog="lshell setup-system", + description="Create/validate group, log directory, and login-shell registration.", + ) + parser.add_argument("--group", default="lshell", help="System group for lshell logs.") + parser.add_argument( + "--log-dir", default="/var/log/lshell", help="Directory used for lshell log files." + ) + parser.add_argument( + "--owner", default="root", help="Owner user for the log directory (default: root)." + ) + parser.add_argument( + "--mode", + default="2770", + help="Octal mode for log directory. Default 2770 keeps group-write + setgid.", + ) + parser.add_argument( + "--shell-path", + default="auto", + help="Path to lshell binary to register in /etc/shells (default: auto).", + ) + parser.add_argument( + "--skip-shell-registration", + action="store_true", + help="Do not modify /etc/shells.", + ) + parser.add_argument( + "--set-shell-user", + action="append", + default=[], + help="Set the login shell of this user to lshell (repeatable).", + ) + parser.add_argument( + "--add-group-user", + action="append", + default=[], + help="Add user to the lshell group so group-write log access works (repeatable).", + ) + args = parser.parse_args(argv) + + try: + _ensure_root() + mode_value = int(args.mode, 8) + if mode_value < 0 or mode_value > 0o7777: + raise ValueError + except ValueError: + print( + f"lshell setup-system: Invalid mode value '{args.mode}'. " + "Use octal digits, e.g. 2770.", + file=sys.stderr, + ) + return 1 + except RuntimeError as exception: + print(f"lshell setup-system: {exception}", file=sys.stderr) + return 1 + + try: + gid = _ensure_group(args.group) + uid = _resolve_uid(args.owner) + _ensure_log_directory(args.log_dir, uid, gid, mode_value) + + shell_path = _resolve_lshell_path(args.shell_path) + if not args.skip_shell_registration: + _ensure_shell_entry(shell_path) + for user_name in args.set_shell_user: + _set_user_shell(user_name, shell_path) + for user_name in args.add_group_user: + _add_user_to_group(user_name, args.group) + except RuntimeError as exception: + print(f"lshell setup-system: {exception}", file=sys.stderr) + return 1 + except (OSError, subprocess.CalledProcessError) as exception: + print(f"lshell setup-system: {exception}", file=sys.stderr) + return 1 + + print( + f"lshell setup complete: group={args.group} log_dir={args.log_dir} " + f"mode={oct(mode_value)} shell={shell_path}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lshell/utils.py b/lshell/utils.py index c7a64ee..05c448f 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -1,4 +1,5 @@ """ Utils for lshell """ +# pylint: disable=too-many-lines import re import subprocess @@ -17,6 +18,7 @@ from lshell import builtincmd from lshell import sec from lshell import messages +from lshell import audit def usage(exitcode=1): @@ -531,6 +533,13 @@ def _handle_unknown_syntax(unknown_command): shell_context.conf, strict=shell_context.conf["strict"], ) + audit.log_command_event( + shell_context.conf, + unknown_command, + allowed=False, + reason=audit.pop_decision_reason(shell_context.conf, "unknown syntax"), + level="warning", + ) if ret == 1 and shell_context.conf["strict"]: # Keep strict-mode behavior aligned with forbidden actions. return 126 @@ -552,6 +561,14 @@ def _handle_unknown_syntax(unknown_command): forbidden_check_line, shell_context.conf, strict=shell_context.conf["strict"] ) if ret_forbidden_chars == 1: + audit.log_command_event( + shell_context.conf, + command_line, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, "forbidden character in command" + ), + ) # see http://tldp.org/LDP/abs/html/exitcodes.html retcode = 126 return retcode @@ -572,12 +589,24 @@ def _handle_unknown_syntax(unknown_command): if executable is None: return _handle_unknown_syntax(item) if assignments: + audit.log_command_event( + shell_context.conf, + item, + allowed=False, + reason="forbidden trusted SSH protocol command: env assignment", + ) shell_context.log.critical( f'lshell: forbidden trusted SSH protocol command: "{item}"' ) sys.stderr.write("lshell: forbidden trusted SSH protocol command\n") return 126 if executable not in trusted_protocol_binaries: + audit.log_command_event( + shell_context.conf, + item, + allowed=False, + reason=f"forbidden trusted SSH protocol command: {executable}", + ) shell_context.log.critical( f'lshell: forbidden trusted SSH protocol command: "{item}"' ) @@ -671,6 +700,14 @@ def _handle_unknown_syntax(unknown_command): for _executable_name, _argument, _split, assignments in parsed_parts: for var_name, _var_value in assignments: if var_name in variables.FORBIDDEN_ENVIRON: + reason = f"forbidden environment variable assignment: {var_name}" + audit.set_decision_reason(shell_context.conf, reason) + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=reason, + ) shell_context.log.critical( f"lshell: forbidden environment variable: {var_name}" ) @@ -687,6 +724,12 @@ def _handle_unknown_syntax(unknown_command): if not executable and assignments: for var_name, var_value in assignments: os.environ[var_name] = var_value + audit.log_command_event( + shell_context.conf, + full_command, + allowed=True, + reason="assignment-only command accepted", + ) retcode = 0 i = j + (2 if background else 1) continue @@ -697,6 +740,14 @@ def _handle_unknown_syntax(unknown_command): full_command, shell_context.conf, strict=shell_context.conf["strict"] ) if ret_check_secure == 1: + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, "forbidden command by security policy" + ), + ) # see http://tldp.org/LDP/abs/html/exitcodes.html retcode = 126 return retcode @@ -706,6 +757,14 @@ def _handle_unknown_syntax(unknown_command): full_command, shell_context.conf, strict=shell_context.conf["strict"] ) if ret_check_path == 1: + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, "forbidden path by security policy" + ), + ) # see http://tldp.org/LDP/abs/html/exitcodes.html retcode = 126 # in case request was sent by WinSCP, return error code has to be @@ -718,6 +777,12 @@ def _handle_unknown_syntax(unknown_command): # Execute command if len(pipeline_parts) == 1 and executable in builtincmd.builtins_list and not background: + audit.log_command_event( + shell_context.conf, + full_command, + allowed=True, + reason="allowed builtin command", + ) retcode, shell_context.conf = handle_builtin_command( full_command, executable, argument, shell_context ) @@ -743,6 +808,12 @@ def _handle_unknown_syntax(unknown_command): "command_not_found", command=missing_executable, ) + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=f"command not found: {missing_executable}", + ) shell_context.log.critical(command_not_found_message) return 127 @@ -755,6 +826,12 @@ def _handle_unknown_syntax(unknown_command): ) if "path_noexec" in shell_context.conf and not uses_shell_escape: extra_env = {"LD_PRELOAD": shell_context.conf["path_noexec"]} + audit.log_command_event( + shell_context.conf, + full_command, + allowed=True, + reason="allowed by command and path policy", + ) retcode = exec_cmd( full_command, background=background, extra_env=extra_env ) diff --git a/lshell/variables.py b/lshell/variables.py index af30b51..c1fc73d 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.11.1rc2" +__version__ = "0.11.1rc3" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] @@ -31,6 +31,7 @@ USAGE = f"""Usage: lshell [OPTIONS] --config : Config file location (default {configfile}) -- : where is *any* config file parameter + --security_audit_json=<0|1> : Emit structured JSON/ECS security audit events -h, --help : Show this help message --version : Show version @@ -39,6 +40,14 @@ --user : Target username --group : Target group (repeat for multiple groups) --json : Print JSON diagnostics output + +Usage: lshell setup-system [OPTIONS] + --group : Group for log directory (default lshell) + --log-dir : Log directory path (default /var/log/lshell) + --owner : Log directory owner (default root) + --mode : Log directory mode (default 2770) + --set-shell-user : Assign lshell as login shell (repeatable) + --add-group-user : Add user to log-writer group (repeatable) """ # Intro Text @@ -89,6 +98,7 @@ "disable_exit=", "policy_commands=", "include_dir=", + "security_audit_json=", ] FORBIDDEN_ENVIRON = ( diff --git a/pyproject.toml b/pyproject.toml index 99147c8..1b1811a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ lshell = "lshell.cli:main" include-package-data = true [tool.setuptools.packages.find] +include = ["lshell*"] exclude = ["test", "test.*"] [tool.setuptools.dynamic] diff --git a/rpm/scripts/fedora-rpm-build.sh b/rpm/scripts/fedora-rpm-build.sh index ab8cb43..17725fd 100644 --- a/rpm/scripts/fedora-rpm-build.sh +++ b/rpm/scripts/fedora-rpm-build.sh @@ -2,10 +2,13 @@ set -euo pipefail -dnf install -y rpm-build python3-devel python3-setuptools -git config --global --add safe.directory /app +dnf install -y rpm-build python3-devel python3-setuptools python3-wheel -VERSION="$(python3 -c "from lshell.variables import __version__; print(__version__)")" +VERSION="$(awk '/^Version:/{print $2; exit}' /app/rpm/lshell.spec)" +if [[ -z "${VERSION}" ]]; then + echo "Unable to read Version from /app/rpm/lshell.spec" >&2 + exit 1 +fi TOPDIR="/tmp/rpmbuild" OUTDIR="/app/build/rpm" @@ -13,11 +16,22 @@ rm -rf "${TOPDIR}" "${OUTDIR}" mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} mkdir -p "${OUTDIR}" -git -C /app archive \ - --format=tar.gz \ - --prefix="lshell-${VERSION}/" \ - -o "${TOPDIR}/SOURCES/lshell-${VERSION}.tar.gz" \ - HEAD +# Build source tarball from the current workspace state (not just git HEAD), +# so local packaging fixes are included in RPM builds. +tar -C /app \ + --exclude-vcs \ + --exclude='./build' \ + --exclude='./.venv' \ + --exclude='./.pytest_cache' \ + --exclude='./.hypothesis' \ + --exclude='./.pylint.d' \ + --exclude='./.pylint_cache' \ + --exclude='./.git' \ + --exclude='./*.egg-info' \ + --exclude='*/__pycache__' \ + --transform "s,^\.,lshell-${VERSION}," \ + -czf "${TOPDIR}/SOURCES/lshell-${VERSION}.tar.gz" \ + . cp /app/rpm/lshell.spec "${TOPDIR}/SPECS/" cp /app/rpm/preinstall /app/rpm/postinstall /app/rpm/postuninstall "${TOPDIR}/SOURCES/" diff --git a/rpm/scripts/fedora-rpm-run.sh b/rpm/scripts/fedora-rpm-run.sh index 26035e6..60c5b37 100644 --- a/rpm/scripts/fedora-rpm-run.sh +++ b/rpm/scripts/fedora-rpm-run.sh @@ -14,12 +14,6 @@ fi install -m 0644 /app/rpm/lshell.rpm-test.conf /etc/lshell.rpm-test.conf -# Prepare users and groups used by RPM test/login flows. -# Use a single identity everywhere: testuser -usermod -s /usr/bin/lshell testuser -echo "testuser:password" | chpasswd -usermod -aG lshell testuser || true - if [ "${MODE}" = "tests" ]; then # Force test invocations to use the installed RPM binary. ln -sf /usr/bin/lshell /home/testuser/lshell/bin/lshell @@ -41,7 +35,7 @@ if [ "${MODE}" = "login" ]; then "" \ "Accounts:" \ " - root (current shell)" \ - " - testuser / password: password (login shell: /usr/bin/lshell, group: lshell)" \ + " - testuser / password: password" \ "" \ "Main RPM test config (layered): /etc/lshell.rpm-test.conf" \ "Layers included in this file:" \ diff --git a/scripts/compose-distro-login-shell.sh b/scripts/compose-distro-login-shell.sh new file mode 100755 index 0000000..af06e3c --- /dev/null +++ b/scripts/compose-distro-login-shell.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +DISTRO="${1:-}" +PKG_CMD="${2:-}" +CFG="${3:-/app/etc/lshell.conf}" + +if [[ "${DISTRO}" != "debian" && "${DISTRO}" != "ubuntu" && "${DISTRO}" != "fedora" ]]; then + echo "Unsupported distro: ${DISTRO}" >&2 + echo "Allowed values: debian, ubuntu, fedora" >&2 + exit 1 +fi + +COMPOSE_CMD="${COMPOSE:-docker compose}" +# shellcheck disable=SC2206 +COMPOSE_PARTS=(${COMPOSE_CMD}) +"${COMPOSE_PARTS[@]}" run --build --rm --user root --entrypoint bash "${DISTRO}" /app/docker/scripts/distro-login-shell.sh "${DISTRO}" "${PKG_CMD}" "${CFG}" diff --git a/scripts/compose-sample-lshell.sh b/scripts/compose-sample-lshell.sh new file mode 100755 index 0000000..f3b60bc --- /dev/null +++ b/scripts/compose-sample-lshell.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +DISTRO="${1:-}" +SAMPLE="${2:-01_baseline_allowlist.conf}" +SAMPLE="${SAMPLE#sample=}" + +if [[ ! -f "test/samples/${SAMPLE}" ]]; then + echo "Unknown sample: ${SAMPLE}" >&2 + echo "Use: just sample-list" >&2 + exit 1 +fi + +if [[ "${DISTRO}" != "debian" && "${DISTRO}" != "ubuntu" && "${DISTRO}" != "fedora" ]]; then + echo "Unsupported distro: ${DISTRO}" >&2 + echo "Allowed values: debian, ubuntu, fedora" >&2 + exit 1 +fi + +echo "Starting interactive lshell on ${DISTRO} with test/samples/${SAMPLE}" + +COMPOSE_CMD="${COMPOSE:-docker compose}" +# shellcheck disable=SC2206 +COMPOSE_PARTS=(${COMPOSE_CMD}) +"${COMPOSE_PARTS[@]}" run --build --rm --entrypoint lshell "${DISTRO}" --config "/app/test/samples/${SAMPLE}" diff --git a/scripts/pkg-ensure-built.sh b/scripts/pkg-ensure-built.sh new file mode 100755 index 0000000..cbd6df4 --- /dev/null +++ b/scripts/pkg-ensure-built.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +STAMP_FILE="${1:?missing stamp file}" +ARTIFACT_GLOB="${2:?missing artifact glob}" +BUILD_RECIPE="${3:?missing build recipe}" +LABEL="${4:-Package}" + +artifact="$(ls -1 ${ARTIFACT_GLOB} 2>/dev/null | head -n1 || true)" +current="$( { git rev-parse HEAD; git status --porcelain=v1 --untracked-files=all; } | shasum -a 256 | awk '{print $1}')" + +if [[ -n "${artifact}" && -f "${STAMP_FILE}" && "$(cat "${STAMP_FILE}")" == "${current}" ]]; then + echo "${LABEL} artifacts are up to date." + exit 0 +fi + +echo "${LABEL} sources changed (or no artifact found); rebuilding package." +just "${BUILD_RECIPE}" diff --git a/scripts/pkg-write-source-stamp.sh b/scripts/pkg-write-source-stamp.sh new file mode 100755 index 0000000..2131938 --- /dev/null +++ b/scripts/pkg-write-source-stamp.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +STAMP_FILE="${1:?missing stamp file}" +mkdir -p "$(dirname "${STAMP_FILE}")" +{ git rev-parse HEAD; git status --porcelain=v1 --untracked-files=all; } | shasum -a 256 | awk '{print $1}' > "${STAMP_FILE}" diff --git a/scripts/test-all.sh b/scripts/test-all.sh new file mode 100755 index 0000000..e2ee5e1 --- /dev/null +++ b/scripts/test-all.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMPOSE_CMD="${1:-docker compose}" +# shellcheck disable=SC2206 +COMPOSE_PARTS=(${COMPOSE_CMD}) + +rc=0 +"${COMPOSE_PARTS[@]}" up --build ubuntu_tests debian_tests fedora_tests || rc=$? +"${COMPOSE_PARTS[@]}" down -v --remove-orphans +exit "${rc}" diff --git a/scripts/test-ssh-e2e.sh b/scripts/test-ssh-e2e.sh new file mode 100755 index 0000000..bf523be --- /dev/null +++ b/scripts/test-ssh-e2e.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +E2E_COMPOSE="${1:-docker compose -f docker-compose.e2e.yml}" +# shellcheck disable=SC2206 +COMPOSE_PARTS=(${E2E_COMPOSE}) + +rc=0 +"${COMPOSE_PARTS[@]}" up --build -d lshell-ssh-target +"${COMPOSE_PARTS[@]}" run --build --rm ansible-runner || rc=$? +"${COMPOSE_PARTS[@]}" down -v --remove-orphans +exit "${rc}" diff --git a/test/test_audit_functional.py b/test/test_audit_functional.py new file mode 100644 index 0000000..50e6b2b --- /dev/null +++ b/test/test_audit_functional.py @@ -0,0 +1,81 @@ +"""Functional tests for structured security audit logging.""" + +import json +import os +import tempfile +import unittest +from getpass import getuser + +import pexpect + + +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" +LSHELL = f"{TOPDIR}/bin/lshell" +USER = getuser() +PROMPT = f"{USER}:~\\$" + + +class TestAuditFunctional(unittest.TestCase): + """Validate ECS audit events from a real interactive shell session.""" + + def test_security_audit_json_emits_allowed_and_denied_events(self): + """Emit structured events with success/failure outcomes and reasons.""" + with tempfile.TemporaryDirectory(prefix="lshell-audit-log-") as log_dir: + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --log {log_dir} --security_audit_json=1 " + "--loglevel 4 --strict 0", + encoding="utf-8", + timeout=10, + ) + try: + child.expect(PROMPT) + + child.sendline("echo AUDIT_OK") + child.expect(PROMPT) + + child.sendline("id") + child.expect(PROMPT) + + child.sendline("exit") + child.expect(pexpect.EOF) + finally: + child.close() + + logfile = os.path.join(log_dir, f"{USER}.log") + self.assertTrue(os.path.exists(logfile)) + + with open(logfile, "r", encoding="utf-8") as handle: + lines = [line.strip() for line in handle if line.strip()] + + events = [] + for line in lines: + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if payload.get("event.action") == "command_authorization": + events.append(payload) + + self.assertGreaterEqual(len(events), 2) + + allowed = [ + event + for event in events + if event.get("process.command_line") == "echo AUDIT_OK" + and event.get("event.outcome") == "success" + ] + denied = [ + event + for event in events + if event.get("process.command_line") == "id" + and event.get("event.outcome") == "failure" + ] + + self.assertTrue(allowed, msg=f"missing allowed audit event in {events}") + self.assertTrue(denied, msg=f"missing denied audit event in {events}") + self.assertIn("unknown syntax", denied[0].get("event.reason", "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_audit_unit.py b/test/test_audit_unit.py new file mode 100644 index 0000000..607af9b --- /dev/null +++ b/test/test_audit_unit.py @@ -0,0 +1,104 @@ +"""Unit tests for structured security audit logging.""" + +import json +import logging +import os +import unittest +from unittest.mock import patch + +from lshell import audit +from lshell.checkconfig import CheckConfig + + +TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" + + +class _DummyAuditLogger: + """Capture structured audit calls.""" + + def __init__(self): + self.entries = [] + + def log(self, level, message, extra=None): + """Record one structured audit call.""" + self.entries.append((level, message, extra or {})) + + +class TestAuditLogging(unittest.TestCase): + """Validate JSON/ECS security audit event behavior.""" + + args = [f"--config={CONFIG}", "--quiet=1"] + + def test_security_audit_json_flag_is_parsed(self): + """Config/CLI flag should enable structured audit mode.""" + conf = CheckConfig(self.args + ["--security_audit_json=1"]).returnconf() + self.assertEqual(conf["security_audit_json"], 1) + + def test_log_command_event_emits_ecs_json(self): + """Structured event should include ECS fields and decision reason.""" + logger = _DummyAuditLogger() + conf = { + "security_audit_json": 1, + "logpath": logger, + "session_id": "session-123", + "username": "testuser", + } + + with patch.dict(os.environ, {"SSH_CLIENT": "192.0.2.10 2222 22"}, clear=False): + audit.log_command_event( + conf, + "cat /etc/passwd", + allowed=False, + reason="forbidden path: /etc/passwd", + ) + + self.assertEqual(len(logger.entries), 1) + level, message, extra = logger.entries[0] + self.assertEqual(level, logging.WARNING) + self.assertEqual(message, "lshell command authorization decision") + self.assertEqual(extra["session_id"], "session-123") + self.assertEqual(extra["source_ip"], "192.0.2.10") + self.assertEqual(extra["process_command_line"], "cat /etc/passwd") + self.assertEqual(extra["event_reason"], "forbidden path: /etc/passwd") + self.assertEqual(extra["event_outcome"], "failure") + + def test_ecs_formatter_outputs_json_payload(self): + """Formatter should render ECS-aligned JSON from log extras.""" + formatter = audit.EcsJsonFormatter() + record = logging.makeLogRecord( + { + "levelname": "WARNING", + "levelno": logging.WARNING, + "msg": "lshell command authorization decision", + "session_id": "session-123", + "source_ip": "192.0.2.10", + "username": "testuser", + "event_kind": "event", + "event_category": ["authentication", "process"], + "event_type": ["access"], + "event_action": "command_authorization", + "event_outcome": "failure", + "event_reason": "forbidden path: /etc/passwd", + "process_command_line": "cat /etc/passwd", + "lshell_security_allowed": False, + } + ) + payload = json.loads(formatter.format(record)) + self.assertEqual(payload["session.id"], "session-123") + self.assertEqual(payload["source.ip"], "192.0.2.10") + self.assertEqual(payload["process.command_line"], "cat /etc/passwd") + self.assertEqual(payload["event.reason"], "forbidden path: /etc/passwd") + self.assertEqual(payload["event.outcome"], "failure") + + def test_log_command_event_noop_when_disabled(self): + """No structured event should be emitted when feature flag is off.""" + logger = _DummyAuditLogger() + conf = { + "security_audit_json": 0, + "logpath": logger, + "session_id": "session-123", + "username": "testuser", + } + audit.log_command_event(conf, "echo ok", allowed=True, reason="allowed") + self.assertEqual(logger.entries, []) diff --git a/test/test_cli_unit.py b/test/test_cli_unit.py index a1ff6c4..d106525 100644 --- a/test/test_cli_unit.py +++ b/test/test_cli_unit.py @@ -62,3 +62,13 @@ def test_main_ignores_invalid_or_unsafe_lshell_args_env(self): with self.subTest(value=value): args = self._run_main_and_capture_args(value) self.assertEqual(args, ["--quiet=1"]) + + def test_main_routes_setup_system_subcommand(self): + """Dispatch setup-system subcommand to dedicated handler.""" + with patch("lshell.cli.system_setup.main", return_value=7) as mock_setup_main: + with patch("lshell.cli.sys.argv", ["lshell", "setup-system", "--group", "ops"]): + with patch("lshell.cli.sys.exit", side_effect=SystemExit) as mock_exit: + with self.assertRaises(SystemExit): + cli.main() + mock_setup_main.assert_called_once_with(["--group", "ops"]) + mock_exit.assert_called_once_with(7) diff --git a/test/test_parser_module_unit.py b/test/test_parser_module_unit.py new file mode 100644 index 0000000..319484b --- /dev/null +++ b/test/test_parser_module_unit.py @@ -0,0 +1,44 @@ +"""Unit tests for lshell.parser module execution paths.""" + +import unittest + +from pyparsing import ParseResults + +from lshell.parser import LshellParser + + +class TestLshellParserModule(unittest.TestCase): + """Cover parser grammar and validation logic outside fuzzing.""" + + def setUp(self): + self.parser = LshellParser() + + def test_parse_accepts_chained_command_with_quotes(self): + """Parser should accept regular shell-like command chains.""" + parsed = self.parser.parse('echo "hello world" && printf ok') + self.assertIsNotNone(parsed) + self.assertTrue(self.parser.validate_command(parsed)) + + def test_parse_rejects_invalid_operator_sequence(self): + """Malformed operator chains should fail parsing cleanly.""" + parsed = self.parser.parse("echo &&&& ls") + self.assertIsNone(parsed) + + def test_clean_input_removes_control_characters(self): + """Control characters should be stripped before grammar parsing.""" + cleaned = self.parser._clean_input("echo\x00ok\x1f\t\n") + self.assertEqual(cleaned, "echook\t\n") + + def test_validate_command_rejects_excessive_token_count(self): + """Validation should reject token lists larger than maximum.""" + parsed = ParseResults([str(index) for index in range(21)]) + self.assertFalse(self.parser.validate_command(parsed)) + + def test_validate_command_rejects_overlong_token(self): + """Validation should reject tokens longer than configured cap.""" + parsed = ParseResults(["x" * 256]) + self.assertFalse(self.parser.validate_command(parsed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_systemsetup_functional.py b/test/test_systemsetup_functional.py new file mode 100644 index 0000000..7c71649 --- /dev/null +++ b/test/test_systemsetup_functional.py @@ -0,0 +1,128 @@ +"""Functional integration tests for lshell setup-system command.""" + +import contextlib +import grp +import io +import os +import pwd +import stat +import tempfile +import unittest +from unittest.mock import patch + +from lshell import systemsetup + + +class TestSystemSetupFunctional(unittest.TestCase): + """Exercise setup-system with real filesystem side effects.""" + + def _current_user_and_group(self): + """Return current account names for owner/group CLI flags.""" + username = pwd.getpwuid(os.getuid()).pw_name + group_name = grp.getgrgid(os.getgid()).gr_name + return username, group_name + + def test_ensure_shell_entry_is_idempotent_with_override_file(self): + """Register shell path exactly once when called repeatedly.""" + with tempfile.TemporaryDirectory(prefix="lshell-setup-shells-") as tempdir: + shells_file = os.path.join(tempdir, "shells") + shell_path = "/usr/local/bin/lshell" + with open(shells_file, "w", encoding="utf-8") as handle: + handle.write("/bin/sh\n") + + with patch.dict( + os.environ, {"LSHELL_SHELLS_FILE": shells_file}, clear=False + ): + systemsetup._ensure_shell_entry(shell_path) + systemsetup._ensure_shell_entry(shell_path) + + with open(shells_file, "r", encoding="utf-8") as handle: + entries = [line.strip() for line in handle if line.strip()] + + self.assertEqual(entries.count(shell_path), 1) + + def test_main_integration_creates_log_dir_and_registers_shell(self): + """Run main flow and verify persisted filesystem effects.""" + with tempfile.TemporaryDirectory(prefix="lshell-setup-main-") as tempdir: + log_dir = os.path.join(tempdir, "var", "log", "lshell") + shells_file = os.path.join(tempdir, "shells") + fake_shell = os.path.join(tempdir, "bin", "lshell") + os.makedirs(os.path.dirname(fake_shell), exist_ok=True) + with open(fake_shell, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\nexit 0\n") + os.chmod(fake_shell, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + + owner, group_name = self._current_user_and_group() + stdout = io.StringIO() + stderr = io.StringIO() + + with patch("lshell.systemsetup.os.geteuid", return_value=0): + with patch("lshell.systemsetup.os.chown"): + with patch.dict( + os.environ, {"LSHELL_SHELLS_FILE": shells_file}, clear=False + ): + with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stderr(stderr): + code = systemsetup.main( + [ + "--group", + group_name, + "--owner", + owner, + "--log-dir", + log_dir, + "--shell-path", + fake_shell, + "--mode", + "2770", + ] + ) + + self.assertEqual(code, 0) + self.assertTrue(os.path.isdir(log_dir)) + mode = stat.S_IMODE(os.stat(log_dir).st_mode) + self.assertEqual(mode & 0o770, 0o770) + self.assertIn("lshell setup complete:", stdout.getvalue()) + self.assertEqual(stderr.getvalue(), "") + + with open(shells_file, "r", encoding="utf-8") as handle: + shells_entries = [line.strip() for line in handle if line.strip()] + self.assertIn(os.path.realpath(fake_shell), shells_entries) + + def test_main_integration_skip_shell_registration(self): + """Skip shell registration should not create the shells file.""" + with tempfile.TemporaryDirectory(prefix="lshell-setup-skip-shells-") as tempdir: + log_dir = os.path.join(tempdir, "var", "log", "lshell") + shells_file = os.path.join(tempdir, "shells") + fake_shell = os.path.join(tempdir, "bin", "lshell") + os.makedirs(os.path.dirname(fake_shell), exist_ok=True) + with open(fake_shell, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\nexit 0\n") + os.chmod(fake_shell, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + + owner, group_name = self._current_user_and_group() + with patch("lshell.systemsetup.os.geteuid", return_value=0): + with patch("lshell.systemsetup.os.chown"): + with patch.dict( + os.environ, {"LSHELL_SHELLS_FILE": shells_file}, clear=False + ): + code = systemsetup.main( + [ + "--group", + group_name, + "--owner", + owner, + "--log-dir", + log_dir, + "--shell-path", + fake_shell, + "--skip-shell-registration", + ] + ) + + self.assertEqual(code, 0) + self.assertFalse(os.path.exists(shells_file)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_systemsetup_unit.py b/test/test_systemsetup_unit.py new file mode 100644 index 0000000..6f33988 --- /dev/null +++ b/test/test_systemsetup_unit.py @@ -0,0 +1,46 @@ +"""Unit tests for lshell setup-system bootstrap command.""" + +import unittest +from unittest.mock import patch + +from lshell import systemsetup + + +class TestSystemSetup(unittest.TestCase): + """Validate setup-system argument and workflow handling.""" + + def test_setup_system_returns_error_when_not_root(self): + """Refuse setup operations when command is not run as root.""" + with patch("lshell.systemsetup.os.geteuid", return_value=1000): + code = systemsetup.main([]) + self.assertEqual(code, 1) + + def test_setup_system_happy_path(self): + """Run setup steps in sequence when prerequisites are met.""" + with patch("lshell.systemsetup.os.geteuid", return_value=0): + with patch("lshell.systemsetup._ensure_group", return_value=444): + with patch("lshell.systemsetup._resolve_uid", return_value=0): + with patch("lshell.systemsetup._ensure_log_directory") as logdir: + with patch( + "lshell.systemsetup._resolve_lshell_path", + return_value="/usr/local/bin/lshell", + ): + with patch("lshell.systemsetup._ensure_shell_entry") as shell_entry: + with patch("lshell.systemsetup._set_user_shell") as set_shell: + with patch( + "lshell.systemsetup._add_user_to_group" + ) as add_group: + code = systemsetup.main( + [ + "--set-shell-user", + "testuser", + "--add-group-user", + "testuser", + ] + ) + + self.assertEqual(code, 0) + logdir.assert_called_once_with("/var/log/lshell", 0, 444, 0o2770) + shell_entry.assert_called_once_with("/usr/local/bin/lshell") + set_shell.assert_called_once_with("testuser", "/usr/local/bin/lshell") + add_group.assert_called_once_with("testuser", "lshell") From 9d69d79de5dfa44626b95b04e26e299ba591b4f4 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 20:42:02 -0400 Subject: [PATCH 13/29] Add harden-init profile generator and tests --- CHANGELOG.md | 10 + README.md | 40 ++ etc/lshell.conf | 2 +- lshell/cli.py | 3 + lshell/hardeninit.py | 610 +++++++++++++++++++++++++++++ lshell/variables.py | 10 + man/lshell.1 | 59 +++ test/test_cli_unit.py | 10 + test/test_hardeninit_functional.py | 63 +++ test/test_hardeninit_unit.py | 154 ++++++++ 10 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 lshell/hardeninit.py create mode 100644 test/test_hardeninit_functional.py create mode 100644 test/test_hardeninit_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa1b67..7040fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) ### v0.11.1rc1 11/03/2026 - Added handling for `command not found` messages, with dedicated test coverage. +### v0.11.1rc2 17/03/2026 +- Added `lshell harden-init` to generate secure baseline configs from vetted profiles. +- Shipped hardened templates: `sftp-only`, `rsync-backup`, `deploy-minimal`, and `readonly-support`. +- Added pre-write profile validation, `--dry-run` sanity checks, and inline hardening comments in generated output. +- Added `--group` and `--user` flags to render scoped `[grp:*]` / `[user:*]` sections directly from `harden-init`. +- Added unit and functional tests for harden-init rendering and CLI flows. +- Added Bash completion packaging and runtime dependencies for DEB/RPM (`bash-completion`). +- Changed `harden-init` default output path to `/etc/lshell.d/.conf`. +- Enabled `include_dir : /etc/lshell.d/*.conf` in the default `/etc/lshell.conf` template. + ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. - Added policy diagnostics and built-ins: `policy-show`, `policy-path`, and `policy-sudo`. diff --git a/README.md b/README.md index 07c6de5..9a91008 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,18 @@ For automated setup (including `/etc/shells` registration + user shell assignmen lshell setup-system --set-shell-user user_name --add-group-user user_name ``` +Generate a hardened scoped include file for a specific group and user directly from CLI flags: + +```bash +lshell harden-init \ + --profile sftp-only \ + --group sftpusers \ + --user alice \ + --output /etc/lshell.d/sftp-only.conf +``` + +If `--output` is omitted, `harden-init` writes to `/etc/lshell.d/.conf`. + ## Policy diagnostics Explain the effective policy and decision for a command: @@ -86,6 +98,34 @@ Hide these built-ins if needed: policy_commands : 0 ``` +## Hardened profile generator + +`harden-init` ships secure-by-default templates to bootstrap restricted accounts quickly: + +- `sftp-only` +- `rsync-backup` +- `deploy-minimal` +- `readonly-support` + +Examples: + +```bash +# Show available templates +lshell harden-init --list-templates + +# Print generated profile to stdout +lshell harden-init --profile readonly-support --stdout + +# Validate rendering and sanity checks without writing files +lshell harden-init --profile rsync-backup --dry-run + +# Show rationale for security controls +lshell harden-init --profile deploy-minimal --stdout --explain + +# Generate scoped sections (no [default] section) +lshell harden-init --profile sftp-only --group sftpusers --user alice --stdout +``` + ## Configuration Primary template: [`etc/lshell.conf`](etc/lshell.conf) diff --git a/etc/lshell.conf b/etc/lshell.conf index e26af75..fec7e88 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -35,7 +35,7 @@ security_audit_json : 0 ## these files can only contain default/user/group configuration. ## global configuration is only loaded from this main configuration file. ## e.g. splitting users into separate files -#include_dir : /etc/lshell.d/*.conf +include_dir : /etc/lshell.d/*.conf ## section precedence reminder (highest to lowest): ## 1) [username] diff --git a/lshell/cli.py b/lshell/cli.py index 830e2d6..5efba00 100644 --- a/lshell/cli.py +++ b/lshell/cli.py @@ -8,6 +8,7 @@ from lshell import policy as policy_mode from lshell import systemsetup as system_setup +from lshell import hardeninit as harden_init from lshell.checkconfig import CheckConfig from lshell.shellcmd import LshellTimeOut, ShellCmd @@ -18,6 +19,8 @@ def main(): sys.exit(policy_mode.main(sys.argv[2:])) if len(sys.argv) > 1 and sys.argv[1] == "setup-system": sys.exit(system_setup.main(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "harden-init": + sys.exit(harden_init.main(sys.argv[2:])) # Set SHELL and process LSHELL_ARGS env variables. os.environ["SHELL"] = os.path.realpath(sys.argv[0]) diff --git a/lshell/hardeninit.py b/lshell/hardeninit.py new file mode 100644 index 0000000..acdfc04 --- /dev/null +++ b/lshell/hardeninit.py @@ -0,0 +1,610 @@ +"""Generate hardened baseline lshell configurations.""" + +import argparse +import configparser +import os +import re +import sys +from datetime import datetime, timezone + +from lshell import configschema + + +SAFE_FORBIDDEN_OPERATORS = [";", "&", "|", "`", ">", "<", "$(", "${"] +REQUIRED_PROFILE_KEYS = { + "allowed", + "allowed_shell_escape", + "forbidden", + "warning_counter", + "strict", + "scp", + "scp_upload", + "scp_download", + "sftp", + "overssh", +} +UNSAFE_ALLOWED_SHELL_ESCAPE = { + "awk", + "bash", + "dash", + "env", + "expect", + "find", + "fish", + "ksh", + "less", + "lua", + "man", + "more", + "nano", + "nawk", + "node", + "perl", + "php", + "python", + "python3", + "ruby", + "sed", + "sh", + "ssh", + "vi", + "vim", + "xargs", + "zsh", +} + + +PROFILE_DEFINITIONS = { + "sftp-only": { + "description": "SFTP transport only with no interactive remote command execution.", + "global": { + "logpath": "/var/log/lshell/", + "loglevel": 2, + "security_audit_json": 1, + }, + "default": { + "allowed": ["pwd", "ls", "cd"], + "allowed_shell_escape": [], + "forbidden": list(SAFE_FORBIDDEN_OPERATORS), + "warning_counter": 2, + "strict": 1, + "scp": 0, + "scp_upload": 0, + "scp_download": 0, + "sftp": 1, + "overssh": [], + "sudo_commands": [], + "allowed_file_extensions": [], + "path": [], + "umask": "0077", + }, + "explain": [ + "Use this for file-drop or file-retrieval accounts over SFTP.", + "Interactive shell functionality is intentionally minimal.", + "SCP is disabled to reduce protocol surface area.", + ], + }, + "rsync-backup": { + "description": "Rsync over SSH for backup jobs with strict command controls.", + "global": { + "logpath": "/var/log/lshell/", + "loglevel": 2, + "security_audit_json": 1, + }, + "default": { + "allowed": ["rsync", "pwd"], + "allowed_shell_escape": [], + "forbidden": list(SAFE_FORBIDDEN_OPERATORS), + "warning_counter": 2, + "strict": 1, + "scp": 0, + "scp_upload": 0, + "scp_download": 0, + "sftp": 0, + "overssh": ["rsync"], + "sudo_commands": [], + "allowed_file_extensions": [], + "path": [], + "umask": "0077", + }, + "explain": [ + "Designed for backup endpoints that need rsync and little else.", + "over-SSH commands are narrowed to rsync only.", + "Strict mode enforces warning counter decrement on unknown syntax.", + ], + }, + "deploy-minimal": { + "description": "Minimal deployment account for artifact sync and service rollout.", + "global": { + "logpath": "/var/log/lshell/", + "loglevel": 2, + "security_audit_json": 1, + }, + "default": { + "allowed": [ + "cat", + "cp", + "git", + "ln", + "ls", + "mkdir", + "mv", + "pwd", + "rsync", + "scp", + "tail", + "touch", + ], + "allowed_shell_escape": [], + "forbidden": list(SAFE_FORBIDDEN_OPERATORS), + "warning_counter": 2, + "strict": 1, + "scp": 1, + "scp_upload": 1, + "scp_download": 0, + "sftp": 0, + "overssh": ["rsync", "scp"], + "sudo_commands": [], + "allowed_file_extensions": [".log", ".txt", ".yml", ".yaml"], + "path": [], + "umask": "0027", + }, + "explain": [ + "Use when CI/CD or operators need tightly scoped deployment operations.", + "SCP upload is enabled for controlled artifact delivery; download is disabled.", + "Keep sudo disabled by default and elevate only in explicit user sections.", + ], + }, + "readonly-support": { + "description": "Read-only troubleshooting profile for support and incident triage.", + "global": { + "logpath": "/var/log/lshell/", + "loglevel": 2, + "security_audit_json": 1, + }, + "default": { + "allowed": ["cat", "grep", "head", "ls", "pwd", "tail"], + "allowed_shell_escape": [], + "forbidden": list(SAFE_FORBIDDEN_OPERATORS), + "warning_counter": 2, + "strict": 1, + "scp": 0, + "scp_upload": 0, + "scp_download": 0, + "sftp": 0, + "overssh": [], + "sudo_commands": [], + "allowed_file_extensions": [".conf", ".ini", ".json", ".log", ".txt", ".yaml"], + "path": [], + "umask": "0077", + }, + "explain": [ + "Purpose-built for support users who should inspect but not modify systems.", + "No SSH transfer protocols are enabled.", + "allowed_file_extensions helps prevent access to unexpected file types.", + ], + }, +} + +FIELD_COMMENTS = { + "allowed": "Explicit allow-list only; never use 'all' for hardened baselines.", + "allowed_shell_escape": ( + "Commands here bypass noexec restrictions; keep this list empty unless " + "you have a reviewed exception." + ), + "forbidden": "Deny shell control operators that enable chaining, redirection, or substitution.", + "warning_counter": "Session is terminated after repeated violations. -1 disables termination.", + "strict": "Strict mode treats unknown syntax as policy violations (recommended: 1).", + "scp": "Enable/disable SCP protocol surface.", + "scp_upload": "Allow SCP uploads only when operationally required.", + "scp_download": "Allow SCP downloads only when operationally required.", + "sftp": "Enable/disable SFTP protocol surface.", + "overssh": "Commands allowed for direct SSH command execution; keep as small as possible.", + "sudo_commands": "Keep empty by default. Add only audited, non-interactive commands.", + "allowed_file_extensions": ( + "Optional file-type allow-list; empty means no extension restriction." + ), + "path": "Optional path restrictions; empty list disables path ACL enforcement.", + "umask": "Conservative process umask for files created in shell sessions.", + "logpath": "Centralized log directory used by lshell.", + "loglevel": "Logging verbosity (0-4). Use >=2 for security operations.", + "security_audit_json": "Enable JSON/ECS audit events for SIEM ingestion.", +} + + +def _format_value(value): + if isinstance(value, str): + return value if value.isdigit() else repr(value) + return repr(value) + + +def list_templates(): + """Return template rows for display.""" + rows = [] + for name in sorted(PROFILE_DEFINITIONS): + rows.append((name, PROFILE_DEFINITIONS[name]["description"])) + return rows + + +def get_profile(name): + """Fetch profile data by name or raise ValueError.""" + try: + return PROFILE_DEFINITIONS[name] + except KeyError as exception: + available = ", ".join(sorted(PROFILE_DEFINITIONS)) + raise ValueError( + f"Unknown profile '{name}'. Available profiles: {available}" + ) from exception + + +def validate_profile(profile_name, profile_data): + """Validate profile structure and security constraints.""" + errors = [] + default = profile_data.get("default", {}) + missing = sorted(REQUIRED_PROFILE_KEYS - set(default.keys())) + if missing: + errors.append(f"missing required keys in [default]: {', '.join(missing)}") + + allowed_value = default.get("allowed", []) + if configschema.is_all_literal(allowed_value) or allowed_value == "all": + errors.append("allowed must be an explicit list and cannot be 'all'") + if not isinstance(allowed_value, list) or not allowed_value: + errors.append("allowed must be a non-empty list") + + strict_value = default.get("strict") + if strict_value != 1: + errors.append("strict must be set to 1 for hardened profiles") + + forbidden_value = default.get("forbidden", []) + if not isinstance(forbidden_value, list): + errors.append("forbidden must be a list") + else: + missing_ops = [item for item in SAFE_FORBIDDEN_OPERATORS if item not in forbidden_value] + if missing_ops: + errors.append( + "forbidden list is missing hardened operators: " + ", ".join(missing_ops) + ) + + shell_escape_value = default.get("allowed_shell_escape", []) + if shell_escape_value == "all" or configschema.is_all_literal(shell_escape_value): + errors.append("allowed_shell_escape cannot be 'all'") + if not isinstance(shell_escape_value, list): + errors.append("allowed_shell_escape must be a list") + else: + for raw_command in shell_escape_value: + base_command = raw_command.strip().split(" ", maxsplit=1)[0].lower() + if base_command in UNSAFE_ALLOWED_SHELL_ESCAPE: + errors.append( + "allowed_shell_escape contains unsafe command: " + raw_command + ) + + for key in ("scp", "scp_upload", "scp_download", "sftp", "warning_counter"): + value = default.get(key) + if not isinstance(value, int): + errors.append(f"{key} must be an integer") + + for key in ("overssh", "sudo_commands", "allowed_file_extensions", "path"): + value = default.get(key) + if not isinstance(value, list): + errors.append(f"{key} must be a list") + + if profile_name == "sftp-only": + if default.get("sftp") != 1 or default.get("scp") != 0: + errors.append("sftp-only profile must set sftp=1 and scp=0") + if default.get("overssh"): + errors.append("sftp-only profile must keep overssh empty") + elif profile_name == "rsync-backup": + if "rsync" not in default.get("allowed", []): + errors.append("rsync-backup profile must allow rsync") + if default.get("overssh") != ["rsync"]: + errors.append("rsync-backup profile must set overssh to ['rsync']") + elif profile_name == "readonly-support": + if any(default.get(key) != 0 for key in ("scp", "sftp", "scp_upload", "scp_download")): + errors.append("readonly-support profile must disable SCP and SFTP") + + return errors + + +def run_sanity_checks(rendered_config): + """Sanity-parse generated config and return (ok, details).""" + return run_sanity_checks_for_targets(rendered_config, []) + + +def run_sanity_checks_for_targets(rendered_config, target_sections): + """Sanity-parse generated config and validate required section keys.""" + details = [] + parser = configparser.ConfigParser(interpolation=None) + try: + parser.read_string(rendered_config) + except (configparser.Error, OSError) as exception: + return False, [f"parser: fail ({exception})"] + details.append("parser: pass") + + if not parser.has_section("global"): + details.append("global-section: fail (missing [global])") + return False, details + details.append("global-section: pass") + + if not target_sections: + if not parser.has_section("default"): + details.append("default-section: fail (missing [default])") + return False, details + details.append("default-section: pass") + target_sections = ["default"] + else: + for section in target_sections: + if not parser.has_section(section): + details.append(f"{section}: fail (missing section)") + return False, details + details.append(f"{section}: pass") + + for section in target_sections: + for key in REQUIRED_PROFILE_KEYS: + if not parser.has_option(section, key): + details.append(f"{section}.{key}: fail (missing)") + return False, details + raw_value = parser.get(section, key) + try: + configschema.parse_config_value(raw_value, key) + details.append(f"{section}.{key}: pass") + except ValueError as exception: + details.append(f"{section}.{key}: fail ({exception})") + return False, details + + return True, details + + +def _render_section(lines, section_name, values): + lines.extend(["", f"[{section_name}]"]) + for key, value in values.items(): + comment = FIELD_COMMENTS.get(key) + if comment: + lines.append(f"# {comment}") + lines.append(f"{key:<15} : {_format_value(value)}") + + +def render_profile(profile_name, profile_data, groups=None, users=None): + """Render profile as lshell configuration text with inline comments.""" + groups = groups or [] + users = users or [] + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") + lines = [ + "# Generated by: lshell harden-init", + f"# Profile: {profile_name}", + f"# Generated (UTC): {timestamp}", + "# Review this baseline and scope per-user/group overrides as needed.", + "", + "[global]", + ] + + for key, value in profile_data["global"].items(): + comment = FIELD_COMMENTS.get(key) + if comment: + lines.append(f"# {comment}") + lines.append(f"{key:<15} : {_format_value(value)}") + + if groups or users: + lines.append("# Scoped profile sections generated from CLI target flags.") + lines.append("# No [default] section is emitted to avoid global policy impact.") + for group_name in groups: + _render_section(lines, f"grp:{group_name}", profile_data["default"]) + for user_name in users: + _render_section(lines, f"user:{user_name}", profile_data["default"]) + else: + _render_section(lines, "default", profile_data["default"]) + + lines.append("") + return "\n".join(lines) + + +def explain_profile(profile_name, profile_data): + """Return human-readable hardening rationale for a profile.""" + lines = [f"Profile: {profile_name}", f"Purpose: {profile_data['description']}", "Controls:"] + lines.extend(f"- {item}" for item in profile_data["explain"]) + return "\n".join(lines) + + +def _print_sanity_checks(details, ok): + for item in details: + print(f"sanity: {item}") + print("sanity: overall: pass" if ok else "sanity: overall: fail") + + +def _write_output(path, rendered_config): + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(rendered_config) + + +def _default_output_path(profile_name): + return f"/etc/lshell.d/{profile_name}.conf" + + +def _run_wizard(): + print("lshell harden-init wizard") + print("Select a hardened profile:") + rows = list_templates() + for index, row in enumerate(rows, start=1): + print(f" {index}. {row[0]} - {row[1]}") + + selection = input("Profile name or number: ").strip() + if selection.isdigit(): + value = int(selection) + if value < 1 or value > len(rows): + print("lshell harden-init: invalid profile selection", file=sys.stderr) + return 1 + profile_name = rows[value - 1][0] + else: + profile_name = selection + + try: + profile_data = get_profile(profile_name) + except ValueError as exception: + print(f"lshell harden-init: {exception}", file=sys.stderr) + return 1 + + output_path = input( + f"Output file path [{_default_output_path(profile_name)}]: " + ).strip() or _default_output_path(profile_name) + group_name = input("Optional target group (blank for none): ").strip() + user_name = input("Optional target user (blank for none): ").strip() + groups = [group_name] if group_name else [] + users = [user_name] if user_name else [] + + rendered = render_profile(profile_name, profile_data, groups=groups, users=users) + errors = validate_profile(profile_name, profile_data) + if errors: + for error in errors: + print(f"lshell harden-init: validation failed: {error}", file=sys.stderr) + return 1 + + target_sections = [f"grp:{item}" for item in groups] + [f"user:{item}" for item in users] + ok, details = run_sanity_checks_for_targets(rendered, target_sections) + _print_sanity_checks(details, ok) + if not ok: + return 1 + + _write_output(output_path, rendered) + print(f"lshell harden-init: wrote {output_path}") + return 0 + + +def build_parser(): + """Build argparse parser for harden-init mode.""" + parser = argparse.ArgumentParser( + prog="lshell harden-init", + description="Generate secure-by-default lshell policy baseline profiles.", + ) + parser.add_argument( + "--list-templates", + action="store_true", + help="List available hardened templates.", + ) + parser.add_argument( + "--profile", + choices=sorted(PROFILE_DEFINITIONS), + help="Template/profile to render.", + ) + parser.add_argument( + "--output", + help="Write rendered config to this path.", + ) + parser.add_argument( + "--stdout", + action="store_true", + help="Write rendered config to standard output.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Render config and run sanity checks without writing files.", + ) + parser.add_argument( + "--explain", + action="store_true", + help="Print profile hardening rationale.", + ) + parser.add_argument( + "--group", + action="append", + default=[], + help="Generate a scoped [grp:] section (repeatable).", + ) + parser.add_argument( + "--user", + action="append", + default=[], + help="Generate a scoped [user:] section (repeatable).", + ) + return parser + + +def _validate_target_names(label, values): + pattern = re.compile(r"^[A-Za-z0-9_.-]+$") + errors = [] + for item in values: + if not pattern.match(item): + errors.append( + f"invalid {label} name '{item}'. Use letters, digits, underscore, dot, or dash." + ) + return errors + + +def main(argv=None): + """Entry point for `lshell harden-init`.""" + parser = build_parser() + args = parser.parse_args(argv) + + if args.list_templates: + if args.group or args.user: + parser.error("--group/--user cannot be used with --list-templates") + for name, description in list_templates(): + print(f"{name:<18} {description}") + return 0 + + if not args.profile: + if sys.stdin.isatty() and sys.stdout.isatty(): + return _run_wizard() + parser.error("missing --profile. Use --list-templates to discover options.") + + if args.output and args.stdout: + parser.error("--output and --stdout cannot be used together") + if not args.output and not args.stdout and not args.dry_run: + args.output = _default_output_path(args.profile) + + profile_name = args.profile + profile_data = get_profile(profile_name) + target_errors = [] + target_errors.extend(_validate_target_names("group", args.group)) + target_errors.extend(_validate_target_names("user", args.user)) + if target_errors: + for error in target_errors: + print(f"lshell harden-init: {error}", file=sys.stderr) + return 1 + + validation_errors = validate_profile(profile_name, profile_data) + if validation_errors: + for error in validation_errors: + print(f"lshell harden-init: validation failed: {error}", file=sys.stderr) + return 1 + + rendered = render_profile( + profile_name, profile_data, groups=args.group, users=args.user + ) + target_sections = [f"grp:{item}" for item in args.group] + [ + f"user:{item}" for item in args.user + ] + ok, details = run_sanity_checks_for_targets(rendered, target_sections) + if args.dry_run: + print(rendered.rstrip()) + _print_sanity_checks(details, ok) + if args.explain: + print("") + print(explain_profile(profile_name, profile_data)) + return 0 if ok else 1 + + if not ok: + _print_sanity_checks(details, ok) + return 1 + + if args.explain: + print(explain_profile(profile_name, profile_data)) + + if args.stdout: + print(rendered.rstrip()) + return 0 + + try: + _write_output(args.output, rendered) + except OSError as exception: + print(f"lshell harden-init: {exception}", file=sys.stderr) + return 1 + + print(f"lshell harden-init: wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lshell/variables.py b/lshell/variables.py index c1fc73d..14a7bd8 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -48,6 +48,16 @@ --mode : Log directory mode (default 2770) --set-shell-user : Assign lshell as login shell (repeatable) --add-group-user : Add user to log-writer group (repeatable) + +Usage: lshell harden-init [OPTIONS] + --list-templates : List available hardened templates + --profile : Template name (sftp-only, rsync-backup, deploy-minimal, readonly-support) + --group : Add scoped [grp:] section (repeatable) + --user : Add scoped [user:] section (repeatable) + --output : Write rendered config file to path (default /etc/lshell.d/.conf) + --stdout : Print rendered config to stdout + --dry-run : Render and run sanity checks without writing + --explain : Print profile hardening rationale """ # Intro Text diff --git a/man/lshell.1 b/man/lshell.1 index e5471d4..0416d66 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -12,6 +12,9 @@ lshell \- Limited Shell .br .B lshell policy-show [\fIOPTIONS\fR] +.br +.B lshell harden-init +[\fIOPTIONS\fR] .SH DESCRIPTION \fBlshell\fR provides a limited shell configured per user via a configuration file. @@ -59,6 +62,35 @@ command to evaluate against final policy .B \--json print diagnostics as JSON .RE +.TP +.B harden-init +Generate a hardened baseline configuration profile. Use with: +.RS +.TP +.B \--list-templates +list available hardened templates +.TP +.B \--profile \fI\fR +template name (\fBsftp-only\fR, \fBrsync-backup\fR, \fBdeploy-minimal\fR, \fBreadonly-support\fR) +.TP +.B \--group \fI\fR +add scoped \fB[grp:]\fR section (repeatable) +.TP +.B \--user \fI\fR +add scoped \fB[user:]\fR section (repeatable) +.TP +.B \--output \fI\fR +write rendered config to file (default: /etc/lshell.d/.conf) +.TP +.B \--stdout +print rendered config to stdout +.TP +.B \--dry-run +render and run sanity checks without writing files +.TP +.B \--explain +print profile hardening rationale +.RE .SH CONFIGURATION You can configure lshell through its configuration file: @@ -565,6 +597,33 @@ Runs lshell with a private session umask; new files default to 600 and directories to 700. .RE .TP +.B $ lshell harden-init --profile sftp-only --group sftpusers --user alice --output /etc/lshell.d/sftp-only.conf +.RS +Generate a hardened scoped include file for a specific group and user directly from CLI flags: +.sp +.nf +.ft 3 + +# /etc/lshell.d/sftp-only.conf +[grp:sftpusers] +allowed : ['pwd','ls','cd'] +allowed_shell_escape : [] +forbidden : [';','&','|','`','>','<','$(','${'] +warning_counter : 2 +strict : 1 +scp : 0 +scp_upload : 0 +scp_download : 0 +sftp : 1 +overssh : [] + +[user:alice] +allowed : ['pwd','ls','cd'] +warning_counter : 1 +.ft +.fi +.RE +.TP .B $ ./test_script.lsh .RS If you include lshell in a script with the \fBshebang (e.g. #!/usr/bin/lshell)\fR and use the \fB`.lsh` extension\fR: diff --git a/test/test_cli_unit.py b/test/test_cli_unit.py index d106525..f572384 100644 --- a/test/test_cli_unit.py +++ b/test/test_cli_unit.py @@ -72,3 +72,13 @@ def test_main_routes_setup_system_subcommand(self): cli.main() mock_setup_main.assert_called_once_with(["--group", "ops"]) mock_exit.assert_called_once_with(7) + + def test_main_routes_harden_init_subcommand(self): + """Dispatch harden-init subcommand to dedicated handler.""" + with patch("lshell.cli.harden_init.main", return_value=3) as mock_harden_main: + with patch("lshell.cli.sys.argv", ["lshell", "harden-init", "--list-templates"]): + with patch("lshell.cli.sys.exit", side_effect=SystemExit) as mock_exit: + with self.assertRaises(SystemExit): + cli.main() + mock_harden_main.assert_called_once_with(["--list-templates"]) + mock_exit.assert_called_once_with(3) diff --git a/test/test_hardeninit_functional.py b/test/test_hardeninit_functional.py new file mode 100644 index 0000000..c9906d5 --- /dev/null +++ b/test/test_hardeninit_functional.py @@ -0,0 +1,63 @@ +"""Functional tests for the lshell harden-init CLI mode.""" + +import os +import subprocess +import tempfile +import unittest + +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +LSHELL = f"{TOPDIR}/bin/lshell" + + +class TestHardenInitFunctional(unittest.TestCase): + """Exercise harden-init end-to-end through the top-level CLI.""" + + def test_harden_init_writes_config_file(self): + """Generate a config file from a hardened template profile.""" + with tempfile.TemporaryDirectory(prefix="lshell-harden-init-") as tempdir: + output_path = os.path.join(tempdir, "generated.conf") + result = subprocess.run( + [LSHELL, "harden-init", "--profile", "sftp-only", "--output", output_path], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + self.assertTrue(os.path.isfile(output_path)) + with open(output_path, "r", encoding="utf-8") as handle: + rendered = handle.read() + self.assertIn("[default]", rendered) + self.assertIn("sftp : 1", rendered) + self.assertIn("strict : 1", rendered) + + def test_harden_init_writes_scoped_group_and_user_sections(self): + """Generate include file targeting one group and one user.""" + with tempfile.TemporaryDirectory(prefix="lshell-harden-init-") as tempdir: + output_path = os.path.join(tempdir, "scoped.conf") + result = subprocess.run( + [ + LSHELL, + "harden-init", + "--profile", + "sftp-only", + "--group", + "sftpusers", + "--user", + "alice", + "--output", + output_path, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + with open(output_path, "r", encoding="utf-8") as handle: + rendered = handle.read() + self.assertIn("[grp:sftpusers]", rendered) + self.assertIn("[user:alice]", rendered) + self.assertNotIn("\n[default]\n", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_hardeninit_unit.py b/test/test_hardeninit_unit.py new file mode 100644 index 0000000..bce677e --- /dev/null +++ b/test/test_hardeninit_unit.py @@ -0,0 +1,154 @@ +"""Unit tests for lshell harden-init profile generator.""" + +import contextlib +import io +import os +import tempfile +import unittest +from unittest.mock import patch + +from lshell import hardeninit + + +class TestHardenInit(unittest.TestCase): + """Validate template rendering, validation, and CLI flags.""" + + def test_list_templates_contains_required_profiles(self): + """List mode includes all shipped hardened templates.""" + rows = dict(hardeninit.list_templates()) + self.assertIn("sftp-only", rows) + self.assertIn("rsync-backup", rows) + self.assertIn("deploy-minimal", rows) + self.assertIn("readonly-support", rows) + + def test_render_profile_includes_inline_security_comments(self): + """Rendered output includes comments that explain key controls.""" + profile = hardeninit.get_profile("readonly-support") + rendered = hardeninit.render_profile("readonly-support", profile) + self.assertIn("# Explicit allow-list only; never use 'all'", rendered) + self.assertIn("strict : 1", rendered) + self.assertIn("forbidden : [';', '&', '|', '`', '>', '<', '$(', '${']", rendered) + + def test_validate_profile_rejects_unsafe_shell_escape(self): + """Validation fails when allowed_shell_escape contains risky commands.""" + profile = hardeninit.get_profile("deploy-minimal") + profile_copy = {"global": dict(profile["global"]), "default": dict(profile["default"])} + profile_copy["default"]["allowed_shell_escape"] = ["vim"] + errors = hardeninit.validate_profile("deploy-minimal", profile_copy) + self.assertTrue(any("unsafe command" in item for item in errors)) + + def test_validate_profile_rejects_missing_required_key(self): + """Validation fails when a required hardened key is absent.""" + profile = hardeninit.get_profile("sftp-only") + profile_copy = {"global": dict(profile["global"]), "default": dict(profile["default"])} + profile_copy["default"].pop("strict") + errors = hardeninit.validate_profile("sftp-only", profile_copy) + self.assertTrue(any("missing required keys" in item for item in errors)) + + def test_run_sanity_checks_pass_for_valid_template(self): + """Sanity checks succeed for stock profile output.""" + profile = hardeninit.get_profile("rsync-backup") + rendered = hardeninit.render_profile("rsync-backup", profile) + ok, details = hardeninit.run_sanity_checks(rendered) + self.assertTrue(ok) + self.assertTrue(any("overall" not in item for item in details)) + + def test_main_list_templates_flag(self): + """--list-templates returns success and prints template names.""" + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = hardeninit.main(["--list-templates"]) + self.assertEqual(code, 0) + output = stdout.getvalue() + self.assertIn("sftp-only", output) + self.assertIn("rsync-backup", output) + + def test_main_stdout_flag(self): + """--stdout renders config to standard output.""" + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = hardeninit.main(["--profile", "sftp-only", "--stdout"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("[default]", rendered) + self.assertIn("sftp : 1", rendered) + + def test_main_stdout_group_and_user_flags_render_scoped_sections(self): + """--group/--user render [grp:*]/[user:*] sections and skip [default].""" + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = hardeninit.main( + [ + "--profile", + "sftp-only", + "--group", + "sftpusers", + "--user", + "alice", + "--stdout", + ] + ) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("[grp:sftpusers]", rendered) + self.assertIn("[user:alice]", rendered) + self.assertNotIn("\n[default]\n", rendered) + + def test_main_dry_run_flag_outputs_sanity(self): + """--dry-run prints rendered config plus sanity pass/fail lines.""" + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = hardeninit.main(["--profile", "rsync-backup", "--dry-run"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("sanity: parser: pass", rendered) + self.assertIn("sanity: overall: pass", rendered) + + def test_main_explain_flag(self): + """--explain prints rationale alongside requested output mode.""" + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = hardeninit.main( + ["--profile", "readonly-support", "--stdout", "--explain"] + ) + self.assertEqual(code, 0) + output = stdout.getvalue() + self.assertIn("Profile: readonly-support", output) + self.assertIn("Purpose:", output) + + def test_main_output_writes_file(self): + """--output writes rendered configuration to the target file.""" + with tempfile.TemporaryDirectory(prefix="lshell-hardeninit-") as tempdir: + output_file = os.path.join(tempdir, "lshell.conf") + code = hardeninit.main( + ["--profile", "deploy-minimal", "--output", output_file] + ) + self.assertEqual(code, 0) + self.assertTrue(os.path.exists(output_file)) + with open(output_file, "r", encoding="utf-8") as handle: + data = handle.read() + self.assertIn("[global]", data) + self.assertIn("strict : 1", data) + + def test_main_defaults_output_to_etc_lshell_d_profile_conf(self): + """Without output/stdout/dry-run, write to /etc/lshell.d/.conf.""" + with patch("lshell.hardeninit._write_output") as write_output: + code = hardeninit.main(["--profile", "sftp-only"]) + self.assertEqual(code, 0) + write_output.assert_called_once() + output_path = write_output.call_args[0][0] + self.assertEqual(output_path, "/etc/lshell.d/sftp-only.conf") + + def test_main_rejects_invalid_group_name(self): + """Invalid section target names are rejected with clear errors.""" + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = hardeninit.main( + ["--profile", "sftp-only", "--group", "bad/name", "--stdout"] + ) + self.assertEqual(code, 1) + self.assertIn("invalid group name", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() From de5ee5155e21d5773425eef3227d2fcc07f7316f Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 20:44:54 -0400 Subject: [PATCH 14/29] Add bash completion support for lshell admins --- MANIFEST.in | 1 + debian/control | 2 +- debian/lshell.dirs | 1 + debian/scripts/debian-deb-run.sh | 9 +++++++ etc/bash_completion.d/lshell | 45 ++++++++++++++++++++++++++++++++ pyproject.toml | 1 + rpm/lshell.spec | 2 ++ rpm/scripts/fedora-rpm-run.sh | 9 +++++++ 8 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 etc/bash_completion.d/lshell diff --git a/MANIFEST.in b/MANIFEST.in index be2228c..5836889 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,5 +3,6 @@ include README.md include CHANGELOG.md include etc/lshell.conf include etc/logrotate.d/lshell +include etc/bash_completion.d/lshell include man/lshell.1 include MANIFEST.in diff --git a/debian/control b/debian/control index 5ce8dc5..3458301 100644 --- a/debian/control +++ b/debian/control @@ -10,7 +10,7 @@ Homepage: https://github.com/ghantoos/lshell Package: lshell Architecture: all Homepage: http://lshell.ghantoos.org/ -Depends: ${misc:Depends}, ${python3:Depends}, adduser +Depends: ${misc:Depends}, ${python3:Depends}, adduser, bash-completion Description: restricts a user's shell environment to limited sets of commands lshell is a 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 diff --git a/debian/lshell.dirs b/debian/lshell.dirs index e2f9f75..1ac4d42 100644 --- a/debian/lshell.dirs +++ b/debian/lshell.dirs @@ -1 +1,2 @@ var/log/lshell/ +usr/share/bash-completion/completions diff --git a/debian/scripts/debian-deb-run.sh b/debian/scripts/debian-deb-run.sh index 77704d3..e32b7b0 100644 --- a/debian/scripts/debian-deb-run.sh +++ b/debian/scripts/debian-deb-run.sh @@ -8,6 +8,15 @@ DEB_FILE="$(ls -1t /app/build/deb/lshell_*_all.deb | head -n1)" apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y "${DEB_FILE}" +DEBIAN_FRONTEND=noninteractive apt-get install -y bash-completion + +if ! grep -q "bash_completion/bash_completion" /root/.bashrc 2>/dev/null; then + cat >>/root/.bashrc <<'EOF' +if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion +fi +EOF +fi install -m 0644 /app/debian/lshell.deb-test.conf /etc/lshell.deb-test.conf diff --git a/etc/bash_completion.d/lshell b/etc/bash_completion.d/lshell new file mode 100644 index 0000000..d4cb46b --- /dev/null +++ b/etc/bash_completion.d/lshell @@ -0,0 +1,45 @@ +# Bash completion for lshell + +_lshell_completion() { + local cur prev subcommand opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + subcommand="${COMP_WORDS[1]}" + + case "$prev" in + --config|--log|--output|--log-dir|--shell-path) + COMPREPLY=( $(compgen -f -- "$cur") ) + return 0 + ;; + --profile) + COMPREPLY=( $(compgen -W "sftp-only rsync-backup deploy-minimal readonly-support" -- "$cur") ) + return 0 + ;; + esac + + if [ "$COMP_CWORD" -eq 1 ]; then + COMPREPLY=( $(compgen -W "policy-show setup-system harden-init --config --log --help --version" -- "$cur") ) + return 0 + fi + + case "$subcommand" in + policy-show) + opts="--config --user --group --json --command --help" + ;; + setup-system) + opts="--group --log-dir --owner --mode --shell-path --skip-shell-registration --set-shell-user --add-group-user --help" + ;; + harden-init) + opts="--list-templates --profile --group --user --output --stdout --dry-run --explain --help" + ;; + *) + opts="--config --log --help --version" + ;; + esac + + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + return 0 +} + +complete -F _lshell_completion lshell diff --git a/pyproject.toml b/pyproject.toml index 1b1811a..7238017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,5 +48,6 @@ version = { attr = "lshell.variables.__version__" } [tool.setuptools.data-files] "etc" = ["etc/lshell.conf"] "etc/logrotate.d" = ["etc/logrotate.d/lshell"] +"share/bash-completion/completions" = ["etc/bash_completion.d/lshell"] "share/doc/lshell" = ["README.md", "COPYING", "CHANGELOG.md", "SECURITY.md"] "share/man/man1" = ["man/lshell.1"] diff --git a/rpm/lshell.spec b/rpm/lshell.spec index 4279977..bcad6b6 100644 --- a/rpm/lshell.spec +++ b/rpm/lshell.spec @@ -16,6 +16,7 @@ BuildRequires: python3-setuptools BuildRequires: pyproject-rpm-macros Requires: python3 Requires: python3-pyparsing >= 3.0.0 +Requires: bash-completion %description lshell is a shell coded in Python that lets you restrict a user's environment @@ -48,6 +49,7 @@ rm -rf %{buildroot} %{_bindir}/lshell %config(noreplace) %{_prefix}/etc/lshell.conf %config(noreplace) %{_prefix}/etc/logrotate.d/lshell +%{_datadir}/bash-completion/completions/lshell %{python3_sitelib}/lshell/ %{python3_sitelib}/limited_shell-*.dist-info %{_mandir}/man1/lshell.1* diff --git a/rpm/scripts/fedora-rpm-run.sh b/rpm/scripts/fedora-rpm-run.sh index 60c5b37..5e13d3c 100644 --- a/rpm/scripts/fedora-rpm-run.sh +++ b/rpm/scripts/fedora-rpm-run.sh @@ -7,6 +7,15 @@ MODE="${MODE#mode=}" RPM_FILE="$(ls -1t /app/build/rpm/RPMS/noarch/lshell-*.rpm | head -n1)" dnf install -y "${RPM_FILE}" +dnf install -y bash-completion + +if ! grep -q "bash_completion" /root/.bashrc 2>/dev/null; then + cat >>/root/.bashrc <<'EOF' +if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion +fi +EOF +fi if [ -f /usr/etc/lshell.conf ] && [ ! -f /etc/lshell.conf ]; then cp -a /usr/etc/lshell.conf /etc/lshell.conf From 54e05dfed13fb5f9e36fe2bcfced26666d6a4e1b Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 21:38:08 -0400 Subject: [PATCH 15/29] containment: enforce max_sessions_per_user session caps --- CHANGELOG.md | 3 + README.md | 13 ++ etc/lshell.conf | 4 + lshell/audit.py | 27 ++- lshell/checkconfig.py | 8 + lshell/cli.py | 22 +++ lshell/configschema.py | 1 + lshell/containment.py | 273 ++++++++++++++++++++++++++++ lshell/variables.py | 1 + man/lshell.1 | 4 + test/test_containment_functional.py | 68 +++++++ test/test_containment_unit.py | 92 ++++++++++ 12 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 lshell/containment.py create mode 100644 test/test_containment_functional.py create mode 100644 test/test_containment_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7040fc2..23bda65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) - Changed `harden-init` default output path to `/etc/lshell.d/.conf`. - Enabled `include_dir : /etc/lshell.d/*.conf` in the default `/etc/lshell.conf` template. +### v0.11.1rc3 18/03/2026 +- Added runtime containment `max_sessions_per_user` with lock-protected per-user session accounting and startup enforcement. + ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. - Added policy diagnostics and built-ins: `policy-show`, `policy-path`, and `policy-sudo`. diff --git a/README.md b/README.md index 9a91008..c803a3f 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Key settings to review: - `messages` - `warning_counter`, `strict` - `umask` +- runtime containment: `max_sessions_per_user` CLI overrides are supported, for example: @@ -148,6 +149,18 @@ CLI overrides are supported, for example: lshell --config /path/to/lshell.conf --log /var/log/lshell --umask 0077 ``` +### Runtime containment limits + +Runtime limits are optional and disabled by default when set to `0`. + +```ini +max_sessions_per_user : 2 +``` + +Operational notes: + +- `max_sessions_per_user` is tracked with lock-protected session records; stale entries are cleaned automatically. + ### Best practices - Prefer an explicit `allowed` allow-list instead of `'all'`. diff --git a/etc/lshell.conf b/etc/lshell.conf index fec7e88..bad3951 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -132,6 +132,10 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" ## a value in seconds for the session timer #timer : 5 +## Runtime containment limits (disabled by default when set to 0): +## Max concurrent lshell sessions for this user. +#max_sessions_per_user : 0 + ## list of paths to restrict where the user can operate ## warning: commands like vi and less can bypass this restriction #path : ['/etc','/var/log','/var/lib'] diff --git a/lshell/audit.py b/lshell/audit.py index 48e849c..d0146a5 100644 --- a/lshell/audit.py +++ b/lshell/audit.py @@ -98,6 +98,27 @@ def pop_decision_reason(conf, default="policy evaluation failed"): def log_command_event(conf, command, allowed, reason, level=None): """Emit one ECS-aligned command authorization event.""" + log_security_event( + conf, + action="command_authorization", + allowed=allowed, + reason=reason, + command=command, + level=level, + message="lshell command authorization decision", + ) + + +def log_security_event( + conf, + action, + allowed, + reason, + command="", + level=None, + message="lshell security decision", +): + """Emit one ECS-aligned runtime security event.""" if not enabled(conf): return @@ -106,7 +127,7 @@ def log_command_event(conf, command, allowed, reason, level=None): log_level = getattr(logging, log_method.upper(), logging.INFO) logger.log( log_level, - "lshell command authorization decision", + message, extra={ "session_id": str(conf.get("session_id", "")), "source_ip": _source_ip(), @@ -114,10 +135,10 @@ def log_command_event(conf, command, allowed, reason, level=None): "event_kind": "event", "event_category": ["authentication", "process"], "event_type": ["access"], - "event_action": "command_authorization", + "event_action": str(action), "event_outcome": "success" if allowed else "failure", "event_reason": str(reason), - "process_command_line": str(command), + "process_command_line": str(command or ""), "lshell_security_allowed": bool(allowed), }, ) diff --git a/lshell/checkconfig.py b/lshell/checkconfig.py index e082fbd..d42402a 100644 --- a/lshell/checkconfig.py +++ b/lshell/checkconfig.py @@ -20,6 +20,7 @@ from lshell import builtincmd from lshell import configschema from lshell import audit +from lshell import containment class CheckConfig: @@ -568,6 +569,7 @@ def get_config_user(self): "policy_commands", "quiet", "security_audit_json", + "max_sessions_per_user", ]: try: if len(self.conf_raw[item]) == 0: @@ -610,6 +612,12 @@ def get_config_user(self): self.log.critical("lshell: config: 'prompt_short' must be 0, 1, or 2") sys.exit(1) + try: + containment.validate_runtime_config(self.conf) + except ValueError as exception: + self.log.critical(f"lshell: config: {exception}") + sys.exit(1) + self.conf["username"] = self.user if "umask" in self.conf_raw: diff --git a/lshell/cli.py b/lshell/cli.py index 5efba00..f2efabf 100644 --- a/lshell/cli.py +++ b/lshell/cli.py @@ -9,6 +9,8 @@ from lshell import policy as policy_mode from lshell import systemsetup as system_setup from lshell import hardeninit as harden_init +from lshell import audit +from lshell import containment from lshell.checkconfig import CheckConfig from lshell.shellcmd import LshellTimeOut, ShellCmd @@ -40,6 +42,24 @@ def main(): userconf = CheckConfig(args).returnconf() userconf["session_id"] = os.environ.get("LSHELL_SESSION_ID", uuid.uuid4().hex) os.environ["LSHELL_SESSION_ID"] = userconf["session_id"] + session_accountant = containment.SessionAccountant(userconf) + try: + session_accountant.acquire() + except containment.ContainmentViolation as exception: + logger = userconf.get("logpath") + if logger: + logger.critical(exception.log_message) + audit.log_security_event( + userconf, + action="session_start", + allowed=False, + reason=exception.reason_code, + command="lshell startup", + level="warning", + message="lshell runtime containment decision", + ) + sys.stderr.write(exception.user_message + "\n") + sys.exit(1) def disable_ctrl_z(_signum, _frame): return None @@ -63,3 +83,5 @@ def disable_ctrl_z(_signum, _frame): except LshellTimeOut: userconf["logpath"].error("Timer expired") sys.stdout.write("\nTime is up.\n") + finally: + session_accountant.release() diff --git a/lshell/configschema.py b/lshell/configschema.py index 84e4a86..86fa707 100644 --- a/lshell/configschema.py +++ b/lshell/configschema.py @@ -42,6 +42,7 @@ "quiet", "loglevel", "security_audit_json", + "max_sessions_per_user", } DICT_VALUE_KEYS = {"aliases", "env_vars", "messages"} STRING_VALUE_KEYS = { diff --git a/lshell/containment.py b/lshell/containment.py new file mode 100644 index 0000000..d8925b3 --- /dev/null +++ b/lshell/containment.py @@ -0,0 +1,273 @@ +"""Runtime containment helpers for per-session guardrails.""" + +import atexit +import contextlib +import errno +import json +import os +import signal +import tempfile +import uuid +from dataclasses import dataclass + +try: # POSIX-only file lock support. + import fcntl +except ImportError: # pragma: no cover - non-POSIX fallback. + fcntl = None + + +RUNTIME_LIMIT_INT_KEYS = ("max_sessions_per_user",) + +_DEFAULT_SESSION_STATE_ROOT = os.path.join(tempfile.gettempdir(), "lshell", "sessions") + + +@dataclass(frozen=True) +class RuntimeLimits: + """Resolved runtime limits for one shell session.""" + + max_sessions_per_user: int = 0 + + +class ContainmentViolation(Exception): + """Raised when a containment guardrail denies an action.""" + + def __init__(self, reason_code, user_message, log_message): + super().__init__(log_message) + self.reason_code = reason_code + self.user_message = user_message + self.log_message = log_message + + +def reason_with_details(reason_code, **details): + """Return a machine-readable reason with optional k=v details.""" + if not details: + return reason_code + ordered = ",".join(f"{key}={details[key]}" for key in sorted(details)) + return f"{reason_code} ({ordered})" + + +def _as_non_negative_int(conf, key): + value = conf.get(key, 0) + try: + parsed = int(value) + except (TypeError, ValueError) as exception: + raise ValueError(f"'{key}' must be an integer") from exception + if parsed < 0: + raise ValueError(f"'{key}' must be a non-negative integer") + return parsed + + +def validate_runtime_config(conf): + """Validate runtime containment keys from parsed config.""" + for key in RUNTIME_LIMIT_INT_KEYS: + _as_non_negative_int(conf, key) + + +def get_runtime_limits(conf): + """Return parsed runtime limits with disabled defaults.""" + return RuntimeLimits( + max_sessions_per_user=_as_non_negative_int(conf, "max_sessions_per_user"), + ) + + +def _session_state_root(): + configured = os.environ.get("LSHELL_SESSION_DIR") + if configured: + return configured + return _DEFAULT_SESSION_STATE_ROOT + + +def _sanitize_component(value): + safe = [] + for char in str(value or ""): + if char.isalnum() or char in {".", "_", "-"}: + safe.append(char) + else: + safe.append("_") + sanitized = "".join(safe).strip("._") + return sanitized or "unknown" + + +def _read_proc_start_time(pid): + """Return process start time ticks from /proc when available.""" + stat_path = f"/proc/{pid}/stat" + try: + with open(stat_path, "r", encoding="utf-8") as handle: + fields = handle.read().split() + except OSError: + return None + + if len(fields) < 22: + return None + return fields[21] + + +def _is_pid_alive(pid): + try: + os.kill(pid, 0) + except OSError as exception: + if exception.errno == errno.ESRCH: + return False + if exception.errno == errno.EPERM: + return True + return False + return True + + +def _matches_running_process(record): + """Return True when record points to a still-running PID.""" + try: + pid = int(record.get("pid", 0)) + except (TypeError, ValueError): + return False + + if pid <= 0 or not _is_pid_alive(pid): + return False + + expected_start = record.get("pid_start") + if not expected_start: + return True + + current_start = _read_proc_start_time(pid) + if current_start is None: + return True + + return str(expected_start) == str(current_start) + + +class SessionAccountant: + """Track active shell sessions per user using lock-protected files.""" + + def __init__(self, conf): + self.conf = conf + self.limits = get_runtime_limits(conf) + self.username = str(conf.get("username") or os.environ.get("USER") or "unknown") + self.session_id = str(conf.get("session_id") or uuid.uuid4().hex) + self.state_root = _session_state_root() + self.user_dir = os.path.join(self.state_root, _sanitize_component(self.username)) + self.session_file = os.path.join( + self.user_dir, + f"session-{_sanitize_component(self.session_id)}-{os.getpid()}.json", + ) + self._registered = False + self._previous_signal_handlers = {} + + @contextlib.contextmanager + def _user_lock(self): + os.makedirs(self.user_dir, mode=0o700, exist_ok=True) + lock_path = os.path.join(self.user_dir, ".lock") + lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + if fcntl is not None: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + yield + finally: + if fcntl is not None: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + def _read_session_record(self, path): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError, ValueError): + return None + + def _write_session_record(self): + payload = { + "pid": os.getpid(), + "pid_start": _read_proc_start_time(os.getpid()), + "session_id": self.session_id, + "username": self.username, + } + with open(self.session_file, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True) + + def _active_sessions_locked(self): + active = [] + for entry in os.listdir(self.user_dir): + if not (entry.startswith("session-") and entry.endswith(".json")): + continue + path = os.path.join(self.user_dir, entry) + record = self._read_session_record(path) + if not record or not _matches_running_process(record): + with contextlib.suppress(OSError): + os.remove(path) + continue + active.append(path) + return active + + def acquire(self): + """Register this session and enforce max concurrent sessions per user.""" + max_sessions = self.limits.max_sessions_per_user + if max_sessions <= 0: + return + + with self._user_lock(): + active_sessions = self._active_sessions_locked() + if len(active_sessions) >= max_sessions: + reason = reason_with_details( + "runtime_limit.max_sessions_per_user_exceeded", + active=len(active_sessions), + limit=max_sessions, + user=self.username, + ) + raise ContainmentViolation( + reason_code=reason, + user_message=( + "lshell: session denied: " + f"max_sessions_per_user={max_sessions} reached" + ), + log_message=( + "lshell: runtime containment denied session start: " + f"user={self.username}, active={len(active_sessions)}, " + f"limit={max_sessions}" + ), + ) + self._write_session_record() + + if not self._registered: + atexit.register(self.release) + self._install_signal_handlers() + self._registered = True + + def release(self): + """Remove this session from accounting storage.""" + if self.limits.max_sessions_per_user <= 0: + return + + if not self.session_file: + return + + with contextlib.suppress(OSError): + with self._user_lock(): + with contextlib.suppress(OSError): + os.remove(self.session_file) + + self._restore_signal_handlers() + + def _install_signal_handlers(self): + for sig_name in ("SIGHUP", "SIGTERM", "SIGQUIT"): + signum = getattr(signal, sig_name, None) + if signum is None: + continue + previous = signal.getsignal(signum) + self._previous_signal_handlers[signum] = previous + signal.signal(signum, self._signal_cleanup_handler) + + def _restore_signal_handlers(self): + for signum, previous in self._previous_signal_handlers.items(): + with contextlib.suppress(OSError, ValueError): + signal.signal(signum, previous) + self._previous_signal_handlers.clear() + + def _signal_cleanup_handler(self, signum, frame): + self.release() + previous = self._previous_signal_handlers.get(signum, signal.SIG_DFL) + if callable(previous): + previous(signum, frame) + return + if previous == signal.SIG_IGN: + return + signal.signal(signum, signal.SIG_DFL) + os.kill(os.getpid(), signum) diff --git a/lshell/variables.py b/lshell/variables.py index 14a7bd8..e95d637 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -109,6 +109,7 @@ "policy_commands=", "include_dir=", "security_audit_json=", + "max_sessions_per_user=", ] FORBIDDEN_ENVIRON = ( diff --git a/man/lshell.1 b/man/lshell.1 index 0416d66..5149b66 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -444,6 +444,10 @@ different user than the default root. .I timer a value in seconds for the session timer .TP +.I max_sessions_per_user +maximum concurrent lshell sessions for the same user. +Set to \fB0\fR to disable this limit (default). +.TP .I umask set process umask for the lshell session. Value must be octal (0000 to 0777), for example \fB0002\fR. diff --git a/test/test_containment_functional.py b/test/test_containment_functional.py new file mode 100644 index 0000000..629ecde --- /dev/null +++ b/test/test_containment_functional.py @@ -0,0 +1,68 @@ +"""Functional tests for runtime containment limits.""" + +import os +import tempfile +import unittest +from getpass import getuser + +import pexpect + + +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" +LSHELL = f"{TOPDIR}/bin/lshell" +USER = getuser() +PROMPT = f"{USER}:~\\$" + + +class TestRuntimeContainmentFunctional(unittest.TestCase): + """Validate max_sessions_per_user in a real shell session.""" + + def _spawn_shell(self, extra_args, env=None, timeout=15): + command = f"{LSHELL} --config {CONFIG} {extra_args}" + return pexpect.spawn(command, encoding="utf-8", timeout=timeout, env=env) + + def _env_with(self, **extra): + env = os.environ.copy() + env.update(extra) + return env + + def _safe_exit(self, child): + if not child.isalive(): + return + child.sendline("exit") + try: + child.expect(pexpect.EOF, timeout=3) + return + except pexpect.TIMEOUT: + pass + + if child.isalive(): + child.sendline("exit") + child.expect(pexpect.EOF, timeout=5) + + def test_max_sessions_per_user_enforced(self): + """Deny second concurrent shell when max_sessions_per_user is exceeded.""" + with tempfile.TemporaryDirectory(prefix="lshell-session-func-") as session_dir: + env = self._env_with(LSHELL_SESSION_DIR=session_dir) + first = self._spawn_shell("--max_sessions_per_user 1 --strict 1", env=env) + second = None + try: + first.expect(PROMPT) + + second = self._spawn_shell( + "--max_sessions_per_user 1 --strict 1", + env=env, + ) + second.expect(pexpect.EOF) + output = second.before + self.assertIn("session denied", output) + self.assertIn("max_sessions_per_user=1", output) + finally: + if second is not None: + second.close(force=True) + self._safe_exit(first) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_containment_unit.py b/test/test_containment_unit.py new file mode 100644 index 0000000..db8909d --- /dev/null +++ b/test/test_containment_unit.py @@ -0,0 +1,92 @@ +"""Unit tests for runtime containment helpers.""" + +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from lshell import containment +from lshell.checkconfig import CheckConfig + + +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" + + +class TestContainmentConfigValidation(unittest.TestCase): + """Validate max_sessions_per_user parsing and bounds.""" + + base_args = [f"--config={CONFIG}", "--quiet=1"] + + def test_max_sessions_per_user_defaults_disabled(self): + """max_sessions_per_user should default to 0.""" + conf = CheckConfig(self.base_args).returnconf() + self.assertEqual(conf["max_sessions_per_user"], 0) + + def test_max_sessions_per_user_rejects_negative_values(self): + """max_sessions_per_user must be non-negative.""" + with self.assertRaises(SystemExit): + CheckConfig(self.base_args + ["--max_sessions_per_user=-1"]).returnconf() + + +class TestSessionAccounting(unittest.TestCase): + """Exercise session accounting limits and stale cleanup.""" + + def _session_conf(self, session_id): + return { + "username": "testuser", + "session_id": session_id, + "max_sessions_per_user": 1, + } + + def test_session_accounting_enforces_cap(self): + """Second session should be denied when cap is reached.""" + with tempfile.TemporaryDirectory(prefix="lshell-session-unit-") as session_dir: + with patch.dict( + os.environ, + {"LSHELL_SESSION_DIR": session_dir}, + clear=False, + ): + first = containment.SessionAccountant(self._session_conf("one")) + first.acquire() + + second = containment.SessionAccountant(self._session_conf("two")) + with self.assertRaises(containment.ContainmentViolation) as violation: + second.acquire() + + self.assertIn( + "runtime_limit.max_sessions_per_user_exceeded", + violation.exception.reason_code, + ) + first.release() + + def test_session_accounting_cleans_stale_entries(self): + """Dead PID records should be removed before counting active sessions.""" + with tempfile.TemporaryDirectory(prefix="lshell-session-unit-") as session_dir: + with patch.dict( + os.environ, + {"LSHELL_SESSION_DIR": session_dir}, + clear=False, + ): + accountant = containment.SessionAccountant(self._session_conf("active")) + os.makedirs(accountant.user_dir, exist_ok=True) + stale_path = os.path.join(accountant.user_dir, "session-stale.json") + with open(stale_path, "w", encoding="utf-8") as handle: + json.dump( + { + "pid": 999999, + "pid_start": "1", + "session_id": "stale", + "username": "testuser", + }, + handle, + ) + + accountant.acquire() + self.assertFalse(os.path.exists(stale_path)) + accountant.release() + + +if __name__ == "__main__": + unittest.main() From f4d09d91a58e6dbac646a7d970f7713e4fd5f8c7 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 21:41:05 -0400 Subject: [PATCH 16/29] containment: enforce max_background_jobs for '&' commands --- CHANGELOG.md | 1 + README.md | 4 +- etc/lshell.conf | 2 + lshell/checkconfig.py | 1 + lshell/configschema.py | 1 + lshell/containment.py | 7 +- lshell/utils.py | 38 ++++++++++- lshell/variables.py | 1 + man/lshell.1 | 4 ++ test/test_audit_functional.py | 67 ++++++++++++++++--- test/test_audit_unit.py | 30 +++++++++ test/test_containment_functional.py | 21 +++++- test/test_containment_unit.py | 11 +-- test/test_extension_parser_unit.py | 8 ++- test/test_security_attack_surface_unit.py | 2 +- ...test_security_attack_surface_unit_part2.py | 2 + 16 files changed, 176 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bda65..a080aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) ### v0.11.1rc3 18/03/2026 - Added runtime containment `max_sessions_per_user` with lock-protected per-user session accounting and startup enforcement. +- Added runtime containment `max_background_jobs` enforcement for interactive `&` job creation with denial audit reasons. ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. diff --git a/README.md b/README.md index c803a3f..df350db 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Key settings to review: - `messages` - `warning_counter`, `strict` - `umask` -- runtime containment: `max_sessions_per_user` +- runtime containment: `max_sessions_per_user`, `max_background_jobs` CLI overrides are supported, for example: @@ -155,11 +155,13 @@ Runtime limits are optional and disabled by default when set to `0`. ```ini max_sessions_per_user : 2 +max_background_jobs : 4 ``` Operational notes: - `max_sessions_per_user` is tracked with lock-protected session records; stale entries are cleaned automatically. +- `max_background_jobs` denies new `&` jobs once the configured active count is reached. ### Best practices diff --git a/etc/lshell.conf b/etc/lshell.conf index bad3951..4891716 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -135,6 +135,8 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" ## Runtime containment limits (disabled by default when set to 0): ## Max concurrent lshell sessions for this user. #max_sessions_per_user : 0 +## Max active background jobs (`&`) tracked in this session. +#max_background_jobs : 0 ## list of paths to restrict where the user can operate ## warning: commands like vi and less can bypass this restriction diff --git a/lshell/checkconfig.py b/lshell/checkconfig.py index d42402a..99e1454 100644 --- a/lshell/checkconfig.py +++ b/lshell/checkconfig.py @@ -570,6 +570,7 @@ def get_config_user(self): "quiet", "security_audit_json", "max_sessions_per_user", + "max_background_jobs", ]: try: if len(self.conf_raw[item]) == 0: diff --git a/lshell/configschema.py b/lshell/configschema.py index 86fa707..81ec187 100644 --- a/lshell/configschema.py +++ b/lshell/configschema.py @@ -43,6 +43,7 @@ "loglevel", "security_audit_json", "max_sessions_per_user", + "max_background_jobs", } DICT_VALUE_KEYS = {"aliases", "env_vars", "messages"} STRING_VALUE_KEYS = { diff --git a/lshell/containment.py b/lshell/containment.py index d8925b3..23c7e8d 100644 --- a/lshell/containment.py +++ b/lshell/containment.py @@ -16,7 +16,10 @@ fcntl = None -RUNTIME_LIMIT_INT_KEYS = ("max_sessions_per_user",) +RUNTIME_LIMIT_INT_KEYS = ( + "max_sessions_per_user", + "max_background_jobs", +) _DEFAULT_SESSION_STATE_ROOT = os.path.join(tempfile.gettempdir(), "lshell", "sessions") @@ -26,6 +29,7 @@ class RuntimeLimits: """Resolved runtime limits for one shell session.""" max_sessions_per_user: int = 0 + max_background_jobs: int = 0 class ContainmentViolation(Exception): @@ -67,6 +71,7 @@ def get_runtime_limits(conf): """Return parsed runtime limits with disabled defaults.""" return RuntimeLimits( max_sessions_per_user=_as_non_negative_int(conf, "max_sessions_per_user"), + max_background_jobs=_as_non_negative_int(conf, "max_background_jobs"), ) diff --git a/lshell/utils.py b/lshell/utils.py index 05c448f..bda05ef 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -19,6 +19,7 @@ from lshell import sec from lshell import messages from lshell import audit +from lshell import containment def usage(exitcode=1): @@ -498,7 +499,7 @@ def handle_builtin_command(full_command, executable, argument, shell_context): elif executable == "cd": retcode, shell_context.conf = builtincmd.cmd_cd(argument, shell_context.conf) elif executable == "ls": - retcode = exec_cmd(full_command) + retcode = exec_cmd(full_command, conf=shell_context.conf, log=shell_context.log) elif executable in ["lpath", "policy-path"]: retcode = builtincmd.cmd_lpath(conf) elif executable in ["lsudo", "policy-sudo"]: @@ -691,6 +692,33 @@ def _handle_unknown_syntax(unknown_command): full_command = " | ".join(pipeline_parts) background = bool(j + 1 < len(command_sequence) and command_sequence[j + 1] == "&") + if background: + limits = containment.get_runtime_limits(shell_context.conf) + if limits.max_background_jobs > 0: + active_jobs = len(builtincmd.jobs()) + if active_jobs >= limits.max_background_jobs: + reason = containment.reason_with_details( + "runtime_limit.max_background_jobs_exceeded", + active=active_jobs, + limit=limits.max_background_jobs, + ) + shell_context.log.critical( + "lshell: runtime containment denied background command: " + f"active_jobs={active_jobs}, limit={limits.max_background_jobs}, " + f'command="{full_command}"' + ) + sys.stderr.write( + "lshell: background job denied: " + f"max_background_jobs={limits.max_background_jobs} reached\n" + ) + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=reason, + ) + return 126 + parsed_parts = [_parse_command(part) for part in pipeline_parts] if any(part[0] is None for part in parsed_parts): return _handle_unknown_syntax(full_command) @@ -833,7 +861,11 @@ def _handle_unknown_syntax(unknown_command): reason="allowed by command and path policy", ) retcode = exec_cmd( - full_command, background=background, extra_env=extra_env + full_command, + background=background, + extra_env=extra_env, + conf=shell_context.conf, + log=shell_context.log, ) else: retcode = _handle_unknown_syntax(full_command) @@ -844,7 +876,7 @@ def _handle_unknown_syntax(unknown_command): return retcode -def exec_cmd(cmd, background=False, extra_env=None): +def exec_cmd(cmd, background=False, extra_env=None, conf=None, log=None): """Execute a command exactly as entered, with support for backgrounding via Ctrl+Z.""" proc = None detached_session = True diff --git a/lshell/variables.py b/lshell/variables.py index e95d637..66a8b21 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -110,6 +110,7 @@ "include_dir=", "security_audit_json=", "max_sessions_per_user=", + "max_background_jobs=", ] FORBIDDEN_ENVIRON = ( diff --git a/man/lshell.1 b/man/lshell.1 index 5149b66..f344648 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -448,6 +448,10 @@ a value in seconds for the session timer maximum concurrent lshell sessions for the same user. Set to \fB0\fR to disable this limit (default). .TP +.I max_background_jobs +maximum active background jobs (\fB&\fR) allowed in one lshell session. +Set to \fB0\fR to disable this limit (default). +.TP .I umask set process umask for the lshell session. Value must be octal (0000 to 0777), for example \fB0002\fR. diff --git a/test/test_audit_functional.py b/test/test_audit_functional.py index 50e6b2b..c52ad0d 100644 --- a/test/test_audit_functional.py +++ b/test/test_audit_functional.py @@ -19,6 +19,21 @@ class TestAuditFunctional(unittest.TestCase): """Validate ECS audit events from a real interactive shell session.""" + def _load_command_events(self, logfile): + events = [] + with open(logfile, "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if payload.get("event.action") == "command_authorization": + events.append(payload) + return events + def test_security_audit_json_emits_allowed_and_denied_events(self): """Emit structured events with success/failure outcomes and reasons.""" with tempfile.TemporaryDirectory(prefix="lshell-audit-log-") as log_dir: @@ -45,17 +60,7 @@ def test_security_audit_json_emits_allowed_and_denied_events(self): logfile = os.path.join(log_dir, f"{USER}.log") self.assertTrue(os.path.exists(logfile)) - with open(logfile, "r", encoding="utf-8") as handle: - lines = [line.strip() for line in handle if line.strip()] - - events = [] - for line in lines: - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - if payload.get("event.action") == "command_authorization": - events.append(payload) + events = self._load_command_events(logfile) self.assertGreaterEqual(len(events), 2) @@ -76,6 +81,46 @@ def test_security_audit_json_emits_allowed_and_denied_events(self): self.assertTrue(denied, msg=f"missing denied audit event in {events}") self.assertIn("unknown syntax", denied[0].get("event.reason", "")) + def test_runtime_limit_denial_reason_is_machine_readable_in_audit(self): + """Denied runtime-limit actions should emit machine-readable reason strings.""" + with tempfile.TemporaryDirectory(prefix="lshell-audit-log-") as log_dir: + with tempfile.TemporaryDirectory(prefix="lshell-audit-session-") as session_dir: + env = os.environ.copy() + env["LSHELL_SESSION_DIR"] = session_dir + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --log {log_dir} --security_audit_json=1 " + "--loglevel 4 --strict 1 --forbidden \"[]\" --allowed \"['sleep']\" " + "--max_background_jobs 1", + encoding="utf-8", + timeout=10, + env=env, + ) + try: + child.expect(PROMPT) + child.sendline("sleep 60 &") + child.expect(PROMPT) + child.sendline("sleep 60 &") + child.expect(PROMPT) + child.sendline("exit") + child.expect(PROMPT) + child.sendline("exit") + child.expect(pexpect.EOF) + finally: + child.close() + + logfile = os.path.join(log_dir, f"{USER}.log") + self.assertTrue(os.path.exists(logfile)) + events = self._load_command_events(logfile) + + denied = [ + event + for event in events + if event.get("event.outcome") == "failure" + and "runtime_limit.max_background_jobs_exceeded" + in event.get("event.reason", "") + ] + self.assertTrue(denied, msg=f"missing runtime-limit denial in {events}") + if __name__ == "__main__": unittest.main() diff --git a/test/test_audit_unit.py b/test/test_audit_unit.py index 607af9b..88bfa90 100644 --- a/test/test_audit_unit.py +++ b/test/test_audit_unit.py @@ -102,3 +102,33 @@ def test_log_command_event_noop_when_disabled(self): } audit.log_command_event(conf, "echo ok", allowed=True, reason="allowed") self.assertEqual(logger.entries, []) + + def test_log_security_event_keeps_machine_readable_reason(self): + """Generic runtime security events should retain reason code strings.""" + logger = _DummyAuditLogger() + conf = { + "security_audit_json": 1, + "logpath": logger, + "session_id": "session-abc", + "username": "testuser", + } + + audit.log_security_event( + conf, + action="runtime_containment", + allowed=False, + reason="runtime_limit.max_background_jobs_exceeded", + command="sleep 60 &", + level="warning", + message="lshell runtime containment decision", + ) + + self.assertEqual(len(logger.entries), 1) + level, message, extra = logger.entries[0] + self.assertEqual(level, logging.WARNING) + self.assertEqual(message, "lshell runtime containment decision") + self.assertEqual(extra["event_action"], "runtime_containment") + self.assertEqual( + extra["event_reason"], "runtime_limit.max_background_jobs_exceeded" + ) + self.assertEqual(extra["event_outcome"], "failure") diff --git a/test/test_containment_functional.py b/test/test_containment_functional.py index 629ecde..2192a48 100644 --- a/test/test_containment_functional.py +++ b/test/test_containment_functional.py @@ -16,7 +16,7 @@ class TestRuntimeContainmentFunctional(unittest.TestCase): - """Validate max_sessions_per_user in a real shell session.""" + """Validate runtime containment limits in a real shell session.""" def _spawn_shell(self, extra_args, env=None, timeout=15): command = f"{LSHELL} --config {CONFIG} {extra_args}" @@ -63,6 +63,25 @@ def test_max_sessions_per_user_enforced(self): second.close(force=True) self._safe_exit(first) + def test_max_background_jobs_enforced(self): + """Deny new background command when max_background_jobs is reached.""" + child = self._spawn_shell( + "--strict 1 --forbidden \"[]\" --allowed \"['sleep']\" " + "--max_background_jobs 1" + ) + try: + child.expect(PROMPT) + child.sendline("sleep 60 &") + child.expect(PROMPT) + + child.sendline("sleep 60 &") + child.expect(PROMPT) + output = child.before + self.assertIn("background job denied", output) + self.assertIn("max_background_jobs=1", output) + finally: + self._safe_exit(child) + if __name__ == "__main__": unittest.main() diff --git a/test/test_containment_unit.py b/test/test_containment_unit.py index db8909d..a6b0fe8 100644 --- a/test/test_containment_unit.py +++ b/test/test_containment_unit.py @@ -20,14 +20,17 @@ class TestContainmentConfigValidation(unittest.TestCase): base_args = [f"--config={CONFIG}", "--quiet=1"] def test_max_sessions_per_user_defaults_disabled(self): - """max_sessions_per_user should default to 0.""" + """Runtime containment keys should default to disabled mode.""" conf = CheckConfig(self.base_args).returnconf() self.assertEqual(conf["max_sessions_per_user"], 0) + self.assertEqual(conf["max_background_jobs"], 0) def test_max_sessions_per_user_rejects_negative_values(self): - """max_sessions_per_user must be non-negative.""" - with self.assertRaises(SystemExit): - CheckConfig(self.base_args + ["--max_sessions_per_user=-1"]).returnconf() + """Runtime containment integer keys must be non-negative.""" + for key in containment.RUNTIME_LIMIT_INT_KEYS: + with self.subTest(key=key): + with self.assertRaises(SystemExit): + CheckConfig(self.base_args + [f"--{key}=-1"]).returnconf() class TestSessionAccounting(unittest.TestCase): diff --git a/test/test_extension_parser_unit.py b/test/test_extension_parser_unit.py index 94ba9eb..25238f0 100644 --- a/test/test_extension_parser_unit.py +++ b/test/test_extension_parser_unit.py @@ -3,7 +3,7 @@ import io import os import unittest -from unittest.mock import patch +from unittest.mock import ANY, patch from lshell import sec from lshell import utils @@ -105,7 +105,11 @@ def test_cmd_parse_execute_treats_ls_as_builtin(self, mock_exec_cmd): shell_context = DummyShellContext(conf) retcode = utils.cmd_parse_execute("ls /tmp", shell_context=shell_context) self.assertEqual(retcode, 0) - mock_exec_cmd.assert_called_once_with("ls /tmp") + mock_exec_cmd.assert_called_once_with( + "ls /tmp", + conf=ANY, + log=ANY, + ) if __name__ == "__main__": diff --git a/test/test_security_attack_surface_unit.py b/test/test_security_attack_surface_unit.py index 51bc8b7..faf1838 100644 --- a/test/test_security_attack_surface_unit.py +++ b/test/test_security_attack_surface_unit.py @@ -367,7 +367,7 @@ def test_cmd_parse_execute_short_circuit_skips_failed_and_branch( mock_secure.side_effect = lambda line, conf, strict=None: (0, conf) mock_path.side_effect = lambda line, conf, strict=None: (0, conf) - def exec_side_effect(command, background=False, extra_env=None): + def exec_side_effect(command, background=False, extra_env=None, **_kwargs): if command == "false": return 1 if command == "echo recovered": diff --git a/test/test_security_attack_surface_unit_part2.py b/test/test_security_attack_surface_unit_part2.py index 7e8cfd2..8bc8c7f 100644 --- a/test/test_security_attack_surface_unit_part2.py +++ b/test/test_security_attack_surface_unit_part2.py @@ -459,6 +459,8 @@ def test_cmd_parse_execute_trusted_protocol_allows_single_sftp_server_command( "/usr/libexec/sftp-server", background=False, extra_env=unittest.mock.ANY, + conf=unittest.mock.ANY, + log=unittest.mock.ANY, ) @patch("lshell.utils.exec_cmd", side_effect=[1, 0]) From 6a5189a64e0f5358435b5577415ee54a6dbb5158 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 21:42:57 -0400 Subject: [PATCH 17/29] containment: add per-command command_timeout enforcement --- CHANGELOG.md | 1 + README.md | 4 +- etc/lshell.conf | 2 + lshell/builtincmd.py | 15 +++++++ lshell/checkconfig.py | 1 + lshell/configschema.py | 1 + lshell/containment.py | 3 ++ lshell/utils.py | 68 ++++++++++++++++++++++++++++- lshell/variables.py | 1 + man/lshell.1 | 5 +++ test/test_containment_functional.py | 15 +++++++ test/test_containment_unit.py | 22 ++++++++++ 12 files changed, 136 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a080aff..a31480c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) ### v0.11.1rc3 18/03/2026 - Added runtime containment `max_sessions_per_user` with lock-protected per-user session accounting and startup enforcement. - Added runtime containment `max_background_jobs` enforcement for interactive `&` job creation with denial audit reasons. +- Added runtime containment `command_timeout` to terminate overlong foreground/background commands and report timeout denials. ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. diff --git a/README.md b/README.md index df350db..c822678 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Key settings to review: - `messages` - `warning_counter`, `strict` - `umask` -- runtime containment: `max_sessions_per_user`, `max_background_jobs` +- runtime containment: `max_sessions_per_user`, `max_background_jobs`, `command_timeout` CLI overrides are supported, for example: @@ -156,12 +156,14 @@ Runtime limits are optional and disabled by default when set to `0`. ```ini max_sessions_per_user : 2 max_background_jobs : 4 +command_timeout : 30 ``` Operational notes: - `max_sessions_per_user` is tracked with lock-protected session records; stale entries are cleaned automatically. - `max_background_jobs` denies new `&` jobs once the configured active count is reached. +- `command_timeout` enforces a per-command wall-clock timeout (foreground and background commands). ### Best practices diff --git a/etc/lshell.conf b/etc/lshell.conf index 4891716..97e16fa 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -137,6 +137,8 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" #max_sessions_per_user : 0 ## Max active background jobs (`&`) tracked in this session. #max_background_jobs : 0 +## Wall-clock timeout in seconds per executed command. +#command_timeout : 0 ## list of paths to restrict where the user can operate ## warning: commands like vi and less can bypass this restriction diff --git a/lshell/builtincmd.py b/lshell/builtincmd.py index 25a20ff..013df05 100644 --- a/lshell/builtincmd.py +++ b/lshell/builtincmd.py @@ -44,6 +44,13 @@ ] +def _cancel_job_timeout(job): + """Cancel a watchdog timer attached to a background job, if any.""" + timer = getattr(job, "lshell_timeout_timer", None) + if timer is not None: + timer.cancel() + + def cmd_lpath(conf): """Show path policy in a concise, readable format.""" current_dir = os.path.realpath(os.getcwd()) @@ -200,6 +207,11 @@ def check_background_jobs(): active_jobs.append(job) continue + _cancel_job_timeout(job) + if getattr(job, "lshell_timeout_triggered", False): + print(f"[{idx}]+ Timed Out {_job_command(job)}") + continue + status = "Done" if job.returncode == 0 else "Failed" args = _job_command(job) # only print if the job has not been interrupted by the user @@ -211,6 +223,8 @@ def check_background_jobs(): def get_job_status(job): """Return the status of a background job.""" + if getattr(job, "lshell_timeout_triggered", False): + return "Timed Out" if job.poll() is None: status = "Stopped" elif job.poll() == 0: @@ -231,6 +245,7 @@ def jobs(): active_jobs = [] for job in BACKGROUND_JOBS: if job.poll() is not None: + _cancel_job_timeout(job) continue active_jobs.append(job) diff --git a/lshell/checkconfig.py b/lshell/checkconfig.py index 99e1454..6e0001f 100644 --- a/lshell/checkconfig.py +++ b/lshell/checkconfig.py @@ -571,6 +571,7 @@ def get_config_user(self): "security_audit_json", "max_sessions_per_user", "max_background_jobs", + "command_timeout", ]: try: if len(self.conf_raw[item]) == 0: diff --git a/lshell/configschema.py b/lshell/configschema.py index 81ec187..260a124 100644 --- a/lshell/configschema.py +++ b/lshell/configschema.py @@ -44,6 +44,7 @@ "security_audit_json", "max_sessions_per_user", "max_background_jobs", + "command_timeout", } DICT_VALUE_KEYS = {"aliases", "env_vars", "messages"} STRING_VALUE_KEYS = { diff --git a/lshell/containment.py b/lshell/containment.py index 23c7e8d..f9b1c72 100644 --- a/lshell/containment.py +++ b/lshell/containment.py @@ -19,6 +19,7 @@ RUNTIME_LIMIT_INT_KEYS = ( "max_sessions_per_user", "max_background_jobs", + "command_timeout", ) _DEFAULT_SESSION_STATE_ROOT = os.path.join(tempfile.gettempdir(), "lshell", "sessions") @@ -30,6 +31,7 @@ class RuntimeLimits: max_sessions_per_user: int = 0 max_background_jobs: int = 0 + command_timeout: int = 0 class ContainmentViolation(Exception): @@ -72,6 +74,7 @@ def get_runtime_limits(conf): return RuntimeLimits( max_sessions_per_user=_as_non_negative_int(conf, "max_sessions_per_user"), max_background_jobs=_as_non_negative_int(conf, "max_background_jobs"), + command_timeout=_as_non_negative_int(conf, "command_timeout"), ) diff --git a/lshell/utils.py b/lshell/utils.py index bda05ef..df42f61 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -9,6 +9,7 @@ import string import shlex import shutil +import threading from getpass import getuser from time import strftime, gmtime import signal @@ -881,6 +882,8 @@ def exec_cmd(cmd, background=False, extra_env=None, conf=None, log=None): proc = None detached_session = True exec_env = dict(os.environ) + runtime_limits = containment.get_runtime_limits(conf or {}) + command_timeout = runtime_limits.command_timeout if extra_env: exec_env.update(extra_env) # Prevent non-interactive shell startup file injection. @@ -917,6 +920,41 @@ def handle_sigcont(signum, frame): else: os.kill(proc.pid, signal.SIGCONT) + def _kill_process_group(target): + if not target or target.poll() is not None: + return + try: + if detached_session: + os.killpg(os.getpgid(target.pid), signal.SIGKILL) + else: + os.kill(target.pid, signal.SIGKILL) + except OSError: + return + + def _timeout_reason(): + return containment.reason_with_details( + "runtime_limit.command_timeout_exceeded", + timeout=command_timeout, + ) + + def _emit_timeout_event(): + if conf: + audit.log_command_event( + conf, + cmd, + allowed=False, + reason=_timeout_reason(), + level="warning", + ) + if log: + log.warning( + "lshell: runtime containment timed out command: " + f'timeout={command_timeout}s, command="{cmd}"' + ) + sys.stderr.write( + f"lshell: command timed out after {command_timeout}s: {cmd}\n" + ) + previous_sigtstp_handler = signal.getsignal(signal.SIGTSTP) previous_sigcont_handler = signal.getsignal(signal.SIGCONT) @@ -945,6 +983,19 @@ def handle_sigcont(signum, frame): popen_kwargs["preexec_fn"] = os.setsid proc = subprocess.Popen(cmd_args, **popen_kwargs) proc.lshell_cmd = cmd + proc.lshell_timeout_timer = None + if command_timeout > 0: + + def _background_timeout(): + if proc and proc.poll() is None: + proc.lshell_timeout_triggered = True + _kill_process_group(proc) + _emit_timeout_event() + + timeout_timer = threading.Timer(command_timeout, _background_timeout) + timeout_timer.daemon = True + timeout_timer.start() + proc.lshell_timeout_timer = timeout_timer # add to background jobs and return builtincmd.BACKGROUND_JOBS.append(proc) job_id = len(builtincmd.BACKGROUND_JOBS) @@ -956,7 +1007,10 @@ def handle_sigcont(signum, frame): popen_kwargs["preexec_fn"] = os.setsid proc = subprocess.Popen(cmd_args, **popen_kwargs) proc.lshell_cmd = cmd - proc.communicate() + if command_timeout > 0: + proc.communicate(timeout=command_timeout) + else: + proc.communicate() retcode = proc.returncode if proc.returncode is not None else 0 except FileNotFoundError: @@ -964,6 +1018,12 @@ def handle_sigcont(signum, frame): "Command execution failed: required shell interpreter not found.\n" ) retcode = 127 + except subprocess.TimeoutExpired: + _kill_process_group(proc) + if proc: + proc.communicate() + _emit_timeout_event() + retcode = 124 except CtrlZException: # Handle Ctrl+Z retcode = 0 except KeyboardInterrupt: # Handle Ctrl+C @@ -974,6 +1034,12 @@ def handle_sigcont(signum, frame): os.kill(proc.pid, signal.SIGINT) retcode = 130 finally: + if ( + proc is not None + and getattr(proc, "lshell_timeout_timer", None) is not None + and proc.poll() is not None + ): + proc.lshell_timeout_timer.cancel() signal.signal(signal.SIGTSTP, previous_sigtstp_handler) signal.signal(signal.SIGCONT, previous_sigcont_handler) diff --git a/lshell/variables.py b/lshell/variables.py index 66a8b21..6195573 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -111,6 +111,7 @@ "security_audit_json=", "max_sessions_per_user=", "max_background_jobs=", + "command_timeout=", ] FORBIDDEN_ENVIRON = ( diff --git a/man/lshell.1 b/man/lshell.1 index f344648..d0ee6fc 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -452,6 +452,11 @@ Set to \fB0\fR to disable this limit (default). maximum active background jobs (\fB&\fR) allowed in one lshell session. Set to \fB0\fR to disable this limit (default). .TP +.I command_timeout +wall-clock timeout in seconds applied per executed command. Commands exceeding +this timeout are terminated and reported as denied by runtime containment. +Set to \fB0\fR to disable this limit (default). +.TP .I umask set process umask for the lshell session. Value must be octal (0000 to 0777), for example \fB0002\fR. diff --git a/test/test_containment_functional.py b/test/test_containment_functional.py index 2192a48..34103ce 100644 --- a/test/test_containment_functional.py +++ b/test/test_containment_functional.py @@ -82,6 +82,21 @@ def test_max_background_jobs_enforced(self): finally: self._safe_exit(child) + def test_command_timeout_kills_long_running_command(self): + """Terminate foreground command when command_timeout is exceeded.""" + child = self._spawn_shell( + "--strict 1 --forbidden \"[]\" --allowed \"['sleep']\" " + "--command_timeout 1" + ) + try: + child.expect(PROMPT) + child.sendline("sleep 3") + child.expect(PROMPT) + output = child.before + self.assertIn("command timed out after 1s", output) + finally: + self._safe_exit(child) + if __name__ == "__main__": unittest.main() diff --git a/test/test_containment_unit.py b/test/test_containment_unit.py index a6b0fe8..eebf8f0 100644 --- a/test/test_containment_unit.py +++ b/test/test_containment_unit.py @@ -3,10 +3,12 @@ import json import os import tempfile +import time import unittest from unittest.mock import patch from lshell import containment +from lshell import utils from lshell.checkconfig import CheckConfig @@ -24,6 +26,7 @@ def test_max_sessions_per_user_defaults_disabled(self): conf = CheckConfig(self.base_args).returnconf() self.assertEqual(conf["max_sessions_per_user"], 0) self.assertEqual(conf["max_background_jobs"], 0) + self.assertEqual(conf["command_timeout"], 0) def test_max_sessions_per_user_rejects_negative_values(self): """Runtime containment integer keys must be non-negative.""" @@ -91,5 +94,24 @@ def test_session_accounting_cleans_stale_entries(self): accountant.release() +class TestRuntimeExecutionHelpers(unittest.TestCase): + """Validate timeout helper behavior.""" + + def test_exec_cmd_timeout_returns_124(self): + """Foreground commands should be killed when command_timeout is exceeded.""" + conf = { + "command_timeout": 1, + "max_sessions_per_user": 0, + "max_background_jobs": 0, + "security_audit_json": 0, + } + started = time.monotonic() + ret = utils.exec_cmd("sleep 2", conf=conf) + elapsed = time.monotonic() - started + + self.assertEqual(ret, 124) + self.assertLess(elapsed, 2.5) + + if __name__ == "__main__": unittest.main() From 7d56b5f2336b3966887dc0f535727c2355e94b46 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Tue, 17 Mar 2026 21:45:41 -0400 Subject: [PATCH 18/29] containment: apply max_processes via RLIMIT_NPROC --- CHANGELOG.md | 1 + README.md | 4 +- etc/lshell.conf | 2 + lshell/checkconfig.py | 1 + lshell/configschema.py | 1 + lshell/containment.py | 63 +++++++++++++++++++++++++++++ lshell/utils.py | 45 +++++++++++++++++++-- lshell/variables.py | 1 + man/lshell.1 | 4 ++ test/test_containment_functional.py | 22 ++++++++++ test/test_containment_unit.py | 40 ++++++++++++++++++ 11 files changed, 179 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a31480c..bfe8e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) - Added runtime containment `max_sessions_per_user` with lock-protected per-user session accounting and startup enforcement. - Added runtime containment `max_background_jobs` enforcement for interactive `&` job creation with denial audit reasons. - Added runtime containment `command_timeout` to terminate overlong foreground/background commands and report timeout denials. +- Added runtime containment `max_processes` with best-effort `RLIMIT_NPROC` enforcement for spawned commands. ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. diff --git a/README.md b/README.md index c822678..60b6430 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Key settings to review: - `messages` - `warning_counter`, `strict` - `umask` -- runtime containment: `max_sessions_per_user`, `max_background_jobs`, `command_timeout` +- runtime containment: `max_sessions_per_user`, `max_background_jobs`, `command_timeout`, `max_processes` CLI overrides are supported, for example: @@ -157,6 +157,7 @@ Runtime limits are optional and disabled by default when set to `0`. max_sessions_per_user : 2 max_background_jobs : 4 command_timeout : 30 +max_processes : 64 ``` Operational notes: @@ -164,6 +165,7 @@ Operational notes: - `max_sessions_per_user` is tracked with lock-protected session records; stale entries are cleaned automatically. - `max_background_jobs` denies new `&` jobs once the configured active count is reached. - `command_timeout` enforces a per-command wall-clock timeout (foreground and background commands). +- `max_processes` is applied via POSIX `RLIMIT_NPROC` on spawned command processes. ### Best practices diff --git a/etc/lshell.conf b/etc/lshell.conf index 97e16fa..47367b0 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -139,6 +139,8 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" #max_background_jobs : 0 ## Wall-clock timeout in seconds per executed command. #command_timeout : 0 +## Max processes for each spawned command (RLIMIT_NPROC). +#max_processes : 0 ## list of paths to restrict where the user can operate ## warning: commands like vi and less can bypass this restriction diff --git a/lshell/checkconfig.py b/lshell/checkconfig.py index 6e0001f..1cb0dca 100644 --- a/lshell/checkconfig.py +++ b/lshell/checkconfig.py @@ -572,6 +572,7 @@ def get_config_user(self): "max_sessions_per_user", "max_background_jobs", "command_timeout", + "max_processes", ]: try: if len(self.conf_raw[item]) == 0: diff --git a/lshell/configschema.py b/lshell/configschema.py index 260a124..38ecb91 100644 --- a/lshell/configschema.py +++ b/lshell/configschema.py @@ -45,6 +45,7 @@ "max_sessions_per_user", "max_background_jobs", "command_timeout", + "max_processes", } DICT_VALUE_KEYS = {"aliases", "env_vars", "messages"} STRING_VALUE_KEYS = { diff --git a/lshell/containment.py b/lshell/containment.py index f9b1c72..24b938e 100644 --- a/lshell/containment.py +++ b/lshell/containment.py @@ -15,11 +15,17 @@ except ImportError: # pragma: no cover - non-POSIX fallback. fcntl = None +try: # POSIX rlimits. + import resource +except ImportError: # pragma: no cover - non-POSIX fallback. + resource = None + RUNTIME_LIMIT_INT_KEYS = ( "max_sessions_per_user", "max_background_jobs", "command_timeout", + "max_processes", ) _DEFAULT_SESSION_STATE_ROOT = os.path.join(tempfile.gettempdir(), "lshell", "sessions") @@ -32,6 +38,7 @@ class RuntimeLimits: max_sessions_per_user: int = 0 max_background_jobs: int = 0 command_timeout: int = 0 + max_processes: int = 0 class ContainmentViolation(Exception): @@ -75,6 +82,7 @@ def get_runtime_limits(conf): max_sessions_per_user=_as_non_negative_int(conf, "max_sessions_per_user"), max_background_jobs=_as_non_negative_int(conf, "max_background_jobs"), command_timeout=_as_non_negative_int(conf, "command_timeout"), + max_processes=_as_non_negative_int(conf, "max_processes"), ) @@ -279,3 +287,58 @@ def _signal_cleanup_handler(self, signum, frame): return signal.signal(signum, signal.SIG_DFL) os.kill(os.getpid(), signum) + + +def apply_rlimits(limits, resource_module=None): + """Apply configured rlimits in the current process context.""" + if resource_module is None: + resource_module = resource + + unsupported = [] + if resource_module is None: + if limits.max_processes > 0: + unsupported.append("max_processes") + return unsupported + + if limits.max_processes > 0: + rlimit_nproc = getattr(resource_module, "RLIMIT_NPROC", None) + if rlimit_nproc is None: + unsupported.append("max_processes") + else: + try: + resource_module.setrlimit( + rlimit_nproc, + (limits.max_processes, limits.max_processes), + ) + except (OSError, ValueError): + unsupported.append("max_processes") + + return unsupported + + +def unsupported_rlimits(limits, resource_module=None): + """Return containment limit keys unsupported by the current platform.""" + if resource_module is None: + resource_module = resource + + unsupported = [] + if resource_module is None: + if limits.max_processes > 0: + unsupported.append("max_processes") + return unsupported + + if limits.max_processes > 0 and getattr(resource_module, "RLIMIT_NPROC", None) is None: + unsupported.append("max_processes") + + return unsupported + + +def build_preexec_fn(detached_session, limits): + """Build subprocess pre-exec hook to apply process/session limits.""" + + def _preexec(): + if detached_session: + os.setsid() + apply_rlimits(limits) + + return _preexec diff --git a/lshell/utils.py b/lshell/utils.py index df42f61..c1b9a7d 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -884,6 +884,17 @@ def exec_cmd(cmd, background=False, extra_env=None, conf=None, log=None): exec_env = dict(os.environ) runtime_limits = containment.get_runtime_limits(conf or {}) command_timeout = runtime_limits.command_timeout + unsupported_limits = containment.unsupported_rlimits(runtime_limits) + if conf is not None and log and unsupported_limits: + logged_key = "_runtime_unsupported_limits_logged" + already_logged = set(conf.get(logged_key, [])) + pending = [item for item in unsupported_limits if item not in already_logged] + if pending: + log.warning( + "lshell: runtime containment limits unsupported on this platform: " + + ", ".join(sorted(pending)) + ) + conf[logged_key] = sorted(already_logged.union(pending)) if extra_env: exec_env.update(extra_env) # Prevent non-interactive shell startup file injection. @@ -971,6 +982,10 @@ def _emit_timeout_event(): cmd_args = split_cmd if not background: detached_session = False + preexec_fn = None + needs_resource_limits = runtime_limits.max_processes > 0 + if os.name == "posix" and (detached_session or needs_resource_limits): + preexec_fn = containment.build_preexec_fn(detached_session, runtime_limits) if background: with open(os.devnull, "r") as devnull_in: popen_kwargs = { @@ -979,8 +994,8 @@ def _emit_timeout_event(): "stderr": sys.stderr, "env": exec_env, } - if detached_session: - popen_kwargs["preexec_fn"] = os.setsid + if preexec_fn is not None: + popen_kwargs["preexec_fn"] = preexec_fn proc = subprocess.Popen(cmd_args, **popen_kwargs) proc.lshell_cmd = cmd proc.lshell_timeout_timer = None @@ -1003,8 +1018,8 @@ def _background_timeout(): retcode = 0 else: popen_kwargs = {"env": exec_env} - if detached_session: - popen_kwargs["preexec_fn"] = os.setsid + if preexec_fn is not None: + popen_kwargs["preexec_fn"] = preexec_fn proc = subprocess.Popen(cmd_args, **popen_kwargs) proc.lshell_cmd = cmd if command_timeout > 0: @@ -1024,6 +1039,28 @@ def _background_timeout(): proc.communicate() _emit_timeout_event() retcode = 124 + except subprocess.SubprocessError as exception: + reason = containment.reason_with_details( + "runtime_limit.preexec_application_failed", + error=str(exception), + ) + if conf: + audit.log_command_event( + conf, + cmd, + allowed=False, + reason=reason, + level="warning", + ) + if log: + log.critical( + "lshell: runtime containment denied command execution: " + f"{reason}" + ) + sys.stderr.write( + "lshell: command denied: unable to apply runtime containment limits\n" + ) + retcode = 126 except CtrlZException: # Handle Ctrl+Z retcode = 0 except KeyboardInterrupt: # Handle Ctrl+C diff --git a/lshell/variables.py b/lshell/variables.py index 6195573..1798c85 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -112,6 +112,7 @@ "max_sessions_per_user=", "max_background_jobs=", "command_timeout=", + "max_processes=", ] FORBIDDEN_ENVIRON = ( diff --git a/man/lshell.1 b/man/lshell.1 index d0ee6fc..20555e3 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -457,6 +457,10 @@ wall-clock timeout in seconds applied per executed command. Commands exceeding this timeout are terminated and reported as denied by runtime containment. Set to \fB0\fR to disable this limit (default). .TP +.I max_processes +maximum processes per spawned command via \fBRLIMIT_NPROC\fR. +Set to \fB0\fR to disable this limit (default). +.TP .I umask set process umask for the lshell session. Value must be octal (0000 to 0777), for example \fB0002\fR. diff --git a/test/test_containment_functional.py b/test/test_containment_functional.py index 34103ce..bc95a3b 100644 --- a/test/test_containment_functional.py +++ b/test/test_containment_functional.py @@ -97,6 +97,28 @@ def test_command_timeout_kills_long_running_command(self): finally: self._safe_exit(child) + def _last_non_empty_line(self, text): + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[-1] if lines else "" + + def test_max_processes_denies_forking_pipeline(self): + """Low max_processes should fail a command requiring child process forks.""" + child = self._spawn_shell( + "--strict 1 --forbidden \"[]\" --allowed \"['cat','echo']\" " + "--max_processes 1 --command_timeout 2" + ) + try: + child.expect(PROMPT) + child.sendline("echo hi | cat") + child.expect(PROMPT) + + child.sendline("echo $?") + child.expect(PROMPT) + exit_line = self._last_non_empty_line(child.before) + self.assertNotEqual(exit_line, "0") + finally: + self._safe_exit(child) + if __name__ == "__main__": unittest.main() diff --git a/test/test_containment_unit.py b/test/test_containment_unit.py index eebf8f0..499cbc0 100644 --- a/test/test_containment_unit.py +++ b/test/test_containment_unit.py @@ -112,6 +112,46 @@ def test_exec_cmd_timeout_returns_124(self): self.assertEqual(ret, 124) self.assertLess(elapsed, 2.5) + def test_apply_rlimits_applies_max_processes(self): + """rlimit helper should apply max_processes via RLIMIT_NPROC.""" + + class FakeResource: + """Minimal resource-module stub capturing setrlimit calls.""" + + RLIMIT_NPROC = 1 + + def __init__(self): + self.calls = [] + + def setrlimit(self, key, value): + """Record the requested resource limit tuple.""" + self.calls.append((key, value)) + + fake_resource = FakeResource() + limits = containment.RuntimeLimits(max_processes=10) + unsupported = containment.apply_rlimits(limits, resource_module=fake_resource) + + self.assertEqual(unsupported, []) + self.assertIn((FakeResource.RLIMIT_NPROC, (10, 10)), fake_resource.calls) + + def test_apply_rlimits_reports_unsupported_max_processes(self): + """Missing RLIMIT_NPROC should be reported, not crash.""" + + class MissingResource: + """Resource stub without RLIMIT constants for unsupported-path tests.""" + + def setrlimit(self, _key, _value): + """Fail if called since no RLIMIT constants are exposed.""" + raise AssertionError("setrlimit should not be called without constants") + + limits = containment.RuntimeLimits(max_processes=1) + unsupported = containment.apply_rlimits( + limits, + resource_module=MissingResource(), + ) + + self.assertIn("max_processes", unsupported) + if __name__ == "__main__": unittest.main() From 9a81b2ae17aac969a6671e715b3fced7d2e38e58 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 06:05:43 -0400 Subject: [PATCH 19/29] ci: enforce main/pre-release release flow and update docs --- .github/workflows/lshell-tests.yml | 8 ++-- .github/workflows/pypi-publish.yml | 65 ++++++++++++++++++++++++++++-- README.md | 9 ++++- pyproject.toml | 2 +- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lshell-tests.yml b/.github/workflows/lshell-tests.yml index 3008ebd..ec4bc59 100644 --- a/.github/workflows/lshell-tests.yml +++ b/.github/workflows/lshell-tests.yml @@ -2,8 +2,10 @@ name: Lshell Tests on: push: - branches: [ "master" ] + branches: [ "main", "pre-release" ] pull_request: + branches: [ "main", "pre-release" ] + workflow_dispatch: permissions: contents: read @@ -16,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Set up Python path @@ -42,7 +44,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Set up Python path diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 93e063f..32f9b15 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,13 +1,63 @@ -name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI +name: Publish Python 🐍 distribution 📦 to PyPI -on: +on: push: tags: - - '*' + - "*" + +permissions: + contents: read jobs: + validate-release-channel: + name: Validate release channel + runs-on: ubuntu-latest + outputs: + branch_channel: ${{ steps.validate.outputs.branch_channel }} + release_type: ${{ steps.validate.outputs.release_type }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate tag and branch channel + id: validate + env: + TAG_NAME: ${{ github.ref_name }} + TAG_SHA: ${{ github.sha }} + run: | + set -euo pipefail + git fetch --no-tags origin main pre-release + TAG_VERSION="${TAG_NAME}" + PACKAGE_VERSION="$(python3 -c 'from lshell.variables import __version__; print(__version__)')" + + if [[ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]]; then + echo "Tag/version mismatch: tag=${TAG_VERSION}, package=${PACKAGE_VERSION}" >&2 + exit 1 + fi + + if [[ "${TAG_NAME}" == *rc* ]]; then + if git merge-base --is-ancestor "${TAG_SHA}" "origin/pre-release"; then + echo "branch_channel=pre-release" >> "${GITHUB_OUTPUT}" + echo "release_type=release-candidate" >> "${GITHUB_OUTPUT}" + else + echo "Tag ${TAG_NAME} is an RC but is not based on pre-release." >&2 + exit 1 + fi + else + if git merge-base --is-ancestor "${TAG_SHA}" "origin/main"; then + echo "branch_channel=main" >> "${GITHUB_OUTPUT}" + echo "release_type=stable" >> "${GITHUB_OUTPUT}" + else + echo "Tag ${TAG_NAME} is stable but is not based on main." >&2 + exit 1 + fi + fi + build: name: Build distribution 📦 + needs: + - validate-release-channel runs-on: ubuntu-latest steps: @@ -34,6 +84,7 @@ jobs: name: >- Publish Python 🐍 distribution 📦 to PyPI needs: + - validate-release-channel - build runs-on: ubuntu-latest @@ -49,5 +100,11 @@ jobs: with: name: python-package-distributions path: dist/ + - name: Print release channel + env: + RELEASE_TYPE: ${{ needs.validate-release-channel.outputs.release_type }} + BRANCH_CHANNEL: ${{ needs.validate-release-channel.outputs.branch_channel }} + run: | + echo "Publishing ${GITHUB_REF_NAME} as ${RELEASE_TYPE} from ${BRANCH_CHANNEL}" - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/README.md b/README.md index 9a91008..f8151d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![PyPI - Version](https://img.shields.io/pypi/v/limited-shell?link=https%3A%2F%2Fpypi.org%2Fproject%2Flimited-shell%2F) ![PyPI - Downloads](https://img.shields.io/pypi/dm/limited-shell) -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ghantoos/lshell/lshell-tests.yml?branch=master&label=tests&link=https%3A%2F%2Fgithub.com%2Fghantoos%2Flshell%2Factions%2Fworkflows%2Flshell-tests.yml) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ghantoos/lshell/lshell-tests.yml?label=tests&link=https%3A%2F%2Fgithub.com%2Fghantoos%2Flshell%2Factions%2Fworkflows%2Flshell-tests.yml) # lshell @@ -36,6 +36,13 @@ Uninstall: pip uninstall limited-shell ``` +## Branch and release workflow + +- `main`: stable release branch. Tag stable versions from this branch (for example `1.2.3`). +- `pre-release`: integration branch for tested features before release. Tag release candidates from this branch (for example `1.2.4rc1`). +- PyPI publishing uses one project (`limited-shell`) and accepts both stable and `rc` versions. +- CI (`lshell-tests`) runs on pushes and PRs targeting both `main` and `pre-release`. + ## Quick start Run `lshell` with an explicit config: diff --git a/pyproject.toml b/pyproject.toml index 7238017..2cdf73c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dynamic = ["version"] [project.urls] GitHub = "https://github.com/ghantoos/lshell" -Changelog = "https://github.com/ghantoos/lshell/blob/master/CHANGELOG.md" +Changelog = "https://github.com/ghantoos/lshell/blob/main/CHANGELOG.md" [project.scripts] lshell = "lshell.cli:main" From 2ea3dfaef4697401c682235a5b3920251977f3a8 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 06:26:21 -0400 Subject: [PATCH 20/29] docs: update PyPI project link format in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8151d3..9b8cea6 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ pip uninstall limited-shell - `main`: stable release branch. Tag stable versions from this branch (for example `1.2.3`). - `pre-release`: integration branch for tested features before release. Tag release candidates from this branch (for example `1.2.4rc1`). -- PyPI publishing uses one project (`limited-shell`) and accepts both stable and `rc` versions. +- PyPI publishing uses one project ([limited-shell](https://pypi.org/project/limited-shell/)) and accepts both stable and `rc` versions. - CI (`lshell-tests`) runs on pushes and PRs targeting both `main` and `pre-release`. ## Quick start From bcbffe6454b0f71b4ba98cbb4784540b1b5dbf2b Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 06:16:13 -0400 Subject: [PATCH 21/29] fix: improve handling of keyboard interrupts in command execution --- lshell/shellcmd.py | 103 ++++++++++++++++++++++++------------------- test/test_signals.py | 7 ++- 2 files changed, 63 insertions(+), 47 deletions(-) diff --git a/lshell/shellcmd.py b/lshell/shellcmd.py index 264e7b7..2eb929a 100644 --- a/lshell/shellcmd.py +++ b/lshell/shellcmd.py @@ -381,58 +381,69 @@ def cmdloop(self, intro=None): partial_line = "" stop = None while not stop: - # Check background jobs after each command - builtincmd.check_background_jobs() - if self.cmdqueue: - line = self.cmdqueue.pop(0) - else: - if self.use_rawinput: - try: - line = input(self.conf["promptprint"]) - except EOFError: - line = "EOF" - except KeyboardInterrupt: - self.stdout.write("\n") - if partial_line: - partial_line = "" - self.conf["promptprint"] = utils.updateprompt( - os.getcwd(), self.conf - ) - continue + try: + # Check background jobs after each command + builtincmd.check_background_jobs() + if self.cmdqueue: + line = self.cmdqueue.pop(0) else: - self.stdout.write(self.conf["promptprint"]) - self.stdout.flush() - line = self.stdin.readline() - if not line: - line = "EOF" + if self.use_rawinput: + try: + line = input(self.conf["promptprint"]) + except EOFError: + line = "EOF" + except KeyboardInterrupt: + self.stdout.write("\n") + if partial_line: + partial_line = "" + self.conf["promptprint"] = utils.updateprompt( + os.getcwd(), self.conf + ) + continue else: - # chop \n - line = line[:-1] - if len(line) > 1 and line.startswith("\\"): - # implying previous partial line - line = line[:1].replace("\\", "", 1) - if partial_line: - line = partial_line + line - if line.endswith("\\"): - # continuation character. First partial line. - # We shall expect the command to continue in - # a new line. Change to bash like PS2 prompt to - # indicate this continuation to the user - partial_line = line.strip("\\") - self.conf["promptprint"] = self.prompt2 # switching to PS2 - continue - elif line.count('"') % 2 != 0 or line.count("'") % 2 != 0: - # unclosed quotes detected - partial_line = line - self.conf["promptprint"] = self.prompt2 # switching to PS2 - continue + self.stdout.write(self.conf["promptprint"]) + self.stdout.flush() + line = self.stdin.readline() + if not line: + line = "EOF" + else: + # chop \n + line = line[:-1] + if len(line) > 1 and line.startswith("\\"): + # implying previous partial line + line = line[:1].replace("\\", "", 1) + if partial_line: + line = partial_line + line + if line.endswith("\\"): + # continuation character. First partial line. + # We shall expect the command to continue in + # a new line. Change to bash like PS2 prompt to + # indicate this continuation to the user + partial_line = line.strip("\\") + self.conf["promptprint"] = self.prompt2 # switching to PS2 + continue + elif line.count('"') % 2 != 0 or line.count("'") % 2 != 0: + # unclosed quotes detected + partial_line = line + self.conf["promptprint"] = self.prompt2 # switching to PS2 + continue + partial_line = "" + self.conf["promptprint"] = utils.updateprompt( + os.getcwd(), self.conf + ) + line = self.precmd(line) + stop = self.onecmd(line) + stop = self.postcmd(stop, line) + except KeyboardInterrupt: + # Keep prompt handling local to cmdloop even when Ctrl+C + # races outside input() (e.g. background-job checks). + self.stdout.write("\n") + self.stdout.flush() partial_line = "" self.conf["promptprint"] = utils.updateprompt( os.getcwd(), self.conf ) - line = self.precmd(line) - stop = self.onecmd(line) - stop = self.postcmd(stop, line) + continue self.postloop() finally: if self.use_rawinput and self.completekey: diff --git a/test/test_signals.py b/test/test_signals.py index aab449c..dcd5388 100644 --- a/test/test_signals.py +++ b/test/test_signals.py @@ -253,7 +253,12 @@ def test_75_interrupt_background_commands(self): # Interrupt the foreground process (should not affect background) child.sendcontrol("c") - child.expect(PROMPT) + try: + child.expect(PROMPT, timeout=5) + except pexpect.TIMEOUT: + # Some PTY/readline combinations only redraw on next Enter. + child.sendline("") + child.expect(PROMPT, timeout=5) # Verify the background command is still running child.sendline("jobs") From 3080537cf9123ba033dd88286c625aa2e4335e21 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 06:23:30 -0400 Subject: [PATCH 22/29] enhance Ctrl+D handling with existing stopped/bg jobs --- lshell/shellcmd.py | 11 +++-- test/test_shellcmd_signal_unit.py | 70 +++++++++++++++++++++++++++++++ test/test_signals.py | 22 ++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 test/test_shellcmd_signal_unit.py diff --git a/lshell/shellcmd.py b/lshell/shellcmd.py index 2eb929a..f7f0d60 100644 --- a/lshell/shellcmd.py +++ b/lshell/shellcmd.py @@ -100,9 +100,6 @@ def __getattr__(self, attr): self.conf["promptprint"] = utils.updateprompt(os.getcwd(), self.conf) self.log = self.conf["logpath"] - if self.g_cmd in ["quit", "exit", "EOF"]: - self.do_exit() - if self.conf["timer"] > 0: self.mytimer(0) @@ -574,6 +571,14 @@ def do_help(self, arg=None): list_tmp.sort() self.columnize(list_tmp) + def do_EOF(self, arg=None): # pylint: disable=invalid-name + """Handle Ctrl+D / EOF exactly like exit.""" + return self.do_exit(arg) + + def do_quit(self, arg=None): + """Handle quit exactly like exit.""" + return self.do_exit(arg) + def do_policy_show(self, arg=None): """Show resolved policy values and optional decision for a command.""" command_line = (arg or "").strip() or None diff --git a/test/test_shellcmd_signal_unit.py b/test/test_shellcmd_signal_unit.py new file mode 100644 index 0000000..9959157 --- /dev/null +++ b/test/test_shellcmd_signal_unit.py @@ -0,0 +1,70 @@ +"""Unit tests for shell signal-related control flow in ShellCmd.""" + +import io +import os +import unittest +from unittest.mock import patch + +from lshell.checkconfig import CheckConfig +from lshell.shellcmd import ShellCmd + +TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" + + +class TestShellCmdSignalUnit(unittest.TestCase): + """Validate ShellCmd behavior for Ctrl+C and Ctrl+D/EOF flows.""" + + args = [f"--config={CONFIG}", "--quiet=1"] + + def _make_shell(self): + conf = CheckConfig(self.args + ["--strict=0"]).returnconf() + shell = ShellCmd( + conf, + args=[], + stdin=io.StringIO(), + stdout=io.StringIO(), + stderr=io.StringIO(), + ) + shell.use_rawinput = False + return shell + + def test_cmdloop_recovers_from_keyboard_interrupt_during_job_check(self): + """Keep cmdloop alive when Ctrl+C races outside input() handling.""" + shell = self._make_shell() + shell.cmdqueue = ["exit"] + + with patch( + "lshell.shellcmd.builtincmd.check_background_jobs", + side_effect=[KeyboardInterrupt, None], + ) as mock_jobs: + with patch( + "lshell.shellcmd.utils.updateprompt", + return_value="unit-prompt$ ", + ) as mock_prompt: + with patch("lshell.shellcmd.readline.write_history_file"): + with patch("lshell.shellcmd.sys.exit", side_effect=SystemExit): + with self.assertRaises(SystemExit): + shell.cmdloop() + + self.assertGreaterEqual(mock_jobs.call_count, 2) + mock_prompt.assert_called_once_with(os.getcwd(), shell.conf) + self.assertEqual(shell.conf["promptprint"], "unit-prompt$ ") + + def test_do_eof_delegates_to_exit(self): + """Route EOF (Ctrl+D) through unified exit behavior.""" + shell = self._make_shell() + with patch.object(shell, "do_exit", return_value=123) as mock_exit: + self.assertEqual(shell.do_EOF(), 123) + mock_exit.assert_called_once_with(None) + + def test_do_quit_delegates_to_exit(self): + """Route quit through unified exit behavior.""" + shell = self._make_shell() + with patch.object(shell, "do_exit", return_value=456) as mock_exit: + self.assertEqual(shell.do_quit(), 456) + mock_exit.assert_called_once_with(None) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_signals.py b/test/test_signals.py index dcd5388..a1aee79 100644 --- a/test/test_signals.py +++ b/test/test_signals.py @@ -314,3 +314,25 @@ def test_77_mix_background_and_foreground(self): assert ( output == expected_output ), f"Expected '{expected_output}', got '{output}'" + + def test_78_ctrl_d_with_stopped_jobs_no_unknown_syntax(self): + """F78 | Ctrl+D with stopped jobs should warn without unknown EOF syntax.""" + child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") + child.expect(PROMPT) + + child.sendline("tail -f") + time.sleep(1) + child.sendcontrol("z") + child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=1) + child.expect(PROMPT) + + # First Ctrl+D should warn and keep shell alive. + child.sendeof() + child.expect("There are stopped jobs.", timeout=5) + child.expect(PROMPT, timeout=5) + output = child.before.decode("utf-8") + assert "unknown syntax: EOF" not in output, output + + # Second Ctrl+D should exit (kill remaining stopped jobs). + child.sendeof() + child.expect(pexpect.EOF, timeout=5) From 0efd0e38781c47aa229e4fbb7bd7856b3866ad8c Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 07:54:34 -0400 Subject: [PATCH 23/29] containment: default unlimited for max_sessions_per_user, max_background_jobs, command_timeout, and max_processes --- etc/lshell.conf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/etc/lshell.conf b/etc/lshell.conf index 47367b0..cba57fd 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -134,13 +134,13 @@ prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" ## Runtime containment limits (disabled by default when set to 0): ## Max concurrent lshell sessions for this user. -#max_sessions_per_user : 0 +max_sessions_per_user : 0 ## Max active background jobs (`&`) tracked in this session. -#max_background_jobs : 0 +max_background_jobs : 0 ## Wall-clock timeout in seconds per executed command. -#command_timeout : 0 +command_timeout : 0 ## Max processes for each spawned command (RLIMIT_NPROC). -#max_processes : 0 +max_processes : 0 ## list of paths to restrict where the user can operate ## warning: commands like vi and less can bypass this restriction From 3434de091282cc7f93c0f8b0132a39414f044636 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 07:54:46 -0400 Subject: [PATCH 24/29] containment: include runtime limits in policy overview and add related tests --- lshell/policy.py | 22 +++++++++++++++++ test/test_policy.py | 60 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/lshell/policy.py b/lshell/policy.py index c27a332..00b39ad 100644 --- a/lshell/policy.py +++ b/lshell/policy.py @@ -13,6 +13,7 @@ from getpass import getuser from lshell import builtincmd +from lshell import containment from lshell import configschema from lshell import sec from lshell import utils @@ -375,6 +376,9 @@ def _build_runtime_policy(conf_raw, username): if ";" in policy["forbidden"]: policy["forbidden"].remove(";") + runtime_limits = containment.get_runtime_limits(conf_raw) + for key in containment.RUNTIME_LIMIT_INT_KEYS: + policy[key] = getattr(runtime_limits, key) return policy @@ -701,11 +705,29 @@ def print_user_view(result, command_line=None, decision=None): timer_value = policy.get("timer") forbidden = sorted(set(policy.get("forbidden", [])), key=str) extensions = policy.get("allowed_file_extensions", []) + def _limit_or_unlimited(value, suffix=""): + return f"{value}{suffix}" if int(value) > 0 else "Unlimited" print(_paint("Policy Overview", "bold", color)) print("-" * 15) print(f"Strict mode : {strict_mode}") print(f"Warnings remaining : {warnings_value}") + print( + "Max sessions/user : " + + _limit_or_unlimited(policy.get("max_sessions_per_user", 0)) + ) + print( + "Max background jobs : " + + _limit_or_unlimited(policy.get("max_background_jobs", 0)) + ) + print( + "Command timeout (sec) : " + + _limit_or_unlimited(policy.get("command_timeout", 0), "s") + ) + print( + "Max processes : " + + _limit_or_unlimited(policy.get("max_processes", 0)) + ) print("") print(_paint("Command Access", "bold", color)) diff --git a/test/test_policy.py b/test/test_policy.py index fc769f9..221285f 100644 --- a/test/test_policy.py +++ b/test/test_policy.py @@ -1,9 +1,11 @@ """Unit tests for lshell policy diagnostics mode.""" +import io import os import tempfile import textwrap import unittest +from contextlib import redirect_stdout from types import SimpleNamespace from unittest.mock import patch @@ -423,6 +425,64 @@ def do_policy_show(self, arg): self.assertEqual(retcode, 0) self.assertEqual(ctx.called, "echo hi") + def test_print_user_view_includes_containment_settings(self): + """EX11 | policy overview includes runtime containment limits.""" + result = { + "policy": { + "username": "bleh", + "strict": 1, + "warning_counter": 2, + "allowed": ["ls"], + "aliases": {}, + "sudo_commands": [], + "timer": 0, + "forbidden": [";"], + "allowed_file_extensions": [], + "max_sessions_per_user": 2, + "max_background_jobs": 3, + "command_timeout": 15, + "max_processes": 10, + } + } + + with redirect_stdout(io.StringIO()) as output: + policy.print_user_view(result) + rendered = output.getvalue() + + self.assertIn("Max sessions/user : 2", rendered) + self.assertIn("Max background jobs : 3", rendered) + self.assertIn("Command timeout (sec) : 15s", rendered) + self.assertIn("Max processes : 10", rendered) + + def test_print_user_view_shows_unlimited_for_zero_containment_limits(self): + """EX12 | zero-valued containment limits should render as Unlimited.""" + result = { + "policy": { + "username": "bleh", + "strict": 0, + "warning_counter": 2, + "allowed": ["ls"], + "aliases": {}, + "sudo_commands": [], + "timer": 0, + "forbidden": [";"], + "allowed_file_extensions": [], + "max_sessions_per_user": 0, + "max_background_jobs": 0, + "command_timeout": 0, + "max_processes": 0, + } + } + + with redirect_stdout(io.StringIO()) as output: + policy.print_user_view(result) + rendered = output.getvalue() + + self.assertIn("Max sessions/user : Unlimited", rendered) + self.assertIn("Max background jobs : Unlimited", rendered) + self.assertIn("Command timeout (sec) : Unlimited", rendered) + self.assertIn("Max processes : Unlimited", rendered) + if __name__ == "__main__": unittest.main() From 5e51a3d0f228b157657ee78df693068a67bd1be3 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 08:15:39 -0400 Subject: [PATCH 25/29] docs: add best practice note for command_timeout and max_processes in README and man page --- README.md | 1 + etc/lshell.conf | 2 ++ man/lshell.1 | 2 ++ 3 files changed, 5 insertions(+) diff --git a/README.md b/README.md index 60b6430..7def3c2 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Operational notes: - `max_background_jobs` denies new `&` jobs once the configured active count is reached. - `command_timeout` enforces a per-command wall-clock timeout (foreground and background commands). - `max_processes` is applied via POSIX `RLIMIT_NPROC` on spawned command processes. +- Best practice: keep `command_timeout` enabled whenever `max_processes` is strict (especially `1`). ### Best practices diff --git a/etc/lshell.conf b/etc/lshell.conf index cba57fd..765fe58 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -140,6 +140,8 @@ max_background_jobs : 0 ## Wall-clock timeout in seconds per executed command. command_timeout : 0 ## Max processes for each spawned command (RLIMIT_NPROC). +## Best practice: keep command_timeout enabled whenever +## max_processes is strict (especially 1). max_processes : 0 ## list of paths to restrict where the user can operate diff --git a/man/lshell.1 b/man/lshell.1 index 20555e3..1839111 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -460,6 +460,8 @@ Set to \fB0\fR to disable this limit (default). .I max_processes maximum processes per spawned command via \fBRLIMIT_NPROC\fR. Set to \fB0\fR to disable this limit (default). +Best practice: keep \fBcommand_timeout\fR enabled whenever \fBmax_processes\fR +is strict (especially \fB1\fR). .TP .I umask set process umask for the lshell session. Value must be octal (0000 to 0777), From 05fefbed8c5bada797a2834f034fc214c1f5e1df Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 08:33:28 -0400 Subject: [PATCH 26/29] ci: update GitHub Actions workflow for PyPI publishing with version checks and conditions --- .github/workflows/pypi-publish.yml | 140 +++++++++++++---------------- 1 file changed, 60 insertions(+), 80 deletions(-) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 32f9b15..0ecd993 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -2,109 +2,89 @@ name: Publish Python 🐍 distribution 📦 to PyPI on: push: + branches: + - pre-release tags: - - "*" + - "*" permissions: contents: read jobs: - validate-release-channel: - name: Validate release channel + publish: runs-on: ubuntu-latest - outputs: - branch_channel: ${{ steps.validate.outputs.branch_channel }} - release_type: ${{ steps.validate.outputs.release_type }} + permissions: + id-token: write steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 - - name: Validate tag and branch channel - id: validate + fetch-depth: 2 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Decide whether to publish + id: decide env: - TAG_NAME: ${{ github.ref_name }} - TAG_SHA: ${{ github.sha }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + BEFORE_SHA: ${{ github.event.before }} run: | set -euo pipefail - git fetch --no-tags origin main pre-release - TAG_VERSION="${TAG_NAME}" - PACKAGE_VERSION="$(python3 -c 'from lshell.variables import __version__; print(__version__)')" - - if [[ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]]; then - echo "Tag/version mismatch: tag=${TAG_VERSION}, package=${PACKAGE_VERSION}" >&2 - exit 1 - fi + CURRENT_VERSION="$(python3 -c 'from lshell.variables import __version__; print(__version__)')" + echo "version=${CURRENT_VERSION}" >> "${GITHUB_OUTPUT}" - if [[ "${TAG_NAME}" == *rc* ]]; then - if git merge-base --is-ancestor "${TAG_SHA}" "origin/pre-release"; then - echo "branch_channel=pre-release" >> "${GITHUB_OUTPUT}" - echo "release_type=release-candidate" >> "${GITHUB_OUTPUT}" + # Official releases: tag is required and must match package version. + if [[ "${REF_TYPE}" == "tag" ]]; then + if [[ "${REF_NAME}" == "${CURRENT_VERSION}" ]]; then + echo "should_publish=true" >> "${GITHUB_OUTPUT}" + echo "reason=tag_release" >> "${GITHUB_OUTPUT}" else - echo "Tag ${TAG_NAME} is an RC but is not based on pre-release." >&2 - exit 1 - fi - else - if git merge-base --is-ancestor "${TAG_SHA}" "origin/main"; then - echo "branch_channel=main" >> "${GITHUB_OUTPUT}" - echo "release_type=stable" >> "${GITHUB_OUTPUT}" - else - echo "Tag ${TAG_NAME} is stable but is not based on main." >&2 - exit 1 + echo "should_publish=false" >> "${GITHUB_OUTPUT}" + echo "reason=tag_version_mismatch" >> "${GITHUB_OUTPUT}" fi + exit 0 fi - build: - name: Build distribution 📦 - needs: - - validate-release-channel - runs-on: ubuntu-latest + # Auto RC release from pre-release only when version file changed. + if [[ "${REF_NAME}" != "pre-release" ]]; then + echo "should_publish=false" >> "${GITHUB_OUTPUT}" + echo "reason=not_pre_release_branch" >> "${GITHUB_OUTPUT}" + exit 0 + fi - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ + if [[ "${CURRENT_VERSION}" != *rc* ]]; then + echo "should_publish=false" >> "${GITHUB_OUTPUT}" + echo "reason=not_rc_version" >> "${GITHUB_OUTPUT}" + exit 0 + fi - publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - needs: - - validate-release-channel - - build - runs-on: ubuntu-latest + if [[ -z "${BEFORE_SHA}" || "${BEFORE_SHA}" =~ ^0+$ ]]; then + echo "should_publish=false" >> "${GITHUB_OUTPUT}" + echo "reason=missing_before_sha" >> "${GITHUB_OUTPUT}" + exit 0 + fi - environment: - name: pypi - url: https://pypi.org/p/limited-shell - permissions: - id-token: write # IMPORTANT: mandatory for trusted publishing + if ! git diff --name-only "${BEFORE_SHA}..${SHA}" | grep -qx "lshell/variables.py"; then + echo "should_publish=false" >> "${GITHUB_OUTPUT}" + echo "reason=version_file_not_changed" >> "${GITHUB_OUTPUT}" + exit 0 + fi - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Print release channel - env: - RELEASE_TYPE: ${{ needs.validate-release-channel.outputs.release_type }} - BRANCH_CHANNEL: ${{ needs.validate-release-channel.outputs.branch_channel }} + echo "should_publish=true" >> "${GITHUB_OUTPUT}" + echo "reason=rc_version_file_changed" >> "${GITHUB_OUTPUT}" + - name: Decision summary + run: | + echo "should_publish=${{ steps.decide.outputs.should_publish }}" + echo "version=${{ steps.decide.outputs.version }}" + echo "reason=${{ steps.decide.outputs.reason }}" + - name: Build distribution 📦 + if: steps.decide.outputs.should_publish == 'true' run: | - echo "Publishing ${GITHUB_REF_NAME} as ${RELEASE_TYPE} from ${BRANCH_CHANNEL}" + python3 -m pip install --upgrade build + python3 -m build - name: Publish distribution 📦 to PyPI + if: steps.decide.outputs.should_publish == 'true' uses: pypa/gh-action-pypi-publish@release/v1 From aa9710306e80b82b1a049c72edc3416bed68fec2 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 08:41:25 -0400 Subject: [PATCH 27/29] ci: Fix environment for PyPI publishing in workflow --- .github/workflows/pypi-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 0ecd993..1147f08 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -13,6 +13,9 @@ permissions: jobs: publish: runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/limited-shell permissions: id-token: write From 742075d136d547a8b950171b19defbc96602db41 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 18 Mar 2026 08:42:18 -0400 Subject: [PATCH 28/29] Bump version to 0.11.1rc4 --- lshell/variables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lshell/variables.py b/lshell/variables.py index 1798c85..45103de 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.11.1rc3" +__version__ = "0.11.1rc4" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] From 648f952e6660086dfef0238744a8990d1cb9fd53 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Sat, 21 Mar 2026 21:58:30 -0400 Subject: [PATCH 29/29] Update version to 0.11.1 --- CHANGELOG.md | 31 +++++++++++++------------------ lshell/variables.py | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe8e76..e9fb024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,24 +3,19 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) [https://github.com/ghantoos/lshell](https://github.com/ghantoos/lshell) -### v0.11.1rc1 11/03/2026 -- Added handling for `command not found` messages, with dedicated test coverage. - -### v0.11.1rc2 17/03/2026 -- Added `lshell harden-init` to generate secure baseline configs from vetted profiles. -- Shipped hardened templates: `sftp-only`, `rsync-backup`, `deploy-minimal`, and `readonly-support`. -- Added pre-write profile validation, `--dry-run` sanity checks, and inline hardening comments in generated output. -- Added `--group` and `--user` flags to render scoped `[grp:*]` / `[user:*]` sections directly from `harden-init`. -- Added unit and functional tests for harden-init rendering and CLI flows. -- Added Bash completion packaging and runtime dependencies for DEB/RPM (`bash-completion`). -- Changed `harden-init` default output path to `/etc/lshell.d/.conf`. -- Enabled `include_dir : /etc/lshell.d/*.conf` in the default `/etc/lshell.conf` template. - -### v0.11.1rc3 18/03/2026 -- Added runtime containment `max_sessions_per_user` with lock-protected per-user session accounting and startup enforcement. -- Added runtime containment `max_background_jobs` enforcement for interactive `&` job creation with denial audit reasons. -- Added runtime containment `command_timeout` to terminate overlong foreground/background commands and report timeout denials. -- Added runtime containment `max_processes` with best-effort `RLIMIT_NPROC` enforcement for spawned commands. +### v0.11.1 21/03/2026 +- Feature: Added `lshell setup-system` to provision logging paths/permissions and user/group wiring for deployments. +- Feature: Added `lshell harden-init` with hardened templates (`sftp-only`, `rsync-backup`, `deploy-minimal`, `readonly-support`) plus `--dry-run`, scoped `[grp:*]`/`[user:*]`, and validation checks. +- Feature: Added configurable handling for `command not found` messages. +- Feature: Hardened CLI env argument parsing for `LSHELL_ARGS`. +- Feature: Added ECS-compatible JSON security audit events via `security_audit_json`. +- Feature: Added runtime containment controls: `max_sessions_per_user`, `max_background_jobs`, `command_timeout`, and `max_processes` (`RLIMIT_NPROC`), and surfaced them in policy diagnostics. +- Feature: Improved shell signal behavior for `Ctrl+C`/interrupt flows and `Ctrl+D` handling when stopped/background jobs exist. +- Package: Added packaged Bash completion support (`etc/bash_completion.d/lshell`). +- Package: Updated DEB/RPM packaging and smoke-test scripts for more stable build/install validation. +- Package: Migrated packaging/build metadata to `pyproject.toml` (PEP 517) and removed `setup.py`. +- Tests: Expanded audit test coverage for structured security events. +- Tests: Added parser fuzzing support (`Atheris`) and expanded security/property-based tests. ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. diff --git a/lshell/variables.py b/lshell/variables.py index 45103de..8583785 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.11.1rc4" +__version__ = "0.11.1" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"]