Skip to content

Commit cd3dbad

Browse files
committed
implement api security metrics
1 parent ebc3f03 commit cd3dbad

11 files changed

Lines changed: 462 additions & 10 deletions

File tree

appsec/src/extension/commands_helpers.c

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,11 @@ void dd_command_process_meta(mpack_node_t root, zend_object *nonnull span)
719719
key_str, key_len, val_str, val_len);
720720
}
721721

722-
if (has_schemas && !get_DD_APM_TRACING_ENABLED()) {
723-
dd_trace_emit_asm_event();
722+
if (has_schemas) {
723+
dd_telemetry_note_schema_extracted();
724+
if (!get_DD_APM_TRACING_ENABLED()) {
725+
dd_trace_emit_asm_event();
726+
}
724727
}
725728
}
726729

appsec/src/extension/request_lifecycle.c

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ static void _set_cur_span(zend_object *nullable span);
3939
static void _reset_globals(void);
4040
const zend_array *nonnull _get_server_equiv(
4141
const zend_array *nonnull superglob_equiv);
42-
static uint64_t _calc_sampling_key(zend_object *root_span, int status_code);
42+
static uint64_t _calc_sampling_key(zend_object *root_span, int status_code,
43+
dd_api_sec_outcome *nonnull outcome);
44+
static bool _shutdown_succeeded(dd_result res);
4345
static void _register_testing_objects(void);
4446

4547
static bool _enabled_user_req;
@@ -388,14 +390,16 @@ static void _do_request_finish_php(bool ignore_verdict)
388390

389391
if (conn && DDAPPSEC_G(active)) {
390392
const int status_code = SG(sapi_headers).http_response_code;
393+
dd_api_sec_outcome api_sec_outcome;
391394
ctx = (struct req_shutdown_info){
392395
.req_info.root_span = dd_req_lifecycle_get_cur_span(),
393396
.req_info.client_ip = dd_req_lifecycle_get_client_ip(),
394397
.status_code = status_code,
395398
.resp_headers_fmt = RESP_HEADERS_LLIST,
396399
.resp_headers_llist = &SG(sapi_headers).headers,
397400
.entity = dd_response_body_buffered(),
398-
.api_sec_samp_key = _calc_sampling_key(_cur_req_span, status_code),
401+
.api_sec_samp_key = _calc_sampling_key(
402+
_cur_req_span, status_code, &api_sec_outcome),
399403
};
400404

401405
struct timespec shutdown_start = dd_monotime_start();
@@ -412,6 +416,9 @@ static void _do_request_finish_php(bool ignore_verdict)
412416
mlog_g(dd_log_info, "request shutdown failed: %s",
413417
dd_result_to_string(res));
414418
}
419+
420+
dd_telemetry_add_api_security_request(
421+
_cur_req_span, api_sec_outcome);
415422
}
416423

417424
dd_helper_rshutdown();
@@ -438,14 +445,16 @@ static zend_array *_do_request_finish_user_req(bool ignore_verdict,
438445
struct req_shutdown_info ctx = {0};
439446

440447
if (conn && DDAPPSEC_G(active)) {
448+
dd_api_sec_outcome api_sec_outcome;
441449
ctx = (struct req_shutdown_info){
442450
.req_info.root_span = dd_req_lifecycle_get_cur_span(),
443451
.req_info.client_ip = dd_req_lifecycle_get_client_ip(),
444452
.status_code = status_code,
445453
.resp_headers_fmt = RESP_HEADERS_MAP_STRING_LIST,
446454
.resp_headers_arr = resp_headers ? resp_headers : &zend_empty_array,
447455
.entity = entity,
448-
.api_sec_samp_key = _calc_sampling_key(_cur_req_span, status_code),
456+
.api_sec_samp_key = _calc_sampling_key(
457+
_cur_req_span, status_code, &api_sec_outcome),
449458
};
450459

451460
struct timespec shutdown_start = dd_monotime_start();
@@ -462,6 +471,9 @@ static zend_array *_do_request_finish_user_req(bool ignore_verdict,
462471
mlog_g(dd_log_info, "request shutdown failed: %s",
463472
dd_result_to_string(res));
464473
}
474+
475+
dd_telemetry_add_api_security_request(
476+
_cur_req_span, api_sec_outcome);
465477
}
466478

467479
dd_helper_rshutdown();
@@ -1003,8 +1015,11 @@ static inline uint64_t _hash_zend_string(
10031015
return _hash_string(hash, ZSTR_VAL(str), ZSTR_LEN(str));
10041016
}
10051017

1006-
static uint64_t _calc_sampling_key(zend_object *root_span, int status_code)
1018+
static uint64_t _calc_sampling_key(zend_object *root_span, int status_code,
1019+
dd_api_sec_outcome *nonnull outcome)
10071020
{
1021+
*outcome = DD_API_SEC_SKIP;
1022+
10081023
if (!get_DD_API_SECURITY_ENABLED()) {
10091024
return 0;
10101025
}
@@ -1079,14 +1094,16 @@ static uint64_t _calc_sampling_key(zend_object *root_span, int status_code)
10791094
}
10801095

10811096
if (!route_or_endpoint) {
1082-
goto error;
1097+
goto missing_route;
10831098
}
10841099

10851100
zval *method =
10861101
zend_hash_str_find(Z_ARRVAL_P(meta), ZEND_STRL("http.method"));
10871102
if (!method || Z_TYPE_P(method) != IS_STRING) {
10881103
mlog_g(dd_log_debug, "No http.method tag; not sampling");
1089-
goto error;
1104+
// we treat the absence of http.method also as a missing route, because
1105+
// it also prevents schema extraction and it's sort of part of the route
1106+
goto missing_route;
10901107
}
10911108

10921109
// use fnv-1a hash with: <route_or_endpoint> NULL <http.method tag> NULL
@@ -1113,9 +1130,17 @@ static uint64_t _calc_sampling_key(zend_object *root_span, int status_code)
11131130
if (free_route_or_endpoint) {
11141131
zend_string_release(route_or_endpoint);
11151132
}
1133+
*outcome = DD_API_SEC_EVALUATED;
11161134
return hash;
11171135

1118-
error:
1136+
missing_route:
1137+
// Neither the route nor a stand-in for it could be determined. 404s are
1138+
// excluded: an endpoint that does not exist has no route to speak of, so
1139+
// counting it would be misleading
1140+
if (status_code != HTTP_NOT_FOUND) {
1141+
*outcome = DD_API_SEC_MISSING_ROUTE;
1142+
}
1143+
11191144
if (free_route_or_endpoint) {
11201145
zend_string_release(route_or_endpoint);
11211146
}

appsec/src/extension/telemetry.c

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ static zend_string *_dd_helper_conn_close_zstr;
2121
static zend_string *_waf_duration_ext_tel_zstr;
2222
static zend_string *_rasp_duration_ext_tel_zstr;
2323
static zend_string *_rasp_rule_skipped_zstr;
24+
static zend_string *_api_sec_request_schema_zstr;
25+
static zend_string *_api_sec_request_no_schema_zstr;
26+
static zend_string *_api_sec_missing_route_zstr;
27+
28+
static zend_string *_component_literal_zstr;
2429

2530
static THREAD_LOCAL_ON_ZTS zend_string *nullable _cached_waf_version;
2631
static THREAD_LOCAL_ON_ZTS zend_string *nullable _cached_event_rules_version;
32+
static THREAD_LOCAL_ON_ZTS bool _schema_extracted;
2733

2834
static zend_string *nullable _duration_ext_tags_from_cache(void);
35+
static zend_string *nonnull _framework_tag(zend_object *nullable root_span);
2936
static void _release_zstr(zend_string *nullable *nonnull slot);
3037
static void _cache_replace(zend_string *nullable *nonnull slot,
3138
const char *nonnull val, size_t val_len);
@@ -49,6 +56,14 @@ void dd_telemetry_startup(void)
4956
zend_string_init_interned(LSTRARG("rasp.duration_ext"), 1);
5057
_rasp_rule_skipped_zstr =
5158
zend_string_init_interned(LSTRARG("rasp.rule.skipped"), 1);
59+
_api_sec_request_schema_zstr =
60+
zend_string_init_interned(LSTRARG("api_security.request.schema"), 1);
61+
_api_sec_request_no_schema_zstr =
62+
zend_string_init_interned(LSTRARG("api_security.request.no_schema"), 1);
63+
_api_sec_missing_route_zstr =
64+
zend_string_init_interned(LSTRARG("api_security.missing_route"), 1);
65+
_component_literal_zstr =
66+
zend_string_init_interned(LSTRARG("component"), 1);
5267
}
5368

5469
void dd_telemetry_mshutdown(void)
@@ -61,6 +76,7 @@ void dd_telemetry_rinit(void)
6176
{
6277
_release_zstr(&_cached_event_rules_version);
6378
_release_zstr(&_cached_waf_version);
79+
_schema_extracted = false;
6480
}
6581

6682
void dd_telemetry_note_helper_string_meta(const char *nonnull key,
@@ -125,6 +141,76 @@ void dd_telemetry_add_rasp_rule_skipped(
125141
efree(tags);
126142
}
127143

144+
void dd_telemetry_note_schema_extracted(void) { _schema_extracted = true; }
145+
146+
void dd_telemetry_add_api_security_request(
147+
zend_object *nullable root_span, dd_api_sec_outcome outcome)
148+
{
149+
const bool schema_extracted = _schema_extracted;
150+
_schema_extracted = false;
151+
152+
if (outcome == DD_API_SEC_SKIP) {
153+
return;
154+
}
155+
156+
if (!dd_trace_loaded() || datadog_metric_register_buffer == NULL ||
157+
datadog_metric_add_point == NULL) {
158+
return;
159+
}
160+
161+
zend_string *name_zstr;
162+
if (outcome == DD_API_SEC_MISSING_ROUTE) {
163+
name_zstr = _api_sec_missing_route_zstr;
164+
} else if (schema_extracted) {
165+
name_zstr = _api_sec_request_schema_zstr;
166+
} else {
167+
name_zstr = _api_sec_request_no_schema_zstr;
168+
}
169+
170+
zend_string *tags_zstr = _framework_tag(root_span);
171+
dd_telemetry_add_metric(name_zstr, 1, tags_zstr, DDTRACE_METRIC_TYPE_COUNT);
172+
zend_string_release(tags_zstr);
173+
}
174+
175+
#define DD_UNKNOWN_FRAMEWORK "unknown"
176+
// Builds the framework tag out of the root span's component tag, which is what
177+
// the framework integrations set (e.g. laravel, symfony, wordpress). Per
178+
// RFC-1012, the name is normalized by lowercasing it and replacing spaces with
179+
// underscores.
180+
static zend_string *nonnull _framework_tag(zend_object *nullable root_span)
181+
{
182+
const char *framework = DD_UNKNOWN_FRAMEWORK;
183+
size_t framework_len = LSTRLEN(DD_UNKNOWN_FRAMEWORK);
184+
185+
zval *nullable meta = root_span ? dd_trace_span_get_meta(root_span) : NULL;
186+
if (meta != NULL && Z_TYPE_P(meta) == IS_ARRAY) {
187+
zval *nullable component =
188+
zend_hash_find_ex(Z_ARRVAL_P(meta), _component_literal_zstr, true);
189+
if (component != NULL && Z_TYPE_P(component) == IS_STRING &&
190+
Z_STRLEN_P(component) > 0) {
191+
framework = Z_STRVAL_P(component);
192+
framework_len = Z_STRLEN_P(component);
193+
}
194+
}
195+
196+
zend_string *tags_zstr =
197+
zend_string_alloc(LSTRLEN("framework:") + framework_len, 0);
198+
memcpy(ZSTR_VAL(tags_zstr), LSTRARG("framework:"));
199+
char *dest = ZSTR_VAL(tags_zstr) + LSTRLEN("framework:");
200+
for (size_t i = 0; i < framework_len; i++) {
201+
char c = framework[i];
202+
if (c == ' ') {
203+
c = '_';
204+
} else if (c >= 'A' && c <= 'Z') {
205+
c = (char)(c - 'A' + 'a');
206+
}
207+
dest[i] = c;
208+
}
209+
ZSTR_VAL(tags_zstr)[ZSTR_LEN(tags_zstr)] = '\0';
210+
211+
return tags_zstr;
212+
}
213+
128214
static void _add_user_auth_metric(zend_string *nonnull name_zstr,
129215
const char *nonnull event_type, size_t event_type_len,
130216
const char *nonnull framework, size_t framework_len)

appsec/src/extension/telemetry.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ void dd_telemetry_submit_duration_ext(double waf_ext_us, double rasp_ext_us);
2929
void dd_telemetry_add_rasp_rule_skipped(
3030
zend_string *nonnull rule_type, zend_string *nullable rule_variant);
3131

32+
// The outcome of the API security decision taken for a request, as far as
33+
// RFC-1012's api_security metrics are concerned.
34+
typedef enum {
35+
// the request is not a candidate for schema extraction for a reason that
36+
// is not worth reporting (API security disabled, request blocked, trace
37+
// dropped, ...). No metric is emitted.
38+
DD_API_SEC_SKIP = 0,
39+
// the request would have been a candidate, but no HTTP route (or a
40+
// stand-in for it) could be determined: appsec.api_security.missing_route
41+
DD_API_SEC_MISSING_ROUTE,
42+
// the request was submitted for schema extraction: either
43+
// appsec.api_security.request.schema or .no_schema, depending on whether
44+
// the helper came back with a schema
45+
DD_API_SEC_EVALUATED,
46+
} dd_api_sec_outcome;
47+
48+
// Called when the helper reports schemas (_dd.appsec.s.*) for the current
49+
// request
50+
void dd_telemetry_note_schema_extracted(void);
51+
void dd_telemetry_add_api_security_request(
52+
zend_object *nullable root_span, dd_api_sec_outcome outcome);
53+
3254
void dd_telemetry_helper_conn_error(void);
3355
void dd_telemetry_helper_conn_success(void);
3456
void dd_telemetry_helper_conn_close(void);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
--TEST--
2+
appsec.api_security.missing_route is reported when no route can be determined
3+
--INI--
4+
extension=ddtrace.so
5+
datadog.appsec.log_file=/tmp/php_appsec_test_apisec_missing_route.log
6+
datadog.appsec.log_level=debug
7+
datadog.appsec.enabled=1
8+
--GET--
9+
key=val
10+
--FILE--
11+
<?php
12+
use function datadog\appsec\testing\{rinit,rshutdown};
13+
use function DDTrace\root_span;
14+
15+
include __DIR__ . '/inc/mock_helper.php';
16+
require __DIR__ . '/inc/logging.php';
17+
18+
truncate_log();
19+
20+
$helper = Helper::createInitedRun([
21+
response_list(response_request_init([[['ok', []]]])),
22+
response_list(response_request_shutdown([
23+
[['ok', []]],
24+
[],
25+
false,
26+
[],
27+
[],
28+
[],
29+
]))
30+
]);
31+
32+
http_response_code(410);
33+
34+
var_dump(rinit());
35+
$helper->get_commands(); // ignore
36+
37+
// no http.route, no http.endpoint and no http.url: no route can be determined
38+
$rootSpan = root_span();
39+
$rootSpan->meta["http.method"] = "GET";
40+
41+
var_dump(rshutdown());
42+
$c = $helper->get_commands();
43+
echo "Sampler hash sent: ", $c[0][1][1], "\n";
44+
45+
match_log('/Telemetry metric api_security\.missing_route added with tags framework:unknown and value 1/');
46+
no_match_log('/api_security\.request\./');
47+
48+
?>
49+
--EXPECT--
50+
bool(true)
51+
bool(true)
52+
Sampler hash sent: 0
53+
found message in log matching /Telemetry metric api_security\.missing_route added with tags framework:unknown and value 1/
54+
no message in log matching /api_security\.request\./
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
--TEST--
2+
No api_security metric is reported for a routeless 404
3+
--INI--
4+
extension=ddtrace.so
5+
datadog.appsec.log_file=/tmp/php_appsec_test_apisec_404.log
6+
datadog.appsec.log_level=debug
7+
datadog.appsec.enabled=1
8+
--GET--
9+
key=val
10+
--FILE--
11+
<?php
12+
use function datadog\appsec\testing\{rinit,rshutdown};
13+
use function DDTrace\root_span;
14+
15+
include __DIR__ . '/inc/mock_helper.php';
16+
require __DIR__ . '/inc/logging.php';
17+
18+
truncate_log();
19+
20+
$helper = Helper::createInitedRun([
21+
response_list(response_request_init([[['ok', []]]])),
22+
response_list(response_request_shutdown([
23+
[['ok', []]],
24+
[],
25+
false,
26+
[],
27+
[],
28+
[],
29+
]))
30+
]);
31+
32+
http_response_code(404);
33+
34+
var_dump(rinit());
35+
$helper->get_commands(); // ignore
36+
37+
// an endpoint that does not exist has no route to speak of; it must not be
38+
// counted as a missing route
39+
$rootSpan = root_span();
40+
$rootSpan->meta["http.method"] = "GET";
41+
42+
var_dump(rshutdown());
43+
$helper->get_commands(); // ignore
44+
45+
no_match_log('/api_security\./');
46+
47+
?>
48+
--EXPECT--
49+
bool(true)
50+
bool(true)
51+
no message in log matching /api_security\./

0 commit comments

Comments
 (0)