diff --git a/bridge/_files_integrations.php b/bridge/_files_integrations.php index c6d1dda04a..d1dd2e1f65 100644 --- a/bridge/_files_integrations.php +++ b/bridge/_files_integrations.php @@ -11,6 +11,7 @@ __DIR__ . '/../src/Integrations/Integrations/AMQP/AMQPIntegration.php', __DIR__ . '/../src/Integrations/Integrations/CakePHP/CakePHPIntegration.php', __DIR__ . '/../src/Integrations/Integrations/CodeIgniter/V2/CodeIgniterIntegration.php', + __DIR__ . '/../src/Integrations/Integrations/Exec/ExecIntegration.php', __DIR__ . '/../src/Integrations/Integrations/Drupal/DrupalIntegration.php', __DIR__ . '/../src/Integrations/Integrations/Web/WebIntegration.php', __DIR__ . '/../src/Integrations/Integrations/IntegrationsLoader.php', diff --git a/config.m4 b/config.m4 index 850a631476..4acba10253 100644 --- a/config.m4 +++ b/config.m4 @@ -142,6 +142,7 @@ if test "$PHP_DDTRACE" != "no"; then ext/handlers_exception.c \ ext/handlers_internal.c \ ext/handlers_pcntl.c \ + ext/integrations/exec_integration.c \ ext/integrations/integrations.c \ ext/ip_extraction.c \ ext/logging.c \ diff --git a/ext/ddtrace.c b/ext/ddtrace.c index 8603523216..b9df2334ea 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -47,6 +47,7 @@ #include "excluded_modules.h" #include "handlers_http.h" #include "handlers_internal.h" +#include "integrations/exec_integration.h" #include "integrations/integrations.h" #include "ip_extraction.h" #include "logging.h" @@ -1100,6 +1101,10 @@ static PHP_RSHUTDOWN_FUNCTION(ddtrace) { zend_hash_destroy(&DDTRACE_G(traced_spans)); + // this needs to be done before dropping the spans + // run unconditionally because ddtrace may've been disabled mid-request + ddtrace_exec_handlers_rshutdown(); + if (get_DD_TRACE_ENABLED()) { dd_force_shutdown_tracing(); } else if (!DDTRACE_G(disable)) { diff --git a/ext/handlers_internal.c b/ext/handlers_internal.c index 94474fb20e..13900abef7 100644 --- a/ext/handlers_internal.c +++ b/ext/handlers_internal.c @@ -2,6 +2,7 @@ #include "arrays.h" #include "ddtrace.h" +#include "integrations/exec_integration.h" void ddtrace_free_unregistered_class(zend_class_entry *ce) { #if PHP_VERSION_ID >= 80100 @@ -108,6 +109,11 @@ static void dd_install_internal_handlers(void) { dd_install_internal_function("curl_exec"); dd_install_internal_function("pcntl_fork"); dd_install_internal_function("pcntl_rfork"); + dd_install_internal_function("DDTrace\\Integrations\\Exec\\register_stream"); + dd_install_internal_function("DDTrace\\Integrations\\Exec\\proc_assoc_span"); + dd_install_internal_function("DDTrace\\Integrations\\Exec\\proc_get_span"); + dd_install_internal_function("DDTrace\\Integrations\\Exec\\proc_get_pid"); + dd_install_internal_function("DDTrace\\Integrations\\Exec\\test_rshutdown"); } #endif @@ -121,7 +127,7 @@ void ddtrace_exception_handlers_rinit(void); void ddtrace_curl_handlers_rshutdown(void); -void ddtrace_internal_handlers_startup(void) { +void ddtrace_internal_handlers_startup() { // On PHP 8.0 zend_execute_internal is not executed in JIT. Manually ensure internal hooks are executed. #if PHP_VERSION_ID >= 80000 && PHP_VERSION_ID < 80200 #if PHP_VERSION_ID >= 80100 @@ -139,6 +145,8 @@ void ddtrace_internal_handlers_startup(void) { ddtrace_pcntl_handlers_startup(); // exception handlers have to run otherwise wrapping will fail horribly ddtrace_exception_handlers_startup(); + + ddtrace_exec_handlers_startup(); } void ddtrace_internal_handlers_shutdown(void) { @@ -150,11 +158,18 @@ void ddtrace_internal_handlers_shutdown(void) { #if PHP_VERSION_ID < 80000 ddtrace_curl_handlers_shutdown(); #endif + + ddtrace_exec_handlers_shutdown(); } void ddtrace_internal_handlers_rinit(void) { ddtrace_curl_handlers_rinit(); ddtrace_exception_handlers_rinit(); + ddtrace_exec_handlers_rinit(); } -void ddtrace_internal_handlers_rshutdown(void) { ddtrace_curl_handlers_rshutdown(); } +void ddtrace_internal_handlers_rshutdown(void) { + ddtrace_curl_handlers_rshutdown(); + // called earlier in zm_deactivate_ddtrace + // ddtrace_exec_handlers_rshutdown(); +} diff --git a/ext/hook/uhook.c b/ext/hook/uhook.c index 9a07c1c2f2..beed6ae4ca 100644 --- a/ext/hook/uhook.c +++ b/ext/hook/uhook.c @@ -58,6 +58,7 @@ typedef struct { zval *retval_ptr; ddtrace_span_data *span; ddtrace_span_stack *prior_stack; + bool returns_reference; } dd_hook_data; typedef struct { @@ -225,6 +226,7 @@ static bool dd_uhook_begin(zend_ulong invocation, zend_execute_data *execute_dat } dyn->hook_data = (dd_hook_data *)dd_hook_data_create(ddtrace_hook_data_ce); + dyn->hook_data->returns_reference = execute_data->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE; dyn->hook_data->vm_stack_top = EG(vm_stack_top); dyn->hook_data->invocation = invocation; @@ -775,6 +777,12 @@ ZEND_METHOD(DDTrace_HookData, overrideReturnValue) { RETURN_FALSE; } + if (hookData->returns_reference) { + ZVAL_MAKE_REF(retval); + } else { + ZVAL_DEREF(retval); + } + zval_ptr_dtor(hookData->retval_ptr); ZVAL_COPY(hookData->retval_ptr, retval); diff --git a/ext/integrations/exec_integration.c b/ext/integrations/exec_integration.c new file mode 100644 index 0000000000..b1813ea8a3 --- /dev/null +++ b/ext/integrations/exec_integration.c @@ -0,0 +1,335 @@ +#include "exec_integration.h" + +#include +#include +#include + +#include +#include + +#include "../compatibility.h" +#include "../ddtrace.h" +#include "../span.h" +#include "exec_integration_arginfo.h" + +#define NS "DDTrace\\Integrations\\Exec\\" + +#if PHP_VERSION_ID <= 80000 +typedef struct php_process_handle php_process_handle; +#endif + +/* popen stream handler close interception */ + +static int dd_php_stdiop_close_wrapper(php_stream *stream, int close_handle); +static void dd_waitpid(ddtrace_span_data *, php_process_id_t); + +ZEND_TLS HashTable *tracked_streams; // php_stream => span +static zend_string *cmd_exit_code_zstr; +static zend_string *error_message_zstr; +static zend_string *has_signalled_zstr; +static zend_string *pclose_minus_one_zstr; +static int (*orig_php_stream_stdio_ops_close)(php_stream *stream, int close_handle); + +static inline bool in_request() { return tracked_streams != NULL; } + +static void dd_exec_init_track_streams() { + ALLOC_HASHTABLE(tracked_streams); + zend_hash_init(tracked_streams, 8, NULL, ZVAL_PTR_DTOR, 0); +} +static void dd_exec_destroy_tracked_streams() { + if (!tracked_streams) { + return; + } + zend_hash_destroy(tracked_streams); + FREE_HASHTABLE(tracked_streams); + tracked_streams = NULL; +} + +static PHP_FUNCTION(DDTrace_integrations_exec_register_stream) { + php_stream *stream; + zval *zstream; + zend_object *span; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_RESOURCE(zstream) + Z_PARAM_OBJ(span) + ZEND_PARSE_PARAMETERS_END(); + + php_stream_from_res(stream, Z_RES_P(zstream)); + if (!stream) { + RETURN_FALSE; + } + + zval zspan; + ZVAL_OBJ(&zspan, span); + zend_hash_str_add(tracked_streams, (const char *)&stream, sizeof stream, &zspan); + GC_ADDREF(span); + + RETURN_TRUE; +} + +static int dd_php_stdiop_close_wrapper(php_stream *stream, int close_handle) { + int ret = orig_php_stream_stdio_ops_close(stream, close_handle); + + if (!in_request()) { + return ret; + } + + zval *span_data_zv = zend_hash_str_find(tracked_streams, (const char *)&stream, sizeof stream); + if (!span_data_zv) { + return ret; + } + + ddtrace_span_data *span_data = OBJ_SPANDATA(Z_OBJ_P(span_data_zv)); + + zend_array *meta = ddtrace_property_array(&span_data->property_meta); + if (ret == -1) { + zval zv; + ZVAL_INTERNED_STR(&zv, pclose_minus_one_zstr); + zend_hash_update(meta, error_message_zstr, &zv); + } else { + zval zexit; + ZVAL_LONG(&zexit, ret); + zend_hash_update(meta, cmd_exit_code_zstr, &zexit); + } + + dd_trace_stop_span_time(span_data); + ddtrace_close_span_restore_stack(span_data); + + zend_hash_str_del(tracked_streams, (const char *)&stream, sizeof stream); + + return ret; +} + +/* proc_open / proc_close handling */ +typedef struct _dd_proc_span { + zend_object *span; + php_process_id_t child; +} dd_proc_span; +static int le_proc; +static int le_proc_span; + +static void dd_proc_wrapper_rsrc_dtor(zend_resource *rsrc) { + // this is called from the beginning of proc_open_rsrc_dtor, + // before the process is possibly reaped + + dd_proc_span *proc_span = (dd_proc_span *)rsrc->ptr; + + ddtrace_span_data *span_data = OBJ_SPANDATA(proc_span->span); + + if (span_data->duration == 0) { + dd_waitpid(span_data, proc_span->child); + + dd_trace_stop_span_time(span_data); + ddtrace_close_span_restore_stack(span_data); + } // else we already finished the span in proc_get_status + + zend_object_release(proc_span->span); + efree(proc_span); +} + +static void dd_waitpid(ddtrace_span_data *span_data, php_process_id_t pid) { + if (span_data->duration) { + // already closed + return; + } + zend_array *meta = ddtrace_property_array(&span_data->property_meta); + + // if FG(pclose_wait) is true, we're called from proc_close, + // which will wait for the process to exit. We reproduce that behavior + + int opts = FG(pclose_wait) ? 0 : WNOHANG | WUNTRACED; + + int wstatus; + int pid_res = -1; + while ((pid_res = waitpid(pid, &wstatus, opts)) == -1 && errno == EINTR) { + } + + if (pid_res != pid) { + return; // 0 was returned (no changed status) or some error + // Probably the process is no more/not a child + } + + bool exited = false; + if (WIFEXITED(wstatus)) { + exited = true; + wstatus = WEXITSTATUS(wstatus); + } else if (WIFSIGNALED(wstatus)) { + // wstatus is not modified! + // note that normal exit code 9 and signal 9 will both result in + // the value 9 for wstatus (unlike e.g. the shell, which would + // add 128 to the signal number). This may be strange, but + // that's how PHP does it in the return value of proc_open + zval has_signalled_zv; + ZVAL_INTERNED_STR(&has_signalled_zv, has_signalled_zstr); + zend_hash_update(meta, error_message_zstr, &has_signalled_zv); + exited = true; + } // else !FG(pclose_wait) and it hasn't finished + + if (exited) { + zval zexit; + ZVAL_LONG(&zexit, wstatus); + + // set tag 'cmd.exit_code' + zend_hash_update(meta, cmd_exit_code_zstr, &zexit); + } +} + +static PHP_FUNCTION(DDTrace_integrations_exec_proc_assoc_span) { + zval *zres; + zend_object *span; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_RESOURCE(zres) + Z_PARAM_OBJ(span) + ZEND_PARSE_PARAMETERS_END(); + + if (Z_RES_TYPE_P(zres) != le_proc) { + RETURN_FALSE; + } + + php_process_handle *proc_h = Z_RES_P(zres)->ptr; + + dd_proc_span *proc_span = emalloc(sizeof *proc_span); + proc_span->span = span; + GC_ADDREF(span); + proc_span->child = proc_h->child; + + proc_h->npipes += 1; + proc_h->pipes = safe_erealloc(proc_h->pipes, proc_h->npipes, sizeof *proc_h->pipes, 0); + zend_resource *proc_span_res = zend_register_resource(proc_span, le_proc_span); + proc_h->pipes[proc_h->npipes - 1] = proc_span_res; + + RETURN_TRUE; +} + +static PHP_FUNCTION(DDTrace_integrations_exec_proc_get_span) { + zval *zres; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_RESOURCE(zres) + ZEND_PARSE_PARAMETERS_END(); + + if (Z_RES_TYPE_P(zres) != le_proc) { + RETURN_NULL(); + } + + php_process_handle *proc_h = Z_RES_P(zres)->ptr; + if (proc_h->npipes == 0) { + RETURN_NULL(); + } + + zend_resource *span_res = proc_h->pipes[proc_h->npipes - 1]; + if (span_res->type != le_proc_span) { + RETURN_NULL(); + } + + dd_proc_span *span_h = span_res->ptr; + RETURN_OBJ_COPY(span_h->span); +} + +// used in testing only +static PHP_FUNCTION(DDTrace_integrations_exec_proc_get_pid) { + zval *zres; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_RESOURCE(zres) + ZEND_PARSE_PARAMETERS_END(); + + if (Z_RES_TYPE_P(zres) != le_proc) { + RETURN_NULL(); + } + + php_process_handle *proc_h = Z_RES_P(zres)->ptr; + RETURN_LONG((long)proc_h->child); +} +static PHP_FUNCTION(DDTrace_integrations_exec_test_rshutdown) { + if (zend_parse_parameters_none() != SUCCESS) { + return; + } + + ddtrace_exec_handlers_rshutdown(); + dd_exec_init_track_streams(); + RETURN_TRUE; +} + +// clang-format off +static const zend_function_entry functions[] = { + ZEND_RAW_FENTRY(NS "register_stream", PHP_FN(DDTrace_integrations_exec_register_stream), arginfo_DDTrace_Integrations_Exec_register_stream, 0) + ZEND_RAW_FENTRY(NS "proc_assoc_span", PHP_FN(DDTrace_integrations_exec_proc_assoc_span), arginfo_DDTrace_Integrations_Exec_proc_assoc_span, 0) + ZEND_RAW_FENTRY(NS "proc_get_span", PHP_FN(DDTrace_integrations_exec_proc_get_span), arginfo_DDTrace_Integrations_Exec_proc_get_span, 0) + ZEND_RAW_FENTRY(NS "proc_get_pid", PHP_FN(DDTrace_integrations_exec_proc_get_pid), arginfo_DDTrace_Integrations_Exec_proc_get_pid, 0) + ZEND_RAW_FENTRY(NS "test_rshutdown", PHP_FN(DDTrace_integrations_exec_test_rshutdown), arginfo_DDTrace_Integrations_Exec_test_rshutdown, 0) + PHP_FE_END +}; +// clang-format on + +void ddtrace_exec_handlers_startup() { + // popen + orig_php_stream_stdio_ops_close = php_stream_stdio_ops.close; + php_stream_stdio_ops.close = dd_php_stdiop_close_wrapper; + + zend_register_functions(NULL, functions, NULL, MODULE_PERSISTENT); + cmd_exit_code_zstr = zend_string_init_interned(ZEND_STRL("cmd.exit_code"), 1); + error_message_zstr = zend_string_init_interned(ZEND_STRL("error.message"), 1); + has_signalled_zstr = zend_string_init_interned(ZEND_STRL("The process was terminated by a signal"), 1); + pclose_minus_one_zstr = zend_string_init_interned(ZEND_STRL("Closing popen() stream returned -1"), 1); + + // proc_open + le_proc = zend_fetch_list_dtor_id("process"); + // we don't have the module number, but it's only relevant for persistent resources anyway + le_proc_span = zend_register_list_destructors_ex(dd_proc_wrapper_rsrc_dtor, NULL, "process_wrapper", -1); +} + +void ddtrace_exec_handlers_shutdown() { + if (orig_php_stream_stdio_ops_close) { + php_stream_stdio_ops.close = orig_php_stream_stdio_ops_close; + orig_php_stream_stdio_ops_close = NULL; + } +} + +void ddtrace_exec_handlers_rinit() { + // also called when ddtrace is reenabled mid-request. + // OTOH ddtrace_exec_handlers_rshutdown is not called when ddtrace is + // disabled because it needs to be called earlier on upon the real rshutodown + + if (tracked_streams) { + dd_exec_destroy_tracked_streams(); + } + + dd_exec_init_track_streams(); +} + +void ddtrace_exec_handlers_rshutdown() { + if (tracked_streams) { + zend_ulong h; + zend_string *key; + zval *val; + ZEND_HASH_REVERSE_FOREACH_KEY_VAL(tracked_streams, h, key, val) { + (void)h; + (void)val; + php_stream *stream; + memcpy(&stream, ZSTR_VAL(key), sizeof stream); + // manually close the tracked stream on rshutdown in case they + // lived till the end of the request so we can finish the span + zend_list_close(stream->res); + } + ZEND_HASH_FOREACH_END(); + + dd_exec_destroy_tracked_streams(); + } + + { + zend_ulong h; + zend_resource *rsrc; + // iterate EG(regular_list) to destroy dd_proc_span resources + // while we are still in the request + ZEND_HASH_FOREACH_NUM_KEY_PTR(&EG(regular_list), h, rsrc) { + (void)h; + if (rsrc->type == le_proc_span) { + zend_list_close(rsrc); + } + } + ZEND_HASH_FOREACH_END(); + } +} diff --git a/ext/integrations/exec_integration.h b/ext/integrations/exec_integration.h new file mode 100644 index 0000000000..edb7d51eb0 --- /dev/null +++ b/ext/integrations/exec_integration.h @@ -0,0 +1,6 @@ +#pragma once + +void ddtrace_exec_handlers_startup(void); +void ddtrace_exec_handlers_shutdown(void); +void ddtrace_exec_handlers_rinit(void); +void ddtrace_exec_handlers_rshutdown(void); diff --git a/ext/integrations/exec_integration.stub.php b/ext/integrations/exec_integration.stub.php new file mode 100644 index 0000000000..92d935f681 --- /dev/null +++ b/ext/integrations/exec_integration.stub.php @@ -0,0 +1,60 @@ +type == DDTRACE_SPAN_CLOSED) { + return; + } + + // switches to the stack of the passed span, closes the span and switches back to the original stack + ddtrace_span_stack *active_stack_before = DDTRACE_G(active_stack); + assert(active_stack_before != NULL); + GC_ADDREF(&active_stack_before->std); + + ddtrace_close_span(span); + + ddtrace_switch_span_stack(active_stack_before); + GC_DELREF(&active_stack_before->std); +} + void ddtrace_close_top_span_without_stack_swap(ddtrace_span_data *span) { ddtrace_span_stack *stack = span->stack; diff --git a/ext/span.h b/ext/span.h index b31d32017c..3f53d56463 100644 --- a/ext/span.h +++ b/ext/span.h @@ -190,6 +190,7 @@ bool ddtrace_has_top_internal_span(ddtrace_span_data *end); void ddtrace_close_stack_userland_spans_until(ddtrace_span_data *until); int ddtrace_close_userland_spans_until(ddtrace_span_data *until); void ddtrace_close_span(ddtrace_span_data *span); +void ddtrace_close_span_restore_stack(ddtrace_span_data *); void ddtrace_close_top_span_without_stack_swap(ddtrace_span_data *span); void ddtrace_close_all_open_spans(bool force_close_root_span); void ddtrace_drop_span(ddtrace_span_data *span); diff --git a/src/Integrations/Integrations/Exec/ExecIntegration.php b/src/Integrations/Integrations/Exec/ExecIntegration.php new file mode 100644 index 0000000000..2548e50501 --- /dev/null +++ b/src/Integrations/Integrations/Exec/ExecIntegration.php @@ -0,0 +1,612 @@ + null); + const UNREDACTED_ENV_VARS = array('LD_PRELOAD' => null, 'LD_LIBRARY_PATH' => null, 'PATH' => null); + + public function getName() + { + return self::NAME; + } + + public function init() + { + if (!self::shouldLoad(self::NAME)) { + return Integration::NOT_LOADED; + } + + \DDTrace\install_hook( + 'exec', + self::preHookShell('exec'), + self::postHookShell('exec') + ); + \DDTrace\install_hook( + 'system', + self::preHookShell('system'), + self::postHookShell('system') + ); + \DDTrace\install_hook( + 'passthru', + self::preHookShell('passthru'), + self::postHookShell('passthru') + ); + \DDTrace\install_hook( + 'shell_exec', + self::preHookShell('shell_exec'), + self::postHookShell('shell_exec') + ); + + \DDTrace\install_hook( + 'popen', + self::preHookShell('popen'), + static function (HookData $hook) { + /** @var SpanData $span */ + $span = $hook->data; + if (!$span) { + return; + } + + if ($hook->exception) { + $span->exception = $hook->exception; + } elseif (!is_resource($hook->returned)) { + $span->meta[Tag::ERROR_MSG] = 'popen() did not return a resource'; + } else { + register_stream($hook->returned, $span); + } + } + ); + + + /* + * This instrumentation works by creating a span on the enter callback, and then + * associating this span with the resource returned by proc_open. This association + * is done by adding a resource to the list of pipes of the proc resource. This + * resource (of type dd_proc_span) is not an actual pipe, but it doesn't matter; + * PHP will only ever destroy this resource. + * + * When the proc resource is destroyed, the dd_proc_span resource is destroyed as + * well, and in the process the span is finished, unless it was finished before + * in proc_get_status. + */ + \DDTrace\install_hook( + 'proc_open', + static function (HookData $hook) { + if (count($hook->args) == 0) { + return; + } + + $arg = $hook->args[0]; + if (is_string($arg)) { + $tags = self::createTagsShellString($arg); + if ($tags === null) { + return; + } + + $span = self::createSpan($tags, 'sh'); + } elseif (is_array($arg) && PHP_VERSION_ID >= 70400 && count($arg) && is_string($arg[0])) { + $tags = self::createTagsExec($arg); + if ($tags === null) { + return; + } + + $spanName = self::baseBinary($arg); + $span = self::createSpan($tags, $spanName); + } else { + return; + } + + $hook->data = $span; + }, + static function (HookData $hook) { + /** @var SpanData $span */ + $span = $hook->data; + if (!$span) { + return; + } + + if ($hook->exception) { + $span->exception = $hook->exception; + } elseif (!is_resource($hook->returned)) { + $span->meta[Tag::ERROR_MSG] = 'proc_open() did not return a resource'; + } else { + proc_assoc_span($hook->returned, $span); + } + } + ); + + \DDTrace\install_hook( + 'proc_get_status', + null, + static function (HookData $hook) { + if (count($hook->args) != 1 || !is_resource($hook->args[0])) { + return; + } + + $span = proc_get_span($hook->args[0]); + if (!$span) { + return; + } + + if ($span->getDuration() != 0) { + // already finished + return; + } + + if (!is_array($hook->returned) || $hook->exception) { + return; + } + + if ($hook->returned['running']) { + return; + } + + if ($hook->returned['signaled']) { + $span->meta[Tag::ERROR_MSG] = 'The process was terminated by a signal'; + $span->meta[Tag::EXEC_EXIT_CODE] = $hook->returned['termsig']; + } else { + $span->meta[Tag::EXEC_EXIT_CODE] = $hook->returned['exitcode']; + } + + self::finishSpanRestoreStack($span); + } + ); + + \DDTrace\install_hook( + 'proc_close', + static function (HookData $hook) { + if (count($hook->args) != 1 || !is_resource($hook->args[0])) { + return; + } + + // the span must be stored in $hook because by the time the post + //hook runs, the resource has already been destroyed + $span = proc_get_span($hook->args[0]); + if (!$span) { + return; + } + // must match condition in dd_proc_wrapper_rsrc_dtor before + // calling dd_waitpid() + if ($span->getDuration() != 0) { + return; + } + // if we get here, we will call waitpid() in the resource + // destructor and very likely reap the process, resulting in + // proc_close() returning -1 + $hook->data = $span; + }, + static function (HookData $hook) { + /** @var SpanData $span */ + $span = $hook->data; + if (!$span) { + return; + } + + if ($hook->returned === -1 && isset($span->meta[Tag::EXEC_EXIT_CODE])) { + $hook->overrideReturnValue($span->meta[Tag::EXEC_EXIT_CODE]); + } + } + ); + + return Integration::LOADED; + } + + const RET_CODE_ARGNUM = [ + 'exec' => 2, + 'system' => 1, + 'passthru' => 1, + 'shell_exec' => null, + ]; + + private static function preHookShell($variant) + { + return static function (HookData $hook) use ($variant) { + if (count($hook->args) == 0 || !is_string($hook->args[0])) { + return; + } + + $tags = self::createTagsShellString($hook->args[0]); + if ($tags === null) { + return; + } + + $span = self::createSpan($tags, 'sh'); + $hook->data = $span; + + $retCodeArg = self::RET_CODE_ARGNUM[$variant]; + if (empty($retCodeArg)) { + return; + } + + while (count($hook->args) < $retCodeArg) { + $hook->args[] = null; + } + if (count($hook->args) == $retCodeArg) { + $exitCode = null; + $hook->args[] =& $exitCode; + } + + // can fail if there isn't enough stack space + $hook->overrideArguments($hook->args); + }; + } + + private static function postHookShell($variant) + { + return static function (HookData $hook) use ($variant) { + /** @var SpanData $span */ + $span = $hook->data; + if (!$span) { + return; + } + + $retCodeArg = self::RET_CODE_ARGNUM[$variant]; + + if ($hook->exception) { + $span->exception = $hook->exception; + } elseif ($hook->returned === false) { + $span->meta[Tag::ERROR_MSG] = "$variant() returned false"; + } elseif ($hook->returned === null && $variant === 'shell_exec') { + $span->meta[Tag::ERROR_MSG] = "shell_exec() returned null"; + } elseif ( + !empty($retCodeArg) && + isset($hook->args[$retCodeArg]) && + $hook->args[$retCodeArg] !== null + ) { + $span->meta[Tag::EXEC_EXIT_CODE] = $hook->args[$retCodeArg]; + } + + self::finishSpanRestoreStack($span); + }; + } + + private static function createSpan(array $tags, string $resource) + { + create_stack(); + $span = start_span(); + $span->name = 'command_execution'; + $span->meta = $tags; + $span->type = Type::SYSTEM; + $span->resource = $resource; + switch_stack(); + + return $span; + } + + /** + * Tags for execution of ['/bin/sh', '-c', $cmd] + * + * @param $cmd the command to be executed + * @return array|null + */ + private static function createTagsShellString($cmd) + { + if (!is_string($cmd) || trim($cmd) === '') { + return null; + } + + $cmd = self::redactParametersShell(self::redactEnvVariablesShell($cmd)); + $truncated = strlen($cmd) > self::MAX_CMD_SIZE; + if ($truncated) { + $cmd = substr($cmd, 0, self::MAX_CMD_SIZE); + } + + $ret = [ + Tag::EXEC_CMDLINE_SHELL => $cmd, + Tag::COMPONENT => 'subprocess', + ]; + if ($truncated) { + $ret[Tag::EXEC_TRUNCATED] = true; + } + return $ret; + } + + + private static function createTagsExec(array $cmd) + { + if (empty($cmd)) { + return null; + } + + $cmd = self::redactParametersExec($cmd); + $totalLen = 0; + $truncated = false; + $cmdTmp = []; + foreach ($cmd as $arg) { + if ($totalLen + strlen($arg) > self::MAX_CMD_SIZE) { + $left = self::MAX_CMD_SIZE - $totalLen; + $arg = substr($arg, 0, $left); + $cmdTmp[] = $arg; + $truncated = true; + } else { + $cmdTmp[] = $arg; + } + $totalLen += strlen($arg); + } + $cmd = $cmdTmp; + + $ret = [ + Tag::EXEC_CMDLINE_EXEC => self::encodeArray($cmd), + Tag::COMPONENT => 'subprocess', + ]; + if ($truncated) { + $ret[Tag::EXEC_TRUNCATED] = true; + } + return $ret; + } + + private static function each_shell_word($cmd, $f) + { + preg_match_all('/(?: + \\\\. | + [^\s"\';|&] | + "(?:\\\\.|[^"])*" | + \'(?:\\\\.|[^\'])*\' + )+/x', $cmd, $result, PREG_PATTERN_ORDER | PREG_OFFSET_CAPTURE); + for ($i = 0; $i < count($result[0]); $i++) { + list($text, $offset) = $result[0][$i]; + if (!$f($text, $offset, $offset + strlen($text))) { + break; + } + } + } + + /** + * On a POSIX shell command, replace environment variable values. + * + * Example: + * FOO=xxx command;BAR=yyy cmd + * turns into + * FOO=? command;BAR=? cmd + * + * Due to the complexity of the shell language, this will not catch 100% of the cases + * (e.g. case...esac). + * + * @param string $cmd the original command + * @return string the redacted command + */ + private static function redactEnvVariablesShell($cmd) + { + $redacted = $cmd; + $offset = 0; + $prevEnd = 0; + self::each_shell_word($cmd, function ($text, $start, $end) use ($cmd, &$redacted, &$offset, &$prevEnd) { + $commandBegin = ($prevEnd === 0 || self::intersticeHasCommandSeparator($cmd, $prevEnd, $start)); + + if (!$commandBegin) { + $prevEnd = $end; + return true; // it's likely an argument; continue + } + + if ($text === 'do' || $text === 'then') { + // ignore the keyword + // do not adjust prevEnd so the separator is found again next time + return true; + } + + + if (self::matchEnvVariableAssignment($text, $matches)) { + // matches a valid name for an environment variable + // note that quotes/escapes are not allowed in env var names + // `"FOO=x" command` doesn't set an env var + if (key_exists($matches[1], self::UNREDACTED_ENV_VARS)) { + return true; + } + + self::replaceWithOffset( + $redacted, + $offset, + $start + strlen($matches[0]), + $end, + '?' + ); + + // do not adjust prevEnd so we find separators/start again + // this is because we can have several env vars: A=x B=y cmd + } else { + $prevEnd = $end; + } + + return true; // continue + }); + + return $redacted; + } + + private static function redactParametersShell($cmd) + { + $redacted = $cmd; + $offset = 0; + $prevEnd = 0; + $redactAll = false; + $redactNext = false; + self::each_shell_word($cmd, function ($text, $start, $end) use ($cmd, &$redacted, &$offset, &$prevEnd, &$redactAll, &$redactNext) { + // start of command + if ($prevEnd === 0 || self::intersticeHasCommandSeparator($cmd, $prevEnd, $start)) { + // simplified way to handle fors and ifs + // it's not strictly correct: `X=y do` tries to execute 'do' + if ($text === 'do' || $text === 'then') { + return true; + } + if (self::matchEnvVariableAssignment($text)) { + return true; + } + + // then assume we have the command + $baseName = basename(self::unquote($text)); + if (key_exists($baseName, self::REDACTED_BINARIES)) { + $redactAll = true; + } else { + $redactAll = false; + $redactNext = false; + } + + $prevEnd = $end; + return true; + } + + $prevEnd = $end; + + // apply the argument redaction logic + if ($redactAll) { + self::replaceWithOffset($redacted, $offset, $start, $end, '?'); + return true; + } + + if ($redactNext) { + $redactNext = false; + self::replaceWithOffset($redacted, $offset, $start, $end, '?'); + return true; + } + + $unquotedText = self::unquote($text); + $equalsPos = strpos($unquotedText, '='); + if ($equalsPos === false) { // no = + if (preg_match(self::REDACTED_PARAM_PAT, $unquotedText)) { + $redactNext = true; + } + } else { // we have = + if (preg_match(self::REDACTED_PARAM_PAT, substr($unquotedText, 0, $equalsPos))) { + self::replaceWithOffset( + $redacted, + $offset, + $start + strpos($text, '=') + 1, + $end - (($text[0] === '"' || $text[0] === "'") ? 1 : 0), + '?' + ); + } + } + + return true; // continue + }); + + return $redacted; + } + + private static function baseBinary(array $cmd) + { + return basename($cmd[0]); + } + + private static function redactParametersExec(array $cmd) + { + if (key_exists(self::baseBinary($cmd), self::REDACTED_BINARIES)) { + $ret = array($cmd[0]); + array_fill($ret, 1, count($cmd) - 1, '?'); + return $ret; + } + + $redactNext = false; + foreach ($cmd as &$arg) { + if ($redactNext) { + $arg = '?'; + $redactNext = false; + } + $equalsPos = strpos($arg, '='); + if ($equalsPos === false) { + if (preg_match(self::REDACTED_PARAM_PAT, $arg)) { + $redactNext = true; + } + } else { // we have = + if (preg_match(self::REDACTED_PARAM_PAT, substr($arg, 0, $equalsPos))) { + $offset = 0; + self::replaceWithOffset($arg, $offset, $equalsPos + 1, strlen($arg), '?'); + } + } + } + return $cmd; + } + + private static function intersticeHasCommandSeparator($cmd, $prevEnd, $curWordStart) + { + $interstice = substr($cmd, $prevEnd, $curWordStart - $prevEnd); + // between two tokens we have ; & | && || \n + // this is for a POSIX shell + // bash for instance would require much more logic + return preg_match('/[;|&\n]/', $interstice); + } + + private static function matchEnvVariableAssignment($word, &$matches = null) + { + return preg_match('/\A\s*([a-zA-Z_][a-zA-Z\d]*)=/', $word, $matches); + } + + /** + * Do an inline replacement on a string where the indices $start and $end + * are shifted by $offset (the reflect offsets in the original string). + * + * @param $str string the string to be modified + * @param $offset int how much to shift the indices + * @param $start int the original start index + * @param $end int the original end index + * @param $replacement string the replacement string + * @return void + */ + private static function replaceWithOffset(&$str, &$offset, $start, $end, $replacement) + { + $replacedLen = $end - $start; + $ret = substr($str, 0, $start + $offset); + $ret .= $replacement; + $ret .= substr($str, $end + $offset); + $offset += strlen($replacement) - $replacedLen; + $str = $ret; + } + + /** + * Removes outside quotes on an argument. It does not remove inside quotes (a'b'c) or + * unescapes sequences, though it could be improved so that it does. + * + * @param $str string the string to unquote + * @return string + */ + private static function unquote($str) + { + if (strlen($str) < 2) { + return $str; + } + if ($str[0] == "'" && $str[strlen($str) - 1] === "'") { + return substr($str, 1, strlen($str) - 2); + } + if ($str[0] == '"' && $str[strlen($str) - 1] === '"') { + return substr($str, 1, strlen($str) - 2); + } + return $str; + } + + private static function encodeArray(array $arr) + { + return '[' . implode(',', array_map( + function ($str) { + return '"' . str_replace('"', '\"', $str) . '"'; + }, + $arr + )) . ']'; + } + + private static function finishSpanRestoreStack(SpanData $span) + { + $stackBefore = active_stack(); + switch_stack($span->stack); + close_span(); + switch_stack($stackBefore); + } +} diff --git a/src/api/Tag.php b/src/api/Tag.php index ec2dc01df9..d3fb17b7af 100644 --- a/src/api/Tag.php +++ b/src/api/Tag.php @@ -99,4 +99,10 @@ class Tag const RABBITMQ_DELIVERY_MODE = 'messaging.rabbitmq.delivery_mode'; const RABBITMQ_EXCHANGE = 'messaging.rabbitmq.exchange'; const RABBITMQ_ROUTING_KEY = 'messaging.rabbitmq.routing_key'; + + // Exec + const EXEC_CMDLINE_EXEC = 'cmd.exec'; + const EXEC_CMDLINE_SHELL = 'cmd.shell'; + const EXEC_TRUNCATED = 'cmd.truncated'; + const EXEC_EXIT_CODE = 'cmd.exit_code'; } diff --git a/src/api/Type.php b/src/api/Type.php index 56412b7c92..cc692ada3a 100644 --- a/src/api/Type.php +++ b/src/api/Type.php @@ -18,4 +18,6 @@ class Type const MEMCACHED = 'memcached'; const MONGO = 'mongodb'; const REDIS = 'redis'; + + const SYSTEM = 'system'; } diff --git a/tests/Common/CLITestCase.php b/tests/Common/CLITestCase.php index ee961b01c4..604946890e 100644 --- a/tests/Common/CLITestCase.php +++ b/tests/Common/CLITestCase.php @@ -31,6 +31,7 @@ protected static function getEnvs() // Uncomment to see debug-level messages 'DD_TRACE_DEBUG' => 'true', 'DD_TEST_INTEGRATION' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ]; return $envs; } @@ -74,7 +75,7 @@ public function getAgentRequestFromCommand($arguments = '', $overrideEnvs = []) $inis = (string) new IniSerializer(static::getInis()); $script = escapeshellarg($this->getScriptLocation()); $arguments = escapeshellarg($arguments); - $commandToExecute = "$envs php $inis $script $arguments"; + $commandToExecute = "$envs " . PHP_BINARY . " $inis $script $arguments"; `$commandToExecute`; return $this->retrieveDumpedTraceData()[0] ?? []; } diff --git a/tests/Common/TracerTestTrait.php b/tests/Common/TracerTestTrait.php index 08304c41fb..5e9d81deb4 100644 --- a/tests/Common/TracerTestTrait.php +++ b/tests/Common/TracerTestTrait.php @@ -264,7 +264,7 @@ public function executeCli($scriptPath, $customEnvs = [], $customInis = [], $arg $script = escapeshellarg($scriptPath); $arguments = escapeshellarg($arguments); - $commandToExecute = "$envs php $inis $script $arguments"; + $commandToExecute = "$envs " . PHP_BINARY . " $inis $script $arguments"; if ($withOutput) { return (string) `$commandToExecute 2>&1`; } else { diff --git a/tests/Common/WebFrameworkTestCase.php b/tests/Common/WebFrameworkTestCase.php index fe64038d19..21329faa73 100644 --- a/tests/Common/WebFrameworkTestCase.php +++ b/tests/Common/WebFrameworkTestCase.php @@ -80,6 +80,7 @@ protected static function getEnvs() 'DD_TRACE_AGENT_FLUSH_INTERVAL' => static::FLUSH_INTERVAL_MS, 'DD_AUTOLOAD_NO_COMPILE' => getenv('DD_AUTOLOAD_NO_COMPILE'), 'DD_TRACE_DEBUG' => ini_get("datadog.trace.debug"), + 'DD_TRACE_EXEC_ENABLED' => 'false', ]; return $envs; diff --git a/tests/Integrations/CLI/Symfony/V4_4/CommonScenariosTest.php b/tests/Integrations/CLI/Symfony/V4_4/CommonScenariosTest.php index 67946343a9..3be74bec74 100644 --- a/tests/Integrations/CLI/Symfony/V4_4/CommonScenariosTest.php +++ b/tests/Integrations/CLI/Symfony/V4_4/CommonScenariosTest.php @@ -18,7 +18,8 @@ public function testThrowCommand() list($traces) = $this->inCli(self::getConsoleScript(), [ 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', - 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true' + 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'app:throw'); $this->assertFlameGraph( @@ -91,6 +92,7 @@ public function testCommand() 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( @@ -150,6 +152,7 @@ public function testLongRunningCommandWithoutRootSpan() 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'false', 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( diff --git a/tests/Integrations/CLI/Symfony/V5_2/CommonScenariosTest.php b/tests/Integrations/CLI/Symfony/V5_2/CommonScenariosTest.php index 2246085396..0eaeb36480 100644 --- a/tests/Integrations/CLI/Symfony/V5_2/CommonScenariosTest.php +++ b/tests/Integrations/CLI/Symfony/V5_2/CommonScenariosTest.php @@ -18,7 +18,8 @@ public function testThrowCommand() list($traces) = $this->inCli(self::getConsoleScript(), [ 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', - 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true' + 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'app:throw'); $this->assertFlameGraph( @@ -91,6 +92,7 @@ public function testCommand() 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( @@ -150,6 +152,7 @@ public function testLongRunningCommandWithoutRootSpan() 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'false', 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( diff --git a/tests/Integrations/CLI/Symfony/V6_2/CommonScenariosTest.php b/tests/Integrations/CLI/Symfony/V6_2/CommonScenariosTest.php index 4a5c3f5778..d35ad4cd20 100644 --- a/tests/Integrations/CLI/Symfony/V6_2/CommonScenariosTest.php +++ b/tests/Integrations/CLI/Symfony/V6_2/CommonScenariosTest.php @@ -18,7 +18,8 @@ public function testThrowCommand() list($traces) = $this->inCli(self::getConsoleScript(), [ 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', - 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true' + 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'app:throw'); $this->assertFlameGraph( @@ -91,6 +92,7 @@ public function testCommand() 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( @@ -150,6 +152,7 @@ public function testLongRunningCommandWithoutRootSpan() 'DD_TRACE_CLI_ENABLED' => 'true', 'DD_TRACE_GENERATE_ROOT_SPAN' => 'false', 'DD_TRACE_AUTO_FLUSH_ENABLED' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( diff --git a/tests/Integrations/Custom/Autoloaded/InstrumentationTest.php b/tests/Integrations/Custom/Autoloaded/InstrumentationTest.php index 2099ef898d..8bf5e7c835 100644 --- a/tests/Integrations/Custom/Autoloaded/InstrumentationTest.php +++ b/tests/Integrations/Custom/Autoloaded/InstrumentationTest.php @@ -83,6 +83,11 @@ public function testInstrumentation() "name" => "pdo", "enabled" => true, ], + [ + "name" => "exec", + "enabled" => false, + "version" => "" + ], [ "name" => "logs", "enabled" => false, diff --git a/tests/Integrations/Exec/ExecIntegrationTest.php b/tests/Integrations/Exec/ExecIntegrationTest.php new file mode 100644 index 0000000000..b64118180c --- /dev/null +++ b/tests/Integrations/Exec/ExecIntegrationTest.php @@ -0,0 +1,735 @@ +markTestSkipped('This test is skipped on non-Linux systems.'); + } + } + + /** + * @dataProvider allShellFunctionsProvider + */ + public function testBasicShell($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => true]; + $res = self::doShell($sf, $opts); + $this->assertEquals('foo', $res); + if ($sf != 'shell_exec') { + $this->assertEquals(33, $opts['exit_code']); + } + }); + $expectedTags = [ + 'cmd.shell' => 'echo -n foo; exit 33', + 'component' => 'subprocess', + ]; + if ($sf != 'shell_exec') { + $expectedTags['cmd.exit_code'] = '33'; + } + $this->assertSpans($traces, [ + SpanAssertion::build('command_execution', $traces[0][0]['service'], 'system', 'sh') + ->withExactTags($expectedTags) + ]); + } + + /** + * Test functions that need to have their arguments changed in order to + * receive the exit code. + * @dataProvider alterArgumentFunctionsProvider + * @param $sf + * @return void + * @throws \Exception + */ + public function testShellUnpassedArguments($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => false]; + + $res = self::doShell($sf, $opts); + $this->assertEquals('foo', $res); + }); + $expectedTags = [ + 'cmd.shell' => 'echo -n foo; exit 33', + 'component' => 'subprocess', + 'cmd.exit_code' => '33', + ]; + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + $expectedTags + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + /** + * Test for proc_open and popen, where the handle is unset rather then closed. + * @dataProvider handleShellFunctionsProvider + * @param $sf + * @return void + * @throws \Exception + */ + public function testUnsetShell($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => true, 'unset' => true]; + $res = self::doShell($sf, $opts); + $this->assertEquals('foo', $res); + }); + $expectedTags = [ + 'cmd.shell' => 'echo -n foo; exit 33', + 'component' => 'subprocess', + 'cmd.exit_code' => '33' + ]; + $this->assertSpans($traces, [ + SpanAssertion::build('command_execution', $traces[0][0]['service'], 'system', 'sh') + ->withExactTags($expectedTags) + ]); + } + + /** + * Test where the process is killed with a signal. + * @dataProvider allShellFunctionsProvider + * @param $sf + * @return void + */ + public function testKillShell($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = [ + 'ret_args' => true, + 'cmd_variant' => 'kill' + ]; + $res = self::doShell($sf, $opts); + $this->assertEquals('foo', $res); + if ($sf != 'shell_exec') { + $this->assertEquals(9, $opts['exit_code']); + } + }); + $expectedTags = [ + 'cmd.shell' => 'echo -n foo; kill -9 $$', + 'component' => 'subprocess', + ]; + if ($sf != 'shell_exec') { + $expectedTags['cmd.exit_code'] = '9'; + } + if ($sf == 'proc_open') { + $expectedTags['error.message'] = 'The process was terminated by a signal'; + } + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + $expectedTags, + null, + isset($expectedTags['error.message']) + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + /** + * No exit code if the process opened by proc_open() is not finished + * by the time the handle is closed. + * @return void + */ + public function testProcOpenUnfinished() + { + $traces = $this->isolateTracer(function () { + $opts = [ + 'cmd_variant' => 'kill_after_sleep', + 'unset' => true, + 'no_wait' => true, + ]; + self::doShell('proc_open', $opts); + }); + + $expectedTags = [ + 'cmd.shell' => 'echo -n foo 2>/dev/null; sleep 5; kill -9 $$', + 'component' => 'subprocess', + ]; + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + $expectedTags + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + /** + * After the span for popen() is created neither the active span nor the active stack changes. + * @return void + */ + public function testPopenStateAfterSpanOpen() + { + $activeSpan = \DDTrace\active_span(); + $activeStack = \DDTrace\active_stack(); + $f = popen('true', 'rb'); + + $this->assertSame($activeStack, \DDTrace\active_stack()); + $this->assertSame($activeSpan, \DDTrace\active_span()); + } + + /** + * After the span for proc_open() is created neither the active span nor the active stack changes. + * @return void + */ + public function testProcOpenStateAfterSpanOpen() + { + $activeSpan = \DDTrace\active_span(); + $activeStack = \DDTrace\active_stack(); + $f = proc_open('true', [], $pipes); + + $this->assertSame($activeStack, \DDTrace\active_stack()); + $this->assertSame($activeSpan, \DDTrace\active_span()); + } + + /** + * Calling the rshutdown logic closes the spans for popen(). + * @return void + */ + public function testPopenRshutdownClosing() + { + $pipe = null; + $traces = $this->isolateTracer(function () use (&$pipe) { + $pipe = popen('exit 33', 'rb'); + \DDTrace\Integrations\Exec\test_rshutdown(); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + [ + 'cmd.shell' => 'exit 33', + 'component' => 'subprocess', + 'cmd.exit_code' => '33', + ] + ); + + $this->assertSpans($traces, [$spanAssertion]); + $this->assertFalse(is_resource($pipe)); // already destroyed + } + + /** + * Calling the rshutdown logic closes the spans for proc_open(). + * @return void + */ + public function testProcOpenRshutdownClosing() + { + $h = null; + $traces = $this->isolateTracer(function () use (&$h) { + $h = proc_open('exit 33', [], $pipes); + self::waitProcessExit($h); + \DDTrace\Integrations\Exec\test_rshutdown(); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + [ + 'cmd.shell' => 'exit 33', + 'component' => 'subprocess', + 'cmd.exit_code' => '33', + ] + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + public function testProcGetStatus() + { + $traces = $this->isolateTracer(function () use (&$pipe) { + $h = proc_open('exit 33', [], $pipes); + self::waitProcessExit($h); + $status = proc_get_status($h); + $this->assertEquals(33, $status['exitcode']); + $this->assertEquals(false, $status['running']); + proc_close($h); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + [ + 'cmd.shell' => 'exit 33', + 'component' => 'subprocess', + 'cmd.exit_code' => '33', + ] + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + public function testProcGetStatusSignal() + { + $traces = $this->isolateTracer(function () use (&$pipe, &$h) { + $h = proc_open('kill -9 $$', [], $pipes); + self::waitProcessExit($h); + $status = proc_get_status($h); + $this->assertEquals(9, $status['termsig']); + $this->assertEquals(true, $status['signaled']); + $this->assertEquals(false, $status['running']); + proc_close($h); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + [ + 'cmd.shell' => 'kill -9 $$', + 'component' => 'subprocess', + 'cmd.exit_code' => '9', + 'error.message' => 'The process was terminated by a signal', + ], + null, + true + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + /** + * Test span for execution without a shell. + * @throws \Exception + */ + public function testDirectExecution() + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('This test is skipped on PHP < 7.4.'); + } + + $traces = $this->isolateTracer(function () use (&$pipe) { + $desc = [1 => ['pipe', 'wb']]; + $h = proc_open([__DIR__ . "/exit_33.sh", 'foo$', 'bar'], $desc, $pipes); + if ($h === false) { + throw new \Exception('Could not open process'); + } + $data = stream_get_contents($pipes[1]); + + $this->assertEquals('foo$ bar', $data); + $this->assertEquals(33, proc_close($h)); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'exit_33.sh', + [ + 'cmd.exec' => '["' . __DIR__ . '/exit_33.sh","foo$","bar"]', + 'component' => 'subprocess', + 'cmd.exit_code' => '33', + ] + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + public function testDirectExecutionRedaction() + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('This test is skipped on PHP < 7.4.'); + } + + $traces = $this->isolateTracer(function () use (&$pipe) { + $desc = [1 => ['pipe', 'wb']]; + $cmd = [__DIR__ . "/exit_33.sh", '--password=foo', '-pass', 'bar']; + $h = proc_open($cmd, $desc, $pipes); + if ($h === false) { + throw new \Exception('Could not open process'); + } + stream_get_contents($pipes[1]); + proc_close($h); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'exit_33.sh', + [ + 'cmd.exec' => '["' . __DIR__ . '/exit_33.sh","--password=?","-pass","?"]', + 'component' => 'subprocess', + 'cmd.exit_code' => '33', + ] + ); + + $this->assertSpans($traces, [$spanAssertion]); + } + + /** + * Test that blank command results in no span. + * + * @dataProvider allShellFunctionsProvider + * @param $sf + * @return void + */ + public function testShellBlankCommand($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => true, 'cmd_variant' => 'blank']; + + try { + $res = self::doShell($sf, $opts); + } catch (\PHPUnit\Framework\Error\Warning | \ValueError $w) { + // ignore warning + $res = false; + } + $this->assertEquals('', $res); + }); + + // no span is created + $this->assertEquals([], $traces); + } + + /** + * Test that a command with a NUL byte in it results in an error span. + * @dataProvider functionsDisallowingNulProvider + * @param $sf + * @return void + */ + public function testShellCommandWithNul($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => true, 'cmd_variant' => 'nul']; + + try { + $res = self::doShell($sf, $opts); + } catch (\PHPUnit\Framework\Error\Warning | \ValueError $w) { + // ignore warning + $res = false; + } + $this->assertEquals('', $res); + }); + + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + [ + 'cmd.shell' => "echo -n foo\x00; exit 33", + 'component' => 'subprocess', + ], + null, + true + ); + $spanAssertion->withExistingTagsNames(['error.message', 'error.type', 'error.stack']); + + $this->assertSpans($traces, [$spanAssertion]); + } + + /** + * Test that bad arguments result in an error span. + * + * @dataProvider allShellFunctionsProvider + * @param $sf + * @return void + */ + public function testShellOtherBadArguments($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => true, 'bad_args' => 'true']; + + try { + $res = self::doShell($sf, $opts); + } catch (\PHPUnit\Framework\Error\Warning | \ArgumentCountError $w) { + // ignore warning + $res = false; + } + $this->assertEquals('', $res); + }); + + if ($sf === 'proc_open' || $sf === 'popen') { + // no span is created + $this->assertEquals(0, count($traces)); + } else { + $spanAssertion = SpanAssertion::build( + 'command_execution', + $traces[0][0]['service'], + 'system', + 'sh', + [ + 'cmd.shell' => "echo -n foo; exit 33", + 'component' => 'subprocess', + ], + null, + true + ); + $spanAssertion->withExistingTagsNames(['error.message', 'error.type', 'error.stack']); + + $this->assertSpans($traces, [$spanAssertion]); + } + } + + /** + * Test that the shell command is redacted. + * @dataProvider allShellFunctionsProvider + * @param $sf + * @return void + */ + public function testShellRedaction($sf) + { + $traces = $this->isolateTracer(function () use ($sf) { + $opts = ['ret_args' => true, 'cmd_variant' => 'redaction']; + self::doShell($sf, $opts); + }); + + $this->assertEquals( + "md5(){ return; }; FOO=? BAR=? echo foo -- --password=? -pass ?; md5 ? ?", + $traces[0][0]['meta']['cmd.shell'] + ); + } + + /** + * Test that the shell command is truncated to 4 kB. + * @return void + */ + public function testShellTruncation() + { + $traces = $this->isolateTracer(function () { + $opts = ['ret_args' => true, 'cmd_variant' => 'truncation']; + self::doShell('exec', $opts); + }); + + $this->assertEquals( + 4 * 1024, + strlen($traces[0][0]['meta']['cmd.shell']) + ); + } + + /** + * Test that the exec command is truncated to 4 kB. + * @return void + * @throws \Exception + */ + public function testExecTruncation() + { + if (PHP_VERSION_ID < 70400) { + $this->markTestSkipped('This test is skipped on PHP < 7.4.'); + } + + $traces = $this->isolateTracer(function () use (&$pipe) { + $desc = [1 => ['pipe', 'wb']]; + $h = proc_open(['echo', str_repeat('a', 4096), 'arg'], $desc, $pipes); + if ($h === false) { + throw new \Exception('Could not open process'); + } + stream_get_contents($pipes[1]); + fclose($pipes[1]); + $this->assertEquals(0, proc_close($h)); + }); + + $this->assertEquals( + '["echo","' . str_repeat('a', 4092) . '",""]', + $traces[0][0]['meta']['cmd.exec'] + ); + } + + private static function doShell($function, array &$opts = []) + { + $cmdVariant = $opts['cmd_variant'] ?? 'normal'; + if ($cmdVariant === 'kill') { + $cmd = 'echo -n foo; kill -9 $$'; + } elseif ($cmdVariant === 'kill_after_sleep') { + $cmd = 'echo -n foo 2>/dev/null; sleep 5; kill -9 $$'; + } elseif ($cmdVariant === 'blank') { + $cmd = ''; + } elseif ($cmdVariant === 'nul') { + $cmd = "echo -n foo\x00; exit 33"; + } elseif ($cmdVariant === 'redaction') { + $cmd = 'md5(){ return; }; FOO=? BAR=? echo foo -- --password=? -pass ?; md5 ? ?'; + } else if ($cmdVariant === 'truncation') { + $cmd = 'echo ' . str_repeat('a', 4 * 1024 - 4) . ' bar'; + } else { + $cmd = 'echo -n foo; exit 33'; + } + if (!isset($opts['ret_args'])) { + // ensure there's enough space in the stack + $f = function (...$a) { + return $a; + }; + $f(1, 2, 3, 4, 5, 6, 7); + } + if ($function === 'exec') { + if (isset($opts['bad_args'])) { + return exec($cmd, $g1, $g2, 33); + } + if (isset($opts['ret_args'])) { + return exec($cmd, $garbage, $opts['exit_code']); + } else { + return exec($cmd); + } + } elseif ($function === 'system') { + if (isset($opts['bad_args'])) { + return system($cmd, $garbage, 33); + } + ob_start(); + try { + if (isset($opts['ret_args'])) { + system($cmd, $opts['exit_code']); + } else { + system($cmd); + } + } finally { + return ob_get_clean(); + } + } elseif ($function === 'passthru') { + if (isset($opts['bad_args'])) { + return passthru($cmd, $garbage, 33); + } + ob_start(); + try { + if (isset($opts['ret_args'])) { + passthru($cmd, $opts['exit_code']); + } else { + passthru($cmd); + } + } finally { + return ob_get_clean(); + } + } elseif ($function === 'shell_exec') { + if (isset($opts['bad_args'])) { + return shell_exec($cmd, 33); + } + return shell_exec($cmd); + } elseif ($function === 'popen') { + if (isset($opts['bad_args'])) { + return popen($cmd, 'rb', 33); + } + $pipe = popen($cmd, 'rb'); + if ($pipe === false) { + return false; + } + if (isset($opts['no_wait'])) { + $res = ''; + } else { + $res = stream_get_contents($pipe); + } + + if (isset($opts['unset'])) { + unset($pipe); + } else { + $opts['exit_code'] = pclose($pipe); + } + return $res; + } elseif ($function === 'proc_open') { + if (isset($opts['bad_args'])) { + return proc_open($cmd, [], $pipes, null, null, null, 33); + } + $desc = [1 => ['pipe', 'wb']]; + $h = proc_open($cmd, $desc, $pipes); + if ($h === false) { + return false; + } + + if (isset($opts['no_wait'])) { + $res = ''; + } else { + $res = stream_get_contents($pipes[1]); + } + unset($pipes); + if (isset($opts['unset'])) { + unset($h); + } else { + $opts['exit_code'] = proc_close($h); + } + return $res; + } else { + throw new Exception("Unknown function $function"); + } + } + + public static function allShellFunctionsProvider() + { + return array_map(function ($sf) { + return [$sf]; + }, self::ALL_SHELL_FUNCTIONS); + } + + public static function alterArgumentFunctionsProvider() + { + return [ + ['exec'], + ['system'], + ['passthru'], + ]; + } + + public static function functionsDisallowingNulProvider() + { + return [ + ['exec'], + ['system'], + ['passthru'], + ]; + } + + public static function handleShellFunctionsProvider() + { + return [ + ['popen'], + ['proc_open'], + ]; + } + + /** + * Wait for a process to exit. + * @param $h resource a proc_open handle + * @return void + */ + private static function waitProcessExit($h) + { + $pid = \DDTrace\Integrations\Exec\proc_get_pid($h); + if ($pid === null) { + throw new Exception('Could not get pid'); + } + + $deadline = time() + 2; + while (time() < $deadline) { + $c = file_get_contents("/proc/$pid/stat"); + if ($c === false) { + return; + } + + if (!preg_match('/\A[^)]+\)(?:\s\S+){49}\s(\d+)/', $c, $matches)) { + throw new Exception("Could not parse /proc/$pid/stat"); + } + + if ($matches[1] !== '0') { + return; + } + usleep(10000); + } + } +} diff --git a/tests/Integrations/Exec/exit_33.sh b/tests/Integrations/Exec/exit_33.sh new file mode 100755 index 0000000000..c8ac7603e6 --- /dev/null +++ b/tests/Integrations/Exec/exit_33.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +echo -n "$@" +exit 33 diff --git a/tests/Integrations/Symfony/V5_2/ConsoleCommandTest.php b/tests/Integrations/Symfony/V5_2/ConsoleCommandTest.php index 01e40cecc7..d15c81f038 100644 --- a/tests/Integrations/Symfony/V5_2/ConsoleCommandTest.php +++ b/tests/Integrations/Symfony/V5_2/ConsoleCommandTest.php @@ -17,6 +17,7 @@ public function testScenario() { list($traces) = $this->inCli(self::getConsoleScript(), [ 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( diff --git a/tests/Integrations/Symfony/V6_2/ConsoleCommandTest.php b/tests/Integrations/Symfony/V6_2/ConsoleCommandTest.php index 816625b979..5c812014d1 100644 --- a/tests/Integrations/Symfony/V6_2/ConsoleCommandTest.php +++ b/tests/Integrations/Symfony/V6_2/ConsoleCommandTest.php @@ -17,6 +17,7 @@ public function testScenario() { list($traces) = $this->inCli(self::getConsoleScript(), [ 'DD_TRACE_GENERATE_ROOT_SPAN' => 'true', + 'DD_TRACE_EXEC_ENABLED' => 'false', ], [], 'about'); $this->assertFlameGraph( diff --git a/tests/Sapi/CliServer/CliServer.php b/tests/Sapi/CliServer/CliServer.php index 3191f7b864..56fb540c4a 100644 --- a/tests/Sapi/CliServer/CliServer.php +++ b/tests/Sapi/CliServer/CliServer.php @@ -68,7 +68,7 @@ public function start() * As a result auto_prepend_file (and the request init hook) is not executed. */ $cmd = sprintf( - 'php %s -S %s:%d -t %s', // . ' %s' + PHP_BINARY . ' %s -S %s:%d -t %s', // . ' %s' new IniSerializer($this->inis), $this->host, $this->port, diff --git a/tests/Unit/Integrations/IntegrationsLoaderTest.php b/tests/Unit/Integrations/IntegrationsLoaderTest.php index 3811f7d4e4..8e5da3076d 100644 --- a/tests/Unit/Integrations/IntegrationsLoaderTest.php +++ b/tests/Unit/Integrations/IntegrationsLoaderTest.php @@ -167,6 +167,7 @@ public function testWeDidNotForgetToRegisterALibraryForAutoLoading() $excluded[] = 'drupal'; $excluded[] = 'elasticsearch'; $excluded[] = 'eloquent'; + $excluded[] = 'exec'; $excluded[] = 'guzzle'; $excluded[] = 'laminas'; $excluded[] = 'laravel'; diff --git a/tests/api/Unit/UserAvailableConstantsTest.php b/tests/api/Unit/UserAvailableConstantsTest.php index 566c925c6a..9315c299f4 100644 --- a/tests/api/Unit/UserAvailableConstantsTest.php +++ b/tests/api/Unit/UserAvailableConstantsTest.php @@ -33,6 +33,7 @@ public function types() [Type::MEMCACHED, 'memcached'], [Type::MONGO, 'mongodb'], [Type::REDIS, 'redis'], + [Type::SYSTEM, 'system'], ]; } @@ -167,6 +168,10 @@ public function tags() [Tag::RABBITMQ_DELIVERY_MODE, 'messaging.rabbitmq.delivery_mode'], [Tag::RABBITMQ_EXCHANGE, 'messaging.rabbitmq.exchange'], [Tag::RABBITMQ_ROUTING_KEY, 'messaging.rabbitmq.routing_key'], + [Tag::EXEC_CMDLINE_EXEC, 'cmd.exec'], + [Tag::EXEC_CMDLINE_SHELL, 'cmd.shell'], + [Tag::EXEC_TRUNCATED, 'cmd.truncated'], + [Tag::EXEC_EXIT_CODE, 'cmd.exit_code'], ]; } diff --git a/zend_abstract_interface/symbols/call.c b/zend_abstract_interface/symbols/call.c index 9155a1390c..8a81657407 100644 --- a/zend_abstract_interface/symbols/call.c +++ b/zend_abstract_interface/symbols/call.c @@ -154,6 +154,8 @@ bool zai_symbol_call_impl( if (object && Z_TYPE_P(object) == IS_OBJECT) { fcc.called_scope = Z_OBJCE_P(object); fci.object = fcc.object = Z_OBJ_P(object); + } else { + fcc.called_scope = fcc.function_handler->common.scope; } } break;