Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib-injection/sources/denied_executable_modules.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib-injection/sources/denied_executables.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1204,4 +1204,4 @@ usr/libexec/grepconf.sh
# Python tools
uwsgi
# crashtracker receiver
_dd_crashtracker_receiver
_dd_crashtracker_receiver
40 changes: 40 additions & 0 deletions lib-injection/sources/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -262,12 +282,30 @@ 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, but only for Python interpreters
if len(sys.argv) >= 3:
executable_basename = os.path.basename(argument)
if executable_basename.startswith("python"):
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
except ValueError:
# "-m" not found in sys.argv, continue normally
pass
return None


def _inject():
global DDTRACE_VERSION
Expand All @@ -276,6 +314,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
Expand All @@ -287,6 +326,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
fixes:
- |
libinjection: allow python module executed with ``-m`` entries in the denylist.
2 changes: 1 addition & 1 deletion riotfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,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(),
Expand Down
228 changes: 228 additions & 0 deletions tests/lib_injection/test_denylist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import os
import sys
from unittest.mock import patch

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")
if lib_injection_path not in sys.path:
sys.path.insert(0, lib_injection_path)

import sitecustomize

sitecustomize.EXECUTABLES_DENY_LIST = sitecustomize.build_denied_executables()
sitecustomize.EXECUTABLE_MODULES_DENY_LIST = sitecustomize.build_denied_executable_modules()

return sitecustomize


@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"

argv = [python_exe] + argv_pattern
with patch.object(sys, "argv", argv):
result = mock_sitecustomize.get_first_incompatible_sysarg()
assert result == "-m py_compile", f"Expected '-m py_compile' for {description} ({python_exe}), got '{result}'"


@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"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"{description} should not be denied for {python_exe}, got '{result}'"


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:
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}'"

with patch.object(sys, "argv", ["py3compile", "test.py"]):
result = mock_sitecustomize.get_first_incompatible_sysarg()
assert result == "py3compile", f"Expected 'py3compile' (basename) to be denied, got '{result}'"


def test_binary_not_in_denylist_allowed(mock_sitecustomize):
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}'"


@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}'"


@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"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],
],
)
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 == "-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}'"
Loading