Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a181c3a
Implement normalised http routes
estringana Aug 10, 2026
4c73d49
Fix pipeline errors
estringana Aug 12, 2026
c3e88f0
test: add APPSEC_NORMALIZED_ROUTE assertions to remaining test files
estringana Aug 12, 2026
b6bb498
Fix laminas
estringana Aug 13, 2026
22814df
Tiny up the PR
estringana Aug 13, 2026
e91704e
Fix pipeline
estringana Aug 13, 2026
9537abf
Remove non required changes
estringana Aug 13, 2026
151ea17
Fix special route
estringana Aug 17, 2026
067fcd1
Refine slim routes
estringana Aug 17, 2026
c3be49b
Fix codeigniter
estringana Aug 17, 2026
511835a
Fix wordpress
estringana Aug 17, 2026
6652dbc
Fix pipeline
estringana Aug 17, 2026
38de705
Fix pipeline
estringana Aug 17, 2026
c62d4a0
Fix wordpress
estringana Aug 17, 2026
88bf095
Improve pr
estringana Aug 17, 2026
0041bad
Fix PR
estringana Aug 17, 2026
7f4e97b
Fix laminas
estringana Aug 17, 2026
778b876
Implement a route caching system
estringana Aug 18, 2026
b52bb00
Fix pipeline
estringana Aug 18, 2026
a3b0e59
Fix pipeline
estringana Aug 18, 2026
7ce230c
Fix pipeline
estringana Aug 18, 2026
0d3dc46
Add cache to wordpress and Slim
estringana Aug 19, 2026
dec4346
Make hashtable thread safety
estringana Aug 19, 2026
793b0c0
Fix pipeline
estringana Aug 19, 2026
00db486
Fix pipeline
estringana Aug 19, 2026
39e0cc1
Fix pipeline
estringana Aug 19, 2026
923587b
Fix codeigniter
estringana Aug 20, 2026
d049db3
Add appsec integration tests
estringana Aug 26, 2026
6035819
Fix integration tests
estringana Aug 26, 2026
fdb455d
Amend test file name
estringana Aug 27, 2026
ecc336e
Generate normalise routes only when appsec is enabled
estringana Aug 27, 2026
b02a141
Improve route generation
estringana Aug 27, 2026
6275ac8
Improve laminas integration
estringana Aug 27, 2026
3f95e7b
Improve Symfony
estringana Aug 27, 2026
c0818ec
Fix pipeline
estringana Aug 27, 2026
e0107a0
Add more tests
estringana Aug 27, 2026
cc539d3
Add more tests
estringana Aug 27, 2026
3d00863
Fix pipeline
estringana Aug 27, 2026
30f8ef4
Fix pipeline
estringana Aug 28, 2026
cd9d6c4
Address report comments
estringana Aug 28, 2026
066da29
Add new tests
estringana Sep 1, 2026
761fec3
Fix tests
estringana Sep 1, 2026
b6bc862
Fix route normalizer: position-aware optional detection, lowercase en…
estringana Sep 1, 2026
182a201
Fix tests
estringana Sep 1, 2026
2aa0cbf
Fix laminas
estringana Sep 1, 2026
82db9a3
Amend Wordpress
estringana Sep 1, 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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ class Laravel8xTests {
assert span.metrics."_dd.appsec.waf.duration" > 0.0d
assert span.meta."_dd.appsec.event_rules.version" != ''
assert span.meta."appsec.blocked" == "true"
// Laravel uri() returns the route without a leading slash
assert span.meta."http.route" == 'dynamic-path/{param01}'
// Normalizer adds the leading slash and keeps {param01} as-is
assert span.meta."_dd.appsec.normalized_route" == '/dynamic-path/{param01}'
}

@Test
Expand Down Expand Up @@ -208,11 +212,109 @@ class Laravel8xTests {
endpoints.size() > 0
})

assert endpoints.size() == 27
assert endpoints.size() == 30
assert endpoints.find { it.path == '/' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /' } != null
assert endpoints.find { it.path == 'login/auth' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET login/auth' } != null
assert endpoints.find { it.path == 'login/signup' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET login/signup' } != null
assert endpoints.find { it.path == 'dynamic-path/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET dynamic-path/{param01}' } != null
assert endpoints.find { it.path == 'api/user' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET api/user' } != null
assert endpoints.find { it.path == 'normalized-optional/{value?}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET normalized-optional/{value?}' } != null
assert endpoints.find { it.path == 'normalized-default/{format?}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET normalized-default/{format?}' } != null
assert endpoints.find {
it.path == 'normalized-ambiguous/{name}.{ext?}' && it.method == 'GET' &&
it.operationName == 'http.request' &&
it.resourceName == 'GET normalized-ambiguous/{name}.{ext?}'
} != null
}

@Test
@Order(10)
void 'optional param present produces correct normalized route'() {
HttpRequest req = container.buildReq('/normalized-optional/hello').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body() == 'hello'
}

Span span = trace.first()
assert span.meta.'http.route' == 'normalized-optional/{value?}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized-optional/{value}'
}

@Test
@Order(11)
void 'optional param absent produces correct normalized route'() {
HttpRequest req = container.buildReq('/normalized-optional').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body() == 'absent'
}

Span span = trace.first()
assert span.meta.'http.route' == 'normalized-optional/{value?}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized-optional'
}

@Test
@Order(12)
void 'defaulted optional absent from URL produces normalized route without the param'() {
// The route uses ->defaults('format', 'html'). When the URL has no {format?} segment,
// Laravel injects 'html' into $route->parameters() — but the param is absent from the URL.
// The normalized route must not include {format} in this case.
HttpRequest req = container.buildReq('/normalized-default').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body() == 'html'
}

Span span = trace.first()
assert span.meta.'http.route' == 'normalized-default/{format?}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized-default'
}

@Test
@Order(13)
void 'route requirements distinguish an absent defaulted mixed parameter'() {
HttpRequest req = container.buildReq('/normalized-ambiguous/report.txt').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body() == 'report.txt/html'
}

Span span = trace.first()
assert span.meta.'http.route' ==
'normalized-ambiguous/{name}.{ext?}'
// Laravel matched all of "report.txt" as name because ext only accepts
// pdf or json, then supplied the default ext. The integration ignores
// those requirements and infers ext participation from the dot alone.
assert span.meta.'_dd.appsec.normalized_route' ==
'/normalized-ambiguous/{name}'
}

@Test
@Order(14)
void 'normalized route is absent when API Security is disabled'() {
try {
def res = CONTAINER.execInContainer(
'bash', '-c',
'''echo export DD_API_SECURITY_ENABLED=false >> /etc/apache2/envvars;
service apache2 restart''')
assert res.exitCode == 0

HttpRequest req = container.buildReq('/normalized-optional/hello').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'http.route' == 'normalized-optional/{value?}'
assert span.meta.'_dd.appsec.normalized_route' == null
} finally {
def res = CONTAINER.execInContainer(
'bash', '-c',
'''sed -i '/export DD_API_SECURITY_ENABLED=/d' /etc/apache2/envvars;
service apache2 restart''')
assert res.exitCode == 0
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class Symfony62Tests {
assert span.meta."_dd.appsec.event_rules.version" != ''
assert span.meta."appsec.blocked" == "true"
assert span.meta."http.route" == '/dynamic-path/{param01}'
assert span.meta."_dd.appsec.normalized_route" == '/dynamic-path/{param01}'
}

@Test
Expand All @@ -129,6 +130,7 @@ class Symfony62Tests {

Span span = trace.first()
assert span.meta."http.route" == '/caminho-dinamico/{param01}'
assert span.meta."_dd.appsec.normalized_route" == '/caminho-dinamico/{param01}'
}

@Test
Expand All @@ -141,6 +143,8 @@ class Symfony62Tests {

Span span = trace.first()
assert span.meta."http.route" == '/café/{item}'
// Static segment 'café' is percent-encoded per RFC 3986; é (U+00E9) → %C3%A9
assert span.meta."_dd.appsec.normalized_route" == '/caf%C3%A9/{item}'
}

@Test
Expand All @@ -162,6 +166,7 @@ class Symfony62Tests {

Span span = trace.first()
assert span.meta."http.route" == null
assert span.meta."_dd.appsec.normalized_route" == null
assert span.meta."symfony.route.name" != null
assert span.resource == 'app_home_dynamic'
} finally {
Expand All @@ -182,6 +187,8 @@ class Symfony62Tests {
assert re.body().contains('are_endpoints_collected: false')
}
}

@Test
@Order(3)
void 'Endpoints are collected after the first request to framework'() {
HttpRequest req = container.buildReq('/outside_of_framework.php').GET().build()
Expand All @@ -190,6 +197,8 @@ class Symfony62Tests {
assert re.body().contains('are_endpoints_collected: true')
}
}

@Test
@Order(2)
void 'Endpoints are sent'() {
def trace = container.traceFromRequest('/') { HttpResponse<InputStream> resp ->
Expand All @@ -205,12 +214,173 @@ class Symfony62Tests {
endpoints.size() > 0
})

assert endpoints.size() == 6
assert endpoints.size() == 14
assert endpoints.find { it.path == '/' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /' } != null
assert endpoints.find { it.path == '/dynamic-path/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /dynamic-path/{param01}' } != null
assert endpoints.find { it.path == '/login' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /login' } != null
assert endpoints.find { it.path == '/_error/{code}.{_format}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /_error/{code}.{_format}' } != null
assert endpoints.find { it.path == '/register' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /register' } != null
assert endpoints.find { it.path == '/caminho-dinamico/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /caminho-dinamico/{param01}' } != null
assert endpoints.find { it.path == '/article/{slug}.{_format}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /article/{slug}.{_format}' } != null
assert endpoints.find { it.path == '/café/{item}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /café/{item}' } != null
assert endpoints.find { it.path == '/posts/{page}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /posts/{page}' } != null
assert endpoints.find {
it.path == '/normalized/mixed/{id}.{_format}' && it.method == 'GET' &&
it.operationName == 'http.request' &&
it.resourceName == 'GET /normalized/mixed/{id}.{_format}'
} != null
assert endpoints.find {
it.path == '/normalized/zero/{id}' && it.method == 'GET' &&
it.operationName == 'http.request' &&
it.resourceName == 'GET /normalized/zero/{id}'
} != null
assert endpoints.find {
it.path == '/normalized/search.{_format}' && it.method == 'GET' &&
it.operationName == 'http.request' &&
it.resourceName == 'GET /normalized/search.{_format}'
} != null
assert endpoints.find {
it.path == '/normalized/utf8/{föo}' && it.method == 'GET' &&
it.operationName == 'http.request' &&
it.resourceName == 'GET /normalized/utf8/{föo}'
} != null
assert endpoints.find {
it.path == '/normalized/ambiguous/{slug}.{format}' && it.method == 'GET' &&
it.operationName == 'http.request' &&
it.resourceName == 'GET /normalized/ambiguous/{slug}.{format}'
} != null
}

@Test
@Order(11)
void 'normalized route is absent when API Security is disabled'() {
try {
def res = CONTAINER.execInContainer(
'bash', '-c',
'''echo export DD_API_SECURITY_ENABLED=false >> /etc/apache2/envvars;
service apache2 restart''')
assert res.exitCode == 0

Trace trace = container.traceFromRequest('/') { HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'http.route' == '/'
assert span.meta.'_dd.appsec.normalized_route' == null
} finally {
def res = CONTAINER.execInContainer(
'bash', '-c',
'''sed -i '/export DD_API_SECURITY_ENABLED=/d' /etc/apache2/envvars;
service apache2 restart''')
assert res.exitCode == 0
}
}

@Test
@Order(12)
void 'mixed dynamic values in one segment are combined'() {
Trace trace = container.traceFromRequest('/normalized/mixed/article.json') {
HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'http.route' == '/normalized/mixed/{id}.{_format}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized/mixed/{id+_format}'
}

@Test
@Order(13)
void 'zero-valued path parameter is retained'() {
Trace trace = container.traceFromRequest('/normalized/zero/0') {
HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'http.route' == '/normalized/zero/{id}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized/zero/{id}'
}

@Test
@Order(14)
void 'static part of a segment remains when its optional parameter is absent'() {
Trace trace = container.traceFromRequest('/normalized/search') {
HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'http.route' == '/normalized/search.{_format}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized/search'
}

@Test
@Order(15)
void 'UTF-8 optional parameter name is omitted when absent'() {
Trace trace = container.traceFromRequest('/normalized/utf8') {
HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'http.route' == '/normalized/utf8/{föo}'
assert span.meta.'_dd.appsec.normalized_route' == '/normalized/utf8'
}

@Test
@Order(16)
void 'optional param absent: cache key does not bleed into present case'() {
// Hit /posts (page absent from URL — uses default=1) first so that if the cache key
// were just the route name, the result '/posts' would be stored and served for /posts/2.
HttpRequest absentReq = container.buildReq('/posts').GET().build()
Trace absentTrace = container.traceFromRequest(absentReq, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
}
assert absentTrace.first().meta.'http.route' == '/posts/{page}'
assert absentTrace.first().meta.'_dd.appsec.normalized_route' == '/posts'

// Now hit /posts/2 (page present in URL). With a coarse cache key (route name only)
// this would incorrectly return '/posts' from cache instead of '/posts/{page}'.
HttpRequest presentReq = container.buildReq('/posts/2').GET().build()
Trace presentTrace = container.traceFromRequest(presentReq, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
}
assert presentTrace.first().meta.'http.route' == '/posts/{page}'
assert presentTrace.first().meta.'_dd.appsec.normalized_route' == '/posts/{page}'
}

@Test
@Order(17)
void 'mixed segment route normalizes both params into one brace group'() {
HttpRequest req = container.buildReq('/article/my-post.html').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body() == 'my-post.html'
}

Span span = trace.first()
assert span.meta.'http.route' == '/article/{slug}.{_format}'
assert span.meta.'_dd.appsec.normalized_route' == '/article/{slug+_format}'
}

@Test
@Order(18)
void 'route requirements distinguish an absent defaulted mixed parameter'() {
HttpRequest req = container.buildReq('/normalized/ambiguous/foo.bar').GET().build()
Trace trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body() == 'Ambiguous mixed route: foo.bar/html'
}

Span span = trace.first()
assert span.meta.'http.route' ==
'/normalized/ambiguous/{slug}.{format}'
// Symfony matched the entire "foo.bar" value as slug and supplied
// format from its default. URL-only inference ignores the framework
// requirements and incorrectly treats "bar" as a matched format.
assert span.meta.'_dd.appsec.normalized_route' ==
'/normalized/ambiguous/{slug}'
}
}
Loading
Loading