diff --git a/.github/workflows/lshell-tests.yml b/.github/workflows/lshell-tests.yml index 3a7e014..bbfeb9e 100644 --- a/.github/workflows/lshell-tests.yml +++ b/.github/workflows/lshell-tests.yml @@ -20,9 +20,9 @@ jobs: python-version: ["3.10", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Set up Python path @@ -34,6 +34,9 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Install the lshell package run: pip install . + - name: Check config coverage manifest freshness + run: | + python3 scripts/update_config_coverage_manifest.py --check - name: Test with pytest run: | pytest @@ -47,9 +50,9 @@ jobs: python-version: ["3.10", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Set up Python path @@ -72,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install just uses: taiki-e/install-action@just - name: Fuzz security parser/policy @@ -85,7 +88,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - 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 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 1147f08..402792f 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -20,11 +20,11 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 2 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" - name: Decide whether to publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 651fb04..a757fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) - Refactor: Removed legacy `lshell.parser` compatibility wrapper; runtime and diagnostics now rely on canonical `lshell.engine.*` parsing paths only. - Refactor: Reorganized configuration code into `lshell/config/` with focused modules (`runtime.py`, `diagnostics.py`, `resolve.py`, `schema.py`) and updated imports accordingly. - Config: Unified runtime (`CheckConfig`) and diagnostics (`policy-show`) merge logic into a shared resolver to keep section precedence, include overlays, +/- list operations, `all` expansion, and glob-path handling aligned. +- Runtime/Security: Added explicit `runtime_executor` mode selection (`shellless` default, `bash_compat` opt-in), trusted absolute bash resolution (no PATH lookup), and retained env hardening (`BASH_ENV`/`ENV`/`BASH_FUNC_*` scrubbing). - CLI: Restored split diagnostics naming: `policy-show` as the external subcommand and `lshow` as the in-shell builtin. - CLI: Removed legacy/extra diagnostics commands `lpath`, `lsudo`, `policy-path`, and `policy-sudo`. - UX: Extended `policy-show` output to include path allow/deny details and sudo policy details directly. diff --git a/README.md b/README.md index fb13713..3fe7604 100644 --- a/README.md +++ b/README.md @@ -102,12 +102,6 @@ Inside an interactive session: `lshow` includes effective command policy, allowed/denied paths, and sudo policy in one output. -Hide these built-ins if needed: - -```ini -policy_commands : 0 -``` - ## Hardened profile generator `harden-init` ships secure-by-default templates to bootstrap restricted accounts quickly: diff --git a/ansible/playbooks/ssh_e2e.yml b/ansible/playbooks/ssh_e2e.yml index fb7419e..7f5e3cb 100644 --- a/ansible/playbooks/ssh_e2e.yml +++ b/ansible/playbooks/ssh_e2e.yml @@ -13,6 +13,8 @@ test_user_winscp: ltestwinscp test_user_pipe_on: ltestpipeon test_user_alias: ltestalias + test_user_shellless: ltestshellless + test_user_bash_compat: ltestbashcompat test_user_scp_over: ltestscpover test_user_scp_force: ltestscpforce test_home: /home/ltest @@ -24,6 +26,8 @@ test_user_winscp_password: ltestwinscppass test_user_pipe_on_password: ltestpipeonpass test_user_alias_password: ltestaliaspass + test_user_shellless_password: ltestshelllesspass + test_user_bash_compat_password: ltestbashcompatpass test_user_scp_over_password: ltestscpoverpass test_user_scp_force_password: ltestscpforcepass tasks: @@ -158,6 +162,32 @@ executable: /bin/bash changed_when: false + - name: Create SSH test user with explicit shellless runtime executor profile + ansible.builtin.user: + name: "{{ test_user_shellless }}" + shell: "{{ lshell_bin }}" + create_home: true + + - name: Set password for shellless-runtime SSH test user + ansible.builtin.shell: | + echo "{{ test_user_shellless }}:{{ test_user_shellless_password }}" | chpasswd + args: + executable: /bin/bash + changed_when: false + + - name: Create SSH test user with bash_compat runtime executor profile + ansible.builtin.user: + name: "{{ test_user_bash_compat }}" + shell: "{{ lshell_bin }}" + create_home: true + + - name: Set password for bash_compat SSH test user + ansible.builtin.shell: | + echo "{{ test_user_bash_compat }}:{{ test_user_bash_compat_password }}" | chpasswd + args: + executable: /bin/bash + changed_when: false + - name: Create SSH test user with scp fallback through overssh ansible.builtin.user: name: "{{ test_user_scp_over }}" @@ -221,6 +251,7 @@ - { user: "{{ test_user_scp_off }}" } - { user: "{{ test_user_winscp }}" } - { user: "{{ test_user_alias }}" } + - { user: "{{ test_user_bash_compat }}" } - { user: "{{ test_user_scp_over }}" } - { user: "{{ test_user_scp_force }}" } @@ -265,6 +296,8 @@ ssh_user_winscp: ltestwinscp ssh_user_pipe_on: ltestpipeon ssh_user_alias: ltestalias + ssh_user_shellless: ltestshellless + ssh_user_bash_compat: ltestbashcompat ssh_user_scp_over: ltestscpover ssh_user_scp_force: ltestscpforce ssh_host: lshell-ssh-target @@ -276,6 +309,8 @@ ssh_password_winscp: ltestwinscppass ssh_password_pipe_on: ltestpipeonpass ssh_password_alias: ltestaliaspass + ssh_password_shellless: ltestshelllesspass + ssh_password_bash_compat: ltestbashcompatpass ssh_password_scp_over: ltestscpoverpass ssh_password_scp_force: ltestscpforcepass ssh_common_opts: >- diff --git a/ansible/templates/lshell.conf.j2 b/ansible/templates/lshell.conf.j2 index 2f7b0a2..e6188b7 100644 --- a/ansible/templates/lshell.conf.j2 +++ b/ansible/templates/lshell.conf.j2 @@ -12,6 +12,7 @@ scp_upload : 0 scp_download : 1 sftp : 1 strict : 1 +runtime_executor : shellless warning_counter : 2 allowed_file_extensions : ['.txt'] @@ -39,6 +40,14 @@ forbidden : [';', '&', '`', '>', '<', '$(', '${'] [ltestalias] aliases : {'ll': 'ls'} +[ltestshellless] +runtime_executor : shellless +overssh : + ['echo'] + +[ltestbashcompat] +runtime_executor : bash_compat +overssh : + ['echo'] + [ltestscpover] scp : 0 diff --git a/ansible/vars/ssh_scenarios.yml b/ansible/vars/ssh_scenarios.yml index 7ec96fb..6edacd6 100644 --- a/ansible/vars/ssh_scenarios.yml +++ b/ansible/vars/ssh_scenarios.yml @@ -100,6 +100,20 @@ ssh_scenarios: expected_rc: 0 expect_text: "hello from txt" + - name: shellless runtime_executor keeps tilde literal + command: "echo ~" + user: "ltestshellless" + password: "ltestshelllesspass" + expected_rc: 0 + expect_text: "~" + + - name: bash_compat runtime_executor expands tilde + command: "echo ~" + user: "ltestbashcompat" + password: "ltestbashcompatpass" + expected_rc: 0 + expect_text: "/home/ltestbashcompat" + - name: blocks chained extension violation command: "cat readme.txt && cat secret.bin" expected_rc: 1 diff --git a/docs/shellless-execution-migration.md b/docs/shellless-execution-migration.md new file mode 100644 index 0000000..2590964 --- /dev/null +++ b/docs/shellless-execution-migration.md @@ -0,0 +1,41 @@ +# Shellless Execution Migration + +This change removes implicit runtime `shell -c` execution and introduces an explicit +runtime executor mode switch: + +- `runtime_executor=shellless` (default, hardened/fail-closed) +- `runtime_executor=bash_compat` (explicit compatibility opt-in) + +## Compatibility Matrix + +| Feature | Policy/Parser Handling | `shellless` Runtime | `bash_compat` Runtime | Notes | +| --- | --- | --- | --- | --- | +| Quoting (`'...'`, `"..."`, escapes) | Parsed by `shlex` and engine splitters | Supported | Supported | Quoting is parsed by lshell in shellless; delegated to bash in compat mode. | +| Variable expansion (`$VAR`, `${...}` subset) | Authorized through expansion inspector checks | Supported for lshell-supported forms via `expand_vars_quoted` | Supported by bash | Unsupported/unsafe forms remain fail-closed in policy path. | +| Globbing (`*`, `?`, `[]`, brace patterns) | Path ACL checks expand wildcard/brace forms for authorization | No implicit shell glob expansion at execution time | Bash globbing semantics | Path ACL checks still run before execution. | +| Pipelines (`|`) | Parsed by canonical engine | Supported via explicit `Popen` pipe wiring | Supported via bash parser/executor | Runtime containment still applies per execution mode constraints. | +| `&&`, `||`, `;`, `&` | Canonical engine sequencing/branching logic | Supported | Supported | | +| Command substitution (`$(...)`, `` `...` ``) | Nested commands still recursively authorized (allowlist/path) | Unsupported at runtime (fail-closed) | Allowed (historic behavior) | | +| Process substitution (`<(...)`, `>(...)`) | Nested commands still recursively authorized (allowlist/path) | Unsupported at runtime (fail-closed) | Allowed (historic behavior) | | +| Redirections (`>`, `<`, `>>`, `2>&1`, here-doc/here-string forms) | Existing checks continue to classify malformed forms | Unsupported at runtime (fail-closed) | Supported by bash syntax, still subject to policy checks | Forbidden-character config can still block metacharacters. | + +### Runtime Executor Matrix + +| `runtime_executor` | Effective behavior | +| --- | --- | +| `shellless` | Hardened mode: command/process substitution denied at runtime. | +| `bash_compat` | Compatibility mode: command/process substitution allowed (historic behavior). | + +## Security Outcome + +- Default mode (`shellless`) keeps fail-closed behavior with no external shell parser. +- `bash_compat` uses only trusted absolute bash candidates (`/bin/bash`, `/usr/bin/bash`, etc.); no PATH-based interpreter lookup is used. +- Nested allowlist/path checks for commands inside substitutions remain enforced before execution. +- Environment hardening remains in place for both modes: `BASH_ENV`, `ENV`, and `BASH_FUNC_*` are stripped from child envs. +- `sudo_noexec` probe remains shellless and uses a trusted absolute `true` binary. + +## Known Compatibility Tradeoffs + +1. `bash_compat` is a compatibility mode and broadens runtime shell semantics versus `shellless`. +2. In `shellless`, shell-only forms (substitution/redirection) are denied explicitly. +3. In `bash_compat`, runtime containment limits such as pipeline-stage counting apply to the outer bash process, not each shell-internal stage. diff --git a/etc/lshell.conf b/etc/lshell.conf index bbdb844..caf8c06 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -1,228 +1,133 @@ -# lshell.py configuration file -# -# $Id: lshell.conf,v 1.27 2010-10-18 19:05:17 ghantoos Exp $ +# lshell configuration +# See 'man lshell' for full option details. [global] -## log directory (default: /var/log/lshell/) -logpath : /var/log/lshell/ -## set log level to 0, 1, 2, 3 or 4 (0: no logs, 1: least verbose, -## 4: log all commands) -loglevel : 2 -## configure log file name (default is %u i.e. username.log) -#logfilename : %y%m%d-%u -#logfilename : syslog - -## 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 +# --- 1) Security baseline --- +# Optional override for sudo_noexec shared library path. +# WARNING: empty string disables noexec preload and weakens confinement. +#path_noexec : '/usr/libexec/sudo/sudo_noexec.so' + +# --- 6) Observability and audit --- +# Log destination and verbosity for session events. +logpath : /var/log/lshell/ +loglevel : 2 +# Optional filename pattern (%u=username, %y%m%d=date) or syslog. +#logfilename : %y%m%d-%u +#logfilename : syslog +# Optional syslog app name when using syslog output. +#syslogname : lshell + +# JSON security audit events for SIEM ingestion (0=off, 1=on). 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 -## to verify that a standard path is detected. -## -## while this should not be a common practice, setting this variable to an empty -## string will disable LD_PRELOAD prepend of the commands. This is done at your -## own risk, as lshell becomes easily breached using some commands like find(1) -## using the -exec flag. -#path_noexec : '/usr/libexec/sudo/sudo_noexec.so' - -## include a directory containing multiple configuration files. -## 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 - -## section precedence reminder (highest to lowest): -## 1) [username] -## 2) [grp:groupname] -## 3) [default] +# --- 8) Config loading --- +# Load extra [default]/[user]/[grp:*] files from this glob. +include_dir : /etc/lshell.d/*.conf + +# Precedence: [username] > [grp:groupname] > [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' -## local commands must be explicitly listed with their relative path -## (e.g. './backup.sh') -## -## if sudo(8) is installed and sudo_noexec.so is available, it will be loaded -## before running every command, preventing it from running further commands -## itself. If not available, beware of commands like vim/find/more/etc. that -## will allow users to execute code (e.g. /bin/sh) from within the application, -## thus easily escaping lshell. See variable 'path_noexec' to use an alternative -## path to the library. -allowed : ['ls','echo','ll','vim','tail','sleep','touch','mkdir','cat','export', 'pwd'] -#allowed : ['echo test'] # this will allow only the command 'echo test' - -## a list of allowed commands that are permitted to execute other -## programs (e.g. shell scripts with exec(3)). Setting this variable to 'all' -## is NOT allowed. warning: do not put here any command that can execute -## arbitrary commands (e.g. find, vim, xargs) -## -## Important: commands defined in 'allowed_shell_escape' override their -## definition in the 'allowed' variable -#allowed_shell_escape : ['man','zcat'] - -## a list of allowed file extensions that can be provided in the command line. -## if set, all other file extensions are denied. -#allowed_file_extensions : ['.tmp', '.log'] +# --- 1) Critical security baseline --- +# Executor mode: keep shellless for hardened parsing. +# WARNING: bash_compat enables shell features and raises escape risk. +runtime_executor : shellless -## a list of forbidden characters or commands -forbidden : [';','&', '|','`','>','<', '$(','${'] - -## a list of allowed commands to use with sudo(8) -## if set to 'all', all values from 'allowed' are accessible through sudo(8) -sudo_commands : ['ls', 'more'] - -## number of warnings before lshell terminates the session. -## set to -1 to disable the counter. -warning_counter : 2 - -## command aliases list (similar to bash’s alias directive) -aliases : {'ll':'ls -l'} - -## customize user-facing messages -## supported keys: -## 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 -# 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': 'lshell: This incident has been reported.' -# } - -## introduction text to print (when entering lshell) -#intro : "== My personal intro ==\nWelcome to lshell\nType '?' or 'help' to get the list of allowed commands" - -## configure your prompt using %u (username) and %h (hostname) -## accessibility tip: use plain text by default; color-only distinction can be -## hard to read for some users and terminals. -#prompt : "%u@%h" -## optional colorized prompt using ANSI escape codes (light red user, light cyan host): -prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" - - -## set prompt path style (0, 1, or 2; default: 0) -## 0 -> ~/relative/path when inside home, absolute path otherwise -## 1 -> only current directory basename -## 2 -> full absolute current path -## note: if $LPS1 is set, prompt_short is ignored -#prompt_short : 0 - -## 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 -## Max active background jobs (`&`) tracked in this session. -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 +# Strict mode: 1 counts unknown syntax as violations, 0 only reports. +strict : 0 +# Violations before session termination (-1 disables this control). +warning_counter : 2 -## 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'] +# --- 2) Core command authorization policy --- +# Command allow-list. Prefer explicit commands over 'all'. +allowed : ['ls','echo','ll','vim','tail','sleep','touch','mkdir','cat','export', 'pwd'] +#allowed : ['echo test'] # Example: allow one exact command -## set the home folder for your user. if not specified, home_path is set to -## the $HOME environment variable -## deprecated: prefer setting the home directory with system account tools. -#home_path : '/home/bla/' +# Deny-list for dangerous control tokens. +forbidden : [';','&', '|','`','>','<', '$(','${'] -## update the environment variable $PATH of the user -#env_path : '/usr/local/bin:/usr/sbin' +# Commands from allow-list that may run through sudo. +sudo_commands : ['ls', 'more'] -## a list of paths; all executable files inside these paths are allowed -#allowed_cmd_path: ['/home/bla/bin','/home/bla/stuff/libexec'] +# Commands allowed to spawn subcommands (never add shell-capable tools). +#allowed_shell_escape : ['man','zcat'] + +# Optional allow-list for argument file extensions. +#allowed_file_extensions : ['.tmp', '.log'] -## add environment variables -#env_vars : {'foo':1, 'bar':'helloworld'} +# --- 3) SSH / SCP / SFTP controls --- +# Commands allowed via non-interactive SSH (for example rsync). +#overssh : ['ls', 'rsync'] -## add environment variables from file -## file format is: export key=value, one per line -#env_vars_files : ['$HOME/.lshell.env'] +# Transfer feature gates (1=allow, 0=deny). +#scp : 1 +#scp_upload : 0 +#scp_download : 0 +#sftp : 1 -## allow or forbid the use of scp (set to 1 or 0) -#scp : 1 +# Force inbound scp files into one directory. +#scpforce : '/home/bla/uploads/' -## forbid scp upload -#scp_upload : 0 +# WinSCP compatibility for scp mode only (not sftp). +# WARNING: enabling overrides scp_upload/scp_download/scpforce and relaxes policy. +#winscp : 0 -## forbid scp download -#scp_download : 0 +# --- 4) Path and environment controls --- +# Restrict accessible paths for command arguments. +# WARNING: some editors/pagers can bypass this control. +#path : ['/etc','/var/log','/var/lib'] -## allow or forbid the use of sftp (set to 1 or 0) -## this option will not work if you are using OpenSSH's internal-sftp service -#sftp : 1 +# Optional session HOME override (prefer account-level home management). +#home_path : '/home/bla/' -## list of commands allowed to execute over ssh (e.g. rsync, rdiff-backup) -#overssh : ['ls', 'rsync'] +# Optional PATH override and extra executable search roots. +#env_path : '/usr/local/bin:/usr/sbin' +#allowed_cmd_path : ['/home/bla/bin','/home/bla/stuff/libexec'] + +# Inject environment values directly or from export-style files. +#env_vars : {'foo':1, 'bar':'helloworld'} +#env_vars_files : ['$HOME/.lshell.env'] + +# Session umask in octal (for example 0002 shared-write, 0077 private). +#umask : 0002 + +# --- 5) Runtime containment and session safety --- +# Set each limit to 0 to disable it. +max_sessions_per_user : 0 +max_background_jobs : 0 +max_processes : 0 +command_timeout : 0 -## strictness mode. if set to 1, unknown syntax/commands are considered -## forbidden and decrement warning_counter (which can kick the user out). -## If set to 0, they are reported as unknown syntax only. -strict : 0 +# Optional session timer and history limits. +#timer : 5 +#history_size : 100 +#history_file : "/home/%u/.lshell_history" -## force files sent through scp to a specific directory -#scpforce : '/home/bla/uploads/' +# --- 6) Observability and audit --- +# Logging and JSON audit options are configured in [global]. -## Enable support for WinSCP with scp mode (NOT sftp) -## When enabled, the following parameters will be overridden: -## - scp_upload: 1 (uses scp(1) from within session) -## - scp_download: 1 (uses scp(1) from within session) -## - scpforce - Ignore (uses scp(1) from within session) -## - forbidden: -[';'] -## - allowed: +['scp', 'env', 'pwd', 'groups', 'unset', 'unalias'] -#winscp: 0 +# --- 7) UX and customization --- +# Optional login banner shown at session start. +#intro : "== My personal intro ==\nWelcome to lshell\nType '?' or 'help' to get the list of allowed commands" -## history file maximum size -#history_size : 100 +# Prompt format tokens: %u=username, %h=hostname. +#prompt : "%u@%h" +prompt : "\033[91m%u\033[97m@\033[96m%h\033[0m" -## set history file name (default is /home/%u/.lhistory) -#history_file : "/home/%u/.lshell_history" +# Prompt path style: 0=home-relative, 1=basename, 2=absolute. +#prompt_short : 0 -## set process umask for the lshell session (octal, e.g. 0022 or 0002) -## this is the persistent way to set file mode creation mask in lshell -## examples: -## 0002 -> files 664 / dirs 775 -## 0022 -> files 644 / dirs 755 -## 0077 -> files 600 / dirs 700 -#umask : 0002 +# Operator-defined command aliases. +aliases : {'ll':'ls -l'} -## define the script to run at user login -## note: login_script is executed in a child shell process; shell state -## changes there (such as umask) do not persist in the lshell parent process -#login_script : "/path/to/myscript.sh" +# Optional message overrides (keys include unknown_syntax, forbidden_*, warning_remaining, session_terminated). +#messages : { +# 'unknown_syntax': 'lshell: unknown syntax: {command}', +# 'forbidden_command': 'lshell: forbidden command: "{command}"' +# } -## disable user exit; useful when lshell is spawned from another -## non-restricted shell (e.g. bash) -#disable_exit : 0 +# --- 8) Legacy / advanced options --- +# Run a script at login (executes in a child shell). +#login_script : "/path/to/myscript.sh" -## show/hide policy introspection builtins: -## lshow -## set to 0 to hide these commands from users -#policy_commands : 0 +# Prevent user-initiated exit from lshell. +#disable_exit : 0 diff --git a/justfile b/justfile index 3d05f74..a73b737 100644 --- a/justfile +++ b/justfile @@ -184,6 +184,14 @@ test-fedora-pypi-pre: test-ssh-e2e: ./scripts/test-ssh-e2e.sh "{{e2e_compose}}" +# Update config coverage manifest (supported keys + test fingerprint) +test-manifest-update: + python3 scripts/update_config_coverage_manifest.py --write + +# Verify config coverage manifest freshness +test-manifest-check: + python3 scripts/update_config_coverage_manifest.py --check + # Lint Python sources test-lint-flake8: pylint lshell test diff --git a/lshell/builtincmd.py b/lshell/builtincmd.py index f60f73c..a7760f8 100644 --- a/lshell/builtincmd.py +++ b/lshell/builtincmd.py @@ -17,10 +17,6 @@ # Store background jobs BACKGROUND_JOBS = [] -POLICY_COMMANDS = [ - "lshow", -] - builtins_list = [ "cd", "ls", @@ -53,6 +49,68 @@ def _cancel_job_timeout(job): timer.cancel() +def _job_members(job): + """Return every process member that belongs to a tracked job.""" + members = getattr(job, "lshell_pipeline", None) + if not members: + return [job] + return [member for member in members if member is not None] + + +def _job_is_running(job): + """Return True while any pipeline member is still alive.""" + return any(member.poll() is None for member in _job_members(job)) + + +def _job_returncode(job): + """Return the job's terminal stage return code once known.""" + members = _job_members(job) + if not members: + return getattr(job, "returncode", None) + + terminal = members[-1] + if terminal.returncode is not None: + return terminal.returncode + + poll_value = terminal.poll() + if poll_value is not None: + return terminal.returncode if terminal.returncode is not None else poll_value + + return getattr(job, "returncode", None) + + +def _signal_job(job, signum): + """Send a signal to all running members of a job.""" + running_members = [member for member in _job_members(job) if member.poll() is None] + if not running_members: + return + + if os.name == "posix": + job_pgid = getattr(job, "lshell_pgid", None) + if job_pgid is not None: + try: + os.killpg(job_pgid, signum) + return + except OSError: + pass + + for member in running_members: + try: + os.kill(member.pid, signum) + except OSError: + continue + + +def _wait_job_members(job): + """Wait for every still-running process in a job.""" + for member in _job_members(job): + if member.poll() is not None: + continue + wait_method = getattr(member, "wait", None) + if callable(wait_method): + wait_method() + + def cmd_lpath(conf): """Show path policy in a concise, readable format.""" current_dir = os.path.realpath(os.getcwd()) @@ -214,7 +272,7 @@ def check_background_jobs(): """Check the status of background jobs and print a completion message if done.""" active_jobs = [] for idx, job in enumerate(BACKGROUND_JOBS, start=1): - if job.poll() is None: + if _job_is_running(job): active_jobs.append(job) continue @@ -223,10 +281,11 @@ def check_background_jobs(): print(f"[{idx}]+ Timed Out {_job_command(job)}") continue - status = "Done" if job.returncode == 0 else "Failed" + returncode = _job_returncode(job) + status = "Done" if returncode == 0 else "Failed" args = _job_command(job) # only print if the job has not been interrupted by the user - if job.returncode != -2: + if returncode != -2: print(f"[{idx}]+ {status} {args}") BACKGROUND_JOBS[:] = active_jobs @@ -236,9 +295,9 @@ 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: + if _job_is_running(job): status = "Stopped" - elif job.poll() == 0: + elif _job_returncode(job) == 0: status = "Completed" # Process completed successfully else: status = "Killed" # Process was killed or terminated with a non-zero code @@ -255,7 +314,7 @@ def jobs(): joblist = [] active_jobs = [] for job in BACKGROUND_JOBS: - if job.poll() is not None: + if not _job_is_running(job): _cancel_job_timeout(job) continue @@ -315,7 +374,7 @@ def cmd_bg_fg(job_type, job_id): if 0 < job_id <= len(BACKGROUND_JOBS): job = BACKGROUND_JOBS[job_id - 1] - if job.poll() is None: + if _job_is_running(job): if job_type == "fg": class CtrlZForeground(Exception): """Raised when the foreground job is suspended with Ctrl+Z.""" @@ -324,8 +383,8 @@ class CtrlZForeground(Exception): def handle_sigtstp(signum, frame): """Suspend the foreground job and keep/update its jobs list entry.""" - if job.poll() is None: - os.killpg(os.getpgid(job.pid), signal.SIGSTOP) + if _job_is_running(job): + _signal_job(job, signal.SIGSTOP) if job in BACKGROUND_JOBS: current_job_id = BACKGROUND_JOBS.index(job) + 1 else: @@ -342,16 +401,16 @@ def handle_sigtstp(signum, frame): signal.signal(signal.SIGTSTP, handle_sigtstp) print(_job_command(job)) # Bring it to the foreground and wait - os.killpg(os.getpgid(job.pid), signal.SIGCONT) - job.wait() + _signal_job(job, signal.SIGCONT) + _wait_job_members(job) # Remove the job from the list if it has completed - if job.poll() is not None: + if not _job_is_running(job): BACKGROUND_JOBS.pop(job_id - 1) return 0 except CtrlZForeground: return 0 except KeyboardInterrupt: - os.killpg(os.getpgid(job.pid), signal.SIGINT) + _signal_job(job, signal.SIGINT) BACKGROUND_JOBS.pop(job_id - 1) return 130 finally: diff --git a/lshell/config/diagnostics.py b/lshell/config/diagnostics.py index dedb68f..5d7c8b6 100644 --- a/lshell/config/diagnostics.py +++ b/lshell/config/diagnostics.py @@ -31,6 +31,7 @@ "allowed_file_extensions", "forbidden", "sudo_commands", + "runtime_executor", "strict", "warning_counter", "path", @@ -46,7 +47,6 @@ "aliases", "messages", "winscp", - "policy_commands", "disable_exit", "timer", "history_size", @@ -100,11 +100,11 @@ def _build_runtime_policy(conf_raw, username): "warning_counter", "overssh", "strict", + "runtime_executor", "aliases", "messages", "allowed_cmd_path", "winscp", - "policy_commands", "scp_upload", "scp_download", ]: @@ -125,13 +125,15 @@ def _build_runtime_policy(conf_raw, username): policy[item] = [] elif item in ["scp_upload", "scp_download"]: policy[item] = 1 + elif item in ["runtime_executor"]: + policy[item] = schema.RUNTIME_EXECUTOR_SHELLLESS elif item in ["aliases", "messages"]: policy[item] = {} - elif item in ["policy_commands"]: - policy[item] = 1 else: policy[item] = 0 + schema.validate_runtime_executor(policy["runtime_executor"]) + policy["username"] = username if "home_path" in conf_raw: @@ -148,10 +150,6 @@ def _build_runtime_policy(conf_raw, username): policy["path"][0] = policy["home_path"] policy["allowed"] += list(set(builtincmd.builtins_list) - set(["export"])) - if policy.get("policy_commands") != 1: - policy["allowed"] = [ - cmd for cmd in policy["allowed"] if cmd not in builtincmd.POLICY_COMMANDS - ] if policy["sudo_commands"]: policy["allowed"].append("sudo") diff --git a/lshell/config/runtime.py b/lshell/config/runtime.py index 14fd755..87e31dc 100644 --- a/lshell/config/runtime.py +++ b/lshell/config/runtime.py @@ -35,16 +35,28 @@ class CheckConfig: """Load, resolve, validate, and apply runtime config for one session.""" + _NOEXEC_PROBE_CANDIDATES = ("/usr/bin/true", "/bin/true") + + def _resolve_noexec_probe_binary(self): + """Return an absolute probe binary path for noexec validation.""" + for candidate in self._NOEXEC_PROBE_CANDIDATES: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + def noexec_library_usable(self, path_noexec): """Return True when a noexec library can be safely preloaded.""" probe_env = dict(os.environ) probe_env["LD_PRELOAD"] = path_noexec probe_env.pop("BASH_ENV", None) probe_env.pop("ENV", None) + probe_binary = self._resolve_noexec_probe_binary() + if not probe_binary: + return False try: probe = subprocess.run( - ["bash", "-c", "/usr/bin/true"], + [probe_binary], env=probe_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -482,13 +494,13 @@ def get_config_user(self): "login_script", "winscp", "disable_exit", - "policy_commands", "quiet", "security_audit_json", "max_sessions_per_user", "max_background_jobs", "command_timeout", "max_processes", + "runtime_executor", ]: try: if len(self.conf_raw[item]) == 0: @@ -509,8 +521,8 @@ def get_config_user(self): self.conf[item] = [] elif item in ["history_size"]: self.conf[item] = -1 - elif item in ["policy_commands"]: - self.conf[item] = 1 + elif item in ["runtime_executor"]: + self.conf[item] = schema.RUNTIME_EXECUTOR_SHELLLESS # default scp is allowed elif item in ["scp_upload", "scp_download"]: self.conf[item] = 1 @@ -531,6 +543,12 @@ def get_config_user(self): self.log.critical("lshell: config: 'prompt_short' must be 0, 1, or 2") sys.exit(1) + try: + schema.validate_runtime_executor(self.conf["runtime_executor"]) + except ValueError as exception: + self.log.critical(f"lshell: config: {exception}") + sys.exit(1) + try: containment.validate_runtime_config(self.conf) except ValueError as exception: @@ -641,14 +659,6 @@ def get_config_user(self): # append default commands to allowed list self.conf["allowed"] += list(set(builtincmd.builtins_list) - set(["export"])) - # Optionally hide policy introspection commands from users. - if self.conf.get("policy_commands") != 1: - self.conf["allowed"] = [ - cmd - for cmd in self.conf["allowed"] - if cmd not in builtincmd.POLICY_COMMANDS - ] - # in case sudo_commands is not empty, append sudo to allowed commands if self.conf["sudo_commands"]: self.conf["allowed"].append("sudo") diff --git a/lshell/config/schema.py b/lshell/config/schema.py index 9b8f819..2ed2977 100644 --- a/lshell/config/schema.py +++ b/lshell/config/schema.py @@ -42,7 +42,6 @@ "history_size", "winscp", "disable_exit", - "policy_commands", "quiet", "loglevel", "security_audit_json", @@ -62,6 +61,7 @@ "scpforce", "logfilename", "syslogname", + "runtime_executor", } DEDUP_LIST_KEYS = { "allowed", @@ -72,6 +72,13 @@ "sudo_commands", } +RUNTIME_EXECUTOR_SHELLLESS = "shellless" +RUNTIME_EXECUTOR_BASH_COMPAT = "bash_compat" +RUNTIME_EXECUTOR_VALUES = { + RUNTIME_EXECUTOR_SHELLLESS, + RUNTIME_EXECUTOR_BASH_COMPAT, +} + def is_all_literal(raw_value): """Return True when the config value denotes the literal 'all'.""" @@ -140,3 +147,10 @@ def parse_config_value(value, key=""): evaluated = list(set(evaluated)) return evaluated + + +def validate_runtime_executor(runtime_executor): + """Validate runtime-executor mode.""" + if runtime_executor not in RUNTIME_EXECUTOR_VALUES: + valid_values = ", ".join(sorted(RUNTIME_EXECUTOR_VALUES)) + raise ValueError(f"runtime_executor must be one of: {valid_values}") diff --git a/lshell/engine/executor.py b/lshell/engine/executor.py index eb9e727..2b26b4f 100644 --- a/lshell/engine/executor.py +++ b/lshell/engine/executor.py @@ -12,6 +12,7 @@ from lshell import sec from lshell import utils from lshell import variables +from lshell.config import schema from lshell.engine import authorizer from lshell.engine import normalizer from lshell.engine import parser as engine_parser @@ -40,11 +41,20 @@ def build_decisions(command_line, policy): def _unknown_syntax_retcode(shell_context, command): - ret, shell_context.conf = sec.warn_unknown_syntax( - command, - shell_context.conf, - strict=shell_context.conf["strict"], - ) + if command.startswith("unsupported shell syntax:") and ( + "command substitution" in command or "process substitution" in command + ): + ret, shell_context.conf = sec.warn_unsupported_shell_syntax( + command, + shell_context.conf, + strict=shell_context.conf["strict"], + ) + else: + ret, shell_context.conf = sec.warn_unknown_syntax( + command, + shell_context.conf, + strict=shell_context.conf["strict"], + ) audit.log_command_event( shell_context.conf, command, @@ -331,6 +341,24 @@ def execute(decisions, runtime): i = j + (2 if background else 1) continue + runtime_executor = shell_context.conf.get( + "runtime_executor", + schema.RUNTIME_EXECUTOR_SHELLLESS, + ) + if runtime_executor == schema.RUNTIME_EXECUTOR_SHELLLESS: + unsupported_reason = None + for part in pipeline_parts: + part_reason = utils.unsupported_runtime_syntax_reason(part) + if part_reason is not None: + unsupported_reason = part_reason + break + if unsupported_reason: + retcode = _unknown_syntax_retcode( + shell_context, + f"unsupported shell syntax: {unsupported_reason}", + ) + return ExecutionResult(retcode=retcode, audit_reason="unknown syntax") + if not trusted_protocol: decision = authorizer.authorize_line( full_command, diff --git a/lshell/engine/reasons.py b/lshell/engine/reasons.py index 2b8c308..4a75b28 100644 --- a/lshell/engine/reasons.py +++ b/lshell/engine/reasons.py @@ -61,7 +61,6 @@ def to_policy_message(reason): return f"command not found '{details.get('command', '')}'" if code == FORBIDDEN_TRUSTED_PROTOCOL: return "forbidden trusted SSH protocol command" - return "policy evaluation failed" @@ -94,7 +93,6 @@ def to_audit_reason(reason): return "forbidden trusted SSH protocol command: " + details.get("command", "") if code == COMMAND_NOT_FOUND: return f"command not found: {details.get('command', '')}" - return "policy evaluation failed" @@ -141,5 +139,4 @@ def warning_payload(reason): "messagetype": f"file extension {details.get('disallowed_extensions', [])}", "command": details.get("full_command", ""), } - return None diff --git a/lshell/sec.py b/lshell/sec.py index 16efa99..b314beb 100644 --- a/lshell/sec.py +++ b/lshell/sec.py @@ -155,6 +155,47 @@ def warn_unknown_syntax(command, conf, strict=None, ssh=None): return 1, conf +def warn_unsupported_shell_syntax(command, conf, strict=None, ssh=None): + """Warn on unsupported shell syntax with explicit non-unknown-syntax wording.""" + log = conf["logpath"] + detail = str(command).strip() + prefix = "unsupported shell syntax:" + if detail.startswith(prefix): + detail = detail[len(prefix) :].strip() + + primary_message = f"lshell: unsupported shell syntax: {detail}" + audit.set_decision_reason(conf, f"unsupported shell syntax: {detail}") + + if ssh: + return 1, conf + + if strict: + conf["warning_counter"] -= 1 + if conf["warning_counter"] < 0: + log.critical(primary_message) + log.critical(messages.get_message(conf, "session_terminated")) + sys.exit(1) + + log.critical(primary_message) + remaining = conf["warning_counter"] + violation_label = "violation" if remaining == 1 else "violations" + sys.stderr.write( + messages.get_message( + conf, + "warning_remaining", + remaining=remaining, + violation_label=violation_label, + ) + + "\n" + ) + log.error(f"lshell: user warned, counter: {remaining}") + return 1, conf + + log.warning(f'INFO: unsupported shell syntax -> "{detail}"') + sys.stderr.write(primary_message + "\n") + return 1, conf + + def tokenize_command(command): """Tokenize the command line into separate commands based on the operators""" diff --git a/lshell/shellcmd.py b/lshell/shellcmd.py index e60e264..6caae11 100644 --- a/lshell/shellcmd.py +++ b/lshell/shellcmd.py @@ -270,7 +270,7 @@ def _aliases_for_ssh_command(): self.ssh_warn("command over SSH", self.conf["ssh"]) else: # case of local shell escapes (e.g. pager/editor invoking - # the login shell with -c). Validate against normal policy. + # a shell with -c). Validate against normal policy. self.conf["ssh"] = utils.get_aliases( self.conf["ssh"], _aliases_for_ssh_command() ) @@ -601,10 +601,6 @@ def do_lshow(self, arg=None): return 0 return 0 if decision["allowed"] else 2 - def do_policy_show(self, arg=None): - """Compatibility shim for legacy internal command name.""" - return self.do_lshow(arg) - def do_exit(self, arg=None): """This method overrides the original do_exit method.""" # Check for background jobs diff --git a/lshell/utils.py b/lshell/utils.py index 0c1017f..74bec2f 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -12,7 +12,7 @@ import shutil import threading from getpass import getuser -from time import strftime, gmtime +from time import strftime, gmtime, monotonic import signal # import lshell specifics @@ -20,6 +20,8 @@ from lshell import builtincmd from lshell import audit from lshell import containment +from lshell import expansion_inspector +from lshell.config import schema from lshell.engine import executor as engine_executor @@ -278,97 +280,32 @@ 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", - "[", -} - - -_TRUSTED_SHELL_PATHS = ( - "/opt/homebrew/bin/bash", - "/usr/local/bin/bash", - "/bin/bash", +_TRUSTED_BASH_CANDIDATES = ( "/usr/bin/bash", - "/opt/homebrew/bin/dash", - "/usr/local/bin/dash", - "/bin/dash", - "/usr/bin/dash", - "/bin/sh", - "/usr/bin/sh", + "/bin/bash", + "/usr/local/bin/bash", + "/opt/homebrew/bin/bash", ) -def _resolve_trusted_shell(): - """Return an absolute trusted shell interpreter path, or None.""" - for candidate in _TRUSTED_SHELL_PATHS: - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - return None - - def _is_bash_function_env_name(name): """Return True for env vars used by Bash function import.""" return name.startswith("BASH_FUNC_") +def resolve_trusted_bash_path(candidates=None): + """Resolve a trusted absolute bash path without PATH lookup.""" + if candidates is None: + candidates = _TRUSTED_BASH_CANDIDATES + + for candidate in candidates: + if not os.path.isabs(candidate): + continue + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def _expand_braced_parameter(expr, support_advanced=True): """Expand ${...} expressions for the supported shell parameter forms.""" if not expr: @@ -526,9 +463,6 @@ def _command_exists(executable): 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) @@ -585,9 +519,107 @@ def cmd_parse_execute(command_line, shell_context=None, trusted_protocol=False): ) +def _contains_unquoted_redirection(command): + """Return True when unquoted shell redirection markers are present.""" + in_single = False + in_double = False + in_backtick = False + escaped = False + + for char in command: + if escaped: + escaped = False + continue + + if char == "\\" and not in_single: + escaped = True + continue + + if char == "'" and not in_double and not in_backtick: + in_single = not in_single + continue + + if char == '"' and not in_single and not in_backtick: + in_double = not in_double + continue + + if char == "`" and not in_single: + in_backtick = not in_backtick + continue + + if in_single or in_double or in_backtick: + continue + + if char in {"<", ">"}: + return True + + return False + + +def _runtime_executor_from_conf(conf): + """Return normalized runtime executor mode from config.""" + if not conf: + return schema.RUNTIME_EXECUTOR_SHELLLESS + mode = conf.get("runtime_executor", schema.RUNTIME_EXECUTOR_SHELLLESS) + if mode in schema.RUNTIME_EXECUTOR_VALUES: + return mode + return schema.RUNTIME_EXECUTOR_SHELLLESS + + +def unsupported_runtime_syntax_reason(command): + """Return user-facing reason when command relies on unsupported shell syntax.""" + expansion_info = expansion_inspector.inspect_shell_expansions(command) + if expansion_info.malformed: + return "unsupported shell expansion syntax" + + for expansion in expansion_info.executable_expansions: + if expansion.kind == "command_substitution": + return "command substitution ($(...))" + if expansion.kind == "backtick": + return "backtick command substitution (`...`)" + if expansion.kind == "process_substitution": + return "process substitution (<(...) or >(...))" + + if _contains_unquoted_redirection(command): + return "redirection operators (<, >, <<, >>, <<<, 2>&1, ...)" + + return None + + +def _split_pipeline_for_execution(command): + """Split one execution line into shellless pipeline stages.""" + sequence = split_command_sequence(command) + if sequence is None: + return None + if not sequence: + return [] + + operators = {"&&", "||", ";", "&", "|"} + stages = [] + expect_command = True + + for token in sequence: + if expect_command: + if token in operators: + return None + stages.append(token) + expect_command = False + continue + + if token != "|": + return None + expect_command = True + + if expect_command: + return None + + return stages + + 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.""" + """Execute command(s) with shell=False, including manual pipeline wiring.""" proc = None + pipeline_processes = [] detached_session = True exec_env = dict(os.environ) runtime_limits = containment.get_runtime_limits(conf or {}) @@ -613,18 +645,123 @@ def exec_cmd(cmd, background=False, extra_env=None, conf=None, log=None): if _is_bash_function_env_name(key): exec_env.pop(key, None) + runtime_executor = _runtime_executor_from_conf(conf) + if runtime_executor == schema.RUNTIME_EXECUTOR_BASH_COMPAT: + bash_path = resolve_trusted_bash_path() + if not bash_path: + sys.stderr.write( + "lshell: runtime_executor=bash_compat requires a trusted absolute bash path\n" + ) + return 126 + stage_specs = [ + { + "argv": [bash_path, "-c", cmd], + "env": dict(exec_env), + } + ] + executable, _argument, _split, _assignments = _parse_command(cmd) + if executable in ("sudo", "su") and not background: + detached_session = False + else: + stage_texts = _split_pipeline_for_execution(cmd) + if stage_texts is None or not stage_texts: + sys.stderr.write(f"lshell: unknown syntax: {cmd}\n") + return 1 + + stage_specs = [] + for stage_text in stage_texts: + expanded_stage = expand_vars_quoted(stage_text, support_advanced_braced=True) + unsupported_reason = unsupported_runtime_syntax_reason(expanded_stage) + if unsupported_reason: + sys.stderr.write( + "lshell: unsupported shell syntax in command execution: " + f"{unsupported_reason}\n" + ) + return 126 + + executable, _argument, split, assignments = _parse_command(expanded_stage) + if executable is None: + sys.stderr.write(f"lshell: unknown syntax: {stage_text}\n") + return 1 + + if not executable: + sys.stderr.write(f"lshell: unknown syntax: {stage_text}\n") + return 1 + + stage_env = dict(exec_env) + for var_name, var_value in assignments: + stage_env[var_name] = var_value + + stage_specs.append( + { + "argv": split[len(assignments) :], + "env": stage_env, + } + ) + + if stage_specs and stage_specs[0]["argv"] and stage_specs[0]["argv"][0] in ( + "sudo", + "su", + ): + if not background: + detached_session = False + class CtrlZException(Exception): """Custom exception to handle Ctrl+Z (SIGTSTP).""" pass + def _pipeline_members(target): + if target is None: + return [] + members = getattr(target, "lshell_pipeline", None) + if not members: + return [target] + return list(members) + + def _running_pipeline_members(target): + return [member for member in _pipeline_members(target) if member.poll() is None] + + def _signal_pipeline(target, signum): + for member in _running_pipeline_members(target): + try: + os.kill(member.pid, signum) + except OSError: + continue + + def _pipeline_pgid(target): + pgid = getattr(target, "lshell_pgid", None) + if pgid is not None: + return pgid + return os.getpgid(target.pid) + + def _make_preexec_fn(stage_index, pipeline_pgid, needs_resource_limits): + if os.name != "posix": + return None + + if len(stage_specs) == 1 and (detached_session or needs_resource_limits): + return containment.build_preexec_fn(detached_session, runtime_limits) + + if not detached_session and not needs_resource_limits: + return None + + def _preexec(): + if detached_session: + if stage_index == 0: + os.setpgid(0, 0) + else: + os.setpgid(0, pipeline_pgid) + containment.apply_rlimits(runtime_limits) + + return _preexec + def handle_sigtstp(signum, frame): """Handle SIGTSTP (Ctrl+Z) by sending the process to the background.""" - if proc and proc.poll() is None: # Ensure process is running - if detached_session: - os.killpg(os.getpgid(proc.pid), signal.SIGSTOP) + if proc and _running_pipeline_members(proc): # Ensure process is running + if detached_session and os.name == "posix": + os.killpg(_pipeline_pgid(proc), signal.SIGSTOP) else: - os.kill(proc.pid, signal.SIGSTOP) + _signal_pipeline(proc, signal.SIGSTOP) # Keep one job entry per process to avoid duplicates on repeated suspend/resume. if proc in builtincmd.BACKGROUND_JOBS: job_id = builtincmd.BACKGROUND_JOBS.index(proc) + 1 @@ -637,20 +774,20 @@ def handle_sigtstp(signum, frame): def handle_sigcont(signum, frame): """Handle SIGCONT to resume a stopped job in the foreground.""" - if proc and proc.poll() is None: - if detached_session: - os.killpg(os.getpgid(proc.pid), signal.SIGCONT) + if proc and _running_pipeline_members(proc): + if detached_session and os.name == "posix": + os.killpg(_pipeline_pgid(proc), signal.SIGCONT) else: - os.kill(proc.pid, signal.SIGCONT) + _signal_pipeline(proc, signal.SIGCONT) def _kill_process_group(target): - if not target or target.poll() is not None: + if not target or not _running_pipeline_members(target): return try: - if detached_session: - os.killpg(os.getpgid(target.pid), signal.SIGKILL) + if detached_session and os.name == "posix": + os.killpg(_pipeline_pgid(target), signal.SIGKILL) else: - os.kill(target.pid, signal.SIGKILL) + _signal_pipeline(target, signal.SIGKILL) except OSError: return @@ -676,6 +813,49 @@ def _emit_timeout_event(): ) sys.stderr.write(f"lshell: command timed out after {command_timeout}s: {cmd}\n") + def _wait_process(target, timeout=None): + """Wait for a subprocess using wait(), with communicate() compatibility fallback.""" + wait_method = getattr(target, "wait", None) + if callable(wait_method): + if timeout is None: + return wait_method() + return wait_method(timeout=timeout) + + communicate_method = getattr(target, "communicate", None) + if callable(communicate_method): + if timeout is None: + communicate_method() + else: + try: + communicate_method(timeout=timeout) + except TypeError: + communicate_method() + return target.returncode + + raise AttributeError("process object has no wait() or communicate() method") + + def _terminate_pipeline_members(members): + """Force-stop and reap any already-started pipeline members.""" + for member in members: + if member.poll() is not None: + continue + try: + if os.name == "posix" and detached_session: + os.killpg(os.getpgid(member.pid), signal.SIGKILL) + else: + os.kill(member.pid, signal.SIGKILL) + except OSError: + try: + os.kill(member.pid, signal.SIGKILL) + except OSError: + continue + + for member in members: + try: + _wait_process(member, timeout=1) + except (subprocess.TimeoutExpired, OSError, AttributeError): + continue + previous_sigtstp_handler = signal.getsignal(signal.SIGTSTP) previous_sigcont_handler = signal.getsignal(signal.SIGCONT) @@ -683,43 +863,99 @@ def _emit_timeout_event(): # Register SIGTSTP (Ctrl+Z) and SIGCONT (resume) signal handlers signal.signal(signal.SIGTSTP, handle_sigtstp) signal.signal(signal.SIGCONT, handle_sigcont) - try: - split_cmd = shlex.split(cmd, posix=True) - except ValueError: - split_cmd = [] - if split_cmd and split_cmd[0] in ("sudo", "su"): - cmd_args = split_cmd - if not background: - detached_session = False - else: - shell_path = _resolve_trusted_shell() - if not shell_path: - sys.stderr.write( - "Command execution failed: trusted system shell interpreter not found.\n" + + if runtime_limits.max_processes > 0 and len(stage_specs) > runtime_limits.max_processes: + reason = containment.reason_with_details( + "runtime_limit.max_processes_exceeded", + requested=len(stage_specs), + limit=runtime_limits.max_processes, + ) + if conf: + audit.log_command_event( + conf, + cmd, + allowed=False, + reason=reason, + level="warning", ) - return 127 - cmd_args = [shell_path, "-c", cmd] - preexec_fn = None + if log: + log.critical( + "lshell: runtime containment denied command execution: " + f"requested_processes={len(stage_specs)}, " + f"limit={runtime_limits.max_processes}, command=\"{cmd}\"" + ) + sys.stderr.write( + "lshell: command denied: " + f"max_processes={runtime_limits.max_processes} " + "is lower than required pipeline stages\n" + ) + return 126 + 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) + pipeline_pgid = None + previous_stdout = None + devnull_in = open(os.devnull, "r", encoding="utf-8") if background else None + + try: + try: + for index, stage in enumerate(stage_specs): + popen_kwargs = {"env": stage["env"]} + if index == 0: + if background and devnull_in is not None: + popen_kwargs["stdin"] = devnull_in + else: + popen_kwargs["stdin"] = previous_stdout + + if index < len(stage_specs) - 1: + popen_kwargs["stdout"] = subprocess.PIPE + elif background: + popen_kwargs["stdout"] = sys.stdout + popen_kwargs["stderr"] = sys.stderr + + preexec_fn = _make_preexec_fn(index, pipeline_pgid, needs_resource_limits) + if preexec_fn is not None: + popen_kwargs["preexec_fn"] = preexec_fn + + stage_proc = subprocess.Popen(stage["argv"], **popen_kwargs) + pipeline_processes.append(stage_proc) + + if previous_stdout is not None: + previous_stdout.close() + previous_stdout = ( + stage_proc.stdout if index < len(stage_specs) - 1 else None + ) + + if ( + index == 0 + and detached_session + and os.name == "posix" + and len(stage_specs) > 1 + ): + pipeline_pgid = stage_proc.pid + except Exception: + _terminate_pipeline_members(pipeline_processes) + raise + finally: + if previous_stdout is not None: + previous_stdout.close() + if devnull_in is not None: + devnull_in.close() + + proc = pipeline_processes[-1] + proc.lshell_cmd = cmd + proc.lshell_pipeline = tuple(pipeline_processes) + proc.lshell_timeout_timer = None + if detached_session and os.name == "posix": + try: + proc.lshell_pgid = pipeline_pgid or os.getpgid(proc.pid) + except OSError: + proc.lshell_pgid = pipeline_pgid + if background: - with open(os.devnull, "r") as devnull_in: - popen_kwargs = { - "stdin": devnull_in, - "stdout": sys.stdout, - "stderr": sys.stderr, - "env": exec_env, - } - 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 if command_timeout > 0: def _background_timeout(): - if proc and proc.poll() is None: + if proc and _running_pipeline_members(proc): proc.lshell_timeout_triggered = True _kill_process_group(proc) _emit_timeout_event() @@ -734,26 +970,40 @@ def _background_timeout(): print(f"[{job_id}] {cmd} (pid: {proc.pid})") retcode = 0 else: - popen_kwargs = {"env": exec_env} - if preexec_fn is not None: - popen_kwargs["preexec_fn"] = preexec_fn - proc = subprocess.Popen(cmd_args, **popen_kwargs) - proc.lshell_cmd = cmd + deadline = monotonic() + command_timeout if command_timeout > 0 else None if command_timeout > 0: - proc.communicate(timeout=command_timeout) + remaining = deadline - monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(proc.args, command_timeout) + _wait_process(proc, timeout=remaining) else: - proc.communicate() + _wait_process(proc) + + for member in pipeline_processes[:-1]: + if command_timeout > 0: + remaining = deadline - monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(member.args, command_timeout) + _wait_process(member, timeout=remaining) + else: + _wait_process(member) retcode = proc.returncode if proc.returncode is not None else 0 - except FileNotFoundError: - sys.stderr.write( - "Command execution failed: required shell interpreter not found.\n" + except FileNotFoundError as exception: + missing = ( + str(exception.filename) + if getattr(exception, "filename", None) + else (proc.args[0] if proc and getattr(proc, "args", None) else cmd) ) + sys.stderr.write(f'lshell: command not found: "{missing}"\n') retcode = 127 except subprocess.TimeoutExpired: _kill_process_group(proc) - if proc: - proc.communicate() + for member in _pipeline_members(proc): + try: + _wait_process(member, timeout=1) + except (subprocess.TimeoutExpired, OSError, AttributeError): + continue _emit_timeout_event() retcode = 124 except subprocess.SubprocessError as exception: @@ -780,17 +1030,17 @@ def _background_timeout(): except CtrlZException: # Handle Ctrl+Z retcode = 0 except KeyboardInterrupt: # Handle Ctrl+C - if proc and proc.poll() is None: - if detached_session: - os.killpg(os.getpgid(proc.pid), signal.SIGINT) + if proc and _running_pipeline_members(proc): + if detached_session and os.name == "posix": + os.killpg(_pipeline_pgid(proc), signal.SIGINT) else: - os.kill(proc.pid, signal.SIGINT) + _signal_pipeline(proc, 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 + and not _running_pipeline_members(proc) ): proc.lshell_timeout_timer.cancel() signal.signal(signal.SIGTSTP, previous_sigtstp_handler) diff --git a/lshell/variables.py b/lshell/variables.py index 8f72fe6..3dab0fe 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.12.0rc1" +__version__ = "0.12.0rc2" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] @@ -104,9 +104,9 @@ "path_noexec=", "umask=", "allowed_shell_escape=", + "runtime_executor=", "winscp=", "disable_exit=", - "policy_commands=", "include_dir=", "security_audit_json=", "max_sessions_per_user=", diff --git a/man/lshell.1 b/man/lshell.1 index 1cf624b..90a08f8 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -145,6 +145,18 @@ Preference loading priority: .RE .fi .SS [global] +.TP +.I path_noexec +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 to verify that +a standard path is detected. + +while this should not be a common practice, setting this variable to an empty +string will disable LD_PRELOAD prepend of the commands. This is done at your +own risk, as lshell becomes easily breached using some commands like find(1) +using the -exec flag. + .TP .I logpath config path (default is /var/log/lshell/) @@ -179,26 +191,37 @@ config path (default is /var/log/lshell/) .I syslogname in case you are using syslog, set your logname (default: lshell) .TP +.I security_audit_json +emit structured security audit events in JSON format (ECS-aligned fields). +Set to 1 to enable and 0 to disable (default). +.TP .I include_dir include a directory containing multiple configuration files. These files can only contain default/user/group configuration. The global configuration will only be loaded from the default configuration file. This variable will be expanded (e.g. /path/*.conf). -.TP -.I path_noexec -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 to verify that -a standard path is detected. -while this should not be a common practice, setting this variable to an empty -string will disable LD_PRELOAD prepend of the commands. This is done at your -own risk, as lshell becomes easily breached using some commands like find(1) -using the -exec flag. .SS [default] and/or [username] and/or [grp:groupname] .TP -.I aliases -command aliases list (similar to bash's alias directive) +.I runtime_executor +runtime command executor mode. +.RS +\fBshellless\fR -> hardened default; does not rely on external shell parsing. +.RE +.RS +\fBbash_compat\fR -> compatibility mode for legacy behavior. Use only with a +trusted absolute bash path; this mode enables shell features such as command +and process substitution and increases escape risk. +.RE +.TP +.I strict +logging strictness. If set to 1, any unknown command is considered as \ +forbidden, and user's warning counter is decreased. If set to 0, command is \ +considered as unknown, and user is only warned (i.e. *** unknown syntax) +.TP +.I warning_counter +number of warnings when user enters a forbidden value before getting exited \ +from lshell. Set to \fB\-1\fR to disable the counter, and just warn the user. .TP .I allowed A list of allowed commands, or set to 'all' to allow all commands in the user's PATH. @@ -225,6 +248,16 @@ allow users to execute code (e.g. /bin/sh) from within the application, thus easily escaping lshell. See variable 'path_noexec' to use an alternative path to library. .TP +.I forbidden +a list of forbidden characters or commands +.TP +.I sudo_commands +a list of the allowed commands that can be used with sudo(8). If set to \ +\'all', all the 'allowed' commands will be accessible through sudo(8). + +It is possible to use the -u sudo flag in order to run a command as a \ +different user than the default root. +.TP .I allowed_shell_escape a list of the allowed commands that are permitted to execute other programs (e.g. shell scripts with exec(3)). Setting this variable to 'all' is NOT @@ -238,34 +271,56 @@ in the \'allowed\' variable. a list of allowed file extensions that can be provided in the command line. If a list of allowed extensions is provided, all other file extensions will be disallowed. .TP -.I allowed_cmd_path -a list of paths; all executable files inside these paths are allowed +.I overssh +list of command allowed to execute over ssh (e.g. rsync, rdiff-backup, scp, \ +etc.) .TP -.I disable_exit -disable user exit, this could be useful when lshell is spawned from another -non-restricted shell (e.g. bash) +.I scp +allow or forbid the use of scp connection - set to 1 or 0 .TP -.I env_path -update the environment variable $PATH of the user (optional) +.I scp_upload +set to 0 to forbid scp uploads (default is 1) .TP -.I env_vars -set environment variables (optional) +.I scp_download +set to 0 to forbid scp downloads (default is 1) .TP -.I env_vars_files -specify a list of files containing environment variables (optional) +.I sftp +allow or forbid the use of sftp connection - set to 1 or 0. + +WARNING: This option will not work if you are using OpenSSH's \ +internal-sftp service (e.g. when configured in chroot) .TP -.I forbidden -a list of forbidden characters or commands +.I scpforce +force files sent through scp to a specific directory .TP -.I history_file -set the history filename. A wildcard can be used: +.I winscp +enable support for WinSCP with scp mode (NOT sftp) + +When enabled, the following parameters will be overridden: .RS -.BR \ \ \ \ %u --> username (e.g. '/home/%u/.lhistory') +.BR \ \ \ \ scp_upload : +1 (uses scp(1) from within session) +.RE +.RS +.BR \ \ \ \ scp_download: +1 (uses scp(1) from within session) +.RE +.RS +.BR \ \ \ \ scpforce : +ignored (uses scp(1) from within session) +.RE +.RS +.BR \ \ \ \ forbidden : +-[';'] +.RE +.RS +.BR \ \ \ \ allowed : ++['scp', 'env', 'pwd', 'groups', 'unset', 'unalias'] .RE .TP -.I history_size -set the maximum size (in lines) of the history file +.I path +list of path to restrict the user geographically. It is possible to use \ +wildcards (e.g. '/var/log/ap*'). .TP .I home_path (deprecated) set the home folder of your user. If not specified, the home directory is set \ @@ -277,57 +332,65 @@ directory. A wildcard can be used: -> username (e.g. '/home/%u') .RE .TP -.I intro -set the introduction to print at login +.I env_path +update the environment variable $PATH of the user (optional) .TP -.I messages -optional dictionary used to customize user-facing shell messages. Unsupported -keys and unsupported placeholders are rejected during configuration parsing. -.RS -\fBunknown_syntax\fR: \fB{command}\fR -.RE -.RS -\fBforbidden_generic\fR: \fB{messagetype}\fR, \fB{command}\fR -.RE -.RS -\fBforbidden_command\fR: \fB{command}\fR -.RE -.RS -\fBforbidden_path\fR: \fB{command}\fR -.RE -.RS -\fBforbidden_character\fR: \fB{command}\fR -.RE -.RS -\fBforbidden_control_char\fR: \fB{command}\fR -.RE -.RS -\fBforbidden_command_over_ssh\fR: \fB{message}\fR, \fB{command}\fR -.RE -.RS -\fBforbidden_scp_over_ssh\fR: \fB{message}\fR -.RE +.I allowed_cmd_path +a list of paths; all executable files inside these paths are allowed +.TP +.I env_vars +set environment variables (optional) +.TP +.I env_vars_files +specify a list of files containing environment variables (optional) +.TP +.I umask +set process umask for the lshell session. Value must be octal (0000 to 0777), +for example \fB0002\fR. .RS -\fBwarning_remaining\fR: \fB{remaining}\fR, \fB{violation_label}\fR +\fB0002\fR -> files 664 / directories 775 .RE .RS -\fBsession_terminated\fR: no placeholders +\fB0022\fR -> files 644 / directories 755 .RE .RS -\fBincident_reported\fR: no placeholders +\fB0077\fR -> files 600 / directories 700 .RE .TP -.I login_script -define the script to run at user login. This script is executed in a child shell -process, so shell state changes there (for example \fBumask\fR) do not persist -in the lshell parent process. +.I max_sessions_per_user +maximum concurrent lshell sessions for the same user. +Set to \fB0\fR to disable this limit (default). .TP -.I passwd -password of specific user (default is empty) +.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 path -list of path to restrict the user geographically. It is possible to use \ -wildcards (e.g. '/var/log/ap*'). +.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 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 timer +a value in seconds for the session timer +.TP +.I history_size +set the maximum size (in lines) of the history file +.TP +.I history_file +set the history filename. A wildcard can be used: +.RS +.BR \ \ \ \ %u +-> username (e.g. '/home/%u/.lhistory') +.RE +.TP +.I intro +set the introduction to print at login .TP .I prompt set the user's prompt format (default: username) @@ -412,108 +475,58 @@ LShell supports prompt customization using the \fB$LPS1\fR environment variable, .BR \ \ \ \ d -> current date in Weekday Month Day format (e.g., Mon Mar 01) .RE -.LP -.I overssh -list of command allowed to execute over ssh (e.g. rsync, rdiff-backup, scp, \ -etc.) -.TP -.I scp -allow or forbid the use of scp connection - set to 1 or 0 -.TP -.I scpforce -force files sent through scp to a specific directory -.TP -.I scp_download -set to 0 to forbid scp downloads (default is 1) -.TP -.I scp_upload -set to 0 to forbid scp uploads (default is 1) .TP -.I sftp -allow or forbid the use of sftp connection - set to 1 or 0. - -WARNING: This option will not work if you are using OpenSSH's \ -internal-sftp service (e.g. when configured in chroot) -.TP -.I sudo_commands -a list of the allowed commands that can be used with sudo(8). If set to \ -\'all', all the 'allowed' commands will be accessible through sudo(8). - -It is possible to use the -u sudo flag in order to run a command as a \ -different user than the default root. -.TP -.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 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 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 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). +.I aliases +command aliases list (similar to bash's alias directive) .TP -.I umask -set process umask for the lshell session. Value must be octal (0000 to 0777), -for example \fB0002\fR. +.I messages +optional dictionary used to customize user-facing shell messages. Unsupported +keys and unsupported placeholders are rejected during configuration parsing. .RS -\fB0002\fR -> files 664 / directories 775 +\fBunknown_syntax\fR: \fB{command}\fR .RE .RS -\fB0022\fR -> files 644 / directories 755 +\fBforbidden_generic\fR: \fB{messagetype}\fR, \fB{command}\fR .RE .RS -\fB0077\fR -> files 600 / directories 700 +\fBforbidden_command\fR: \fB{command}\fR .RE -.TP -.I strict -logging strictness. If set to 1, any unknown command is considered as \ -forbidden, and user's warning counter is decreased. If set to 0, command is \ -considered as unknown, and user is only warned (i.e. *** unknown syntax) -.TP -.I warning_counter -number of warnings when user enters a forbidden value before getting exited \ -from lshell. Set to \fB\-1\fR to disable the counter, and just warn the user. -.TP -.I winscp -enable support for WinSCP with scp mode (NOT sftp) - -When enabled, the following parameters will be overridden: .RS -.BR \ \ \ \ scp_upload : -1 (uses scp(1) from within session) +\fBforbidden_path\fR: \fB{command}\fR .RE .RS -.BR \ \ \ \ scp_download: -1 (uses scp(1) from within session) +\fBforbidden_character\fR: \fB{command}\fR .RE .RS -.BR \ \ \ \ scpforce : -ignored (uses scp(1) from within session) +\fBforbidden_control_char\fR: \fB{command}\fR .RE .RS -.BR \ \ \ \ forbidden : --[';'] +\fBforbidden_command_over_ssh\fR: \fB{message}\fR, \fB{command}\fR .RE .RS -.BR \ \ \ \ allowed : -+['scp', 'env', 'pwd', 'groups', 'unset', 'unalias'] +\fBforbidden_scp_over_ssh\fR: \fB{message}\fR +.RE +.RS +\fBwarning_remaining\fR: \fB{remaining}\fR, \fB{violation_label}\fR +.RE +.RS +\fBsession_terminated\fR: no placeholders +.RE +.RS +\fBincident_reported\fR: no placeholders .RE .TP -.I policy_commands -enable/disable policy introspection builtins. If set to 1 (default), users can -run \fBlshow\fR. If set to 0, this command is hidden. +.I login_script +define the script to run at user login. This script is executed in a child shell +process, so shell state changes there (for example \fBumask\fR) do not persist +in the lshell parent process. +.TP +.I disable_exit +disable user exit, this could be useful when lshell is spawned from another +non-restricted shell (e.g. bash) +.TP +.I passwd +password of specific user (default is empty) .SH BEST PRACTICES .TP diff --git a/scripts/update_config_coverage_manifest.py b/scripts/update_config_coverage_manifest.py new file mode 100644 index 0000000..a30db28 --- /dev/null +++ b/scripts/update_config_coverage_manifest.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Update or check test/config_coverage_manifest.json.""" + +from __future__ import annotations + +import argparse +import difflib +import json +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from test.config_coverage_manifest_tools import ( + DEFAULT_TRACKED_TESTS_GLOB, + MANIFEST_PATH, + supported_config_keys, + compute_tests_fingerprint, +) + + +def _normalized_manifest(current_manifest): + supported_keys = sorted(supported_config_keys()) + coverage = current_manifest.get("coverage", {}) + + normalized_coverage = {} + for key in supported_keys: + entry = coverage.get(key, {}) + direct = sorted(set(entry.get("direct", []))) + interaction = sorted(set(entry.get("interaction", []))) + normalized_coverage[key] = { + "direct": direct, + "interaction": interaction, + } + + tracked_glob = current_manifest.get("tracked_tests_glob", DEFAULT_TRACKED_TESTS_GLOB) + + normalized = { + "version": 1, + "tracked_tests_glob": tracked_glob, + "tests_fingerprint_sha256": compute_tests_fingerprint(tracked_glob), + "supported_keys": supported_keys, + "coverage": normalized_coverage, + } + return normalized + + +def _render(data): + return json.dumps(data, indent=2, sort_keys=False) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--write", + action="store_true", + help="Write normalized manifest content to disk.", + ) + mode.add_argument( + "--check", + action="store_true", + help="Check manifest is up to date (default mode).", + ) + args = parser.parse_args(argv) + + with open(MANIFEST_PATH, "r", encoding="utf-8") as handle: + current = json.load(handle) + + normalized = _normalized_manifest(current) + current_text = _render(current) + normalized_text = _render(normalized) + + if args.write: + with open(MANIFEST_PATH, "w", encoding="utf-8") as handle: + handle.write(normalized_text) + print(f"Updated {MANIFEST_PATH}") + return 0 + + if current_text != normalized_text: + diff = difflib.unified_diff( + current_text.splitlines(), + normalized_text.splitlines(), + fromfile="current", + tofile="expected", + lineterm="", + ) + print("\n".join(diff)) + print( + "\nManifest is stale. Run: python3 scripts/update_config_coverage_manifest.py --write" + ) + return 1 + + print("Manifest is up to date.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/config_coverage_manifest.json b/test/config_coverage_manifest.json new file mode 100644 index 0000000..139e40e --- /dev/null +++ b/test/config_coverage_manifest.json @@ -0,0 +1,406 @@ +{ + "version": 1, + "tracked_tests_glob": "test/test_*.py", + "tests_fingerprint_sha256": "1dbd7b9fbaaef3950d99d04ed162b1d2a2864458442cf3e9e6c4b0cc271f884c", + "supported_keys": [ + "aliases", + "allowed", + "allowed_cmd_path", + "allowed_file_extensions", + "allowed_shell_escape", + "command_timeout", + "disable_exit", + "env_path", + "env_vars", + "env_vars_files", + "forbidden", + "history_file", + "history_size", + "home_path", + "include_dir", + "intro", + "logfilename", + "login_script", + "loglevel", + "logpath", + "max_background_jobs", + "max_processes", + "max_sessions_per_user", + "messages", + "overssh", + "path", + "path_noexec", + "prompt", + "prompt_short", + "quiet", + "runtime_executor", + "scp", + "scp_download", + "scp_upload", + "scpforce", + "security_audit_json", + "sftp", + "strict", + "sudo_commands", + "syslogname", + "timer", + "umask", + "warning_counter", + "winscp" + ], + "coverage": { + "aliases": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_alias_expansion_smuggling_is_blocked_and_session_recovers" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_lshow_always_available_while_aliases_expand" + ] + }, + "allowed": { + "direct": [ + "test/test_config.py::TestFunctions::test_schema_accepts_valid_allowed_list" + ], + "interaction": [ + "test/test_policy_merge_parity_unit.py::TestPolicyMergeParity::test_parity_user_group_default_precedence_with_include_overlay" + ] + }, + "allowed_cmd_path": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_allowed_cmd_path_resolves_and_allows_executable" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "allowed_file_extensions": { + "direct": [ + "test/test_file_extension.py::TestFunctions::test_allowed_extension_success" + ], + "interaction": [ + "test/test_file_extension.py::TestFunctions::test_allowed_file_extensions_plus_minus_chain" + ] + }, + "allowed_shell_escape": { + "direct": [ + "test/test_config.py::TestFunctions::test_allowed_shell_escape_plus_minus_chain" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_runtime_executor_allowed_shell_escape_and_path_noexec_interaction" + ] + }, + "command_timeout": { + "direct": [ + "test/test_containment_functional.py::TestRuntimeContainmentFunctional::test_command_timeout_kills_long_running_command" + ], + "interaction": [ + "test/test_containment_functional.py::TestRuntimeContainmentFunctional::test_max_processes_denies_forking_pipeline" + ] + }, + "disable_exit": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_disable_exit_blocks_quit_and_ctrl_d" + ], + "interaction": [ + "test/test_exit.py::TestFunctions::test_disable_exit" + ] + }, + "env_path": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_env_path_resolves_allowed_command_binary" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "env_vars": { + "direct": [ + "test/test_env_vars.py::TestFunctions::test_env_variable_with_dollar_braces" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "env_vars_files": { + "direct": [ + "test/test_env_vars.py::TestFunctions::test_load_env_vars_from_file" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "forbidden": { + "direct": [ + "test/test_config.py::TestFunctions::test_forbidden_remove_one_b" + ], + "interaction": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_alias_expansion_smuggling_is_blocked_and_session_recovers" + ] + }, + "history_file": { + "direct": [ + "test/test_unit.py::TestFunctions::test_history_file_accepts_string_and_expands_home" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_home_path_and_history_file_expand_username_placeholder", + "test/test_history_size_unit.py::TestHistorySizeUnit::test_cmdloop_applies_history_size_when_history_file_exists" + ] + }, + "history_size": { + "direct": [ + "test/test_history_size_unit.py::TestHistorySizeUnit::test_history_size_accepts_integer_override" + ], + "interaction": [ + "test/test_history_size_unit.py::TestHistorySizeUnit::test_cmdloop_applies_history_size_when_history_file_exists" + ] + }, + "home_path": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_home_path_and_history_file_expand_username_placeholder" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "include_dir": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_include_dir_runtime_precedence_user_group_default" + ], + "interaction": [ + "test/test_policy_merge_parity_unit.py::TestPolicyMergeParity::test_parity_user_group_default_precedence_with_include_overlay" + ] + }, + "intro": { + "direct": [ + "test/test_builtins.py::TestFunctions::test_welcome_message" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_custom_intro_is_displayed_before_first_prompt" + ] + }, + "logfilename": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_global_log_settings_create_custom_log_file_and_logger_name" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_observability_interaction_custom_logfilename_with_json_audit" + ] + }, + "login_script": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_login_script_runs_before_first_prompt" + ], + "interaction": [ + "test/test_security_attack_surface_unit.py::TestAttackSurface::test_cmdloop_executes_login_script_with_bash_script_invocation" + ] + }, + "loglevel": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_global_loglevel_is_clamped_to_supported_range" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_observability_interaction_custom_logfilename_with_json_audit" + ] + }, + "logpath": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_global_log_settings_create_custom_log_file_and_logger_name" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_observability_interaction_custom_logfilename_with_json_audit" + ] + }, + "max_background_jobs": { + "direct": [ + "test/test_containment_functional.py::TestRuntimeContainmentFunctional::test_max_background_jobs_enforced" + ], + "interaction": [ + "test/test_audit_functional.py::TestAuditFunctional::test_runtime_limit_denial_reason_is_machine_readable_in_audit" + ] + }, + "max_processes": { + "direct": [ + "test/test_containment_functional.py::TestRuntimeContainmentFunctional::test_max_processes_denies_forking_pipeline" + ], + "interaction": [ + "test/test_containment_unit.py::TestRuntimeExecutionHelpers::test_apply_rlimits_applies_max_processes" + ] + }, + "max_sessions_per_user": { + "direct": [ + "test/test_containment_functional.py::TestRuntimeContainmentFunctional::test_max_sessions_per_user_enforced" + ], + "interaction": [ + "test/test_cli_unit.py::TestCliArgs::test_main_logs_and_exits_when_session_limit_denied" + ] + }, + "messages": { + "direct": [ + "test/test_config.py::TestFunctions::test_custom_messages_override_warning_output" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_messages_config_rejects_unsupported_keys_and_placeholders" + ] + }, + "overssh": { + "direct": [ + "test/test_ssh.py::TestFunctions::test_overssh_allowed_command_exit_0" + ], + "interaction": [ + "test/test_ssh.py::TestFunctions::test_overssh_plus_minus_chain_controls_warning_and_allow" + ] + }, + "path": { + "direct": [ + "test/test_path.py::TestFunctions::test_external_forbidden_path" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "path_noexec": { + "direct": [ + "test/test_unit.py::TestFunctions::test_disable_ld_preload" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_runtime_executor_allowed_shell_escape_and_path_noexec_interaction" + ] + }, + "prompt": { + "direct": [ + "test/test_prompt_unit.py::TestPromptUnit::test_getpromptbase_uses_config_prompt_when_lps1_not_set" + ], + "interaction": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_lps1_prompt_override_persists_across_prompt_refresh" + ] + }, + "prompt_short": { + "direct": [ + "test/test_prompt_unit.py::TestPromptUnit::test_prompt_short_rejects_values_outside_documented_range" + ], + "interaction": [ + "test/test_unit.py::TestFunctions::test_prompt_short_2" + ] + }, + "quiet": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_quiet_is_parsed_and_rejects_non_integer_values" + ], + "interaction": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_unknown_command_user_message_differs_by_strict_mode" + ] + }, + "runtime_executor": { + "direct": [ + "test/test_runtime_executor_config_unit.py::TestRuntimeExecutorConfig::test_runtime_executor_accepts_bash_compat" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_runtime_executor_allowed_shell_escape_and_path_noexec_interaction" + ] + }, + "scp": { + "direct": [ + "test/test_ssh_scp_sftp_attack_surface_unit.py::TestSSHScpSftpAttackSurface::test_run_overssh_rejects_scp_when_disabled_and_not_in_overssh" + ], + "interaction": [ + "test/test_ssh.py::TestFunctions::test_overssh_scp_upload_denied_when_uploads_disabled" + ] + }, + "scp_download": { + "direct": [ + "test/test_ssh.py::TestFunctions::test_overssh_scp_download_denied_when_downloads_disabled" + ], + "interaction": [ + "test/test_ssh_scp_sftp_attack_surface_unit.py::TestSSHScpSftpAttackSurface::test_run_overssh_rejects_scp_download_when_scp_download_disabled" + ] + }, + "scp_upload": { + "direct": [ + "test/test_ssh.py::TestFunctions::test_overssh_scp_upload_denied_when_uploads_disabled" + ], + "interaction": [ + "test/test_ssh_scp_sftp_attack_surface_unit.py::TestSSHScpSftpAttackSurface::test_run_overssh_rejects_scp_upload_when_scp_upload_disabled" + ] + }, + "scpforce": { + "direct": [ + "test/test_ssh.py::TestFunctions::test_overssh_scpforce_rewrites_upload_target_before_path_check" + ], + "interaction": [ + "test/test_ssh_scp_sftp_attack_surface_unit.py::TestSSHScpSftpAttackSurface::test_run_overssh_applies_scpforce_to_upload_target" + ] + }, + "security_audit_json": { + "direct": [ + "test/test_audit_unit.py::TestAuditLogging::test_security_audit_json_flag_is_parsed" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_observability_interaction_custom_logfilename_with_json_audit" + ] + }, + "sftp": { + "direct": [ + "test/test_ssh.py::TestFunctions::test_overssh_sftp_server_denied_when_sftp_disabled" + ], + "interaction": [ + "test/test_ssh_scp_sftp_attack_surface_unit.py::TestSSHScpSftpAttackSurface::test_run_overssh_allows_sftp_when_enabled" + ] + }, + "strict": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_unknown_command_user_message_differs_by_strict_mode" + ], + "interaction": [ + "test/test_exit.py::TestFunctions::test_warnings_then_kickout" + ] + }, + "sudo_commands": { + "direct": [ + "test/test_config.py::TestFunctions::test_sudo_commands_all_quoted_reflected_in_lshow" + ], + "interaction": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_forbidden_sudo_subcommand_shows_policy_denial" + ] + }, + "syslogname": { + "direct": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_global_log_settings_create_custom_log_file_and_logger_name" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_global_log_settings_create_custom_log_file_and_logger_name" + ] + }, + "timer": { + "direct": [ + "test/test_session_interaction_functional.py::TestSessionInteractionFunctional::test_timer_expiry_prints_message_and_ends_session" + ], + "interaction": [ + "test/test_cli_unit.py::TestCliArgs::test_main_handles_timer_timeout_path" + ] + }, + "umask": { + "direct": [ + "test/test_unit.py::TestFunctions::test_umask_sets_process_mask" + ], + "interaction": [ + "test/test_config_coverage_functional.py::TestConfigCoverageFunctional::test_path_env_umask_and_env_file_interaction" + ] + }, + "warning_counter": { + "direct": [ + "test/test_exit.py::TestFunctions::test_warnings_then_kickout" + ], + "interaction": [ + "test/test_config.py::TestFunctions::test_custom_messages_override_warning_output" + ] + }, + "winscp": { + "direct": [ + "test/test_ssh_scp_sftp_config_unit.py::TestSSHScpSftpConfig::test_winscp_allowed_commands" + ], + "interaction": [ + "test/test_ssh.py::TestFunctions::test_winscp_mode_allows_semicolon_in_interactive_session" + ] + } + } +} diff --git a/test/config_coverage_manifest_tools.py b/test/config_coverage_manifest_tools.py new file mode 100644 index 0000000..290e63e --- /dev/null +++ b/test/config_coverage_manifest_tools.py @@ -0,0 +1,80 @@ +"""Shared helpers for config-coverage manifest maintenance and guard tests.""" + +import ast +import hashlib +from pathlib import Path + +from lshell import variables +from lshell.config import schema + + +MANIFEST_PATH = Path(__file__).with_name("config_coverage_manifest.json") +NON_SETTING_CLI_PARAMS = {"config", "log"} +DEFAULT_TRACKED_TESTS_GLOB = "test/test_*.py" + + +def supported_config_keys(): + """Return the full set of currently supported runtime config keys.""" + keys = set() + + for option in variables.configparams: + if option.endswith("="): + key = option[:-1] + if key not in NON_SETTING_CLI_PARAMS: + keys.add(key) + + keys.update(schema.LIST_VALUE_KEYS) + keys.update(schema.INT_VALUE_KEYS) + keys.update(schema.DICT_VALUE_KEYS) + keys.update(schema.STRING_VALUE_KEYS) + + # Runtime-only accepted key (not exposed in variables.configparams). + keys.add("login_script") + # Global include lookup is merge-time behavior. + keys.add("include_dir") + # Global logging destination is consumed before user policy materialization. + keys.add("logpath") + + return keys + + +def collect_python_test_nodeids(): + """Collect pytest-style node IDs for top-level and unittest-style tests.""" + nodeids = set() + test_dir = Path(__file__).parent + repo_root = Path(__file__).resolve().parents[1] + for path in sorted(test_dir.glob("test_*.py")): + module = ast.parse(path.read_text(encoding="utf-8")) + try: + rel_path = path.relative_to(repo_root).as_posix() + except ValueError: + rel_path = path.as_posix() + + for node in module.body: + if isinstance(node, ast.ClassDef): + for member in node.body: + if isinstance(member, ast.FunctionDef) and member.name.startswith( + "test_" + ): + nodeids.add(f"{rel_path}::{node.name}::{member.name}") + continue + + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"): + nodeids.add(f"{rel_path}::{node.name}") + + return nodeids + + +def compute_tests_fingerprint(tracked_glob=DEFAULT_TRACKED_TESTS_GLOB): + """Hash tracked test file paths and contents for manifest freshness checks.""" + repo_root = Path(__file__).resolve().parents[1] + digest = hashlib.sha256() + for path in sorted(repo_root.glob(tracked_glob)): + if not path.is_file(): + continue + rel_path = path.relative_to(repo_root).as_posix().encode("utf-8") + digest.update(rel_path) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() diff --git a/test/samples/01_baseline_allowlist.conf b/test/samples/01_baseline_allowlist.conf index 3899154..f303486 100644 --- a/test/samples/01_baseline_allowlist.conf +++ b/test/samples/01_baseline_allowlist.conf @@ -18,5 +18,4 @@ forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 aliases : {'ll': 'ls -la'} strict : 0 -policy_commands : 1 prompt : "baseline:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/02_strict_warning_counter.conf b/test/samples/02_strict_warning_counter.conf index 66c668c..d4fbe2d 100644 --- a/test/samples/02_strict_warning_counter.conf +++ b/test/samples/02_strict_warning_counter.conf @@ -17,5 +17,4 @@ allowed : ['ls', 'pwd', 'echo', 'whoami'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 strict : 1 -policy_commands : 1 prompt : "strict:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/03_path_restrictions.conf b/test/samples/03_path_restrictions.conf index 760ed8e..6dfa9d5 100644 --- a/test/samples/03_path_restrictions.conf +++ b/test/samples/03_path_restrictions.conf @@ -18,5 +18,4 @@ forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] path : ['/tmp', '/home/testuser'] warning_counter : 4 strict : 0 -policy_commands : 1 prompt : "paths:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/04_sudo_and_aliases.conf b/test/samples/04_sudo_and_aliases.conf index 4860606..4ef87f5 100644 --- a/test/samples/04_sudo_and_aliases.conf +++ b/test/samples/04_sudo_and_aliases.conf @@ -20,5 +20,4 @@ forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 aliases : {'ll': 'ls -l', 'la': 'ls -la'} strict : 1 -policy_commands : 1 prompt : "sudo:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/05_file_extensions.conf b/test/samples/05_file_extensions.conf index 02dd883..13a269c 100644 --- a/test/samples/05_file_extensions.conf +++ b/test/samples/05_file_extensions.conf @@ -18,5 +18,4 @@ allowed_file_extensions : ['.log', '.txt', '.conf'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 strict : 1 -policy_commands : 1 prompt : "ext:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/06_env_history_umask.conf b/test/samples/06_env_history_umask.conf index 01bdcc2..9d05e9a 100644 --- a/test/samples/06_env_history_umask.conf +++ b/test/samples/06_env_history_umask.conf @@ -22,5 +22,4 @@ history_file : '/tmp/.lshell_history_%u' umask : 0077 warning_counter: 3 strict : 0 -policy_commands: 1 prompt : "env:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/07_ssh_transfer_controls.conf b/test/samples/07_ssh_transfer_controls.conf index 8da8b67..7043f86 100644 --- a/test/samples/07_ssh_transfer_controls.conf +++ b/test/samples/07_ssh_transfer_controls.conf @@ -23,5 +23,4 @@ sftp : 0 overssh : ['scp', 'rsync'] warning_counter : 4 strict : 1 -policy_commands : 1 prompt : "ssh:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/samples/08_user_group_precedence.conf b/test/samples/08_user_group_precedence.conf index db54b68..14d354d 100644 --- a/test/samples/08_user_group_precedence.conf +++ b/test/samples/08_user_group_precedence.conf @@ -18,7 +18,6 @@ forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 2 strict : 0 aliases : {'ll': 'ls -la'} -policy_commands : 1 prompt : "default:\033[91m%u\033[97m@\033[96m%h\033[0m" [grp:testuser] diff --git a/test/samples/09_include_dir_main.conf b/test/samples/09_include_dir_main.conf index 0c63e26..ab5e8a7 100644 --- a/test/samples/09_include_dir_main.conf +++ b/test/samples/09_include_dir_main.conf @@ -18,5 +18,4 @@ allowed : ['ls', 'pwd', 'echo'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 strict : 0 -policy_commands : 1 prompt : "include:\033[91m%u\033[97m@\033[96m%h\033[0m" diff --git a/test/test_breakout_regressions.py b/test/test_breakout_regressions.py index aa2936b..d07c666 100644 --- a/test/test_breakout_regressions.py +++ b/test/test_breakout_regressions.py @@ -105,7 +105,10 @@ def test_interactive_breakout_executes_disallowed_command_via_parameter_expansio child, "echo ${LSHELL_BREAKOUT_TEST:-$(id)}", ) - self.assertIn('lshell: forbidden command: "id"', breakout) + self.assertTrue( + ('lshell: forbidden command: "id"' in breakout) + or ("unsupported shell syntax: command substitution" in breakout) + ) self.assertNotRegex(breakout, r"uid=[0-9]+") finally: self._safe_exit(child) diff --git a/test/test_cli_unit.py b/test/test_cli_unit.py index 969cda2..7b5e285 100644 --- a/test/test_cli_unit.py +++ b/test/test_cli_unit.py @@ -201,12 +201,12 @@ def cmdloop(self): with patch( "lshell.cli.containment.SessionAccountant", return_value=accountant, - ): - with patch("lshell.cli.sys.argv", ["lshell", "--quiet=1"]): - with patch("lshell.cli.sys.exit", side_effect=SystemExit): - with self.assertRaises(SystemExit): - cli.main() - exported_session_id = os.environ["LSHELL_SESSION_ID"] + ): + with patch("lshell.cli.sys.argv", ["lshell", "--quiet=1"]): + with patch("lshell.cli.sys.exit", side_effect=SystemExit): + with self.assertRaises(SystemExit): + cli.main() + exported_session_id = os.environ["LSHELL_SESSION_ID"] self.assertEqual(captured["session_id"], "fixed-session") self.assertEqual(exported_session_id, "fixed-session") diff --git a/test/test_command_execution.py b/test/test_command_execution.py index 42c6188..1f3dc14 100644 --- a/test/test_command_execution.py +++ b/test/test_command_execution.py @@ -241,19 +241,27 @@ def test_pipeline_is_shell_compatible(self): self.do_exit(child) def test_redirection_is_shell_compatible(self): - """F46 | Redirections should be handled by shell semantics.""" + """F46 | Redirections should fail closed in shellless execution mode.""" + output_path = "/tmp/lshell_redir_test" + if os.path.exists(output_path): + os.remove(output_path) + child = pexpect.spawn( - f"{LSHELL} --config {CONFIG} --path \"['/tmp']\" " + f"{LSHELL} --config {CONFIG} --strict 1 --path \"['/tmp']\" " "--forbidden \"-['>','<','&']\" --allowed \"+['cat']\"" ) child.expect(PROMPT) - child.sendline("ls does_not_exist >/tmp/lshell_redir_test 2>&1") + child.sendline(f"ls does_not_exist >{output_path} 2>&1") child.expect(PROMPT) - child.sendline("cat /tmp/lshell_redir_test") + rejected = child.before.decode("utf8") + self.assertIn("lshell: unknown syntax:", rejected) + self.assertIn("unsupported shell syntax: redirection operators", rejected) + + child.sendline(f"cat {output_path}") child.expect(PROMPT) result = child.before.decode("utf8").split("\n", 1)[1] - self.assertIn("does_not_exist", result) + self.assertIn("No such file or directory", result) self.do_exit(child) def test_allowed_missing_binary_uses_lshell_error(self): @@ -276,6 +284,47 @@ def test_allowed_missing_binary_uses_lshell_error(self): self.assertNotIn("bash:", output) self.do_exit(child) + def test_bash_compat_allows_historic_substitution_behavior(self): + """F68 | bash_compat should allow command/process substitution semantics.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --strict 0 --forbidden \"[]\" " + "--allowed \"+['printf','cat','tee']\" " + "--runtime_executor bash_compat" + ) + child.expect(PROMPT) + try: + cases = [ + ("echo $(printf CMD_OK)", "CMD_OK"), + ("echo `printf TICK_OK`", "TICK_OK"), + ("cat <(printf PROC_OK)", "PROC_OK"), + ("printf PROCW_OK | tee >(cat)", "PROCW_OK"), + ] + for command, expected in cases: + child.sendline(command) + child.expect(PROMPT) + output = child.before.decode("utf8") + self.assertNotIn("lshell: unknown syntax:", output) + self.assertIn(expected, output) + + self.do_exit(child) + finally: + if child.isalive(): + child.close() + + def test_bash_compat_keeps_nested_substitution_allowlist_checks(self): + """F68b | Enabled substitutions must still enforce nested allow-list rules.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --strict 1 --forbidden \"[]\" " + "--allowed \"['echo']\" " + "--runtime_executor bash_compat" + ) + child.expect(PROMPT) + child.sendline("echo $(id)") + child.expect(PROMPT) + output = child.before.decode("utf8") + self.assertIn('lshell: forbidden command: "id"', output) + self.do_exit(child) + def test_operator_matrix_fuzz(self): """F69 | Operator and expansion matrix should remain stable.""" child = pexpect.spawn( @@ -299,13 +348,9 @@ def expect_clean_prompt(): ("echo MATRIX_START", ["MATRIX_START"]), ("echo 'a b c' | wc -w", ["3"]), ("printf matrix | wc -c", ["6"]), - (f"echo one > {temp_file}", []), - (f"echo two >> {temp_file}", []), - (f"cat {temp_file}", ["one", "two"]), ("true && echo branch_true", ["branch_true"]), ("false || echo branch_false", ["branch_false"]), ("cd /tmp && pwd", ["/tmp"]), - ("echo $(printf nested_ok)", ["nested_ok"]), ('NAME=ALPHA echo "$NAME"', ["ALPHA"]), ("echo ${HOME}", ["/"]), ] @@ -316,6 +361,26 @@ def expect_clean_prompt(): for expected in expected_bits: self.assertIn(expected, output) + rejected_matrix = [ + ( + f"echo one > {temp_file}", + ["lshell: unknown syntax:", "unsupported shell syntax: redirection operators"], + ), + ( + "echo $(printf nested_ok)", + [ + "lshell: unsupported shell syntax:", + "unsupported shell syntax: command substitution", + ], + ), + ] + + for command, expected_bits in rejected_matrix: + child.sendline(command) + output = expect_clean_prompt() + for expected in expected_bits: + self.assertIn(expected, output) + self.do_exit(child) finally: if os.path.exists(temp_file): diff --git a/test/test_config.py b/test/test_config.py index e2337d7..ad74081 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -179,6 +179,19 @@ def test_schema_rejects_non_dict_aliases(self): "lshell: config: 'aliases' must be a dictionary", ) + def test_schema_rejects_unknown_runtime_executor(self): + """F63b | runtime_executor must match supported values.""" + self.assert_startup_failure( + f"{LSHELL} --config {CONFIG} --runtime_executor unknown_mode", + "lshell: config: runtime_executor must be one of: bash_compat, shellless", + ) + + def test_schema_accepts_bash_compat_runtime_executor(self): + """F63c | runtime_executor=bash_compat should start successfully.""" + child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --runtime_executor bash_compat") + child.expect(PROMPT) + self.do_exit(child) + def test_custom_messages_override_warning_output(self): """F64 | messages config should override warning text.""" child = pexpect.spawn( diff --git a/test/test_config_coverage_functional.py b/test/test_config_coverage_functional.py new file mode 100644 index 0000000..6231ede --- /dev/null +++ b/test/test_config_coverage_functional.py @@ -0,0 +1,509 @@ +"""Functional and integration coverage for lshell configuration settings.""" + +import io +import json +import os +import re +import stat +import tempfile +import textwrap +import unittest +from getpass import getuser +from unittest.mock import patch + +import pexpect + +from lshell.config.runtime import CheckConfig +from lshell.config import diagnostics as policy + + +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 TestConfigCoverageFunctional(unittest.TestCase): + """Supplementary config-coverage tests focused on behavior contracts.""" + + args = [f"--config={CONFIG}", "--quiet=1"] + + def setUp(self): + self._saved_lshell_args = os.environ.get("LSHELL_ARGS") + self._saved_lps1 = os.environ.get("LPS1") + + def tearDown(self): + if self._saved_lshell_args is None: + os.environ.pop("LSHELL_ARGS", None) + else: + os.environ["LSHELL_ARGS"] = self._saved_lshell_args + + if self._saved_lps1 is None: + os.environ.pop("LPS1", None) + else: + os.environ["LPS1"] = self._saved_lps1 + + def _write_config(self, directory, content, filename="lshell.conf"): + path = os.path.join(directory, filename) + with open(path, "w", encoding="utf-8") as handle: + handle.write(textwrap.dedent(content).strip() + "\n") + return path + + def _clean_env(self, extra=None): + env = os.environ.copy() + env.pop("LSHELL_ARGS", None) + env.pop("LPS1", None) + if extra: + env.update(extra) + return env + + def _spawn_shell_with_config( + self, + config_path, + extra_args="", + timeout=12, + env=None, + prompt=PROMPT, + ): + command = f"{LSHELL} --config {config_path} {extra_args}".strip() + child = pexpect.spawn( + command, + encoding="utf-8", + timeout=timeout, + env=self._clean_env(env), + ) + child.expect(prompt) + return child + + def _safe_exit(self, child): + if not child.isalive(): + return + child.sendline("exit") + try: + child.expect(pexpect.EOF, timeout=4) + except pexpect.TIMEOUT: + child.close(force=True) + + def _runtime_checkconfig(self, configfile, username, group_ids, gid_to_group): + def _fake_getgrgid(gid): + if gid in gid_to_group: + return (gid_to_group[gid], "x", gid, []) + raise KeyError(gid) + + with ( + patch("lshell.config.runtime.getuser", return_value=username), + patch("lshell.config.runtime.os.getgroups", return_value=group_ids), + patch("lshell.config.runtime.grp.getgrgid", side_effect=_fake_getgrgid), + ): + checker = CheckConfig( + [f"--config={configfile}", "--quiet=1"], + refresh=True, + stdin=io.StringIO(), + stdout=io.StringIO(), + stderr=io.StringIO(), + ) + return checker.returnconf() + + def test_global_log_settings_create_custom_log_file_and_logger_name(self): + """Cover logpath/loglevel/logfilename/syslogname integration in runtime config.""" + with tempfile.TemporaryDirectory(prefix="lshell-log-coverage-") as logdir: + conf = CheckConfig( + self.args + + [ + f"--logpath={logdir}", + "--loglevel=4", + "--logfilename=cov-%u", + "--syslogname=coverage-syslog", + ] + ).returnconf() + + expected_log = os.path.join(logdir, f"cov-{USER}.log") + self.assertTrue(os.path.isfile(expected_log)) + self.assertEqual(conf["loglevel"], 4) + self.assertTrue(conf["logpath"].name.startswith("coverage-syslog.")) + + def test_global_loglevel_is_clamped_to_supported_range(self): + """Values outside 0..4 should clamp to the nearest valid loglevel.""" + high = CheckConfig(self.args + ["--loglevel=99"]).returnconf() + low = CheckConfig(self.args + ["--loglevel=-7"]).returnconf() + self.assertEqual(high["loglevel"], 4) + self.assertEqual(low["loglevel"], 0) + + def test_include_dir_runtime_precedence_user_group_default(self): + """Runtime loader should merge include_dir sections with user > group > default.""" + with tempfile.TemporaryDirectory(prefix="lshell-include-runtime-") as tempdir: + include_dir = os.path.join(tempdir, "include.d") + os.makedirs(include_dir, exist_ok=True) + + configfile = self._write_config( + tempdir, + f""" + [global] + logpath : /tmp + loglevel : 0 + include_dir : {include_dir}/layer. + + [default] + allowed : ['base'] + forbidden : [';'] + warning_counter : 2 + strict : 1 + """, + ) + + self._write_config( + include_dir, + """ + [default] + allowed : + ['default_inc'] + """, + filename="layer.10-default", + ) + + self._write_config( + include_dir, + """ + [grp:ops] + allowed : + ['group_inc'] + """, + filename="layer.20-group", + ) + + self._write_config( + include_dir, + """ + [alice] + allowed : + ['user_inc'] - ['base'] + """, + filename="layer.30-user", + ) + + conf = self._runtime_checkconfig( + configfile=configfile, + username="alice", + group_ids=[1000], + gid_to_group={1000: "ops"}, + ) + + self.assertIn("default_inc", conf["allowed"]) + self.assertIn("group_inc", conf["allowed"]) + self.assertIn("user_inc", conf["allowed"]) + self.assertNotIn("base", conf["allowed"]) + + def test_home_path_and_history_file_expand_username_placeholder(self): + """home_path/history_file should resolve %u placeholders for the target user.""" + with tempfile.TemporaryDirectory(prefix="lshell-home-path-") as tempdir: + alice_home = os.path.join(tempdir, "alice-home") + os.makedirs(alice_home, exist_ok=True) + configfile = self._write_config( + tempdir, + f""" + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['echo'] + forbidden : [] + warning_counter : 2 + strict : 0 + home_path : '{tempdir}/%u-home' + history_file : '.hist_%u' + """, + ) + + with ( + patch("lshell.config.runtime.getuser", return_value="alice"), + patch("lshell.config.runtime.os.getgroups", return_value=[]), + ): + conf = CheckConfig( + [f"--config={configfile}", "--quiet=1"], + refresh=True, + stdin=io.StringIO(), + stdout=io.StringIO(), + stderr=io.StringIO(), + ).returnconf() + + expected_home = os.path.join(tempdir, "alice-home") + self.assertEqual(conf["home_path"], expected_home) + self.assertEqual(conf["oldpwd"], expected_home) + self.assertEqual(conf["history_file"], f"{expected_home}/.hist_alice") + + def test_custom_intro_is_displayed_before_first_prompt(self): + """Configured intro string should be printed before interactive prompt.""" + with tempfile.TemporaryDirectory(prefix="lshell-intro-") as tempdir: + configfile = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['echo'] + forbidden : [] + warning_counter : 2 + strict : 0 + intro : 'COVERAGE_INTRO_MESSAGE' + """, + ) + + child = pexpect.spawn( + f"{LSHELL} --config {configfile}", + encoding="utf-8", + timeout=10, + env=self._clean_env(), + ) + try: + child.expect(PROMPT) + self.assertIn("COVERAGE_INTRO_MESSAGE", child.before) + child.sendline("exit") + child.expect(pexpect.EOF) + finally: + child.close(force=True) + + def test_quiet_is_parsed_and_rejects_non_integer_values(self): + """quiet should parse as int and reject non-integer values.""" + conf = CheckConfig(self.args + ["--quiet=1"]).returnconf() + self.assertEqual(conf["quiet"], 1) + + with self.assertRaises(SystemExit): + CheckConfig(self.args + ["--quiet='loud'"]).returnconf() + + def test_messages_config_rejects_unsupported_keys_and_placeholders(self): + """messages should reject unknown keys and unsupported placeholder fields.""" + with tempfile.TemporaryDirectory(prefix="lshell-messages-") as tempdir: + invalid_key_config = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['ls'] + forbidden : [';'] + warning_counter : 2 + messages : {'unsupported_key': 'x'} + """, + filename="invalid-key.conf", + ) + + invalid_placeholder_config = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['ls'] + forbidden : [';'] + warning_counter : 2 + messages : {'unknown_syntax': 'bad {nope}'} + """, + filename="invalid-placeholder.conf", + ) + + with self.assertRaises(ValueError) as invalid_key_error: + policy.resolve_policy(invalid_key_config, USER, []) + self.assertIn("unsupported key", str(invalid_key_error.exception)) + + with self.assertRaises(ValueError) as invalid_placeholder_error: + policy.resolve_policy(invalid_placeholder_config, USER, []) + self.assertIn("unsupported placeholders", str(invalid_placeholder_error.exception)) + + def test_allowed_shell_escape_all_literal_is_rejected(self): + """allowed_shell_escape=all must fail closed in policy resolution.""" + with tempfile.TemporaryDirectory(prefix="lshell-shell-escape-") as tempdir: + configfile = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['ls'] + allowed_shell_escape : all + forbidden : [';'] + warning_counter : 2 + """, + ) + + with self.assertRaises(ValueError) as error: + policy.resolve_policy(configfile, USER, []) + self.assertIn("allowed_shell_escape", str(error.exception)) + self.assertIn("cannot be set to 'all'", str(error.exception)) + + def test_runtime_executor_allowed_shell_escape_and_path_noexec_interaction(self): + """shellless should still allow explicit shell escape when path_noexec is disabled.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} " + "--runtime_executor shellless " + "--path_noexec \"''\" " + "--allowed \"[]\" " + "--allowed_shell_escape \"['echo']\" " + "--forbidden \"[]\"", + encoding="utf-8", + timeout=10, + env=self._clean_env(), + ) + try: + child.expect(PROMPT) + child.sendline("echo SHELL_ESCAPE_OK") + child.expect(PROMPT) + output = child.before + self.assertIn("SHELL_ESCAPE_OK", output) + self._safe_exit(child) + finally: + child.close(force=True) + + def test_path_env_umask_and_env_file_interaction(self): + """Combined path/env/umask controls should enforce predictable runtime behavior.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-env-") as tempdir: + home_path = os.path.join(tempdir, "home") + bindir = os.path.join(tempdir, "bin") + os.makedirs(home_path, exist_ok=True) + os.makedirs(bindir, exist_ok=True) + + command_name = "cov_allowed_cmd" + command_path = os.path.join(bindir, command_name) + with open(command_path, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") + handle.write("echo ALLOWED_CMD_PATH_AND_ENV_PATH_OK\n") + os.chmod(command_path, 0o700) + + env_file = os.path.join(tempdir, "extra.env") + with open(env_file, "w", encoding="utf-8") as handle: + handle.write("export COV_RUNTIME_ENV=from_file\n") + + configfile = self._write_config( + tempdir, + f""" + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['echo', 'touch', '{command_name}'] + forbidden : [] + warning_counter : 2 + strict : 1 + path : ['{home_path}'] + home_path : '{home_path}' + env_path : '{bindir}' + allowed_cmd_path : ['{bindir}'] + env_vars : {{'COV_RUNTIME_ENV': 'from_conf'}} + env_vars_files : ['{env_file}'] + umask : 0077 + """, + ) + + active_prompt = ( + rf"{re.escape(USER)}:" + rf"(?:~|{re.escape(os.path.realpath(home_path))})\$" + ) + child = self._spawn_shell_with_config(configfile, prompt=active_prompt) + try: + child.sendline("echo $COV_RUNTIME_ENV") + child.expect(active_prompt) + self.assertIn("from_file", child.before) + + child.sendline(command_name) + child.expect(active_prompt) + self.assertIn("ALLOWED_CMD_PATH_AND_ENV_PATH_OK", child.before) + + child.sendline("touch secret.txt") + child.expect(active_prompt) + finally: + self._safe_exit(child) + child.close(force=True) + + mode = stat.S_IMODE(os.stat(os.path.join(home_path, "secret.txt")).st_mode) + self.assertEqual(mode, 0o600) + + def test_observability_interaction_custom_logfilename_with_json_audit(self): + """Logpath/loglevel/logfilename/audit-json should emit ECS command events to custom file.""" + with tempfile.TemporaryDirectory(prefix="lshell-observability-") as log_dir: + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --log {log_dir} " + "--loglevel=4 --logfilename=coverage-%u --security_audit_json=1 " + "--strict 0", + encoding="utf-8", + timeout=10, + env=self._clean_env(), + ) + try: + child.expect(PROMPT) + child.sendline("echo OBSERVABILITY_OK") + child.expect(PROMPT) + child.sendline("exit") + child.expect(pexpect.EOF) + finally: + child.close(force=True) + + logfile = os.path.join(log_dir, f"coverage-{USER}.log") + self.assertTrue(os.path.exists(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) + + success_events = [ + event + for event in events + if event.get("process.command_line") == "echo OBSERVABILITY_OK" + and event.get("event.outcome") == "success" + ] + self.assertTrue(success_events, msg=f"missing command event in {events}") + + def test_lshow_always_available_while_aliases_expand(self): + """Legacy policy_commands key must not hide lshow; aliases should still expand.""" + with tempfile.TemporaryDirectory(prefix="lshell-lshow-always-") as tempdir: + configfile = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['echo'] + forbidden : [] + warning_counter : 3 + strict : 0 + aliases : {'sayhi':'echo ALIAS_OK'} + policy_commands : 0 + """, + ) + + child = self._spawn_shell_with_config(configfile, "--quiet 1") + try: + child.sendline("sayhi") + child.expect(PROMPT) + self.assertIn("ALIAS_OK", child.before) + + child.sendline("lshow echo visible") + child.expect(PROMPT) + output = child.before + self.assertIn("Command : echo visible", output) + self.assertIn("Decision :", output) + self.assertIn("ALLOW", output) + finally: + self._safe_exit(child) + child.close(force=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_config_coverage_manifest_guard.py b/test/test_config_coverage_manifest_guard.py new file mode 100644 index 0000000..e12b0de --- /dev/null +++ b/test/test_config_coverage_manifest_guard.py @@ -0,0 +1,103 @@ +"""Guardrails for exhaustive configuration-coverage mapping.""" + +import json +import unittest + +from test.config_coverage_manifest_tools import ( + DEFAULT_TRACKED_TESTS_GLOB, + MANIFEST_PATH, + collect_python_test_nodeids, + compute_tests_fingerprint, + supported_config_keys, +) + + +def _load_manifest(): + with open(MANIFEST_PATH, "r", encoding="utf-8") as handle: + return json.load(handle) + + +class TestConfigCoverageManifestGuard(unittest.TestCase): + """Fail fast when config settings lose explicit test coverage.""" + + def test_manifest_supported_key_list_matches_runtime(self): + """Manifest key inventory must exactly match currently supported config keys.""" + manifest = _load_manifest() + declared = set(manifest.get("supported_keys", [])) + computed = supported_config_keys() + self.assertEqual( + declared, + computed, + msg=( + "config_coverage_manifest.json supported_keys does not match " + "runtime/schema/configparams-derived keys" + ), + ) + + def test_every_supported_key_has_direct_and_interaction_coverage(self): + """Every supported key must map to at least one direct and interaction test.""" + manifest = _load_manifest() + coverage = manifest.get("coverage", {}) + supported = supported_config_keys() + + missing = sorted(key for key in supported if key not in coverage) + self.assertFalse( + missing, + msg=f"Missing coverage entries for supported keys: {', '.join(missing)}", + ) + + for key in sorted(supported): + entry = coverage[key] + direct = entry.get("direct", []) + interaction = entry.get("interaction", []) + self.assertTrue( + direct, + msg=f"Key '{key}' must declare at least one direct test", + ) + self.assertTrue( + interaction, + msg=f"Key '{key}' must declare at least one interaction test", + ) + + def test_manifest_references_existing_python_tests(self): + """All manifest node IDs must resolve to concrete Python tests in test/.""" + manifest = _load_manifest() + coverage = manifest.get("coverage", {}) + known_tests = collect_python_test_nodeids() + + missing_test_ids = [] + for key, entry in coverage.items(): + for field in ("direct", "interaction"): + for nodeid in entry.get(field, []): + if nodeid not in known_tests: + missing_test_ids.append((key, field, nodeid)) + + self.assertFalse( + missing_test_ids, + msg=( + "Manifest references unknown tests: " + + ", ".join( + f"{key}:{field}:{nodeid}" + for key, field, nodeid in missing_test_ids + ) + ), + ) + + def test_manifest_test_fingerprint_matches_current_suite(self): + """Manifest fingerprint must track current test suite content.""" + manifest = _load_manifest() + tracked_glob = manifest.get("tracked_tests_glob", DEFAULT_TRACKED_TESTS_GLOB) + declared_fingerprint = manifest.get("tests_fingerprint_sha256") + computed_fingerprint = compute_tests_fingerprint(tracked_glob) + self.assertEqual( + declared_fingerprint, + computed_fingerprint, + msg=( + "config_coverage_manifest.json test fingerprint is stale. " + "Run: python3 scripts/update_config_coverage_manifest.py --write" + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_engine_pipeline_unit.py b/test/test_engine_pipeline_unit.py index 783e6a2..6bf9f84 100644 --- a/test/test_engine_pipeline_unit.py +++ b/test/test_engine_pipeline_unit.py @@ -176,6 +176,48 @@ def test_authorizer_parses_nested_command_substitutions(self): ) self.assertTrue(decision.allowed) + def test_runtime_authorizer_allows_substitutions_in_bash_compat(self): + """bash_compat runtime mode should allow both substitution families.""" + policy = _policy( + allowed=["echo", "printf", "cat", "tee"], + forbidden=[], + strict=0, + runtime_executor="bash_compat", + ) + command_cases = [ + "echo $(printf ok)", + "echo `printf ok`", + "cat <(printf ok)", + "printf ok | tee >(cat)", + ] + for line in command_cases: + with self.subTest(line=line): + decision = authorizer.authorize_line( + line, + policy, + mode="runtime", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + + def test_runtime_authorizer_keeps_nested_allowlist_checks_in_command_substitution( + self, + ): + """Enabled substitution must still recurse into nested allow-list checks.""" + decision = authorizer.authorize_line( + "echo $(id)", + _policy( + allowed=["echo"], + forbidden=[], + strict=1, + runtime_executor="bash_compat", + ), + mode="runtime", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.FORBIDDEN_COMMAND) + def test_authorizer_enforces_overssh_allowlist_inside_nested_expansions(self): """SSH-mode nested expansions must use overssh allow-list decisions.""" decision = authorizer.authorize_line( diff --git a/test/test_parser_jobs_unit.py b/test/test_parser_jobs_unit.py index c39df55..a8410f1 100644 --- a/test/test_parser_jobs_unit.py +++ b/test/test_parser_jobs_unit.py @@ -5,7 +5,7 @@ import tempfile import unittest from contextlib import redirect_stdout -from unittest.mock import patch +from unittest.mock import call, patch from lshell import builtincmd from lshell import completion @@ -34,6 +34,26 @@ def wait(self): return self.returncode +class FakePipelineMember: + """Simple fake process object representing one pipeline stage.""" + + def __init__(self, poll_value=None, returncode=0, pid=20000): + self._poll_value = poll_value + self.returncode = returncode + self.pid = pid + self.wait_called = False + + def poll(self): + """Return the configured running/completed state.""" + return self._poll_value + + def wait(self): + """Mark member as completed when waited.""" + self.wait_called = True + self._poll_value = self.returncode + return self.returncode + + class FakeProcess: """Simple fake subprocess object used by exec_cmd signal tests.""" @@ -50,13 +70,18 @@ def poll(self): """Report running state until the fake process completes.""" return self._poll_value - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Simulate command execution, optionally triggering Ctrl+Z.""" if self.trigger_suspend: handler = self._signal_handlers.get(utils.signal.SIGTSTP) if handler is not None: handler(utils.signal.SIGTSTP, None) self._poll_value = self.returncode + return self.returncode + + def communicate(self): + """Simulate command execution, optionally triggering Ctrl+Z.""" + self.wait() class TestParserUtilities(unittest.TestCase): @@ -331,11 +356,11 @@ def test_cmd_bg_fg_invalid_job_id(self): self.assertEqual(ret, 1) self.assertIn("lshell: invalid job ID", stdout.getvalue()) - @patch("os.getpgid", return_value=4242) - @patch("os.killpg") - def test_cmd_bg_fg_resumes_and_removes_job(self, mock_killpg, _mock_getpgid): + @patch("lshell.builtincmd.os.killpg") + def test_cmd_bg_fg_resumes_and_removes_job(self, mock_killpg): """Resume a running job in foreground and remove it once completed.""" job = FakeJob(poll_value=None, returncode=0, pid=9876, cmd="sleep 10") + job.lshell_pgid = 4242 builtincmd.BACKGROUND_JOBS.append(job) stdout = io.StringIO() @@ -346,13 +371,13 @@ def test_cmd_bg_fg_resumes_and_removes_job(self, mock_killpg, _mock_getpgid): self.assertTrue(job.wait_called) self.assertEqual(len(builtincmd.BACKGROUND_JOBS), 0) self.assertIn("sleep 10", stdout.getvalue()) - mock_killpg.assert_called_once() + mock_killpg.assert_called_once_with(4242, utils.signal.SIGCONT) - @patch("lshell.builtincmd.os.getpgid", return_value=4242) @patch("lshell.builtincmd.os.killpg") - def test_cmd_bg_fg_ctrl_z_keeps_single_job_entry(self, mock_killpg, _mock_getpgid): + def test_cmd_bg_fg_ctrl_z_keeps_single_job_entry(self, mock_killpg): """Ctrl+Z during fg should not duplicate the same job or raise.""" job = FakeJob(poll_value=None, returncode=0, pid=9876, cmd="tail -f blabla") + job.lshell_pgid = 4242 builtincmd.BACKGROUND_JOBS.append(job) stdout = io.StringIO() handlers = {} @@ -378,6 +403,12 @@ def trigger_ctrl_z(): self.assertEqual(len(builtincmd.BACKGROUND_JOBS), 1) self.assertIn("Stopped", stdout.getvalue()) self.assertEqual(mock_killpg.call_count, 2) + mock_killpg.assert_has_calls( + [ + call(4242, utils.signal.SIGCONT), + call(4242, utils.signal.SIGSTOP), + ] + ) @patch("lshell.utils.os.getpgid", return_value=4242) @patch("lshell.utils.os.killpg") @@ -474,6 +505,150 @@ def test_check_background_jobs_prunes_all_completed_entries(self): self.assertEqual(output.count("Done"), 2) self.assertEqual(output.count("Failed"), 1) + def test_check_background_jobs_keeps_pipeline_until_all_members_finish(self): + """Do not mark a pipeline done while any earlier stage is still running.""" + producer = FakePipelineMember(poll_value=None, returncode=0, pid=2001) + consumer = FakePipelineMember(poll_value=0, returncode=0, pid=2002) + job = FakeJob(poll_value=0, returncode=0, pid=2002, cmd="sleep 10 | cat") + job.lshell_pipeline = (producer, consumer) + builtincmd.BACKGROUND_JOBS.append(job) + + stdout = io.StringIO() + with redirect_stdout(stdout): + builtincmd.check_background_jobs() + + self.assertEqual(len(builtincmd.BACKGROUND_JOBS), 1) + self.assertIs(builtincmd.BACKGROUND_JOBS[0], job) + self.assertEqual(stdout.getvalue(), "") + + def test_jobs_keeps_pipeline_listed_while_any_member_is_running(self): + """jobs() must not prune a pipeline when only its last stage has exited.""" + producer = FakePipelineMember(poll_value=None, returncode=0, pid=2101) + consumer = FakePipelineMember(poll_value=0, returncode=0, pid=2102) + job = FakeJob(poll_value=0, returncode=0, pid=2102, cmd="sleep 5 | cat") + job.lshell_pipeline = (producer, consumer) + builtincmd.BACKGROUND_JOBS.append(job) + + joblist = builtincmd.jobs() + + self.assertEqual(joblist, [[1, "Stopped", "sleep 5 | cat"]]) + self.assertEqual(len(builtincmd.BACKGROUND_JOBS), 1) + self.assertIs(builtincmd.BACKGROUND_JOBS[0], job) + + @patch("lshell.builtincmd.os.kill") + @patch("lshell.builtincmd.os.killpg") + def test_cmd_bg_fg_waits_running_pipeline_members_when_tail_already_exited( + self, mock_killpg, mock_kill + ): + """fg should still manage a pipeline if only an earlier stage remains alive.""" + producer = FakePipelineMember(poll_value=None, returncode=0, pid=2201) + consumer = FakePipelineMember(poll_value=0, returncode=0, pid=2202) + job = FakeJob(poll_value=0, returncode=0, pid=2202, cmd="sleep 15 | cat") + job.lshell_pipeline = (producer, consumer) + job.lshell_pgid = 4242 + builtincmd.BACKGROUND_JOBS.append(job) + + stdout = io.StringIO() + with redirect_stdout(stdout): + ret = builtincmd.cmd_bg_fg("fg", "1") + + self.assertEqual(ret, 0) + self.assertTrue(producer.wait_called) + self.assertEqual(len(builtincmd.BACKGROUND_JOBS), 0) + self.assertIn("sleep 15 | cat", stdout.getvalue()) + mock_killpg.assert_called_with(4242, utils.signal.SIGCONT) + mock_kill.assert_not_called() + + @patch("lshell.builtincmd.os.kill") + @patch("lshell.builtincmd.os.killpg") + def test_cmd_bg_fg_non_detached_pipeline_signals_running_members_only( + self, mock_killpg, mock_kill + ): + """fg on non-detached jobs must signal running members by PID only.""" + producer = FakePipelineMember(poll_value=None, returncode=0, pid=2301) + consumer = FakePipelineMember(poll_value=0, returncode=0, pid=2302) + job = FakeJob(poll_value=0, returncode=0, pid=2302, cmd="sudo whoami | cat") + job.lshell_pipeline = (producer, consumer) + builtincmd.BACKGROUND_JOBS.append(job) + + stdout = io.StringIO() + with redirect_stdout(stdout): + ret = builtincmd.cmd_bg_fg("fg", "1") + + self.assertEqual(ret, 0) + self.assertTrue(producer.wait_called) + self.assertFalse(consumer.wait_called) + self.assertEqual(len(builtincmd.BACKGROUND_JOBS), 0) + mock_killpg.assert_not_called() + mock_kill.assert_called_once_with(2301, utils.signal.SIGCONT) + + @patch("lshell.builtincmd.os.kill") + @patch("lshell.builtincmd.os.killpg") + def test_signal_job_detached_pipeline_uses_tracked_pgid_only( + self, mock_killpg, mock_kill + ): + """Detached jobs should keep the process-group signaling fast path.""" + producer = FakePipelineMember(poll_value=None, returncode=0, pid=2401) + consumer = FakePipelineMember(poll_value=None, returncode=0, pid=2402) + job = FakeJob(poll_value=None, returncode=0, pid=2402, cmd="sleep 1 | cat") + job.lshell_pipeline = (producer, consumer) + job.lshell_pgid = 7788 + + builtincmd._signal_job(job, utils.signal.SIGCONT) + + mock_killpg.assert_called_once_with(7788, utils.signal.SIGCONT) + mock_kill.assert_not_called() + + @patch("lshell.builtincmd.os.kill") + @patch( + "lshell.builtincmd.os.killpg", + side_effect=OSError("failed process-group signal"), + ) + def test_signal_job_falls_back_to_member_kill_when_killpg_fails( + self, mock_killpg, mock_kill + ): + """Detached jobs should still signal running members if killpg fails.""" + producer = FakePipelineMember(poll_value=None, returncode=0, pid=2501) + consumer = FakePipelineMember(poll_value=0, returncode=0, pid=2502) + job = FakeJob(poll_value=0, returncode=0, pid=2502, cmd="sleep 2 | cat") + job.lshell_pipeline = (producer, consumer) + job.lshell_pgid = 8899 + + builtincmd._signal_job(job, utils.signal.SIGSTOP) + + mock_killpg.assert_called_once_with(8899, utils.signal.SIGSTOP) + mock_kill.assert_called_once_with(2501, utils.signal.SIGSTOP) + + @patch("lshell.builtincmd.os.kill") + @patch("lshell.builtincmd.os.killpg") + def test_signal_job_member_fallback_continues_after_partial_kill_errors( + self, mock_killpg, mock_kill + ): + """Per-member fallback should continue signaling remaining running members.""" + first = FakePipelineMember(poll_value=None, returncode=0, pid=2601) + second = FakePipelineMember(poll_value=None, returncode=0, pid=2602) + job = FakeJob(poll_value=None, returncode=0, pid=2602, cmd="sudo sleep 60") + job.lshell_pipeline = (first, second) + + def _kill_side_effect(pid, signum): + if pid == 2601: + raise OSError("first member already gone") + self.assertEqual(signum, utils.signal.SIGINT) + return None + + mock_kill.side_effect = _kill_side_effect + + builtincmd._signal_job(job, utils.signal.SIGINT) + + mock_killpg.assert_not_called() + self.assertEqual( + mock_kill.call_args_list, + [ + call(2601, utils.signal.SIGINT), + call(2602, utils.signal.SIGINT), + ], + ) + def test_jobs_prunes_finished_and_reindexes_running(self): """jobs() should drop non-running jobs and reindex active ones from 1.""" builtincmd.BACKGROUND_JOBS.extend( diff --git a/test/test_path.py b/test/test_path.py index c6155e9..f46320a 100644 --- a/test/test_path.py +++ b/test/test_path.py @@ -15,6 +15,45 @@ class TestFunctions(unittest.TestCase): """Functional tests for lshell""" + @staticmethod + def _normalized_path_for_message(path): + """Return canonical path text matching lshell forbidden-path formatting.""" + resolved = os.path.realpath(path) + if os.path.isdir(resolved) and not resolved.endswith("/"): + return f"{resolved}/" + return resolved + + @staticmethod + def _normalize_output(output): + """Normalize terminal output line endings for stable assertions.""" + return output.replace("\r\n", "\n") + + @staticmethod + def _expected_warning_line(remaining): + """Return exact warning line text for strict mode violations.""" + violation_label = "violation" if remaining == 1 else "violations" + return ( + f"lshell: warning: {remaining} {violation_label} " + "remaining before session termination\n" + ) + + def _assert_forbidden_path_output(self, output, forbidden_path, remaining): + """Assert exact forbidden-path output block with no extra lines.""" + expected = ( + f'lshell: forbidden path: "{forbidden_path}"\n' + f"{self._expected_warning_line(remaining)}" + ) + self.assertEqual(expected, self._normalize_output(output)) + + def _assert_exact_missing_path_error(self, output, path_literal): + """Assert exact single-line ls missing-path errors across GNU/BSD variants.""" + normalized = self._normalize_output(output) + expected_variants = { + f"ls: cannot access '{path_literal}': No such file or directory\n", + f"ls: {path_literal}: No such file or directory\n", + } + self.assertIn(normalized, expected_variants) + def setUp(self): """spawn lshell with pexpect and return the child""" self.child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --strict 1") @@ -41,65 +80,49 @@ def test_external_echo_forbidden_syntax(self): def test_external_forbidden_path(self): """F09 | external command forbidden path - ls /root""" - expected = ( - 'lshell: forbidden path: "/root/"\r\n' - "lshell: warning: 1 violation remaining before session termination\r\n" - ) + forbidden_root = self._normalized_path_for_message(os.path.expanduser("~root")) self.child.sendline("ls ~root") self.child.expect(PROMPT) result = self.child.before.decode("utf8").split("\n", 1)[1] - self.assertEqual(expected, result) + self._assert_forbidden_path_output(result, forbidden_root, remaining=1) def test_builtin_cd_forbidden_path(self): """F10 | built-in command forbidden path - cd ~root""" - expected = ( - 'lshell: forbidden path: "/root/"\r\n' - "lshell: warning: 1 violation remaining before session termination\r\n" - ) + forbidden_root = self._normalized_path_for_message(os.path.expanduser("~root")) self.child.sendline("cd ~root") self.child.expect(PROMPT) result = self.child.before.decode("utf8").split("\n", 1)[1] - self.assertEqual(expected, result) + self._assert_forbidden_path_output(result, forbidden_root, remaining=1) def test_etc_passwd_1(self): """F11 | /etc/passwd: empty variable 'ls "$a"/etc/passwd'""" - expected = ( - 'lshell: forbidden path: "/etc/passwd"\r\n' - "lshell: warning: 1 violation remaining before session termination\r\n" - ) + forbidden_path = self._normalized_path_for_message("/etc/passwd") self.child.sendline('ls "$a"/etc/passwd') self.child.expect(PROMPT) result = self.child.before.decode("utf8").split("\n", 1)[1] - self.assertEqual(expected, result) + self._assert_forbidden_path_output(result, forbidden_path, remaining=1) def test_etc_passwd_2(self): """F12 | /etc/passwd: empty variable 'ls -l .*./.*./etc/passwd'""" - expected = ( - "ls: cannot access '.*./.*./etc/passwd': No such file or directory\r\n" - ) self.child.sendline("ls -l .*./.*./etc/passwd") self.child.expect(PROMPT) result = self.child.before.decode("utf8").split("\n", 1)[1] - self.assertEqual(expected, result) + self._assert_exact_missing_path_error(result, ".*./.*./etc/passwd") def test_etc_passwd_3(self): """F13(a) | /etc/passwd: empty variable 'ls -l .?/.?/etc/passwd'""" - expected = "ls: cannot access '.?/.?/etc/passwd': No such file or directory\r\n" self.child.sendline("ls -l .?/.?/etc/passwd") self.child.expect(PROMPT) result = self.child.before.decode("utf8").split("\n", 1)[1] - self.assertEqual(expected, result) + self._assert_exact_missing_path_error(result, ".?/.?/etc/passwd") def test_etc_passwd_4(self): """F13(b) | /etc/passwd: empty variable 'ls -l ../../etc/passwd'""" - expected = ( - 'lshell: forbidden path: "/etc/passwd"\r\n' - "lshell: warning: 1 violation remaining before session termination\r\n" - ) + forbidden_path = self._normalized_path_for_message("/etc/passwd") self.child.sendline("ls -l ../../etc/passwd") self.child.expect(PROMPT) result = self.child.before.decode("utf8").split("\n", 1)[1] - self.assertEqual(expected, result) + self._assert_forbidden_path_output(result, forbidden_path, remaining=1) def test_allow_slash(self): """F21 | user should able to allow / access minus some directory @@ -110,13 +133,13 @@ def test_allow_slash(self): ) child.expect(PROMPT) - expected = 'lshell: forbidden path: "/var/"' + forbidden_var = self._normalized_path_for_message("/var") child.sendline("cd /") child.expect(f"{USER}:/\\$") child.sendline("cd var") child.expect(f"{USER}:/\\$") result = child.before.decode("utf8").split("\n")[1].strip() - self.assertEqual(expected, result) + self.assertEqual(f'lshell: forbidden path: "{forbidden_var}"', result) self.do_exit(child) def test_path_plus_minus_reallow_and_warning_messages(self): @@ -130,17 +153,24 @@ def test_path_plus_minus_reallow_and_warning_messages(self): child.sendline("cd /var") child.expect(PROMPT) - output_1 = child.before.decode("utf8") - self.assertIn('lshell: forbidden path: "/var/"', output_1) - self.assertIn("lshell: warning: 1 violation remaining", output_1) + output_1 = child.before.decode("utf8").split("\n", 1)[1] + self._assert_forbidden_path_output( + output_1, + self._normalized_path_for_message("/var"), + remaining=1, + ) child.sendline("cd /var/log") - child.expect(f"{USER}:/var/log\\$") + canonical_log_prompt_path = os.path.realpath("/var/log") + child.expect(f"{USER}:{canonical_log_prompt_path}\\$") child.sendline("cd /var/lib") - child.expect(f"{USER}:/var/log\\$") - output_2 = child.before.decode("utf8") - self.assertIn('lshell: forbidden path: "/var/lib/"', output_2) - self.assertIn("lshell: warning: 0 violations remaining", output_2) + child.expect(f"{USER}:{canonical_log_prompt_path}\\$") + output_2 = child.before.decode("utf8").split("\n", 1)[1] + self._assert_forbidden_path_output( + output_2, + self._normalized_path_for_message("/var/lib"), + remaining=0, + ) self.do_exit(child) diff --git a/test/test_runtime_executor_config_unit.py b/test/test_runtime_executor_config_unit.py new file mode 100644 index 0000000..00dd5ee --- /dev/null +++ b/test/test_runtime_executor_config_unit.py @@ -0,0 +1,62 @@ +"""Unit tests for runtime executor configuration and trusted bash resolver.""" + +import os +import unittest +from unittest.mock import patch + +from lshell.config.runtime import CheckConfig +from lshell import utils + +TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" + + +class TestRuntimeExecutorConfig(unittest.TestCase): + """Validate runtime_executor configuration semantics.""" + + args = [f"--config={CONFIG}", "--quiet=1"] + + def test_shellless_defaults_are_fail_closed(self): + """Default runtime config should stay shellless.""" + conf = CheckConfig(self.args).returnconf() + self.assertEqual(conf["runtime_executor"], "shellless") + + def test_runtime_executor_accepts_bash_compat(self): + """bash_compat should be accepted as an explicit mode.""" + conf = CheckConfig(self.args + ["--runtime_executor=bash_compat"]).returnconf() + self.assertEqual(conf["runtime_executor"], "bash_compat") + + def test_runtime_executor_rejects_unknown_value(self): + """runtime_executor must be one of the supported values.""" + with self.assertRaises(SystemExit): + CheckConfig(self.args + ["--runtime_executor=unknown_mode"]).returnconf() + + +class TestTrustedBashResolver(unittest.TestCase): + """Trusted bash resolver must only accept absolute executable candidates.""" + + def test_resolver_ignores_non_absolute_candidates(self): + """Relative bash candidate names must never be trusted.""" + with ( + patch("lshell.utils.os.path.isfile", return_value=True), + patch("lshell.utils.os.access", return_value=True), + ): + self.assertIsNone(utils.resolve_trusted_bash_path(("bash",))) + + def test_resolver_returns_first_executable_absolute_candidate(self): + """Resolver should pick the first matching absolute candidate.""" + with ( + patch( + "lshell.utils.os.path.isfile", + side_effect=lambda path: path == "/bin/bash", + ), + patch("lshell.utils.os.access", return_value=True), + ): + resolved = utils.resolve_trusted_bash_path( + ("bash", "/bin/bash", "/usr/bin/bash") + ) + self.assertEqual(resolved, "/bin/bash") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_security_attack_surface_unit.py b/test/test_security_attack_surface_unit.py index 169d0f0..3afea89 100644 --- a/test/test_security_attack_surface_unit.py +++ b/test/test_security_attack_surface_unit.py @@ -466,9 +466,9 @@ def __init__(self): self.args = ["sudo", "ls"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Simulate foreground process I/O completion.""" - return None + return self.returncode def poll(self): """Simulate an already-finished subprocess.""" @@ -500,9 +500,9 @@ def __init__(self): self.args = ["sudo", "ls"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Simulate foreground process I/O completion.""" - return None + return self.returncode def poll(self): """Simulate an already-finished subprocess.""" @@ -537,9 +537,9 @@ def __init__(self): self.args = ["su", "-"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Simulate foreground process I/O completion.""" - return None + return self.returncode def poll(self): """Simulate an already-finished subprocess.""" @@ -571,9 +571,9 @@ def __init__(self): self.args = ["su", "-"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Simulate foreground process I/O completion.""" - return None + return self.returncode def poll(self): """Simulate an already-finished subprocess.""" diff --git a/test/test_security_attack_surface_unit_part2.py b/test/test_security_attack_surface_unit_part2.py index 83d8765..fb9d017 100644 --- a/test/test_security_attack_surface_unit_part2.py +++ b/test/test_security_attack_surface_unit_part2.py @@ -90,7 +90,7 @@ def __init__(self): self.args = ["sudo", "ls"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Raise keyboard interrupt while waiting for process I/O.""" raise KeyboardInterrupt @@ -129,10 +129,10 @@ class FakeProc: def __init__(self): self.returncode = None self.pid = 5252 - self.args = ["bash", "-c", "sleep 60"] + self.args = ["sleep", "60"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Raise keyboard interrupt while waiting for process I/O.""" raise KeyboardInterrupt @@ -164,12 +164,12 @@ class FakeProc: def __init__(self): self.returncode = 0 self.pid = 3131 - self.args = ["bash", "-c", "echo ok"] + self.args = ["echo", "ok"] self.lshell_cmd = "" - def communicate(self): + def wait(self, timeout=None): # pylint: disable=unused-argument """Simulate a successful foreground command run.""" - return None + return self.returncode def poll(self): """Report completed process state.""" diff --git a/test/test_security_attack_surface_unit_part3.py b/test/test_security_attack_surface_unit_part3.py index 6da2f1a..dfa4ab8 100644 --- a/test/test_security_attack_surface_unit_part3.py +++ b/test/test_security_attack_surface_unit_part3.py @@ -1,6 +1,8 @@ """Additional attack-surface unit tests split from part2 for lint size limits.""" +import errno import io +import os import unittest from contextlib import redirect_stderr from unittest.mock import patch @@ -11,18 +13,16 @@ class TestAttackSurfacePart3(unittest.TestCase): """Focused execution-path hardening tests.""" - @patch("lshell.utils._resolve_trusted_shell", return_value="/bin/dash") @patch("lshell.utils.signal.getsignal", return_value=None) @patch("lshell.utils.signal.signal") @patch("lshell.utils.subprocess.Popen") - def test_exec_cmd_uses_resolved_trusted_shell_path( + def test_exec_cmd_runs_with_shell_false_argv_parsing( self, mock_popen, _mock_signal, _mock_getsignal, - _mock_shell_resolver, ): - """Foreground execution should use trusted absolute shell path.""" + """Foreground execution should pass parsed argv directly to subprocess.""" class FakeProc: """Minimal successful foreground process stub.""" @@ -30,44 +30,272 @@ class FakeProc: def __init__(self): self.returncode = 0 self.pid = 6161 - self.args = ["/bin/dash", "-c", "echo ok"] + self.args = ["echo", "ok"] self.lshell_cmd = "" - def communicate(self): - """Simulate a successful foreground command run.""" - return None - def poll(self): """Report completed process state.""" return self.returncode + def wait(self, timeout=None): # pylint: disable=unused-argument + """Simulate a successful foreground command run.""" + return self.returncode + mock_popen.return_value = FakeProc() ret = utils.exec_cmd("echo ok") self.assertEqual(ret, 0) popen_args = mock_popen.call_args.args[0] - self.assertEqual(popen_args, ["/bin/dash", "-c", "echo ok"]) + self.assertEqual(popen_args, ["echo", "ok"]) - @patch("lshell.utils._resolve_trusted_shell", return_value=None) @patch("lshell.utils.signal.getsignal", return_value=None) @patch("lshell.utils.signal.signal") @patch("lshell.utils.subprocess.Popen") - def test_exec_cmd_denies_execution_without_trusted_shell( + def test_exec_cmd_pipeline_wires_subprocess_stdio_without_shell( self, mock_popen, _mock_signal, _mock_getsignal, - _mock_shell_resolver, ): - """Fail closed when no trusted shell interpreter is available.""" + """Pipeline execution should connect stdout/stdin directly between stages.""" + + class FakePipe: + """Minimal file-like pipe placeholder used for mocked stdio wiring.""" + + def close(self): + """Match close() calls performed by exec_cmd.""" + return None + + class FakeProc: + """Minimal successful foreground subprocess fake.""" + + def __init__(self, pid, args, stdout=None): + self.returncode = 0 + self.pid = pid + self.args = args + self.stdout = stdout + self.lshell_cmd = "" + + def poll(self): + """Report completed process state.""" + return self.returncode + + def wait(self, timeout=None): # pylint: disable=unused-argument + """Simulate a successful foreground command run.""" + return self.returncode + + first_pipe = FakePipe() + popen_calls = [] + + def _popen_side_effect(args, **kwargs): + index = len(popen_calls) + if index == 0: + proc = FakeProc(7001, args, stdout=first_pipe) + else: + proc = FakeProc(7002, args, stdout=None) + popen_calls.append({"args": args, "kwargs": kwargs, "proc": proc}) + return proc + + mock_popen.side_effect = _popen_side_effect + + ret = utils.exec_cmd("printf foo | wc -c") + + self.assertEqual(ret, 0) + self.assertEqual(len(popen_calls), 2) + self.assertEqual(popen_calls[0]["args"], ["printf", "foo"]) + self.assertEqual(popen_calls[1]["args"], ["wc", "-c"]) + self.assertEqual(popen_calls[0]["kwargs"]["stdout"], utils.subprocess.PIPE) + self.assertIs(popen_calls[1]["kwargs"]["stdin"], first_pipe) + + @patch("lshell.utils.signal.getsignal", return_value=None) + @patch("lshell.utils.signal.signal") + @patch("lshell.utils.os.getpgid", return_value=7001) + @patch("lshell.utils.os.killpg") + @patch("lshell.utils.subprocess.Popen") + def test_exec_cmd_pipeline_spawn_failure_kills_and_reaps_started_members( + self, + mock_popen, + mock_killpg, + _mock_getpgid, + _mock_signal, + _mock_getsignal, + ): + """If a later stage fails to spawn, already started members must be killed/reaped.""" + + class FakePipe: + """Minimal pipe placeholder compatible with close() calls.""" + + def close(self): + """Match close() usage inside pipeline setup.""" + return None + + class RunningProc: + """Mock process that only exits after receiving a kill signal.""" + + def __init__(self): + self.pid = 7001 + self.args = ["sleep", "60"] + self.stdout = FakePipe() + self.returncode = None + self.killed = False + self.wait_called = False + + def poll(self): + """Report running until the test marks this process as killed.""" + return self.returncode + + def wait(self, timeout=None): # pylint: disable=unused-argument + """Only return once the process has been killed by cleanup logic.""" + self.wait_called = True + if not self.killed: + raise utils.subprocess.TimeoutExpired(self.args, timeout or 0) + return self.returncode + + first_stage = RunningProc() + + def _popen_side_effect(args, **kwargs): # pylint: disable=unused-argument + if args[0] == "sleep": + return first_stage + raise FileNotFoundError( + errno.ENOENT, + "No such file or directory", + "missing_stage", + ) + + def _killpg_side_effect(pgid, signum): + self.assertEqual(signum, utils.signal.SIGKILL) + self.assertEqual(pgid, first_stage.pid) + first_stage.killed = True + first_stage.returncode = -9 + + mock_popen.side_effect = _popen_side_effect + mock_killpg.side_effect = _killpg_side_effect + stderr = io.StringIO() with redirect_stderr(stderr): - ret = utils.exec_cmd("echo ok") + ret = utils.exec_cmd("sleep 60 | missing_stage") self.assertEqual(ret, 127) + self.assertIn('lshell: command not found: "missing_stage"', stderr.getvalue()) + self.assertTrue(first_stage.killed) + self.assertTrue(first_stage.wait_called) + + @patch("lshell.utils.signal.getsignal", return_value=None) + @patch("lshell.utils.signal.signal") + @patch("lshell.utils.subprocess.Popen") + def test_exec_cmd_rejects_command_substitution_without_shell_execution( + self, + mock_popen, + _mock_signal, + _mock_getsignal, + ): + """Fail closed when command relies on shell command substitution syntax.""" + stderr = io.StringIO() + with redirect_stderr(stderr): + ret = utils.exec_cmd("echo $(printf ok)") + + self.assertEqual(ret, 126) + self.assertIn( + "unsupported shell syntax in command execution: command substitution ($(...))", + stderr.getvalue(), + ) + mock_popen.assert_not_called() + + @patch("lshell.utils.signal.getsignal", return_value=None) + @patch("lshell.utils.signal.signal") + @patch("lshell.utils.subprocess.Popen") + def test_exec_cmd_rejects_redirection_syntax_without_shell_execution( + self, + mock_popen, + _mock_signal, + _mock_getsignal, + ): + """Fail closed when command relies on shell-only redirection syntax.""" + stderr = io.StringIO() + with redirect_stderr(stderr): + ret = utils.exec_cmd("echo ok > /tmp/lshell-redir") + + self.assertEqual(ret, 126) + self.assertIn( + "unsupported shell syntax in command execution: redirection operators", + stderr.getvalue(), + ) + mock_popen.assert_not_called() + + @patch("lshell.utils.signal.getsignal", return_value=None) + @patch("lshell.utils.signal.signal") + @patch("lshell.utils.resolve_trusted_bash_path", return_value="/bin/bash") + @patch("lshell.utils.subprocess.Popen") + def test_exec_cmd_bash_compat_uses_trusted_bash_and_scrubs_env( + self, + mock_popen, + _mock_resolve_bash, + _mock_signal, + _mock_getsignal, + ): + """bash_compat execution must use trusted bash path and hardened child env.""" + + class FakeProc: + """Minimal successful foreground process stub.""" + + def __init__(self): + self.returncode = 0 + self.pid = 8181 + self.args = ["/bin/bash", "-c", "echo ok"] + self.lshell_cmd = "" + + def poll(self): + """Report completed process state.""" + return self.returncode + + def wait(self, timeout=None): # pylint: disable=unused-argument + """Simulate a successful foreground command run.""" + return self.returncode + + mock_popen.return_value = FakeProc() + + with patch.dict( + os.environ, + { + "BASH_ENV": "/tmp/inject", + "ENV": "/tmp/inject", + "BASH_FUNC_echo%%": "() { id; }", + "LSHELL_SAFE_ENV": "ok", + }, + clear=True, + ): + ret = utils.exec_cmd("echo ok", conf={"runtime_executor": "bash_compat"}) + + self.assertEqual(ret, 0) + self.assertEqual( + mock_popen.call_args.args[0], ["/bin/bash", "-c", "echo ok"] + ) + child_env = mock_popen.call_args.kwargs["env"] + self.assertNotIn("BASH_ENV", child_env) + self.assertNotIn("ENV", child_env) + self.assertNotIn("BASH_FUNC_echo%%", child_env) + self.assertEqual(child_env.get("LSHELL_SAFE_ENV"), "ok") + + @patch("lshell.utils.signal.getsignal", return_value=None) + @patch("lshell.utils.signal.signal") + @patch("lshell.utils.resolve_trusted_bash_path", return_value=None) + @patch("lshell.utils.subprocess.Popen") + def test_exec_cmd_bash_compat_fails_closed_when_no_trusted_bash( + self, + mock_popen, + _mock_resolve_bash, + _mock_signal, + _mock_getsignal, + ): + """bash_compat should fail closed if no trusted absolute bash path is present.""" + stderr = io.StringIO() + with redirect_stderr(stderr): + ret = utils.exec_cmd("echo ok", conf={"runtime_executor": "bash_compat"}) + + self.assertEqual(ret, 126) self.assertIn( - "trusted system shell interpreter not found", + "runtime_executor=bash_compat requires a trusted absolute bash path", stderr.getvalue(), ) mock_popen.assert_not_called() diff --git a/test/test_security_hardening_functional.py b/test/test_security_hardening_functional.py index 92bc673..696afac 100644 --- a/test/test_security_hardening_functional.py +++ b/test/test_security_hardening_functional.py @@ -159,6 +159,29 @@ def test_bash_env_persistence_should_not_inject_into_future_commands(self): if os.path.exists(bash_env): os.remove(bash_env) + def test_bash_compat_scrubs_bash_env_injection(self): + """bash_compat mode should still scrub BASH_ENV/ENV function-import attack vectors.""" + with tempfile.NamedTemporaryFile( + "w", delete=False, prefix="lshell-bashenv-compat-" + ) as handle: + handle.write("echo BASH_COMPAT_ENV_INJECTION\n") + bash_env = handle.name + + try: + result = self._run_lsh_script( + script_body=f"BASH_ENV={bash_env}\nENV={bash_env}\necho SAFE_COMPAT\n", + extra_shell_args=( + "--forbidden \"[]\" " + "--runtime_executor bash_compat " + ), + ) + self.assertEqual(result.returncode, 0) + self.assertIn("SAFE_COMPAT", result.stdout) + self.assertNotIn("BASH_COMPAT_ENV_INJECTION", result.stdout) + finally: + if os.path.exists(bash_env): + os.remove(bash_env) + def test_path_acl_glob_checks_all_matches_and_blocks_forbidden_target(self): """Glob path checks must fail closed when any expanded item is forbidden.""" with tempfile.TemporaryDirectory(prefix="lshell-path-hardening-") as tmpdir: diff --git a/test/test_signals.py b/test/test_signals.py index a9b3350..39d0b54 100644 --- a/test/test_signals.py +++ b/test/test_signals.py @@ -3,6 +3,7 @@ import os import unittest import time +import signal from getpass import getuser import pexpect @@ -30,6 +31,44 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) + def _suspend_and_assert_stopped(self, child, stopped_pattern): + """Suspend foreground job; handle prompt-first redraw races in CI PTYs.""" + self._suspend_with_signal(child) + try: + match = child.expect([stopped_pattern, PROMPT], timeout=5) + if match == 0: + self._expect_prompt(child, timeout=5) + return + + # Some runner/readline combinations redraw the prompt before printing + # the stop notification. Validate stopped state through `jobs`. + child.sendline("jobs") + self._expect_prompt(child, timeout=5) + self.assertRegex(child.before.decode("utf-8"), stopped_pattern) + except pexpect.TIMEOUT: + # Another runner variant requires an extra newline to trigger + # prompt redraw after terminal signals. + self._expect_prompt(child, timeout=5) + child.sendline("jobs") + self._expect_prompt(child, timeout=5) + self.assertRegex(child.before.decode("utf-8"), stopped_pattern) + + def _expect_prompt(self, child, timeout=5): + """Expect prompt; send newline once if readline did not redraw yet.""" + try: + child.expect(PROMPT, timeout=timeout) + except pexpect.TIMEOUT: + child.sendline("") + child.expect(PROMPT, timeout=timeout) + + def _suspend_with_signal(self, child): + """Trigger SIGTSTP directly to avoid PTY Ctrl+Z portability issues.""" + os.kill(child.pid, signal.SIGTSTP) + + def _interrupt_with_signal(self, child): + """Trigger SIGINT directly to avoid PTY Ctrl+C portability issues.""" + os.kill(child.pid, signal.SIGINT) + def test_keyboard_interrupt(self): """F25 | test cat(1) with KeyboardInterrupt, should not exit""" child = pexpect.spawn( @@ -388,3 +427,34 @@ def test_background_timeout_removes_job_from_jobs_listing(self): child.expect(PROMPT) self.assertIn("TIMEOUT_CLEANUP_OK", child.before.decode("utf-8")) self.do_exit(child) + + def test_fg_non_detached_sudo_ctrl_z_keeps_shell_responsive(self): + """`fg` on sudo jobs must not stop the shell process group itself.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --forbidden \"[]\" " + "--allowed \"+['sudo','echo']\" --sudo_commands \"['sleep']\"" + ) + child.expect(PROMPT) + + child.sendline("sudo sleep 60") + time.sleep(1) + stopped_pattern = r"\[\d+\]\+ Stopped sudo sleep 60" + self._suspend_and_assert_stopped(child, stopped_pattern) + + child.sendline("fg") + child.expect("sudo sleep 60", timeout=5) + time.sleep(1) + self._suspend_and_assert_stopped(child, stopped_pattern) + + child.sendline("echo FG_SUDO_SIGNAL_BOUNDARY_OK") + child.expect(PROMPT, timeout=5) + self.assertIn( + "FG_SUDO_SIGNAL_BOUNDARY_OK", + child.before.decode("utf-8"), + ) + + child.sendline("fg") + child.expect("sudo sleep 60", timeout=5) + self._interrupt_with_signal(child) + self._expect_prompt(child, timeout=5) + self.do_exit(child) diff --git a/test/test_unit.py b/test/test_unit.py index 1a0c6d9..9576135 100644 --- a/test/test_unit.py +++ b/test/test_unit.py @@ -473,16 +473,17 @@ def test_auto_ls_alias_expands_during_local_execution(self): else: os.environ[key] = value - def test_policy_commands_enabled_by_default(self): - """U45 | policy commands should be available by default.""" + def test_lshow_is_available_by_default(self): + """U45 | lshow should always be available by default.""" userconf = CheckConfig(self.args).returnconf() self.assertIn("lshow", userconf["allowed"]) - def test_policy_commands_can_be_hidden(self): - """U46 | policy commands can be hidden via --policy_commands=0.""" + def test_removed_policy_commands_cli_option_is_rejected(self): + """U46 | removed --policy_commands CLI option should be rejected.""" args = self.args + ["--policy_commands=0"] - userconf = CheckConfig(args).returnconf() - self.assertNotIn("lshow", userconf["allowed"]) + with self.assertRaises(SystemExit) as exc: + CheckConfig(args).returnconf() + self.assertEqual(exc.exception.code, 1) def test_history_file_accepts_string_and_expands_home(self): """U48 | --history_file should parse as string and resolve under home path.""" @@ -510,3 +511,42 @@ def test_incompatible_noexec_library_is_disabled(self, _mock_usable): args = self.args + [f"--path_noexec='{fake_lib.name}'"] userconf = CheckConfig(args).returnconf() self.assertNotIn("path_noexec", userconf) + + @patch("lshell.config.runtime.subprocess.run") + @patch("lshell.config.runtime.os.access") + @patch("lshell.config.runtime.os.path.isfile") + def test_noexec_probe_uses_absolute_true_binary_without_shell( + self, + mock_isfile, + mock_access, + mock_run, + ): + """U51 | noexec probe should execute trusted `true` directly, not via shell.""" + mock_isfile.side_effect = lambda path: path == "/usr/bin/true" + mock_access.return_value = True + mock_run.return_value.returncode = 0 + + checker = object.__new__(CheckConfig) + result = checker.noexec_library_usable("/tmp/fake_noexec.so") + + self.assertTrue(result) + self.assertEqual(mock_run.call_args.args[0], ["/usr/bin/true"]) + child_env = mock_run.call_args.kwargs["env"] + self.assertEqual(child_env.get("LD_PRELOAD"), "/tmp/fake_noexec.so") + self.assertNotIn("BASH_ENV", child_env) + self.assertNotIn("ENV", child_env) + + @patch("lshell.config.runtime.subprocess.run") + @patch("lshell.config.runtime.os.access", return_value=False) + @patch("lshell.config.runtime.os.path.isfile", return_value=False) + def test_noexec_probe_fails_closed_when_no_trusted_true_binary( + self, + _mock_isfile, + _mock_access, + mock_run, + ): + """U52 | noexec probe should fail closed when no trusted probe binary exists.""" + checker = object.__new__(CheckConfig) + result = checker.noexec_library_usable("/tmp/fake_noexec.so") + self.assertFalse(result) + mock_run.assert_not_called()