Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(asm): cmdi patch refactor + rasp support #11870

Merged
merged 15 commits into from
Jan 9, 2025
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ benchmarks/base/aspects_benchmarks_generate.py @DataDog/asm-python
ddtrace/appsec/ @DataDog/asm-python
ddtrace/settings/asm.py @DataDog/asm-python
ddtrace/contrib/subprocess/ @DataDog/asm-python
ddtrace/contrib/internal/subprocess/ @DataDog/asm-python
ddtrace/contrib/flask_login/ @DataDog/asm-python
ddtrace/contrib/webbrowser @DataDog/asm-python
ddtrace/contrib/urllib @DataDog/asm-python
Expand Down
3 changes: 2 additions & 1 deletion ddtrace/appsec/_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Flags(enum.IntFlag):
ASM_SESSION_FINGERPRINT = 1 << 33
ASM_NETWORK_FINGERPRINT = 1 << 34
ASM_HEADER_FINGERPRINT = 1 << 35
ASM_RASP_CMDI = 1 << 37


_ALL_ASM_BLOCKING = (
Expand All @@ -49,7 +50,7 @@ class Flags(enum.IntFlag):
| Flags.ASM_HEADER_FINGERPRINT
)

_ALL_RASP = Flags.ASM_RASP_SQLI | Flags.ASM_RASP_LFI | Flags.ASM_RASP_SSRF | Flags.ASM_RASP_SHI
_ALL_RASP = Flags.ASM_RASP_SQLI | Flags.ASM_RASP_LFI | Flags.ASM_RASP_SSRF | Flags.ASM_RASP_SHI | Flags.ASM_RASP_CMDI
_FEATURE_REQUIRED = Flags.ASM_ACTIVATION | Flags.ASM_AUTO_USER


Expand Down
119 changes: 75 additions & 44 deletions ddtrace/appsec/_common_module_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@
from typing import Callable
from typing import Dict
from typing import Iterable
from typing import List
from typing import Union

from wrapt import FunctionWrapper
from wrapt import resolve_path

import ddtrace
from ddtrace.appsec._asm_request_context import get_blocked
from ddtrace.appsec._constants import EXPLOIT_PREVENTION
from ddtrace.appsec._constants import WAF_ACTIONS
from ddtrace.appsec._iast._iast_request_context import is_iast_request_enabled
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_sink
from ddtrace.appsec._iast.constants import VULN_PATH_TRAVERSAL
import ddtrace.contrib.internal.subprocess.patch as subprocess_patch
from ddtrace.internal import core
from ddtrace.internal._exceptions import BlockingException
from ddtrace.internal._unpatched import _gc as gc
Expand All @@ -30,6 +34,9 @@

_is_patched = False

_RASP_SYSTEM = "rasp_os.system"
_RASP_POPEN = "rasp_Popen"


def patch_common_modules():
global _is_patched
Expand All @@ -39,7 +46,10 @@ def patch_common_modules():
try_wrap_function_wrapper("urllib.request", "OpenerDirector.open", wrapped_open_ED4CF71136E15EBF)
try_wrap_function_wrapper("_io", "BytesIO.read", wrapped_read_F3E51D71B4EC16EF)
try_wrap_function_wrapper("_io", "StringIO.read", wrapped_read_F3E51D71B4EC16EF)
try_wrap_function_wrapper("os", "system", wrapped_system_5542593D237084A7)
# ensure that the subprocess patch is applied even after one click activation
subprocess_patch.patch()
subprocess_patch.add_str_callback(_RASP_SYSTEM, wrapped_system_5542593D237084A7)
subprocess_patch.add_lst_callback(_RASP_POPEN, popen_FD233052260D8B4D)
core.on("asm.block.dbapi.execute", execute_4C9BAC8E228EB347)
if asm_config._iast_enabled:
_set_metric_iast_instrumented_sink(VULN_PATH_TRAVERSAL)
Expand All @@ -54,6 +64,8 @@ def unpatch_common_modules():
try_unwrap("urllib.request", "OpenerDirector.open")
try_unwrap("_io", "BytesIO.read")
try_unwrap("_io", "StringIO.read")
subprocess_patch.del_str_callback(_RASP_SYSTEM)
subprocess_patch.del_lst_callback(_RASP_POPEN)
_is_patched = False


Expand Down Expand Up @@ -106,7 +118,6 @@ def wrapped_open_CFDDB7ABBA9081B6(original_open_callable, instance, args, kwargs
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
from ddtrace.appsec._constants import EXPLOIT_PREVENTION
except ImportError:
# open is used during module initialization
# and shouldn't be changed at that time
Expand All @@ -124,7 +135,9 @@ def wrapped_open_CFDDB7ABBA9081B6(original_open_callable, instance, args, kwargs
rule_type=EXPLOIT_PREVENTION.TYPE.LFI,
)
if res and _must_block(res.actions):
raise BlockingException(get_blocked(), "exploit_prevention", "lfi", filename)
raise BlockingException(
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.LFI, filename
)
try:
return original_open_callable(*args, **kwargs)
except Exception as e:
Expand All @@ -151,7 +164,6 @@ def wrapped_open_ED4CF71136E15EBF(original_open_callable, instance, args, kwargs
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
from ddtrace.appsec._constants import EXPLOIT_PREVENTION
except ImportError:
# open is used during module initialization
# and shouldn't be changed at that time
Expand All @@ -168,7 +180,9 @@ def wrapped_open_ED4CF71136E15EBF(original_open_callable, instance, args, kwargs
rule_type=EXPLOIT_PREVENTION.TYPE.SSRF,
)
if res and _must_block(res.actions):
raise BlockingException(get_blocked(), "exploit_prevention", "ssrf", url)
raise BlockingException(
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.SSRF, url
)
return original_open_callable(*args, **kwargs)


Expand All @@ -191,7 +205,6 @@ def wrapped_request_D8CB81E472AF98A2(original_request_callable, instance, args,
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
from ddtrace.appsec._constants import EXPLOIT_PREVENTION
except ImportError:
# open is used during module initialization
# and shouldn't be changed at that time
Expand All @@ -206,50 +219,67 @@ def wrapped_request_D8CB81E472AF98A2(original_request_callable, instance, args,
rule_type=EXPLOIT_PREVENTION.TYPE.SSRF,
)
if res and _must_block(res.actions):
raise BlockingException(get_blocked(), "exploit_prevention", "ssrf", url)
raise BlockingException(
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.SSRF, url
)

return original_request_callable(*args, **kwargs)


def wrapped_system_5542593D237084A7(original_command_callable, instance, args, kwargs):
def wrapped_system_5542593D237084A7(command: str) -> None:
"""
wrapper for os.system function
"""
command = args[0] if args else kwargs.get("command", None)
if command is not None:
if asm_config._iast_enabled and is_iast_request_enabled():
from ddtrace.appsec._iast.taint_sinks.command_injection import _iast_report_cmdi

_iast_report_cmdi(command)

if (
asm_config._asm_enabled
and asm_config._ep_enabled
and ddtrace.tracer._appsec_processor is not None
and ddtrace.tracer._appsec_processor.rasp_cmdi_enabled
):
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
from ddtrace.appsec._constants import EXPLOIT_PREVENTION
except ImportError:
return original_command_callable(*args, **kwargs)

if in_asm_context():
res = call_waf_callback(
{EXPLOIT_PREVENTION.ADDRESS.CMDI: command},
crop_trace="wrapped_system_5542593D237084A7",
rule_type=EXPLOIT_PREVENTION.TYPE.CMDI,
if (
asm_config._asm_enabled
and asm_config._ep_enabled
and ddtrace.tracer._appsec_processor is not None
and ddtrace.tracer._appsec_processor.rasp_shi_enabled
):
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
except ImportError:
return

if in_asm_context():
res = call_waf_callback(
{EXPLOIT_PREVENTION.ADDRESS.SHI: command},
crop_trace="wrapped_system_5542593D237084A7",
rule_type=EXPLOIT_PREVENTION.TYPE.SHI,
)
if res and _must_block(res.actions):
raise BlockingException(
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.SHI, command
)


def popen_FD233052260D8B4D(arg_list: Union[List[str], str]) -> None:
"""
listener for subprocess.Popen class
"""
if (
asm_config._asm_enabled
and asm_config._ep_enabled
and ddtrace.tracer._appsec_processor is not None
and ddtrace.tracer._appsec_processor.rasp_cmdi_enabled
):
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
except ImportError:
return

if in_asm_context():
res = call_waf_callback(
{EXPLOIT_PREVENTION.ADDRESS.CMDI: arg_list if isinstance(arg_list, list) else [arg_list]},
crop_trace="popen_FD233052260D8B4D",
rule_type=EXPLOIT_PREVENTION.TYPE.CMDI,
)
if res and _must_block(res.actions):
raise BlockingException(
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.CMDI, arg_list
)
if res and _must_block(res.actions):
raise BlockingException(get_blocked(), "exploit_prevention", "cmdi", command)
try:
return original_command_callable(*args, **kwargs)
except Exception as e:
previous_frame = e.__traceback__.tb_frame.f_back
raise e.with_traceback(
e.__traceback__.__class__(None, previous_frame, previous_frame.f_lasti, previous_frame.f_lineno)
)


_DB_DIALECTS = {
Expand Down Expand Up @@ -279,7 +309,6 @@ def execute_4C9BAC8E228EB347(instrument_self, query, args, kwargs) -> None:
try:
from ddtrace.appsec._asm_request_context import call_waf_callback
from ddtrace.appsec._asm_request_context import in_asm_context
from ddtrace.appsec._constants import EXPLOIT_PREVENTION
except ImportError:
# execute is used during module initialization
# and shouldn't be changed at that time
Expand All @@ -296,7 +325,9 @@ def execute_4C9BAC8E228EB347(instrument_self, query, args, kwargs) -> None:
rule_type=EXPLOIT_PREVENTION.TYPE.SQLI,
)
if res and _must_block(res.actions):
raise BlockingException(get_blocked(), "exploit_prevention", "sqli", query)
raise BlockingException(
get_blocked(), EXPLOIT_PREVENTION.BLOCKING, EXPLOIT_PREVENTION.TYPE.SQLI, query
)


def try_unwrap(module, name):
Expand Down
6 changes: 5 additions & 1 deletion ddtrace/appsec/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ class WAF_DATA_NAMES(metaclass=Constant_Class):

# EPHEMERAL ADDRESSES
PROCESSOR_SETTINGS: Literal["waf.context.processor"] = "waf.context.processor"
CMDI_ADDRESS: Literal["server.sys.shell.cmd"] = "server.sys.shell.cmd"
CMDI_ADDRESS: Literal["server.sys.exec.cmd"] = "server.sys.exec.cmd"
SHI_ADDRESS: Literal["server.sys.shell.cmd"] = "server.sys.shell.cmd"
LFI_ADDRESS: Literal["server.io.fs.file"] = "server.io.fs.file"
SSRF_ADDRESS: Literal["server.io.net.url"] = "server.io.net.url"
SQLI_ADDRESS: Literal["server.db.statement"] = "server.db.statement"
Expand Down Expand Up @@ -328,6 +329,7 @@ class DEFAULT(metaclass=Constant_Class):


class EXPLOIT_PREVENTION(metaclass=Constant_Class):
BLOCKING: Literal["exploit_prevention"] = "exploit_prevention"
STACK_TRACE_ID: Literal["stack_id"] = "stack_id"
EP_ENABLED: Literal["DD_APPSEC_RASP_ENABLED"] = "DD_APPSEC_RASP_ENABLED"
STACK_TRACE_ENABLED: Literal["DD_APPSEC_STACK_TRACE_ENABLED"] = "DD_APPSEC_STACK_TRACE_ENABLED"
Expand All @@ -339,13 +341,15 @@ class EXPLOIT_PREVENTION(metaclass=Constant_Class):

class TYPE(metaclass=Constant_Class):
CMDI: Literal["command_injection"] = "command_injection"
SHI: Literal["shell_injection"] = "shell_injection"
LFI: Literal["lfi"] = "lfi"
SSRF: Literal["ssrf"] = "ssrf"
SQLI: Literal["sql_injection"] = "sql_injection"

class ADDRESS(metaclass=Constant_Class):
CMDI: Literal["CMDI_ADDRESS"] = "CMDI_ADDRESS"
LFI: Literal["LFI_ADDRESS"] = "LFI_ADDRESS"
SHI: Literal["SHI_ADDRESS"] = "SHI_ADDRESS"
SSRF: Literal["SSRF_ADDRESS"] = "SSRF_ADDRESS"
SQLI: Literal["SQLI_ADDRESS"] = "SQLI_ADDRESS"
SQLI_TYPE: Literal["SQLI_SYSTEM_ADDRESS"] = "SQLI_SYSTEM_ADDRESS"
Expand Down
2 changes: 2 additions & 0 deletions ddtrace/appsec/_iast/_pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def ddtrace_iast(request, ddspan):
Optionally output the test as failed if vulnerabilities are found.
"""
yield
if ddspan is None:
return
data = ddspan.get_tag(IAST.JSON)
if not data:
return
Expand Down
51 changes: 10 additions & 41 deletions ddtrace/appsec/_iast/taint_sinks/command_injection.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import os
import subprocess # nosec
from typing import List
from typing import Union

from ddtrace.appsec._common_module_patches import try_unwrap
from ddtrace.appsec._constants import IAST_SPAN_TAGS
from ddtrace.appsec._iast import oce
from ddtrace.appsec._iast._iast_request_context import is_iast_request_enabled
from ddtrace.appsec._iast._metrics import _set_metric_iast_executed_sink
from ddtrace.appsec._iast._metrics import _set_metric_iast_instrumented_sink
from ddtrace.appsec._iast._metrics import increment_iast_span_metric
from ddtrace.appsec._iast._patch import try_wrap_function_wrapper
from ddtrace.appsec._iast._taint_tracking._taint_objects import is_pyobject_tainted
from ddtrace.appsec._iast.constants import VULN_CMDI
import ddtrace.contrib.internal.subprocess.patch as subprocess_patch
from ddtrace.internal.logger import get_logger
from ddtrace.settings.asm import config as asm_config

Expand All @@ -26,48 +23,20 @@ def get_version() -> str:
return ""


def patch():
if not asm_config._iast_enabled:
return

if not getattr(os, "_datadog_cmdi_patch", False):
# all os.spawn* variants eventually use this one:
try_wrap_function_wrapper("os", "_spawnvef", _iast_cmdi_osspawn)

if not getattr(subprocess, "_datadog_cmdi_patch", False):
try_wrap_function_wrapper("subprocess", "Popen.__init__", _iast_cmdi_subprocess_init)
_IAST_CMDI = "iast_cmdi"

os._datadog_cmdi_patch = True
subprocess._datadog_cmdi_patch = True

_set_metric_iast_instrumented_sink(VULN_CMDI)
def patch():
if asm_config._iast_enabled:
subprocess_patch.patch()
subprocess_patch.add_str_callback(_IAST_CMDI, _iast_report_cmdi)
subprocess_patch.add_lst_callback(_IAST_CMDI, _iast_report_cmdi)
_set_metric_iast_instrumented_sink(VULN_CMDI)


def unpatch() -> None:
try_unwrap("os", "system")
try_unwrap("os", "_spawnvef")
try_unwrap("subprocess", "Popen.__init__")

os._datadog_cmdi_patch = False # type: ignore[attr-defined]
subprocess._datadog_cmdi_patch = False # type: ignore[attr-defined]


def _iast_cmdi_osspawn(wrapped, instance, args, kwargs):
mode, file, func_args, _, _ = args
_iast_report_cmdi(func_args)

if hasattr(wrapped, "__func__"):
return wrapped.__func__(instance, *args, **kwargs)
return wrapped(*args, **kwargs)


def _iast_cmdi_subprocess_init(wrapped, instance, args, kwargs):
cmd_args = args[0] if len(args) else kwargs["args"]
_iast_report_cmdi(cmd_args)

if hasattr(wrapped, "__func__"):
return wrapped.__func__(instance, *args, **kwargs)
return wrapped(*args, **kwargs)
subprocess_patch.del_str_callback(_IAST_CMDI)
subprocess_patch.del_lst_callback(_IAST_CMDI)


@oce.register
Expand Down
Loading
Loading