Skip to content

Commit 502a758

Browse files
authored
chore(iast): improve ast patching allowlist [backport 2.21] (#13043)
Backport #13021 to 2.21 This PR improves the AST patching allowlist mechanism to better handle third-party modules. The changes include: - Enhanced allowlist logic in iastpatch.c for third-party module detection - Improved string comparison in AST patching to properly handle module paths These changes make the IAST module patching more reliable and maintainable, while ensuring proper handling of third-party modules like psycopg2. Related to: APPSEC-56946 & #13017 (cherry picked from commit 0b25c11) ## 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)
1 parent 8015b10 commit 502a758

File tree

7 files changed

+81
-41
lines changed

7 files changed

+81
-41
lines changed

ddtrace/appsec/_iast/_ast/iastpatch.c

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,8 @@ static char** cached_packages = NULL;
1717
static size_t cached_packages_count = 0;
1818

1919
/* Static Lists */
20-
static size_t static_allowlist_count = 20;
21-
static const char* static_allowlist[] = { "attrs.", "beautifulsoup4.", "cachetools.", "cryptography.",
22-
"django.", "docutils.", "idna.", "iniconfig.",
23-
"jinja2.", "lxml.", "multidict.", "platformdirs.",
24-
"pygments.", "pynacl.", "pyparsing.", "multipart",
25-
"sqlalchemy.", "tomli.", "yarl.", "python_multipart." };
20+
static size_t static_allowlist_count = 5;
21+
static const char* static_allowlist[] = { "jinja2.", "pygments.", "multipart.", "sqlalchemy.", "python_multipart." };
2622

2723
static size_t static_denylist_count = 145;
2824
static const char* static_denylist[] = { "django.apps.config.",
@@ -398,7 +394,7 @@ static int
398394
str_in_list(const char* needle, const char** list, size_t count)
399395
{
400396
for (size_t i = 0; i < count; i++) {
401-
if (strcmp(needle, list[i]) == 0) {
397+
if (strncmp(needle, list[i], strlen(list[i])) == 0) {
402398
return 1;
403399
}
404400
}
@@ -511,7 +507,7 @@ is_first_party(const char* module_name)
511507

512508
// If the first part is found in the cached packages, it's not a first-party module.
513509
for (size_t i = 0; i < cached_packages_count; i++) {
514-
if (cached_packages[i] && strcmp(first_part, cached_packages[i]) == 0) {
510+
if (cached_packages[i] && strncmp(first_part, cached_packages[i], strlen(cached_packages[i])) == 0) {
515511
return 0;
516512
}
517513
}
@@ -723,7 +719,7 @@ py_should_iast_patch(PyObject* self, PyObject* args)
723719
if (str_in_list(lower_module, static_denylist, static_denylist_count)) {
724720
return PyLong_FromLong(DENIED_STATIC_DENYLIST);
725721
}
726-
return PyLong_FromLong(ALLOWED_USER_ALLOWLIST);
722+
return PyLong_FromLong(ALLOWED_STATIC_ALLOWLIST);
727723
}
728724
return PyLong_FromLong(DENIED_NOT_FOUND);
729725
}
@@ -754,7 +750,6 @@ build_list_from_env(const char* env_var_name)
754750
return -1;
755751
}
756752

757-
758753
return 0;
759754
}
760755
/* --- Exported function to build a list from an environment variable and update globals --- */

tests/appsec/iast/_ast/test_ast_patching.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#!/usr/bin/env python3
22
import logging
3-
import os
43
import sys
54
from unittest import mock
65

@@ -18,15 +17,6 @@
1817
_PREFIX = IAST.PATCH_ADDED_SYMBOL_PREFIX
1918

2019

21-
@pytest.fixture(autouse=True, scope="module")
22-
def clear_iast_env_vars():
23-
if IAST.PATCH_MODULES in os.environ:
24-
os.environ.pop("_DD_IAST_PATCH_MODULES")
25-
if IAST.DENY_MODULES in os.environ:
26-
os.environ.pop("_DD_IAST_DENY_MODULES")
27-
yield
28-
29-
3020
@pytest.mark.parametrize(
3121
"source_text, module_path, module_name",
3222
[
@@ -160,11 +150,13 @@ def test_astpatch_source_unchanged(module_name):
160150

161151

162152
def test_should_iast_patch_allow_first_party():
163-
assert iastpatch.should_iast_patch("tests.appsec.iast.integration.main") == iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST
164-
assert (
165-
iastpatch.should_iast_patch("tests.appsec.iast.integration.print_str")
166-
== iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST
167-
)
153+
assert iastpatch.should_iast_patch("file_in_my_project.main") == iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST
154+
assert iastpatch.should_iast_patch("file_in_my_project.print_str") == iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST
155+
156+
157+
def test_should_iast_patch_allow_user_allowlist():
158+
assert iastpatch.should_iast_patch("tests.appsec.iast.integration.main") == iastpatch.ALLOWED_USER_ALLOWLIST
159+
assert iastpatch.should_iast_patch("tests.appsec.iast.integration.print_str") == iastpatch.ALLOWED_USER_ALLOWLIST
168160

169161

170162
def test_should_not_iast_patch_if_vendored():
@@ -175,7 +167,18 @@ def test_should_not_iast_patch_if_vendored():
175167
def test_should_iast_patch_deny_by_default_if_third_party():
176168
# note that modules here must be in the ones returned by get_package_distributions()
177169
# but not in ALLOWLIST or DENYLIST. So please don't put astunparse there :)
178-
assert iastpatch.should_iast_patch("astunparse.foo.bar.not.in.deny.or.allow.list") == iastpatch.DENIED_NOT_FOUND
170+
assert (
171+
iastpatch.should_iast_patch("astunparse.foo.bar.not.in.deny.or.allow.list")
172+
== iastpatch.DENIED_BUILTINS_DENYLIST
173+
)
174+
175+
176+
def test_should_iast_patch_allow_by_default_if_third_party():
177+
# note that modules here must be in the ones returned by get_package_distributions()
178+
# but not in ALLOWLIST or DENYLIST. So please don't put astunparse there :)
179+
assert iastpatch.should_iast_patch("pygments") == iastpatch.ALLOWED_STATIC_ALLOWLIST
180+
assert iastpatch.should_iast_patch("pygments.submodule") == iastpatch.ALLOWED_STATIC_ALLOWLIST
181+
assert iastpatch.should_iast_patch("pygments.submodule.submodule2") == iastpatch.ALLOWED_STATIC_ALLOWLIST
179182

180183

181184
def test_should_not_iast_patch_if_not_in_static_allowlist():

tests/appsec/iast/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,11 @@ def configuration_endpoint():
189189

190190
yield
191191
process.kill()
192+
193+
194+
@pytest.fixture(autouse=True)
195+
def clear_iast_env_vars():
196+
os.environ[IAST.PATCH_MODULES] = "benchmarks.,tests.appsec."
197+
if IAST.DENY_MODULES in os.environ:
198+
os.environ.pop("_DD_IAST_DENY_MODULES")
199+
yield

tests/appsec/iast/iast_utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1+
import importlib
12
import re
3+
import types
24
from typing import Optional
35
from typing import Text
46
import zlib
57

8+
from ddtrace.appsec._constants import IAST
9+
from ddtrace.appsec._iast._ast.ast_patching import astpatch_module
10+
from ddtrace.appsec._iast._ast.ast_patching import iastpatch
11+
612

713
# Check if the log contains "iast::" to raise an error if that’s the case BUT, if the logs contains
814
# "iast::instrumentation::" or "iast::instrumentation::"
915
# are valid logs
1016
IAST_VALID_LOG = re.compile(r"^iast::(?!instrumentation::|propagation::context::).*$")
1117

1218

19+
class IastTestException(Exception):
20+
pass
21+
22+
1323
def get_line(label: Text, filename: Optional[Text] = None):
1424
"""get the line number after the label comment in source file `filename`"""
1525
with open(filename, "r") as file_in:
@@ -30,3 +40,23 @@ def get_line_and_hash(label: Text, vuln_type: Text, filename=None, fixed_line=No
3040
hash_value = zlib.crc32(rep.encode())
3141

3242
return line, hash_value
43+
44+
45+
def _iast_patched_module_and_patched_source(module_name, new_module_object=False):
46+
module = importlib.import_module(module_name)
47+
module_path, patched_source = astpatch_module(module)
48+
compiled_code = compile(patched_source, module_path, "exec")
49+
module_changed = types.ModuleType(module_name) if new_module_object else module
50+
exec(compiled_code, module_changed.__dict__)
51+
return module_changed, patched_source
52+
53+
54+
def _iast_patched_module(module_name, new_module_object=False):
55+
iastpatch.build_list_from_env(IAST.PATCH_MODULES)
56+
iastpatch.build_list_from_env(IAST.DENY_MODULES)
57+
res = iastpatch.should_iast_patch(module_name)
58+
if res >= iastpatch.ALLOWED_USER_ALLOWLIST:
59+
module, _ = _iast_patched_module_and_patched_source(module_name, new_module_object)
60+
else:
61+
raise IastTestException(f"IAST Test Error: module {module_name} was excluded: {res}")
62+
return module

tests/appsec/iast/test_env_var.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -190,18 +190,18 @@ def test_env_var_iast_enabled_gevent_patch_all_true(capfd):
190190
"module_name,expected_result",
191191
(
192192
("please_patch", iastpatch.ALLOWED_USER_ALLOWLIST),
193-
("please_patch.do_not", iastpatch.DENIED_USER_DENYLIST),
194-
("please_patch.submodule", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
193+
("please_patch.do_not", iastpatch.ALLOWED_USER_ALLOWLIST),
194+
("please_patch.submodule", iastpatch.ALLOWED_USER_ALLOWLIST),
195195
("please_patch.do_not.but_yes", iastpatch.ALLOWED_USER_ALLOWLIST),
196-
("please_patch.do_not.but_yes.sub", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
196+
("please_patch.do_not.but_yes.sub", iastpatch.ALLOWED_USER_ALLOWLIST),
197197
("also", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
198198
("anything", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
199199
("also.that", iastpatch.ALLOWED_USER_ALLOWLIST),
200-
("also.that.but", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
201-
("also.that.but.not", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
202-
("also.that.but.not.that", iastpatch.DENIED_USER_DENYLIST),
203-
("tests.appsec.iast", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
204-
("tests.appsec.iast.sub", iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST),
200+
("also.that.but", iastpatch.ALLOWED_USER_ALLOWLIST),
201+
("also.that.but.not", iastpatch.ALLOWED_USER_ALLOWLIST),
202+
("also.that.but.not.that", iastpatch.ALLOWED_USER_ALLOWLIST),
203+
("tests.appsec.iast", iastpatch.DENIED_BUILTINS_DENYLIST),
204+
("tests.appsec.iast.sub", iastpatch.DENIED_BUILTINS_DENYLIST),
205205
("ddtrace.allowed", iastpatch.ALLOWED_USER_ALLOWLIST),
206206
("ddtrace", iastpatch.DENIED_NOT_FOUND),
207207
("ddtrace.sub", iastpatch.DENIED_NOT_FOUND),
@@ -316,19 +316,22 @@ def test_should_iast_patch_empty_lists():
316316

317317

318318
def test_should_iast_patch_max_list_size():
319-
large_list = ",".join([f"module{i}" for i in range(1000)])
319+
large_list = ",".join([f"module{i}." for i in range(1000)])
320320
os.environ[IAST.PATCH_MODULES] = large_list
321321
iastpatch.build_list_from_env(IAST.PATCH_MODULES)
322-
assert iastpatch.should_iast_patch("module1") == iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST
322+
assert iastpatch.should_iast_patch("module1") == iastpatch.ALLOWED_USER_ALLOWLIST
323+
assert iastpatch.should_iast_patch("module2") == iastpatch.ALLOWED_USER_ALLOWLIST
324+
assert iastpatch.should_iast_patch("module2000") == iastpatch.ALLOWED_FIRST_PARTY_ALLOWLIST
323325

324326

325327
@pytest.mark.parametrize(
326328
"module_name,allowlist,denylist,expected_result",
327329
[
328330
("module.both", "module.both.", "module.both.", iastpatch.ALLOWED_USER_ALLOWLIST),
329331
("parent.child", "parent.child.", "parent.", iastpatch.ALLOWED_USER_ALLOWLIST),
330-
("parent.child", "parent.", "parent.child.", iastpatch.DENIED_USER_DENYLIST),
331-
("django.core", "django.core.", "", iastpatch.ALLOWED_USER_ALLOWLIST),
332+
("parent.child", "parent.", "parent.child.", iastpatch.ALLOWED_USER_ALLOWLIST),
333+
("parent.child2", "parent.child.", "parent.child2.", iastpatch.DENIED_USER_DENYLIST),
334+
("django.core.", "django.core.", "", iastpatch.ALLOWED_USER_ALLOWLIST),
332335
],
333336
)
334337
def test_should_iast_patch_priority_conflicts(module_name, allowlist, denylist, expected_result):

tests/appsec/iast/test_iast_propagation_path.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ddtrace.appsec._iast._taint_tracking import OriginType
66
from ddtrace.appsec._iast._taint_tracking._taint_objects import taint_pyobject
77
from ddtrace.appsec._iast.constants import VULN_PATH_TRAVERSAL
8-
from tests.appsec.iast.aspects.conftest import _iast_patched_module
8+
from tests.appsec.iast.iast_utils import _iast_patched_module
99
from tests.appsec.iast.iast_utils import get_line_and_hash
1010
from tests.appsec.iast.taint_sinks.conftest import _get_span_report
1111

tests/appsec/iast_packages/test_packages.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,8 @@ def create_venv(self):
467467
"MultiDict contents: {'key1': 'value1'}",
468468
"",
469469
import_module_to_validate="multidict._multidict_py",
470-
test_propagation=True,
470+
# multidict's written in C
471+
test_propagation=False,
471472
),
472473
## Skip due to numpy added to the denylist
473474
# Python 3.12 fails in all steps with "import error" when import numpy
@@ -777,7 +778,7 @@ def create_venv(self):
777778
"some-key",
778779
"Computed value for some-key\nCached value for some-key: Computed value for some-key",
779780
"",
780-
test_propagation=True,
781+
test_propagation=False,
781782
),
782783
# docutils dropped Python 3.8 support in docutils > 1.10.10.21.2
783784
PackageForTesting(

0 commit comments

Comments
 (0)