diff --git a/.github/workflows/lshell-tests.yml b/.github/workflows/lshell-tests.yml index ec4bc59..3a7e014 100644 --- a/.github/workflows/lshell-tests.yml +++ b/.github/workflows/lshell-tests.yml @@ -14,13 +14,17 @@ jobs: pytest: name: Pytest Unit/Integration Tests runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.14"] steps: - uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: ${{ matrix.python-version }} - name: Set up Python path run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - name: Install dependencies @@ -38,8 +42,9 @@ jobs: name: Lint + Flake8 runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.10", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fb024..651fb04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) [https://github.com/ghantoos/lshell](https://github.com/ghantoos/lshell) +### v0.12.0 (UNRELEASED) +- Packaging/CI: Raised minimum supported Python version to 3.10 (`requires-python >=3.10`), removed EOL Python versions from CI, and aligned docs/package metadata with the new baseline; CI/classifiers now track active CPython release branches 3.10-3.14 (Python 3.6 reached EOL on 23/12/2021). +- Security: Removed regex-driven shell parsing from the authorization flow. +- Engine: Migrated security parsing to a deterministic scanner. +- 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. +- 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. +- Tests: Added runtime-vs-diagnostics parity coverage for precedence, include overlays, `allowed=all` minus operations, and schema error behavior. + ### v0.11.1 21/03/2026 - Feature: Added `lshell setup-system` to provision logging paths/permissions and user/group wiring for deployments. - Feature: Added `lshell harden-init` with hardened templates (`sftp-only`, `rsync-backup`, `deploy-minimal`, `readonly-support`) plus `--dry-run`, scoped `[grp:*]`/`[user:*]`, and validation checks. @@ -19,7 +31,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) ### v0.11.0 10/03/2026 - Reworked command parsing with a new `pyparsing`-based parser for more reliable command handling. -- Added policy diagnostics and built-ins: `policy-show`, `policy-path`, and `policy-sudo`. +- Added policy diagnostics commands: `policy-show` (CLI) and `lshow` (in-shell). - Added customizable user-facing messages via the `messages` configuration section. - Added session `umask` configuration support from `lshell.conf` and CLI overrides. - Improved `sudo` behavior and command execution handling. @@ -143,8 +155,8 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) ### v0.9.14 27/10/2010 - Corrected `get_aliases` function, as it was looping when aliases were "recursive" (e.g. `ls:ls --color=auto`) -- Added `lsudo` built-in command to list allowed sudo commands. -- Corrected completion function when 2 strings collided (e.g. `ls` and `lsudo`) +- Added diagnostics sudo-policy visibility for allowed sudo commands. +- Corrected completion function when 2 strings collided (for example `ls` with diagnostics commands) - Corrected the README's installation part (adding `--prefix`). - Added possibility to log via syslog. - Corrected warning counter (was counting minus 1). @@ -179,7 +191,7 @@ Contact: [ghantoos@ghantoos.org](mailto:ghantoos@ghantoos.org) - Added the possibility to configure introduction prompt. - Replaced "joker" by "warnings" (more elegant) - Possibility of limiting the history file size. -- Added `lpath` built-in command to list allowed and denied path. Thanks to Adrien Urban. +- Added diagnostics path-policy visibility for allowed and denied paths. Thanks to Adrien Urban. - Corrected bug when using `~` was not parsed as "home directory" when used in a command other than `cd`. Thank you Adrien Urban for finding this. - Corrected minor typo when warning for a forbidden path. - If `$(foo)` is present in the line, check if `foo` is allowed before executing the line. Thank you Adrien Urban for pointing this out! diff --git a/README.md b/README.md index 59b64bc..fb13713 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ PyPI project page: https://pypi.org/project/limited-shell/ ## Installation +Supported Python versions: `3.10`, `3.11`, `3.12`, `3.13`, `3.14` (minimum `3.10`). + Install from PyPI: ```bash @@ -95,9 +97,10 @@ lshell policy-show \ Inside an interactive session: -- `policy-show []` -- `policy-path` (`lpath` alias) -- `policy-sudo` (`lsudo` alias) +- `lshow []` + +`lshow` includes effective command policy, allowed/denied paths, and sudo +policy in one output. Hide these built-ins if needed: @@ -296,6 +299,7 @@ just test-fuzz-security-parser 20000 Optional local run (if you want to fuzz outside Docker): ```bash +python3 --version # should be >= 3.10 pip install -r requirements-fuzz.txt python3 fuzz/fuzz_parser_policy.py -runs=20000 ``` diff --git a/debian/control b/debian/control index 3458301..f9bb906 100644 --- a/debian/control +++ b/debian/control @@ -2,8 +2,8 @@ Source: lshell Section: shells Priority: optional Maintainer: Ignace Mouzannar -Build-Depends: debhelper-compat (= 13), python3 (>= 3.4), pybuild-plugin-pyproject -X-Python3-Version: >= 3.4 +Build-Depends: debhelper-compat (= 13), python3 (>= 3.10), pybuild-plugin-pyproject +X-Python3-Version: >= 3.10 Standards-Version: 3.9.7 Homepage: https://github.com/ghantoos/lshell diff --git a/debian/lshell.deb-test.conf b/debian/lshell.deb-test.conf index 5125f41..fef1535 100644 --- a/debian/lshell.deb-test.conf +++ b/debian/lshell.deb-test.conf @@ -13,7 +13,7 @@ allowed_file_extensions : ['.conf', '.log', '.txt'] aliases : {'ll': 'ls -l', 'la': 'ls -la'} env_vars : {'LSHELL_LAYER': 'default', 'LSHELL_ENV': 'deb-test'} prompt_short : 1 -intro : "\033[1;95mDEB Test Profile: layered default/group/user policy\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\ndate\nll\ncat /home/testuser/lshell/test/testfiles/test.conf\npolicy-show\npolicy-show cat /home/testuser/lshell/test/testfiles/test.conf\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\ncat /etc/passwd\ncat /tmp/test.log\n" +intro : "\033[1;95mDEB Test Profile: layered default/group/user policy\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\ndate\nll\ncat /home/testuser/lshell/test/testfiles/test.conf\nlshow\nlshow cat /home/testuser/lshell/test/testfiles/test.conf\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\ncat /etc/passwd\ncat /tmp/test.log\n" messages : { 'unknown_syntax': 'deb-test: unknown syntax -> {command}', 'forbidden_command': 'deb-test: command blocked -> "{command}"', @@ -35,7 +35,7 @@ env_vars : {'LSHELL_LAYER': 'group'} [testuser] # User layer applies on top of default + group for testuser. -allowed : + ['cat', 'head', 'tail', 'policy-show'] - ['echo'] +allowed : + ['cat', 'head', 'tail', 'lshow'] - ['echo'] path : + ['/home/testuser/lshell/test/testfiles'] - ['/home'] overssh : + ['ls'] - ['rsync'] allowed_file_extensions : + ['.yaml'] - ['.txt'] diff --git a/docs/engine-migration.md b/docs/engine-migration.md new file mode 100644 index 0000000..35bc6ac --- /dev/null +++ b/docs/engine-migration.md @@ -0,0 +1,59 @@ +# Canonical Engine Architecture + +## Overview + +Lshell now uses a single canonical command engine for both runtime execution +and policy diagnostics. There is no legacy engine toggle or fallback path. + +## Pipeline + +The shared flow is: + +1. `parse(line)` -> `lshell.engine.ast.ParsedAST` +2. `normalize(parsed_ast)` -> `lshell.engine.ast.CanonicalAST` +3. `authorize(canonical_ast, policy)` -> structured allow/deny decision +4. `execute(decisions, runtime)` -> retcode + audit outcome + +Runtime entrypoint: + +- `lshell.utils.cmd_parse_execute` -> `lshell.engine.executor.execute_for_shell` + +Policy diagnostics entrypoint: + +- `lshell.config.diagnostics.policy_command_decision` -> canonical authorizer path + +## Engine Modules + +- `lshell/engine/ast.py`: canonical AST structures. +- `lshell/engine/parser.py`: top-level command/operator sequence parser. +- `lshell/engine/normalizer.py`: canonical command normalization and assignment splitting. +- `lshell/engine/authorizer.py`: centralized command/path/security authorization. +- `lshell/engine/reasons.py`: structured reason codes and user/audit mappings. +- `lshell/engine/executor.py`: runtime execution with strict-mode and audit semantics. + +## Reason Codes + +Primary reason codes are defined in `lshell.engine.reasons`, including: + +- `allowed` +- `unknown_syntax` +- `forbidden_control_char` +- `forbidden_character` +- `forbidden_path` +- `forbidden_command` +- `forbidden_sudo_command` +- `forbidden_file_extension` +- `forbidden_env_assignment` +- `forbidden_trusted_protocol` +- `command_not_found` + +Mappings: + +- `to_policy_message(reason)` for policy-show text. +- `to_audit_reason(reason)` for runtime audit logging. +- `warning_payload(reason)` for strict/warning-counter compatibility hooks. + +## Validation + +- Unit tests: parser/normalizer/authorizer behavior. +- Security regressions: parser smuggling, substitution checks, and path ACL edge cases. diff --git a/etc/lshell.conf b/etc/lshell.conf index 765fe58..bbdb844 100644 --- a/etc/lshell.conf +++ b/etc/lshell.conf @@ -223,6 +223,6 @@ strict : 0 #disable_exit : 0 ## show/hide policy introspection builtins: -## policy-show, policy-path, policy-sudo (+ aliases lpath, lsudo) +## lshow ## set to 0 to hide these commands from users #policy_commands : 0 diff --git a/fuzz/fuzz_parser_policy.py b/fuzz/fuzz_parser_policy.py index 7a31150..7aa4564 100644 --- a/fuzz/fuzz_parser_policy.py +++ b/fuzz/fuzz_parser_policy.py @@ -12,8 +12,9 @@ ) from exc with atheris.instrument_imports(): - from lshell import parser as lshell_parser - from lshell import policy + from lshell.engine import normalizer as engine_normalizer + from lshell.engine import parser as engine_parser + from lshell.config import diagnostics as policy from lshell import sec from lshell import utils @@ -39,8 +40,6 @@ def info(self, _message): _FUZZ_TMP = tempfile.mkdtemp(prefix="lshell-fuzz-") -_FUZZ_PARSER = lshell_parser.LshellParser() - def _base_conf(): """Build an isolated, permissive config for parser/policy fuzz entrypoints.""" @@ -80,9 +79,8 @@ def _fuzz_one_line(line): "path": conf["path"], } try: - parsed = _FUZZ_PARSER.parse(line) - if parsed is not None: - _FUZZ_PARSER.validate_command(parsed) + parsed = engine_parser.parse(line) + engine_normalizer.normalize(parsed) utils.split_command_sequence(line) utils.split_commands(line) diff --git a/lshell/builtincmd.py b/lshell/builtincmd.py index 013df05..2d5910a 100644 --- a/lshell/builtincmd.py +++ b/lshell/builtincmd.py @@ -3,7 +3,6 @@ import glob import sys import os -import re import shlex import readline import signal @@ -11,17 +10,14 @@ # import lshell specifics from lshell import variables from lshell import utils +from lshell import sec as sec_policy # Store background jobs BACKGROUND_JOBS = [] POLICY_COMMANDS = [ - "policy-show", - "policy-path", - "policy-sudo", - "lpath", - "lsudo", + "lshow", ] builtins_list = [ @@ -31,11 +27,7 @@ "exit", "export", "history", - "policy-show", - "policy-path", - "policy-sudo", - "lpath", - "lsudo", + "lshow", "help", "fg", "bg", @@ -54,12 +46,15 @@ def _cancel_job_timeout(job): def cmd_lpath(conf): """Show path policy in a concise, readable format.""" current_dir = os.path.realpath(os.getcwd()) - current_match = current_dir if current_dir.endswith("/") else f"{current_dir}/" - allowed_re = str(conf["path"][0]) - denied_re = str(conf["path"][1][:-1]) - current_allowed = bool(re.findall(allowed_re, current_match)) - current_denied = bool(re.findall(denied_re, current_match)) if denied_re else False - current_status = "allowed" if current_allowed and not current_denied else "denied" + path_acl = conf.get("path", ["", ""]) + allow = path_acl[0] if len(path_acl) > 0 else "" + deny = path_acl[1] if len(path_acl) > 1 else "" + allowed_roots = sec_policy._split_path_acl_entries(allow) + denied_roots = sec_policy._split_path_acl_entries(deny) + current_allowed = sec_policy._is_path_allowed( + current_dir, allowed_roots, denied_roots + ) + current_status = "allowed" if current_allowed else "denied" sys.stdout.write("Path Policy\n") sys.stdout.write("-----------\n") diff --git a/lshell/cli.py b/lshell/cli.py index f2efabf..2e843cd 100644 --- a/lshell/cli.py +++ b/lshell/cli.py @@ -6,12 +6,12 @@ import sys import uuid -from lshell import policy as policy_mode +from lshell.config import diagnostics as policy_mode from lshell import systemsetup as system_setup from lshell import hardeninit as harden_init from lshell import audit from lshell import containment -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.shellcmd import LshellTimeOut, ShellCmd diff --git a/lshell/config/__init__.py b/lshell/config/__init__.py new file mode 100644 index 0000000..009bca2 --- /dev/null +++ b/lshell/config/__init__.py @@ -0,0 +1,10 @@ +"""Configuration subsystem for runtime loading, resolution, and diagnostics. + +Modules: +- schema: parse and validate configuration values. +- resolve: compute effective raw values from layered config sections. +- runtime: build and apply the effective runtime shell configuration. +- diagnostics: inspect/print effective policy resolution for policy-show. +""" + +__all__ = ["schema", "resolve", "runtime", "diagnostics"] diff --git a/lshell/policy.py b/lshell/config/diagnostics.py similarity index 62% rename from lshell/policy.py rename to lshell/config/diagnostics.py index 00b39ad..dedb68f 100644 --- a/lshell/policy.py +++ b/lshell/config/diagnostics.py @@ -1,4 +1,12 @@ -"""Diagnostics mode for policy resolution and command decisions.""" +"""Resolve and inspect effective lshell configuration for diagnostics. + +This module powers ``policy-show`` style diagnostics: +- resolve effective config using the same resolver as runtime +- preserve a trace of section/key transformations for explainability +- render user-facing and machine-facing diagnostics output + +Unlike runtime loading, diagnostics raises ``ValueError`` on invalid config. +""" import argparse import configparser @@ -7,28 +15,16 @@ import json import os import pwd -import re import sys import textwrap from getpass import getuser from lshell import builtincmd from lshell import containment -from lshell import configschema -from lshell import sec -from lshell import utils +from lshell.config import schema +from lshell.config import resolve from lshell import variables - -MERGE_LIST_KEYS = { - "path", - "overssh", - "allowed", - "allowed_shell_escape", - "allowed_file_extensions", - "forbidden", -} - DISPLAY_KEY_ORDER = [ "allowed", "allowed_shell_escape", @@ -63,74 +59,7 @@ def _safe_eval(value, key=""): """Safely parse config values with shared schema validation.""" - return configschema.parse_config_value(value, key) - - -def _expand_all(): - """Expand 'all' into executable names from PATH plus shell builtins.""" - expanded_all = [ - "bg", - "break", - "case", - "cd", - "continue", - "eval", - "exec", - "exit", - "fg", - "if", - "jobs", - "kill", - "login", - "logout", - "set", - "shift", - "stop", - "suspend", - "umask", - "unset", - "wait", - "while", - ] - - for directory in os.environ.get("PATH", "").split(":"): - if not directory: - continue - if os.path.exists(directory): - for item in os.listdir(directory): - if os.access(os.path.join(directory, item), os.X_OK): - expanded_all.append(item) - - return str(expanded_all) - - -def _minusplus(conf_raw, key, extra): - """Update configuration lists containing -/+ operators.""" - if key in conf_raw: - current = _safe_eval(conf_raw[key], key) - elif key == "path": - current = ["", ""] - else: - current = [] - - sublist = _safe_eval(extra[1:], key) - if extra.startswith("+"): - if key == "path": - for path in sublist: - current[0] += os.path.realpath(path) + "/|" - else: - for item in sublist: - current.append(item) - elif extra.startswith("-"): - if key == "path": - for path in sublist: - current[1] += os.path.realpath(path) + "/|" - else: - for item in sublist: - if item in current: - current.remove(item) - - return {key: str(current)} + return schema.parse_config_value(value, key) def _read_config_with_sources(configfile, include_dir): @@ -153,134 +82,9 @@ def _read_config_with_sources(configfile, include_dir): return parser, include_files, key_sources -def _merge_section(conf_raw, section, section_items, key_sources, trace): - """Apply a single section to conf_raw while recording trace details.""" - for key, value in section_items: - source = key_sources.get((section, key)) - split = [""] - if isinstance(value, str): - split = re.split(r"((?:\+|-)\s*\[[^\]]+\])", value) - - previous = conf_raw.get(key) - - if len(split) > 1 and key in MERGE_LIST_KEYS: - for token in split: - if not token.strip(): - continue - if token.startswith("-") or token.startswith("+"): - conf_raw.update(_minusplus(conf_raw, key, token)) - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": token[0], - "token": token[1:], - "before": previous, - "after": conf_raw.get(key), - } - ) - previous = conf_raw.get(key) - elif configschema.is_all_literal(token): - if key == "allowed_shell_escape": - raise ValueError( - "'allowed_shell_escape' cannot be set to 'all'" - ) - if key == "allowed": - conf_raw.update({key: _expand_all()}) - else: - raise ValueError(f"'{key}' cannot be set to 'all'") - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": "set_all", - "token": token, - "before": previous, - "after": conf_raw.get(key), - } - ) - previous = conf_raw.get(key) - elif key == "path": - allow_deny = ["", ""] - for path_pattern in _safe_eval(token, key): - for item in glob.glob(path_pattern): - allow_deny[0] += os.path.realpath(item) + "/|" - allow_deny[0] = allow_deny[0].replace("//", "/") - conf_raw.update({key: str(allow_deny)}) - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": "set", - "token": token, - "before": previous, - "after": conf_raw.get(key), - } - ) - previous = conf_raw.get(key) - elif isinstance(_safe_eval(token, key), list): - conf_raw.update({key: token}) - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": "set", - "token": token, - "before": previous, - "after": conf_raw.get(key), - } - ) - previous = conf_raw.get(key) - elif key == "allowed" and configschema.is_all_literal(split[0]): - conf_raw.update({key: _expand_all()}) - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": "set_all", - "token": split[0], - "before": previous, - "after": conf_raw.get(key), - } - ) - elif key == "allowed_shell_escape" and configschema.is_all_literal(split[0]): - raise ValueError("'allowed_shell_escape' cannot be set to 'all'") - elif key == "path": - allow_deny = ["", ""] - for path_pattern in _safe_eval(value, "path"): - for item in glob.glob(path_pattern): - allow_deny[0] += os.path.realpath(item) + "/|" - allow_deny[0] = allow_deny[0].replace("//", "/") - conf_raw.update({key: str(allow_deny)}) - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": "set", - "token": value, - "before": previous, - "after": conf_raw.get(key), - } - ) - else: - conf_raw[key] = value - trace.append( - { - "section": section, - "source": source, - "key": key, - "op": "set", - "token": value, - "before": previous, - "after": conf_raw.get(key), - } - ) +def _merge_error(message): + """Raise merge-time errors in diagnostics mode.""" + raise ValueError(message) def _build_runtime_policy(conf_raw, username): @@ -358,7 +162,7 @@ def _build_runtime_policy(conf_raw, username): if os.access(cmd, os.X_OK): policy["allowed"].append(item) - if "sudo_commands" in conf_raw and configschema.is_all_literal( + if "sudo_commands" in conf_raw and schema.is_all_literal( str(conf_raw["sudo_commands"]) ): exclude = [cmd for cmd in builtincmd.builtins_list if cmd != "ls"] + ["sudo"] @@ -412,12 +216,17 @@ def resolve_policy(configfile, username, groups): for section in precedence_chain: if parser.has_section(section): applied_sections.append(section) - _merge_section( - conf_raw, - section, - list(parser.items(section)), - key_sources, - trace, + resolve.merge_section( + conf_raw=conf_raw, + section=section, + section_items=list(parser.items(section)), + parse_value=_safe_eval, + expand_all_value=lambda: resolve.expand_all( + os.environ.get("PATH", "") + ), + on_error=_merge_error, + trace=trace, + key_sources=key_sources, ) for required_key in variables.required_config: @@ -440,67 +249,23 @@ def resolve_policy(configfile, username, groups): def policy_command_decision(command_line, policy): """Determine whether a command would be allowed and why.""" - if re.findall(r"[\x01-\x1F\x7F]", command_line): - return {"allowed": False, "reason": "forbidden control character"} - - for item in policy["forbidden"]: - if item in ["&", "|"]: - escaped = re.escape(item) - if re.search(rf"(? {value}" for key, value in sorted(aliases.items())] - sudo_commands = sorted(set(policy.get("sudo_commands", [])), key=str) timer_value = policy.get("timer") forbidden = sorted(set(policy.get("forbidden", [])), key=str) extensions = policy.get("allowed_file_extensions", []) + def _limit_or_unlimited(value, suffix=""): return f"{value}{suffix}" if int(value) > 0 else "Unlimited" @@ -734,8 +499,6 @@ def _limit_or_unlimited(value, suffix=""): print("-" * 14) print("Allowed commands : ", end="") print(_format_wrapped_list(allowed_entries, indent=24)) - print("Allowed sudo : ", end="") - print(_format_wrapped_list(sudo_commands, indent=24)) print("Aliases : ", end="") print(_format_wrapped_list(alias_entries, indent=24)) print(f"Timer : {timer_value}") @@ -754,6 +517,30 @@ def _limit_or_unlimited(value, suffix=""): print("Allowed extensions : " + allowed_extensions) print("") + path_acl = policy.get("path", ["", ""]) + allowed_paths_raw = path_acl[0] if len(path_acl) > 0 else "" + denied_paths_raw = path_acl[1] if len(path_acl) > 1 else "" + allowed_paths = sorted(path for path in allowed_paths_raw.split("|") if path) + denied_paths = sorted(path for path in denied_paths_raw.split("|") if path) + + print("Allowed paths") + print("-------------") + if allowed_paths: + for path in allowed_paths: + print(path) + else: + print("none") + + if denied_paths: + print("") + print("Denied paths") + print("------------") + for path in denied_paths: + print(path) + print("") + builtincmd.cmd_lsudo(policy) + print("") + if command_line is not None and decision is not None: verdict = "ALLOW" if decision["allowed"] else "DENY" verdict_style = "green" if decision["allowed"] else "red" diff --git a/lshell/config/resolve.py b/lshell/config/resolve.py new file mode 100644 index 0000000..b47bd30 --- /dev/null +++ b/lshell/config/resolve.py @@ -0,0 +1,215 @@ +"""Resolve final raw configuration values from layered config sections. + +This module is the canonical implementation shared by runtime and diagnostics. +It handles: +- ``+/-`` list operations +- ``all`` expansion for allow-lists +- path glob expansion into allow/deny path entries +- per-section application with optional source/trace recording + +The functions are callback-driven so callers control parsing, errors, and logs. +""" + +import glob +import os +import re + +from lshell.config import schema + + +MERGE_LIST_KEYS = { + "path", + "overssh", + "allowed", + "allowed_shell_escape", + "allowed_file_extensions", + "forbidden", +} + +_MERGE_TOKEN_SPLIT = re.compile(r"((?:\+|-)\s*\[[^\]]+\])") + +_SHELL_BUILTINS_FOR_ALL = [ + "bg", + "break", + "case", + "cd", + "continue", + "eval", + "exec", + "exit", + "fg", + "if", + "jobs", + "kill", + "login", + "logout", + "set", + "shift", + "stop", + "suspend", + "umask", + "unset", + "wait", + "while", +] + + +def expand_all(path_env, on_missing_path=None): + """Expand 'all' into shell builtins and executable names from PATH.""" + expanded_all = list(_SHELL_BUILTINS_FOR_ALL) + for directory in path_env.split(":"): + if not directory: + continue + if os.path.exists(directory): + for item in os.listdir(directory): + if os.access(os.path.join(directory, item), os.X_OK): + expanded_all.append(item) + elif on_missing_path: + on_missing_path(directory) + return str(expanded_all) + + +def minusplus( + conf_raw, + key, + extra, + parse_value, + on_missing_remove=None, +): + """Apply +/- list merge operation for a single token.""" + if key in conf_raw: + current = parse_value(conf_raw[key], key) + elif key == "path": + current = ["", ""] + else: + current = [] + + sublist = parse_value(extra[1:], key) + if extra.startswith("+"): + if key == "path": + for path in sublist: + current[0] += os.path.realpath(path) + "/|" + else: + for item in sublist: + current.append(item) + elif extra.startswith("-"): + if key == "path": + for path in sublist: + current[1] += os.path.realpath(path) + "/|" + else: + for item in sublist: + if item in current: + current.remove(item) + elif on_missing_remove: + on_missing_remove(key, item) + + return {key: str(current)} + + +def merge_section( + conf_raw, + section, + section_items, + parse_value, + expand_all_value, + on_error, + trace=None, + key_sources=None, + on_missing_remove=None, +): + """Merge one section into conf_raw with optional trace recording.""" + for key, value in section_items: + source = None + if key_sources is not None: + source = key_sources.get((section, key)) + + split = [""] + if isinstance(value, str): + split = _MERGE_TOKEN_SPLIT.split(value) + + previous = conf_raw.get(key) + + def _trace_event(op, token): + if trace is None: + return + trace.append( + { + "section": section, + "source": source, + "key": key, + "op": op, + "token": token, + "before": previous, + "after": conf_raw.get(key), + } + ) + + if len(split) > 1 and key in MERGE_LIST_KEYS: + for token in split: + if not token.strip(): + continue + + if token.startswith("-") or token.startswith("+"): + conf_raw.update( + minusplus( + conf_raw, + key, + token, + parse_value, + on_missing_remove=on_missing_remove, + ) + ) + _trace_event(token[0], token[1:]) + previous = conf_raw.get(key) + continue + + if schema.is_all_literal(token): + if key == "allowed": + conf_raw.update({key: expand_all_value()}) + _trace_event("set_all", token) + previous = conf_raw.get(key) + elif key == "allowed_shell_escape": + on_error("'allowed_shell_escape' cannot be set to 'all'") + else: + on_error(f"'{key}' cannot be set to 'all'") + continue + + if key == "path": + allow_deny = ["", ""] + for path_pattern in parse_value(token, key): + for item in glob.glob(path_pattern): + allow_deny[0] += os.path.realpath(item) + "/|" + allow_deny[0] = allow_deny[0].replace("//", "/") + conf_raw.update({key: str(allow_deny)}) + _trace_event("set", token) + previous = conf_raw.get(key) + continue + + parsed_token = parse_value(token, key) + if isinstance(parsed_token, list): + conf_raw.update({key: token}) + _trace_event("set", token) + previous = conf_raw.get(key) + continue + + if key == "allowed" and schema.is_all_literal(split[0]): + conf_raw.update({key: expand_all_value()}) + _trace_event("set_all", split[0]) + continue + + if key == "allowed_shell_escape" and schema.is_all_literal(split[0]): + on_error("'allowed_shell_escape' cannot be set to 'all'") + continue + + if key == "path": + allow_deny = ["", ""] + for path_pattern in parse_value(value, key): + for item in glob.glob(path_pattern): + allow_deny[0] += os.path.realpath(item) + "/|" + allow_deny[0] = allow_deny[0].replace("//", "/") + conf_raw.update({key: str(allow_deny)}) + _trace_event("set", value) + continue + + conf_raw[key] = value + _trace_event("set", value) diff --git a/lshell/checkconfig.py b/lshell/config/runtime.py similarity index 83% rename from lshell/checkconfig.py rename to lshell/config/runtime.py index 1cb0dca..14fd755 100644 --- a/lshell/checkconfig.py +++ b/lshell/config/runtime.py @@ -1,4 +1,12 @@ -"""This module contains the checkconfig class of lshell""" +"""Build and apply the effective runtime lshell configuration. + +This module owns the runtime configuration workflow for interactive sessions: +- load global/default/group/user config layers (with include_dir support) +- resolve final raw values through ``lshell.config.resolve`` +- validate schema/constraints and apply runtime side effects (logging, env, cwd) + +It is intentionally imperative and exits on fatal configuration errors. +""" import sys import os @@ -18,13 +26,14 @@ from lshell import utils from lshell import variables from lshell import builtincmd -from lshell import configschema +from lshell.config import schema from lshell import audit from lshell import containment +from lshell.config import resolve class CheckConfig: - """Check the configuration file.""" + """Load, resolve, validate, and apply runtime config for one session.""" def noexec_library_usable(self, path_noexec): """Return True when a noexec library can be safely preloaded.""" @@ -345,143 +354,50 @@ def get_config_sub(self, section): if self.config.has_section(section): conf = list(self.config.items(section)) + conf - for item in conf: - key = item[0] - value = item[1] - # if string, then split - split = [""] - if isinstance(value, str): - split = re.split(r"((?:\+|-)\s*\[[^\]]+\])", value) - if len(split) > 1 and key in [ - "path", - "overssh", - "allowed", - "allowed_shell_escape", - "allowed_file_extensions", - "forbidden", - ]: - for stuff in split: - if not stuff.strip(): - continue - if stuff.startswith("-") or stuff.startswith("+"): - self.conf_raw.update( - self.minusplus(self.conf_raw, key, stuff) - ) - elif configschema.is_all_literal(stuff): - if key == "allowed": - self.conf_raw.update({key: self.expand_all()}) - else: - self.log.critical( - f"lshell: config: '{key}' cannot be set to 'all'" - ) - sys.exit(1) - elif stuff and key == "path": - liste = ["", ""] - for path in self._parse_config_value(stuff, key): - for item in glob.glob(path): - liste[0] += os.path.realpath(item) + "/|" - # remove double slashes - liste[0] = liste[0].replace("//", "/") - self.conf_raw.update({key: str(liste)}) - elif stuff and isinstance( - self._parse_config_value(stuff, key), list - ): - self.conf_raw.update({key: stuff}) - # case allowed/sudo_commands is set to all - elif key == "allowed" and configschema.is_all_literal(split[0]): - self.conf_raw.update({key: self.expand_all()}) - elif key == "allowed_shell_escape" and configschema.is_all_literal( - split[0] - ): - self.log.critical( - "lshell: config: 'allowed_shell_escape' cannot be set to 'all'" - ) - sys.exit(1) - elif key == "path": - liste = ["", ""] - for path in self._parse_config_value(value, "path"): - for item in glob.glob(path): - liste[0] += os.path.realpath(item) + "/|" - # remove double slashes - liste[0] = liste[0].replace("//", "/") - self.conf_raw.update({key: str(liste)}) - else: - self.conf_raw.update(dict([item])) + resolve.merge_section( + conf_raw=self.conf_raw, + section=section, + section_items=conf, + parse_value=self._parse_config_value, + expand_all_value=self.expand_all, + on_error=self._merge_error, + on_missing_remove=self._on_minusplus_remove_missing, + ) + + def _merge_error(self, message): + """Handle merge-time errors with legacy runtime fatal behavior.""" + self.log.critical(f"lshell: config: {message}") + sys.exit(1) + + def _on_minusplus_remove_missing(self, key, item): + """Keep legacy warning when removing a non-existing list item.""" + self.log.error(f"lshell: config: -['{item}'] ignored in '{key}' list.") + + def _on_expand_all_missing_path(self, directory): + """Keep legacy warning for non-existing PATH entries.""" + self.log.error(f'lshell: config: PATH entry "{directory}" does not exist') def minusplus(self, confdict, key, extra): """update configuration lists containing -/+ operators""" - if key in confdict: - liste = self._parse_config_value(confdict[key], key) - elif key == "path": - liste = ["", ""] - else: - liste = [] - - sublist = self._parse_config_value(extra[1:], key) - if extra.startswith("+"): - if key == "path": - for path in sublist: - liste[0] += os.path.realpath(path) + "/|" - else: - for item in sublist: - liste.append(item) - elif extra.startswith("-"): - if key == "path": - for path in sublist: - liste[1] += os.path.realpath(path) + "/|" - else: - for item in sublist: - if item in liste: - liste.remove(item) - else: - self.log.error( - f"lshell: config: -['{item}'] ignored in '{key}' list." - ) - return {key: str(liste)} + return resolve.minusplus( + conf_raw=confdict, + key=key, + extra=extra, + parse_value=self._parse_config_value, + on_missing_remove=self._on_minusplus_remove_missing, + ) def expand_all(self): """expand allowed, if set to 'all'""" - # initialize list to common shell built-ins - expanded_all = [ - "bg", - "break", - "case", - "cd", - "continue", - "eval", - "exec", - "exit", - "fg", - "if", - "jobs", - "kill", - "login", - "logout", - "set", - "shift", - "stop", - "suspend", - "umask", - "unset", - "wait", - "while", - ] - for directory in os.environ["PATH"].split(":"): - if os.path.exists(directory): - for item in os.listdir(directory): - if os.access(os.path.join(directory, item), os.X_OK): - expanded_all.append(item) - else: - self.log.error( - f'lshell: config: PATH entry "{directory}" does not exist' - ) - - return str(expanded_all) + return resolve.expand_all( + path_env=os.environ["PATH"], + on_missing_path=self._on_expand_all_missing_path, + ) def _parse_config_value(self, value, key=""): """Safely parse config values and enforce key schema.""" try: - return configschema.parse_config_value(value, key) + return schema.parse_config_value(value, key) except ValueError as exception: self.log.critical(f"lshell: config: {exception}") sys.exit(1) @@ -663,7 +579,7 @@ def get_config_user(self): if "scpforce" in self.conf_raw: self.conf_raw["scpforce"] = self._parse_config_value( - self.conf_raw["scpforce"] + self.conf_raw["scpforce"], "scpforce" ) try: if os.path.exists(self.conf_raw["scpforce"]): @@ -749,7 +665,7 @@ def get_config_user(self): self.conf["allowed"].append(item) # case sudo_commands set to 'all', expand to all 'allowed' commands - if "sudo_commands" in self.conf_raw and configschema.is_all_literal( + if "sudo_commands" in self.conf_raw and schema.is_all_literal( str(self.conf_raw["sudo_commands"]) ): # Keep shell-internal builtins out of sudo all-expansion while @@ -759,7 +675,7 @@ def get_config_user(self): dict.fromkeys(x for x in self.conf["allowed"] if x not in exclude) ) - # sort lsudo commands + # sort sudo commands self.conf["sudo_commands"].sort() # Enable colored `ls` output by default while preserving explicit aliases. diff --git a/lshell/configschema.py b/lshell/config/schema.py similarity index 94% rename from lshell/configschema.py rename to lshell/config/schema.py index 38ecb91..9b8f819 100644 --- a/lshell/configschema.py +++ b/lshell/config/schema.py @@ -1,4 +1,8 @@ -"""Shared config parsing and schema validation for lshell.""" +"""Parse and validate typed lshell configuration values. + +This module provides safe literal parsing, key-specific type enforcement, +and user-facing validation messages for both runtime and diagnostics paths. +""" import ast diff --git a/lshell/engine/__init__.py b/lshell/engine/__init__.py new file mode 100644 index 0000000..2b82580 --- /dev/null +++ b/lshell/engine/__init__.py @@ -0,0 +1,8 @@ +"""Canonical v2 parse/authorize/execute engine.""" + +from lshell.engine.authorizer import authorize, authorize_line +from lshell.engine.executor import execute, execute_for_shell +from lshell.engine.normalizer import normalize +from lshell.engine.parser import parse + +__all__ = ["parse", "normalize", "authorize", "authorize_line", "execute", "execute_for_shell"] diff --git a/lshell/engine/ast.py b/lshell/engine/ast.py new file mode 100644 index 0000000..b52e374 --- /dev/null +++ b/lshell/engine/ast.py @@ -0,0 +1,39 @@ +"""Canonical AST structures used by the v2 engine.""" + +from typing import NamedTuple, Tuple + + +OPERATORS = ("&&", "||", "|", ";", "&") + + +class ParsedAST(NamedTuple): + """Result of parse(line) for the canonical engine.""" + + line: str + sequence: Tuple[str, ...] + parse_error: bool = False + error: str = "" + + +class CanonicalCommand(NamedTuple): + """Canonical command segment extracted from a top-level sequence.""" + + index: int + raw: str + normalized: str + tokens: Tuple[str, ...] + executable: str + argument: str + args: Tuple[str, ...] + assignments: Tuple[Tuple[str, str], ...] + full_command: str + + +class CanonicalAST(NamedTuple): + """Normalized AST used by authorizer and executor.""" + + line: str + sequence: Tuple[str, ...] + commands: Tuple[CanonicalCommand, ...] + parse_error: bool = False + error: str = "" diff --git a/lshell/engine/authorizer.py b/lshell/engine/authorizer.py new file mode 100644 index 0000000..bee3bac --- /dev/null +++ b/lshell/engine/authorizer.py @@ -0,0 +1,307 @@ +"""Canonical policy authorizer shared by runtime and diagnostics.""" + +import os +import re +from typing import NamedTuple + +from lshell import sec +from lshell.engine import normalizer +from lshell.engine import parser as engine_parser +from lshell.engine import reasons + + +class AuthorizationDecision(NamedTuple): + """Authorization result for one canonical AST.""" + + allowed: bool + reason: reasons.Reason + ast: object + + +def _deny(code, ast, **details): + return AuthorizationDecision(False, reasons.make_reason(code, **details), ast) + + +def _allow(ast, reason_text="allowed by final policy"): + return AuthorizationDecision( + True, reasons.make_reason(reasons.ALLOWED, reason=reason_text), ast + ) + + +def _allowed_commands(policy, ssh=False): + if ssh: + return set(policy.get("overssh", [])) + return set(policy.get("allowed", [])) + + +def _is_path_allowed(policy, candidate): + path_acl = policy.get("path", ["", ""]) + allow = path_acl[0] if len(path_acl) > 0 else "" + deny = path_acl[1] if len(path_acl) > 1 else "" + allowed_roots = sec._split_path_acl_entries(allow) + denied_roots = sec._split_path_acl_entries(deny) + return sec._is_path_allowed(candidate, allowed_roots, denied_roots) + + +def _first_path_violation(line, policy, check_current_dir=False): + path_tokens = sec._path_tokens_from_line(line) + + for item in path_tokens: + candidates = sec.expand_shell_wildcards(item) + if not candidates: + return item + + for candidate in candidates: + if not _is_path_allowed(policy, candidate): + return sec._format_path_for_message(candidate) + + if check_current_dir: + current_dir = os.path.realpath(os.getcwd()) + if not _is_path_allowed(policy, current_dir): + return sec._format_path_for_message(current_dir) + + return None + + +def _forbidden_char_violation(line, policy): + for item in policy.get("forbidden", []): + if item in ["&", "|"]: + escaped_item = re.escape(item) + if re.search(rf"(? 8: + return _deny( + reasons.UNKNOWN_SYNTAX, + canonical_ast, + command=canonical_ast.line, + line=canonical_ast.line, + ) + + if canonical_ast.parse_error: + return _deny( + reasons.UNKNOWN_SYNTAX, + canonical_ast, + command=canonical_ast.line, + line=canonical_ast.line, + ) + + oline = canonical_ast.line + line = oline.strip() + + for item in _quoted_literals_without_assignment(line): + if os.path.exists(item): + violation = _first_path_violation(item, policy, check_current_dir=False) + if violation: + return _deny( + reasons.FORBIDDEN_PATH, + canonical_ast, + path=violation, + line=oline, + ) + + if re.findall(r"[\x01-\x1F\x7F]", oline): + return _deny( + reasons.FORBIDDEN_CONTROL_CHAR, + canonical_ast, + line=oline, + ) + + forbidden_item = _forbidden_char_violation(line, policy) + if forbidden_item is not None: + return _deny( + reasons.FORBIDDEN_CHARACTER, + canonical_ast, + token=forbidden_item, + line=oline, + ) + + expansions = sec._scan_shell_expansions(line) + + for expansion in expansions: + if expansion.kind != "command_substitution": + continue + inner = expansion.body.strip() + violation = _first_path_violation(inner, policy, check_current_dir=False) + if violation: + return _deny( + reasons.FORBIDDEN_PATH, + canonical_ast, + path=violation, + line=oline, + ) + + nested_decision = _authorize_nested(inner, policy, mode, ssh, depth) + if not nested_decision.allowed: + return nested_decision + + for expansion in expansions: + if expansion.kind != "backtick": + continue + nested_decision = _authorize_nested( + expansion.body.strip(), policy, mode, ssh, depth + ) + if not nested_decision.allowed: + return nested_decision + + for expansion in expansions: + if expansion.kind != "parameter_expansion": + continue + variable_text = sec._parameter_expansion_path_probe(expansion.body).strip() + + violation = _first_path_violation( + variable_text, policy, check_current_dir=False + ) + if violation: + return _deny( + reasons.FORBIDDEN_PATH, + canonical_ast, + path=violation, + line=oline, + ) + + allowed_commands = _allowed_commands(policy, ssh=ssh) + + for command_node in canonical_ast.commands: + command = command_node.executable + command_args_list = list(command_node.args) + full_command = command_node.full_command + + if command == "sudo" and command_args_list: + if command_args_list[0] == "-u": + if len(command_args_list) < 3: + return _deny( + reasons.FORBIDDEN_SUDO_COMMAND, + canonical_ast, + command="", + line=oline, + missing_target=True, + ) + sudocmd = command_args_list[2] + else: + sudocmd = command_args_list[0] + + if sudocmd not in policy.get("sudo_commands", []): + return _deny( + reasons.FORBIDDEN_SUDO_COMMAND, + canonical_ast, + command=sudocmd, + line=oline, + missing_target=False, + ) + + if ( + full_command not in allowed_commands + and command not in allowed_commands + and command + ): + if policy.get("strict"): + return _deny( + reasons.FORBIDDEN_COMMAND, + canonical_ast, + command=command, + line=oline, + ) + return _deny( + reasons.UNKNOWN_SYNTAX, + canonical_ast, + command=full_command, + line=oline, + ) + + allowed_extensions = policy.get("allowed_file_extensions") + if allowed_extensions and sec.should_enforce_file_extensions(command): + check_extensions, disallowed_extensions = sec.check_allowed_file_extensions( + full_command, allowed_extensions + ) + if check_extensions is False: + return _deny( + reasons.FORBIDDEN_FILE_EXTENSION, + canonical_ast, + disallowed_extensions=disallowed_extensions, + full_command=full_command, + line=oline, + ) + + violation = _first_path_violation( + oline, + policy, + check_current_dir=bool(check_current_dir), + ) + if violation: + return _deny( + reasons.FORBIDDEN_PATH, + canonical_ast, + path=violation, + line=oline, + ) + + return _allow(canonical_ast) + + +def authorize_line( + line, + policy, + mode="runtime", + ssh=False, + check_current_dir=None, + depth=0, +): + """Convenience helper: parse -> normalize -> authorize.""" + parsed = engine_parser.parse(line) + canonical = normalizer.normalize(parsed) + return authorize( + canonical, + policy, + mode=mode, + ssh=ssh, + check_current_dir=check_current_dir, + depth=depth, + ) + + +__all__ = [ + "AuthorizationDecision", + "authorize", + "authorize_line", + "forbidden_chars_decision", +] diff --git a/lshell/engine/executor.py b/lshell/engine/executor.py new file mode 100644 index 0000000..eb9e727 --- /dev/null +++ b/lshell/engine/executor.py @@ -0,0 +1,443 @@ +"""Runtime execution path for v2 parse/authorize pipeline.""" + +import os +import re +import sys +from typing import NamedTuple + +from lshell import audit +from lshell import builtincmd +from lshell import containment +from lshell import messages +from lshell import sec +from lshell import utils +from lshell import variables +from lshell.engine import authorizer +from lshell.engine import normalizer +from lshell.engine import parser as engine_parser +from lshell.engine import reasons + + +class EngineDecisions(NamedTuple): + """Input for execute(decisions, runtime).""" + + ast: object + policy: dict + + +class ExecutionResult(NamedTuple): + """Execution result produced by the v2 executor.""" + + retcode: int + audit_reason: str + + +def build_decisions(command_line, policy): + """parse(line) -> normalize(AST) decisions bundle.""" + parsed = engine_parser.parse(command_line) + canonical = normalizer.normalize(parsed) + return EngineDecisions(ast=canonical, policy=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"], + ) + audit.log_command_event( + shell_context.conf, + command, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, + reasons.to_audit_reason(reasons.make_reason(reasons.UNKNOWN_SYNTAX, command=command)), + ), + level="warning", + ) + if ret == 1 and shell_context.conf["strict"]: + return 126 + return 1 + + +def _deny_with_reason(shell_context, command_line, decision): + reason = decision.reason + payload = reasons.warning_payload(reason) + + if payload and payload.get("kind") == "unknown_syntax": + return _unknown_syntax_retcode(shell_context, payload.get("command", command_line)) + + if payload and payload.get("kind") == "warn_count": + _ret, shell_context.conf = sec.warn_count( + payload.get("messagetype", "command"), + payload.get("command", command_line), + shell_context.conf, + strict=shell_context.conf["strict"], + ) + audit.log_command_event( + shell_context.conf, + command_line, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, + reasons.to_audit_reason(reason), + ), + ) + return 126 + + if reason.code == reasons.FORBIDDEN_ENV_ASSIGNMENT: + variable = reason.details.get("variable", "") + audit.set_decision_reason( + shell_context.conf, + reasons.to_audit_reason(reason), + ) + audit.log_command_event( + shell_context.conf, + command_line, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, + reasons.to_audit_reason(reason), + ), + ) + shell_context.log.critical(f"lshell: forbidden environment variable: {variable}") + sys.stderr.write(f"lshell: forbidden environment variable: {variable}\n") + return 126 + + if reason.code == reasons.FORBIDDEN_TRUSTED_PROTOCOL: + audit.log_command_event( + shell_context.conf, + command_line, + allowed=False, + reason=reasons.to_audit_reason(reason), + ) + shell_context.log.critical( + f'lshell: forbidden trusted SSH protocol command: "{command_line}"' + ) + sys.stderr.write("lshell: forbidden trusted SSH protocol command\n") + return 126 + + audit.log_command_event( + shell_context.conf, + command_line, + allowed=False, + reason=reasons.to_audit_reason(reason), + ) + return 126 + + +def _trusted_protocol_precheck(sequence, shell_context): + operators = {"&&", "||", "|", ";", "&"} + trusted = set(variables.TRUSTED_SFTP_PROTOCOL_BINARIES) + + for item in sequence: + if item in operators: + continue + + executable, _argument, _split, assignments = utils._parse_command(item) + if executable is None: + return authorizer.AuthorizationDecision( + False, + reasons.make_reason(reasons.UNKNOWN_SYNTAX, command=item, line=item), + None, + ) + if assignments: + return authorizer.AuthorizationDecision( + False, + reasons.make_reason( + reasons.FORBIDDEN_TRUSTED_PROTOCOL, + command=item, + detail="env assignment", + ), + None, + ) + if executable not in trusted: + return authorizer.AuthorizationDecision( + False, + reasons.make_reason( + reasons.FORBIDDEN_TRUSTED_PROTOCOL, + command=executable, + ), + None, + ) + + return None + + +def execute(decisions, runtime): + """execute(decisions, runtime) -> retcode + audit.""" + shell_context = runtime["shell_context"] + trusted_protocol = bool(runtime.get("trusted_protocol")) + command_line = decisions.ast.line + + if decisions.ast.parse_error: + retcode = _unknown_syntax_retcode(shell_context, command_line) + return ExecutionResult(retcode=retcode, audit_reason="unknown syntax") + + forbidden_check_line = utils.expand_vars_quoted( + command_line, + support_advanced_braced=False, + ) + ret_forbidden_chars, shell_context.conf = sec.check_forbidden_chars( + forbidden_check_line, + shell_context.conf, + strict=shell_context.conf["strict"], + ) + if ret_forbidden_chars == 1: + audit.log_command_event( + shell_context.conf, + command_line, + allowed=False, + reason=audit.pop_decision_reason( + shell_context.conf, "forbidden character in command" + ), + ) + retcode = 126 + return ExecutionResult( + retcode=retcode, + audit_reason="forbidden character in command", + ) + + command_sequence = list(decisions.ast.sequence) + retcode = 0 + + if trusted_protocol: + trusted_check = _trusted_protocol_precheck(command_sequence, shell_context) + if trusted_check is not None: + retcode = _deny_with_reason(shell_context, command_line, trusted_check) + return ExecutionResult( + retcode=retcode, + audit_reason=reasons.to_audit_reason(trusted_check.reason), + ) + + i = 0 + while i < len(command_sequence): + current_item = command_sequence[i] + + if current_item in ["&&", "||", "|", "&", ";"]: + i += 1 + continue + + prev_operator = command_sequence[i - 1] if i > 0 else None + + if prev_operator == "&&" and retcode != 0: + j = i + while ( + j + 2 < len(command_sequence) + and command_sequence[j + 1] == "|" + and command_sequence[j + 2] not in ["&&", "||", "|", "&", ";"] + ): + j += 2 + i = j + ( + 2 + if j + 1 < len(command_sequence) and command_sequence[j + 1] == "&" + else 1 + ) + continue + if prev_operator == "||" and retcode == 0: + j = i + while ( + j + 2 < len(command_sequence) + and command_sequence[j + 1] == "|" + and command_sequence[j + 2] not in ["&&", "||", "|", "&", ";"] + ): + j += 2 + i = j + ( + 2 + if j + 1 < len(command_sequence) and command_sequence[j + 1] == "&" + else 1 + ) + continue + + pipeline_parts = [current_item] + j = i + while ( + j + 2 < len(command_sequence) + and command_sequence[j + 1] == "|" + and command_sequence[j + 2] not in ["&&", "||", "|", "&", ";"] + ): + pipeline_parts.append(command_sequence[j + 2]) + j += 2 + + pipeline_parts = [utils.replace_exit_code(part, retcode) for part in pipeline_parts] + pipeline_parts = [ + utils.expand_vars_quoted(part, support_advanced_braced=False) + for part in pipeline_parts + ] + full_command = " | ".join(pipeline_parts) + background = bool(j + 1 < len(command_sequence) and command_sequence[j + 1] == "&") + + if background: + limits = containment.get_runtime_limits(shell_context.conf) + if limits.max_background_jobs > 0: + active_jobs = len(builtincmd.jobs()) + if active_jobs >= limits.max_background_jobs: + reason = containment.reason_with_details( + "runtime_limit.max_background_jobs_exceeded", + active=active_jobs, + limit=limits.max_background_jobs, + ) + shell_context.log.critical( + "lshell: runtime containment denied background command: " + f"active_jobs={active_jobs}, limit={limits.max_background_jobs}, " + f'command="{full_command}"' + ) + sys.stderr.write( + "lshell: background job denied: " + f"max_background_jobs={limits.max_background_jobs} reached\n" + ) + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=reason, + ) + return ExecutionResult(retcode=126, audit_reason=reason) + + parsed_parts = [utils._parse_command(part) for part in pipeline_parts] + if any(part[0] is None for part in parsed_parts): + retcode = _unknown_syntax_retcode(shell_context, full_command) + return ExecutionResult(retcode=retcode, audit_reason="unknown syntax") + + for _executable_name, _argument, _split, assignments in parsed_parts: + for var_name, _var_value in assignments: + if var_name in variables.FORBIDDEN_ENVIRON: + deny_decision = authorizer.AuthorizationDecision( + False, + reasons.make_reason( + reasons.FORBIDDEN_ENV_ASSIGNMENT, + variable=var_name, + line=full_command, + ), + None, + ) + retcode = _deny_with_reason(shell_context, full_command, deny_decision) + return ExecutionResult( + retcode=retcode, + audit_reason=reasons.to_audit_reason(deny_decision.reason), + ) + + executable, argument, _, assignments = parsed_parts[0] + if not executable and assignments: + for var_name, var_value in assignments: + os.environ[var_name] = var_value + audit.log_command_event( + shell_context.conf, + full_command, + allowed=True, + reason="assignment-only command accepted", + ) + retcode = 0 + i = j + (2 if background else 1) + continue + + if not trusted_protocol: + decision = authorizer.authorize_line( + full_command, + shell_context.conf, + mode="runtime", + check_current_dir=True, + ) + if not decision.allowed: + retcode = _deny_with_reason(shell_context, full_command, decision) + if ( + decision.reason.code == reasons.FORBIDDEN_PATH + and shell_context.conf.get("winscp") + and re.search("WinSCP: this is end-of-file", command_line) + ): + utils.exec_cmd(f'echo "WinSCP: this is end-of-file: {retcode}"') + return ExecutionResult( + retcode=retcode, + audit_reason=reasons.to_audit_reason(decision.reason), + ) + + if len(pipeline_parts) == 1 and executable in builtincmd.builtins_list and not background: + audit.log_command_event( + shell_context.conf, + full_command, + allowed=True, + reason="allowed builtin command", + ) + retcode, shell_context.conf = utils.handle_builtin_command( + full_command, + executable, + argument, + shell_context, + ) + elif trusted_protocol or all( + executable_name + and utils._is_allowed_command(executable_name, part, shell_context.conf) + for (executable_name, _, _, _), part in zip(parsed_parts, pipeline_parts) + ): + if not trusted_protocol: + missing_executable = next( + ( + executable_name + for executable_name, _, _, _ in parsed_parts + if executable_name + and executable_name not in builtincmd.builtins_list + and not utils._command_exists(executable_name) + ), + None, + ) + if missing_executable: + command_not_found_message = messages.get_message( + shell_context.conf, + "command_not_found", + command=missing_executable, + ) + audit.log_command_event( + shell_context.conf, + full_command, + allowed=False, + reason=f"command not found: {missing_executable}", + ) + shell_context.log.critical(command_not_found_message) + return ExecutionResult(retcode=127, audit_reason="command not found") + + extra_env = None + allowed_shell_escape = set(shell_context.conf.get("allowed_shell_escape", [])) + uses_shell_escape = any( + executable_name in allowed_shell_escape + for (executable_name, _, _, _) in parsed_parts + if executable_name + ) + if "path_noexec" in shell_context.conf and not uses_shell_escape: + extra_env = {"LD_PRELOAD": shell_context.conf["path_noexec"]} + + audit.log_command_event( + shell_context.conf, + full_command, + allowed=True, + reason="allowed by command and path policy", + ) + retcode = utils.exec_cmd( + full_command, + background=background, + extra_env=extra_env, + conf=shell_context.conf, + log=shell_context.log, + ) + else: + retcode = _unknown_syntax_retcode(shell_context, full_command) + return ExecutionResult(retcode=retcode, audit_reason="unknown syntax") + + i = j + (2 if background else 1) + + return ExecutionResult(retcode=retcode, audit_reason="command execution complete") + + +def execute_for_shell(command_line, shell_context, trusted_protocol=False): + """Convenience runtime entrypoint for utils.cmd_parse_execute v2 path.""" + decisions = build_decisions(command_line, shell_context.conf) + result = execute( + decisions, + { + "shell_context": shell_context, + "trusted_protocol": trusted_protocol, + }, + ) + return result.retcode + + +__all__ = ["EngineDecisions", "ExecutionResult", "build_decisions", "execute", "execute_for_shell"] diff --git a/lshell/engine/normalizer.py b/lshell/engine/normalizer.py new file mode 100644 index 0000000..0006630 --- /dev/null +++ b/lshell/engine/normalizer.py @@ -0,0 +1,110 @@ +"""Canonical AST normalizer for v2 engine.""" + +import re +import shlex + +from lshell.engine.ast import OPERATORS, CanonicalAST, CanonicalCommand + + +_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") + + +def _is_assignment_word(word): + return bool(_ASSIGNMENT_RE.match(word)) + + +def _parse_command_words(command_text): + """Parse one command segment into executable/args/assignment prefixes.""" + try: + tokens = shlex.split(command_text, posix=True) + except ValueError: + return None + + if not tokens: + return { + "tokens": tuple(), + "assignments": tuple(), + "executable": "", + "args": tuple(), + "argument": "", + "full_command": "", + } + + assignments = [] + index = 0 + while index < len(tokens) and _is_assignment_word(tokens[index]): + name, value = tokens[index].split("=", 1) + assignments.append((name, value)) + index += 1 + + if index >= len(tokens): + return { + "tokens": tuple(tokens), + "assignments": tuple(assignments), + "executable": "", + "args": tuple(), + "argument": "", + "full_command": "", + } + + executable = tokens[index] + args = tuple(tokens[index + 1 :]) + return { + "tokens": tuple(tokens), + "assignments": tuple(assignments), + "executable": executable, + "args": args, + "argument": " ".join(args), + "full_command": " ".join((executable,) + args).strip(), + } + + +def normalize(parsed_ast): + """Normalize parse output into canonical command nodes.""" + if parsed_ast.parse_error: + return CanonicalAST( + line=parsed_ast.line, + sequence=parsed_ast.sequence, + commands=tuple(), + parse_error=True, + error=parsed_ast.error, + ) + + commands = [] + for index, item in enumerate(parsed_ast.sequence): + if item in OPERATORS: + continue + + normalized = re.sub(r"\)$", "", item) + normalized = " ".join(normalized.split()) + parsed = _parse_command_words(normalized) + if parsed is None: + return CanonicalAST( + line=parsed_ast.line, + sequence=parsed_ast.sequence, + commands=tuple(commands), + parse_error=True, + error="unknown syntax", + ) + + commands.append( + CanonicalCommand( + index=index, + raw=item, + normalized=normalized, + tokens=parsed["tokens"], + executable=parsed["executable"], + argument=parsed["argument"], + args=parsed["args"], + assignments=parsed["assignments"], + full_command=parsed["full_command"], + ) + ) + + return CanonicalAST( + line=parsed_ast.line, + sequence=parsed_ast.sequence, + commands=tuple(commands), + parse_error=False, + error="", + ) diff --git a/lshell/engine/parser.py b/lshell/engine/parser.py new file mode 100644 index 0000000..851d73c --- /dev/null +++ b/lshell/engine/parser.py @@ -0,0 +1,16 @@ +"""Canonical parser entrypoint for the v2 engine.""" + +from lshell.engine.ast import ParsedAST +from lshell import utils + + +def parse(line): + """Parse command line into top-level command/operator sequence.""" + if line is None: + line = "" + + sequence = utils.split_command_sequence(line) + if sequence is None: + return ParsedAST(line=line, sequence=tuple(), parse_error=True, error="unknown syntax") + + return ParsedAST(line=line, sequence=tuple(sequence), parse_error=False, error="") diff --git a/lshell/engine/reasons.py b/lshell/engine/reasons.py new file mode 100644 index 0000000..2b8c308 --- /dev/null +++ b/lshell/engine/reasons.py @@ -0,0 +1,145 @@ +"""Reason codes and user-facing mappings for the canonical v2 engine.""" + +from typing import Any, Dict, NamedTuple + + +class Reason(NamedTuple): + """Structured authorization reason.""" + + code: str + details: Dict[str, Any] + + +ALLOWED = "allowed" +UNKNOWN_SYNTAX = "unknown_syntax" +FORBIDDEN_CONTROL_CHAR = "forbidden_control_char" +FORBIDDEN_CHARACTER = "forbidden_character" +FORBIDDEN_PATH = "forbidden_path" +FORBIDDEN_COMMAND = "forbidden_command" +FORBIDDEN_SUDO_COMMAND = "forbidden_sudo_command" +FORBIDDEN_FILE_EXTENSION = "forbidden_file_extension" +FORBIDDEN_ENV_ASSIGNMENT = "forbidden_env_assignment" +FORBIDDEN_TRUSTED_PROTOCOL = "forbidden_trusted_protocol" +COMMAND_NOT_FOUND = "command_not_found" + + +def make_reason(code, **details): + """Build a structured reason payload.""" + return Reason(code=code, details=details) + + +def to_policy_message(reason): + """Map structured reasons to legacy policy-show decision text.""" + code = reason.code + details = reason.details + + if code == ALLOWED: + return "allowed by final policy" + if code == UNKNOWN_SYNTAX: + return f"unknown syntax '{details.get('command', '')}'" + if code == FORBIDDEN_CONTROL_CHAR: + return "forbidden control character" + if code == FORBIDDEN_CHARACTER: + return f"forbidden character '{details.get('token', '')}'" + if code == FORBIDDEN_PATH: + return "forbidden path" + if code == FORBIDDEN_COMMAND: + return f"forbidden command '{details.get('command', '')}'" + if code == FORBIDDEN_SUDO_COMMAND: + if details.get("missing_target"): + return "forbidden sudo command (missing target command)" + return f"forbidden sudo command '{details.get('command', '')}'" + if code == FORBIDDEN_FILE_EXTENSION: + disallowed = details.get("disallowed_extensions", []) + return "forbidden file extension(s) " + ", ".join(disallowed) + if code == FORBIDDEN_ENV_ASSIGNMENT: + return ( + "forbidden environment variable assignment " + f"'{details.get('variable', '')}'" + ) + if code == COMMAND_NOT_FOUND: + return f"command not found '{details.get('command', '')}'" + if code == FORBIDDEN_TRUSTED_PROTOCOL: + return "forbidden trusted SSH protocol command" + + return "policy evaluation failed" + + +def to_audit_reason(reason): + """Map structured reasons to runtime audit strings.""" + code = reason.code + details = reason.details + + if code == ALLOWED: + return details.get("reason", "allowed by command and path policy") + if code == UNKNOWN_SYNTAX: + return f"unknown syntax: {details.get('command', '')}" + if code == FORBIDDEN_CONTROL_CHAR: + return f"forbidden control char: {details.get('line', '')}" + if code == FORBIDDEN_CHARACTER: + return f"forbidden character: {details.get('token', '')}" + if code == FORBIDDEN_PATH: + return f"forbidden path: {details.get('path', '')}" + if code == FORBIDDEN_COMMAND: + return f"forbidden command: {details.get('command', '')}" + if code == FORBIDDEN_SUDO_COMMAND: + return f"forbidden sudo command: {details.get('line', '')}" + if code == FORBIDDEN_FILE_EXTENSION: + return f"forbidden file extension: {', '.join(details.get('disallowed_extensions', []))}" + if code == FORBIDDEN_ENV_ASSIGNMENT: + return "forbidden environment variable assignment: " + details.get( + "variable", "" + ) + if code == FORBIDDEN_TRUSTED_PROTOCOL: + 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" + + +def warning_payload(reason): + """Map structured reasons to warn_count()/warn_unknown_syntax payload.""" + code = reason.code + details = reason.details + + if code == UNKNOWN_SYNTAX: + return {"kind": "unknown_syntax", "command": details.get("command", "")} + if code == FORBIDDEN_CONTROL_CHAR: + return { + "kind": "warn_count", + "messagetype": "control char", + "command": details.get("line", ""), + } + if code == FORBIDDEN_CHARACTER: + return { + "kind": "warn_count", + "messagetype": "character", + "command": details.get("token", ""), + } + if code == FORBIDDEN_PATH: + return { + "kind": "warn_count", + "messagetype": "path", + "command": details.get("path", ""), + } + if code == FORBIDDEN_COMMAND: + return { + "kind": "warn_count", + "messagetype": "command", + "command": details.get("command", ""), + } + if code == FORBIDDEN_SUDO_COMMAND: + return { + "kind": "warn_count", + "messagetype": "sudo command", + "command": details.get("line", ""), + } + if code == FORBIDDEN_FILE_EXTENSION: + return { + "kind": "warn_count", + "messagetype": f"file extension {details.get('disallowed_extensions', [])}", + "command": details.get("full_command", ""), + } + + return None diff --git a/lshell/hardeninit.py b/lshell/hardeninit.py index acdfc04..2ea3701 100644 --- a/lshell/hardeninit.py +++ b/lshell/hardeninit.py @@ -7,7 +7,7 @@ import sys from datetime import datetime, timezone -from lshell import configschema +from lshell.config import schema as configschema SAFE_FORBIDDEN_OPERATORS = [";", "&", "|", "`", ">", "<", "$(", "${"] diff --git a/lshell/parser.py b/lshell/parser.py deleted file mode 100644 index a917b02..0000000 --- a/lshell/parser.py +++ /dev/null @@ -1,246 +0,0 @@ -""" Custom shell command parser with advanced tokenization and error handling """ - -from typing import Optional -from pyparsing import ( - Word, - alphanums, - quoted_string, - Group, - ZeroOrMore, - Literal, - Optional as PyOptional, - ParseException, - one_of, - ParseResults, - alphas, - printables, - OneOrMore, - SkipTo, -) - - -class LshellParser: - """Custom shell command parser""" - - def __init__(self): - """Initialize the parser with custom settings""" - # Improved tokenization - self._escape_char = "\\" - self._quote_chars = ['"', "'"] - - def _handle_escaped_chars(self, token: str) -> str: - """ - Handle escaped characters in tokens - Supports escaping of quote characters and special symbols - """ - if not token: - return token - - cleaned_token = [] - is_escaped = False - - for char in token: - if is_escaped: - # List of escapable characters - escaped_map = { - "n": "\n", - "t": "\t", - "r": "\r", - '"': '"', - "'": "'", - "\\": "\\", - } - cleaned_token.append(escaped_map.get(char, char)) - is_escaped = False - elif char == self._escape_char: - is_escaped = True - else: - cleaned_token.append(char) - - return "".join(cleaned_token) - - def _advanced_quote_handler(self, token: str) -> str: - """ - Advanced quote handling with nested quote support - """ - if not token: - return token - - # If it's a quoted string - if token[0] in self._quote_chars and token[0] == token[-1]: - # Keep the quotes, but handle escaped characters inside - quoted_content = token[1:-1] - unescaped_content = self._handle_escaped_chars(quoted_content) - # Return with original quotes - return token[0] + unescaped_content + token[-1] - - # For non-quoted strings, just handle escaped chars - return self._handle_escaped_chars(token) - - def _build_grammar(self): - """ - Construct a more robust parsing grammar with background support - """ - # Variable assignment pattern - var_name = Word(alphas + "_", alphanums + "_") - var_value = Word(alphanums + "_/.-") | quoted_string - var_assignment = Group(var_name + Literal("=") + var_value) - - # Command substitution patterns - cmd_subst_dollar = Group( - Literal("$(") + SkipTo(Literal(")")) + Literal(")") - ).set_parse_action( - lambda t: [" ".join(t[0])] - ) # Preserve as single token - - cmd_subst_backtick = Group( - Literal("`") + SkipTo(Literal("`")) + Literal("`") - ).set_parse_action( - lambda t: [" ".join(t[0])] - ) # Preserve as single token - - # Variable expansion pattern - var_expansion = Group( - Literal("${") + Word(alphanums + "_") + Literal("}") - ).set_parse_action( - lambda t: [" ".join(t[0])] - ) # Preserve as single token - - # Advanced word tokenization - allow all printable chars except operators - operator_chars = "|&;><" - # Create allowed chars string: all printables except operators - word_chars = "".join(c for c in printables if c not in operator_chars) - - # Define custom quoted string that preserves all spaces - quoted_text = quoted_string.set_parse_action(lambda t: t[0]) - - # Define tokens that can start a command (no operators) - safe_chars = "".join( - set(word_chars) - set(operator_chars) - ) # Convert to sets for subtraction - command_start = ( - cmd_subst_dollar # Command substitution - | cmd_subst_backtick # Command substitution - | var_expansion # Variable expansion - | quoted_text - | Word("$" + word_chars) # Environment variables and paths - | Word(safe_chars) # Regular words, excluding operators - ) - - # Advanced word tokenization - advanced_word = ( - cmd_subst_dollar # Command substitution - | cmd_subst_backtick # Command substitution - | var_expansion # Variable expansion - | quoted_text - | Word("$" + word_chars) # Environment variables and paths - | Word(word_chars) # Regular words - ) - - # Operators with more flexible parsing - operators = ["&&", "||", "|", ";"] - - # Redirection with enhanced support - redirection_ops = [">", ">>", "<", "2>", "2>>", ">&"] - - # Background operator - background_op = ~Literal("&&") + Literal("&") - - # Trailing semicolon - trailing_semicolon = Literal(";") - - # Command structure with optional variable assignments - command = Group( - ( - # Either a command with optional var assignments - ( - ZeroOrMore(var_assignment) - + command_start - + ZeroOrMore(advanced_word) - + PyOptional(background_op) - ) - # Or just variable assignments - | OneOrMore(var_assignment) - ) - + PyOptional(one_of(" ".join(redirection_ops)) + advanced_word) - ) - - # Full command sequence with optional background at the end - command_sequence = Group( - command - + ZeroOrMore(one_of(" ".join(operators)) + command) - + PyOptional(trailing_semicolon) - ) - - return command_sequence - - def _clean_input(self, command: str) -> str: - """Clean control characters from input""" - return "".join(char for char in command if ord(char) >= 32 or char in "\n\r\t") - - def parse(self, command: str) -> Optional[ParseResults]: - """ - Main parsing method with error handling - """ - try: - # Clean the input first - cleaned_command = self._clean_input(command) - grammar = self._build_grammar() - parsed_result = grammar.parse_string(cleaned_command, parse_all=True) - ret = parsed_result - except ParseException: - ret = None - return ret - - def validate_command(self, parsed_command: ParseResults) -> bool: - """ - Basic command validation - Can be extended with more sophisticated checks - """ - if not parsed_command: - return False - - # Example validation rules - max_tokens = 20 - max_token_length = 255 - - flattened = list(parsed_command) - - # Check total number of tokens - if len(flattened) > max_tokens: - return False - - # Check individual token lengths - for token in flattened: - if len(str(token)) > max_token_length: - return False - - return True - - -# Testing -if __name__ == "__main__": - parser = LshellParser() - - test_commands = [ - 'echo "hello world"', - 'grep "error" /var/log/syslog | sort -u > errors.txt', - 'find / -name "*.py" -print | xargs grep "def \\"test\\""', - r'echo "escaped \"quote\""', - "tar -czf backup.tar.gz /home/user/data && mv backup.tar.gz /mnt/backup/ " - '|| echo "Backup failed"', - 'find / -name "*.py" -print | xargs grep "def " > functions.txt; echo "Search complete" &', - 'grep "error" /var/log/syslog | sort -u > errors.txt && echo "Errors found" ' - '|| echo "No errors"', - 'echo "hello" &', - "ls nRVmmn8RGypVneYIp8HxyVAvaEaD55; echo $?", - ] - - for cmd in test_commands: - print(f"Parsing: {cmd}") - parsed_result = parser.parse(cmd) - if parsed_result: - print("Parsed Successfully:") - print(parsed_result) - print("Validation:", parser.validate_command(parsed_result)) - print("-" * 40) diff --git a/lshell/sec.py b/lshell/sec.py index 7a637d7..d57f9d1 100644 --- a/lshell/sec.py +++ b/lshell/sec.py @@ -8,6 +8,7 @@ import os import shlex import glob +from typing import NamedTuple # import lshell specifics from lshell import messages @@ -18,10 +19,215 @@ MAX_WILDCARD_MATCHES = 4096 +class _ShellExpansion(NamedTuple): + """Parsed shell expansion from an input line.""" + + kind: str + body: str + + def _is_assignment_word(word): return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*=.*$", word)) +def _quoted_literals_without_assignment(line): + """Extract quoted literals, excluding immediate assignment values (X="...").""" + literals = [] + index = 0 + length = len(line) + + while index < length: + quote = line[index] + if quote not in {"'", '"'}: + index += 1 + continue + + previous = line[index - 1] if index > 0 else "" + index += 1 + chunk = [] + escaped = False + + while index < length: + char = line[index] + if quote == '"' and escaped: + chunk.append(char) + escaped = False + index += 1 + continue + if quote == '"' and char == "\\": + escaped = True + index += 1 + continue + if char == quote: + break + chunk.append(char) + index += 1 + + if index < length and line[index] == quote: + chunk_text = "".join(chunk) + if previous != "=": + literals.append(chunk_text) + if quote == "'": + literals.extend(_quoted_literals_without_assignment(chunk_text)) + index += 1 + + return literals + + +def _read_backtick_expansion(line, start): + """Read a backtick expansion beginning at start and return (end, body).""" + i = start + 1 + escaped = False + while i < len(line): + char = line[i] + if escaped: + escaped = False + i += 1 + continue + if char == "\\": + escaped = True + i += 1 + continue + if char == "`": + return i + 1, line[start + 1 : i] + i += 1 + return None, None + + +def _read_dollar_expansion(line, start, closing): + """Read a balanced $()- or ${}-style expansion from start.""" + i = start + 2 + in_single = False + in_double = False + in_backtick = False + escaped = False + closers = [closing] + + while i < len(line): + char = line[i] + next_char = line[i + 1] if i + 1 < len(line) else "" + + if escaped: + escaped = False + i += 1 + continue + + if char == "\\" and not in_single: + escaped = True + i += 1 + continue + + if char == "'" and not in_double and not in_backtick: + in_single = not in_single + i += 1 + continue + + if char == '"' and not in_single and not in_backtick: + in_double = not in_double + i += 1 + continue + + if char == "`" and not in_single: + in_backtick = not in_backtick + i += 1 + continue + + if in_single or in_double or in_backtick: + i += 1 + continue + + if char == "$" and next_char == "(": + closers.append(")") + i += 2 + continue + + if char == "$" and next_char == "{": + closers.append("}") + i += 2 + continue + + if closers and char == closers[-1]: + closers.pop() + if not closers: + return i + 1, line[start + 2 : i] + i += 1 + continue + + i += 1 + + return None, None + + +def _scan_shell_expansions(line): + """Parse shell expansions in-order while honoring quotes/escapes.""" + expansions = [] + i = 0 + in_single = False + in_double = False + escaped = False + + while i < len(line): + char = line[i] + next_char = line[i + 1] if i + 1 < len(line) else "" + + if escaped: + escaped = False + i += 1 + continue + + if char == "\\" and not in_single: + escaped = True + i += 1 + continue + + if char == "'" and not in_double: + in_single = not in_single + i += 1 + continue + + if char == '"' and not in_single: + in_double = not in_double + i += 1 + continue + + if in_single: + i += 1 + continue + + if char == "$" and next_char == "(": + end, body = _read_dollar_expansion(line, i, ")") + if end is not None and body: + expansions.append(_ShellExpansion("command_substitution", body)) + i = end + continue + + if char == "$" and next_char == "{": + end, body = _read_dollar_expansion(line, i, "}") + if end is not None and body: + expansions.append(_ShellExpansion("parameter_expansion", body)) + i = end + continue + + if char == "`": + end, body = _read_backtick_expansion(line, i) + if end is not None and body: + expansions.append(_ShellExpansion("backtick", body)) + i = end + continue + + i += 1 + + return expansions + + +def _parameter_expansion_path_probe(expression): + """Return the value-side text from ${...} forms, or the expression itself.""" + for index, char in enumerate(expression): + if char in {"=", "+", "?", "-"}: + return expression[index + 1 :] + return expression + + def should_enforce_file_extensions(command): """Return True when extension restrictions should apply to this command.""" return command not in EXTENSION_RESTRICTION_EXEMPT_COMMANDS @@ -360,15 +566,7 @@ def check_secure(line, conf, strict=None, ssh=None): # init return code returncode = 0 - # This logic is kept crudely simple on purpose. - # At most we might match the same stanza twice - # (for e.g. "'a'", 'a') but the converse would - # require detecting single quotation stanzas - # nested within double quotes and vice versa - relist = re.findall(r"[^=]\"(.+)\"", line) - relist2 = re.findall(r"[^=]\'(.+)\'", line) - relist = relist + relist2 - for item in relist: + for item in _quoted_literals_without_assignment(line): if os.path.exists(item): ret_check_path, conf = check_path(item, conf, strict=strict) returncode += ret_check_path @@ -378,43 +576,40 @@ def check_secure(line, conf, strict=None, ssh=None): ret, conf = warn_count("control char", oline, conf, strict=strict, ssh=ssh) return ret, conf - for item in conf["forbidden"]: - # allow '&&' and '||' even if singles are forbidden - if item in ["&", "|"]: - if re.findall(rf"[^\{item}]\{item}[^\{item}]", line): - ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh) - return ret, conf - else: - if item in line: - ret, conf = warn_count("character", item, conf, strict=strict, ssh=ssh) - return ret, conf + ret_forbidden, conf = check_forbidden_chars(line, conf, strict=strict, ssh=ssh) + if ret_forbidden: + return ret_forbidden, conf + + expansions = _scan_shell_expansions(line) # check if the line contains $(foo) executions, and check them - executions = re.findall(r"\$\([^)]+[)]", line) - for item in executions: + for expansion in expansions: + if expansion.kind != "command_substitution": + continue + inner = expansion.body.strip() # recurse on check_path - ret_check_path, conf = check_path(item[2:-1].strip(), conf, strict=strict) + ret_check_path, conf = check_path(inner, conf, strict=strict) returncode += ret_check_path # recurse on check_secure - ret_check_secure, conf = check_secure(item[2:-1].strip(), conf, strict=strict) + ret_check_secure, conf = check_secure(inner, conf, strict=strict) returncode += ret_check_secure # check for executions using back quotes '`' - executions = re.findall(r"\`[^`]+[`]", line) - for item in executions: - ret_check_secure, conf = check_secure(item[1:-1].strip(), conf, strict=strict) + for expansion in expansions: + if expansion.kind != "backtick": + continue + ret_check_secure, conf = check_secure( + expansion.body.strip(), conf, strict=strict + ) returncode += ret_check_secure # check if the line contains ${foo=bar}, and check them - curly = re.findall(r"\$\{[^}]+[}]", line) - for item in curly: - # split to get variable only, and remove last character "}" - if re.findall(r"=|\+|\?|\-", item): - variable = re.split(r"=|\+|\?|\-", item, maxsplit=1) - else: - variable = item - ret_check_path, conf = check_path(variable[1][:-1], conf, strict=strict) + for expansion in expansions: + if expansion.kind != "parameter_expansion": + continue + variable = _parameter_expansion_path_probe(expansion.body).strip() + ret_check_path, conf = check_path(variable, conf, strict=strict) returncode += ret_check_path # if unknown commands where found, return 1 and don't execute the line diff --git a/lshell/shellcmd.py b/lshell/shellcmd.py index f7f0d60..e60e264 100644 --- a/lshell/shellcmd.py +++ b/lshell/shellcmd.py @@ -12,14 +12,14 @@ import readline # import lshell specifics -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell import utils from lshell import builtincmd from lshell import messages from lshell import sec from lshell import completion from lshell import variables -from lshell import policy as policy_mode +from lshell.config import diagnostics as policy_mode from lshell import audit @@ -538,17 +538,22 @@ def onecmd(self, line): self.g_cmd, self.g_arg, self.g_line = [cmd, arg, line] if not line: return self.emptyline() - if cmd is None: + if cmd is None or cmd == "": + stripped = line.lstrip() + # Keep comment/shebang lines ignored (script compatibility), + # but validate tokenization failures such as leading operators. + if stripped and not stripped.startswith("#"): + try: + getattr(self, "do___lshell_dispatch") + except AttributeError: + pass return self.default(line) self.lastcmd = line - if cmd == "": + try: + func = getattr(self, "do_" + cmd) + except AttributeError: return self.default(line) - else: - try: - func = getattr(self, "do_" + cmd) - except AttributeError: - return self.default(line) - return func(arg) + return func(arg) def emptyline(self): """This method overrides the original emptyline method, so it doesn't @@ -579,30 +584,27 @@ def do_quit(self, arg=None): """Handle quit exactly like exit.""" return self.do_exit(arg) - def do_policy_show(self, arg=None): - """Show resolved policy values and optional decision for a command.""" + def do_lshow(self, arg=None): + """Show current session policy values and optional command decision.""" command_line = (arg or "").strip() or None - username = self.conf.get("username") - groups = policy_mode._resolve_user_groups(username, []) - try: - result = policy_mode.resolve_policy( - self.conf["configfile"], - username, - groups, - ) - except ValueError as exception: - self.stderr.write(f"lshell: {exception}\n") - return 1 + # `lshow` runs inside an active shell session. Use the in-memory runtime + # policy so command-line overrides (for example --allowed/--sudo_commands) + # are reflected exactly as enforced in the current session. + result = {"policy": self.conf} decision = None if command_line: - decision = policy_mode.policy_command_decision(command_line, result["policy"]) + decision = policy_mode.policy_command_decision(command_line, self.conf) policy_mode.print_user_view(result, command_line, decision) if decision is 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 c1b9a7d..7b4e23d 100644 --- a/lshell/utils.py +++ b/lshell/utils.py @@ -17,8 +17,6 @@ # import lshell specifics from lshell import variables from lshell import builtincmd -from lshell import sec -from lshell import messages from lshell import audit from lshell import containment @@ -482,7 +480,7 @@ def _command_exists(executable): def handle_builtin_command(full_command, executable, argument, shell_context): """ - Handle built-in commands like cd, lpath, lsudo, etc. + Handle built-in commands like cd and lshow. Returns tuple of (retcode, conf) """ @@ -491,8 +489,8 @@ def handle_builtin_command(full_command, executable, argument, shell_context): if executable == "help": shell_context.do_help(executable) - elif executable == "policy-show": - shell_context.do_policy_show(argument) + elif executable == "lshow": + shell_context.do_lshow(argument) elif executable == "exit": shell_context.do_exit(full_command) elif executable == "history": @@ -501,10 +499,6 @@ def handle_builtin_command(full_command, executable, argument, shell_context): retcode, shell_context.conf = builtincmd.cmd_cd(argument, shell_context.conf) elif executable == "ls": retcode = exec_cmd(full_command, conf=shell_context.conf, log=shell_context.log) - elif executable in ["lpath", "policy-path"]: - retcode = builtincmd.cmd_lpath(conf) - elif executable in ["lsudo", "policy-sudo"]: - retcode = builtincmd.cmd_lsudo(conf) elif executable == "export": retcode, var = builtincmd.cmd_export(full_command) if retcode == 1: @@ -529,352 +523,13 @@ def cmd_parse_execute(command_line, shell_context=None, trusted_protocol=False): trusted_protocol is only for protocol commands (scp/sftp-server) that were already validated in run_overssh. """ - def _handle_unknown_syntax(unknown_command): - ret, shell_context.conf = sec.warn_unknown_syntax( - unknown_command, - shell_context.conf, - strict=shell_context.conf["strict"], - ) - audit.log_command_event( - shell_context.conf, - unknown_command, - allowed=False, - reason=audit.pop_decision_reason(shell_context.conf, "unknown syntax"), - level="warning", - ) - if ret == 1 and shell_context.conf["strict"]: - # Keep strict-mode behavior aligned with forbidden actions. - return 126 - return 1 - - command_sequence = split_command_sequence(command_line) - if command_sequence is None: - return _handle_unknown_syntax(command_line) + from lshell.engine import executor as engine_executor # pylint: disable=import-outside-toplevel - # Initialize return code - retcode = 0 - - # Check forbidden characters on an expanded view of the line so - # `${VAR}` references are treated consistently with prior behavior. - forbidden_check_line = expand_vars_quoted( - command_line, support_advanced_braced=False - ) - ret_forbidden_chars, shell_context.conf = sec.check_forbidden_chars( - forbidden_check_line, shell_context.conf, strict=shell_context.conf["strict"] + return engine_executor.execute_for_shell( + command_line, + shell_context=shell_context, + trusted_protocol=trusted_protocol, ) - if ret_forbidden_chars == 1: - audit.log_command_event( - shell_context.conf, - command_line, - allowed=False, - reason=audit.pop_decision_reason( - shell_context.conf, "forbidden character in command" - ), - ) - # see http://tldp.org/LDP/abs/html/exitcodes.html - retcode = 126 - return retcode - - # Protocol commands (handled/gated by run_overssh) can bypass generic - # policy/path checks while still using the same execution surface. - skip_policy_checks = bool(trusted_protocol) - trusted_protocol_binaries = set(variables.TRUSTED_SFTP_PROTOCOL_BINARIES) - - if skip_policy_checks: - # Trusted protocol mode may use chaining if config permits it, - # but every command segment must be a known protocol command. - protocol_operators = {"&&", "||", "|", ";", "&"} - for item in command_sequence: - if isinstance(item, str) and item in protocol_operators: - continue - executable, _argument, _split, assignments = _parse_command(item) - if executable is None: - return _handle_unknown_syntax(item) - if assignments: - audit.log_command_event( - shell_context.conf, - item, - allowed=False, - reason="forbidden trusted SSH protocol command: env assignment", - ) - shell_context.log.critical( - f'lshell: forbidden trusted SSH protocol command: "{item}"' - ) - sys.stderr.write("lshell: forbidden trusted SSH protocol command\n") - return 126 - if executable not in trusted_protocol_binaries: - audit.log_command_event( - shell_context.conf, - item, - allowed=False, - reason=f"forbidden trusted SSH protocol command: {executable}", - ) - shell_context.log.critical( - f'lshell: forbidden trusted SSH protocol command: "{item}"' - ) - sys.stderr.write("lshell: forbidden trusted SSH protocol command\n") - return 126 - - # Iterate through the command sequence - i = 0 - while i < len(command_sequence): - current_item = command_sequence[i] - - # Skip if it's an operator - if isinstance(current_item, str) and current_item in [ - "&&", - "||", - "|", - "&", - ";", - ]: - i += 1 - continue - - # Get the previous operator (if any) - prev_operator = ( - command_sequence[i - 1] - if i > 0 and isinstance(command_sequence[i - 1], str) - else None - ) - - # Skip empty commands - if not current_item: - i += 1 - continue - - # Handle logical operators - if prev_operator == "&&" and retcode != 0: - # Previous command failed, skip this branch (including pipeline/background). - j = i - while ( - j + 2 < len(command_sequence) - and command_sequence[j + 1] == "|" - and command_sequence[j + 2] - not in ["&&", "||", "|", "&", ";"] - ): - j += 2 - i = j + (2 if j + 1 < len(command_sequence) and command_sequence[j + 1] == "&" else 1) - continue - elif prev_operator == "||" and retcode == 0: - # Previous command succeeded, skip this branch (including pipeline/background). - j = i - while ( - j + 2 < len(command_sequence) - and command_sequence[j + 1] == "|" - and command_sequence[j + 2] - not in ["&&", "||", "|", "&", ";"] - ): - j += 2 - i = j + (2 if j + 1 < len(command_sequence) and command_sequence[j + 1] == "&" else 1) - continue - - # Build a pipeline command sequence at top-level (`cmd1 | cmd2 | ...`). - pipeline_parts = [current_item] - j = i - while ( - j + 2 < len(command_sequence) - and command_sequence[j + 1] == "|" - and command_sequence[j + 2] - not in ["&&", "||", "|", "&", ";"] - ): - pipeline_parts.append(command_sequence[j + 2]) - j += 2 - - # Expand `$?` for each command segment so sequences like - # `cmd1; echo $?` reflect the exit code from `cmd1`. - pipeline_parts = [replace_exit_code(part, retcode) for part in pipeline_parts] - # Expand variables at execution time for each segment so assignment-only - # commands earlier in a chain affect later commands (e.g. `A=1 && echo $A`). - pipeline_parts = [ - expand_vars_quoted(part, support_advanced_braced=False) - for part in pipeline_parts - ] - full_command = " | ".join(pipeline_parts) - background = bool(j + 1 < len(command_sequence) and command_sequence[j + 1] == "&") - - if background: - limits = containment.get_runtime_limits(shell_context.conf) - if limits.max_background_jobs > 0: - active_jobs = len(builtincmd.jobs()) - if active_jobs >= limits.max_background_jobs: - reason = containment.reason_with_details( - "runtime_limit.max_background_jobs_exceeded", - active=active_jobs, - limit=limits.max_background_jobs, - ) - shell_context.log.critical( - "lshell: runtime containment denied background command: " - f"active_jobs={active_jobs}, limit={limits.max_background_jobs}, " - f'command="{full_command}"' - ) - sys.stderr.write( - "lshell: background job denied: " - f"max_background_jobs={limits.max_background_jobs} reached\n" - ) - audit.log_command_event( - shell_context.conf, - full_command, - allowed=False, - reason=reason, - ) - return 126 - - parsed_parts = [_parse_command(part) for part in pipeline_parts] - if any(part[0] is None for part in parsed_parts): - return _handle_unknown_syntax(full_command) - - # Reject forbidden env-var assignments in command prefixes (e.g. PATH=... cmd). - # This keeps assignment-prefix behavior aligned with `export` restrictions. - for _executable_name, _argument, _split, assignments in parsed_parts: - for var_name, _var_value in assignments: - if var_name in variables.FORBIDDEN_ENVIRON: - reason = f"forbidden environment variable assignment: {var_name}" - audit.set_decision_reason(shell_context.conf, reason) - audit.log_command_event( - shell_context.conf, - full_command, - allowed=False, - reason=reason, - ) - shell_context.log.critical( - f"lshell: forbidden environment variable: {var_name}" - ) - sys.stderr.write( - f"lshell: forbidden environment variable: {var_name}\n" - ) - return 126 - - executable, argument, _, assignments = parsed_parts[0] - if executable is None: - return _handle_unknown_syntax(current_item) - - # Assignment-only command: persist in current shell environment. - if not executable and assignments: - for var_name, var_value in assignments: - os.environ[var_name] = var_value - audit.log_command_event( - shell_context.conf, - full_command, - allowed=True, - reason="assignment-only command accepted", - ) - retcode = 0 - i = j + (2 if background else 1) - continue - - if not skip_policy_checks: - # check that commands/chars present in line are allowed/secure - ret_check_secure, shell_context.conf = sec.check_secure( - full_command, shell_context.conf, strict=shell_context.conf["strict"] - ) - if ret_check_secure == 1: - audit.log_command_event( - shell_context.conf, - full_command, - allowed=False, - reason=audit.pop_decision_reason( - shell_context.conf, "forbidden command by security policy" - ), - ) - # see http://tldp.org/LDP/abs/html/exitcodes.html - retcode = 126 - return retcode - - # check that path present in line are allowed/secure - ret_check_path, shell_context.conf = sec.check_path( - full_command, shell_context.conf, strict=shell_context.conf["strict"] - ) - if ret_check_path == 1: - audit.log_command_event( - shell_context.conf, - full_command, - allowed=False, - reason=audit.pop_decision_reason( - shell_context.conf, "forbidden path by security policy" - ), - ) - # see http://tldp.org/LDP/abs/html/exitcodes.html - retcode = 126 - # in case request was sent by WinSCP, return error code has to be - # sent via a specific echo command - if shell_context.conf["winscp"] and re.search( - "WinSCP: this is end-of-file", command_line - ): - exec_cmd(f'echo "WinSCP: this is end-of-file: {retcode}"') - return retcode - - # Execute command - if len(pipeline_parts) == 1 and executable in builtincmd.builtins_list and not background: - audit.log_command_event( - shell_context.conf, - full_command, - allowed=True, - reason="allowed builtin command", - ) - retcode, shell_context.conf = handle_builtin_command( - full_command, executable, argument, shell_context - ) - elif skip_policy_checks or all( - executable_name - and _is_allowed_command(executable_name, part, shell_context.conf) - for (executable_name, _, _, _), part in zip(parsed_parts, pipeline_parts) - ): - if not skip_policy_checks: - missing_executable = next( - ( - executable_name - for executable_name, _, _, _ in parsed_parts - if executable_name - and executable_name not in builtincmd.builtins_list - and not _command_exists(executable_name) - ), - None, - ) - if missing_executable: - command_not_found_message = messages.get_message( - shell_context.conf, - "command_not_found", - command=missing_executable, - ) - audit.log_command_event( - shell_context.conf, - full_command, - allowed=False, - reason=f"command not found: {missing_executable}", - ) - shell_context.log.critical(command_not_found_message) - return 127 - - extra_env = None - allowed_shell_escape = set(shell_context.conf.get("allowed_shell_escape", [])) - uses_shell_escape = any( - executable_name in allowed_shell_escape - for (executable_name, _, _, _) in parsed_parts - if executable_name - ) - if "path_noexec" in shell_context.conf and not uses_shell_escape: - extra_env = {"LD_PRELOAD": shell_context.conf["path_noexec"]} - audit.log_command_event( - shell_context.conf, - full_command, - allowed=True, - reason="allowed by command and path policy", - ) - retcode = exec_cmd( - full_command, - background=background, - extra_env=extra_env, - conf=shell_context.conf, - log=shell_context.log, - ) - else: - retcode = _handle_unknown_syntax(full_command) - return retcode - - i = j + (2 if background else 1) - - return retcode def exec_cmd(cmd, background=False, extra_env=None, conf=None, log=None): diff --git a/lshell/variables.py b/lshell/variables.py index 8583785..8f72fe6 100644 --- a/lshell/variables.py +++ b/lshell/variables.py @@ -3,7 +3,7 @@ import sys import os -__version__ = "0.11.1" +__version__ = "0.12.0rc1" # Required config variable list per user required_config = ["allowed", "forbidden", "warning_counter"] diff --git a/man/lshell.1 b/man/lshell.1 index 1839111..1cf624b 100644 --- a/man/lshell.1 +++ b/man/lshell.1 @@ -18,6 +18,7 @@ lshell \- Limited Shell .SH DESCRIPTION \fBlshell\fR provides a limited shell configured per user via a configuration file. +Current releases require Python 3.10 or newer. You can also use lshell within scripts by adding the appropriate shebang (#!/usr/bin/lshell) to your script file. @@ -512,8 +513,7 @@ ignored (uses scp(1) from within session) .TP .I policy_commands enable/disable policy introspection builtins. If set to 1 (default), users can -run \fBpolicy-show\fR, \fBpolicy-path\fR, \fBpolicy-sudo\fR (and aliases -\fBlpath\fR, \fBlsudo\fR). If set to 0, these commands are hidden. +run \fBlshow\fR. If set to 0, this command is hidden. .SH BEST PRACTICES .TP @@ -568,20 +568,8 @@ print the list of allowed commands .I history print the commands history .TP -.I policy-show [command] -show resolved policy values. If a command is provided, also show allow/deny decision. -.TP -.I policy-path -lists all allowed and forbidden path -.TP -.I policy-sudo -lists all sudo allowed commands -.TP -.I lpath -alias for \fBpolicy-path\fR -.TP -.I lsudo -alias for \fBpolicy-sudo\fR +.I lshow [command] +show resolved policy values including path and sudo sections. If a command is provided, also show allow/deny decision. .TP .I jobs list background jobs diff --git a/pyproject.toml b/pyproject.toml index 2cdf73c..9842b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "limited-shell" description = "lshell - Limited Shell" readme = "README.md" -requires-python = ">=3.6" +requires-python = ">=3.10" license = { text = "GPL-3.0-or-later" } keywords = ["limited", "shell", "security", "python"] authors = [{ name = "Ignace Mouzannar", email = "ghantoos@ghantoos.org" }] @@ -18,7 +18,12 @@ classifiers = [ "Intended Audience :: System Administrators", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: POSIX", - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Security", "Topic :: System :: Shells", "Topic :: System :: System Shells", diff --git a/rpm/lshell.rpm-test.conf b/rpm/lshell.rpm-test.conf index 5f886f5..bbca3ba 100644 --- a/rpm/lshell.rpm-test.conf +++ b/rpm/lshell.rpm-test.conf @@ -13,7 +13,7 @@ allowed_file_extensions : ['.conf', '.log', '.txt'] aliases : {'ll': 'ls -l', 'la': 'ls -la'} env_vars : {'LSHELL_LAYER': 'default', 'LSHELL_ENV': 'rpm-test'} prompt_short : 1 -intro : "\033[1;95mRPM Test Profile: layered default/group/user policy\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\ndate\nll\ncat /home/testuser/lshell/test/testfiles/test.conf\npolicy-show\npolicy-show cat /home/testuser/lshell/test/testfiles/test.conf\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\ncat /etc/passwd\ncat /tmp/test.log\n" +intro : "\033[1;95mRPM Test Profile: layered default/group/user policy\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\ndate\nll\ncat /home/testuser/lshell/test/testfiles/test.conf\nlshow\nlshow cat /home/testuser/lshell/test/testfiles/test.conf\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\ncat /etc/passwd\ncat /tmp/test.log\n" messages : { 'unknown_syntax': 'rpm-test: unknown syntax -> {command}', 'forbidden_command': 'rpm-test: command blocked -> "{command}"', @@ -35,7 +35,7 @@ env_vars : {'LSHELL_LAYER': 'group'} [testuser] # User layer applies on top of default + group for testuser. -allowed : + ['cat', 'head', 'tail', 'policy-show'] - ['echo'] +allowed : + ['cat', 'head', 'tail', 'lshow'] - ['echo'] path : + ['/home/testuser/lshell/test/testfiles'] - ['/home'] overssh : + ['ls'] - ['rsync'] allowed_file_extensions : + ['.yaml'] - ['.txt'] diff --git a/test/samples/01_baseline_allowlist.conf b/test/samples/01_baseline_allowlist.conf index e5985ef..3899154 100644 --- a/test/samples/01_baseline_allowlist.conf +++ b/test/samples/01_baseline_allowlist.conf @@ -5,14 +5,14 @@ # - echo hello # allowed # - uname -a # denied (not in allowed list) # - echo hi; id # denied (forbidden character ';') -# - policy-show # shows effective policy +# - lshow # shows effective policy [global] logpath : /tmp/lshell-logs/ loglevel : 2 [default] -intro : "\033[1;95mSample 01: baseline allow-list\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\npwd\nll\necho hello\npolicy-show\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\nuname -a\necho hi; id\n" +intro : "\033[1;95mSample 01: baseline allow-list\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\npwd\nll\necho hello\nlshow\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\nuname -a\necho hi; id\n" allowed : ['ls', 'pwd', 'echo', 'cat', 'whoami', 'id', 'date', 'clear'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 diff --git a/test/samples/04_sudo_and_aliases.conf b/test/samples/04_sudo_and_aliases.conf index 24993b6..4860606 100644 --- a/test/samples/04_sudo_and_aliases.conf +++ b/test/samples/04_sudo_and_aliases.conf @@ -5,14 +5,14 @@ # - sudo id # allowed # - sudo cat /etc/passwd # denied (sudo target command not allowed) # - sudo bash # denied (sudo target command not allowed) -# - policy-sudo # inspect allowed sudo commands +# - lshow # inspect merged policy (includes sudo section) [global] logpath : /tmp/lshell-logs/ loglevel : 3 [default] -intro : "\033[1;95mSample 04: sudo controls and aliases\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nll\nsudo ls /tmp\nsudo id\npolicy-sudo\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\nsudo cat /etc/passwd\nsudo bash\n" +intro : "\033[1;95mSample 04: sudo controls and aliases\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nll\nsudo ls /tmp\nsudo id\nlshow\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\nsudo cat /etc/passwd\nsudo bash\n" allowed : ['ls', 'pwd', 'echo', 'id', 'sudo'] allowed_shell_escape : ['sudo'] sudo_commands : ['ls', 'id'] diff --git a/test/samples/07_ssh_transfer_controls.conf b/test/samples/07_ssh_transfer_controls.conf index 0c40a41..8da8b67 100644 --- a/test/samples/07_ssh_transfer_controls.conf +++ b/test/samples/07_ssh_transfer_controls.conf @@ -1,8 +1,8 @@ # Sample 07: SSH/scp/sftp/overssh controls # Commands to test: -# - policy-show # inspect transfer-related policy keys -# - policy-show scp -f /tmp/x # inspect command decision for scp -# - policy-show rsync /tmp x # inspect command decision for rsync +# - lshow # inspect transfer-related policy keys +# - lshow scp -f /tmp/x # inspect command decision for scp +# - lshow rsync /tmp x # inspect command decision for rsync # - scp -f /tmp/file # policy allows protocol command path # - sftp # denied (sftp disabled) # - rsync --version # allowed (in overssh and allowed) @@ -12,7 +12,7 @@ logpath : /tmp/lshell-logs/ loglevel : 4 [default] -intro : "\033[1;95mSample 07: SSH transfer controls\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\npolicy-show\npolicy-show scp -f /tmp/x\npolicy-show rsync /tmp x\nscp -f /tmp/file\nrsync --version\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\nsftp\nscp -t /tmp/file\n" +intro : "\033[1;95mSample 07: SSH transfer controls\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nlshow\nlshow scp -f /tmp/x\nlshow rsync /tmp x\nscp -f /tmp/file\nrsync --version\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\nsftp\nscp -t /tmp/file\n" allowed : ['ls', 'pwd', 'echo', 'scp', 'rsync'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] scp : 1 diff --git a/test/samples/08_user_group_precedence.conf b/test/samples/08_user_group_precedence.conf index 03f6f1f..db54b68 100644 --- a/test/samples/08_user_group_precedence.conf +++ b/test/samples/08_user_group_precedence.conf @@ -5,14 +5,14 @@ # - echo hello # denied (removed by [testuser]) # - ll # allowed (user alias) # - gll # allowed (group alias) -# - policy-show # verify resolved values from all sections +# - lshow # verify resolved values from all sections [global] logpath : /tmp/lshell-logs/ loglevel : 3 [default] -intro : "\033[1;95mSample 08: user/group/default precedence\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\nll\ngll\npolicy-show\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\n" +intro : "\033[1;95mSample 08: user/group/default precedence\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\nid\nwhoami\nll\ngll\nlshow\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\n" allowed : ['ls', 'pwd', 'echo'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 2 diff --git a/test/samples/09_include_dir_main.conf b/test/samples/09_include_dir_main.conf index 7e4b8fc..0c63e26 100644 --- a/test/samples/09_include_dir_main.conf +++ b/test/samples/09_include_dir_main.conf @@ -5,7 +5,7 @@ # - whoami # allowed (from include.d/30_user_testuser.conf) # - echo hello # denied (user include removes it) # - ll # allowed (alias from include file) -# - policy-show # shows included files and merged policy +# - lshow # shows included files and merged policy [global] logpath : /tmp/lshell-logs/ @@ -13,7 +13,7 @@ loglevel : 3 include_dir : /app/test/samples/include.d/*.conf [default] -intro : "\033[1;95mSample 09: include_dir configuration merge\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\npwd\nid\nwhoami\nll\npolicy-show\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\n" +intro : "\033[1;95mSample 09: include_dir configuration merge\033[0m\n\n\033[1;92mAllowed examples\033[0m\n\033[90m---------------\033[0m\npwd\nid\nwhoami\nll\nlshow\n\n\033[1;91mForbidden examples\033[0m\n\033[90m------------------\033[0m\necho hello\n" allowed : ['ls', 'pwd', 'echo'] forbidden : [';', '&', '|', '`', '>', '<', '$(', '${'] warning_counter : 3 diff --git a/test/test_audit_unit.py b/test/test_audit_unit.py index 88bfa90..71a077f 100644 --- a/test/test_audit_unit.py +++ b/test/test_audit_unit.py @@ -7,7 +7,7 @@ from unittest.mock import patch from lshell import audit -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" diff --git a/test/test_builtins.py b/test/test_builtins.py index 894a447..9f4d5c8 100644 --- a/test/test_builtins.py +++ b/test/test_builtins.py @@ -30,7 +30,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_01_welcome_message(self): + def test_welcome_message(self): """F01 | lshell welcome message""" expected = ( "You are in a limited shell.\r\nType '?' or 'help' to get" @@ -39,7 +39,7 @@ def test_01_welcome_message(self): result = self.child.before.decode("utf8") self.assertEqual(expected, result) - def test_02_builtin_ls_command(self): + def test_builtin_ls_command(self): """F02 | built-in ls command""" p = subprocess.Popen( "ls ~", shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE @@ -51,7 +51,7 @@ def test_02_builtin_ls_command(self): output = self.child.before.decode("utf8").split("ls\r", 1)[1] self.assertEqual(len(expected.strip().split()), len(output.strip().split())) - def test_06_builtin_cd_change_dir(self): + def test_builtin_cd_change_dir(self): """F06 | built-in cd - change directory""" expected = "" home = os.path.expanduser("~") @@ -68,7 +68,7 @@ def test_06_builtin_cd_change_dir(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_07_builtin_cd_tilda(self): + def test_builtin_cd_tilda(self): """F07 | built-in cd - tilda bug""" expected = ( 'lshell: forbidden path: "/etc/passwd"\r\n' @@ -79,7 +79,7 @@ def test_07_builtin_cd_tilda(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_08_builtin_cd_quotes(self): + def test_builtin_cd_quotes(self): """F08 | built-in - quotes in cd "/" """ expected = ( 'lshell: forbidden path: "/"\r\n' @@ -90,7 +90,7 @@ def test_08_builtin_cd_quotes(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_18_cd_exitcode_with_separator_internal_cmd(self): + def test_cd_exitcode_with_separator_internal_cmd(self): """F18 | built-in command exit codes with separator""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') child.expect(PROMPT) @@ -102,7 +102,7 @@ def test_18_cd_exitcode_with_separator_internal_cmd(self): self.assertEqual(expected, result) self.do_exit(child) - def test_19_cd_exitcode_without_separator_external_cmd(self): + def test_cd_exitcode_without_separator_external_cmd(self): """F19 | built-in exit codes without separator""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') child.expect(PROMPT) @@ -116,7 +116,7 @@ def test_19_cd_exitcode_without_separator_external_cmd(self): self.assertEqual(expected, result) self.do_exit(child) - def test_20_cd_with_cmd_unknwon_dir(self): + def test_cd_with_cmd_unknwon_dir(self): """F20 | test built-in cd with command when dir does not exist Should be returning error, not executing cmd """ @@ -133,7 +133,7 @@ def test_20_cd_with_cmd_unknwon_dir(self): self.assertEqual(expected, result) self.do_exit(child) - def test_68_source_nonexistent_file(self): + def test_source_nonexistent_file(self): """F68 | Test sourcing a nonexistent environment file shows an error""" # Define a nonexistent file path @@ -157,7 +157,7 @@ def test_68_source_nonexistent_file(self): # Clean up and end session self.do_exit(child) - def test_69_source_valid_file(self): + def test_source_valid_file(self): """F69 | Test sourcing a valid environment file sets variables""" # Start lshell and source the environment file @@ -180,7 +180,7 @@ def test_69_source_valid_file(self): # Clean up and end session self.do_exit(child) - def test_70_source_overwrite_variable(self): + def test_source_overwrite_variable(self): """F70 | Test sourcing a file overwrites existing environment variables""" # Start lshell, set initial variable, and source file to overwrite it diff --git a/test/test_cli_unit.py b/test/test_cli_unit.py index f572384..969cda2 100644 --- a/test/test_cli_unit.py +++ b/test/test_cli_unit.py @@ -1,7 +1,9 @@ """Unit tests for lshell.cli argument handling.""" +import io import os import unittest +from types import SimpleNamespace from unittest.mock import MagicMock, patch from lshell import cli @@ -82,3 +84,207 @@ def test_main_routes_harden_init_subcommand(self): cli.main() mock_harden_main.assert_called_once_with(["--list-templates"]) mock_exit.assert_called_once_with(3) + + def test_main_routes_policy_show_subcommand(self): + """Dispatch policy-show subcommand to diagnostics handler.""" + with patch("lshell.cli.policy_mode.main", return_value=5) as mock_policy_main: + with patch("lshell.cli.sys.argv", ["lshell", "policy-show", "--user", "alice"]): + with patch("lshell.cli.sys.exit", side_effect=SystemExit) as mock_exit: + with self.assertRaises(SystemExit): + cli.main() + mock_policy_main.assert_called_once_with(["--user", "alice"]) + mock_exit.assert_called_once_with(5) + + def test_main_logs_and_exits_when_session_limit_denied(self): + """Containment denial at startup should be audited and returned as exit 1.""" + denied = cli.containment.ContainmentViolation( + reason_code="runtime_limit.max_sessions_per_user_exceeded", + user_message="lshell: session denied: max_sessions_per_user=1 reached", + log_message="lshell: runtime containment denied session start", + ) + + class _DummyCheckConfig: + def __init__(self, _args): + pass + + def returnconf(self): + """Return minimal runtime config for startup-path tests.""" + return {"logpath": MagicMock()} + + stderr = io.StringIO() + with patch("lshell.cli.CheckConfig", _DummyCheckConfig): + with patch("lshell.cli.ShellCmd", _DummyShell): + with patch("lshell.cli.sys.argv", ["lshell", "--quiet=1"]): + with patch("lshell.cli.sys.stderr", stderr): + with patch( + "lshell.cli.containment.SessionAccountant" + ) as mock_accountant: + with patch("lshell.cli.audit.log_security_event") as mock_audit: + with patch( + "lshell.cli.sys.exit", side_effect=SystemExit + ) as mock_exit: + mock_accountant.return_value.acquire.side_effect = denied + with self.assertRaises(SystemExit): + cli.main() + + userconf = mock_accountant.call_args[0][0] + userconf["logpath"].critical.assert_called_once_with(denied.log_message) + self.assertIn(denied.user_message, stderr.getvalue()) + mock_audit.assert_called_once() + self.assertEqual( + mock_audit.call_args.kwargs["reason"], + "runtime_limit.max_sessions_per_user_exceeded", + ) + mock_exit.assert_called_once_with(1) + + def test_main_retries_after_keyboard_interrupt_then_exits_on_eof(self): + """Main loop should recover from Ctrl+C outside command handlers.""" + + class _InterruptThenEOF: + calls = 0 + + def __init__(self, _userconf, _args): + pass + + def cmdloop(self): + """Raise Ctrl+C once, then EOF to terminate retry loop.""" + type(self).calls += 1 + if type(self).calls == 1: + raise KeyboardInterrupt + raise EOFError + + class _DummyCheckConfig: + def __init__(self, _args): + pass + + def returnconf(self): + """Return minimal runtime config for retry-loop tests.""" + return {"logpath": MagicMock()} + + with patch("lshell.cli.CheckConfig", _DummyCheckConfig): + with patch("lshell.cli.ShellCmd", _InterruptThenEOF): + with patch("lshell.cli.sys.argv", ["lshell", "--quiet=1"]): + with patch( + "lshell.cli.sys.exit", side_effect=SystemExit + ) as mock_exit: + with self.assertRaises(SystemExit): + cli.main() + + self.assertEqual(_InterruptThenEOF.calls, 2) + mock_exit.assert_called_once_with(0) + + def test_main_preserves_existing_session_id_and_releases_accountant(self): + """Existing LSHELL_SESSION_ID should be reused and accountant released.""" + captured = {} + + class _DummyCheckConfig: + def __init__(self, _args): + pass + + def returnconf(self): + """Return minimal runtime config for session-id tests.""" + return {"logpath": MagicMock()} + + class _EOFShell: + def __init__(self, userconf, _args): + captured["session_id"] = userconf["session_id"] + + def cmdloop(self): + """Terminate main loop via EOF.""" + raise EOFError + + accountant = MagicMock() + exported_session_id = None + with patch.dict(os.environ, {"LSHELL_SESSION_ID": "fixed-session"}, clear=False): + with patch("lshell.cli.CheckConfig", _DummyCheckConfig): + with patch("lshell.cli.ShellCmd", _EOFShell): + 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"] + + self.assertEqual(captured["session_id"], "fixed-session") + self.assertEqual(exported_session_id, "fixed-session") + accountant.acquire.assert_called_once() + accountant.release.assert_called_once() + + def test_main_generates_session_id_when_missing(self): + """Missing LSHELL_SESSION_ID should generate and export a new value.""" + captured = {} + + class _DummyCheckConfig: + def __init__(self, _args): + pass + + def returnconf(self): + """Return minimal runtime config for generated session-id tests.""" + return {"logpath": MagicMock()} + + class _EOFShell: + def __init__(self, userconf, _args): + captured["session_id"] = userconf["session_id"] + + def cmdloop(self): + """Terminate main loop via EOF.""" + raise EOFError + + accountant = MagicMock() + exported_session_id = None + with patch.dict(os.environ, {}, clear=True): + with patch("lshell.cli.uuid.uuid4", return_value=SimpleNamespace(hex="generated-id")): + with patch("lshell.cli.CheckConfig", _DummyCheckConfig): + with patch("lshell.cli.ShellCmd", _EOFShell): + 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"] + + self.assertEqual(captured["session_id"], "generated-id") + self.assertEqual(exported_session_id, "generated-id") + accountant.release.assert_called_once() + + def test_main_handles_timer_timeout_path(self): + """LshellTimeOut should log timer expiry and still release accountant.""" + + class _DummyCheckConfig: + def __init__(self, _args): + pass + + def returnconf(self): + """Return minimal runtime config for timeout-path tests.""" + return {"logpath": MagicMock()} + + class _TimeoutShell: + def __init__(self, _userconf, _args): + pass + + def cmdloop(self): + """Simulate session timeout raised from shell loop.""" + raise cli.LshellTimeOut() + + accountant = MagicMock() + stdout = io.StringIO() + with patch("lshell.cli.CheckConfig", _DummyCheckConfig): + with patch("lshell.cli.ShellCmd", _TimeoutShell): + with patch( + "lshell.cli.containment.SessionAccountant", + return_value=accountant, + ) as mock_accountant_class: + with patch("lshell.cli.sys.argv", ["lshell", "--quiet=1"]): + with patch("lshell.cli.sys.stdout", stdout): + cli.main() + + accountant.release.assert_called_once() + userconf = mock_accountant_class.call_args[0][0] + userconf["logpath"].error.assert_called_once_with("Timer expired") + self.assertIn("Time is up.", stdout.getvalue()) diff --git a/test/test_command_execution.py b/test/test_command_execution.py index de8df77..42c6188 100644 --- a/test/test_command_execution.py +++ b/test/test_command_execution.py @@ -30,7 +30,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_03_external_echo_command_num(self): + def test_external_echo_command_num(self): """F03 | external echo number""" expected = "32" self.child.sendline("echo 32") @@ -38,7 +38,7 @@ def test_03_external_echo_command_num(self): result = self.child.before.decode("utf8").split()[2] self.assertEqual(expected, result) - def test_04_external_echo_command_string(self): + def test_external_echo_command_string(self): """F04 | external echo random string""" expected = "bla blabla 32 blibli! plop." self.child.sendline(f'echo "{expected}"') @@ -46,7 +46,7 @@ def test_04_external_echo_command_string(self): result = self.child.before.decode("utf8").split("\n", 1)[1].strip() self.assertEqual(expected, result) - def test_16a_exitcode_with_separator_external_cmd(self): + def test_exitcode_with_separator_external_cmd(self): """F16(a) | external command exit codes with separator""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') child.expect(PROMPT) @@ -68,7 +68,7 @@ def test_16a_exitcode_with_separator_external_cmd(self): self.assertEqual(expected_3, result_3) self.do_exit(child) - def test_16b_exitcode_with_separator_external_cmd(self): + def test_exitcode_with_separator_external_cmd_b(self): """F16(b) | external command exit codes with separator""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') child.expect(PROMPT) @@ -87,7 +87,7 @@ def test_16b_exitcode_with_separator_external_cmd(self): self.assertEqual(expected_2, result_2) self.do_exit(child) - def test_17_exitcode_without_separator_external_cmd(self): + def test_exitcode_without_separator_external_cmd(self): """F17 | external command exit codes without separator""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " '--forbidden "[]"') child.expect(PROMPT) @@ -101,7 +101,7 @@ def test_17_exitcode_without_separator_external_cmd(self): self.assertEqual(expected, result) self.do_exit(child) - def test_24_cd_and_command(self): + def test_cd_and_command(self): """F24 | cd && command should not be interpreted by internal function""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} --forbidden \"-['&']\"") child.expect(PROMPT) @@ -113,7 +113,7 @@ def test_24_cd_and_command(self): self.assertEqual(expected, result) self.do_exit(child) - def test_33_ls_non_existing_directory_and_echo(self): + def test_ls_non_existing_directory_and_echo(self): """Test: ls non_existing_directory && echo nothing""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG}") child.expect(PROMPT) @@ -126,7 +126,7 @@ def test_33_ls_non_existing_directory_and_echo(self): self.assertNotIn("nothing", output) self.do_exit(child) - def test_34_ls_and_echo_ok(self): + def test_ls_and_echo_ok(self): """Test: ls && echo OK""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --forbidden \"-['&']\"") child.expect(PROMPT) @@ -139,7 +139,7 @@ def test_34_ls_and_echo_ok(self): self.assertIn("OK", output) self.do_exit(child) - def test_35_ls_non_existing_directory_or_echo_ok(self): + def test_ls_non_existing_directory_or_echo_ok(self): """Test: ls non_existing_directory || echo OK""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --forbidden \"-['|']\"") child.expect(PROMPT) @@ -152,7 +152,7 @@ def test_35_ls_non_existing_directory_or_echo_ok(self): self.assertIn("OK", output) self.do_exit(child) - def test_36_ls_or_echo_nothing(self): + def test_ls_or_echo_nothing(self): """Test: ls || echo nothing""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG}") child.expect(PROMPT) @@ -165,7 +165,7 @@ def test_36_ls_or_echo_nothing(self): self.assertNotIn("nothing", output) self.do_exit(child) - def test_41_multicmd_with_wrong_arg_should_fail(self): + def test_multicmd_with_wrong_arg_should_fail(self): """F20 | Allowing 'echo asd': Test 'echo qwe' should fail""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" @@ -180,7 +180,7 @@ def test_41_multicmd_with_wrong_arg_should_fail(self): self.assertEqual(expected, result) self.do_exit(child) - def test_42_multicmd_with_near_exact_arg_should_fail(self): + def test_multicmd_with_near_exact_arg_should_fail(self): """F41 | Allowing 'echo asd': Test 'echo asds' should fail""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" @@ -195,7 +195,7 @@ def test_42_multicmd_with_near_exact_arg_should_fail(self): self.assertEqual(expected, result) self.do_exit(child) - def test_43_multicmd_without_arg_should_fail(self): + def test_multicmd_without_arg_should_fail(self): """F42 | Allowing 'echo asd': Test 'echo' should fail""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"['echo asd']\"" @@ -210,7 +210,7 @@ def test_43_multicmd_without_arg_should_fail(self): self.assertEqual(expected, result) self.do_exit(child) - def test_44_multicmd_asd_should_pass(self): + def test_multicmd_asd_should_pass(self): """F43 | Allowing 'echo asd': Test 'echo asd' should pass""" child = pexpect.spawn( @@ -226,7 +226,7 @@ def test_44_multicmd_asd_should_pass(self): self.assertEqual(expected, result) self.do_exit(child) - def test_45_pipeline_is_shell_compatible(self): + def test_pipeline_is_shell_compatible(self): """F45 | Pipeline should pass stdout between commands.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} " @@ -240,7 +240,7 @@ def test_45_pipeline_is_shell_compatible(self): self.assertEqual("3", result) self.do_exit(child) - def test_46_redirection_is_shell_compatible(self): + def test_redirection_is_shell_compatible(self): """F46 | Redirections should be handled by shell semantics.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --path \"['/tmp']\" " @@ -256,7 +256,7 @@ def test_46_redirection_is_shell_compatible(self): self.assertIn("does_not_exist", result) self.do_exit(child) - def test_47_allowed_missing_binary_uses_lshell_error(self): + def test_allowed_missing_binary_uses_lshell_error(self): """F47 | Allowed command missing on PATH should use lshell error format.""" random_suffix = random.randint(100000, 999999) missing_cmd = f"lshell_missing_cmd_{random_suffix}" @@ -276,7 +276,7 @@ def test_47_allowed_missing_binary_uses_lshell_error(self): self.assertNotIn("bash:", output) self.do_exit(child) - def test_69_operator_matrix_fuzz(self): + def test_operator_matrix_fuzz(self): """F69 | Operator and expansion matrix should remain stable.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --strict 1 " @@ -323,7 +323,7 @@ def expect_clean_prompt(): if child.isalive(): child.close() - def test_70_multiline_and_interrupt_storm(self): + def test_multiline_and_interrupt_storm(self): """F70 | Repeated multiline and Ctrl-C should recover cleanly.""" child = pexpect.spawn(f'{LSHELL} --config {CONFIG} --strict 1 --forbidden "[]"') @@ -360,7 +360,7 @@ def expect_clean_prompt(): if child.isalive(): child.close() - def test_71_history_randomized_session_consistency(self): + def test_history_randomized_session_consistency(self): """F71 | History should retain randomized interactive command stream.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --strict 1 " @@ -402,7 +402,7 @@ def expect_clean_prompt(): if child.isalive(): child.close() - def test_72_background_job_lifecycle(self): + def test_background_job_lifecycle(self): """F72 | Background jobs should appear and then complete cleanly.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --strict 1 " diff --git a/test/test_completion.py b/test/test_completion.py index 1906b4c..57b4009 100644 --- a/test/test_completion.py +++ b/test/test_completion.py @@ -30,7 +30,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_15_cmd_completion_tab_tab(self): + def test_cmd_completion_tab_tab(self): """F15 | command completion: tab to list commands""" self.child.sendline("\t\t") self.child.expect(PROMPT) @@ -45,16 +45,12 @@ def test_15_cmd_completion_tab_tab(self): "help", "history", "jobs", - "lpath", - "lsudo", - "policy-path", - "policy-show", - "policy-sudo", + "lshow", "source", ]: self.assertIn(command, result) - def test_14_path_completion_tilda(self): + def test_path_completion_tilda(self): """F14 | path completion with ~/""" # Create two random directories in the home directory home_dir = f"/home/{USER}" @@ -100,7 +96,7 @@ def test_14_path_completion_tilda(self): os.remove(file1) os.remove(file2) - def test_15_file_completion_tilda(self): + def test_file_completion_tilda(self): """F15 | file completion ls with ~/""" # Create two random directories in the home directory home_dir = f"/home/{USER}" @@ -146,7 +142,7 @@ def test_15_file_completion_tilda(self): os.remove(file1) os.remove(file2) - def test_16_file_completion_with_arg(self): + def test_file_completion_with_arg(self): """F15 | file completion ls with ~/""" # Create two random directories in the home directory home_dir = f"/home/{USER}" @@ -192,7 +188,7 @@ def test_16_file_completion_with_arg(self): os.remove(file1) os.remove(file2) - def test_26_cmd_completion_dot_slash(self): + def test_cmd_completion_dot_slash(self): """F26 | command completion: tab to list ./foo1 ./foo2""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['./foo1', './foo2']\"" diff --git a/test/test_config.py b/test/test_config.py index 532d58d..e2337d7 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -28,7 +28,7 @@ def assert_startup_failure(self, command, expected_fragment): output = child.before.decode("utf-8", errors="ignore") self.assertIn(expected_fragment, output) - def test_55_allowed_all_minus_list(self): + def test_allowed_all_minus_list(self): """F55 | allow all commands minus the list""" command = "echo 1" @@ -45,7 +45,7 @@ def test_55_allowed_all_minus_list(self): self.assertEqual(expected, output) self.do_exit(child) - def test_55b_allowed_all_unquoted_allows_non_default_command(self): + def test_allowed_all_unquoted_allows_non_default_command(self): """F55b | allowed=all (unquoted) should allow commands outside default list.""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed all") child.expect(PROMPT) @@ -57,7 +57,7 @@ def test_55b_allowed_all_unquoted_allows_non_default_command(self): self.assertIn("Linux", output) self.do_exit(child) - def test_56_path_minus_specific_path(self): + def test_path_minus_specific_path(self): """F56 | allow paths except for the specified path""" command1 = "cd /usr/" @@ -79,7 +79,7 @@ def test_56_path_minus_specific_path(self): self.assertEqual(expected2, output) self.do_exit(child) - def test_58_allowed_plus_minus_list(self): + def test_allowed_plus_minus_list(self): """F58 | allow plus list minus list""" command = "echo 1" expected = "lshell: unknown syntax: echo 1" @@ -97,7 +97,7 @@ def test_58_allowed_plus_minus_list(self): self.assertEqual(expected, output) self.do_exit(child) - def test_59a_forbidden_remove_one(self): + def test_forbidden_remove_one(self): """F59a | remove all items from forbidden list""" command = "echo 1 ; echo 2" @@ -114,7 +114,7 @@ def test_59a_forbidden_remove_one(self): self.assertEqual(expected, output) self.do_exit(child) - def test_59b_forbidden_remove_one(self): + def test_forbidden_remove_one_b(self): """F59b | fixed forbidden list""" command = "echo 1 ; echo 2" @@ -129,7 +129,7 @@ def test_59b_forbidden_remove_one(self): self.assertEqual(expected, output) self.do_exit(child) - def test_59c_forbidden_remove_one(self): + def test_forbidden_remove_one_c(self): """F59c | remove an item from forbidden list""" command = "echo 1 ; echo 2" @@ -146,7 +146,7 @@ def test_59c_forbidden_remove_one(self): self.assertEqual(expected, output) self.do_exit(child) - def test_60_schema_accepts_valid_allowed_list(self): + def test_schema_accepts_valid_allowed_list(self): """F60 | valid list-based override should start shell and allow command.""" child = pexpect.spawn( f'{LSHELL} --config {CONFIG} --allowed "[\'echo\']" --forbidden "[]"' @@ -158,28 +158,28 @@ def test_60_schema_accepts_valid_allowed_list(self): self.assertIn("OK", output) self.do_exit(child) - def test_61_schema_rejects_non_list_allowed(self): + def test_schema_rejects_non_list_allowed(self): """F61 | scalar value for allowed must fail schema validation.""" self.assert_startup_failure( f"{LSHELL} --config {CONFIG} --allowed 1", "lshell: config: 'allowed' must be a list", ) - def test_62_schema_rejects_non_string_allowed_entries(self): + def test_schema_rejects_non_string_allowed_entries(self): """F62 | allowed list entries must be strings.""" self.assert_startup_failure( f"{LSHELL} --config {CONFIG} --allowed \"['echo', 1]\"", "lshell: config: 'allowed' list entries must be strings", ) - def test_63_schema_rejects_non_dict_aliases(self): + def test_schema_rejects_non_dict_aliases(self): """F63 | aliases must be a dictionary.""" self.assert_startup_failure( f"{LSHELL} --config {CONFIG} --aliases \"['ll']\"", "lshell: config: 'aliases' must be a dictionary", ) - def test_64_custom_messages_override_warning_output(self): + def test_custom_messages_override_warning_output(self): """F64 | messages config should override warning text.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --strict 1 " @@ -197,7 +197,7 @@ def test_64_custom_messages_override_warning_output(self): ) self.do_exit(child) - def test_65_allowed_shell_escape_plus_minus_chain(self): + def test_allowed_shell_escape_plus_minus_chain(self): """F65 | +/- merge on allowed_shell_escape impacts command allow-list.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} " @@ -220,8 +220,8 @@ def test_65_allowed_shell_escape_plus_minus_chain(self): self.do_exit(child) - def test_66_sudo_commands_all_quoted_reflected_in_policy_sudo(self): - """F66 | sudo_commands='all' should expose effective sudo allow-list.""" + def test_sudo_commands_all_quoted_reflected_in_lshow(self): + """F66 | sudo_commands='all' should expose effective sudo allow-list in lshow.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} " "--allowed \"['ls','echo','cat']\" " @@ -229,7 +229,7 @@ def test_66_sudo_commands_all_quoted_reflected_in_policy_sudo(self): ) child.expect(PROMPT) - child.sendline("policy-sudo") + child.sendline("lshow") child.expect(PROMPT) output = child.before.decode("utf-8") self.assertIn("Sudo access : enabled", output) diff --git a/test/test_containment_unit.py b/test/test_containment_unit.py index 499cbc0..bd90b2d 100644 --- a/test/test_containment_unit.py +++ b/test/test_containment_unit.py @@ -9,7 +9,7 @@ from lshell import containment from lshell import utils -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) diff --git a/test/test_engine_parser_unit.py b/test/test_engine_parser_unit.py new file mode 100644 index 0000000..3b7caa0 --- /dev/null +++ b/test/test_engine_parser_unit.py @@ -0,0 +1,41 @@ +"""Unit tests for canonical engine parser entrypoints.""" + +import unittest + +from lshell.engine import parser as engine_parser + + +class TestEngineParser(unittest.TestCase): + """Cover direct parse() behavior for canonical engine parser.""" + + def test_parse_accepts_chained_command_with_quotes(self): + """Parser should accept standard shell-style chains with quoted text.""" + parsed = engine_parser.parse('echo "hello world" && printf ok') + self.assertFalse(parsed.parse_error) + self.assertEqual(parsed.sequence, ('echo "hello world"', "&&", "printf ok")) + + def test_parse_rejects_invalid_operator_sequence(self): + """Malformed top-level operator chains should produce unknown syntax.""" + parsed = engine_parser.parse("echo &&&& ls") + self.assertTrue(parsed.parse_error) + self.assertEqual(parsed.sequence, tuple()) + self.assertEqual(parsed.error, "unknown syntax") + + def test_parse_handles_none_input_as_empty_command(self): + """None input should normalize to a non-error empty parse.""" + parsed = engine_parser.parse(None) + self.assertFalse(parsed.parse_error) + self.assertEqual(parsed.sequence, tuple()) + self.assertEqual(parsed.line, "") + self.assertEqual(parsed.error, "") + + def test_parse_preserves_control_chars_for_downstream_security_checks(self): + """Parser does not sanitize control chars; authorizer handles that later.""" + parsed = engine_parser.parse("echo\x00ok\x1f\t\n") + self.assertFalse(parsed.parse_error) + self.assertEqual(len(parsed.sequence), 1) + self.assertIn("\x00", parsed.sequence[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_engine_pipeline_unit.py b/test/test_engine_pipeline_unit.py new file mode 100644 index 0000000..21040d0 --- /dev/null +++ b/test/test_engine_pipeline_unit.py @@ -0,0 +1,222 @@ +"""Unit tests for v2 canonical engine parse/normalize/authorize pipeline.""" + +import os +import tempfile +import unittest + +from lshell.engine import authorizer +from lshell.engine import normalizer +from lshell.engine import parser as engine_parser +from lshell.engine import reasons + + +def _policy(**overrides): + policy = { + "allowed": ["echo", "cd", "ls", "sudo"], + "forbidden": [";"], + "strict": 0, + "sudo_commands": ["ls"], + "allowed_file_extensions": [], + "path": ["/|", ""], + } + policy.update(overrides) + return policy + + +class TestEnginePipeline(unittest.TestCase): + """Core parser/normalizer/authorizer behavior for v2 engine.""" + + def test_parse_and_normalize_extracts_assignment_prefix(self): + """parse+normalize should preserve sequence and assignment prefixes.""" + parsed = engine_parser.parse("A=1 echo ok && ls /tmp") + self.assertFalse(parsed.parse_error) + self.assertEqual(parsed.sequence, ("A=1 echo ok", "&&", "ls /tmp")) + + canonical = normalizer.normalize(parsed) + self.assertFalse(canonical.parse_error) + first = canonical.commands[0] + second = canonical.commands[1] + + self.assertEqual(first.assignments, (("A", "1"),)) + self.assertEqual(first.executable, "echo") + self.assertEqual(first.full_command, "echo ok") + self.assertEqual(second.executable, "ls") + self.assertEqual(second.args, ("/tmp",)) + + def test_authorizer_accepts_exact_full_command_allow_rule(self): + """Full-command allow-list entries should still be honored.""" + decision = authorizer.authorize_line( + "echo only-this", + _policy(allowed=["echo only-this"], strict=1), + mode="policy", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + self.assertEqual(decision.reason.code, reasons.ALLOWED) + + def test_authorizer_unknown_syntax_when_not_strict(self): + """Non-strict unknown command should map to unknown_syntax.""" + decision = authorizer.authorize_line( + "cat /etc/passwd", + _policy(allowed=["echo"], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.UNKNOWN_SYNTAX) + + def test_authorizer_forbidden_command_when_strict(self): + """Strict mode should classify non-allowlisted command as forbidden.""" + decision = authorizer.authorize_line( + "cat /etc/passwd", + _policy(allowed=["echo"], strict=1), + mode="policy", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.FORBIDDEN_COMMAND) + + def test_authorizer_enforces_path_acl(self): + """Path ACL checks should deny paths outside allowed roots.""" + with tempfile.TemporaryDirectory(prefix="lshell-engine-path-") as tmpdir: + allowed_dir = os.path.join(tmpdir, "allowed") + blocked_dir = os.path.join(tmpdir, "blocked") + os.makedirs(allowed_dir) + os.makedirs(blocked_dir) + + decision_allowed = authorizer.authorize_line( + f"ls {allowed_dir}", + _policy(path=[f"{allowed_dir}|", ""]), + mode="policy", + check_current_dir=False, + ) + decision_blocked = authorizer.authorize_line( + f"ls {blocked_dir}", + _policy(path=[f"{allowed_dir}|", ""]), + mode="policy", + check_current_dir=False, + ) + + self.assertTrue(decision_allowed.allowed) + self.assertFalse(decision_blocked.allowed) + self.assertEqual(decision_blocked.reason.code, reasons.FORBIDDEN_PATH) + + def test_quoted_literal_extraction_is_not_greedy(self): + """Quoted-literal extraction should keep each quoted segment separate.""" + literals = authorizer._quoted_literals_without_assignment( + 'echo "a" "b" VAR="skip" \'c\'' + ) + self.assertEqual(literals, ["a", "b", "c"]) + + def test_authorizer_blocks_quoted_executable_path_at_segment_start(self): + """Quoted executable paths should still be path-ACL validated.""" + with tempfile.TemporaryDirectory(prefix="lshell-engine-quoted-cmd-") as tmpdir: + allowed_dir = os.path.join(tmpdir, "allowed") + blocked_dir = os.path.join(tmpdir, "blocked") + os.makedirs(allowed_dir) + os.makedirs(blocked_dir) + + blocked_exec = os.path.join(blocked_dir, "runme") + with open(blocked_exec, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\nexit 0\n") + + decision = authorizer.authorize_line( + f'"{blocked_exec}" arg', + _policy( + allowed=[blocked_exec], + path=[f"{allowed_dir}|", ""], + strict=1, + ), + mode="policy", + check_current_dir=False, + ) + + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.FORBIDDEN_PATH) + + def test_authorizer_blocks_nested_double_quote_in_single_quote(self): + """Nested quotes in single-quoted payloads should still trigger path ACL.""" + with tempfile.TemporaryDirectory(prefix="lshell-engine-nested-quote-") as tmpdir: + allowed_dir = os.path.join(tmpdir, "allowed") + blocked_exec = os.path.join(tmpdir, "blocked", "bash") + os.makedirs(allowed_dir) + os.makedirs(os.path.dirname(blocked_exec)) + + decision = authorizer.authorize_line( + f'awk \'BEGIN {{system("{blocked_exec}")}}\'', + _policy( + allowed=["awk"], + path=[f"{allowed_dir}|", ""], + strict=1, + ), + mode="policy", + check_current_dir=False, + ) + + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.FORBIDDEN_PATH) + + def test_authorizer_parses_command_substitution_with_quoted_parenthesis(self): + """Quoted ')' inside $() should not truncate nested command parsing.""" + decision = authorizer.authorize_line( + "echo $(printf ')')", + _policy(allowed=["echo", "printf"], forbidden=[], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + + def test_authorizer_parses_nested_command_substitutions(self): + """Nested $() expansions should recurse through inner allow-list checks.""" + decision = authorizer.authorize_line( + "echo $(echo $(echo ok))", + _policy(allowed=["echo"], forbidden=[], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + + def test_authorizer_handles_parameter_expansion_with_logical_operators(self): + """${...} operands containing ||/&& should parse as a single expansion body.""" + decision = authorizer.authorize_line( + "echo ${LSHELL_WORD:-a||b&&c}", + _policy(allowed=["echo"], forbidden=[], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + + def test_authorizer_ignores_single_quoted_command_substitution_literal(self): + """Single-quoted $() text should remain literal and not recurse.""" + decision = authorizer.authorize_line( + "echo '$(cat /etc/passwd)'", + _policy(allowed=["echo"], forbidden=[], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + + def test_authorizer_enforces_command_substitution_inside_double_quotes(self): + """Double-quoted $() should still recurse and enforce allow-list.""" + decision = authorizer.authorize_line( + 'echo "$(cat /etc/passwd)"', + _policy(allowed=["echo"], forbidden=[], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.UNKNOWN_SYNTAX) + + def test_authorizer_ignores_escaped_command_substitution_marker(self): + """Escaped '$(' should remain literal and not be parsed as substitution.""" + decision = authorizer.authorize_line( + r"echo \$(cat /etc/passwd)", + _policy(allowed=["echo"], forbidden=[], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertTrue(decision.allowed) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_engine_security_regressions_unit.py b/test/test_engine_security_regressions_unit.py new file mode 100644 index 0000000..a4a51cd --- /dev/null +++ b/test/test_engine_security_regressions_unit.py @@ -0,0 +1,130 @@ +"""Security regression coverage for the canonical engine.""" + +import os +import unittest + +from lshell.config.runtime import CheckConfig +from lshell import utils +from lshell.engine import authorizer +from lshell.engine import reasons + +TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" + + +class DummyLog: + """Minimal logger stub for command execution tests.""" + + def critical(self, _message): + """Discard critical log writes in unit tests.""" + return None + + def error(self, _message): + """Discard error log writes in unit tests.""" + return None + + +class DummyShellContext: + """Minimal shell context consumed by utils.cmd_parse_execute.""" + + def __init__(self, conf): + self.conf = conf + self.log = DummyLog() + + +class TestEngineSecurityRegressions(unittest.TestCase): + """Regression tests for parser smuggling/substitution/path ACL edges.""" + + args = [f"--config={CONFIG}", "--quiet=1"] + + def _policy(self, **overrides): + policy = { + "allowed": ["echo", "cd", "ls", "sudo"], + "forbidden": [], + "strict": 0, + "sudo_commands": ["ls"], + "allowed_file_extensions": [], + "path": ["/|", ""], + } + policy.update(overrides) + return policy + + def test_smuggling_invalid_operator_chain_is_denied(self): + """Malformed operator smuggling should fail closed.""" + decision = authorizer.authorize_line( + "echo ok ||| echo pwn", + self._policy(), + mode="policy", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.UNKNOWN_SYNTAX) + + def test_substitution_denied_when_inner_command_not_allowlisted(self): + """Nested substitution should enforce inner command allow-list.""" + decision = authorizer.authorize_line( + "echo $(cat /etc/passwd)", + self._policy(allowed=["echo"], strict=0), + mode="policy", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.UNKNOWN_SYNTAX) + + def test_braced_substitution_forbidden_token_blocks_line(self): + """Forbidden '${' token should block braced substitutions.""" + decision = authorizer.authorize_line( + "echo ${HOME}", + self._policy(forbidden=["${"], allowed=["echo"]), + mode="policy", + check_current_dir=False, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.reason.code, reasons.FORBIDDEN_CHARACTER) + + def test_path_specific_allow_beats_broader_deny(self): + """Path ACL specificity must preserve /var deny + /var/log allow behavior.""" + policy = self._policy( + path=["/|/var/log|", "/var|"], + allowed=["cd"], + ) + + allowed_decision = authorizer.authorize_line( + "cd /var/log", + policy, + mode="policy", + check_current_dir=False, + ) + denied_decision = authorizer.authorize_line( + "cd /var/tmp", + policy, + mode="policy", + check_current_dir=False, + ) + + self.assertTrue(allowed_decision.allowed) + self.assertFalse(denied_decision.allowed) + self.assertEqual(denied_decision.reason.code, reasons.FORBIDDEN_PATH) + + def test_runtime_blocks_forbidden_env_assignment(self): + """Runtime should keep forbidden env-assignment protection.""" + conf = CheckConfig(self.args + ["--forbidden=[]", "--strict=0"]).returnconf() + shell = DummyShellContext(conf) + + original = os.environ.get("LD_PRELOAD") + try: + retcode = utils.cmd_parse_execute( + "LD_PRELOAD=/tmp/evil.so", + shell_context=shell, + ) + finally: + if original is None: + os.environ.pop("LD_PRELOAD", None) + else: + os.environ["LD_PRELOAD"] = original + + self.assertEqual(retcode, 126) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_env_vars.py b/test/test_env_vars.py index 1502292..979813f 100644 --- a/test/test_env_vars.py +++ b/test/test_env_vars.py @@ -47,7 +47,7 @@ def _run_command_and_get_body(self, child, command): child.expect(PROMPT) return child.before.decode("utf8").split("\n", 1)[1] - def test_22_expand_env_variables(self): + def test_expand_env_variables(self): """F22 | expanding of environment variables""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['export']\"" @@ -63,7 +63,7 @@ def test_22_expand_env_variables(self): self.assertEqual(expected, result) self.do_exit(child) - def test_23_expand_env_variables_cd(self): + def test_expand_env_variables_cd(self): """F23 | expanding of environment variables when using cd""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['export']\"" @@ -81,7 +81,7 @@ def test_23_expand_env_variables_cd(self): self.assertEqual(expected, result) self.do_exit(child) - def test_37_env_vars_file_not_found(self): + def test_env_vars_file_not_found(self): """Test missing environment variable file""" missing_file_path = "/path/to/missing/file" @@ -105,7 +105,7 @@ def test_37_env_vars_file_not_found(self): self.assertIn(expected, child.before.decode("utf8")) self.do_exit(child) - def test_38_load_env_vars_from_file(self): + def test_load_env_vars_from_file(self): """Test loading environment variables from file""" # Create a temporary file to store environment variables @@ -134,7 +134,7 @@ def test_38_load_env_vars_from_file(self): os.remove(temp_env_file_path) self.do_exit(child) - def test_47_backticks(self): + def test_backticks(self): """F47 | Forbidden backticks should be reported""" expected = ( 'lshell: forbidden character: "`"\r\n' @@ -145,7 +145,7 @@ def test_47_backticks(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_48_replace_backticks_with_dollar_parentheses(self): + def test_replace_backticks_with_dollar_parentheses(self): """F48 | Forbidden syntax $(command) should be reported""" expected = ( 'lshell: forbidden character: "$("\r\n' @@ -156,7 +156,7 @@ def test_48_replace_backticks_with_dollar_parentheses(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_49_env_variable_with_dollar_braces(self): + def test_env_variable_with_dollar_braces(self): """F49 | Syntax ${command} should replace with the variable""" child = self._spawn_shell( '--env_vars "{\'foo\':\'OK\'}"', @@ -170,7 +170,7 @@ def test_49_env_variable_with_dollar_braces(self): self.assertEqual(expected, result) self.do_exit(child) - def test_50_single_quotes_do_not_expand_variables(self): + def test_single_quotes_do_not_expand_variables(self): """F50 | Single-quoted variables should not be expanded.""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['export']\"" @@ -185,7 +185,7 @@ def test_50_single_quotes_do_not_expand_variables(self): self.assertEqual("$A", result) self.do_exit(child) - def test_51_inline_assignment_is_command_scoped(self): + def test_inline_assignment_is_command_scoped(self): """F51 | VAR=... cmd should not persist in parent shell.""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['printenv']\"" @@ -203,7 +203,7 @@ def test_51_inline_assignment_is_command_scoped(self): self.assertEqual("", persisted_result) self.do_exit(child) - def test_52_braced_parameter_expansion_matrix_when_braces_are_allowed(self): + def test_braced_parameter_expansion_matrix_when_braces_are_allowed(self): """F52 | ${...} forms should behave like shell parameter expansion when allowed.""" cases = [ ("${LSHELL_SET}", {"LSHELL_SET": "VALUE"}, "VALUE"), @@ -232,7 +232,7 @@ def test_52_braced_parameter_expansion_matrix_when_braces_are_allowed(self): finally: self.do_exit(child) - def test_53_braced_parameter_expansion_matrix_when_braces_are_forbidden(self): + def test_braced_parameter_expansion_matrix_when_braces_are_forbidden(self): """F53 | ${...} forms should be blocked when '${' is forbidden.""" expressions = [ "${LSHELL_SET}", diff --git a/test/test_env_vars_files_unit.py b/test/test_env_vars_files_unit.py index 25e05fa..cf98d57 100644 --- a/test/test_env_vars_files_unit.py +++ b/test/test_env_vars_files_unit.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch, call -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" CONFIG = f"{TOPDIR}/test/testfiles/test.conf" @@ -16,7 +16,7 @@ class TestEnvVarsFilesUnit(unittest.TestCase): args = [f"--config={CONFIG}", "--quiet=1"] - @patch("lshell.checkconfig.builtincmd.cmd_source", return_value=0) + @patch("lshell.config.runtime.builtincmd.cmd_source", return_value=0) def test_checkconfig_calls_cmd_source_for_each_env_file(self, mock_cmd_source): """Load each configured env_vars_files entry through cmd_source.""" files = ["/tmp/a.env", "/tmp/b.env"] diff --git a/test/test_exit.py b/test/test_exit.py index 63a1ea8..d8f84bf 100644 --- a/test/test_exit.py +++ b/test/test_exit.py @@ -29,7 +29,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_30_disable_exit(self): + def test_disable_exit(self): """F31 | test disabled exit command""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} " "--disable_exit 1 ") child.expect(PROMPT) @@ -42,7 +42,7 @@ def test_30_disable_exit(self): self.assertIn(expected, result) - def test_50_warnings_then_kickout(self): + def test_warnings_then_kickout(self): """F50 | kicked out after warning counter""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --strict 1 --warning_counter 0" @@ -50,6 +50,7 @@ def test_50_warnings_then_kickout(self): child.sendline("lslsls") child.sendline("lslsls") child.expect(pexpect.EOF, timeout=10) + child.close() # Assert that the process exited self.assertIsNotNone( @@ -58,4 +59,3 @@ def test_50_warnings_then_kickout(self): # Optionally, you can assert that the exit code is correct self.assertEqual(child.exitstatus, 1, "The process should exit with code 1.") - self.do_exit(child) diff --git a/test/test_extension_parser_unit.py b/test/test_extension_parser_unit.py index 25238f0..86724f5 100644 --- a/test/test_extension_parser_unit.py +++ b/test/test_extension_parser_unit.py @@ -7,7 +7,7 @@ from lshell import sec from lshell import utils -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" CONFIG = f"{TOPDIR}/test/testfiles/test.conf" diff --git a/test/test_file_extension.py b/test/test_file_extension.py index 42c2cfd..9dfab95 100644 --- a/test/test_file_extension.py +++ b/test/test_file_extension.py @@ -2,7 +2,6 @@ import os import unittest -import inspect import tempfile from getpass import getuser import pexpect @@ -23,11 +22,10 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_60_allowed_extension_success(self): + def test_allowed_extension_success(self): """F60 | allow extension and cat file with similar extension""" - f_name = inspect.currentframe().f_code.co_name - log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" + log_file = f"{TOPDIR}/test/testfiles/test_60_allowed_extension_success.log" command = f"cat {log_file}" expected = "Hello world!" @@ -44,7 +42,7 @@ def test_60_allowed_extension_success(self): self.assertEqual(expected, output) self.do_exit(child) - def test_61_allowed_extension_fail(self): + def test_allowed_extension_fail(self): """F61 | allow extension and cat file with different extension""" command = f"cat {CONFIG}" @@ -63,7 +61,7 @@ def test_61_allowed_extension_fail(self): self.assertEqual(expected, output) self.do_exit(child) - def test_62_allowed_extension_empty(self): + def test_allowed_extension_empty(self): """F62 | allow extension empty and cat any file extension""" command = f"cat {CONFIG}" @@ -82,7 +80,7 @@ def test_62_allowed_extension_empty(self): self.assertEqual(expected, output) self.do_exit(child) - def test_63_extensionless_filename_is_forbidden(self): + def test_extensionless_filename_is_forbidden(self): """F63 | extensionless file arguments are rejected when extensions are enforced.""" target = os.path.join(tempfile.gettempdir(), "lshell_extensionless_target") if os.path.exists(target): @@ -105,7 +103,7 @@ def test_63_extensionless_filename_is_forbidden(self): self.assertFalse(os.path.exists(target)) self.do_exit(child) - def test_64_allowed_file_extensions_plus_minus_chain(self): + def test_allowed_file_extensions_plus_minus_chain(self): """F64 | +/- merge on allowed_file_extensions controls warning outcome.""" f_name = "test_60_allowed_extension_success" log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" diff --git a/test/test_hardeninit_unit.py b/test/test_hardeninit_unit.py index bce677e..c3d5dc3 100644 --- a/test/test_hardeninit_unit.py +++ b/test/test_hardeninit_unit.py @@ -149,6 +149,16 @@ def test_main_rejects_invalid_group_name(self): self.assertEqual(code, 1) self.assertIn("invalid group name", stderr.getvalue()) + def test_main_rejects_invalid_user_name(self): + """Invalid user target names are rejected with clear errors.""" + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = hardeninit.main( + ["--profile", "sftp-only", "--user", "bad/name", "--stdout"] + ) + self.assertEqual(code, 1) + self.assertIn("invalid user name", stderr.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/test/test_history_size_unit.py b/test/test_history_size_unit.py index d771fca..2d55276 100644 --- a/test/test_history_size_unit.py +++ b/test/test_history_size_unit.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import mock_open, patch -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.shellcmd import ShellCmd TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" diff --git a/test/test_parser_module_unit.py b/test/test_parser_module_unit.py deleted file mode 100644 index 319484b..0000000 --- a/test/test_parser_module_unit.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Unit tests for lshell.parser module execution paths.""" - -import unittest - -from pyparsing import ParseResults - -from lshell.parser import LshellParser - - -class TestLshellParserModule(unittest.TestCase): - """Cover parser grammar and validation logic outside fuzzing.""" - - def setUp(self): - self.parser = LshellParser() - - def test_parse_accepts_chained_command_with_quotes(self): - """Parser should accept regular shell-like command chains.""" - parsed = self.parser.parse('echo "hello world" && printf ok') - self.assertIsNotNone(parsed) - self.assertTrue(self.parser.validate_command(parsed)) - - def test_parse_rejects_invalid_operator_sequence(self): - """Malformed operator chains should fail parsing cleanly.""" - parsed = self.parser.parse("echo &&&& ls") - self.assertIsNone(parsed) - - def test_clean_input_removes_control_characters(self): - """Control characters should be stripped before grammar parsing.""" - cleaned = self.parser._clean_input("echo\x00ok\x1f\t\n") - self.assertEqual(cleaned, "echook\t\n") - - def test_validate_command_rejects_excessive_token_count(self): - """Validation should reject token lists larger than maximum.""" - parsed = ParseResults([str(index) for index in range(21)]) - self.assertFalse(self.parser.validate_command(parsed)) - - def test_validate_command_rejects_overlong_token(self): - """Validation should reject tokens longer than configured cap.""" - parsed = ParseResults(["x" * 256]) - self.assertFalse(self.parser.validate_command(parsed)) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_path.py b/test/test_path.py index be33993..c6155e9 100644 --- a/test/test_path.py +++ b/test/test_path.py @@ -28,7 +28,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_05_external_echo_forbidden_syntax(self): + def test_external_echo_forbidden_syntax(self): """F05 | echo forbidden syntax $(bleh)""" expected = ( 'lshell: forbidden character: "$("\r\n' @@ -39,7 +39,7 @@ def test_05_external_echo_forbidden_syntax(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_09_external_forbidden_path(self): + def test_external_forbidden_path(self): """F09 | external command forbidden path - ls /root""" expected = ( 'lshell: forbidden path: "/root/"\r\n' @@ -50,7 +50,7 @@ def test_09_external_forbidden_path(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_10_builtin_cd_forbidden_path(self): + def test_builtin_cd_forbidden_path(self): """F10 | built-in command forbidden path - cd ~root""" expected = ( 'lshell: forbidden path: "/root/"\r\n' @@ -61,7 +61,7 @@ def test_10_builtin_cd_forbidden_path(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_11_etc_passwd_1(self): + def test_etc_passwd_1(self): """F11 | /etc/passwd: empty variable 'ls "$a"/etc/passwd'""" expected = ( 'lshell: forbidden path: "/etc/passwd"\r\n' @@ -72,7 +72,7 @@ def test_11_etc_passwd_1(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_12_etc_passwd_2(self): + 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" @@ -82,7 +82,7 @@ def test_12_etc_passwd_2(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_13a_etc_passwd_3(self): + 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") @@ -90,7 +90,7 @@ def test_13a_etc_passwd_3(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_13b_etc_passwd_4(self): + def test_etc_passwd_4(self): """F13(b) | /etc/passwd: empty variable 'ls -l ../../etc/passwd'""" expected = ( 'lshell: forbidden path: "/etc/passwd"\r\n' @@ -101,7 +101,7 @@ def test_13b_etc_passwd_4(self): result = self.child.before.decode("utf8").split("\n", 1)[1] self.assertEqual(expected, result) - def test_21_allow_slash(self): + def test_allow_slash(self): """F21 | user should able to allow / access minus some directory (e.g. /var) """ @@ -119,7 +119,7 @@ def test_21_allow_slash(self): self.assertEqual(expected, result) self.do_exit(child) - def test_22_path_plus_minus_reallow_and_warning_messages(self): + def test_path_plus_minus_reallow_and_warning_messages(self): """F22 | path +/- chain should re-allow child path and keep warning countdown.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} " diff --git a/test/test_policy.py b/test/test_policy.py index 221285f..a639de3 100644 --- a/test/test_policy.py +++ b/test/test_policy.py @@ -9,7 +9,7 @@ from types import SimpleNamespace from unittest.mock import patch -from lshell import policy +from lshell.config import diagnostics as policy from lshell import utils @@ -151,8 +151,23 @@ def test_policy_command_decision_exempts_builtin_ls_from_extension_filter(self): decision = policy.policy_command_decision("ls /tmp", runtime_policy) self.assertTrue(decision["allowed"]) + def test_policy_command_decision_rejects_invalid_operator_sequence(self): + """EX03e | canonical policy path rejects malformed operator chains.""" + runtime_policy = { + "forbidden": [";"], + "allowed": ["echo"], + "strict": 0, + "sudo_commands": [], + "allowed_file_extensions": [], + "path": ["", ""], + } + + decision = policy.policy_command_decision("echo ok ||| echo pwn", runtime_policy) + self.assertFalse(decision["allowed"]) + self.assertIn("unknown syntax", decision["reason"]) + def test_resolve_policy_allowed_all_unquoted_expands(self): - """EX03e | allowed=all (unquoted) should expand successfully.""" + """EX03f | allowed=all (unquoted) should expand successfully.""" with tempfile.TemporaryDirectory() as tempdir: config = self._write_config( tempdir, @@ -265,9 +280,9 @@ def test_grouped_rows_follow_resolution_order(self): ) self.assertEqual([r["key"] for r in grouped["user:bleh"]], ["umask"]) - @patch("lshell.policy.grp.getgrall") - @patch("lshell.policy.grp.getgrgid") - @patch("lshell.policy.pwd.getpwnam") + @patch("lshell.config.diagnostics.grp.getgrall") + @patch("lshell.config.diagnostics.grp.getgrgid") + @patch("lshell.config.diagnostics.pwd.getpwnam") def test_resolve_user_groups_auto_lookup( self, mock_getpwnam, mock_getgrgid, mock_getgrall ): @@ -402,8 +417,8 @@ def test_resolve_policy_rejects_invalid_allowed_schema(self): policy.resolve_policy(config, "bleh", []) self.assertIn("allowed", str(exc.exception)) - def test_builtin_policy_show_dispatches_from_utils(self): - """EX10 | builtin dispatcher calls shell_context.do_policy_show.""" + def test_builtin_lshow_dispatches_from_utils(self): + """EX10 | builtin dispatcher calls shell_context.do_lshow.""" class DummyContext: """Minimal shell context stub for builtin dispatcher tests.""" @@ -413,14 +428,14 @@ def __init__(self): self.conf = {} self.called = None - def do_policy_show(self, arg): + def do_lshow(self, arg): """Record argument and mimic a successful builtin call.""" self.called = arg return 0 ctx = DummyContext() retcode, _ = utils.handle_builtin_command( - "policy-show echo hi", "policy-show", "echo hi", ctx + "lshow echo hi", "lshow", "echo hi", ctx ) self.assertEqual(retcode, 0) self.assertEqual(ctx.called, "echo hi") @@ -438,6 +453,7 @@ def test_print_user_view_includes_containment_settings(self): "timer": 0, "forbidden": [";"], "allowed_file_extensions": [], + "path": ["/tmp/|", ""], "max_sessions_per_user": 2, "max_background_jobs": 3, "command_timeout": 15, @@ -453,6 +469,9 @@ def test_print_user_view_includes_containment_settings(self): self.assertIn("Max background jobs : 3", rendered) self.assertIn("Command timeout (sec) : 15s", rendered) self.assertIn("Max processes : 10", rendered) + self.assertIn("Allowed paths", rendered) + self.assertNotIn("Path Policy", rendered) + self.assertIn("Sudo Policy", rendered) def test_print_user_view_shows_unlimited_for_zero_containment_limits(self): """EX12 | zero-valued containment limits should render as Unlimited.""" @@ -467,6 +486,7 @@ def test_print_user_view_shows_unlimited_for_zero_containment_limits(self): "timer": 0, "forbidden": [";"], "allowed_file_extensions": [], + "path": ["/tmp/|", ""], "max_sessions_per_user": 0, "max_background_jobs": 0, "command_timeout": 0, @@ -483,6 +503,39 @@ def test_print_user_view_shows_unlimited_for_zero_containment_limits(self): self.assertIn("Command timeout (sec) : Unlimited", rendered) self.assertIn("Max processes : Unlimited", rendered) + def test_print_user_view_embeds_allowed_paths_and_sudo_content(self): + """EX13 | lshow output includes allowed paths and sudo policy sections.""" + result = { + "policy": { + "username": "bleh", + "strict": 1, + "warning_counter": 2, + "allowed": ["ls"], + "aliases": {}, + "sudo_commands": ["id", "ls"], + "timer": 0, + "forbidden": [";"], + "allowed_file_extensions": [], + "path": ["/tmp/|", "/etc/|"], + "max_sessions_per_user": 0, + "max_background_jobs": 0, + "command_timeout": 0, + "max_processes": 0, + } + } + + with redirect_stdout(io.StringIO()) as output: + policy.print_user_view(result) + rendered = output.getvalue() + + self.assertNotIn("Path Policy", rendered) + self.assertNotIn("Current directory", rendered) + self.assertIn("Allowed paths", rendered) + self.assertIn("Denied paths", rendered) + self.assertIn("Sudo Policy", rendered) + self.assertNotIn("Allowed sudo :", rendered) + self.assertIn("Allowed via sudo : id, ls", rendered) + if __name__ == "__main__": unittest.main() diff --git a/test/test_policy_functional.py b/test/test_policy_functional.py new file mode 100644 index 0000000..d66efdf --- /dev/null +++ b/test/test_policy_functional.py @@ -0,0 +1,110 @@ +"""Functional tests for `lshell policy-show` CLI behavior.""" + +import json +import os +import subprocess +import tempfile +import textwrap +import unittest +from getpass import getuser + + +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +LSHELL = f"{TOPDIR}/bin/lshell" + + +class TestPolicyShowFunctional(unittest.TestCase): + """Exercise policy-show end-to-end through the top-level CLI.""" + + def _write_config(self, directory, content): + config_path = os.path.join(directory, "lshell.conf") + with open(config_path, "w", encoding="utf-8") as handle: + handle.write(textwrap.dedent(content).strip() + "\n") + return config_path + + def test_policy_show_json_returns_allow_decision(self): + """policy-show should return JSON payload with allow decision details.""" + with tempfile.TemporaryDirectory(prefix="lshell-policy-show-") as tempdir: + config_path = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['ls'] + forbidden : [';'] + warning_counter : 2 + strict : 1 + """, + ) + + result = subprocess.run( + [ + LSHELL, + "policy-show", + "--config", + config_path, + "--user", + getuser(), + "--command", + "ls", + "--json", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["command"], "ls") + self.assertTrue(payload["decision"]["allowed"]) + self.assertIn("allowed by final policy", payload["decision"]["reason"]) + + def test_policy_show_trailing_command_args_return_deny_exit_code(self): + """Trailing command args (after --) should be parsed and denied with code 2.""" + with tempfile.TemporaryDirectory(prefix="lshell-policy-show-") as tempdir: + config_path = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['ls'] + forbidden : [';'] + warning_counter : 2 + strict : 0 + """, + ) + + result = subprocess.run( + [ + LSHELL, + "policy-show", + "--config", + config_path, + "--user", + getuser(), + "--json", + "--", + "echo", + "blocked", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 2, msg=result.stdout + result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["command"], "echo blocked") + self.assertFalse(payload["decision"]["allowed"]) + self.assertIn("unknown syntax", payload["decision"]["reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_policy_merge_parity_unit.py b/test/test_policy_merge_parity_unit.py new file mode 100644 index 0000000..47ba31f --- /dev/null +++ b/test/test_policy_merge_parity_unit.py @@ -0,0 +1,198 @@ +"""Regression tests ensuring runtime and diagnostics share merge behavior.""" + +import io +import os +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +from lshell.config.runtime import CheckConfig +from lshell.config import diagnostics as policy + + +class TestPolicyMergeParity(unittest.TestCase): + """Validate parity between runtime config merge and policy diagnostics.""" + + 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 _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), + ): + return CheckConfig( + [f"--config={configfile}", "--quiet=1"], + refresh=True, + stdin=io.StringIO(), + stdout=io.StringIO(), + stderr=io.StringIO(), + ) + + def assert_runtime_policy_parity(self, runtime_conf, policy_conf): + """Compare stable effective policy fields across both code paths.""" + self.assertEqual(runtime_conf["warning_counter"], policy_conf["warning_counter"]) + self.assertEqual(runtime_conf["strict"], policy_conf["strict"]) + self.assertEqual(runtime_conf["path"], policy_conf["path"]) + self.assertEqual( + set(runtime_conf["allowed_file_extensions"]), + set(policy_conf["allowed_file_extensions"]), + ) + self.assertEqual( + set(runtime_conf["allowed_shell_escape"]), + set(policy_conf["allowed_shell_escape"]), + ) + self.assertEqual(set(runtime_conf["forbidden"]), set(policy_conf["forbidden"])) + self.assertEqual(set(runtime_conf["overssh"]), set(policy_conf["overssh"])) + self.assertEqual(set(runtime_conf["allowed"]), set(policy_conf["allowed"])) + + def test_parity_user_group_default_precedence_with_include_overlay(self): + """PAR01 | Runtime and policy-show resolve same precedence and include overlays.""" + with tempfile.TemporaryDirectory() as tempdir: + include_dir = os.path.join(tempdir, "lshell.d") + os.makedirs(include_dir, exist_ok=True) + + allow_main = os.path.join(tempdir, "allow-main") + allow_blocked = os.path.join(tempdir, "allow-blocked") + allow_extra = os.path.join(tempdir, "allow-extra") + for directory in [allow_main, allow_blocked, allow_extra]: + os.makedirs(directory, exist_ok=True) + + configfile = self._write_config( + tempdir, + f""" + [global] + logpath : /tmp + loglevel : 0 + include_dir : {include_dir}/conf. + + [default] + allowed : ['base'] + allowed_shell_escape : ['ase_base'] + allowed_file_extensions : ['.log'] + forbidden : [';'] + overssh : ['scp'] + path : ['{tempdir}/allow-*'] + warning_counter : 2 + strict : 1 + """, + ) + + self._write_config( + include_dir, + """ + [default] + allowed : ['base'] + ['include_default'] + forbidden : [';'] + ['#'] + """, + filename="conf.10-default", + ) + self._write_config( + include_dir, + f""" + [grp:alpha] + allowed : + ['group_alpha'] + allowed_shell_escape : + ['ase_group'] - ['ase_base'] + overssh : + ['rsync'] + forbidden : + ['|'] + path : - ['{allow_blocked}'] + """, + filename="conf.20-group", + ) + self._write_config( + include_dir, + f""" + [alice] + allowed : + ['user_only'] - ['base'] + allowed_file_extensions : + ['.txt'] - ['.log'] + path : + ['{allow_extra}'] + """, + filename="conf.30-user", + ) + + runtime = self._runtime_checkconfig( + configfile=configfile, + username="alice", + group_ids=[100, 200], + gid_to_group={100: "alpha", 200: "beta"}, + ) + result = policy.resolve_policy(configfile, "alice", ["alpha", "beta"]) + + self.assert_runtime_policy_parity(runtime.returnconf(), result["policy"]) + self.assertIn("include_default", result["policy"]["allowed"]) + self.assertIn("group_alpha", result["policy"]["allowed"]) + self.assertIn("user_only", result["policy"]["allowed"]) + self.assertNotIn("base", result["policy"]["allowed"]) + self.assertIn(f"{os.path.realpath(allow_blocked)}/|", result["policy"]["path"][1]) + + def test_parity_allowed_all_minus_list(self): + """PAR02 | allowed='all' with minus operation resolves identically.""" + with tempfile.TemporaryDirectory() as tempdir: + configfile = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : all - ['echo'] + forbidden : [';'] + warning_counter : 2 + """, + ) + + runtime = self._runtime_checkconfig( + configfile=configfile, + username="alice", + group_ids=[], + gid_to_group={}, + ) + result = policy.resolve_policy(configfile, "alice", []) + + self.assert_runtime_policy_parity(runtime.returnconf(), result["policy"]) + self.assertIn("ls", result["policy"]["allowed"]) + self.assertNotIn("echo", result["policy"]["allowed"]) + + def test_invalid_schema_type_error_behavior_preserved(self): + """PAR03 | Runtime exits on schema failure while diagnostics raises ValueError.""" + with tempfile.TemporaryDirectory() as tempdir: + configfile = self._write_config( + tempdir, + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : 1 + forbidden : [';'] + warning_counter : 2 + """, + ) + + with self.assertRaises(SystemExit): + self._runtime_checkconfig( + configfile=configfile, + username="alice", + group_ids=[], + gid_to_group={}, + ) + + with self.assertRaises(ValueError) as exc: + policy.resolve_policy(configfile, "alice", []) + self.assertIn("allowed", str(exc.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_prompt_unit.py b/test/test_prompt_unit.py index 888fdf9..dc343e0 100644 --- a/test/test_prompt_unit.py +++ b/test/test_prompt_unit.py @@ -5,7 +5,7 @@ from getpass import getuser from unittest.mock import patch -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.utils import getpromptbase, updateprompt TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" diff --git a/test/test_ps2.py b/test/test_ps2.py index ec4ffec..da6cffb 100644 --- a/test/test_ps2.py +++ b/test/test_ps2.py @@ -20,11 +20,7 @@ "help", "history", "jobs", - "lpath", - "lsudo", - "policy-path", - "policy-show", - "policy-sudo", + "lshow", "source", ] @@ -37,7 +33,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_63_multi_line_command(self): + def test_multi_line_command(self): """F63 | Test multi-line command execution using line continuation""" # Start the shell process with lshell config @@ -58,7 +54,7 @@ def test_63_multi_line_command(self): # Send an exit command to end the shell session self.do_exit(child) - def test_64_multi_line_command_with_two_echos(self): + def test_multi_line_command_with_two_echos(self): """F64 | Test multi-line command execution with two echo commands""" # Start the shell process with lshell config @@ -79,7 +75,7 @@ def test_64_multi_line_command_with_two_echos(self): # Send an exit command to end the shell session self.do_exit(child) - def test_65_multi_line_command_security_echo(self): + def test_multi_line_command_security_echo(self): """F65 | test help, then echo FREEDOM! && help () sh && help""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} --forbidden \"-[';','&']\"" @@ -107,7 +103,7 @@ def test_65_multi_line_command_security_echo(self): self.assertIn(command, result) self.do_exit(child) - def test_66_multi_line_command_ctrl_c(self): + def test_multi_line_command_ctrl_c(self): """F66 | Test multi-line command then ctrl-c to cancel""" # Start the shell process with lshell config @@ -129,7 +125,7 @@ def test_66_multi_line_command_ctrl_c(self): # Send an exit command to end the shell session self.do_exit(child) - def test_67_unclosed_quotes_traceback(self): + def test_unclosed_quotes_traceback(self): """F67 | Test that unclsed quotes do not cause a traceback""" # Start the shell process with lshell config diff --git a/test/test_regex.py b/test/test_regex.py index 8245efd..8d6bba0 100644 --- a/test/test_regex.py +++ b/test/test_regex.py @@ -2,7 +2,6 @@ import os import unittest -import inspect from getpass import getuser import pexpect @@ -21,13 +20,12 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_51_grep_valid_log_entry(self): + def test_grep_valid_log_entry(self): """F51 | Test that grep matches a valid log entry format.""" pattern = ( r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" ) - f_name = inspect.currentframe().f_code.co_name - log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" + log_file = f"{TOPDIR}/test/testfiles/test_51_grep_valid_log_entry.log" command = f"grep -P '{pattern}' {log_file}" child = pexpect.spawn( @@ -44,13 +42,12 @@ def test_51_grep_valid_log_entry(self): self.assertIn("user123", output) self.do_exit(child) - def test_52_grep_invalid_date_format(self): + def test_grep_invalid_date_format(self): """F52 | Test that grep matches a valid log entry format.""" pattern = ( r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" ) - f_name = inspect.currentframe().f_code.co_name - log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" + log_file = f"{TOPDIR}/test/testfiles/test_52_grep_invalid_date_format.log" command = f"grep -P '{pattern}' {log_file}" @@ -65,13 +62,12 @@ def test_52_grep_invalid_date_format(self): self.assertNotIn("user123", output) self.do_exit(child) - def test_53_grep_missing_uid(self): + def test_grep_missing_uid(self): """F53 | Test that grep matches a valid log entry format.""" pattern = ( r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" ) - f_name = inspect.currentframe().f_code.co_name - log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" + log_file = f"{TOPDIR}/test/testfiles/test_53_grep_missing_uid.log" command = f"grep -P '{pattern}' {log_file}" @@ -86,13 +82,14 @@ def test_53_grep_missing_uid(self): self.assertNotIn("user123", output) self.do_exit(child) - def test_54_grep_special_characters_in_uid(self): + def test_grep_special_characters_in_uid(self): """F54 | Test that grep matches a valid log entry format.""" pattern = ( r"\[\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+(?:-|\+)\d{4}\].+UID=[\w.]+" ) - f_name = inspect.currentframe().f_code.co_name - log_file = f"{TOPDIR}/test/testfiles/{f_name}.log" + log_file = ( + f"{TOPDIR}/test/testfiles/test_54_grep_special_characters_in_uid.log" + ) command = f"grep -P '{pattern}' {log_file}" child = pexpect.spawn( diff --git a/test/test_scripts.py b/test/test_scripts.py index 96e4dfd..b654524 100644 --- a/test/test_scripts.py +++ b/test/test_scripts.py @@ -23,11 +23,7 @@ "help", "history", "jobs", - "lpath", - "lsudo", - "policy-path", - "policy-show", - "policy-sudo", + "lshow", "source", ] @@ -48,7 +44,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_39_script_execution_with_template(self): + def test_script_execution_with_template(self): """Test executing script after modifying shebang and clean up afterward""" template_path = f"{TOPDIR}/test/template.lsh" @@ -102,7 +98,7 @@ def test_39_script_execution_with_template(self): os.remove(test_script_path) self.do_exit(child) - def test_40_script_execution_with_template_strict(self): + def test_script_execution_with_template_strict(self): """Test executing script after modifying shebang and clean up afterward""" template_path = f"{TOPDIR}/test/template.lsh" diff --git a/test/test_security.py b/test/test_security.py index 8eafa5e..dedb1fe 100644 --- a/test/test_security.py +++ b/test/test_security.py @@ -20,11 +20,7 @@ "help", "history", "jobs", - "lpath", - "lsudo", - "policy-path", - "policy-show", - "policy-sudo", + "lshow", "source", ] @@ -45,7 +41,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_31_security_echo_freedom_and_help(self): + def test_security_echo_freedom_and_help(self): """F31 | test help, then echo FREEDOM! && help () sh && help""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} --forbidden \"-[';','&']\" " @@ -70,7 +66,7 @@ def test_31_security_echo_freedom_and_help(self): self.assertIn(command, result) self.do_exit(child) - def test_32_security_echo_freedom_and_cd(self): + def test_security_echo_freedom_and_cd(self): """F32 | test echo FREEDOM! && cd () bash && cd ~/""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} --forbidden \"-[';','&']\" " @@ -95,7 +91,7 @@ def test_32_security_echo_freedom_and_cd(self): self.assertEqual(expected_output, result) self.do_exit(child) - def test_27_checksecure_awk(self): + def test_checksecure_awk(self): """F27 | checksecure awk script with /bin/sh""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['awk']\"" diff --git a/test/test_security_attack_surface_unit.py b/test/test_security_attack_surface_unit.py index faf1838..169d0f0 100644 --- a/test/test_security_attack_surface_unit.py +++ b/test/test_security_attack_surface_unit.py @@ -12,7 +12,7 @@ from contextlib import redirect_stderr from unittest.mock import patch -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.shellcmd import ShellCmd from lshell import sec from lshell import utils @@ -345,9 +345,9 @@ def test_cmd_parse_execute_malformed_operator_decrements_counter_in_strict_mode( self.assertEqual(conf["warning_counter"], starting_counter - 1) self.assertIn("lshell: warning:", stderr.getvalue()) - @patch("lshell.utils.sec.check_forbidden_chars") - @patch("lshell.utils.sec.check_secure") - @patch("lshell.utils.sec.check_path") + @patch("lshell.sec.check_forbidden_chars") + @patch("lshell.sec.check_secure") + @patch("lshell.sec.check_path") @patch("lshell.utils.exec_cmd") def test_cmd_parse_execute_short_circuit_skips_failed_and_branch( self, mock_exec, mock_path, mock_secure, mock_forbidden @@ -384,9 +384,9 @@ def exec_side_effect(command, background=False, extra_env=None, **_kwargs): executed = [call.args[0] for call in mock_exec.call_args_list] self.assertEqual(executed, ["false", "echo recovered"]) - @patch("lshell.utils.sec.check_forbidden_chars") - @patch("lshell.utils.sec.check_secure") - @patch("lshell.utils.sec.check_path") + @patch("lshell.sec.check_forbidden_chars") + @patch("lshell.sec.check_secure") + @patch("lshell.sec.check_path") @patch("lshell.utils.exec_cmd") def test_cmd_parse_execute_assignment_only_updates_parent_env_without_exec( self, mock_exec, mock_path, mock_secure, mock_forbidden @@ -413,9 +413,9 @@ def test_cmd_parse_execute_assignment_only_updates_parent_env_without_exec( else: os.environ["LSHELL_ATTACK_SURFACE"] = original - @patch("lshell.utils.sec.check_forbidden_chars") - @patch("lshell.utils.sec.check_secure") - @patch("lshell.utils.sec.check_path") + @patch("lshell.sec.check_forbidden_chars") + @patch("lshell.sec.check_secure") + @patch("lshell.sec.check_path") @patch("lshell.utils.exec_cmd") def test_cmd_parse_execute_allowed_shell_escape_skips_ld_preload( self, mock_exec, mock_path, mock_secure, mock_forbidden diff --git a/test/test_security_attack_surface_unit_part2.py b/test/test_security_attack_surface_unit_part2.py index 8bc8c7f..26ef7ff 100644 --- a/test/test_security_attack_surface_unit_part2.py +++ b/test/test_security_attack_surface_unit_part2.py @@ -9,10 +9,11 @@ import os import tempfile import unittest -from contextlib import redirect_stderr +from contextlib import redirect_stderr, redirect_stdout from unittest.mock import patch -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig +from lshell import builtincmd from lshell import sec from lshell.shellcmd import ShellCmd from lshell import utils @@ -169,7 +170,7 @@ def test_cmd_parse_execute_should_block_forbidden_env_assignment_via_assignment_ else: os.environ["LD_PRELOAD"] = original - @patch("lshell.utils.sec.check_forbidden_chars") + @patch("lshell.sec.check_forbidden_chars") @patch("lshell.utils.exec_cmd") def test_cmd_parse_execute_forbidden_chars_short_circuits_execution( self, mock_exec, mock_forbidden @@ -344,6 +345,102 @@ def test_check_secure_rejects_command_substitution_when_inner_command_is_not_all ).returnconf() self.assertEqual(sec.check_secure("echo $(printf ok)", conf)[0], 1) + def test_check_secure_allows_command_substitution_with_quoted_parenthesis(self): + """Quoted ')' inside $() should not truncate nested command parsing.""" + conf = CheckConfig( + self.args + + [ + "--allowed=['echo','printf']", + "--forbidden=[]", + "--strict=0", + ] + ).returnconf() + self.assertEqual(sec.check_secure("echo $(printf ')')", conf)[0], 0) + + def test_check_secure_handles_nested_command_substitutions(self): + """Nested $() constructs should recurse correctly through allow-list checks.""" + conf = CheckConfig( + self.args + + [ + "--allowed=['echo']", + "--forbidden=[]", + "--strict=0", + ] + ).returnconf() + self.assertEqual(sec.check_secure("echo $(echo $(echo ok))", conf)[0], 0) + + def test_check_secure_handles_parameter_expansion_with_logical_operators(self): + """${VAR:-a||b&&c} should parse as one expansion body without false splits.""" + conf = CheckConfig( + self.args + + [ + "--allowed=['echo']", + "--forbidden=[]", + "--strict=0", + ] + ).returnconf() + self.assertEqual(sec.check_secure("echo ${LSHELL_WORD:-a||b&&c}", conf)[0], 0) + + def test_check_secure_blocks_single_operators_at_command_boundaries(self): + """Single '&' and '|' must be blocked even at start/end boundaries.""" + def _new_conf(): + return CheckConfig( + self.args + + [ + "--allowed=['echo']", + "--forbidden=['&','|']", + "--strict=0", + ] + ).returnconf() + + self.assertEqual(sec.check_secure("echo ok &", _new_conf())[0], 1) + self.assertEqual(sec.check_secure("& echo ok", _new_conf())[0], 1) + self.assertEqual(sec.check_secure("| echo ok", _new_conf())[0], 1) + + def test_check_forbidden_chars_allows_double_pipe_when_single_pipe_forbidden(self): + """Permit || when only single | is forbidden by policy.""" + conf = CheckConfig(self.args + ["--forbidden=['|']", "--strict=0"]).returnconf() + ret, _conf = sec.check_forbidden_chars("echo ok || echo still_ok", conf) + self.assertEqual(ret, 0) + + def test_check_secure_ignores_single_quoted_substitution_literals(self): + """Single-quoted substitution text should not trigger recursive security checks.""" + conf = CheckConfig( + self.args + ["--allowed=['echo']", "--forbidden=[]", "--strict=0"] + ).returnconf() + self.assertEqual(sec.check_secure("echo '$(cat /etc/passwd)'", conf)[0], 0) + self.assertEqual(sec.check_secure("echo '`cat /etc/passwd`'", conf)[0], 0) + + def test_check_secure_enforces_substitution_inside_double_quotes(self): + """Double-quoted substitutions should still be parsed and validated.""" + conf = CheckConfig( + self.args + ["--allowed=['echo']", "--forbidden=[]", "--strict=0"] + ).returnconf() + self.assertEqual(sec.check_secure('echo "$(cat /etc/passwd)"', conf)[0], 1) + self.assertEqual(sec.check_secure('echo "`cat /etc/passwd`"', conf)[0], 1) + + def test_check_secure_ignores_escaped_substitution_markers(self): + """Escaped expansion markers should remain literal.""" + conf = CheckConfig( + self.args + ["--allowed=['echo']", "--forbidden=[]", "--strict=0"] + ).returnconf() + self.assertEqual(sec.check_secure(r"echo \$(cat /etc/passwd)", conf)[0], 0) + self.assertEqual(sec.check_secure(r"echo \${HOME}", conf)[0], 0) + self.assertEqual(sec.check_secure(r"echo \`cat /etc/passwd\`", conf)[0], 0) + + def test_scan_shell_expansions_mixed_order_respects_escaping(self): + """Scanner should parse real expansions in-order and skip escaped/literal forms.""" + line = r"echo '$(skip)' \$(skip) `echo ok` ${A:-x} $(printf done)" + expansions = sec._scan_shell_expansions(line) + self.assertEqual( + expansions, + [ + sec._ShellExpansion("backtick", "echo ok"), + sec._ShellExpansion("parameter_expansion", "A:-x"), + sec._ShellExpansion("command_substitution", "printf done"), + ], + ) + @patch("lshell.utils.exec_cmd", return_value=0) def test_cmd_parse_execute_passes_parameter_expansion_forms_when_config_allows_them( self, mock_exec @@ -371,6 +468,31 @@ def test_cmd_parse_execute_passes_parameter_expansion_forms_when_config_allows_t "echo ${LSHELL_MISSING:-fallback} ${#HOME}", ) + def test_cmd_lpath_handles_paths_with_regex_metacharacters(self): + """Path policy output should use canonical ACL checks, not regex matching.""" + previous_cwd = os.getcwd() + try: + with tempfile.TemporaryDirectory(prefix="lshell+lpath-", dir="/tmp") as tmpdir: + conf = CheckConfig( + self.args + + [ + f"--path=['{tmpdir}']", + "--strict=0", + ] + ).returnconf() + + os.chdir(tmpdir) + output = io.StringIO() + with redirect_stdout(output): + builtincmd.cmd_lpath(conf) + + self.assertIn( + f"Current directory : {os.path.realpath(tmpdir)} (allowed)", + output.getvalue(), + ) + finally: + os.chdir(previous_cwd) + def test_check_path_should_expand_brace_operands_like_shell(self): """Expected shell parity: brace-expanded path operands should all be validated.""" with tempfile.TemporaryDirectory(prefix="lshell-brace-path-", dir="/tmp") as tmpdir: diff --git a/test/test_security_property_based_unit.py b/test/test_security_property_based_unit.py index 3bab35a..07dc64b 100644 --- a/test/test_security_property_based_unit.py +++ b/test/test_security_property_based_unit.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from lshell import policy +from lshell.config import diagnostics as policy from lshell import sec from lshell import utils diff --git a/test/test_session_interaction_functional.py b/test/test_session_interaction_functional.py new file mode 100644 index 0000000..3da3ec2 --- /dev/null +++ b/test/test_session_interaction_functional.py @@ -0,0 +1,424 @@ +"""Functional interaction regression tests for user-visible shell behavior.""" + +import os +import re +import tempfile +import textwrap +import time +import unittest +from getpass import getuser + +import pexpect + + +TOPDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +CONFIG = f"{TOPDIR}/test/testfiles/test.conf" +LSHELL = f"{TOPDIR}/bin/lshell" +USER = getuser() +PROMPT = f"{USER}:~\\$" + + +class TestSessionInteractionFunctional(unittest.TestCase): + """Cover session lifecycle and user-facing interaction edge cases.""" + + def _clean_env(self, extra=None): + """Return a sanitized environment for deterministic subprocess behavior.""" + env = os.environ.copy() + env.pop("LSHELL_ARGS", None) + env.pop("LPS1", None) + if extra: + env.update(extra) + return env + + def _spawn_shell(self, extra_args="", env=None, timeout=10, prompt=PROMPT): + command = f"{LSHELL} --config {CONFIG} {extra_args}".strip() + child = pexpect.spawn( + command, + encoding="utf-8", + timeout=timeout, + env=self._clean_env(env), + ) + child.expect(prompt) + return child + + def _run_command(self, child, command, prompt=PROMPT): + child.sendline(command) + child.expect(prompt) + return child.before.split("\n", 1)[1] + + def _safe_exit(self, child): + if not child.isalive(): + return + child.sendline("exit") + try: + child.expect(pexpect.EOF, timeout=3) + except pexpect.TIMEOUT: + child.close(force=True) + + def _last_non_empty_line(self, text): + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[-1] if lines else "" + + def test_login_script_runs_before_first_prompt(self): + """Startup login_script should execute before the first interactive prompt.""" + with tempfile.TemporaryDirectory(prefix="lshell-login-script-") as tempdir: + config_path = os.path.join(tempdir, "lshell.conf") + with open(config_path, "w", encoding="utf-8") as handle: + handle.write( + textwrap.dedent( + """ + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : ['echo'] + forbidden : [] + warning_counter : 2 + strict : 0 + login_script : 'echo LOGIN_SCRIPT_RAN' + """ + ).strip() + + "\n" + ) + + child = pexpect.spawn( + f"{LSHELL} --config {config_path}", + encoding="utf-8", + timeout=10, + env=self._clean_env(), + ) + try: + child.expect(PROMPT) + startup = child.before + self.assertIn("LOGIN_SCRIPT_RAN", startup) + child.sendline("exit") + child.expect(pexpect.EOF) + finally: + child.close(force=True) + + def test_quit_exits_session(self): + """`quit` should terminate the session like `exit`.""" + child = self._spawn_shell() + try: + child.sendline("quit") + child.expect(pexpect.EOF) + finally: + child.close(force=True) + + def test_ctrl_d_exits_session_without_stopped_jobs(self): + """Ctrl-D on an idle prompt should exit the shell.""" + child = self._spawn_shell() + try: + child.sendeof() + child.expect(pexpect.EOF) + finally: + child.close(force=True) + + def test_disable_exit_blocks_quit_and_ctrl_d(self): + """disable_exit should keep session alive for both quit and Ctrl-D.""" + child = self._spawn_shell("--disable_exit 1") + try: + child.sendline("quit") + child.expect(PROMPT) + + child.sendeof() + child.expect(PROMPT) + + output = self._run_command(child, "echo STILL_HERE") + self.assertIn("STILL_HERE", output) + finally: + child.close(force=True) + + def test_timer_expiry_prints_message_and_ends_session(self): + """Timer expiry should end session with the user-facing timeout message.""" + child = self._spawn_shell("--timer 1", timeout=12) + try: + child.expect("Time is up\\.", timeout=8) + child.expect(pexpect.EOF, timeout=5) + finally: + child.close(force=True) + + def test_unknown_command_user_message_differs_by_strict_mode(self): + """Unknown-command output should differ between strict and non-strict modes.""" + non_strict = self._spawn_shell("--strict 0 --warning_counter 2 --quiet 0") + try: + non_strict_output = self._run_command(non_strict, "id") + self.assertIn("lshell: unknown syntax: id", non_strict_output) + self.assertNotIn("lshell: warning:", non_strict_output) + finally: + self._safe_exit(non_strict) + non_strict.close(force=True) + + strict = self._spawn_shell("--strict 1 --warning_counter 2 --quiet 0") + try: + strict_output = self._run_command(strict, "id") + self.assertIn('lshell: forbidden command: "id"', strict_output) + self.assertIn("lshell: warning: 1 violation remaining", strict_output) + finally: + self._safe_exit(strict) + strict.close(force=True) + + def test_bg_builtin_reports_not_supported(self): + """`bg` should report explicit unsupported status to the user.""" + child = self._spawn_shell() + try: + output = self._run_command(child, "bg") + self.assertIn("lshell: bg not supported", output) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_lshow_allowed_command_sets_shell_visible_success(self): + """Allowed `lshow ` decision should leave a success exit status.""" + child = self._spawn_shell('--forbidden "[]" --strict 0') + try: + allow_output = self._run_command(child, "lshow echo HELLO") + self.assertIn("Command : echo HELLO", allow_output) + self.assertIn("Decision :", allow_output) + self.assertIn("ALLOW", allow_output) + + allow_status = self._run_command(child, "echo $?") + self.assertEqual(self._last_non_empty_line(allow_status), "0") + finally: + self._safe_exit(child) + child.close(force=True) + + def test_lshow_denied_command_prints_decision_and_ends_session(self): + """Denied `lshow ` should print decision and terminate session.""" + child = self._spawn_shell('--forbidden "[]" --strict 0') + try: + child.sendline("lshow id") + child.expect(pexpect.EOF) + output = child.before + self.assertIn("Command : id", output) + self.assertIn("Decision :", output) + self.assertIn("DENY", output) + finally: + child.close(force=True) + + def test_forbidden_sudo_subcommand_shows_policy_denial(self): + """Unauthorized sudo subcommand should be denied with user-visible warning text.""" + child = self._spawn_shell( + "--allowed \"['sudo']\" " + "--sudo_commands \"['ls']\" " + "--forbidden \"[]\" " + "--strict 1 --warning_counter 2 --quiet 0" + ) + try: + output = self._run_command(child, "sudo cat /etc/passwd") + self.assertIn( + 'lshell: forbidden sudo command: "sudo cat /etc/passwd"', + output, + ) + self.assertIn("lshell: warning: 1 violation remaining", output) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_alias_expansion_smuggling_is_blocked_and_session_recovers(self): + """Alias expansion should not bypass forbidden-operator enforcement.""" + child = self._spawn_shell( + "--strict 1 --warning_counter 5 --quiet 0 " + "--aliases \"{'safe':'echo SAFE; id'}\"" + ) + try: + attack_output = self._run_command(child, "safe") + self.assertIn('lshell: forbidden character: ";"', attack_output) + self.assertNotIn("uid=", attack_output) + + post_attack = self._run_command(child, "echo AFTER_ALIAS_BLOCK") + self.assertIn("AFTER_ALIAS_BLOCK", post_attack) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_forbidden_environment_assignments_ld_family_and_tmpdir(self): + """Dangerous env assignment prefixes should be blocked and shell should stay usable.""" + child = self._spawn_shell( + "--strict 1 --warning_counter 5 --quiet 0 " + "--forbidden \"[]\" --allowed \"+['printenv','echo']\"" + ) + try: + for var_name in ("LD_PRELOAD", "LD_LIBRARY_PATH", "TMPDIR"): + with self.subTest(var_name=var_name): + output = self._run_command( + child, + f"{var_name}=/tmp printenv {var_name}", + ) + self.assertIn( + f"lshell: forbidden environment variable: {var_name}", + output, + ) + lines = [line.strip() for line in output.splitlines() if line.strip()] + self.assertNotIn("/tmp", lines) + + post_attack = self._run_command(child, "echo ENV_GUARD_OK") + self.assertIn("ENV_GUARD_OK", post_attack) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_assignment_only_command_persists_in_live_session(self): + """Assignment-only input should update interactive shell env and chained command view.""" + child = self._spawn_shell('--strict 1 --forbidden "[]" --allowed "+[\'echo\']"') + try: + assignment_output = self._run_command(child, "LSHELL_INTERACTIVE_ASSIGN=LIVE") + self.assertEqual(self._last_non_empty_line(assignment_output), "") + + value_output = self._run_command(child, "echo $LSHELL_INTERACTIVE_ASSIGN") + self.assertEqual(self._last_non_empty_line(value_output), "LIVE") + + chained_output = self._run_command( + child, + "LSHELL_CHAIN_ASSIGN=CHAINED && echo $LSHELL_CHAIN_ASSIGN", + ) + self.assertEqual(self._last_non_empty_line(chained_output), "CHAINED") + finally: + self._safe_exit(child) + child.close(force=True) + + def test_config_reload_applies_new_policy_mid_session(self): + """Config mtime change should reload policy and affect next user command.""" + with tempfile.TemporaryDirectory(prefix="lshell-config-reload-") as tempdir: + config_path = os.path.join(tempdir, "lshell.conf") + + def write_config(allowed): + with open(config_path, "w", encoding="utf-8") as handle: + handle.write( + textwrap.dedent( + f""" + [global] + logpath : /tmp + loglevel : 0 + + [default] + allowed : {allowed} + forbidden : [] + warning_counter : 5 + strict : 1 + """ + ).strip() + + "\n" + ) + + write_config("['echo']") + child = pexpect.spawn( + f"{LSHELL} --config {config_path}", + encoding="utf-8", + timeout=10, + env=self._clean_env(), + ) + try: + child.expect(PROMPT) + + first_attempt = self._run_command(child, "id") + self.assertIn('lshell: forbidden command: "id"', first_attempt) + + # Config reload check uses mtime comparison; ensure a visible timestamp bump. + time.sleep(1.1) + write_config("['echo','id']") + os.utime(config_path, None) + + second_attempt = self._run_command(child, "id") + self.assertIn("uid=", second_attempt) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_leading_trailing_operator_sequences_fail_closed(self): + """Leading/trailing operators should not execute payloads and should show syntax denial.""" + child = self._spawn_shell('--strict 0 --forbidden "[]" --allowed "+[\'echo\']"') + try: + leading = self._run_command(child, "|| echo PAYLOAD") + self.assertIn("lshell: unknown syntax:", leading) + leading_lines = [line.strip() for line in leading.splitlines() if line.strip()] + self.assertNotIn("PAYLOAD", leading_lines) + + trailing = self._run_command(child, "echo SAFE &&") + self.assertIn("lshell: unknown syntax:", trailing) + + post_probe = self._run_command(child, "echo AFTER_OPERATOR_PROBE") + self.assertIn("AFTER_OPERATOR_PROBE", post_probe) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_allowed_cmd_path_resolves_and_allows_executable(self): + """allowed_cmd_path should expose discovered binaries as runnable allowed commands.""" + with tempfile.TemporaryDirectory(prefix="lshell-allowed-cmd-path-") as bindir: + command_name = "lshell_allowed_cmd_probe" + script_path = os.path.join(bindir, command_name) + with open(script_path, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\necho ALLOWED_CMD_PATH_OK\n") + os.chmod(script_path, 0o700) + + child = self._spawn_shell( + f'--forbidden "[]" --allowed "[]" --allowed_cmd_path "[\'{bindir}\']"' + ) + try: + output = self._run_command(child, command_name) + self.assertIn("ALLOWED_CMD_PATH_OK", output) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_env_path_resolves_allowed_command_binary(self): + """env_path should extend PATH for allowed command lookup in live session.""" + with tempfile.TemporaryDirectory(prefix="lshell-env-path-") as bindir: + command_name = "lshell_env_path_probe" + script_path = os.path.join(bindir, command_name) + with open(script_path, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\necho ENV_PATH_OK\n") + os.chmod(script_path, 0o700) + + child = self._spawn_shell( + f'--forbidden "[]" --allowed "[\'{command_name}\']" --env_path {bindir}' + ) + try: + output = self._run_command(child, command_name) + self.assertIn("ENV_PATH_OK", output) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_malformed_sudo_dash_u_is_denied_and_session_recovers(self): + """Malformed `sudo -u` forms should be denied without killing the session.""" + child = self._spawn_shell( + "--allowed \"['sudo','echo']\" " + "--sudo_commands \"['ls']\" " + "--forbidden \"[]\" " + "--strict 1 --warning_counter 5 --quiet 0" + ) + try: + for malformed in ("sudo -u", "sudo -u root"): + with self.subTest(malformed=malformed): + output = self._run_command(child, malformed) + self.assertIn(f'lshell: forbidden sudo command: "{malformed}"', output) + + post_probe = self._run_command(child, "echo SUDO_MALFORMED_OK") + self.assertIn("SUDO_MALFORMED_OK", post_probe) + finally: + self._safe_exit(child) + child.close(force=True) + + def test_lps1_prompt_override_persists_across_prompt_refresh(self): + """LPS1 environment prompt override should remain stable after commands.""" + custom_prompt = "LSHELL_PROMPT> " + env = os.environ.copy() + env["LPS1"] = custom_prompt + + child = self._spawn_shell(env=env, prompt=re.escape(custom_prompt)) + try: + child.sendline("cd /tmp") + child.expect(re.escape(custom_prompt)) + + child.sendline("echo PROMPT_OK") + child.expect(re.escape(custom_prompt)) + self.assertIn("PROMPT_OK", child.before) + finally: + child.close(force=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_shellcmd_signal_unit.py b/test/test_shellcmd_signal_unit.py index 9959157..1c9a921 100644 --- a/test/test_shellcmd_signal_unit.py +++ b/test/test_shellcmd_signal_unit.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.shellcmd import ShellCmd TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" diff --git a/test/test_signals.py b/test/test_signals.py index a1aee79..a9b3350 100644 --- a/test/test_signals.py +++ b/test/test_signals.py @@ -30,7 +30,7 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_25_keyboard_interrupt(self): + def test_keyboard_interrupt(self): """F25 | test cat(1) with KeyboardInterrupt, should not exit""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} " "--allowed \"+ ['cat']\"" @@ -57,7 +57,7 @@ def test_25_keyboard_interrupt(self): self.assertIn(expected, result) self.do_exit(child) - def test_28_catch_terminal_ctrl_j(self): + def test_catch_terminal_ctrl_j(self): """F28 | test ctrl-v ctrl-j then command, forbidden/security""" child = pexpect.spawn(f"{LSHELL} " f"--config {CONFIG} ") child.expect(PROMPT) @@ -79,7 +79,7 @@ def test_28_catch_terminal_ctrl_j(self): ) self.do_exit(child) - def test_29_catch_terminal_ctrl_k(self): + def test_catch_terminal_ctrl_k(self): """F29 | test ctrl-v ctrl-k then command, forbidden/security""" child = pexpect.spawn( f"{LSHELL} " f"--config {CONFIG} --forbidden \"-['&',';']\"" @@ -98,7 +98,7 @@ def test_29_catch_terminal_ctrl_k(self): self.assertIn(expected, result) self.do_exit(child) - def test_71_backgrounding_with_ctrl_z(self): + def test_backgrounding_with_ctrl_z(self): """F71 | est backgrounding a command with Ctrl+Z.""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") child.expect(PROMPT) @@ -142,7 +142,7 @@ def test_71_backgrounding_with_ctrl_z(self): child.sendcontrol("c") child.expect(PROMPT, timeout=5) - def test_72_background_command_with_ampersand(self): + def test_background_command_with_ampersand(self): """F72 | Test backgrounding a command with `&`.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --allowed \"+['sleep']\" --forbidden \"-['&',';']\"" @@ -172,7 +172,7 @@ def test_72_background_command_with_ampersand(self): output == expected_output ), f"Expected '{expected_output}', got '{output}'" - def test_73_exit_with_stopped_jobs(self): + def test_exit_with_stopped_jobs(self): """F73 | Test exiting with stopped jobs.""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") child.expect(PROMPT) @@ -181,7 +181,8 @@ def test_73_exit_with_stopped_jobs(self): child.sendline("tail -f") time.sleep(1) child.sendcontrol("z") - child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=1) + # CI/container scheduling can delay job-control status emission slightly. + child.expect(r"\[\d+\]\+ Stopped tail -f", timeout=3) # Attempt to exit child.sendline("exit") @@ -195,7 +196,7 @@ def test_73_exit_with_stopped_jobs(self): child.sendline("exit") child.expect(pexpect.EOF, timeout=5) - def test_74_resume_stopped_jobs(self): + def test_resume_stopped_jobs(self): """F74 | Test resuming stopped jobs.""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") child.expect(PROMPT) @@ -240,7 +241,7 @@ def test_74_resume_stopped_jobs(self): child.sendcontrol("c") # Send Ctrl+C to stop the job child.expect(PROMPT) - def test_75_interrupt_background_commands(self): + def test_interrupt_background_commands(self): """F75 | Test that `Ctrl+C` does not interrupt background commands.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --allowed \"+['sleep']\" --forbidden \"-['&',';']\"" @@ -264,7 +265,7 @@ def test_75_interrupt_background_commands(self): child.sendline("jobs") child.expect(r"\[\d+\]\+ Stopped sleep 60", timeout=5) - def test_76_jobs_after_completion(self): + def test_jobs_after_completion(self): """F76 | Test that completed jobs are removed from the `jobs` list.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} --allowed \"+['sleep']\" --forbidden \"-['&',';']\"" @@ -284,7 +285,7 @@ def test_76_jobs_after_completion(self): output = child.before.decode("utf-8").split("\n", 1)[1].strip() assert output == "", f"Expected no jobs, got: '{output}'" - def test_77_mix_background_and_foreground(self): + def test_mix_background_and_foreground(self): """F77 | Test mixing background and foreground commands.""" child = pexpect.spawn( f"{LSHELL} --config {CONFIG} " @@ -315,7 +316,7 @@ def test_77_mix_background_and_foreground(self): output == expected_output ), f"Expected '{expected_output}', got '{output}'" - def test_78_ctrl_d_with_stopped_jobs_no_unknown_syntax(self): + def test_ctrl_d_with_stopped_jobs_no_unknown_syntax(self): """F78 | Ctrl+D with stopped jobs should warn without unknown EOF syntax.""" child = pexpect.spawn(f"{LSHELL} --config {CONFIG} --allowed \"+['tail']\"") child.expect(PROMPT) @@ -336,3 +337,54 @@ def test_78_ctrl_d_with_stopped_jobs_no_unknown_syntax(self): # Second Ctrl+D should exit (kill remaining stopped jobs). child.sendeof() child.expect(pexpect.EOF, timeout=5) + + def test_fg_negative_paths_show_explicit_errors_and_keep_session_usable(self): + """`fg` should fail closed for missing, non-numeric, and unknown job IDs.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --forbidden \"[]\" --allowed \"+['sleep','echo']\"" + ) + child.expect(PROMPT) + + child.sendline("fg") + child.expect(PROMPT) + no_job_output = child.before.decode("utf-8") + self.assertIn("lshell: fg: current: no such job", no_job_output) + + child.sendline("fg abc") + child.expect(PROMPT) + non_numeric_output = child.before.decode("utf-8") + self.assertIn("lshell: invalid job ID", non_numeric_output) + + child.sendline("fg 99") + child.expect(PROMPT) + missing_id_output = child.before.decode("utf-8") + self.assertIn("lshell: fg: 99: no such job", missing_id_output) + + child.sendline("echo FG_NEGATIVE_OK") + child.expect(PROMPT) + self.assertIn("FG_NEGATIVE_OK", child.before.decode("utf-8")) + self.do_exit(child) + + def test_background_timeout_removes_job_from_jobs_listing(self): + """Background `command_timeout` expiry should clean stale entries from `jobs`.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --strict 1 --forbidden \"[]\" " + "--allowed \"+['sleep','echo']\" --command_timeout 1" + ) + child.expect(PROMPT) + + child.sendline("sleep 60 &") + child.expect(r"\[\d+\] sleep 60 \(pid: \d+\)", timeout=5) + child.expect(PROMPT) + + # Allow the background timeout handler to kill the process. + time.sleep(2) + child.sendline("jobs") + child.expect(PROMPT) + jobs_output = child.before.decode("utf-8") + self.assertNotIn("Stopped sleep 60", jobs_output) + + child.sendline("echo TIMEOUT_CLEANUP_OK") + child.expect(PROMPT) + self.assertIn("TIMEOUT_CLEANUP_OK", child.before.decode("utf-8")) + self.do_exit(child) diff --git a/test/test_ssh.py b/test/test_ssh.py index 78699d1..f5a41f9 100644 --- a/test/test_ssh.py +++ b/test/test_ssh.py @@ -1,6 +1,7 @@ """Functional tests for lshell SSH handling""" import os +import tempfile import unittest from getpass import getuser import pexpect @@ -29,7 +30,14 @@ def do_exit(self, child): child.sendline("exit") child.expect(pexpect.EOF) - def test_45_overssh_allowed_command_exit_0(self): + def _ssh_env(self): + """Return an SSH-like environment that triggers overssh execution path.""" + env = os.environ.copy() + env["SSH_CLIENT"] = "random" + env.pop("SSH_TTY", None) + return env + + def test_overssh_allowed_command_exit_0(self): """F44 | Test 'ssh -c ls' command should exit 0""" # add SSH_CLIENT to environment if not os.environ.get("SSH_CLIENT"): @@ -39,6 +47,7 @@ def test_45_overssh_allowed_command_exit_0(self): f"{LSHELL} " f"--config {CONFIG} " f"--overssh \"['ls']\" " f"-c 'ls'" ) self.child.expect(pexpect.EOF, timeout=10) + self.child.close() # Assert that the process exited self.assertIsNotNone( @@ -53,7 +62,7 @@ def test_45_overssh_allowed_command_exit_0(self): f"The process should exit with code 0, got {self.child.exitstatus}.", ) - def test_46_overssh_allowed_command_exit_1(self): + def test_overssh_allowed_command_exit_1(self): """F44 | Test 'ssh -c ls' command should exit 1""" # add SSH_CLIENT to environment if not os.environ.get("SSH_CLIENT"): @@ -66,6 +75,7 @@ def test_46_overssh_allowed_command_exit_1(self): f"-c 'ls /random'" ) self.child.expect(pexpect.EOF, timeout=10) + self.child.close() # Assert that the process exited self.assertIsNotNone( @@ -79,7 +89,7 @@ def test_46_overssh_allowed_command_exit_1(self): f"The process should exit with code 1, got {self.child.exitstatus}.", ) - def test_46_overssh_not_allowed_command_exit_1(self): + def test_overssh_not_allowed_command_exit_1(self): """F44 | Test 'ssh -c lss' command should succeed""" # add SSH_CLIENT to environment if not os.environ.get("SSH_CLIENT"): @@ -89,6 +99,7 @@ def test_46_overssh_not_allowed_command_exit_1(self): f"{LSHELL} " f"--config {CONFIG} " f"--overssh \"['ls']\" " f"-c 'lss'" ) self.child.expect(pexpect.EOF, timeout=10) + self.child.close() # Assert that the process exited self.assertIsNotNone( @@ -102,7 +113,7 @@ def test_46_overssh_not_allowed_command_exit_1(self): f"The process should exit with code 1, got {self.child.exitstatus}.", ) - def test_57_overssh_all_minus_list(self): + def test_overssh_all_minus_list(self): """F57 | overssh minus command list.""" command = "echo 1" expected = ( @@ -124,7 +135,7 @@ def test_57_overssh_all_minus_list(self): output = self.child.before.decode("utf-8").strip() self.assertEqual(expected, output) - def test_58_overssh_plus_minus_chain_controls_warning_and_allow(self): + def test_overssh_plus_minus_chain_controls_warning_and_allow(self): """F58 | overssh +/- chain should deny removed command and allow added one.""" if not os.environ.get("SSH_CLIENT"): os.environ["SSH_CLIENT"] = "random" @@ -147,3 +158,104 @@ def test_58_overssh_plus_minus_chain_controls_warning_and_allow(self): allowed.expect(pexpect.EOF, timeout=10) allowed_output = allowed.before.decode("utf-8") self.assertIn("1", allowed_output) + + def test_overssh_scp_download_denied_when_downloads_disabled(self): + """SCP -f should be denied when scp_download is disabled.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} " + "--scp 1 --scp_download 0 --overssh \"['scp']\" " + "-c 'scp -f /tmp/file'", + env=self._ssh_env(), + ) + child.expect(pexpect.EOF, timeout=10) + child.close() + self.assertEqual(child.exitstatus, 1) + + def test_overssh_scp_upload_denied_when_uploads_disabled(self): + """SCP -t should be denied when scp_upload is disabled.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} " + "--scp 1 --scp_upload 0 --overssh \"['scp']\" " + "-c 'scp -t /tmp/file'", + env=self._ssh_env(), + ) + child.expect(pexpect.EOF, timeout=10) + child.close() + self.assertEqual(child.exitstatus, 1) + + def test_overssh_sftp_server_denied_when_sftp_disabled(self): + """sftp-server over SSH should exit with denial when sftp is disabled.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --sftp 0 -c 'sftp-server'", + env=self._ssh_env(), + ) + child.expect(pexpect.EOF, timeout=10) + child.close() + self.assertEqual(child.exitstatus, 1) + + def test_winscp_mode_allows_semicolon_in_interactive_session(self): + """winscp mode should relax semicolon restriction for user commands.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --winscp 1 --forbidden \"[';']\" " + "--allowed \"['echo']\"" + ) + child.expect(PROMPT) + + child.sendline("echo ONE; echo TWO") + child.expect(PROMPT) + output = child.before.decode("utf-8") + self.assertIn("ONE", output) + self.assertIn("TWO", output) + self.do_exit(child) + + def test_overssh_trusted_sftp_rejects_assignment_prefix(self): + """Trusted sftp-server flow should deny env-assignment command prefixes.""" + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --sftp 1 " + "--overssh \"['sftp-server']\" " + "-c 'TMPDIR=/tmp sftp-server'", + env=self._ssh_env(), + ) + child.expect(pexpect.EOF, timeout=10) + output = child.before.decode("utf-8") + child.close() + self.assertIn("lshell: forbidden trusted SSH protocol command", output) + self.assertEqual(child.exitstatus, 126) + + def test_overssh_scpforce_rewrites_upload_target_before_path_check(self): + """scpforce should rewrite upload destination before SSH path authorization.""" + with tempfile.TemporaryDirectory(prefix="lshell-scpforce-") as forced_dir: + forced_real = os.path.realpath(forced_dir) + original_target = "/tmp/lshell_scpforce_original_target" + + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --scp 1 --scp_upload 1 " + "--overssh \"['scp']\" " + f"--scpforce \"'{forced_dir}'\" " + f"-c 'scp -t {original_target}'", + env=self._ssh_env(), + ) + child.expect(pexpect.EOF, timeout=10) + output = child.before.decode("utf-8") + child.close() + + self.assertIn("lshell: forbidden path over SSH:", output) + self.assertIn(forced_real, output) + self.assertNotIn(original_target, output) + self.assertEqual(child.exitstatus, 1) + + def test_overssh_scpforce_unquoted_path_should_not_fail_config_parsing(self): + """Unquoted scpforce CLI path should be accepted and reach SSH policy checks.""" + with tempfile.TemporaryDirectory(prefix="lshell-scpforce-") as forced_dir: + child = pexpect.spawn( + f"{LSHELL} --config {CONFIG} --scp 1 --scp_upload 1 " + "--overssh \"['scp']\" " + f"--scpforce {forced_dir} " + "-c 'scp -t /tmp/lshell_scpforce_parse_probe'", + env=self._ssh_env(), + ) + child.expect(pexpect.EOF, timeout=10) + output = child.before.decode("utf-8") + child.close() + + self.assertNotIn("Incomplete field in configuration file", output) diff --git a/test/test_ssh_scp_sftp_attack_surface_unit.py b/test/test_ssh_scp_sftp_attack_surface_unit.py index e305902..f66eecb 100644 --- a/test/test_ssh_scp_sftp_attack_surface_unit.py +++ b/test/test_ssh_scp_sftp_attack_surface_unit.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import patch -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.shellcmd import ShellCmd TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" diff --git a/test/test_ssh_scp_sftp_config_unit.py b/test/test_ssh_scp_sftp_config_unit.py index b4ac370..b090db8 100644 --- a/test/test_ssh_scp_sftp_config_unit.py +++ b/test/test_ssh_scp_sftp_config_unit.py @@ -4,7 +4,7 @@ import tempfile import unittest -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell import builtincmd TOPDIR = f"{os.path.dirname(os.path.realpath(__file__))}/../" @@ -16,7 +16,7 @@ class TestSSHScpSftpConfig(unittest.TestCase): args = [f"--config={CONFIG}", "--quiet=1"] - def test_20_winscp_allowed_commands(self): + def test_winscp_allowed_commands(self): """U20 | when winscp is enabled, new allowed commands are automatically added.""" args = self.args + ["--allowed=[]", "--winscp=1"] userconf = CheckConfig(args).returnconf() @@ -27,27 +27,27 @@ def test_20_winscp_allowed_commands(self): allowed.sort() self.assertEqual(allowed, expected) - def test_21_winscp_allowed_semicolon(self): + def test_winscp_allowed_semicolon(self): """U21 | when winscp is enabled, use of semicolon is allowed.""" args = self.args + ["--forbidden=[';']", "--winscp=1"] userconf = CheckConfig(args).returnconf() self.assertNotIn(";", userconf["forbidden"]) - def test_21b_winscp_forces_scp_transfers_enabled(self): + def test_winscp_forces_scp_transfers_enabled(self): """U21b | winscp should override scp_upload/scp_download to enabled.""" args = self.args + ["--scp_upload=0", "--scp_download=0", "--winscp=1"] userconf = CheckConfig(args).returnconf() self.assertEqual(userconf["scp_upload"], 1) self.assertEqual(userconf["scp_download"], 1) - def test_21c_winscp_ignores_scpforce(self): + def test_winscp_ignores_scpforce(self): """U21c | winscp should ignore scpforce setting.""" with tempfile.TemporaryDirectory() as forced_dir: args = self.args + [f"--scpforce='{forced_dir}'", "--winscp=1"] userconf = CheckConfig(args).returnconf() self.assertNotIn("scpforce", userconf) - def test_21d_scp_transfer_flags_default_to_enabled(self): + def test_scp_transfer_flags_default_to_enabled(self): """U21d | scp_upload/scp_download default values should be enabled.""" userconf = CheckConfig(self.args).returnconf() self.assertEqual(userconf["scp_upload"], 1) diff --git a/test/test_systemsetup_unit.py b/test/test_systemsetup_unit.py index 6f33988..82f5539 100644 --- a/test/test_systemsetup_unit.py +++ b/test/test_systemsetup_unit.py @@ -1,5 +1,7 @@ """Unit tests for lshell setup-system bootstrap command.""" +import contextlib +import io import unittest from unittest.mock import patch @@ -44,3 +46,20 @@ def test_setup_system_happy_path(self): shell_entry.assert_called_once_with("/usr/local/bin/lshell") set_shell.assert_called_once_with("testuser", "/usr/local/bin/lshell") add_group.assert_called_once_with("testuser", "lshell") + + def test_setup_system_rejects_invalid_mode(self): + """Invalid octal mode should fail fast with exit code 1.""" + stderr = io.StringIO() + with patch("lshell.systemsetup.os.geteuid", return_value=0): + with contextlib.redirect_stderr(stderr): + code = systemsetup.main(["--mode", "8888"]) + + self.assertEqual(code, 1) + self.assertIn("Invalid mode value", stderr.getvalue()) + + def test_resolve_lshell_path_auto_errors_when_binary_missing(self): + """Auto shell-path resolution should fail when lshell is not in PATH.""" + with patch("lshell.systemsetup.shutil.which", return_value=None): + with self.assertRaises(RuntimeError) as exc: + systemsetup._resolve_lshell_path("auto") + self.assertIn("binary not found", str(exc.exception)) diff --git a/test/test_unit.py b/test/test_unit.py index 9b123b8..ef7a42f 100644 --- a/test/test_unit.py +++ b/test/test_unit.py @@ -11,7 +11,7 @@ from unittest.mock import patch # import lshell specifics -from lshell.checkconfig import CheckConfig +from lshell.config.runtime import CheckConfig from lshell.utils import get_aliases, updateprompt, parse_ps1, getpromptbase from lshell import builtincmd from lshell import sec @@ -27,47 +27,47 @@ class TestFunctions(unittest.TestCase): args = [f"--config={CONFIG}", "--quiet=1"] userconf = CheckConfig(args).returnconf() - def test_03_checksecure_doublepipe(self): + def test_checksecure_doublepipe(self): """U03 | double pipes should be allowed, even if pipe is forbidden""" args = self.args + ["--forbidden=['|']"] userconf = CheckConfig(args).returnconf() input_command = "ls || ls" return self.assertEqual(sec.check_secure(input_command, userconf)[0], 0) - def test_04_checksecure_forbiddenpipe(self): + def test_checksecure_forbiddenpipe(self): """U04 | forbid pipe, should return 1""" args = self.args + ["--forbidden=['|']"] userconf = CheckConfig(args).returnconf() input_command = "ls | ls" return self.assertEqual(sec.check_secure(input_command, userconf)[0], 1) - def test_05_checksecure_forbiddenchar(self): + def test_checksecure_forbiddenchar(self): """U05 | forbid character, should return 1""" args = self.args + ["--forbidden=['l']"] userconf = CheckConfig(args).returnconf() input_command = "ls" return self.assertEqual(sec.check_secure(input_command, userconf)[0], 1) - def test_06_checksecure_sudo_command(self): + def test_checksecure_sudo_command(self): """U06 | quoted text should not be forbidden""" input_command = "sudo ls" return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) - def test_07_checksecure_notallowed_command(self): + def test_checksecure_notallowed_command(self): """U07 | forbidden command, should return 1""" args = self.args + ["--allowed=['ls']"] userconf = CheckConfig(args).returnconf() input_command = "ll" return self.assertEqual(sec.check_secure(input_command, userconf)[0], 1) - def test_08_checkpath_notallowed_path(self): + def test_checkpath_notallowed_path(self): """U08 | forbidden command, should return 1""" args = self.args + ["--path=['/home', '/var']"] userconf = CheckConfig(args).returnconf() input_command = "cd /tmp" return self.assertEqual(sec.check_path(input_command, userconf)[0], 1) - def test_09_checkpath_notallowed_path_completion(self): + def test_checkpath_notallowed_path_completion(self): """U09 | forbidden command, should return 1""" args = self.args + ["--path=['/home', '/var']"] userconf = CheckConfig(args).returnconf() @@ -76,20 +76,20 @@ def test_09_checkpath_notallowed_path_completion(self): sec.check_path(input_command, userconf, completion=1)[0], 1 ) - def test_10_checkpath_dollarparenthesis(self): + def test_checkpath_dollarparenthesis(self): """U10 | when $() is allowed, return 0 if path allowed""" args = self.args + ["--forbidden=[';', '&', '|','`','>','<', '${']"] userconf = CheckConfig(args).returnconf() input_command = "echo $(echo aze)" return self.assertEqual(sec.check_path(input_command, userconf)[0], 0) - def test_11_checkconfig_configoverwrite(self): + def test_checkconfig_configoverwrite(self): """U12 | forbid ';', then check_secure should return 1""" args = [f"--config={CONFIG}", "--strict=123"] userconf = CheckConfig(args).returnconf() return self.assertEqual(userconf["strict"], 123) - def test_11b_merge_plus_minus_supported_for_all_list_merge_keys(self): + def test_merge_plus_minus_supported_for_all_list_merge_keys(self): """U12b | +/- merge semantics are applied for all merge-capable list keys.""" args = self.args + [ "--allowed=['basecmd'] + ['pluscmd'] - ['basecmd']", @@ -119,7 +119,7 @@ def test_11b_merge_plus_minus_supported_for_all_list_merge_keys(self): self.assertIn(f"{os.path.realpath('/var')}/|", userconf["path"][1]) self.assertIn(f"{os.path.realpath('/etc')}/|", userconf["path"][1]) - def test_13_multiple_aliases_with_separator(self): + def test_multiple_aliases_with_separator(self): """U13 | multiple aliases using &&, || and ; separators""" # enable &, | and ; characters aliases = {"foo": "foo -l", "bar": "open"} @@ -129,7 +129,7 @@ def test_13_multiple_aliases_with_separator(self): " foo -l; fooo ; open&& foo -l " "&& foo -l | open|| open || foo -l", ) - def test_14_sudo_all_commands_expansion(self): + def test_sudo_all_commands_expansion(self): """U14 | sudo_commands set to 'all' is equal to allowed variable""" args = self.args + ["--sudo_commands=all"] userconf = CheckConfig(args).returnconf() @@ -141,21 +141,21 @@ def test_14_sudo_all_commands_expansion(self): allowed.sort() return self.assertEqual(allowed, userconf["sudo_commands"]) - def test_14b_allowed_all_unquoted_expands(self): + def test_allowed_all_unquoted_expands(self): """U14b | allowed=all (unquoted) expands to executable allow-list.""" args = self.args + ["--allowed=all"] userconf = CheckConfig(args).returnconf() self.assertIsInstance(userconf["allowed"], list) self.assertIn("ls", userconf["allowed"]) - def test_14c_allowed_all_quoted_expands(self): + def test_allowed_all_quoted_expands(self): """U14c | allowed='all' (quoted) expands to executable allow-list.""" args = self.args + ["--allowed='all'"] userconf = CheckConfig(args).returnconf() self.assertIsInstance(userconf["allowed"], list) self.assertIn("ls", userconf["allowed"]) - def test_14d_sudo_all_quoted_expansion(self): + def test_sudo_all_quoted_expansion(self): """U14d | sudo_commands='all' (quoted) expands against effective allowed list.""" args = self.args + ["--sudo_commands='all'"] userconf = CheckConfig(args).returnconf() @@ -168,7 +168,7 @@ def test_14d_sudo_all_quoted_expansion(self): msg="sudo_commands all-expansion must not duplicate ls", ) - def test_16_allowed_ld_preload_builtin(self): + def test_allowed_ld_preload_builtin(self): """U16 | builtin commands should NOT be prepended with LD_PRELOAD""" args = self.args + ["--allowed=['echo','export']"] userconf = CheckConfig(args).returnconf() @@ -176,7 +176,7 @@ def test_16_allowed_ld_preload_builtin(self): # prepended with LD_PRELOAD) return self.assertNotIn("export", userconf["aliases"]) - def test_17_allowed_exec_cmd(self): + def test_allowed_exec_cmd(self): """U17 | allowed_shell_escape should NOT be prepended with LD_PRELOAD The command should not be added to the aliases variable """ @@ -185,21 +185,21 @@ def test_17_allowed_exec_cmd(self): # sort lists to compare return self.assertNotIn("echo", userconf["aliases"]) - def test_18_forbidden_environment(self): + def test_forbidden_environment(self): """U18 | unsafe environment are forbidden""" input_command = "export LD_PRELOAD=/lib64/ld-2.21.so" args = input_command retcode = builtincmd.cmd_export(args)[0] return self.assertEqual(retcode, 1) - def test_19_allowed_environment(self): + def test_allowed_environment(self): """U19 | other environment are accepted""" input_command = "export MY_PROJECT_VERSION=43" args = input_command retcode = builtincmd.cmd_export(args)[0] return self.assertEqual(retcode, 0) - def test_22_prompt_short_0(self): + def test_prompt_short_0(self): """U22 | short_prompt = 0 should show dir compared to home dir""" expected = f"{getuser()}:~/foo$ " args = self.args + ["--prompt_short=0"] @@ -209,7 +209,7 @@ def test_22_prompt_short_0(self): # sort lists to compare return self.assertEqual(prompt, expected) - def test_23_prompt_short_1(self): + def test_prompt_short_1(self): """U23 | short_prompt = 1 should show only current dir""" expected = f"{getuser()}:foo$ " args = self.args + ["--prompt_short=1"] @@ -219,7 +219,7 @@ def test_23_prompt_short_1(self): # sort lists to compare return self.assertEqual(prompt, expected) - def test_24_prompt_short_2(self): + def test_prompt_short_2(self): """U24 | short_prompt = 2 should show full dir path""" expected = f"{getuser()}:{os.getcwd()}/foo$ " args = self.args + ["--prompt_short=2"] @@ -229,29 +229,29 @@ def test_24_prompt_short_2(self): # sort lists to compare return self.assertEqual(prompt, expected) - def test_25_disable_ld_preload(self): + def test_disable_ld_preload(self): """U25 | empty path_noexec should disable LD_PRELOAD""" args = self.args + ["--allowed=['echo','export']", "--path_noexec=''"] userconf = CheckConfig(args).returnconf() # verify that no alias was created containing LD_PRELOAD return self.assertNotIn("echo", userconf["aliases"]) - def test_26_checksecure_quoted_command(self): + def test_checksecure_quoted_command(self): """U26 | quoted command should be parsed""" input_command = 'echo 1 && "bash"' return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) - def test_27_checksecure_quoted_command(self): + def test_checksecure_quoted_command_case_27(self): """U27 | quoted command should be parsed""" input_command = '"bash" && echo 1' return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) - def test_28_checksecure_quoted_command(self): + def test_checksecure_quoted_command_case_28(self): """U28 | quoted command should be parsed""" input_command = "echo'/1.sh'" return self.assertEqual(sec.check_secure(input_command, self.userconf)[0], 1) - def test_29_env_path_updates_path_variable(self): + def test_env_path_updates_path_variable(self): """U29 | Test that --env_path updates the PATH environment variable.""" # store the original $PATH original_path = os.environ["PATH"] @@ -273,7 +273,7 @@ def test_29_env_path_updates_path_variable(self): os.environ["PATH"] = original_path @patch("sys.exit") # Mock sys.exit to prevent exiting the test on failure - def test_30_invalid_new_path(self, mock_exit): + def test_invalid_new_path(self, mock_exit): """U30 | Test that an invalid new PATH triggers an error and sys.exit.""" original_path = os.environ["PATH"] random_path = "/usr/random:/invalid$path" @@ -291,7 +291,7 @@ def test_30_invalid_new_path(self, mock_exit): self.assertEqual(os.environ["PATH"], original_path) @patch("sys.exit") - def test_31_new_path_starts_with_colon(self, mock_exit): + def test_new_path_starts_with_colon(self, mock_exit): """U31 | Test that a new PATH starting with a colon triggers an error.""" original_path = os.environ["PATH"] random_path = ":/usr/random:/this_is_a_test" @@ -308,7 +308,7 @@ def test_31_new_path_starts_with_colon(self, mock_exit): # The PATH should not have been changed self.assertEqual(os.environ["PATH"], original_path) - def test_32_lps1_user_host_time(self): + def test_lps1_user_host_time(self): r"""U32 | LPS1 using \u@\h - \t> format""" os.environ["LPS1"] = r"\u@\h - \t> " expected = f"{getuser()}@{os.uname()[1].split('.')[0]} - {strftime('%H:%M:%S', gmtime())}> " @@ -316,7 +316,7 @@ def test_32_lps1_user_host_time(self): self.assertEqual(prompt, expected) del os.environ["LPS1"] - def test_33_lps1_with_cwd(self): + def test_lps1_with_cwd(self): r"""U33 | LPS1 should replace cwd with \w format""" os.environ["LPS1"] = r"\u:\w$ " expected = f"{getuser()}:{os.getcwd().replace(os.path.expanduser('~'), '~')}$ " @@ -324,7 +324,7 @@ def test_33_lps1_with_cwd(self): self.assertEqual(prompt, expected) del os.environ["LPS1"] - def test_34_prompt_default_user_host(self): + def test_prompt_default_user_host(self): """U34 | Default config-based prompt should replace %u and %h""" userconf = CheckConfig(self.args).returnconf() userconf["prompt"] = "%u@%h" @@ -332,7 +332,7 @@ def test_34_prompt_default_user_host(self): prompt = getpromptbase(userconf) self.assertEqual(prompt, expected) - def test_35_updateprompt_lps1_defined(self): + def test_updateprompt_lps1_defined(self): """U35 | LPS1 environment variable should override config-based prompt""" os.environ["LPS1"] = r"\u@\H \W$ " expected = f"{getuser()}@{os.uname()[1]} {os.path.basename(os.getcwd())}$ " @@ -341,7 +341,7 @@ def test_35_updateprompt_lps1_defined(self): self.assertEqual(prompt, expected) del os.environ["LPS1"] - def test_36_updateprompt_home_path(self): + def test_updateprompt_home_path(self): """U36 | Prompt path should use '~' for home directory""" userconf = CheckConfig(self.args).returnconf() currentpath = userconf["home_path"] @@ -349,7 +349,7 @@ def test_36_updateprompt_home_path(self): prompt = updateprompt(currentpath, userconf) self.assertEqual(prompt, expected) - def test_37_updateprompt_short_prompt_level_1(self): + def test_updateprompt_short_prompt_level_1(self): """U37 | short_prompt = 1 should show only last directory in path""" userconf = CheckConfig(self.args).returnconf() userconf["prompt_short"] = 1 @@ -358,7 +358,7 @@ def test_37_updateprompt_short_prompt_level_1(self): prompt = updateprompt(currentpath, userconf) self.assertEqual(prompt, expected) - def test_38_updateprompt_short_prompt_level_2(self): + def test_updateprompt_short_prompt_level_2(self): """U38 | short_prompt = 2 should show full directory path""" userconf = CheckConfig(self.args).returnconf() userconf["prompt_short"] = 2 @@ -367,7 +367,7 @@ def test_38_updateprompt_short_prompt_level_2(self): prompt = updateprompt(currentpath, userconf) self.assertEqual(prompt, expected) - def test_39_updateprompt_path_inside_home(self): + def test_updateprompt_path_inside_home(self): """U39 | Path inside home directory should start with '~'""" userconf = CheckConfig(self.args).returnconf() currentpath = f"{userconf['home_path']}/projects" @@ -375,7 +375,7 @@ def test_39_updateprompt_path_inside_home(self): prompt = updateprompt(currentpath, userconf) self.assertEqual(prompt, expected) - def test_40_updateprompt_absolute_path_outside_home(self): + def test_updateprompt_absolute_path_outside_home(self): """U40 | Absolute path outside home should display fully in prompt""" userconf = CheckConfig(self.args).returnconf() currentpath = "/etc" @@ -383,22 +383,22 @@ def test_40_updateprompt_absolute_path_outside_home(self): prompt = updateprompt(currentpath, userconf) self.assertEqual(prompt, expected) - @patch("lshell.checkconfig.os.umask") - def test_41_umask_sets_process_mask(self, mock_umask): + @patch("lshell.config.runtime.os.umask") + def test_umask_sets_process_mask(self, mock_umask): """U41 | --umask should be parsed as octal and applied to process mask""" args = self.args + ["--umask=0002"] userconf = CheckConfig(args).returnconf() self.assertEqual(userconf["umask"], "0002") mock_umask.assert_called_once_with(0o002) - def test_42_invalid_umask_value_raises(self): + def test_invalid_umask_value_raises(self): """U42 | invalid umask value should exit with error""" args = self.args + ["--umask=0088"] with self.assertRaises(SystemExit) as exc: CheckConfig(args).returnconf() self.assertEqual(exc.exception.code, 1) - def test_42b_umask_masks_new_history_file_permissions(self): + def test_umask_masks_new_history_file_permissions(self): """U42b | configured umask should affect newly created lshell artifacts.""" original_umask = os.umask(0) os.umask(original_umask) @@ -419,7 +419,7 @@ def test_42b_umask_masks_new_history_file_permissions(self): finally: os.umask(original_umask) - def test_43_default_ls_alias_enables_auto_color(self): + def test_default_ls_alias_enables_auto_color(self): """U43 | default config should alias ls to a platform color option.""" userconf = CheckConfig(self.args).returnconf() expected = None @@ -429,13 +429,13 @@ def test_43_default_ls_alias_enables_auto_color(self): expected = "ls -G" self.assertEqual(userconf["aliases"].get("ls"), expected) - def test_44_explicit_ls_alias_is_preserved(self): + def test_explicit_ls_alias_is_preserved(self): """U44 | explicit ls alias should not be overwritten.""" args = self.args + ["--aliases={'ls':'ls -lh'}"] userconf = CheckConfig(args).returnconf() self.assertEqual(userconf["aliases"].get("ls"), "ls -lh") - def test_44b_auto_ls_alias_expands_during_local_execution(self): + def test_auto_ls_alias_expands_during_local_execution(self): """U44b | local execution should dispatch through the generated ls alias.""" saved_env = {} for key in ("SSH_CLIENT", "SSH_TTY", "SSH_ORIGINAL_COMMAND"): @@ -467,33 +467,18 @@ def test_44b_auto_ls_alias_expands_during_local_execution(self): else: os.environ[key] = value - def test_45_policy_commands_enabled_by_default(self): + def test_policy_commands_enabled_by_default(self): """U45 | policy commands should be available by default.""" userconf = CheckConfig(self.args).returnconf() - self.assertIn("policy-show", userconf["allowed"]) - self.assertIn("policy-path", userconf["allowed"]) - self.assertIn("policy-sudo", userconf["allowed"]) - self.assertIn("lpath", userconf["allowed"]) - self.assertIn("lsudo", userconf["allowed"]) + self.assertIn("lshow", userconf["allowed"]) - def test_46_policy_commands_can_be_hidden(self): + def test_policy_commands_can_be_hidden(self): """U46 | policy commands can be hidden via --policy_commands=0.""" args = self.args + ["--policy_commands=0"] userconf = CheckConfig(args).returnconf() - self.assertNotIn("policy-show", userconf["allowed"]) - self.assertNotIn("policy-path", userconf["allowed"]) - self.assertNotIn("policy-sudo", userconf["allowed"]) - self.assertNotIn("lpath", userconf["allowed"]) - self.assertNotIn("lsudo", userconf["allowed"]) - - def test_47_invalid_allowed_type_rejected(self): - """U47 | allowed must be a list, scalar values should be rejected.""" - args = self.args + ["--allowed=1"] - with self.assertRaises(SystemExit) as exc: - CheckConfig(args).returnconf() - self.assertEqual(exc.exception.code, 1) + self.assertNotIn("lshow", userconf["allowed"]) - def test_48_history_file_accepts_string_and_expands_home(self): + def test_history_file_accepts_string_and_expands_home(self): """U48 | --history_file should parse as string and resolve under home path.""" history_name = ".lshell_%u_history" args = self.args + [f"--history_file='{history_name}'"] @@ -503,7 +488,7 @@ def test_48_history_file_accepts_string_and_expands_home(self): ) self.assertEqual(userconf["history_file"], expected_history) - def test_49_history_file_absolute_path_kept_absolute(self): + def test_history_file_absolute_path_kept_absolute(self): """U49 | absolute --history_file path should not be prefixed by home path.""" history_path = "/tmp/lshell_%u_history" args = self.args + [f"--history_file='{history_path}'"] @@ -512,8 +497,8 @@ def test_49_history_file_absolute_path_kept_absolute(self): userconf["history_file"], history_path.replace("%u", userconf["username"]) ) - @patch("lshell.checkconfig.CheckConfig.noexec_library_usable", return_value=False) - def test_50_incompatible_noexec_library_is_disabled(self, _mock_usable): + @patch("lshell.config.runtime.CheckConfig.noexec_library_usable", return_value=False) + def test_incompatible_noexec_library_is_disabled(self, _mock_usable): """U50 | incompatible --path_noexec should be removed from runtime config.""" with tempfile.NamedTemporaryFile() as fake_lib: args = self.args + [f"--path_noexec='{fake_lib.name}'"]