Skip to content

Expand appsec integrations to Laminas Framework#3716

Open
estringana wants to merge 17 commits intomasterfrom
estringana/add-laminas-appsec-integration
Open

Expand appsec integrations to Laminas Framework#3716
estringana wants to merge 17 commits intomasterfrom
estringana/add-laminas-appsec-integration

Conversation

@estringana
Copy link
Copy Markdown
Contributor

@estringana estringana commented Mar 20, 2026

Add AAP support to Laminas framework

This PR introduces AAP support to the Laminas framework, enabling improved observability and authentication-related event tracking.

Features added

  • Provide http.route
    Captures and exposes the resolved route for incoming requests.

  • Collect endpoints at startup
    Gathers and registers available endpoints during application bootstrap.

  • Login success event
    Emits an event when a user successfully authenticates.

  • Login failure event
    Emits an event when authentication fails.

  • Authenticated request events
    Tracks requests performed by authenticated users.

  • Collect path parameters
    Extracts and includes route path parameters in the collected data.

Limitations / Missing features

  • Signup events
    Not implemented because Laminas does not provide a built-in signup mechanism. This functionality is application-specific and must be implemented by developers if needed.

Reviewer checklist

  • Test coverage seems ok.
  • Appropriate labels assigned.

@estringana estringana force-pushed the estringana/add-laminas-appsec-integration branch from 820057d to eae5325 Compare March 23, 2026 11:22
@datadog-prod-us1-4
Copy link
Copy Markdown

datadog-prod-us1-4 Bot commented Mar 23, 2026

Tests

Fix all issues with BitsAI or with Cursor

⚠️ Warnings

❄️ 2 New flaky tests detected

    Endpoints are sent() from com.datadog.appsec.php.integration.Laminas33Tests   View in Datadog   (Fix with Cursor)

    path params trigger WAF block and laminas http route template() from com.datadog.appsec.php.integration.Laminas33Tests   View in Datadog   (Fix with Cursor)

ℹ️ Info

No other issues found (see more)

🧪 All tests passed

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 60.64% (-0.05%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: e0b3991 | Docs | Datadog PR Page | Give us feedback!

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Mar 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.74%. Comparing base (78ecdb0) to head (d2866e8).
⚠️ Report is 16 commits behind head on master.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #3716      +/-   ##
==========================================
- Coverage   68.81%   68.74%   -0.08%     
==========================================
  Files         166      166              
  Lines       19030    19030              
  Branches     1797     1797              
==========================================
- Hits        13096    13082      -14     
- Misses       5121     5135      +14     
  Partials      813      813              
Flag Coverage Δ
helper-rust-integration 78.82% <ø> (-0.03%) ⬇️
helper-rust-unit 49.17% <ø> (-0.20%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 4 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 78ecdb0...d2866e8. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pr-commenter
Copy link
Copy Markdown

pr-commenter Bot commented Mar 23, 2026

Benchmarks [ tracer ]

Benchmark execution time: 2026-04-14 15:46:16

Comparing candidate commit a35cd52 in PR branch estringana/add-laminas-appsec-integration with baseline commit df43587 in branch master.

Found 0 performance improvements and 6 performance regressions! Performance is the same for 188 metrics, 0 unstable metrics.

scenario:MessagePackSerializationBench/benchMessagePackSerialization-opcache

  • 🟥 execution_time [+3.785µs; +5.055µs] or [+3.818%; +5.100%]

scenario:PDOBench/benchPDOOverhead-opcache

  • 🟥 execution_time [+8.038µs; +9.869µs] or [+3.365%; +4.131%]

scenario:PDOBench/benchPDOOverheadWithDBM-opcache

  • 🟥 execution_time [+7.608µs; +9.649µs] or [+3.175%; +4.027%]

scenario:PHPRedisBench/benchRedisOverhead

  • 🟥 execution_time [+33.225µs; +41.437µs] or [+3.496%; +4.360%]

scenario:SamplingRuleMatchingBench/benchRegexMatching1

  • 🟥 execution_time [+31.682ns; +109.118ns] or [+2.139%; +7.366%]

scenario:SamplingRuleMatchingBench/benchRegexMatching3

  • 🟥 execution_time [+44.495ns; +120.705ns] or [+2.997%; +8.131%]

@github-actions
Copy link
Copy Markdown
Contributor

Snapshots difference summary

The following differences have been observed in committed snapshots. It is meant to help the reviewer.
The diff is simplistic, so please check some files anyway while we improve it.

If you need to update snapshots, please refer to CONTRIBUTING.md

@estringana estringana force-pushed the estringana/add-laminas-appsec-integration branch from d2866e8 to 651e235 Compare April 13, 2026 10:39
@estringana estringana force-pushed the estringana/add-laminas-appsec-integration branch from c199b10 to 9b662b3 Compare April 13, 2026 14:28
@estringana estringana marked this pull request as ready for review April 15, 2026 13:24
@estringana estringana requested review from a team as code owners April 15, 2026 13:24
@cataphract
Copy link
Copy Markdown
Contributor

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 140b8c4617

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +652 to +656
$adapter = $hook->args[0] ?? null;
$userLogin = null;

if ($adapter && method_exists($adapter, 'getIdentity')) {
$userLogin = $adapter->getIdentity();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive login from configured adapter on auth failures

The failure path only inspects $hook->args[0] for the adapter, so applications that use setAdapter($adapter) followed by authenticate() (no argument) emit login-failure events without a user identifier. That drops automated identification data for a common Laminas authentication flow and makes failure telemetry much less useful.

Useful? React with 👍 / 👎.

Comment on lines +312 to +314
if ($event->getRouteMatch() === null) {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register endpoints even when route match is null

Endpoint collection is gated on getRouteMatch() !== null, so requests that route to 404/no-match paths skip collection entirely. If early traffic is mostly unmatched (or an app primarily serves unmatched probes), are_endpoints_collected() can remain false and endpoints may never be registered despite the router being available.

Useful? React with 👍 / 👎.

Comment on lines +35 to +36
static boolean expectedVersion =
['7.3', '7.4', '8.0', '8.1'].contains(getPhpVersion()) && !getVariant().contains('zts')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we test with a more recent version? This seems very old. I fyou want you can test the oldest supported and the newest supported (make a base class / trait with the tests and then extend in two separate test classes)

Comment on lines +1039 to +1059
if ($route instanceof \Laminas\Router\Http\Part) {
$base = self::partRouteBaseTemplate($route);
$base = $base ?? '';
if (!$hasChild) {
return $base !== '' ? $base : null;
}
$child = self::httpRouteTemplateFromNamedRouteStack($route, $segments[1]);
if ($child === null) {
return $base !== '' ? $base : null;
}

return $base . $child;
}

if ($route instanceof \Laminas\Router\Http\TreeRouteStack && $hasChild) {
return self::httpRouteTemplateFromNamedRouteStack($route, $segments[1]);
}

if ($hasChild) {
return self::httpRouteTemplateFromMatchedRoute($route, null);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this cause the route name to be set multiple times? For the individual parts and for the high level router? I think you should not call this method unless it's the top level.

@Testcontainers
@EnabledIf('isExpectedVersion')
@TestMethodOrder(MethodOrderer.OrderAnnotation)
class Laminas33Tests {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't have tests for interesting routers. Try to test the full hierarchy:

  RouteInterface                        (single method: match())
  └── RouteStackInterface               (adds: addRoute, addRoutes, removeRoute, setRoutes)
      └── SimpleRouteStack              (concrete; adds getRoutes() + PriorityList storage)
          └── TreeRouteStack            (adds path-offset matching, defaultParams injection)
              ├── Part                  (a "node" in the tree: base route + lazy children)
              └── Chain                 (concatenates multiple route stages sequentially)

  HttpRouteInterface extends RouteInterface
      ├── Literal                       (exact string match)
      ├── Segment                       (`:param` placeholders, optional parts)
      ├── Regex                         (regex match with named captures)
      ├── Hostname                      (matches the Host header)
      ├── Method                        (matches HTTP verb)
      ├── Scheme                        (matches http/https)
      ├── Wildcard                      (key/value pairs after a prefix)
      └── Placeholder                   (always matches, no constraints)

For instance, I can tell that chain doesn't work. This test fails the final assertion

    @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'
    }

with

diff --git a/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php b/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php
index ee2635397..c9c91f285 100644
--- a/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php
+++ b/appsec/tests/integration/src/test/www/laminas33/module/Application/config/module.config.php
@@ -56,6 +56,60 @@ return [
                     ],
                 ],
             ],
+            'nested_resource' => [
+                'type' => Literal::class,
+                'options' => [
+                    'route' => '/resource',
+                    'defaults' => [
+                        'controller' => DynamicPathController::class,
+                        'action' => 'index',
+                    ],
+                ],
+                'may_terminate' => true,
+                'child_routes' => [
+                    'item' => [
+                        'type' => Segment::class,
+                        'options' => [
+                            'route' => '/:resourceId',
+                            'defaults' => [
+                                'controller' => DynamicPathController::class,
+                                'action' => 'index',
+                            ],
+                        ],
+                        'may_terminate' => true,
+                        'child_routes' => [
+                            'sub' => [
+                                'type' => Segment::class,
+                                'options' => [
+                                    'route' => '/:subId',
+                                    'defaults' => [
+                                        'controller' => DynamicPathController::class,
+                                        'action' => 'index',
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            'chained_resource' => [
+                'type' => Literal::class,
+                'options' => [
+                    'route' => '/chain',
+                ],
+                'chain_routes' => [
+                    [
+                        'type' => Segment::class,
+                        'options' => [
+                            'route' => '/:chainId',
+                            'defaults' => [
+                                'controller' => DynamicPathController::class,
+                                'action' => 'index',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
         ],
     ],
     'controllers' => [

assert span.metrics.'_dd.appsec.waf.duration' > 0.0d
assert span.meta.'_dd.appsec.event_rules.version' != ''
assert span.meta.'appsec.blocked' == 'true'
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no tests for http.route

Comment on lines +289 to +297
// Push path params to appsec
if (function_exists('\datadog\appsec\push_addresses')) {
$params = $routeMatch->getParams();
// Filter out the framework-specific params (controller, action)
$pathParams = array_diff_key($params, array_flip(['controller', 'action']));
if (count($pathParams) > 0) {
\datadog\appsec\push_addresses(["server.request.path_params" => $pathParams]);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should fire only for the top level router, otherwise you're pushing to the WAF multiple times.


hook_method(
'Laminas\Authentication\AuthenticationService',
'getIdentity',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You prob should dedup these calls to avoid calling track_authenticated_user_event_automated multiple times per request. For instance, you call this method yourself from another hook.

return $out;
}

private static function walkRouteStackCollectEndpointRows(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part children are only populated on first match(). The onRoute hook fires after one path through the route tree is matched, so only the subtree along the matched path is materialized. You need to force this materialization.

See the in the other comment when I added extra routes with child routes. Those are not found by the Endpoints are sent test.

$currentStack,
string $namePrefix,
array &$rows
): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not seem to catch the Chain router I added. The code only recurses into Part. Note that Chain is fundamentally different from Part in this context - its children are sequential stages that together form a single endpoint URL, not separate sibling endpoints.

$qualifiedName = $namePrefix === '' ? (string) $name : $namePrefix . '/' . $name;
$path = self::httpRouteTemplateFromNamedRouteStack($rootRouter, $qualifiedName);
if ($path !== null && $path !== '') {
$method = 'GET';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcoded GET is not correct. Make sure the integration tests include non-GET endpoints.

string $namePrefix,
array &$rows
): void {
foreach ($currentStack->getRoutes() as $name => $route) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code guards on instanceof RouteStackInterface but then calls->getRoutes(). getRoutes() is defined only on the concrete SimpleRouteStack

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants