From 246c4fbbe09c82e6d0b72f783e2340c96603c016 Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Fri, 19 Sep 2025 11:26:24 +0200 Subject: [PATCH 1/7] allow libinjection denylist to deny python module executions Signed-off-by: Juanjo Alvarez --- lib-injection/sources/denied_executables.txt | 2 + lib-injection/sources/sitecustomize.py | 12 +++ riotfile.py | 2 +- tests/lib_injection/test_denylist.py | 98 ++++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/lib_injection/test_denylist.py diff --git a/lib-injection/sources/denied_executables.txt b/lib-injection/sources/denied_executables.txt index 67aef135e03..eb0cc69545f 100644 --- a/lib-injection/sources/denied_executables.txt +++ b/lib-injection/sources/denied_executables.txt @@ -1205,3 +1205,5 @@ usr/libexec/grepconf.sh uwsgi # crashtracker receiver _dd_crashtracker_receiver +# Python modules run from the interpreter +-m py_compile \ No newline at end of file diff --git a/lib-injection/sources/sitecustomize.py b/lib-injection/sources/sitecustomize.py index 27b43afd802..ade4992adc5 100644 --- a/lib-injection/sources/sitecustomize.py +++ b/lib-injection/sources/sitecustomize.py @@ -262,12 +262,24 @@ def get_first_incompatible_sysarg(): _log("Checking sys.args: len(sys.argv): %s" % (len(sys.argv),), level="debug") if len(sys.argv) <= 1: return + + # Check the main executable first argument = sys.argv[0] _log("Is argument %s in deny-list?" % (argument,), level="debug") if argument in EXECUTABLES_DENY_LIST or os.path.basename(argument) in EXECUTABLES_DENY_LIST: _log("argument %s is in deny-list" % (argument,), level="debug") return argument + # Check for -m module patterns (e.g., python -m py_compile which would match the -m py_compile entry) + if len(sys.argv) >= 3 and sys.argv[1] == "-m": + module_pattern = "-m %s" % sys.argv[2] + _log("Checking -m module pattern: %s" % (module_pattern,), level="debug") + if module_pattern in EXECUTABLES_DENY_LIST: + _log("-m module pattern %s is in deny-list" % (module_pattern,), level="debug") + return module_pattern + + return None + def _inject(): global DDTRACE_VERSION diff --git a/riotfile.py b/riotfile.py index ede6fcf8152..d7322560b04 100644 --- a/riotfile.py +++ b/riotfile.py @@ -514,7 +514,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT ), Venv( name="lib_injection", - command="pytest {cmdargs} tests/lib_injection/test_guardrails.py", + command="pytest {cmdargs} tests/lib_injection/", venvs=[ Venv( pys=select_pys(), diff --git a/tests/lib_injection/test_denylist.py b/tests/lib_injection/test_denylist.py new file mode 100644 index 00000000000..225f654b8a7 --- /dev/null +++ b/tests/lib_injection/test_denylist.py @@ -0,0 +1,98 @@ +""" +Unit tests for the -m module denial functionality in sitecustomize.py. +These tests directly test the denial logic without requiring the complex venv fixtures. +""" +import os +import sys +import tempfile +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def mock_sitecustomize(): + """Mock the sitecustomize module for testing.""" + # Add the lib-injection sources to sys.path + lib_injection_path = os.path.join(os.path.dirname(__file__), "../../lib-injection/sources") + if lib_injection_path not in sys.path: + sys.path.insert(0, lib_injection_path) + + # Import the module + import sitecustomize + + return sitecustomize + + +def test_python_module_denylist_denied(mock_sitecustomize): + """Test that -m py_compile is detected and denied.""" + # Build the deny list + mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() + + # Verify -m py_compile is in the deny list + assert "-m py_compile" in mock_sitecustomize.EXECUTABLES_DENY_LIST, "-m py_compile should be in deny list" + + # Test detection of -m py_compile pattern + with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "py_compile", "test.py"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result == "-m py_compile", f"Expected '-m py_compile', got '{result}'" + + +def test_regular_python_nondenied(mock_sitecustomize): + """Test that normal Python execution is not denied.""" + mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() + + # Test normal python execution + with patch.object(sys, "argv", ["/usr/bin/python3.10", "script.py"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"Normal python execution should not be denied, got '{result}'" + + +def test_python_module_notdenylist_notdenied(mock_sitecustomize): + """Test that other -m modules are not denied.""" + mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() + + # Test -m json.tool (should not be denied) + with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "json.tool"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"python -m json.tool should not be denied, got '{result}'" + + # Test -m pip (should not be denied) + with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "pip", "install", "something"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"python -m pip should not be denied, got '{result}'" + + +def test_binary_denylist_denied(mock_sitecustomize): + """Test that /usr/bin/py3compile is still denied (existing functionality).""" + mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() + + # Verify /usr/bin/py3compile is in deny list + assert ( + "/usr/bin/py3compile" in mock_sitecustomize.EXECUTABLES_DENY_LIST + ), "/usr/bin/py3compile should be in deny list" + + # Test traditional py3compile execution + with patch.object(sys, "argv", ["/usr/bin/py3compile", "test.py"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result == "/usr/bin/py3compile", f"Expected '/usr/bin/py3compile', got '{result}'" + + +def test_module_denial_edge_cases(mock_sitecustomize): + """Test edge cases for the module denial logic.""" + mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() + + # Test insufficient arguments (should not crash) + with patch.object(sys, "argv", ["/usr/bin/python3.10"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"Single argument should not be denied, got '{result}'" + + # Test -m without module name (should not crash) + with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"-m without module should not be denied, got '{result}'" + + # Test no sys.argv (should not crash) + with patch("builtins.hasattr", return_value=False): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"Missing sys.argv should not be denied, got '{result}'" From 28b1e15b8291db82bc5968d8ca6a1ec3ba190602 Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Fri, 19 Sep 2025 11:33:17 +0200 Subject: [PATCH 2/7] changelog Signed-off-by: Juanjo Alvarez --- .../libinjection-denytlist-modules-a5e0407ed6b8166a.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 releasenotes/notes/libinjection-denytlist-modules-a5e0407ed6b8166a.yaml diff --git a/releasenotes/notes/libinjection-denytlist-modules-a5e0407ed6b8166a.yaml b/releasenotes/notes/libinjection-denytlist-modules-a5e0407ed6b8166a.yaml new file mode 100644 index 00000000000..ad5965ca1fd --- /dev/null +++ b/releasenotes/notes/libinjection-denytlist-modules-a5e0407ed6b8166a.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + libinjection: allow python module executed with ``-m`` entries in the denylist. From 29cf090438b9d6df0f801732e19ea6927ab0a7ed Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Fri, 19 Sep 2025 11:40:13 +0200 Subject: [PATCH 3/7] improve tests Signed-off-by: Juanjo Alvarez --- tests/lib_injection/test_denylist.py | 83 +++++++++++++++++----------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/tests/lib_injection/test_denylist.py b/tests/lib_injection/test_denylist.py index 225f654b8a7..29c9ab27ec4 100644 --- a/tests/lib_injection/test_denylist.py +++ b/tests/lib_injection/test_denylist.py @@ -1,10 +1,5 @@ -""" -Unit tests for the -m module denial functionality in sitecustomize.py. -These tests directly test the denial logic without requiring the complex venv fixtures. -""" import os import sys -import tempfile from unittest.mock import patch import pytest @@ -12,87 +7,109 @@ @pytest.fixture def mock_sitecustomize(): - """Mock the sitecustomize module for testing.""" - # Add the lib-injection sources to sys.path lib_injection_path = os.path.join(os.path.dirname(__file__), "../../lib-injection/sources") if lib_injection_path not in sys.path: sys.path.insert(0, lib_injection_path) - # Import the module import sitecustomize return sitecustomize def test_python_module_denylist_denied(mock_sitecustomize): - """Test that -m py_compile is detected and denied.""" - # Build the deny list mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - - # Verify -m py_compile is in the deny list assert "-m py_compile" in mock_sitecustomize.EXECUTABLES_DENY_LIST, "-m py_compile should be in deny list" - - # Test detection of -m py_compile pattern with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "py_compile", "test.py"]): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result == "-m py_compile", f"Expected '-m py_compile', got '{result}'" def test_regular_python_nondenied(mock_sitecustomize): - """Test that normal Python execution is not denied.""" mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - - # Test normal python execution with patch.object(sys, "argv", ["/usr/bin/python3.10", "script.py"]): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"Normal python execution should not be denied, got '{result}'" def test_python_module_notdenylist_notdenied(mock_sitecustomize): - """Test that other -m modules are not denied.""" mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - - # Test -m json.tool (should not be denied) with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "json.tool"]): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"python -m json.tool should not be denied, got '{result}'" - # Test -m pip (should not be denied) with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "pip", "install", "something"]): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"python -m pip should not be denied, got '{result}'" def test_binary_denylist_denied(mock_sitecustomize): - """Test that /usr/bin/py3compile is still denied (existing functionality).""" mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - # Verify /usr/bin/py3compile is in deny list - assert ( - "/usr/bin/py3compile" in mock_sitecustomize.EXECUTABLES_DENY_LIST - ), "/usr/bin/py3compile should be in deny list" + denied_binaries = ["/usr/bin/py3compile", "/usr/bin/gcc", "/usr/bin/make", "/usr/sbin/chkrootkit"] + + for binary in denied_binaries: + assert binary in mock_sitecustomize.EXECUTABLES_DENY_LIST, f"{binary} should be in deny list" + with patch.object(sys, "argv", [binary, "some", "args"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result == binary, f"Expected '{binary}' to be denied, got '{result}'" - # Test traditional py3compile execution - with patch.object(sys, "argv", ["/usr/bin/py3compile", "test.py"]): + with patch.object(sys, "argv", ["py3compile", "test.py"]): result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result == "/usr/bin/py3compile", f"Expected '/usr/bin/py3compile', got '{result}'" + assert result == "py3compile", f"Expected 'py3compile' (basename) to be denied, got '{result}'" + + +def test_binary_not_in_denylist_allowed(mock_sitecustomize): + mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() + + candidate_allowed_binaries = [ + "/usr/bin/python3", + "/usr/bin/python3.10", + "/bin/bash", + "/usr/bin/cat", + "/usr/bin/ls", + "/usr/bin/echo", + "/usr/bin/node", + "/usr/bin/ruby", + "/usr/bin/java", + "/usr/bin/wget", + "/usr/bin/vim", + "/usr/bin/nano", + "/usr/local/bin/custom_app", + ] + + allowed_binaries = [] + for binary in candidate_allowed_binaries: + if ( + binary not in mock_sitecustomize.EXECUTABLES_DENY_LIST + and os.path.basename(binary) not in mock_sitecustomize.EXECUTABLES_DENY_LIST + ): + allowed_binaries.append(binary) + + for binary in allowed_binaries: + with patch.object(sys, "argv", [binary, "some", "args"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"Expected '{binary}' to be allowed, but got denied: '{result}'" + + safe_basenames = ["myapp", "custom_script", "user_program"] + for basename in safe_basenames: + assert basename not in mock_sitecustomize.EXECUTABLES_DENY_LIST, f"'{basename}' should not be in deny list" + + with patch.object(sys, "argv", [basename, "arg1", "arg2"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"Expected '{basename}' to be allowed, but got denied: '{result}'" def test_module_denial_edge_cases(mock_sitecustomize): - """Test edge cases for the module denial logic.""" mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - # Test insufficient arguments (should not crash) with patch.object(sys, "argv", ["/usr/bin/python3.10"]): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"Single argument should not be denied, got '{result}'" - # Test -m without module name (should not crash) with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m"]): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"-m without module should not be denied, got '{result}'" - # Test no sys.argv (should not crash) with patch("builtins.hasattr", return_value=False): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"Missing sys.argv should not be denied, got '{result}'" From de3c9498813d74baed441e2e035a1eec9430015a Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Fri, 19 Sep 2025 16:22:21 +0200 Subject: [PATCH 4/7] Changes from review Signed-off-by: Juanjo Alvarez --- .../sources/denied_executable_modules.txt | 8 + lib-injection/sources/denied_executables.txt | 4 +- lib-injection/sources/sitecustomize.py | 42 ++++- tests/lib_injection/test_denylist.py | 155 +++++++++++++++--- 4 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 lib-injection/sources/denied_executable_modules.txt diff --git a/lib-injection/sources/denied_executable_modules.txt b/lib-injection/sources/denied_executable_modules.txt new file mode 100644 index 00000000000..fce2382ab4e --- /dev/null +++ b/lib-injection/sources/denied_executable_modules.txt @@ -0,0 +1,8 @@ +# Python modules run from the interpreter that should be denied +# These are module names (without -m prefix) that will be checked +# when Python interpreters are executed with the -m flag +py_compile + +# Additional modules can be added here in the future +# For example: +# some_other_problematic_module \ No newline at end of file diff --git a/lib-injection/sources/denied_executables.txt b/lib-injection/sources/denied_executables.txt index eb0cc69545f..aca2cd8dbc1 100644 --- a/lib-injection/sources/denied_executables.txt +++ b/lib-injection/sources/denied_executables.txt @@ -1204,6 +1204,4 @@ usr/libexec/grepconf.sh # Python tools uwsgi # crashtracker receiver -_dd_crashtracker_receiver -# Python modules run from the interpreter --m py_compile \ No newline at end of file +_dd_crashtracker_receiver \ No newline at end of file diff --git a/lib-injection/sources/sitecustomize.py b/lib-injection/sources/sitecustomize.py index ade4992adc5..24e38d20eda 100644 --- a/lib-injection/sources/sitecustomize.py +++ b/lib-injection/sources/sitecustomize.py @@ -60,11 +60,13 @@ def parse_version(version): RESULT_REASON = "unknown" RESULT_CLASS = "unknown" EXECUTABLES_DENY_LIST = set() +EXECUTABLE_MODULES_DENY_LIST = set() REQUIREMENTS_FILE_LOCATIONS = ( os.path.abspath(os.path.join(SCRIPT_DIR, "../datadog-lib/requirements.csv")), os.path.abspath(os.path.join(SCRIPT_DIR, "requirements.csv")), ) EXECUTABLE_DENY_LOCATION = os.path.abspath(os.path.join(SCRIPT_DIR, "denied_executables.txt")) +EXECUTABLE_MODULES_DENY_LOCATION = os.path.abspath(os.path.join(SCRIPT_DIR, "denied_executable_modules.txt")) SITE_PKGS_MARKER = "site-packages-ddtrace-py" BOOTSTRAP_MARKER = "bootstrap" @@ -147,6 +149,24 @@ def build_denied_executables(): return denied_executables +def build_denied_executable_modules(): + denied_modules = set() + _log("Checking denied-executable-modules list", level="debug") + try: + if os.path.exists(EXECUTABLE_MODULES_DENY_LOCATION): + with open(EXECUTABLE_MODULES_DENY_LOCATION, "r") as denyfile: + _log("Found modules deny-list file", level="debug") + for line in denyfile.readlines(): + cleaned = line.strip("\n").strip() + # Skip empty lines and comments + if cleaned and not cleaned.startswith("#"): + denied_modules.add(cleaned) + _log("Built denied-executable-modules list of %s entries" % (len(denied_modules),), level="debug") + except Exception as e: + _log("Failed to build denied-executable-modules list: %s" % e, level="debug") + return denied_modules + + def create_count_metric(metric, tags=None): if tags is None: tags = [] @@ -270,14 +290,18 @@ def get_first_incompatible_sysarg(): _log("argument %s is in deny-list" % (argument,), level="debug") return argument - # Check for -m module patterns (e.g., python -m py_compile which would match the -m py_compile entry) - if len(sys.argv) >= 3 and sys.argv[1] == "-m": - module_pattern = "-m %s" % sys.argv[2] - _log("Checking -m module pattern: %s" % (module_pattern,), level="debug") - if module_pattern in EXECUTABLES_DENY_LIST: - _log("-m module pattern %s is in deny-list" % (module_pattern,), level="debug") - return module_pattern - + # Check for "-m module" patterns, but only for Python interpreters + if len(sys.argv) >= 3: + executable_basename = os.path.basename(argument) + if executable_basename.startswith("python"): + # Look for -m flag in any position from argv[1] onwards + for i in range(1, len(sys.argv) - 1): # -1 because we need argv[i+1] to exist + if sys.argv[i] == "-m": + module_name = sys.argv[i + 1] + if module_name in EXECUTABLE_MODULES_DENY_LIST: + _log("Module %s is in deny-list" % (module_name,), level="debug") + return "-m %s" % module_name # Return in -m format for consistency + break # Stop after finding the first -m flag return None @@ -288,6 +312,7 @@ def _inject(): global PYTHON_RUNTIME global DDTRACE_REQUIREMENTS global EXECUTABLES_DENY_LIST + global EXECUTABLE_MODULES_DENY_LIST global TELEMETRY_DATA global RESULT global RESULT_REASON @@ -299,6 +324,7 @@ def _inject(): INSTALLED_PACKAGES = build_installed_pkgs() DDTRACE_REQUIREMENTS = build_requirements(PYTHON_VERSION) EXECUTABLES_DENY_LIST = build_denied_executables() + EXECUTABLE_MODULES_DENY_LIST = build_denied_executable_modules() dependency_incomp = False runtime_incomp = False spec = None diff --git a/tests/lib_injection/test_denylist.py b/tests/lib_injection/test_denylist.py index 29c9ab27ec4..1e48b1ae5f4 100644 --- a/tests/lib_injection/test_denylist.py +++ b/tests/lib_injection/test_denylist.py @@ -5,6 +5,27 @@ import pytest +# Python interpreters for parametrized testing +PYTHON_INTERPRETERS = [ + "/usr/bin/python", + "/usr/bin/python3", + "/usr/bin/python3.8", + "/usr/bin/python3.9", + "/usr/bin/python3.10", + "/usr/bin/python3.11", + "/usr/bin/python3.12", + "/usr/local/bin/python", + "/usr/local/bin/python3", + "/opt/python/bin/python3.10", + "/home/user/.pyenv/versions/3.11.0/bin/python", + "python", + "python3", + "python3.10", + "./python", + "../bin/python3" +] + + @pytest.fixture def mock_sitecustomize(): lib_injection_path = os.path.join(os.path.dirname(__file__), "../../lib-injection/sources") @@ -13,37 +34,67 @@ def mock_sitecustomize(): import sitecustomize + sitecustomize.EXECUTABLES_DENY_LIST = sitecustomize.build_denied_executables() + sitecustomize.EXECUTABLE_MODULES_DENY_LIST = sitecustomize.build_denied_executable_modules() + return sitecustomize -def test_python_module_denylist_denied(mock_sitecustomize): - mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - assert "-m py_compile" in mock_sitecustomize.EXECUTABLES_DENY_LIST, "-m py_compile should be in deny list" - with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "py_compile", "test.py"]): - result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result == "-m py_compile", f"Expected '-m py_compile', got '{result}'" +@pytest.mark.parametrize("python_exe", PYTHON_INTERPRETERS) +def test_python_module_denylist_denied_basic(mock_sitecustomize, python_exe): + assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST, "py_compile should be in modules deny list" + with patch.object(sys, "argv", [python_exe, "-m", "py_compile", "test.py"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result == "-m py_compile", f"Expected '-m py_compile' for {python_exe}, got '{result}'" + + +@pytest.mark.parametrize( + "python_exe, argv_pattern, description", + [ + (PYTHON_INTERPRETERS[1], ["-v", "-m", "py_compile", "test.py"], "python -v -m py_compile"), + (PYTHON_INTERPRETERS[8], ["-u", "-m", "py_compile"], "python -u -m py_compile"), + (PYTHON_INTERPRETERS[12], ["-O", "-v", "-m", "py_compile"], "python -O -v -m py_compile"), + (PYTHON_INTERPRETERS[1], ["-W", "ignore", "-m", "py_compile"], "python -W ignore -m py_compile"), + (PYTHON_INTERPRETERS[8], ["-u", "-v", "-m", "py_compile"], "python -u -v -m py_compile"), + (PYTHON_INTERPRETERS[12], ["-O", "-m", "py_compile", "file.py"], "python -O -m py_compile"), + ] +) +def test_python_module_denylist_denied_with_flags(mock_sitecustomize, python_exe, argv_pattern, description): + assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST, "py_compile should be in modules deny list" -def test_regular_python_nondenied(mock_sitecustomize): - mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - with patch.object(sys, "argv", ["/usr/bin/python3.10", "script.py"]): + argv = [python_exe] + argv_pattern + with patch.object(sys, "argv", argv): result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result is None, f"Normal python execution should not be denied, got '{result}'" + assert result == "-m py_compile", f"Expected '-m py_compile' for {description} ({python_exe}), got '{result}'" -def test_python_module_notdenylist_notdenied(mock_sitecustomize): - mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "json.tool"]): +@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[4], PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[1]]) +def test_regular_python_nondenied(mock_sitecustomize, python_exe): + with patch.object(sys, "argv", [python_exe, "script.py"]): result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result is None, f"python -m json.tool should not be denied, got '{result}'" - - with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m", "pip", "install", "something"]): + assert result is None, f"Normal python execution should not be denied for {python_exe}, got '{result}'" + + +@pytest.mark.parametrize( + "python_exe, module_name, description", + [ + (PYTHON_INTERPRETERS[4], "json.tool", "python -m json.tool"), + (PYTHON_INTERPRETERS[11], "json.tool", "python -m json.tool"), + (PYTHON_INTERPRETERS[8], "json.tool", "python -m json.tool"), + (PYTHON_INTERPRETERS[4], "pip", "python -m pip"), + (PYTHON_INTERPRETERS[11], "pip", "python -m pip"), + (PYTHON_INTERPRETERS[8], "pip", "python -m pip"), + ] +) +def test_python_module_notdenylist_notdenied(mock_sitecustomize, python_exe, module_name, description): + argv = [python_exe, "-m", module_name] + (["install", "something"] if module_name == "pip" else []) + with patch.object(sys, "argv", argv): result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result is None, f"python -m pip should not be denied, got '{result}'" + assert result is None, f"{description} should not be denied for {python_exe}, got '{result}'" def test_binary_denylist_denied(mock_sitecustomize): - mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() denied_binaries = ["/usr/bin/py3compile", "/usr/bin/gcc", "/usr/bin/make", "/usr/sbin/chkrootkit"] @@ -59,8 +110,6 @@ def test_binary_denylist_denied(mock_sitecustomize): def test_binary_not_in_denylist_allowed(mock_sitecustomize): - mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() - candidate_allowed_binaries = [ "/usr/bin/python3", "/usr/bin/python3.10", @@ -99,17 +148,71 @@ def test_binary_not_in_denylist_allowed(mock_sitecustomize): assert result is None, f"Expected '{basename}' to be allowed, but got denied: '{result}'" -def test_module_denial_edge_cases(mock_sitecustomize): - mock_sitecustomize.EXECUTABLES_DENY_LIST = mock_sitecustomize.build_denied_executables() +@pytest.mark.parametrize("python_exe", PYTHON_INTERPRETERS) +def test_single_argument_not_denied(mock_sitecustomize, python_exe): + with patch.object(sys, "argv", [python_exe]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"Single argument should not be denied for {python_exe}, got '{result}'" + + +@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[4], PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[9]]) +def test_m_without_module_not_denied(mock_sitecustomize, python_exe): + with patch.object(sys, "argv", [python_exe, "-m"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"-m without module should not be denied for {python_exe}, got '{result}'" + + +@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[1], PYTHON_INTERPRETERS[7], PYTHON_INTERPRETERS[10]]) +def test_m_as_last_argument_not_denied(mock_sitecustomize, python_exe): + with patch.object(sys, "argv", [python_exe, "-v", "-m"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + assert result is None, f"-m as last argument should not be denied for {python_exe}, got '{result}'" + - with patch.object(sys, "argv", ["/usr/bin/python3.10"]): +@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[4], PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[8]]) +def test_multiple_m_flags_uses_first(mock_sitecustomize, python_exe): + with patch.object(sys, "argv", [python_exe, "-m", "json.tool", "-m", "py_compile"]): result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result is None, f"Single argument should not be denied, got '{result}'" + assert result is None, f"First -m should be used (json.tool is allowed) for {python_exe}, got '{result}'" + - with patch.object(sys, "argv", ["/usr/bin/python3.10", "-m"]): +@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[1], PYTHON_INTERPRETERS[2], PYTHON_INTERPRETERS[9], PYTHON_INTERPRETERS[14]]) +def test_py_compile_denied_all_interpreters(mock_sitecustomize, python_exe): + with patch.object(sys, "argv", [python_exe, "-m", "py_compile", "test.py"]): result = mock_sitecustomize.get_first_incompatible_sysarg() - assert result is None, f"-m without module should not be denied, got '{result}'" + assert result == "-m py_compile", f"py_compile should be denied for {python_exe}, got '{result}'" + +def test_missing_sys_argv_not_denied(mock_sitecustomize): with patch("builtins.hasattr", return_value=False): result = mock_sitecustomize.get_first_incompatible_sysarg() assert result is None, f"Missing sys.argv should not be denied, got '{result}'" + + +def test_non_python_executable_with_m_flag_allowed(mock_sitecustomize): + assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST + + non_python_executables = [ + "/bin/whatever", + "/usr/bin/some_tool", + "/usr/local/bin/custom_app", + "/usr/bin/gcc", # This is actually in deny list, but not for -m + "/bin/bash", + "/usr/bin/node", + "/usr/bin/java" + ] + + for executable in non_python_executables: + with patch.object(sys, "argv", [executable, "-m", "py_compile", "test.py"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + + if result is not None: + assert result == executable or result == os.path.basename(executable), \ + f"Expected '{executable}' itself to be denied (if at all), not '-m py_compile'. Got: '{result}'" + + with patch.object(sys, "argv", [executable, "-m", "some_other_module"]): + result = mock_sitecustomize.get_first_incompatible_sysarg() + + if result is not None: + assert result == executable or result == os.path.basename(executable), \ + f"Non-Python executable '{executable}' should not be denied for -m patterns. Got: '{result}'" From ce993dc41c767207d83e8b9b7957c7713b6f06f8 Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Fri, 19 Sep 2025 16:25:54 +0200 Subject: [PATCH 5/7] update comment Signed-off-by: Juanjo Alvarez --- lib-injection/sources/sitecustomize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib-injection/sources/sitecustomize.py b/lib-injection/sources/sitecustomize.py index 24e38d20eda..ae97ab9e16e 100644 --- a/lib-injection/sources/sitecustomize.py +++ b/lib-injection/sources/sitecustomize.py @@ -294,7 +294,7 @@ def get_first_incompatible_sysarg(): if len(sys.argv) >= 3: executable_basename = os.path.basename(argument) if executable_basename.startswith("python"): - # Look for -m flag in any position from argv[1] onwards + # Look for -m flag in any position from argv[1:len(-1)] for i in range(1, len(sys.argv) - 1): # -1 because we need argv[i+1] to exist if sys.argv[i] == "-m": module_name = sys.argv[i + 1] From f242003ff33d46840dee65bd8d9f92f9d7494c42 Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Fri, 19 Sep 2025 16:29:53 +0200 Subject: [PATCH 6/7] fmt Signed-off-by: Juanjo Alvarez --- tests/lib_injection/test_denylist.py | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/lib_injection/test_denylist.py b/tests/lib_injection/test_denylist.py index 1e48b1ae5f4..0a191ee4920 100644 --- a/tests/lib_injection/test_denylist.py +++ b/tests/lib_injection/test_denylist.py @@ -22,7 +22,7 @@ "python3", "python3.10", "./python", - "../bin/python3" + "../bin/python3", ] @@ -58,7 +58,7 @@ def test_python_module_denylist_denied_basic(mock_sitecustomize, python_exe): (PYTHON_INTERPRETERS[1], ["-W", "ignore", "-m", "py_compile"], "python -W ignore -m py_compile"), (PYTHON_INTERPRETERS[8], ["-u", "-v", "-m", "py_compile"], "python -u -v -m py_compile"), (PYTHON_INTERPRETERS[12], ["-O", "-m", "py_compile", "file.py"], "python -O -m py_compile"), - ] + ], ) def test_python_module_denylist_denied_with_flags(mock_sitecustomize, python_exe, argv_pattern, description): assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST, "py_compile should be in modules deny list" @@ -85,7 +85,7 @@ def test_regular_python_nondenied(mock_sitecustomize, python_exe): (PYTHON_INTERPRETERS[4], "pip", "python -m pip"), (PYTHON_INTERPRETERS[11], "pip", "python -m pip"), (PYTHON_INTERPRETERS[8], "pip", "python -m pip"), - ] + ], ) def test_python_module_notdenylist_notdenied(mock_sitecustomize, python_exe, module_name, description): argv = [python_exe, "-m", module_name] + (["install", "something"] if module_name == "pip" else []) @@ -95,7 +95,6 @@ def test_python_module_notdenylist_notdenied(mock_sitecustomize, python_exe, mod def test_binary_denylist_denied(mock_sitecustomize): - denied_binaries = ["/usr/bin/py3compile", "/usr/bin/gcc", "/usr/bin/make", "/usr/sbin/chkrootkit"] for binary in denied_binaries: @@ -176,7 +175,16 @@ def test_multiple_m_flags_uses_first(mock_sitecustomize, python_exe): assert result is None, f"First -m should be used (json.tool is allowed) for {python_exe}, got '{result}'" -@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[1], PYTHON_INTERPRETERS[2], PYTHON_INTERPRETERS[9], PYTHON_INTERPRETERS[14]]) +@pytest.mark.parametrize( + "python_exe", + [ + PYTHON_INTERPRETERS[11], + PYTHON_INTERPRETERS[1], + PYTHON_INTERPRETERS[2], + PYTHON_INTERPRETERS[9], + PYTHON_INTERPRETERS[14], + ], +) def test_py_compile_denied_all_interpreters(mock_sitecustomize, python_exe): with patch.object(sys, "argv", [python_exe, "-m", "py_compile", "test.py"]): result = mock_sitecustomize.get_first_incompatible_sysarg() @@ -199,7 +207,7 @@ def test_non_python_executable_with_m_flag_allowed(mock_sitecustomize): "/usr/bin/gcc", # This is actually in deny list, but not for -m "/bin/bash", "/usr/bin/node", - "/usr/bin/java" + "/usr/bin/java", ] for executable in non_python_executables: @@ -207,12 +215,14 @@ def test_non_python_executable_with_m_flag_allowed(mock_sitecustomize): result = mock_sitecustomize.get_first_incompatible_sysarg() if result is not None: - assert result == executable or result == os.path.basename(executable), \ - f"Expected '{executable}' itself to be denied (if at all), not '-m py_compile'. Got: '{result}'" + assert result == executable or result == os.path.basename( + executable + ), f"Expected '{executable}' itself to be denied (if at all), not '-m py_compile'. Got: '{result}'" with patch.object(sys, "argv", [executable, "-m", "some_other_module"]): result = mock_sitecustomize.get_first_incompatible_sysarg() if result is not None: - assert result == executable or result == os.path.basename(executable), \ - f"Non-Python executable '{executable}' should not be denied for -m patterns. Got: '{result}'" + assert result == executable or result == os.path.basename( + executable + ), f"Non-Python executable '{executable}' should not be denied for -m patterns. Got: '{result}'" From 73f97d44e6def7271c00ec4890f24cec5532a063 Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Date: Mon, 22 Sep 2025 15:59:41 +0200 Subject: [PATCH 7/7] Use index to search for the -m Signed-off-by: Juanjo Alvarez --- lib-injection/sources/sitecustomize.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib-injection/sources/sitecustomize.py b/lib-injection/sources/sitecustomize.py index ae97ab9e16e..862224d58fd 100644 --- a/lib-injection/sources/sitecustomize.py +++ b/lib-injection/sources/sitecustomize.py @@ -294,14 +294,16 @@ def get_first_incompatible_sysarg(): if len(sys.argv) >= 3: executable_basename = os.path.basename(argument) if executable_basename.startswith("python"): - # Look for -m flag in any position from argv[1:len(-1)] - for i in range(1, len(sys.argv) - 1): # -1 because we need argv[i+1] to exist - if sys.argv[i] == "-m": - module_name = sys.argv[i + 1] + try: + m_index = sys.argv.index("-m") + if m_index + 1 < len(sys.argv): + module_name = sys.argv[m_index + 1] if module_name in EXECUTABLE_MODULES_DENY_LIST: _log("Module %s is in deny-list" % (module_name,), level="debug") - return "-m %s" % module_name # Return in -m format for consistency - break # Stop after finding the first -m flag + return "-m %s" % module_name + except ValueError: + # "-m" not found in sys.argv, continue normally + pass return None