Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bb07192
Add ato appsec events to Laminas
estringana Mar 18, 2026
96dc760
Add path_params to laminas
estringana Mar 18, 2026
da58311
Fix laminas tests
estringana Mar 23, 2026
cc8c1f8
Add http.route
estringana Mar 23, 2026
6bc7031
Add appsec integration test
estringana Mar 23, 2026
9f6b905
Update rest laminas snapshots
estringana Mar 24, 2026
a00defc
Add endpoint collection to Laminas
estringana Mar 24, 2026
651e235
Extract http.route instead of generating it
estringana Mar 26, 2026
9b662b3
Remove http.route from laminas
estringana Apr 13, 2026
a609187
Fix routes generation
estringana Apr 13, 2026
e459f8f
Refactor Laminas integration
estringana Apr 14, 2026
a35cd52
Fix integration tests
estringana Apr 14, 2026
a8835ae
Improving login tracking events
estringana Apr 15, 2026
ce653bd
Add http.route to laminas
estringana Apr 15, 2026
efba4e7
Remove http.route from rest endpoints
estringana Apr 15, 2026
140b8c4
Fix laminas rest
estringana Apr 15, 2026
e0b3991
Amend dynamic routes on Laminas
estringana Apr 30, 2026
2ac3a5d
Filter out system path params
estringana May 4, 2026
59d28f1
Expand php supported versions
estringana May 4, 2026
0508196
Fix extracting http verb
estringana May 4, 2026
b6e2fdc
Avoid calling twice to waf on auth requests
estringana May 4, 2026
3b4aca1
Revert check for multiple auth calls
estringana May 5, 2026
d9144c3
Amend issue with getting routes
estringana May 5, 2026
ed63020
Improve collecting routes
estringana May 5, 2026
34e06b6
Avoid pushing partial addresses
estringana May 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package com.datadog.appsec.php.integration

import com.datadog.appsec.php.docker.AppSecContainer
import com.datadog.appsec.php.docker.FailOnUnmatchedTraces
import com.datadog.appsec.php.docker.InspectContainerHelper
import com.datadog.appsec.php.TelemetryHelpers
import com.datadog.appsec.php.model.Span
import com.datadog.appsec.php.model.Trace
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestMethodOrder
import org.junit.jupiter.api.condition.EnabledIf
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers

import java.io.InputStream
import java.net.http.HttpHeaders
import java.net.http.HttpRequest
import java.net.http.HttpResponse

import static com.datadog.appsec.php.integration.TestParams.getPhpVersion
import static com.datadog.appsec.php.integration.TestParams.getVariant
import static java.net.http.HttpResponse.BodyHandlers.ofInputStream
import static java.net.http.HttpResponse.BodyHandlers.ofString

@Testcontainers
@EnabledIf('isExpectedVersion')
@TestMethodOrder(MethodOrderer.OrderAnnotation)
class Laminas33Tests {
Comment thread
estringana marked this conversation as resolved.

/**
* Laminas MVC 3.3.x supports PHP 7.3–8.1 per composer constraints in www/laminas33.
*/
static boolean expectedVersion =
['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'].contains(getPhpVersion()) && !getVariant().contains('zts')

AppSecContainer getContainer() {
getClass().CONTAINER
}

@Container
@FailOnUnmatchedTraces
public static final AppSecContainer CONTAINER =
new AppSecContainer(
workVolume: this.name,
baseTag: 'apache2-mod-php',
phpVersion: getPhpVersion(),
phpVariant: getVariant(),
www: 'laminas33',
)

static void main(String[] args) {
InspectContainerHelper.run(CONTAINER)
}

@Test
@Order(1)
void 'Endpoints are not collected before the first request to framework'() {
HttpRequest req = container.buildReq('/outside_of_framework.php').GET().build()
container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body().contains('are_endpoints_collected: false')
}
}

@Test
@Order(2)
void 'Endpoints are sent'() {
Trace trace = container.traceFromRequest('/') { HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

assert trace.traceId != null
assert trace.first().meta.'http.route' == '/'

List<TelemetryHelpers.Endpoint> endpoints

TelemetryHelpers.waitForAppEndpoints(container, 30, { List<TelemetryHelpers.Endpoint> messages ->
endpoints = messages.collectMany { it.endpoints }
endpoints.size() > 0
})

assert endpoints.size() == 13
assert endpoints.find { it.path == '/' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /' } != null
assert endpoints.find { it.path == '/authenticate' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /authenticate' } != null
assert endpoints.find { it.path == '/behind-auth' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /behind-auth' } != 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 == '/resource' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /resource' } != null
assert endpoints.find { it.path == '/resource/:resourceId' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /resource/:resourceId' } != null
assert endpoints.find { it.path == '/resource/:resourceId/:subId' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /resource/:resourceId/:subId' } != null
assert endpoints.find { it.path == '/chain/:chainId' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /chain/:chainId' } != null
assert endpoints.find { it.path == '/verb-test' && it.method == 'GET' && it.operationName == 'http.request' && it.resourceName == 'GET /verb-test' } != null
assert endpoints.find { it.path == '/verb-test' && it.method == 'POST' && it.operationName == 'http.request' && it.resourceName == 'POST /verb-test' } != null
assert endpoints.find { it.path == '/verb-test' && it.method == 'PUT' && it.operationName == 'http.request' && it.resourceName == 'PUT /verb-test' } != null
assert endpoints.find { it.path == '/verb-test' && it.method == 'PATCH' && it.operationName == 'http.request' && it.resourceName == 'PATCH /verb-test' } != null
assert endpoints.find { it.path == '/verb-test' && it.method == 'DELETE' && it.operationName == 'http.request' && it.resourceName == 'DELETE /verb-test' } != null
}

@Test
@Order(3)
void 'Endpoints are collected after the first request to framework'() {
HttpRequest req = container.buildReq('/outside_of_framework.php').GET().build()
container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 200
assert re.body().contains('are_endpoints_collected: true')
}
}

@Test
@Order(4)
void 'Login failure automated event'() {
Trace trace = container.traceFromRequest('/authenticate?email=nonExisiting@email.com') {
HttpResponse<InputStream> resp ->
assert resp.statusCode() == 403
}

Span span = trace.first()
assert span.meta.'appsec.events.users.login.failure.track' == 'true'
assert span.meta.'_dd.appsec.events.users.login.failure.auto.mode' == 'identification'
assert span.meta.'appsec.events.users.login.failure.usr.exists' == 'false'
assert span.metrics._sampling_priority_v1 == 2.0d
assert span.meta.'http.route' == '/authenticate'
}

@Test
@Order(5)
void 'Login success automated event'() {
def trace = container.traceFromRequest('/authenticate?email=ciuser@example.com') {
HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'usr.id' == '1'
assert span.meta.'_dd.appsec.events.users.login.success.auto.mode' == 'identification'
assert span.meta.'appsec.events.users.login.success.track' == 'true'
assert span.metrics._sampling_priority_v1 == 2.0d
assert span.meta.'http.route' == '/authenticate'
}

@Test
@Order(6)
void 'Authenticated user automated event after session login'() {
HttpRequest loginReq = container.buildReq('/authenticate?email=ciuser@example.com').GET().build()
HttpResponse<InputStream> loginResp = container.httpClient.send(loginReq, ofInputStream())
assert loginResp.statusCode() == 200
loginResp.body().close()

String cookieHeader = loginResp.headers().allValues('Set-Cookie')
.collect { full -> full.split(';', 2)[0] }
.join('; ')
assert cookieHeader, 'login response should include session cookie'

container.nextCapturedTrace()

HttpRequest behindReq = container.buildReq('/behind-auth')
.header('Cookie', cookieHeader)
.GET()
.build()
Trace trace = container.traceFromRequest(behindReq, ofInputStream()) { HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
}

Span span = trace.first()
assert span.meta.'usr.id' == '1'
assert span.meta.'_dd.appsec.usr.id' == '1'
assert span.meta.'_dd.appsec.user.collection_mode' == 'identification'
assert span.meta.'http.route' == '/behind-auth'
}

@Test
@Order(7)
void 'path params trigger WAF block and laminas http route template'() {
HttpRequest req = container.buildReq('/dynamic-path/someValue').GET().build()
def trace = container.traceFromRequest(req, ofString()) { HttpResponse<String> re ->
assert re.statusCode() == 403
assert re.body().toLowerCase().contains('blocked')
}

Span span = trace.first()
assert span.metrics.'_dd.appsec.enabled' == 1.0d
assert span.metrics.'_dd.appsec.waf.duration' > 0.0d
assert span.meta.'_dd.appsec.event_rules.version' != ''
assert span.meta.'appsec.blocked' == 'true'
assert span.meta.'http.route' == '/dynamic-path[/:param01]'
}

@Test
@Order(8)
void 'nested Part and Chain routes produce correct http route'() {
HttpRequest nestedReq = container.buildReq('/resource/42/99').GET().build()
Trace nestedTrace = container.traceFromRequest(nestedReq, ofString()) { HttpResponse<String> resp ->
assert resp.statusCode() == 200
}
assert nestedTrace.first().meta.'http.route' == '/resource/:resourceId/:subId'

HttpRequest chainReq = container.buildReq('/chain/abc').GET().build()
Trace chainTrace = container.traceFromRequest(chainReq, ofString()) { HttpResponse<String> resp ->
assert resp.statusCode() == 200
}
assert chainTrace.first().meta.'http.route' == '/chain/:chainId'
}
Comment thread
estringana marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions appsec/tests/integration/src/test/www/laminas33/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor/
/data/cache/
28 changes: 28 additions & 0 deletions appsec/tests/integration/src/test/www/laminas33/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "datadog/laminas-appsec-integration",
"description": "Laminas MVC 3.3 app for AppSec integration tests",
"type": "project",
"license": "BSD-3-Clause",
"require": {
"php": "^7.3 || ~8.0.0 || ~8.1.0",
"laminas/laminas-component-installer": "^2.4",
"laminas/laminas-development-mode": "^3.2",
"laminas/laminas-mvc": "3.3.*",
"laminas/laminas-authentication": "^2.9",
"laminas/laminas-db": "^2.13",
"laminas/laminas-session": "^2.10"
},
"autoload": {
"psr-4": {
"Application\\": "module/Application/src/"
}
},
"config": {
"allow-plugins": {
"laminas/laminas-component-installer": true
},
"platform": {
"php": "7.3.33"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

return [
'modules' => require __DIR__ . '/modules.config.php',
'module_listener_options' => [
'use_laminas_loader' => false,
'config_glob_paths' => [
realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php',
],
'config_cache_enabled' => false,
'config_cache_key' => 'application.config.cache',
'module_map_cache_enabled' => false,
'module_map_cache_key' => 'application.module.cache',
'cache_dir' => 'data/cache/',
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

use Laminas\Authentication\AuthenticationService;
use Laminas\Authentication\Storage\Session as SessionStorage;
use Laminas\Db\Adapter\Adapter;

return [
'db' => [
'driver' => 'Pdo',
'dsn' => 'sqlite:/tmp/laminas_appsec.sqlite',
],
'service_manager' => [
'factories' => [
Adapter::class => function ($container) {
return new Adapter($container->get('config')['db']);
},
AuthenticationService::class => function ($container) {
$storage = new SessionStorage();
return new AuthenticationService($storage);
},
],
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

return [];
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

use Laminas\Mvc\Application;
use Laminas\Stdlib\ArrayUtils;

$appConfig = require __DIR__ . '/application.config.php';
if (file_exists(__DIR__ . '/development.config.php')) {
$appConfig = ArrayUtils::merge($appConfig, require __DIR__ . '/development.config.php');
}

return Application::init($appConfig)
->getServiceManager();
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

return [
'Laminas\Session',
'Laminas\Db',
'Laminas\Router',
'Laminas\Validator',
'Laminas\ZendFrameworkBridge',
'Application',
];
26 changes: 26 additions & 0 deletions appsec/tests/integration/src/test/www/laminas33/initialize.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash -e

cd /var/www

export DD_TRACE_CLI_ENABLED=false

composer install --no-interaction --no-dev
chown -R www-data:www-data vendor

mkdir -p data/cache /tmp/logs/laminas
rm -f /tmp/laminas_appsec.sqlite
touch /tmp/laminas_appsec.sqlite

php -r '
$pdo = new PDO("sqlite:/tmp/laminas_appsec.sqlite");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL)");
$hash = md5("password");
$stmt = $pdo->prepare("INSERT INTO users (name, email, password) VALUES (?, ?, ?)");
$stmt->execute(["Ci User", "ciuser@example.com", $hash]);
'

chown www-data:www-data /tmp/laminas_appsec.sqlite
chown -R www-data:www-data /var/www/data
mkdir -p /tmp/logs/laminas
chown www-data:www-data /tmp/logs/laminas
Loading
Loading