attackmap-analyzer-php-laminas 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attackmap_analyzer_php_laminas-0.2.0/LICENSE +21 -0
- attackmap_analyzer_php_laminas-0.2.0/PKG-INFO +97 -0
- attackmap_analyzer_php_laminas-0.2.0/README.md +58 -0
- attackmap_analyzer_php_laminas-0.2.0/pyproject.toml +74 -0
- attackmap_analyzer_php_laminas-0.2.0/setup.cfg +4 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas/__init__.py +4 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas/analyzer.py +367 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas/phparray.py +447 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas/routes.py +623 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas.egg-info/PKG-INFO +97 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas.egg-info/SOURCES.txt +17 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas.egg-info/dependency_links.txt +1 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas.egg-info/entry_points.txt +2 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas.egg-info/requires.txt +9 -0
- attackmap_analyzer_php_laminas-0.2.0/src/attackmap_analyzer_php_laminas.egg-info/top_level.txt +1 -0
- attackmap_analyzer_php_laminas-0.2.0/tests/test_php_laminas_analyzer.py +210 -0
- attackmap_analyzer_php_laminas-0.2.0/tests/test_regex_budget.py +47 -0
- attackmap_analyzer_php_laminas-0.2.0/tests/test_signal_conformance.py +86 -0
- attackmap_analyzer_php_laminas-0.2.0/tests/test_structural_routes.py +519 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Davis and AttackMap Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attackmap-analyzer-php-laminas
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Framework-aware Laminas analyzer plugin for AttackMap
|
|
5
|
+
Author: AttackMap Contributors
|
|
6
|
+
Author-email: Matthew Davis <mlaify@mlaify.io>
|
|
7
|
+
Maintainer-email: Matthew Davis <mlaify@mlaify.io>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/mlaify/attackmap-analyzer-php-laminas
|
|
10
|
+
Project-URL: Repository, https://github.com/mlaify/attackmap-analyzer-php-laminas
|
|
11
|
+
Project-URL: Issues, https://github.com/mlaify/attackmap-analyzer-php-laminas/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/mlaify/attackmap-analyzer-php-laminas/blob/main/CHANGELOG.md
|
|
13
|
+
Project-URL: AttackMap core, https://github.com/mlaify/AttackMap
|
|
14
|
+
Keywords: security,static-analysis,appsec,defensive-security,attackmap,attackmap-plugin
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Information Technology
|
|
18
|
+
Classifier: Intended Audience :: System Administrators
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
25
|
+
Classifier: Topic :: Security
|
|
26
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: pydantic>=2.7.0
|
|
32
|
+
Provides-Extra: core
|
|
33
|
+
Requires-Dist: attackmap>=0.6.0; extra == "core"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
37
|
+
Requires-Dist: attackmap>=0.6.0; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# attackmap-analyzer-php-laminas
|
|
41
|
+
|
|
42
|
+
> [!IMPORTANT]
|
|
43
|
+
> **Active development, slow pace.** AttackMap is under active development, but
|
|
44
|
+
> progress may be slow until more contributors or co-maintainers join. Help is
|
|
45
|
+
> very welcome with the core engine, an analyzer, the macOS app, or the docs —
|
|
46
|
+
> see [CONTRIBUTING.md](CONTRIBUTING.md) or open an issue on
|
|
47
|
+
> [mlaify/AttackMap](https://github.com/mlaify/AttackMap/issues) to say hello.
|
|
48
|
+
> Security reports are still welcome at [security@mlaify.io](mailto:security@mlaify.io).
|
|
49
|
+
|
|
50
|
+
Framework-aware Laminas analyzer for [AttackMap](https://github.com/mlaify/AttackMap).
|
|
51
|
+
|
|
52
|
+
This module focuses on structured signal extraction for Laminas/Zend MVC projects:
|
|
53
|
+
|
|
54
|
+
- route paths from the `'router' => ['routes' => [...]]` config tree, walked structurally (see [Routes](#routes)); route *names* such as navigation's `'route' => 'home'` are not routes
|
|
55
|
+
- Mezzio/Expressive routes (`$app->get('/x', ...)`, `->post/put/patch/delete/any`, `$app->route('/x', $mw, ['GET', 'POST'])`) in files that reference `Mezzio\` or `Zend\Expressive\`
|
|
56
|
+
- controller mapping hints
|
|
57
|
+
- service-manager mapping hints
|
|
58
|
+
- broad PHP security signals (outbound calls, datastore, auth, secret hints)
|
|
59
|
+
|
|
60
|
+
## Analyzer identity
|
|
61
|
+
|
|
62
|
+
- `name`: `php-laminas`
|
|
63
|
+
- `display_name`: `PHP Laminas Analyzer`
|
|
64
|
+
- `version`: `0.1.0`
|
|
65
|
+
- `experimental`: `true`
|
|
66
|
+
- `enabled_by_default`: `false`
|
|
67
|
+
|
|
68
|
+
## Detection
|
|
69
|
+
|
|
70
|
+
`detect(repo_path)` returns true when one or more Laminas indicators exist:
|
|
71
|
+
|
|
72
|
+
- composer dependencies beginning with `laminas/`, `zendframework/` or `mezzio/`
|
|
73
|
+
- a `module/` directory together with `config/application.config.php`
|
|
74
|
+
- any `module.config.php` outside `vendor/` (and the other shared skip dirs)
|
|
75
|
+
|
|
76
|
+
A generic `src/`, `app/` or `config/` directory on its own is not enough.
|
|
77
|
+
|
|
78
|
+
## Routes
|
|
79
|
+
|
|
80
|
+
Router config is read with a small PHP-array tokenizer (`phparray.py`) that balances `[`/`]` and `array(`/`)` and skips strings, heredocs and comments, so a route is reported with its full path:
|
|
81
|
+
|
|
82
|
+
- **`child_routes` are joined** onto the parent path: `'/admin'` → `'/users'` → `'/:id'` is `/admin/users/:id`. A parent with children is itself a route only with `'may_terminate' => true`.
|
|
83
|
+
- **Method routes** (`'type' => Method::class, 'options' => ['verb' => 'post']`, or `'get,head'`) set the HTTP method on the parent's path instead of adding a path.
|
|
84
|
+
- **Segment optional parts are expanded progressively**: the minimal form, then one more optional group opened at a time in source order, at most 4 paths per route. `/:controller[/:action[/:id]]` gives `/:controller`, `/:controller/:action` and `/:controller/:action/:id`; sibling groups `[/a][/b]` give `''`, `/a`, `/a/b` (not `/b` alone). A child path joins the parent's *maximal* form, because Laminas' parent route consumes its optional parts greedily and never backs off (`/h[/:action]` + `/x` is reachable only as `/h/:action/x`); a child that can match nothing (`[/:id]`) keeps every parent form (`/h`, `/h/:action`, `/h/:action/:id`). A trailing `[/]` doesn't add a path; `:id{-}` delimiters and translated `{literal}` parts are normalized.
|
|
85
|
+
- `Regex` routes use their `spec` (`%id%` → `:id`); `Hostname`/`Scheme` routes add no path; Part, Wildcard, Query and console routes are skipped.
|
|
86
|
+
- **Config merged across modules**: a module's `'admin' => ['child_routes' => [...]]` stub (no type or route of its own) joins onto the `admin` route another module defines, as Laminas' config merge does. A stub extending a route no file defines is skipped.
|
|
87
|
+
- A file that only `return`s a route map (required from a router config) is walked too, and `'router' => $router` / `'routes' => $routes` follow a same-file `$routes = [...]` assignment.
|
|
88
|
+
- Method children apply to every expanded form of the parent: `/user[/:id]` with a `delete` child gives `DELETE /user` and `DELETE /user/:id`.
|
|
89
|
+
- Any `'route' => '/path'` literal the walk didn't reach (routes built with `array_merge`, in loops, or past the tokenizer's 500k-token cap) keeps the flat match, except literals inside a `child_routes` array, which are only fragments of a path.
|
|
90
|
+
|
|
91
|
+
Non-UTF-8 sources (cp1252/latin-1, common in ZF1/ZF2 code) are decoded by `attackmap.sdk.read_source` rather than skipped.
|
|
92
|
+
|
|
93
|
+
## Notes
|
|
94
|
+
|
|
95
|
+
This analyzer is heuristic and intentionally avoids AST parsing in this first iteration.
|
|
96
|
+
|
|
97
|
+
Secret hints are env var names (`getenv`, `$_ENV`, `$_SERVER`) containing `SECRET`, `TOKEN`, `KEY`, `PASSWORD` or `PASSWD`, matched case-sensitively; `DB_HOST`-style connection settings are not secrets. `jwt` needs a JWT library (`Firebase\JWT\JWT`, `JWT::decode/encode`, `Lcobucci\JWT`, `tymon/jwt-auth`), and `auth` needs the `Auth::` facade or a chained `auth()->…` helper.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# attackmap-analyzer-php-laminas
|
|
2
|
+
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> **Active development, slow pace.** AttackMap is under active development, but
|
|
5
|
+
> progress may be slow until more contributors or co-maintainers join. Help is
|
|
6
|
+
> very welcome with the core engine, an analyzer, the macOS app, or the docs —
|
|
7
|
+
> see [CONTRIBUTING.md](CONTRIBUTING.md) or open an issue on
|
|
8
|
+
> [mlaify/AttackMap](https://github.com/mlaify/AttackMap/issues) to say hello.
|
|
9
|
+
> Security reports are still welcome at [security@mlaify.io](mailto:security@mlaify.io).
|
|
10
|
+
|
|
11
|
+
Framework-aware Laminas analyzer for [AttackMap](https://github.com/mlaify/AttackMap).
|
|
12
|
+
|
|
13
|
+
This module focuses on structured signal extraction for Laminas/Zend MVC projects:
|
|
14
|
+
|
|
15
|
+
- route paths from the `'router' => ['routes' => [...]]` config tree, walked structurally (see [Routes](#routes)); route *names* such as navigation's `'route' => 'home'` are not routes
|
|
16
|
+
- Mezzio/Expressive routes (`$app->get('/x', ...)`, `->post/put/patch/delete/any`, `$app->route('/x', $mw, ['GET', 'POST'])`) in files that reference `Mezzio\` or `Zend\Expressive\`
|
|
17
|
+
- controller mapping hints
|
|
18
|
+
- service-manager mapping hints
|
|
19
|
+
- broad PHP security signals (outbound calls, datastore, auth, secret hints)
|
|
20
|
+
|
|
21
|
+
## Analyzer identity
|
|
22
|
+
|
|
23
|
+
- `name`: `php-laminas`
|
|
24
|
+
- `display_name`: `PHP Laminas Analyzer`
|
|
25
|
+
- `version`: `0.1.0`
|
|
26
|
+
- `experimental`: `true`
|
|
27
|
+
- `enabled_by_default`: `false`
|
|
28
|
+
|
|
29
|
+
## Detection
|
|
30
|
+
|
|
31
|
+
`detect(repo_path)` returns true when one or more Laminas indicators exist:
|
|
32
|
+
|
|
33
|
+
- composer dependencies beginning with `laminas/`, `zendframework/` or `mezzio/`
|
|
34
|
+
- a `module/` directory together with `config/application.config.php`
|
|
35
|
+
- any `module.config.php` outside `vendor/` (and the other shared skip dirs)
|
|
36
|
+
|
|
37
|
+
A generic `src/`, `app/` or `config/` directory on its own is not enough.
|
|
38
|
+
|
|
39
|
+
## Routes
|
|
40
|
+
|
|
41
|
+
Router config is read with a small PHP-array tokenizer (`phparray.py`) that balances `[`/`]` and `array(`/`)` and skips strings, heredocs and comments, so a route is reported with its full path:
|
|
42
|
+
|
|
43
|
+
- **`child_routes` are joined** onto the parent path: `'/admin'` → `'/users'` → `'/:id'` is `/admin/users/:id`. A parent with children is itself a route only with `'may_terminate' => true`.
|
|
44
|
+
- **Method routes** (`'type' => Method::class, 'options' => ['verb' => 'post']`, or `'get,head'`) set the HTTP method on the parent's path instead of adding a path.
|
|
45
|
+
- **Segment optional parts are expanded progressively**: the minimal form, then one more optional group opened at a time in source order, at most 4 paths per route. `/:controller[/:action[/:id]]` gives `/:controller`, `/:controller/:action` and `/:controller/:action/:id`; sibling groups `[/a][/b]` give `''`, `/a`, `/a/b` (not `/b` alone). A child path joins the parent's *maximal* form, because Laminas' parent route consumes its optional parts greedily and never backs off (`/h[/:action]` + `/x` is reachable only as `/h/:action/x`); a child that can match nothing (`[/:id]`) keeps every parent form (`/h`, `/h/:action`, `/h/:action/:id`). A trailing `[/]` doesn't add a path; `:id{-}` delimiters and translated `{literal}` parts are normalized.
|
|
46
|
+
- `Regex` routes use their `spec` (`%id%` → `:id`); `Hostname`/`Scheme` routes add no path; Part, Wildcard, Query and console routes are skipped.
|
|
47
|
+
- **Config merged across modules**: a module's `'admin' => ['child_routes' => [...]]` stub (no type or route of its own) joins onto the `admin` route another module defines, as Laminas' config merge does. A stub extending a route no file defines is skipped.
|
|
48
|
+
- A file that only `return`s a route map (required from a router config) is walked too, and `'router' => $router` / `'routes' => $routes` follow a same-file `$routes = [...]` assignment.
|
|
49
|
+
- Method children apply to every expanded form of the parent: `/user[/:id]` with a `delete` child gives `DELETE /user` and `DELETE /user/:id`.
|
|
50
|
+
- Any `'route' => '/path'` literal the walk didn't reach (routes built with `array_merge`, in loops, or past the tokenizer's 500k-token cap) keeps the flat match, except literals inside a `child_routes` array, which are only fragments of a path.
|
|
51
|
+
|
|
52
|
+
Non-UTF-8 sources (cp1252/latin-1, common in ZF1/ZF2 code) are decoded by `attackmap.sdk.read_source` rather than skipped.
|
|
53
|
+
|
|
54
|
+
## Notes
|
|
55
|
+
|
|
56
|
+
This analyzer is heuristic and intentionally avoids AST parsing in this first iteration.
|
|
57
|
+
|
|
58
|
+
Secret hints are env var names (`getenv`, `$_ENV`, `$_SERVER`) containing `SECRET`, `TOKEN`, `KEY`, `PASSWORD` or `PASSWD`, matched case-sensitively; `DB_HOST`-style connection settings are not secrets. `jwt` needs a JWT library (`Firebase\JWT\JWT`, `JWT::decode/encode`, `Lcobucci\JWT`, `tymon/jwt-auth`), and `auth` needs the `Auth::` facade or a chained `auth()->…` helper.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "attackmap-analyzer-php-laminas"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Framework-aware Laminas analyzer plugin for AttackMap"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Matthew Davis", email = "mlaify@mlaify.io"},
|
|
15
|
+
{name = "AttackMap Contributors"},
|
|
16
|
+
]
|
|
17
|
+
maintainers = [
|
|
18
|
+
{name = "Matthew Davis", email = "mlaify@mlaify.io"},
|
|
19
|
+
]
|
|
20
|
+
keywords = [
|
|
21
|
+
"security",
|
|
22
|
+
"static-analysis",
|
|
23
|
+
"appsec",
|
|
24
|
+
"defensive-security",
|
|
25
|
+
"attackmap",
|
|
26
|
+
"attackmap-plugin",
|
|
27
|
+
]
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 4 - Beta",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Intended Audience :: Information Technology",
|
|
32
|
+
"Intended Audience :: System Administrators",
|
|
33
|
+
"Operating System :: OS Independent",
|
|
34
|
+
"Programming Language :: Python :: 3",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
39
|
+
"Topic :: Security",
|
|
40
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
41
|
+
"Typing :: Typed",
|
|
42
|
+
]
|
|
43
|
+
dependencies = [
|
|
44
|
+
"pydantic>=2.7.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.optional-dependencies]
|
|
48
|
+
core = [
|
|
49
|
+
"attackmap>=0.6.0",
|
|
50
|
+
]
|
|
51
|
+
dev = [
|
|
52
|
+
"pytest>=8.0.0",
|
|
53
|
+
"build>=1.2.0",
|
|
54
|
+
"attackmap>=0.6.0",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[project.urls]
|
|
58
|
+
Homepage = "https://github.com/mlaify/attackmap-analyzer-php-laminas"
|
|
59
|
+
Repository = "https://github.com/mlaify/attackmap-analyzer-php-laminas"
|
|
60
|
+
Issues = "https://github.com/mlaify/attackmap-analyzer-php-laminas/issues"
|
|
61
|
+
Changelog = "https://github.com/mlaify/attackmap-analyzer-php-laminas/blob/main/CHANGELOG.md"
|
|
62
|
+
"AttackMap core" = "https://github.com/mlaify/AttackMap"
|
|
63
|
+
|
|
64
|
+
[project.entry-points."attackmap.analyzers"]
|
|
65
|
+
php_laminas = "attackmap_analyzer_php_laminas:PhpLaminasAnalyzer"
|
|
66
|
+
|
|
67
|
+
[tool.setuptools]
|
|
68
|
+
package-dir = {"" = "src"}
|
|
69
|
+
|
|
70
|
+
[tool.setuptools.packages.find]
|
|
71
|
+
where = ["src"]
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from attackmap.sdk import (
|
|
8
|
+
AnalyzerMetadata,
|
|
9
|
+
AuthHint,
|
|
10
|
+
DatabaseHint,
|
|
11
|
+
ExternalCall,
|
|
12
|
+
FrameworkHint,
|
|
13
|
+
iter_repo_files,
|
|
14
|
+
line_of,
|
|
15
|
+
line_snippet,
|
|
16
|
+
read_source,
|
|
17
|
+
rel,
|
|
18
|
+
Route,
|
|
19
|
+
ScanResult,
|
|
20
|
+
SecretHint,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .phparray import PhpArray, TokenList, tokenize
|
|
24
|
+
from .routes import RouteNode, fallback_route_literals, mezzio_routes, route_trees, router_routes
|
|
25
|
+
|
|
26
|
+
# (file, content, tokens, route trees) awaiting the cross-file route index.
|
|
27
|
+
_RouteFile = tuple[str, str, TokenList, list[PhpArray]]
|
|
28
|
+
|
|
29
|
+
# Fallback for a file with no recognizable `'router' => ['routes' => ...]`
|
|
30
|
+
# tree (see `routes.py` for the structural walk): Laminas router
|
|
31
|
+
# `'options' => ['route' => '/album[/:id]']`. Only URL path
|
|
32
|
+
# specs: rooted (`/...`) or an optional child segment (`[/:id]`). The same
|
|
33
|
+
# `'route' =>` key in `navigation` pages and redirects holds a route *name*
|
|
34
|
+
# (`'route' => 'home'`, `'route' => 'admin/site'`), which is not a path
|
|
35
|
+
# (port of mlaify/attackmap-analyzer-php-web#2's config-path fix).
|
|
36
|
+
LAMINAS_ROUTE_PATTERN = re.compile(r"['\"]route['\"]\s*=>\s*['\"]([/\[][^'\"]*)['\"]", re.IGNORECASE)
|
|
37
|
+
# `Foo\\BarController::class` — whole token matched possessively, keyword
|
|
38
|
+
# checked in Python (see the service pattern below; mlaify/AttackMap#236).
|
|
39
|
+
LAMINAS_CONTROLLER_PATTERN = re.compile(r"(?<![A-Za-z0-9_\\])([A-Za-z_\\][A-Za-z0-9_\\]*+)::class")
|
|
40
|
+
# Match the whole `Foo\\BarService::class` token in one possessive pass, then
|
|
41
|
+
# check for a service keyword in Python. The old single regex
|
|
42
|
+
# (`[..]*(?:Service|…)[..]*::class`) backtracked quadratically on a long
|
|
43
|
+
# identifier-like line, enough to stall a scan (mlaify/AttackMap#236).
|
|
44
|
+
LAMINAS_SERVICE_PATTERN = re.compile(r"(?<![A-Za-z0-9_\\])([A-Za-z_\\][A-Za-z0-9_\\]*+)::class")
|
|
45
|
+
_SERVICE_WORD = re.compile(r"Service|Manager|Repository|TableGateway|Adapter", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
# Composer packages that make a repo a Laminas (or Zend/Mezzio) app.
|
|
48
|
+
_LAMINAS_PACKAGE_PREFIXES = ("laminas/", "zendframework/", "mezzio/")
|
|
49
|
+
|
|
50
|
+
OUTBOUND_PATTERNS = [
|
|
51
|
+
re.compile(r"curl_init\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
52
|
+
re.compile(r"file_get_contents\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
53
|
+
re.compile(r"->request\s*\(\s*['\"](?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)['\"]\s*,\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
54
|
+
re.compile(r"->(?:get|post|put|patch|delete)\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
DATABASE_PATTERNS = [
|
|
58
|
+
(re.compile(r"\bnew\s+PDO\s*\(", re.IGNORECASE), "sql"),
|
|
59
|
+
(re.compile(r"Doctrine\\DBAL|Doctrine\\ORM|EntityManager", re.IGNORECASE), "sql"),
|
|
60
|
+
(re.compile(r"\bmysqli_connect\s*\(", re.IGNORECASE), "mysql"),
|
|
61
|
+
(re.compile(r"\bnew\s+mysqli\s*\(", re.IGNORECASE), "mysql"),
|
|
62
|
+
(re.compile(r"\bpg_connect\s*\(", re.IGNORECASE), "postgresql"),
|
|
63
|
+
(re.compile(r"\bRedis\s*::|\bnew\s+Redis\s*\(", re.IGNORECASE), "redis"),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
AUTH_PATTERNS = [
|
|
67
|
+
(re.compile(r"\bsession_start\s*\(", re.IGNORECASE), "session"),
|
|
68
|
+
(re.compile(r"\$_SESSION\b", re.IGNORECASE), "session"),
|
|
69
|
+
# JWT verification only (AttackMap#325): core credits any auth hint near a
|
|
70
|
+
# route as that route's auth, so `use` imports, `JWT::encode` and building
|
|
71
|
+
# tokens don't count. firebase/php-jwt `JWT::decode` always verifies; tymon
|
|
72
|
+
# facade checks and `jwt.auth` middleware; lcobucci validator / SignedWith;
|
|
73
|
+
# Tuupola JwtAuthentication middleware.
|
|
74
|
+
(
|
|
75
|
+
re.compile(
|
|
76
|
+
r"\bJWT::decode\s*\(|\bJWTAuth::(?:parseToken|authenticate|toUser|getPayload|check)\b"
|
|
77
|
+
r"|['\"]jwt\.(?:auth|verify)['\"]|\bvalidator\s*\(\s*\)\s*->\s*(?:validate|assert)\s*\("
|
|
78
|
+
r"|\bnew\s+SignedWith\s*\(|\bJwtAuthentication\b"
|
|
79
|
+
),
|
|
80
|
+
"jwt",
|
|
81
|
+
),
|
|
82
|
+
# Laravel-style `Auth::` facade or the `auth()` / `auth('guard')` helper
|
|
83
|
+
# chained into a guard call, not any function or method named `auth(`.
|
|
84
|
+
(re.compile(r"\bAuth::|(?<![\w$>:\\])auth\s*\(\s*(?:['\"][\w-]*['\"]\s*)?\)\s*->"), "auth"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
# Secret-shaped env var names only. `API`/`DB` on their own matched
|
|
88
|
+
# `DB_HOST`/`API_URL`; `DB_PASSWORD`/`API_KEY`/`API_TOKEN` still match via
|
|
89
|
+
# PASSWORD/KEY/TOKEN. Case-sensitive: env var names are upper-case
|
|
90
|
+
# (port of mlaify/attackmap-analyzer-php-web#2).
|
|
91
|
+
_SECRET_NAME = r"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD)[A-Z0-9_]*)"
|
|
92
|
+
SECRET_PATTERNS = [
|
|
93
|
+
re.compile(r"getenv\s*\(\s*['\"]" + _SECRET_NAME + r"['\"]"),
|
|
94
|
+
re.compile(r"\$_ENV\s*\[\s*['\"]" + _SECRET_NAME + r"['\"]\s*\]"),
|
|
95
|
+
re.compile(r"\$_SERVER\s*\[\s*['\"]" + _SECRET_NAME + r"['\"]\s*\]"),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PhpLaminasAnalyzer:
|
|
100
|
+
metadata = AnalyzerMetadata(
|
|
101
|
+
name="php-laminas",
|
|
102
|
+
display_name="PHP Laminas Analyzer",
|
|
103
|
+
version="0.2.0",
|
|
104
|
+
description="Framework-aware Laminas analyzer for route, controller, and service mapping hints.",
|
|
105
|
+
scope="Laminas and Zend Framework MVC projects using module and application config arrays.",
|
|
106
|
+
targets=["php-laminas", "laminas", "zendframework"],
|
|
107
|
+
languages=["php"],
|
|
108
|
+
priority=70,
|
|
109
|
+
experimental=True,
|
|
110
|
+
enabled_by_default=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def name(self) -> str:
|
|
115
|
+
return self.metadata.name
|
|
116
|
+
|
|
117
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
118
|
+
root = Path(repo_path).resolve()
|
|
119
|
+
if not root.exists() or not root.is_dir():
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
if self._has_laminas_composer_dependencies(root):
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
if (root / "module").is_dir() and (root / "config" / "application.config.php").exists():
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
if next(iter_repo_files(root, names={"module.config.php"}), None) is not None:
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
134
|
+
root = Path(repo_path).resolve()
|
|
135
|
+
result = ScanResult(root=str(root))
|
|
136
|
+
|
|
137
|
+
if not root.exists() or not root.is_dir():
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
self._extract_composer_signals(root, result)
|
|
141
|
+
|
|
142
|
+
# Pruned by repo-relative dir name (vendor, node_modules, .git, ...);
|
|
143
|
+
# symlinks out of the repo are not followed (AttackMap#253).
|
|
144
|
+
pending: list[_RouteFile] = []
|
|
145
|
+
seen: set[tuple[str, str, str]] = set()
|
|
146
|
+
for file_path in iter_repo_files(root, suffixes={".php"}):
|
|
147
|
+
result.files_scanned += 1
|
|
148
|
+
if "php" not in result.languages:
|
|
149
|
+
result.languages.append("php")
|
|
150
|
+
|
|
151
|
+
content = read_source(file_path)
|
|
152
|
+
if content is None:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
relative = rel(file_path, root)
|
|
156
|
+
self._extract_routes(content, relative, result, pending, seen)
|
|
157
|
+
self._extract_laminas_controllers(content, relative, result)
|
|
158
|
+
self._extract_laminas_services(content, relative, result)
|
|
159
|
+
self._extract_external_calls(content, relative, result)
|
|
160
|
+
self._extract_datastores(content, relative, result)
|
|
161
|
+
self._extract_auth_hints(content, relative, result)
|
|
162
|
+
self._extract_secret_hints(content, relative, result)
|
|
163
|
+
|
|
164
|
+
self._emit_router_routes(pending, result, seen)
|
|
165
|
+
result.languages.sort()
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def _has_laminas_composer_dependencies(self, root: Path) -> bool:
|
|
169
|
+
data = self._load_composer(root)
|
|
170
|
+
if data is None:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
requirements = {
|
|
174
|
+
**(data.get("require", {}) if isinstance(data.get("require", {}), dict) else {}),
|
|
175
|
+
**(data.get("require-dev", {}) if isinstance(data.get("require-dev", {}), dict) else {}),
|
|
176
|
+
}
|
|
177
|
+
for package in requirements:
|
|
178
|
+
if package.lower().startswith(_LAMINAS_PACKAGE_PREFIXES):
|
|
179
|
+
return True
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _extract_composer_signals(self, root: Path, result: ScanResult) -> None:
|
|
183
|
+
text = read_source(root / "composer.json", root=root)
|
|
184
|
+
data = self._load_composer(root)
|
|
185
|
+
if data is None or text is None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
requirements = {
|
|
189
|
+
**(data.get("require", {}) if isinstance(data.get("require", {}), dict) else {}),
|
|
190
|
+
**(data.get("require-dev", {}) if isinstance(data.get("require-dev", {}), dict) else {}),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for package in requirements:
|
|
194
|
+
lowered = package.lower()
|
|
195
|
+
offset = self._composer_offset(text, package)
|
|
196
|
+
if lowered.startswith(_LAMINAS_PACKAGE_PREFIXES):
|
|
197
|
+
self._append_unique_hint(
|
|
198
|
+
result.framework_hints, FrameworkHint, "laminas_dependency", "composer.json", text, offset, 0.9
|
|
199
|
+
)
|
|
200
|
+
if "doctrine" in lowered:
|
|
201
|
+
self._append_unique_database(result, "sql", "composer.json", text, offset)
|
|
202
|
+
|
|
203
|
+
def _extract_routes(
|
|
204
|
+
self, content: str, relative: str, result: ScanResult, pending: list[_RouteFile], seen: set
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Mezzio routes now; router trees go to ``pending`` for
|
|
207
|
+
:meth:`_emit_router_routes`, which needs every file's route names."""
|
|
208
|
+
mezzio = "Mezzio\\" in content or "Zend\\Expressive\\" in content
|
|
209
|
+
# `'route'`, `'routes'`, `'router'` (cheap pre-check before tokenizing).
|
|
210
|
+
if not (mezzio or "'route" in content or '"route' in content):
|
|
211
|
+
return
|
|
212
|
+
tokens = tokenize(content)
|
|
213
|
+
pending.append((relative, content, tokens, route_trees(tokens)))
|
|
214
|
+
if mezzio:
|
|
215
|
+
for route in mezzio_routes(tokens):
|
|
216
|
+
line = line_of(content, route.offset)
|
|
217
|
+
self._append_unique_route(result, seen, route.path, route.method, relative, line)
|
|
218
|
+
|
|
219
|
+
def _emit_router_routes(self, pending: list[_RouteFile], result: ScanResult, seen: set) -> None:
|
|
220
|
+
# Laminas merges all modules' config: index every named route first so
|
|
221
|
+
# a module's `'admin' => ['child_routes' => [...]]` stub joins onto the
|
|
222
|
+
# `/admin` route defined in another module. A few rounds settle stubs
|
|
223
|
+
# of stubs.
|
|
224
|
+
index: dict[str, RouteNode] = {}
|
|
225
|
+
for _ in range(3):
|
|
226
|
+
before = len(index)
|
|
227
|
+
for _relative, _content, _tokens, trees in pending:
|
|
228
|
+
router_routes(trees, known=index, index=index)
|
|
229
|
+
if len(index) == before:
|
|
230
|
+
break
|
|
231
|
+
for relative, content, tokens, trees in pending:
|
|
232
|
+
consumed: set[int] = set()
|
|
233
|
+
for route in router_routes(trees, known=index, consumed=consumed):
|
|
234
|
+
line = line_of(content, route.offset)
|
|
235
|
+
self._append_unique_route(result, seen, route.path, route.method, relative, line)
|
|
236
|
+
# Literals the walk never reached (routes built in a way it can't
|
|
237
|
+
# follow, or past the tokenizer cap) keep the flat match.
|
|
238
|
+
for path, offset in fallback_route_literals(content, tokens, consumed, LAMINAS_ROUTE_PATTERN):
|
|
239
|
+
self._append_unique_route(result, seen, path, "ANY", relative, line_of(content, offset))
|
|
240
|
+
|
|
241
|
+
def _extract_laminas_controllers(self, content: str, relative: str, result: ScanResult) -> None:
|
|
242
|
+
first: int | None = None
|
|
243
|
+
for match in LAMINAS_CONTROLLER_PATTERN.finditer(content):
|
|
244
|
+
if "controller" not in match.group(1).lower():
|
|
245
|
+
continue
|
|
246
|
+
if first is None:
|
|
247
|
+
first = match.start()
|
|
248
|
+
self._append_unique_hint(
|
|
249
|
+
result.framework_hints, FrameworkHint, f"controller:{match.group(1)}", relative, content, match.start(), 0.8
|
|
250
|
+
)
|
|
251
|
+
if first is not None:
|
|
252
|
+
self._append_unique_hint(
|
|
253
|
+
result.framework_hints, FrameworkHint, "laminas_controller_mapping", relative, content, first, 0.8
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _extract_laminas_services(self, content: str, relative: str, result: ScanResult) -> None:
|
|
257
|
+
first: int | None = None
|
|
258
|
+
for match in LAMINAS_SERVICE_PATTERN.finditer(content):
|
|
259
|
+
if not _SERVICE_WORD.search(match.group(1)):
|
|
260
|
+
continue
|
|
261
|
+
if first is None:
|
|
262
|
+
first = match.start()
|
|
263
|
+
self._append_unique_hint(
|
|
264
|
+
result.framework_hints, FrameworkHint, f"service:{match.group(1)}", relative, content, match.start(), 0.7
|
|
265
|
+
)
|
|
266
|
+
manager = re.search(r"['\"]service_manager['\"]", content)
|
|
267
|
+
anchor = manager.start() if manager else first
|
|
268
|
+
if anchor is not None:
|
|
269
|
+
self._append_unique_hint(
|
|
270
|
+
result.framework_hints, FrameworkHint, "laminas_service_manager", relative, content, anchor, 0.8
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
|
|
274
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
275
|
+
for match in pattern.finditer(content):
|
|
276
|
+
self._append_unique_external(result, match.group(1), relative, content, match.start())
|
|
277
|
+
|
|
278
|
+
def _extract_datastores(self, content: str, relative: str, result: ScanResult) -> None:
|
|
279
|
+
for pattern, kind in DATABASE_PATTERNS:
|
|
280
|
+
match = pattern.search(content)
|
|
281
|
+
if match:
|
|
282
|
+
self._append_unique_database(result, kind, relative, content, match.start())
|
|
283
|
+
|
|
284
|
+
def _extract_auth_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
285
|
+
for pattern, hint in AUTH_PATTERNS:
|
|
286
|
+
match = pattern.search(content)
|
|
287
|
+
if match:
|
|
288
|
+
self._append_unique_hint(result.auth_hints, AuthHint, hint, relative, content, match.start())
|
|
289
|
+
|
|
290
|
+
def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
291
|
+
for pattern in SECRET_PATTERNS:
|
|
292
|
+
for match in pattern.finditer(content):
|
|
293
|
+
self._append_unique_secret(result, match.group(1), relative, content, match.start())
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _load_composer(root: Path) -> dict | None:
|
|
297
|
+
text = read_source(root / "composer.json", root=root)
|
|
298
|
+
if text is None:
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
data = json.loads(text)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
return None
|
|
304
|
+
return data if isinstance(data, dict) else None
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _composer_offset(text: str, package: str) -> int:
|
|
308
|
+
"""Offset of a package's `"name": "constraint"` entry in composer.json (0 if not found)."""
|
|
309
|
+
index = text.find(f'"{package}"')
|
|
310
|
+
return index if index >= 0 else 0
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _append_unique_route(result: ScanResult, seen: set, path: str, method: str, file: str, line: int) -> None:
|
|
314
|
+
key = (path, method, file)
|
|
315
|
+
if key in seen:
|
|
316
|
+
return
|
|
317
|
+
seen.add(key)
|
|
318
|
+
result.routes.append(Route(path=path, method=method, file=file, line=line))
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, content: str, offset: int) -> None:
|
|
322
|
+
key = (target, file)
|
|
323
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
324
|
+
return
|
|
325
|
+
line = line_of(content, offset)
|
|
326
|
+
result.external_calls.append(
|
|
327
|
+
ExternalCall(target=target, file=file, line=line, evidence_text=line_snippet(content, line) or target)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, content: str, offset: int) -> None:
|
|
332
|
+
key = (kind, file)
|
|
333
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
334
|
+
return
|
|
335
|
+
line = line_of(content, offset)
|
|
336
|
+
result.databases.append(
|
|
337
|
+
DatabaseHint(kind=kind, file=file, line=line, evidence_text=line_snippet(content, line) or kind)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
@staticmethod
|
|
341
|
+
def _append_unique_hint(
|
|
342
|
+
bucket: list,
|
|
343
|
+
model: type,
|
|
344
|
+
hint: str,
|
|
345
|
+
file: str,
|
|
346
|
+
content: str,
|
|
347
|
+
offset: int,
|
|
348
|
+
confidence: float | None = None,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Append an AuthHint/FrameworkHint once per (hint, file), located at ``offset``."""
|
|
351
|
+
if any((item.hint, item.file) == (hint, file) for item in bucket):
|
|
352
|
+
return
|
|
353
|
+
line = line_of(content, offset)
|
|
354
|
+
extra = {"confidence": confidence} if confidence is not None else {}
|
|
355
|
+
bucket.append(
|
|
356
|
+
model(hint=hint, file=file, line=line, evidence_text=line_snippet(content, line) or hint, **extra)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
@staticmethod
|
|
360
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, content: str, offset: int) -> None:
|
|
361
|
+
key = (name, file)
|
|
362
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
363
|
+
return
|
|
364
|
+
line = line_of(content, offset)
|
|
365
|
+
result.secret_hints.append(
|
|
366
|
+
SecretHint(name=name, file=file, line=line, evidence_text=line_snippet(content, line) or name)
|
|
367
|
+
)
|