From 6f96c64ac5498340651cde9e44349d0f42af7b7c Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 22:10:55 -0400 Subject: [PATCH 1/7] Atheris fuzz initial commit --- .github/workflows/pytest.yml | 6 + Dockerfile | 2 +- README.md | 15 + fuzz/fuzz_parser_policy.py | 96 +++++++ justfile | 4 + lshell/sec.py | 56 +++- requirements-fuzz.txt | 2 + requirements.txt | 1 + ...test_security_attack_surface_unit_part2.py | 37 +++ test/test_security_property_based_unit.py | 269 ++++++++++++++++++ 10 files changed, 480 insertions(+), 8 deletions(-) create mode 100644 fuzz/fuzz_parser_policy.py create mode 100644 requirements-fuzz.txt create mode 100644 test/test_security_property_based_unit.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6c696e4..f9c7694 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -18,6 +18,8 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install just + uses: taiki-e/install-action@just - name: Set up Python 3.10 uses: actions/setup-python@v3 with: @@ -40,6 +42,10 @@ jobs: - name: Test with pytest run: | pytest + - name: Fuzz security parser/policy + timeout-minutes: 45 + run: | + just fuzz-security-parser-policy 20000 ssh-e2e: name: SSH E2E (Docker + Ansible) diff --git a/Dockerfile b/Dockerfile index 413a960..3c098c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN \ # For Debian/Ubuntu if [ -f /etc/debian_version ]; then \ apt-get update && \ - apt-get install -y python3 python3-pip git flake8 pylint python3-pytest python3-pexpect python3-setuptools python3-pyparsing vim procps sudo && \ + apt-get install -y python3 python3-pip python3-dev build-essential clang libclang-rt-dev git flake8 pylint python3-pytest python3-pexpect python3-setuptools python3-pyparsing vim procps sudo && \ apt-get clean; \ groupadd -f testuser; \ useradd -m -d /home/testuser -s /bin/bash -g testuser testuser; \ diff --git a/README.md b/README.md index 11db5ff..afe2c3f 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,21 @@ just sample-list just sample-ubuntu 01_baseline_allowlist.conf ``` +### Fuzzing parser/policy checks + +Run Atheris fuzzing in Debian Docker (dependencies installed in-container): + +```bash +just fuzz-security-parser-policy 20000 +``` + +Optional local run (if you want to fuzz outside Docker): + +```bash +pip install -r requirements-fuzz.txt +python3 fuzz/fuzz_parser_policy.py -runs=20000 +``` + ## Contributing Open an issue or pull request: https://github.com/ghantoos/lshell/issues diff --git a/fuzz/fuzz_parser_policy.py b/fuzz/fuzz_parser_policy.py new file mode 100644 index 0000000..3b1f095 --- /dev/null +++ b/fuzz/fuzz_parser_policy.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Atheris fuzz target for parser/policy security primitives.""" + +import sys +import tempfile + +try: + import atheris +except ImportError as exc: # pragma: no cover - optional dependency + raise SystemExit( + "atheris is not installed. Install fuzz deps with: pip install -r requirements-fuzz.txt" + ) from exc + +with atheris.instrument_imports(): + from lshell import sec + from lshell import utils + + +class _NullLog: + """Minimal logger required by security helpers during fuzzing.""" + + def critical(self, _message): + return None + + def error(self, _message): + return None + + def warning(self, _message): + return None + + def info(self, _message): + return None + + +_FUZZ_TMP = tempfile.mkdtemp(prefix="lshell-fuzz-") + + +def _base_conf(): + """Build an isolated, permissive config for parser/policy fuzz entrypoints.""" + return { + "allowed": [ + "echo", + "printf", + "cat", + "ls", + "pwd", + "true", + "false", + "cd", + "sudo", + ], + "allowed_file_extensions": [], + "forbidden": [";", "&", "|", "`", ">", "<", "$(", "${"], + "sudo_commands": ["ls"], + "overssh": ["ls", "pwd", "echo"], + "warning_counter": 64, + "path": ["/|", ""], + "home_path": _FUZZ_TMP, + "promptprint": "", + "logpath": _NullLog(), + } + + +def _fuzz_one_line(line): + """Exercise parser and security check surfaces on one fuzzed command line.""" + conf = _base_conf() + try: + utils.split_command_sequence(line) + utils.split_commands(line) + utils.expand_vars_quoted(line, support_advanced_braced=True) + utils.expand_vars_quoted(line, support_advanced_braced=False) + sec.check_forbidden_chars(line, conf, strict=0) + sec.check_path(line, conf, completion=1, strict=0) + sec.check_secure(line, conf, strict=0) + sec.check_allowed_file_extensions(line, [".txt", ".log"]) + except SystemExit: + # check_secure/check_path may terminate on warning exhaustion; ignore. + pass + + +def test_one_input(data): + """Atheris entrypoint.""" + line = data.decode("utf-8", errors="ignore") + if not line: + return + _fuzz_one_line(line[:512]) + + +def main(): + """Run Atheris fuzzing loop.""" + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/justfile b/justfile index 1e6266f..eedcbb6 100644 --- a/justfile +++ b/justfile @@ -188,6 +188,10 @@ test-lint-flake8: pylint $(git ls-files '*.py') flake8 lshell test +# Run Atheris fuzzing in Debian Docker container (host deps not required) +fuzz-security-parser-policy runs='20000': + {{compose}} run --build --rm --entrypoint bash debian -lc "CLANG_BIN=clang python3 -m pip install --user --break-system-packages -r /app/requirements-fuzz.txt && PYTHONPATH=/app python3 /app/fuzz/fuzz_parser_policy.py -runs={{runs}}" + # Full local validation in one command test-all: just test-lint-flake8 diff --git a/lshell/sec.py b/lshell/sec.py index 567692a..c7fea55 100644 --- a/lshell/sec.py +++ b/lshell/sec.py @@ -14,6 +14,7 @@ from lshell import utils EXTENSION_RESTRICTION_EXEMPT_COMMANDS = {"cd", "clear", "fg", "bg", "ls"} +MAX_WILDCARD_MATCHES = 4096 def _is_assignment_word(word): @@ -109,20 +110,50 @@ def tokenize_command(command): return tokens +def _safe_realpath(path): + """Resolve canonical path and ignore malformed/unresolvable inputs.""" + try: + return os.path.realpath(path) + except (OSError, TypeError, ValueError): + return None + + +def _safe_expand_path(path): + """Expand user/env path fragments and reject malformed values.""" + try: + expanded = os.path.expanduser(path) + return os.path.expandvars(expanded) + except (TypeError, ValueError): + return None + + def expand_shell_wildcards(item): """Expand shell wildcards and return all candidate filesystem paths.""" # Expand shell variables like $HOME first. - expanded_item = os.path.expanduser(item) - expanded_item = os.path.expandvars(expanded_item) + expanded_item = _safe_expand_path(item) + if expanded_item is None: + return [] # Expand wildcard patterns against the filesystem and validate all matches. - expanded_items = glob.glob(expanded_item, recursive=True) + # Fail closed if expansion fans out too much to avoid memory abuse. + try: + expanded_items = [] + for match in glob.iglob(expanded_item, recursive=True): + resolved = _safe_realpath(match) + if resolved: + expanded_items.append(resolved) + if len(expanded_items) > MAX_WILDCARD_MATCHES: + return [] + except (OSError, RuntimeError, ValueError, re.error): + return [] + if expanded_items: - return [os.path.realpath(match) for match in expanded_items] + return expanded_items # If no glob match exists, still validate the canonical target path. - return [os.path.realpath(expanded_item)] + resolved_item = _safe_realpath(expanded_item) + return [resolved_item] if resolved_item else [] def _split_path_acl_entries(path_acl): @@ -135,7 +166,9 @@ def _split_path_acl_entries(path_acl): candidate = token.strip() if not candidate: continue - entries.append(os.path.realpath(candidate)) + resolved = _safe_realpath(candidate) + if resolved: + entries.append(resolved) return entries @@ -254,6 +287,11 @@ def check_path(line, conf, completion=None, ssh=None, strict=None): for item in path_tokens: candidates = expand_shell_wildcards(item) + if not candidates: + if not completion: + ret, conf = warn_count("path", item, conf, strict=strict, ssh=ssh) + return 1, conf + for candidate in candidates: if not _is_path_allowed(candidate, allowed_roots, denied_roots): if not completion: @@ -488,7 +526,11 @@ def check_allowed_file_extensions(command_line, allowed_extensions): extension = os.path.splitext(basename)[1] # Existing directories are valid SCP/SFTP targets and do not # represent file-extension risk on their own. - is_existing_dir = os.path.isdir(os.path.realpath(os.path.expanduser(value))) + expanded_value = _safe_expand_path(value) + resolved_value = ( + _safe_realpath(expanded_value) if expanded_value is not None else None + ) + is_existing_dir = bool(resolved_value and os.path.isdir(resolved_value)) has_path_markers = any( char in value for char in ["/", "\\", "*", "?", "[", "]"] ) or value.startswith(("~", ".")) diff --git a/requirements-fuzz.txt b/requirements-fuzz.txt new file mode 100644 index 0000000..6388feb --- /dev/null +++ b/requirements-fuzz.txt @@ -0,0 +1,2 @@ +-r requirements.txt +atheris>=2.3.0; platform_system != "Windows" diff --git a/requirements.txt b/requirements.txt index 4ef1751..e54daaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pexpect pyparsing +hypothesis>=6.0.0 diff --git a/test/test_security_attack_surface_unit_part2.py b/test/test_security_attack_surface_unit_part2.py index 69909de..7e8cfd2 100644 --- a/test/test_security_attack_surface_unit_part2.py +++ b/test/test_security_attack_surface_unit_part2.py @@ -398,6 +398,31 @@ def test_check_path_should_expand_brace_operands_like_shell(self): ), ) + def test_check_path_rejects_nul_byte_path_without_crashing(self): + """Malformed NUL-byte path operands should fail closed without exceptions.""" + conf = CheckConfig( + self.args + ["--path=['/tmp']", "--strict=0"] + ).returnconf() + ret, _conf = sec.check_path("ls /tmp/\x00crash", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + def test_check_path_rejects_nul_byte_tilde_path_without_crashing(self): + """Malformed tilde-prefixed NUL paths should fail closed without exceptions.""" + conf = CheckConfig( + self.args + ["--path=['/tmp']", "--strict=0"] + ).returnconf() + ret, _conf = sec.check_path("ls ~\x00crash", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + @patch("lshell.sec._safe_realpath", side_effect=lambda path: path) + @patch("lshell.sec.glob.iglob") + def test_expand_shell_wildcards_rejects_excessive_matches(self, mock_iglob, _mock_realpath): + """Massive wildcard expansions should fail closed instead of exhausting memory.""" + mock_iglob.return_value = ( + f"/tmp/match-{index}" for index in range(sec.MAX_WILDCARD_MATCHES + 1) + ) + self.assertEqual(sec.expand_shell_wildcards("/tmp/**"), []) + @patch("lshell.utils.exec_cmd") def test_cmd_parse_execute_trusted_protocol_blocks_non_protocol_chained_command( self, mock_exec @@ -464,6 +489,18 @@ def test_check_allowed_file_extensions_allows_existing_directory_target(self): self.assertTrue(allowed) self.assertIsNone(blocked) + def test_check_allowed_file_extensions_handles_nul_byte_path_without_crashing(self): + """Malformed NUL-byte values should be processed safely.""" + allowed, blocked = sec.check_allowed_file_extensions("cat /tmp/\x00bad", [".txt"]) + self.assertFalse(allowed) + self.assertEqual(blocked, [""]) + + def test_check_allowed_file_extensions_handles_nul_byte_tilde_without_crashing(self): + """Malformed tilde-prefixed NUL values should be processed safely.""" + allowed, blocked = sec.check_allowed_file_extensions("cat ~\x00bad", [".txt"]) + self.assertFalse(allowed) + self.assertEqual(blocked, [""]) + def test_config_rejects_message_override_with_unknown_placeholder(self): """Fail closed when a custom message references unsupported placeholders.""" with self.assertRaises(SystemExit): diff --git a/test/test_security_property_based_unit.py b/test/test_security_property_based_unit.py new file mode 100644 index 0000000..ce383e5 --- /dev/null +++ b/test/test_security_property_based_unit.py @@ -0,0 +1,269 @@ +"""Property-based security tests for parser, expansion, and path ACL checks.""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from lshell import sec +from lshell import utils + +try: + from hypothesis import HealthCheck + from hypothesis import assume + from hypothesis import given + from hypothesis import settings + from hypothesis import strategies as st +except ImportError: # pragma: no cover - environment-dependent skip + class _DummyStrategy: + """Placeholder strategy object used when Hypothesis is unavailable.""" + + class _DummyStrategies: + """Minimal strategy API shim so tests can be collected and skipped.""" + + @staticmethod + def characters(*_args, **_kwargs): + return _DummyStrategy() + + @staticmethod + def from_regex(*_args, **_kwargs): + return _DummyStrategy() + + @staticmethod + def integers(*_args, **_kwargs): + return _DummyStrategy() + + @staticmethod + def text(*_args, **_kwargs): + return _DummyStrategy() + + @staticmethod + def sampled_from(*_args, **_kwargs): + return _DummyStrategy() + + @staticmethod + def composite(_function): + def _wrapper(*_args, **_kwargs): + return _DummyStrategy() + + return _wrapper + + class HealthCheck: # pragma: no cover - shim only + too_slow = "too_slow" + + def assume(_condition): + """No-op assume shim used only when tests are skipped.""" + return None + + def settings(*_args, **_kwargs): + """Pass-through decorator when Hypothesis is unavailable.""" + + def _decorator(function): + return function + + return _decorator + + def given(*_args, **_kwargs): + """Skip decorated tests when Hypothesis is unavailable.""" + + def _decorator(function): + return unittest.skip("hypothesis is not installed")(function) + + return _decorator + + st = _DummyStrategies() + + +_OPERATOR_TOKENS = ["&&", "||", "|", ";", "&"] +_PAYLOAD_ALPHABET = st.characters( + blacklist_characters=['"', "'", "\\", "\n", "\r"], + min_codepoint=32, + max_codepoint=126, +) +_NAME_STRATEGY = st.from_regex(r"[a-z]{3,8}", fullmatch=True) + + +@st.composite +def _quoted_operator_sequence(draw): + """Build `echo "payload"` command chains with explicit operator tokens.""" + command_count = draw(st.integers(min_value=1, max_value=4)) + chunks = [] + expected = [] + + for index in range(command_count): + payload = draw(st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=12)) + command = f'echo "{payload}"' + chunks.append(command) + expected.append(command) + if index < command_count - 1: + operator = draw(st.sampled_from(_OPERATOR_TOKENS)) + chunks.append(operator) + expected.append(operator) + + return " ".join(chunks), expected + + +def _path_conf(allowed_path, denied_paths=None): + """Create a minimal path-only security config for `sec.check_path`.""" + denied = denied_paths or [] + allow_acl = f"{os.path.realpath(allowed_path)}|" + deny_acl = "".join(f"{os.path.realpath(path)}|" for path in denied) + return {"path": [allow_acl, deny_acl]} + + +class TestSecurityPropertyBased(unittest.TestCase): + """Property-driven tests for parser/auth hardening invariants.""" + + @settings(max_examples=100, deadline=None) + @given(sequence=_quoted_operator_sequence()) + def test_split_command_sequence_round_trips_known_operator_sequences(self, sequence): + """Parser should preserve explicit command/operator structure.""" + line, expected = sequence + self.assertEqual(utils.split_command_sequence(line), expected) + + @settings(max_examples=100, deadline=None) + @given(payload=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=24)) + def test_split_command_sequence_does_not_split_operators_inside_single_quotes( + self, payload + ): + """Top-level split must ignore operators that are inside single quotes.""" + line = f"echo '{payload}' && echo done" + self.assertEqual( + utils.split_command_sequence(line), + [f"echo '{payload}'", "&&", "echo done"], + ) + + @settings(max_examples=100, deadline=None) + @given(payload=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=24)) + def test_split_command_sequence_does_not_split_operators_inside_double_quotes( + self, payload + ): + """Top-level split must ignore operators that are inside double quotes.""" + line = f'echo "{payload}" || echo done' + self.assertEqual( + utils.split_command_sequence(line), + [f'echo "{payload}"', "||", "echo done"], + ) + + @settings(max_examples=80, deadline=None) + @given( + payload=st.text( + st.characters( + blacklist_characters=["'", ")", "\\", "\n", "\r"], + min_codepoint=32, + max_codepoint=126, + ), + min_size=1, + max_size=16, + ) + ) + def test_split_command_sequence_does_not_split_operators_inside_substitution( + self, payload + ): + """Top-level split must ignore operators inside command substitution.""" + line = f"echo $(printf '%s' '{payload}') | wc -c" + self.assertEqual( + utils.split_command_sequence(line), + [f"echo $(printf '%s' '{payload}')", "|", "wc -c"], + ) + + @settings(max_examples=100, deadline=None) + @given( + variable=st.from_regex(r"[A-Z_][A-Z0-9_]{0,9}", fullmatch=True), + value=st.text( + st.characters( + blacklist_characters=["\\", "'", '"', "\n", "\r", "$"], + min_codepoint=32, + max_codepoint=126, + ), + min_size=0, + max_size=20, + ), + ) + def test_expand_vars_quoted_keeps_single_quoted_variable_literal(self, variable, value): + """Single-quoted `$VAR` must remain literal while unquoted `$VAR` expands.""" + line = f"echo '${variable}' ${variable}" + with patch.dict(os.environ, {variable: value}, clear=False): + expanded = utils.expand_vars_quoted(line) + self.assertEqual(expanded, f"echo '${variable}' {value}") + + @settings(max_examples=100, deadline=None) + @given( + variable=st.from_regex(r"[A-Z_][A-Z0-9_]{0,9}", fullmatch=True), + value=st.text( + st.characters( + blacklist_characters=["\\", "'", '"', "\n", "\r", "$"], + min_codepoint=32, + max_codepoint=126, + ), + min_size=0, + max_size=20, + ), + ) + def test_expand_vars_quoted_keeps_backslash_escaped_dollar_literal( + self, variable, value + ): + """Escaped dollars must remain literal and not trigger expansion.""" + line = rf"echo \${variable} ${variable}" + with patch.dict(os.environ, {variable: value}, clear=False): + expanded = utils.expand_vars_quoted(line) + self.assertEqual(expanded, rf"echo \${variable} {value}") + + @settings(max_examples=60, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + allowed_name=_NAME_STRATEGY, + sibling_suffix=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + ) + def test_check_path_blocks_sibling_prefix_confusion_property( + self, allowed_name, sibling_suffix + ): + """Allowing `/x/allow` must not allow sibling `/x/allow-*` paths.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + allowed_dir = os.path.join(tempdir, allowed_name) + sibling_dir = os.path.join(tempdir, f"{allowed_name}-{sibling_suffix}") + assume(os.path.realpath(allowed_dir) != os.path.realpath(sibling_dir)) + os.makedirs(allowed_dir, exist_ok=True) + os.makedirs(sibling_dir, exist_ok=True) + + conf = _path_conf(allowed_dir) + ret, _ = sec.check_path(f"ls {sibling_dir}", conf, completion=1, strict=0) + self.assertEqual(ret, 1) + + @settings(max_examples=60, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + allowed_name=_NAME_STRATEGY, + child_a=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + child_b=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + ) + def test_check_path_allows_nested_descendants_property( + self, allowed_name, child_a, child_b + ): + """Paths nested under an allow root should pass ACL checks.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + allowed_dir = os.path.join(tempdir, allowed_name) + nested_dir = os.path.join(allowed_dir, child_a, child_b) + os.makedirs(nested_dir, exist_ok=True) + + conf = _path_conf(allowed_dir) + ret, _ = sec.check_path(f"ls {nested_dir}", conf, completion=1, strict=0) + self.assertEqual(ret, 0) + + @settings(max_examples=40, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + allowed_name=_NAME_STRATEGY, + denied_name=st.from_regex(r"[a-z]{3,8}", fullmatch=True), + ) + def test_check_path_glob_fails_closed_when_any_match_is_denied( + self, allowed_name, denied_name + ): + """Glob checks should fail closed if any expanded target is outside allow roots.""" + assume(allowed_name != denied_name) + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + allowed_dir = os.path.join(tempdir, allowed_name) + denied_dir = os.path.join(tempdir, denied_name) + os.makedirs(allowed_dir, exist_ok=True) + os.makedirs(denied_dir, exist_ok=True) + + conf = _path_conf(allowed_dir) + ret, _ = sec.check_path(f"ls {tempdir}/*", conf, completion=1, strict=0) + self.assertEqual(ret, 1) From a8b313d66d565391ee5099fe7a31da7881d9211b Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Wed, 11 Mar 2026 22:12:54 -0400 Subject: [PATCH 2/7] Fix pylint --- fuzz/fuzz_parser_policy.py | 4 ++++ test/test_security_property_based_unit.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/fuzz/fuzz_parser_policy.py b/fuzz/fuzz_parser_policy.py index 3b1f095..3c17471 100644 --- a/fuzz/fuzz_parser_policy.py +++ b/fuzz/fuzz_parser_policy.py @@ -20,15 +20,19 @@ class _NullLog: """Minimal logger required by security helpers during fuzzing.""" def critical(self, _message): + """Discard critical log messages during fuzzing.""" return None def error(self, _message): + """Discard error log messages during fuzzing.""" return None def warning(self, _message): + """Discard warning log messages during fuzzing.""" return None def info(self, _message): + """Discard info log messages during fuzzing.""" return None diff --git a/test/test_security_property_based_unit.py b/test/test_security_property_based_unit.py index ce383e5..3a5c911 100644 --- a/test/test_security_property_based_unit.py +++ b/test/test_security_property_based_unit.py @@ -23,32 +23,40 @@ class _DummyStrategies: @staticmethod def characters(*_args, **_kwargs): + """Return placeholder `characters` strategy.""" return _DummyStrategy() @staticmethod def from_regex(*_args, **_kwargs): + """Return placeholder `from_regex` strategy.""" return _DummyStrategy() @staticmethod def integers(*_args, **_kwargs): + """Return placeholder `integers` strategy.""" return _DummyStrategy() @staticmethod def text(*_args, **_kwargs): + """Return placeholder `text` strategy.""" return _DummyStrategy() @staticmethod def sampled_from(*_args, **_kwargs): + """Return placeholder `sampled_from` strategy.""" return _DummyStrategy() @staticmethod def composite(_function): + """Return placeholder composite-decorator wrapper.""" def _wrapper(*_args, **_kwargs): return _DummyStrategy() return _wrapper class HealthCheck: # pragma: no cover - shim only + """Fallback shim exposing Hypothesis health-check constants.""" + too_slow = "too_slow" def assume(_condition): From 27b96cbb279d1e71ec7a0a1870dcb300fe199a9e Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 09:18:12 -0400 Subject: [PATCH 3/7] Update payload alphabet to exclude backtick and dollar characters --- test/test_security_property_based_unit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_security_property_based_unit.py b/test/test_security_property_based_unit.py index 3a5c911..416fb85 100644 --- a/test/test_security_property_based_unit.py +++ b/test/test_security_property_based_unit.py @@ -83,8 +83,10 @@ def _decorator(function): _OPERATOR_TOKENS = ["&&", "||", "|", ";", "&"] +# Keep payloads free of expansion/backtick metacharacters so the generated +# lines stay within this test's "known-valid quoting" scope. _PAYLOAD_ALPHABET = st.characters( - blacklist_characters=['"', "'", "\\", "\n", "\r"], + blacklist_characters=['"', "'", "\\", "\n", "\r", "`", "$"], min_codepoint=32, max_codepoint=126, ) From 405cbd0cf1f2515db369e36dd5257fc5f0ebe2a7 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 20:33:56 -0400 Subject: [PATCH 4/7] Refactor fuzzing commands and update Dockerfile for dependency installation --- .github/workflows/pytest.yml | 2 +- .gitignore | 1 + Dockerfile | 5 + README.md | 2 +- fuzz/fuzz_parser_policy.py | 19 +++- justfile | 3 +- test/test_security_property_based_unit.py | 109 +++++++++++++++++++++- 7 files changed, 135 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f9c7694..d0165b3 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -45,7 +45,7 @@ jobs: - name: Fuzz security parser/policy timeout-minutes: 45 run: | - just fuzz-security-parser-policy 20000 + just test-fuzz-security-parser 20000 ssh-e2e: name: SSH E2E (Docker + Ansible) diff --git a/.gitignore b/.gitignore index 851029f..2354b86 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ test.lsh .pylint_cache/ .pylint.d/ +.hypothesis/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3c098c0..fbae2ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,11 @@ ENV PYTHONPATH=/home/testuser/lshell # Copy the code and requirements COPY . /home/testuser/lshell +# Install test/runtime Python dependencies from the repository requirements. +# Debian/Ubuntu images may require --break-system-packages (PEP 668). +RUN python3 -m pip install --no-cache-dir -r /home/testuser/lshell/requirements.txt \ + || python3 -m pip install --break-system-packages --no-cache-dir -r /home/testuser/lshell/requirements.txt + # Install lshell from the source RUN python3 setup.py install diff --git a/README.md b/README.md index afe2c3f..631ea1c 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ just sample-ubuntu 01_baseline_allowlist.conf Run Atheris fuzzing in Debian Docker (dependencies installed in-container): ```bash -just fuzz-security-parser-policy 20000 +just test-fuzz-security-parser 20000 ``` Optional local run (if you want to fuzz outside Docker): diff --git a/fuzz/fuzz_parser_policy.py b/fuzz/fuzz_parser_policy.py index 3c17471..7a31150 100644 --- a/fuzz/fuzz_parser_policy.py +++ b/fuzz/fuzz_parser_policy.py @@ -12,6 +12,8 @@ ) from exc with atheris.instrument_imports(): + from lshell import parser as lshell_parser + from lshell import policy from lshell import sec from lshell import utils @@ -37,6 +39,7 @@ def info(self, _message): _FUZZ_TMP = tempfile.mkdtemp(prefix="lshell-fuzz-") +_FUZZ_PARSER = lshell_parser.LshellParser() def _base_conf(): @@ -68,15 +71,29 @@ def _base_conf(): def _fuzz_one_line(line): """Exercise parser and security check surfaces on one fuzzed command line.""" conf = _base_conf() + runtime_policy = { + "forbidden": conf["forbidden"], + "allowed": conf["allowed"], + "strict": 0, + "sudo_commands": conf["sudo_commands"], + "allowed_file_extensions": conf["allowed_file_extensions"], + "path": conf["path"], + } try: + parsed = _FUZZ_PARSER.parse(line) + if parsed is not None: + _FUZZ_PARSER.validate_command(parsed) + utils.split_command_sequence(line) utils.split_commands(line) utils.expand_vars_quoted(line, support_advanced_braced=True) utils.expand_vars_quoted(line, support_advanced_braced=False) + sec._path_tokens_from_line(line) sec.check_forbidden_chars(line, conf, strict=0) sec.check_path(line, conf, completion=1, strict=0) sec.check_secure(line, conf, strict=0) sec.check_allowed_file_extensions(line, [".txt", ".log"]) + policy.policy_command_decision(line, runtime_policy) except SystemExit: # check_secure/check_path may terminate on warning exhaustion; ignore. pass @@ -84,7 +101,7 @@ def _fuzz_one_line(line): def test_one_input(data): """Atheris entrypoint.""" - line = data.decode("utf-8", errors="ignore") + line = data.decode("latin-1") if not line: return _fuzz_one_line(line[:512]) diff --git a/justfile b/justfile index eedcbb6..7be38e4 100644 --- a/justfile +++ b/justfile @@ -189,7 +189,7 @@ test-lint-flake8: flake8 lshell test # Run Atheris fuzzing in Debian Docker container (host deps not required) -fuzz-security-parser-policy runs='20000': +test-fuzz-security-parser runs='20000': {{compose}} run --build --rm --entrypoint bash debian -lc "CLANG_BIN=clang python3 -m pip install --user --break-system-packages -r /app/requirements-fuzz.txt && PYTHONPATH=/app python3 /app/fuzz/fuzz_parser_policy.py -runs={{runs}}" # Full local validation in one command @@ -201,4 +201,5 @@ test-all: {{compose}} down -v --remove-orphans; \ exit $rc\ ' + just test-fuzz-security-parser just test-ssh-e2e diff --git a/test/test_security_property_based_unit.py b/test/test_security_property_based_unit.py index 416fb85..3bab35a 100644 --- a/test/test_security_property_based_unit.py +++ b/test/test_security_property_based_unit.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import patch +from lshell import policy from lshell import sec from lshell import utils @@ -113,10 +114,13 @@ def _quoted_operator_sequence(draw): return " ".join(chunks), expected -def _path_conf(allowed_path, denied_paths=None): +def _path_conf(allowed_paths, denied_paths=None): """Create a minimal path-only security config for `sec.check_path`.""" + if isinstance(allowed_paths, str): + allowed_paths = [allowed_paths] + allowed = allowed_paths or [] denied = denied_paths or [] - allow_acl = f"{os.path.realpath(allowed_path)}|" + allow_acl = "".join(f"{os.path.realpath(path)}|" for path in allowed) deny_acl = "".join(f"{os.path.realpath(path)}|" for path in denied) return {"path": [allow_acl, deny_acl]} @@ -131,6 +135,28 @@ def test_split_command_sequence_round_trips_known_operator_sequences(self, seque line, expected = sequence self.assertEqual(utils.split_command_sequence(line), expected) + @settings(max_examples=100, deadline=None) + @given(sequence=_quoted_operator_sequence()) + def test_split_commands_matches_non_operator_tokens_for_valid_sequences(self, sequence): + """`split_commands` should keep only command segments from valid sequences.""" + line, expected = sequence + expected_commands = [item for item in expected if item not in _OPERATOR_TOKENS] + self.assertEqual(utils.split_commands(line), expected_commands) + + @settings(max_examples=100, deadline=None) + @given( + left=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=12), + operator_a=st.sampled_from(_OPERATOR_TOKENS), + operator_b=st.sampled_from(_OPERATOR_TOKENS), + right=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=12), + ) + def test_split_command_sequence_rejects_adjacent_operators( + self, left, operator_a, operator_b, right + ): + """Two consecutive top-level operators should fail closed.""" + line = f'echo "{left}" {operator_a} {operator_b} echo "{right}"' + self.assertIsNone(utils.split_command_sequence(line)) + @settings(max_examples=100, deadline=None) @given(payload=st.text(_PAYLOAD_ALPHABET, min_size=1, max_size=24)) def test_split_command_sequence_does_not_split_operators_inside_single_quotes( @@ -277,3 +303,82 @@ def test_check_path_glob_fails_closed_when_any_match_is_denied( conf = _path_conf(allowed_dir) ret, _ = sec.check_path(f"ls {tempdir}/*", conf, completion=1, strict=0) self.assertEqual(ret, 1) + + @settings(max_examples=60, deadline=None, suppress_health_check=[HealthCheck.too_slow]) + @given( + root_name=_NAME_STRATEGY, + deny_name=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + reallow_name=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + leaf_name=st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True), + ) + def test_check_path_uses_specificity_with_reallow_over_broader_deny( + self, root_name, deny_name, reallow_name, leaf_name + ): + """Most-specific ACL prefix should win for deny/re-allow path chains.""" + with tempfile.TemporaryDirectory(prefix="lshell-path-prop-") as tempdir: + root_dir = os.path.join(tempdir, root_name) + denied_root = os.path.join(root_dir, deny_name) + reallowed_root = os.path.join(denied_root, reallow_name) + denied_leaf = os.path.join(denied_root, "blocked") + reallowed_leaf = os.path.join(reallowed_root, leaf_name) + os.makedirs(denied_leaf, exist_ok=True) + os.makedirs(reallowed_leaf, exist_ok=True) + + conf = _path_conf([root_dir, reallowed_root], [denied_root]) + denied_ret, _ = sec.check_path( + f"ls {denied_leaf}", conf, completion=1, strict=0 + ) + allowed_ret, _ = sec.check_path( + f"ls {reallowed_leaf}", conf, completion=1, strict=0 + ) + self.assertEqual(denied_ret, 1) + self.assertEqual(allowed_ret, 0) + + @settings(max_examples=80, deadline=None) + @given( + command=st.from_regex(r"[a-z]{3,10}", fullmatch=True), + strict=st.sampled_from([0, 1]), + ) + def test_policy_command_decision_unknown_command_reason_reflects_strict_mode( + self, command, strict + ): + """Policy decision reasons should differ between strict/non-strict modes.""" + assume(command != "echo") + runtime_policy = { + "forbidden": [], + "allowed": ["echo"], + "strict": strict, + "sudo_commands": [], + "allowed_file_extensions": [], + "path": ["", ""], + } + + decision = policy.policy_command_decision(f"{command} arg", runtime_policy) + self.assertFalse(decision["allowed"]) + if strict: + self.assertIn("forbidden command", decision["reason"]) + else: + self.assertIn("unknown syntax", decision["reason"]) + + @settings(max_examples=80, deadline=None) + @given( + variable=st.from_regex(r"[A-Z_][A-Z0-9_]{0,7}", fullmatch=True), + value=st.from_regex(r"[A-Za-z0-9_]{0,8}", fullmatch=True), + ) + def test_policy_command_decision_allows_assignment_prefix_for_allowlisted_full_command( + self, variable, value + ): + """Allowlist checks should still pass when command uses assignment prefixes.""" + runtime_policy = { + "forbidden": [], + "allowed": ["echo ok"], + "strict": 1, + "sudo_commands": [], + "allowed_file_extensions": [], + "path": ["", ""], + } + + decision = policy.policy_command_decision( + f"{variable}={value} echo ok", runtime_policy + ) + self.assertTrue(decision["allowed"]) From 9629af075c2c5015f8e21108275b3d1a16799934 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 22:19:18 -0400 Subject: [PATCH 5/7] Refactor GitHub Actions workflow to separate testing and linting steps, update dependencies installation, and improve readability --- .github/workflows/pytest.yml | 44 ++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index d0165b3..af2f297 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -13,7 +13,7 @@ permissions: jobs: build: - + name: Pytest Unit/Integration Tests runs-on: ubuntu-latest steps: @@ -29,26 +29,56 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest + pip install pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Install the lshell package run: pip install . - - name: Lint with flake8 + - name: Test with pytest + run: | + pytest + + lint: + name: Lint + Flake8 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Set up Python path + run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV + - name: Install dependencies run: | + python -m pip install --upgrade pip + pip install pylint flake8 + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install the lshell package + run: pip install . + - name: Lint with pylint and flake8 + run: | + pylint $(git ls-files '*.py') # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + + fuzz-security-parser: + name: Fuzz Security Parser/Policy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install just + uses: taiki-e/install-action@just - name: Fuzz security parser/policy timeout-minutes: 45 run: | just test-fuzz-security-parser 20000 ssh-e2e: - name: SSH E2E (Docker + Ansible) + name: SSH end-to-end (Docker/Ansible) runs-on: ubuntu-latest steps: From 60e6ce63832242a37d663155b0e9ee91178ffffb Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 22:24:35 -0400 Subject: [PATCH 6/7] Separate GA workflows for fuzzing, linting, and SSH end-to-end testing in different files --- .github/workflows/fuzz-security-parser.yml | 23 ++++++++ .github/workflows/pylint.yml | 10 ++-- .github/workflows/pytest.yml | 62 +--------------------- .github/workflows/ssh-e2e.yml | 25 +++++++++ 4 files changed, 57 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/fuzz-security-parser.yml create mode 100644 .github/workflows/ssh-e2e.yml diff --git a/.github/workflows/fuzz-security-parser.yml b/.github/workflows/fuzz-security-parser.yml new file mode 100644 index 0000000..5113bbd --- /dev/null +++ b/.github/workflows/fuzz-security-parser.yml @@ -0,0 +1,23 @@ +name: Fuzz Security Parser/Policy + +on: + push: + branches: [ "master" ] + pull_request: + +permissions: + contents: read + +jobs: + fuzz-security-parser: + name: Fuzz Security Parser/Policy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install just + uses: taiki-e/install-action@just + - name: Fuzz security parser/policy + timeout-minutes: 45 + run: | + just test-fuzz-security-parser 20000 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 9613649..b24abde 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,4 +1,4 @@ -name: Pylint +name: Lint + Flake8 on: [push] @@ -19,8 +19,12 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pylint + pip install pylint flake8 if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Analysing the code with pylint + - name: Analyse with pylint and flake8 run: | pylint $(git ls-files '*.py') + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index af2f297..2f6b8ea 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,7 +1,6 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python +# This workflow installs Python dependencies and runs pytest. -name: Pytest +name: Pytest Unit/Integration Tests on: push: @@ -13,7 +12,6 @@ permissions: jobs: build: - name: Pytest Unit/Integration Tests runs-on: ubuntu-latest steps: @@ -36,59 +34,3 @@ jobs: - name: Test with pytest run: | pytest - - lint: - name: Lint + Flake8 - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Set up Python path - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install the lshell package - run: pip install . - - name: Lint with pylint and flake8 - run: | - pylint $(git ls-files '*.py') - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - fuzz-security-parser: - name: Fuzz Security Parser/Policy - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Install just - uses: taiki-e/install-action@just - - name: Fuzz security parser/policy - timeout-minutes: 45 - run: | - just test-fuzz-security-parser 20000 - - ssh-e2e: - name: SSH end-to-end (Docker/Ansible) - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Run SSH end-to-end tests - run: | - docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from ansible-runner ansible-runner - - - name: Cleanup SSH E2E stack - if: always() - run: | - docker compose -f docker-compose.e2e.yml down -v --remove-orphans diff --git a/.github/workflows/ssh-e2e.yml b/.github/workflows/ssh-e2e.yml new file mode 100644 index 0000000..bd1df0a --- /dev/null +++ b/.github/workflows/ssh-e2e.yml @@ -0,0 +1,25 @@ +name: SSH end-to-end (Docker/Ansible) + +on: + push: + branches: [ "master" ] + pull_request: + +permissions: + contents: read + +jobs: + ssh-e2e: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Run SSH end-to-end tests + run: | + docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from ansible-runner ansible-runner + + - name: Cleanup SSH E2E stack + if: always() + run: | + docker compose -f docker-compose.e2e.yml down -v --remove-orphans From a371cd445e0fa84b25a56c2c4a2bb94a794564c1 Mon Sep 17 00:00:00 2001 From: Ignace Mouzannar Date: Thu, 12 Mar 2026 22:40:46 -0400 Subject: [PATCH 7/7] Merge all tests into a single file --- .github/workflows/fuzz-security-parser.yml | 23 ------ .github/workflows/lshell-tests.yml | 88 ++++++++++++++++++++++ .github/workflows/pylint.yml | 30 -------- .github/workflows/pytest.yml | 36 --------- .github/workflows/ssh-e2e.yml | 25 ------ 5 files changed, 88 insertions(+), 114 deletions(-) delete mode 100644 .github/workflows/fuzz-security-parser.yml create mode 100644 .github/workflows/lshell-tests.yml delete mode 100644 .github/workflows/pylint.yml delete mode 100644 .github/workflows/pytest.yml delete mode 100644 .github/workflows/ssh-e2e.yml diff --git a/.github/workflows/fuzz-security-parser.yml b/.github/workflows/fuzz-security-parser.yml deleted file mode 100644 index 5113bbd..0000000 --- a/.github/workflows/fuzz-security-parser.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Fuzz Security Parser/Policy - -on: - push: - branches: [ "master" ] - pull_request: - -permissions: - contents: read - -jobs: - fuzz-security-parser: - name: Fuzz Security Parser/Policy - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Install just - uses: taiki-e/install-action@just - - name: Fuzz security parser/policy - timeout-minutes: 45 - run: | - just test-fuzz-security-parser 20000 diff --git a/.github/workflows/lshell-tests.yml b/.github/workflows/lshell-tests.yml new file mode 100644 index 0000000..7e0cbe5 --- /dev/null +++ b/.github/workflows/lshell-tests.yml @@ -0,0 +1,88 @@ +name: Lshell Tests + +on: + push: + branches: [ "master" ] + pull_request: + +permissions: + contents: read + +jobs: + pytest: + name: Pytest Unit/Integration Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Set up Python path + run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install the lshell package + run: pip install . + - name: Test with pytest + run: | + pytest + + lint: + name: Lint + Flake8 + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Set up Python path + run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint flake8 + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Analyse with pylint and flake8 + run: | + pylint $(git ls-files '*.py') + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + fuzz-security-parser: + name: Fuzz Security Parser/Policy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install just + uses: taiki-e/install-action@just + - name: Fuzz security parser/policy + timeout-minutes: 45 + run: | + just test-fuzz-security-parser 20000 + + ssh-e2e: + name: SSH End-to-End (Docker + Ansible) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Run SSH end-to-end tests + run: | + docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from ansible-runner ansible-runner + - name: Cleanup SSH E2E stack + if: always() + run: | + docker compose -f docker-compose.e2e.yml down -v --remove-orphans diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml deleted file mode 100644 index b24abde..0000000 --- a/.github/workflows/pylint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Lint + Flake8 - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Set up Python path - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pylint flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Analyse with pylint and flake8 - run: | - pylint $(git ls-files '*.py') - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index 2f6b8ea..0000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,36 +0,0 @@ -# This workflow installs Python dependencies and runs pytest. - -name: Pytest Unit/Integration Tests - -on: - push: - branches: [ "master" ] - pull_request: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Install just - uses: taiki-e/install-action@just - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Set up Python path - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install the lshell package - run: pip install . - - name: Test with pytest - run: | - pytest diff --git a/.github/workflows/ssh-e2e.yml b/.github/workflows/ssh-e2e.yml deleted file mode 100644 index bd1df0a..0000000 --- a/.github/workflows/ssh-e2e.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: SSH end-to-end (Docker/Ansible) - -on: - push: - branches: [ "master" ] - pull_request: - -permissions: - contents: read - -jobs: - ssh-e2e: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Run SSH end-to-end tests - run: | - docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from ansible-runner ansible-runner - - - name: Cleanup SSH E2E stack - if: always() - run: | - docker compose -f docker-compose.e2e.yml down -v --remove-orphans