Skip to content

Commit 2c86d23

Browse files
fix: allow libinjection denylist to deny python module executions [backport 3.12] (#14664)
Backport 644ba68 from #14645 to 3.12. This allows entries in the denylist like `-m py_compile` and thus avoid some problems we hit with libinjection patching it even while we have `/usr/bin/py3compile` on it. ## Checklist - [X] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) Signed-off-by: Juanjo Alvarez <[email protected]> Co-authored-by: Juanjo Alvarez Martinez <[email protected]>
1 parent 78d13a5 commit 2c86d23

File tree

6 files changed

+282
-2
lines changed

6 files changed

+282
-2
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Python modules run from the interpreter that should be denied
2+
# These are module names (without -m prefix) that will be checked
3+
# when Python interpreters are executed with the -m flag
4+
py_compile
5+
6+
# Additional modules can be added here in the future
7+
# For example:
8+
# some_other_problematic_module

lib-injection/sources/denied_executables.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,4 +1204,4 @@ usr/libexec/grepconf.sh
12041204
# Python tools
12051205
uwsgi
12061206
# crashtracker receiver
1207-
_dd_crashtracker_receiver
1207+
_dd_crashtracker_receiver

lib-injection/sources/sitecustomize.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ def parse_version(version):
6060
RESULT_REASON = "unknown"
6161
RESULT_CLASS = "unknown"
6262
EXECUTABLES_DENY_LIST = set()
63+
EXECUTABLE_MODULES_DENY_LIST = set()
6364
REQUIREMENTS_FILE_LOCATIONS = (
6465
os.path.abspath(os.path.join(SCRIPT_DIR, "../datadog-lib/requirements.csv")),
6566
os.path.abspath(os.path.join(SCRIPT_DIR, "requirements.csv")),
6667
)
6768
EXECUTABLE_DENY_LOCATION = os.path.abspath(os.path.join(SCRIPT_DIR, "denied_executables.txt"))
69+
EXECUTABLE_MODULES_DENY_LOCATION = os.path.abspath(os.path.join(SCRIPT_DIR, "denied_executable_modules.txt"))
6870
SITE_PKGS_MARKER = "site-packages-ddtrace-py"
6971
BOOTSTRAP_MARKER = "bootstrap"
7072

@@ -147,6 +149,24 @@ def build_denied_executables():
147149
return denied_executables
148150

149151

152+
def build_denied_executable_modules():
153+
denied_modules = set()
154+
_log("Checking denied-executable-modules list", level="debug")
155+
try:
156+
if os.path.exists(EXECUTABLE_MODULES_DENY_LOCATION):
157+
with open(EXECUTABLE_MODULES_DENY_LOCATION, "r") as denyfile:
158+
_log("Found modules deny-list file", level="debug")
159+
for line in denyfile.readlines():
160+
cleaned = line.strip("\n").strip()
161+
# Skip empty lines and comments
162+
if cleaned and not cleaned.startswith("#"):
163+
denied_modules.add(cleaned)
164+
_log("Built denied-executable-modules list of %s entries" % (len(denied_modules),), level="debug")
165+
except Exception as e:
166+
_log("Failed to build denied-executable-modules list: %s" % e, level="debug")
167+
return denied_modules
168+
169+
150170
def create_count_metric(metric, tags=None):
151171
if tags is None:
152172
tags = []
@@ -262,12 +282,30 @@ def get_first_incompatible_sysarg():
262282
_log("Checking sys.args: len(sys.argv): %s" % (len(sys.argv),), level="debug")
263283
if len(sys.argv) <= 1:
264284
return
285+
286+
# Check the main executable first
265287
argument = sys.argv[0]
266288
_log("Is argument %s in deny-list?" % (argument,), level="debug")
267289
if argument in EXECUTABLES_DENY_LIST or os.path.basename(argument) in EXECUTABLES_DENY_LIST:
268290
_log("argument %s is in deny-list" % (argument,), level="debug")
269291
return argument
270292

293+
# Check for "-m module" patterns, but only for Python interpreters
294+
if len(sys.argv) >= 3:
295+
executable_basename = os.path.basename(argument)
296+
if executable_basename.startswith("python"):
297+
try:
298+
m_index = sys.argv.index("-m")
299+
if m_index + 1 < len(sys.argv):
300+
module_name = sys.argv[m_index + 1]
301+
if module_name in EXECUTABLE_MODULES_DENY_LIST:
302+
_log("Module %s is in deny-list" % (module_name,), level="debug")
303+
return "-m %s" % module_name
304+
except ValueError:
305+
# "-m" not found in sys.argv, continue normally
306+
pass
307+
return None
308+
271309

272310
def _inject():
273311
global DDTRACE_VERSION
@@ -276,6 +314,7 @@ def _inject():
276314
global PYTHON_RUNTIME
277315
global DDTRACE_REQUIREMENTS
278316
global EXECUTABLES_DENY_LIST
317+
global EXECUTABLE_MODULES_DENY_LIST
279318
global TELEMETRY_DATA
280319
global RESULT
281320
global RESULT_REASON
@@ -287,6 +326,7 @@ def _inject():
287326
INSTALLED_PACKAGES = build_installed_pkgs()
288327
DDTRACE_REQUIREMENTS = build_requirements(PYTHON_VERSION)
289328
EXECUTABLES_DENY_LIST = build_denied_executables()
329+
EXECUTABLE_MODULES_DENY_LIST = build_denied_executable_modules()
290330
dependency_incomp = False
291331
runtime_incomp = False
292332
spec = None
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
libinjection: allow python module executed with ``-m`` entries in the denylist.

riotfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT
514514
),
515515
Venv(
516516
name="lib_injection",
517-
command="pytest {cmdargs} tests/lib_injection/test_guardrails.py",
517+
command="pytest {cmdargs} tests/lib_injection/",
518518
venvs=[
519519
Venv(
520520
pys=select_pys(),
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import os
2+
import sys
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
8+
# Python interpreters for parametrized testing
9+
PYTHON_INTERPRETERS = [
10+
"/usr/bin/python",
11+
"/usr/bin/python3",
12+
"/usr/bin/python3.8",
13+
"/usr/bin/python3.9",
14+
"/usr/bin/python3.10",
15+
"/usr/bin/python3.11",
16+
"/usr/bin/python3.12",
17+
"/usr/local/bin/python",
18+
"/usr/local/bin/python3",
19+
"/opt/python/bin/python3.10",
20+
"/home/user/.pyenv/versions/3.11.0/bin/python",
21+
"python",
22+
"python3",
23+
"python3.10",
24+
"./python",
25+
"../bin/python3",
26+
]
27+
28+
29+
@pytest.fixture
30+
def mock_sitecustomize():
31+
lib_injection_path = os.path.join(os.path.dirname(__file__), "../../lib-injection/sources")
32+
if lib_injection_path not in sys.path:
33+
sys.path.insert(0, lib_injection_path)
34+
35+
import sitecustomize
36+
37+
sitecustomize.EXECUTABLES_DENY_LIST = sitecustomize.build_denied_executables()
38+
sitecustomize.EXECUTABLE_MODULES_DENY_LIST = sitecustomize.build_denied_executable_modules()
39+
40+
return sitecustomize
41+
42+
43+
@pytest.mark.parametrize("python_exe", PYTHON_INTERPRETERS)
44+
def test_python_module_denylist_denied_basic(mock_sitecustomize, python_exe):
45+
assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST, "py_compile should be in modules deny list"
46+
47+
with patch.object(sys, "argv", [python_exe, "-m", "py_compile", "test.py"]):
48+
result = mock_sitecustomize.get_first_incompatible_sysarg()
49+
assert result == "-m py_compile", f"Expected '-m py_compile' for {python_exe}, got '{result}'"
50+
51+
52+
@pytest.mark.parametrize(
53+
"python_exe, argv_pattern, description",
54+
[
55+
(PYTHON_INTERPRETERS[1], ["-v", "-m", "py_compile", "test.py"], "python -v -m py_compile"),
56+
(PYTHON_INTERPRETERS[8], ["-u", "-m", "py_compile"], "python -u -m py_compile"),
57+
(PYTHON_INTERPRETERS[12], ["-O", "-v", "-m", "py_compile"], "python -O -v -m py_compile"),
58+
(PYTHON_INTERPRETERS[1], ["-W", "ignore", "-m", "py_compile"], "python -W ignore -m py_compile"),
59+
(PYTHON_INTERPRETERS[8], ["-u", "-v", "-m", "py_compile"], "python -u -v -m py_compile"),
60+
(PYTHON_INTERPRETERS[12], ["-O", "-m", "py_compile", "file.py"], "python -O -m py_compile"),
61+
],
62+
)
63+
def test_python_module_denylist_denied_with_flags(mock_sitecustomize, python_exe, argv_pattern, description):
64+
assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST, "py_compile should be in modules deny list"
65+
66+
argv = [python_exe] + argv_pattern
67+
with patch.object(sys, "argv", argv):
68+
result = mock_sitecustomize.get_first_incompatible_sysarg()
69+
assert result == "-m py_compile", f"Expected '-m py_compile' for {description} ({python_exe}), got '{result}'"
70+
71+
72+
@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[4], PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[1]])
73+
def test_regular_python_nondenied(mock_sitecustomize, python_exe):
74+
with patch.object(sys, "argv", [python_exe, "script.py"]):
75+
result = mock_sitecustomize.get_first_incompatible_sysarg()
76+
assert result is None, f"Normal python execution should not be denied for {python_exe}, got '{result}'"
77+
78+
79+
@pytest.mark.parametrize(
80+
"python_exe, module_name, description",
81+
[
82+
(PYTHON_INTERPRETERS[4], "json.tool", "python -m json.tool"),
83+
(PYTHON_INTERPRETERS[11], "json.tool", "python -m json.tool"),
84+
(PYTHON_INTERPRETERS[8], "json.tool", "python -m json.tool"),
85+
(PYTHON_INTERPRETERS[4], "pip", "python -m pip"),
86+
(PYTHON_INTERPRETERS[11], "pip", "python -m pip"),
87+
(PYTHON_INTERPRETERS[8], "pip", "python -m pip"),
88+
],
89+
)
90+
def test_python_module_notdenylist_notdenied(mock_sitecustomize, python_exe, module_name, description):
91+
argv = [python_exe, "-m", module_name] + (["install", "something"] if module_name == "pip" else [])
92+
with patch.object(sys, "argv", argv):
93+
result = mock_sitecustomize.get_first_incompatible_sysarg()
94+
assert result is None, f"{description} should not be denied for {python_exe}, got '{result}'"
95+
96+
97+
def test_binary_denylist_denied(mock_sitecustomize):
98+
denied_binaries = ["/usr/bin/py3compile", "/usr/bin/gcc", "/usr/bin/make", "/usr/sbin/chkrootkit"]
99+
100+
for binary in denied_binaries:
101+
assert binary in mock_sitecustomize.EXECUTABLES_DENY_LIST, f"{binary} should be in deny list"
102+
with patch.object(sys, "argv", [binary, "some", "args"]):
103+
result = mock_sitecustomize.get_first_incompatible_sysarg()
104+
assert result == binary, f"Expected '{binary}' to be denied, got '{result}'"
105+
106+
with patch.object(sys, "argv", ["py3compile", "test.py"]):
107+
result = mock_sitecustomize.get_first_incompatible_sysarg()
108+
assert result == "py3compile", f"Expected 'py3compile' (basename) to be denied, got '{result}'"
109+
110+
111+
def test_binary_not_in_denylist_allowed(mock_sitecustomize):
112+
candidate_allowed_binaries = [
113+
"/usr/bin/python3",
114+
"/usr/bin/python3.10",
115+
"/bin/bash",
116+
"/usr/bin/cat",
117+
"/usr/bin/ls",
118+
"/usr/bin/echo",
119+
"/usr/bin/node",
120+
"/usr/bin/ruby",
121+
"/usr/bin/java",
122+
"/usr/bin/wget",
123+
"/usr/bin/vim",
124+
"/usr/bin/nano",
125+
"/usr/local/bin/custom_app",
126+
]
127+
128+
allowed_binaries = []
129+
for binary in candidate_allowed_binaries:
130+
if (
131+
binary not in mock_sitecustomize.EXECUTABLES_DENY_LIST
132+
and os.path.basename(binary) not in mock_sitecustomize.EXECUTABLES_DENY_LIST
133+
):
134+
allowed_binaries.append(binary)
135+
136+
for binary in allowed_binaries:
137+
with patch.object(sys, "argv", [binary, "some", "args"]):
138+
result = mock_sitecustomize.get_first_incompatible_sysarg()
139+
assert result is None, f"Expected '{binary}' to be allowed, but got denied: '{result}'"
140+
141+
safe_basenames = ["myapp", "custom_script", "user_program"]
142+
for basename in safe_basenames:
143+
assert basename not in mock_sitecustomize.EXECUTABLES_DENY_LIST, f"'{basename}' should not be in deny list"
144+
145+
with patch.object(sys, "argv", [basename, "arg1", "arg2"]):
146+
result = mock_sitecustomize.get_first_incompatible_sysarg()
147+
assert result is None, f"Expected '{basename}' to be allowed, but got denied: '{result}'"
148+
149+
150+
@pytest.mark.parametrize("python_exe", PYTHON_INTERPRETERS)
151+
def test_single_argument_not_denied(mock_sitecustomize, python_exe):
152+
with patch.object(sys, "argv", [python_exe]):
153+
result = mock_sitecustomize.get_first_incompatible_sysarg()
154+
assert result is None, f"Single argument should not be denied for {python_exe}, got '{result}'"
155+
156+
157+
@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[4], PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[9]])
158+
def test_m_without_module_not_denied(mock_sitecustomize, python_exe):
159+
with patch.object(sys, "argv", [python_exe, "-m"]):
160+
result = mock_sitecustomize.get_first_incompatible_sysarg()
161+
assert result is None, f"-m without module should not be denied for {python_exe}, got '{result}'"
162+
163+
164+
@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[1], PYTHON_INTERPRETERS[7], PYTHON_INTERPRETERS[10]])
165+
def test_m_as_last_argument_not_denied(mock_sitecustomize, python_exe):
166+
with patch.object(sys, "argv", [python_exe, "-v", "-m"]):
167+
result = mock_sitecustomize.get_first_incompatible_sysarg()
168+
assert result is None, f"-m as last argument should not be denied for {python_exe}, got '{result}'"
169+
170+
171+
@pytest.mark.parametrize("python_exe", [PYTHON_INTERPRETERS[4], PYTHON_INTERPRETERS[11], PYTHON_INTERPRETERS[8]])
172+
def test_multiple_m_flags_uses_first(mock_sitecustomize, python_exe):
173+
with patch.object(sys, "argv", [python_exe, "-m", "json.tool", "-m", "py_compile"]):
174+
result = mock_sitecustomize.get_first_incompatible_sysarg()
175+
assert result is None, f"First -m should be used (json.tool is allowed) for {python_exe}, got '{result}'"
176+
177+
178+
@pytest.mark.parametrize(
179+
"python_exe",
180+
[
181+
PYTHON_INTERPRETERS[11],
182+
PYTHON_INTERPRETERS[1],
183+
PYTHON_INTERPRETERS[2],
184+
PYTHON_INTERPRETERS[9],
185+
PYTHON_INTERPRETERS[14],
186+
],
187+
)
188+
def test_py_compile_denied_all_interpreters(mock_sitecustomize, python_exe):
189+
with patch.object(sys, "argv", [python_exe, "-m", "py_compile", "test.py"]):
190+
result = mock_sitecustomize.get_first_incompatible_sysarg()
191+
assert result == "-m py_compile", f"py_compile should be denied for {python_exe}, got '{result}'"
192+
193+
194+
def test_missing_sys_argv_not_denied(mock_sitecustomize):
195+
with patch("builtins.hasattr", return_value=False):
196+
result = mock_sitecustomize.get_first_incompatible_sysarg()
197+
assert result is None, f"Missing sys.argv should not be denied, got '{result}'"
198+
199+
200+
def test_non_python_executable_with_m_flag_allowed(mock_sitecustomize):
201+
assert "py_compile" in mock_sitecustomize.EXECUTABLE_MODULES_DENY_LIST
202+
203+
non_python_executables = [
204+
"/bin/whatever",
205+
"/usr/bin/some_tool",
206+
"/usr/local/bin/custom_app",
207+
"/usr/bin/gcc", # This is actually in deny list, but not for -m
208+
"/bin/bash",
209+
"/usr/bin/node",
210+
"/usr/bin/java",
211+
]
212+
213+
for executable in non_python_executables:
214+
with patch.object(sys, "argv", [executable, "-m", "py_compile", "test.py"]):
215+
result = mock_sitecustomize.get_first_incompatible_sysarg()
216+
217+
if result is not None:
218+
assert result == executable or result == os.path.basename(
219+
executable
220+
), f"Expected '{executable}' itself to be denied (if at all), not '-m py_compile'. Got: '{result}'"
221+
222+
with patch.object(sys, "argv", [executable, "-m", "some_other_module"]):
223+
result = mock_sitecustomize.get_first_incompatible_sysarg()
224+
225+
if result is not None:
226+
assert result == executable or result == os.path.basename(
227+
executable
228+
), f"Non-Python executable '{executable}' should not be denied for -m patterns. Got: '{result}'"

0 commit comments

Comments
 (0)