Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6881f35
feat(appsec): collect security-testing headers on HTTP entry spans (A…
christophe-papazian May 27, 2026
213245c
Address review: move header collection to tags.c, drop CHANGELOG
christophe-papazian May 28, 2026
798aef9
Address review comments on security-testing headers
christophe-papazian May 28, 2026
4c5519d
Remove redundant AppSec tags.c collection
christophe-papazian Jun 1, 2026
77e7c81
Revert ancillary_tags.phpt to master state
christophe-papazian Jun 1, 2026
7f3ca82
Fix stale comment in serializer.c
christophe-papazian Jun 1, 2026
90d1460
Inject security-testing headers into DD_TRACE_HEADER_TAGS at startup
christophe-papazian Jun 3, 2026
1f718e9
Use a custom parser for DD_TRACE_HEADER_TAGS to inject security headers
christophe-papazian Jun 4, 2026
6c4c1c9
Fix unit tests for DD_TRACE_HEADER_TAGS to include security headers
christophe-papazian Jun 4, 2026
3dc12e4
Fix ZVAL_EMPTY_STRING vs ZVAL_EMPTY_PSTRING in dd_parse_header_tags
christophe-papazian Jun 4, 2026
2cebd07
Revert to ZVAL_EMPTY_STRING — ZVAL_EMPTY_PSTRING causes double-free
christophe-papazian Jun 4, 2026
849f9b4
Merge branch 'master' into christophe-papazian/security-testing-headers
christophe-papazian Jun 4, 2026
ff72b23
Fix dd_parse_header_tags: prepend headers to value string for safe in…
christophe-papazian Jun 4, 2026
e1e2bc7
Append security headers instead of prepend in dd_parse_header_tags
christophe-papazian Jun 4, 2026
4d70ac2
Clarify comments in ddtrace_alter_DD_TRACE_HEADER_TAGS
christophe-papazian Jun 4, 2026
b0d1e6d
Merge branch 'master' into christophe-papazian/security-testing-headers
christophe-papazian Jun 5, 2026
ff86515
Avoid string manipulation stuff
bwoebi Jun 5, 2026
d0157af
Simplify dynamic config handling
bwoebi Jun 5, 2026
5d7d4d4
Simplify dynamic config handling
bwoebi Jun 5, 2026
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 ext/configuration.c
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ bool datadog_config_minit(int module_number) {
LOG(ERROR, "Unable to load configuration; likely due to json symbols failing to resolve.");
return false;
}

// We immediately initialize inis at MINIT, so that we can use a select few values already at minit.
// Note that we are not calling zai_config_rinit(), i.e. the get_...() functions will not work.
// This is intentional, so that places wishing to use values pre-RINIT do have to explicitly opt in by using the
Expand Down
71 changes: 71 additions & 0 deletions tests/Integrations/Swoole/SecurityTestingHeadersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace DDTrace\Tests\Integrations\Swoole;

use DDTrace\Tests\Common\WebFrameworkTestCase;
use DDTrace\Tests\Frameworks\Util\Request\GetSpec;

class SecurityTestingHeadersTest extends WebFrameworkTestCase
{
public static function getAppIndexScript()
{
return __DIR__ . '/../../Frameworks/Swoole/index.php';
}

protected static function isSwoole()
{
return true;
}

protected static function getEnvs()
{
return array_merge(parent::getEnvs(), [
'DD_TRACE_CLI_ENABLED' => 'true',
]);
}

protected static function getInis()
{
return array_merge(parent::getInis(), [
'extension' => 'swoole.so',
]);
}

public function testSecurityTestingHeadersCollectedUnconditionally()
{
$traces = $this->tracesFromWebRequest(function () {
$spec = GetSpec::create('request', '/', [
'X-Datadog-Endpoint-Scan: endpoint-scan-uuid',
'X-Datadog-Security-Test: security-test-uuid',
]);
$this->call($spec);
});

$span = $traces[0][0];
$this->assertSame(
'endpoint-scan-uuid',
$span['meta']['http.request.headers.x-datadog-endpoint-scan']
);
$this->assertSame(
'security-test-uuid',
$span['meta']['http.request.headers.x-datadog-security-test']
);
}

public function testSecurityTestingHeadersAbsentWhenNotSent()
{
$traces = $this->tracesFromWebRequest(function () {
$this->call(GetSpec::create('request', '/'));
});

$span = $traces[0][0];
$this->assertArrayNotHasKey(
'http.request.headers.x-datadog-endpoint-scan',
$span['meta']
);
$this->assertArrayNotHasKey(
'http.request.headers.x-datadog-security-test',
$span['meta']
);
}
}
24 changes: 19 additions & 5 deletions tests/Unit/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,25 +280,39 @@ public function testGlobalTagsDelimterPrecedence()
$this->assertEquals(["env" => "test", "bKey" => "", "dKey" => "", "dVal" => "", "cKey" => ""], \dd_trace_env_config("DD_TAGS"));
}

public function testHttpHeadersDefaultsToEmpty()
public function testHttpHeadersDefaultsToSecurityTestingHeaders()
{
$this->assertEmpty(\dd_trace_env_config("DD_TRACE_HEADER_TAGS"));
// x-datadog-endpoint-scan and x-datadog-security-test are always present (APPSEC-62412)
$this->assertSame(
['x-datadog-endpoint-scan' => '', 'x-datadog-security-test' => ''],
\dd_trace_env_config("DD_TRACE_HEADER_TAGS")
);
}

public function testHttpHeadersCanSetOne()
{
$this->putEnvAndReloadConfig([
'DD_TRACE_HEADER_TAGS=A-Header',
]);
$this->assertSame(['a-header'], array_keys(\dd_trace_env_config("DD_TRACE_HEADER_TAGS")));
// Security-testing headers are always present alongside user-configured ones
$this->assertSame(
['a-header', 'x-datadog-endpoint-scan', 'x-datadog-security-test'],
array_keys(\dd_trace_env_config("DD_TRACE_HEADER_TAGS"))
);
}

public function testHttpHeadersCanSetMultiple()
{
$this->putEnvAndReloadConfig([
'DD_TRACE_HEADER_TAGS=A-Header ,Any-Name , cOn7aining-!spe_cial?:ch/ars , valueless:, Some-Header:with-colon-Key',
]);
$this->assertSame(['a-header', 'any-name', 'con7aining-!spe_cial?', 'valueless', 'some-header'], array_keys(\dd_trace_env_config("DD_TRACE_HEADER_TAGS")));
$this->assertEquals(['a-header' => '', 'any-name' => '', 'con7aining-!spe_cial?' => 'ch/ars', 'valueless' => '', 'some-header' => 'with-colon-Key'], \dd_trace_env_config("DD_TRACE_HEADER_TAGS"));
$this->assertSame(
['a-header', 'any-name', 'con7aining-!spe_cial?', 'valueless', 'some-header', 'x-datadog-endpoint-scan', 'x-datadog-security-test'],
array_keys(\dd_trace_env_config("DD_TRACE_HEADER_TAGS"))
);
$this->assertEquals(
['a-header' => '', 'any-name' => '', 'con7aining-!spe_cial?' => 'ch/ars', 'valueless' => '', 'some-header' => 'with-colon-Key', 'x-datadog-endpoint-scan' => '', 'x-datadog-security-test' => ''],
\dd_trace_env_config("DD_TRACE_HEADER_TAGS")
);
}
}
50 changes: 50 additions & 0 deletions tests/ext/inferred_proxy/security_headers_forwarded.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
--TEST--
Security-testing headers are forwarded to the inferred proxy span
--ENV--
DD_TRACE_AUTO_FLUSH_ENABLED=0
DD_TRACE_GENERATE_ROOT_SPAN=0
DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0
DD_TRACE_INFERRED_PROXY_SERVICES_ENABLED=1
HTTP_X_DD_PROXY=aws-apigateway
HTTP_X_DD_PROXY_REQUEST_TIME_MS=100
HTTP_X_DD_PROXY_PATH=/test
HTTP_X_DD_PROXY_HTTPMETHOD=GET
HTTP_X_DD_PROXY_DOMAIN_NAME=example.com
HTTP_X_DD_PROXY_STAGE=aws-prod
HTTP_X_DATADOG_ENDPOINT_SCAN=endpoint-scan-uuid
HTTP_X_DATADOG_SECURITY_TEST=security-test-uuid
METHOD=GET
SERVER_NAME=localhost:8888
SCRIPT_NAME=/foo.php
REQUEST_URI=/foo
DD_TRACE_DEBUG_PRNG_SEED=42
--FILE--
<?php
DDTrace\start_span();
DDTrace\close_span();
$spans = dd_trace_serialize_closed_spans();

// The PHP service-entry span has a parent_id pointing to the inferred span;
// the inferred span itself has no parent_id (it is the trace root).
$rootSpan = null;
$inferredSpan = null;
foreach ($spans as $span) {
if (!isset($span['parent_id'])) {
$inferredSpan = $span;
} else {
$rootSpan = $span;
}
}

// Tags must be present on the PHP service-entry span
var_dump($rootSpan['meta']['http.request.headers.x-datadog-endpoint-scan'] ?? 'NOT SET');
var_dump($rootSpan['meta']['http.request.headers.x-datadog-security-test'] ?? 'NOT SET');
// And forwarded to the inferred proxy span
var_dump($inferredSpan['meta']['http.request.headers.x-datadog-endpoint-scan'] ?? 'NOT SET');
var_dump($inferredSpan['meta']['http.request.headers.x-datadog-security-test'] ?? 'NOT SET');
?>
--EXPECT--
string(18) "endpoint-scan-uuid"
string(18) "security-test-uuid"
string(18) "endpoint-scan-uuid"
string(18) "security-test-uuid"
19 changes: 19 additions & 0 deletions tests/ext/root_span_security_testing_headers.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
--TEST--
Security-testing headers are collected unconditionally on the root span
--ENV--
DD_TRACE_AUTO_FLUSH_ENABLED=0
DD_TRACE_GENERATE_ROOT_SPAN=0
DD_TRACE_HEADER_TAGS=
HTTP_X_DATADOG_ENDPOINT_SCAN=endpoint-scan-uuid
HTTP_X_DATADOG_SECURITY_TEST=security-test-uuid
--FILE--
<?php
DDTrace\start_span();
DDTrace\close_span(0);
$spans = dd_trace_serialize_closed_spans();
var_dump($spans[0]['meta']['http.request.headers.x-datadog-endpoint-scan']);
var_dump($spans[0]['meta']['http.request.headers.x-datadog-security-test']);
?>
--EXPECT--
string(18) "endpoint-scan-uuid"
string(18) "security-test-uuid"
16 changes: 16 additions & 0 deletions tests/ext/root_span_security_testing_headers_absent.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
--TEST--
Security-testing header tags are absent when headers are not sent
--ENV--
DD_TRACE_AUTO_FLUSH_ENABLED=0
DD_TRACE_GENERATE_ROOT_SPAN=0
--FILE--
<?php
DDTrace\start_span();
DDTrace\close_span(0);
$spans = dd_trace_serialize_closed_spans();
var_dump(array_key_exists('http.request.headers.x-datadog-endpoint-scan', $spans[0]['meta']));
var_dump(array_key_exists('http.request.headers.x-datadog-security-test', $spans[0]['meta']));
?>
--EXPECT--
bool(false)
bool(false)
2 changes: 1 addition & 1 deletion tracer/configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
CONFIG(CUSTOM(INT), DD_TRACE_SAMPLING_RULES_FORMAT, "glob", .parser = dd_parse_sampling_rules_format) \
CONFIG(JSON, DD_SPAN_SAMPLING_RULES, "[]") \
CONFIG(STRING, DD_SPAN_SAMPLING_RULES_FILE, "", .ini_change = ddtrace_alter_sampling_rules_file_config) \
CONFIG(SET_OR_MAP_LOWERCASE, DD_TRACE_HEADER_TAGS, "", .ini_change = ddtrace_alter_DD_TRACE_HEADER_TAGS) \
CONFIG(CUSTOM(MAP), DD_TRACE_HEADER_TAGS, "", .ini_change = ddtrace_alter_DD_TRACE_HEADER_TAGS, .parser = dd_parse_header_tags) \
CONFIG(INT, DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH, "512") \
CONFIG(MAP, DD_TRACE_PEER_SERVICE_MAPPING, "") \
CONFIG(BOOL, DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED, "false") \
Expand Down
20 changes: 20 additions & 0 deletions tracer/configuration_dependencies.h
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@ static bool dd_parse_tags(zai_str value, zval *decoded_value, bool persistent) {
return true;
}

// Custom parser to ensure security-testing headers are always captured.
static bool dd_parse_header_tags(zai_str value, zval *decoded_value, bool persistent) {
if (!zai_config_decode_value(value, ZAI_CONFIG_TYPE_SET_OR_MAP_LOWERCASE, NULL, decoded_value, persistent)) {
return false;
}

HashTable *ht = Z_ARRVAL_P(decoded_value);
zval empty;
if (persistent) {
ZVAL_EMPTY_PSTRING(&empty);
} else {
ZVAL_EMPTY_STRING(&empty);
}
Z_TRY_ADDREF(empty);
zend_hash_str_update(ht, ZEND_STRL("x-datadog-endpoint-scan"), &empty);
zend_hash_str_update(ht, ZEND_STRL("x-datadog-security-test"), &empty);

return true;
}

#define INI_CHANGE_DYNAMIC_CONFIG(name, config) \
static bool ddtrace_alter_##name(zval *old_value, zval *new_value, zend_string *new_str) { \
UNUSED(old_value, new_value); \
Expand Down
5 changes: 5 additions & 0 deletions tracer/serializer.c
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@

ZEND_EXTERN_MODULE_GLOBALS(datadog);

#define DD_TAG_HTTP_REQH_ENDPOINT_SCAN "http.request.headers.x-datadog-endpoint-scan"
#define DD_TAG_HTTP_REQH_SECURITY_TEST "http.request.headers.x-datadog-security-test"

extern void (*profiling_notify_trace_finished)(uint64_t local_root_span_id,
zai_str span_type,
zai_str resource);
Expand Down Expand Up @@ -1834,6 +1837,8 @@ ddog_SpanBytes *ddtrace_serialize_span_to_rust_span(ddtrace_span_data *span, ddo
transfer_meta_data(rust_span, serialized_inferred_span, "_dd.p.dm", true);
transfer_meta_data(rust_span, serialized_inferred_span, "_dd.p.ksr", false);
transfer_meta_data(rust_span, serialized_inferred_span, "_dd.p.tid", true);
transfer_meta_data(rust_span, serialized_inferred_span, DD_TAG_HTTP_REQH_ENDPOINT_SCAN, false);
transfer_meta_data(rust_span, serialized_inferred_span, DD_TAG_HTTP_REQH_SECURITY_TEST, false);
Comment thread
christophe-papazian marked this conversation as resolved.

ddog_set_span_error(serialized_inferred_span, ddog_get_span_error(rust_span));
}
Expand Down
Loading