Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b7d8c9a
Implement normalised http routes
estringana Aug 10, 2026
d2c0af9
Fix pipeline errors
estringana Aug 12, 2026
aa90d45
test: add APPSEC_NORMALIZED_ROUTE assertions to remaining test files
estringana Aug 12, 2026
0e0d7ad
Fix laminas
estringana Aug 13, 2026
50bbe28
Tiny up the PR
estringana Aug 13, 2026
adf0671
Fix pipeline
estringana Aug 13, 2026
d65cbb0
Remove non required changes
estringana Aug 13, 2026
c019f12
Fix special route
estringana Aug 17, 2026
d3cda14
Refine slim routes
estringana Aug 17, 2026
af5e753
Fix codeigniter
estringana Aug 17, 2026
e4a5bed
Fix wordpress
estringana Aug 17, 2026
fc4d005
Fix pipeline
estringana Aug 17, 2026
9986ed4
Fix pipeline
estringana Aug 17, 2026
4e5ef08
Fix wordpress
estringana Aug 17, 2026
62e2a80
Improve pr
estringana Aug 17, 2026
089cfa9
Fix PR
estringana Aug 17, 2026
4409a33
Fix laminas
estringana Aug 17, 2026
b54daeb
Implement a route caching system
estringana Aug 18, 2026
624bef1
Fix pipeline
estringana Aug 18, 2026
7161d5c
Fix pipeline
estringana Aug 18, 2026
b717ae0
Fix pipeline
estringana Aug 18, 2026
852cf42
Add cache to wordpress and Slim
estringana Aug 19, 2026
6fafc5b
Make hashtable thread safety
estringana Aug 19, 2026
4ece131
Fix pipeline
estringana Aug 19, 2026
25a08ea
Fix pipeline
estringana Aug 19, 2026
4908914
Fix pipeline
estringana Aug 19, 2026
78edf05
Fix codeigniter
estringana Aug 20, 2026
efb527c
Add appsec integration tests
estringana Aug 26, 2026
8a4e8cb
Fix integration tests
estringana Aug 26, 2026
6bb9203
Amend test file name
estringana Aug 27, 2026
502d23c
Generate normalise routes only when appsec is enabled
estringana Aug 27, 2026
84c6912
Improve route generation
estringana Aug 27, 2026
ba10db0
Improve laminas integration
estringana Aug 27, 2026
0c0acec
Improve Symfony
estringana Aug 27, 2026
f34373d
Fix pipeline
estringana Aug 27, 2026
34884f0
Add more tests
estringana Aug 27, 2026
4e7276f
Add more tests
estringana Oct 2, 2026
122bf57
Fix pipeline
estringana Aug 27, 2026
63b1d9b
Fix pipeline
estringana Aug 28, 2026
02455f2
Address report comments
estringana Aug 28, 2026
2385d72
Add new tests
estringana Sep 1, 2026
4ece178
Fix tests
estringana Sep 1, 2026
4e3122a
Fix route normalizer: position-aware optional detection, lowercase en…
estringana Sep 1, 2026
ddb918e
Fix tests
estringana Sep 1, 2026
f84cce4
Fix laminas
estringana Sep 1, 2026
37fb0b4
Amend Wordpress
estringana Sep 1, 2026
4785c91
Address comments
estringana Sep 8, 2026
073c5d6
Fix compatibility error
estringana Sep 9, 2026
9ae0ce8
Fix pipeline
estringana Sep 29, 2026
96fe8a9
Adress F09 and F12
estringana Oct 2, 2026
ba8fef4
Fix issues
estringana Oct 2, 2026
087f79f
Fix pipeline
estringana Oct 2, 2026
3c80bc1
Fix wordpress
estringana Oct 2, 2026
6648b60
Amend tests
estringana Oct 2, 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() == 21
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 @@ -219,6 +228,166 @@ class Symfony62Tests {
assert endpoints.find { it.path == '/telemetry' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /telemetry' } != null
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