Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
0ea1b6b
Implement normalised http routes
estringana Aug 10, 2026
da06566
Fix pipeline errors
estringana Aug 12, 2026
6e25523
test: add APPSEC_NORMALIZED_ROUTE assertions to remaining test files
estringana Aug 12, 2026
b382a41
Fix laminas
estringana Aug 13, 2026
7f7a4d4
Tiny up the PR
estringana Aug 13, 2026
9a58b11
Fix pipeline
estringana Aug 13, 2026
9e7d5a3
Remove non required changes
estringana Aug 13, 2026
3406d05
Fix special route
estringana Aug 17, 2026
102de43
Refine slim routes
estringana Aug 17, 2026
95d93b7
Fix codeigniter
estringana Aug 17, 2026
c411da2
Fix wordpress
estringana Aug 17, 2026
f7354f5
Fix pipeline
estringana Aug 17, 2026
d077580
Fix pipeline
estringana Aug 17, 2026
3142412
Fix wordpress
estringana Aug 17, 2026
467c3a5
Improve pr
estringana Aug 17, 2026
5b40ef7
Fix PR
estringana Aug 17, 2026
8608adc
Fix laminas
estringana Aug 17, 2026
1002690
Implement a route caching system
estringana Aug 18, 2026
391e8b2
Fix pipeline
estringana Aug 18, 2026
644719b
Fix pipeline
estringana Aug 18, 2026
d04c406
Fix pipeline
estringana Aug 18, 2026
930fcc9
Add cache to wordpress and Slim
estringana Aug 19, 2026
d318dbe
Make hashtable thread safety
estringana Aug 19, 2026
12e4f83
Fix pipeline
estringana Aug 19, 2026
7c6efdb
Fix pipeline
estringana Aug 19, 2026
5b69b1e
Fix pipeline
estringana Aug 19, 2026
5a26801
Fix codeigniter
estringana Aug 20, 2026
31bc609
Add appsec integration tests
estringana Aug 26, 2026
c7d77ce
Fix integration tests
estringana Aug 26, 2026
677a1eb
Amend test file name
estringana Aug 27, 2026
44f8570
Generate normalise routes only when appsec is enabled
estringana Aug 27, 2026
c39623f
Improve route generation
estringana Aug 27, 2026
c063509
Improve laminas integration
estringana Aug 27, 2026
d00e920
Improve Symfony
estringana Aug 27, 2026
9206ee5
Fix pipeline
estringana Aug 27, 2026
bc9d837
Add more tests
estringana Aug 27, 2026
32b5ba1
Add more tests
estringana Aug 27, 2026
cf86e55
Fix pipeline
estringana Aug 27, 2026
67eb2e7
Fix pipeline
estringana Aug 28, 2026
e24939e
Address report comments
estringana Aug 28, 2026
0af2bb5
Add new tests
estringana Sep 1, 2026
728784e
Fix tests
estringana Sep 1, 2026
0d92f3d
Fix route normalizer: position-aware optional detection, lowercase en…
estringana Sep 1, 2026
a20f566
Fix tests
estringana Sep 1, 2026
06af725
Fix laminas
estringana Sep 1, 2026
b671983
Amend Wordpress
estringana Sep 1, 2026
f1917d3
Address comments
estringana Sep 8, 2026
6e42a3f
Fix compatibility error
estringana Sep 9, 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,7 +214,7 @@ class Symfony62Tests {
endpoints.size() > 0
})

assert endpoints.size() == 14
assert endpoints.size() == 17
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 == '/caminho-dinamico/{param01}' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /caminho-dinamico/{param01}' } != null
Expand All @@ -220,5 +229,166 @@ class Symfony62Tests {
assert endpoints.find { it.path == '/lucky/number' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /lucky/number' } != null
assert endpoints.find { it.path == '/lucky/fail' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /lucky/fail' } != 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 == '/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