phpwarden 2.4.0__tar.gz → 2.5.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.
- {phpwarden-2.4.0 → phpwarden-2.5.0}/PKG-INFO +30 -13
- {phpwarden-2.4.0 → phpwarden-2.5.0}/README.md +29 -12
- {phpwarden-2.4.0 → phpwarden-2.5.0}/pyproject.toml +1 -1
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/__init__.py +1 -1
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/cli.py +33 -1
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/probe.py +301 -11
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/dbflow.py +245 -24
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/README.md +35 -48
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/PKG-INFO +30 -13
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/SOURCES.txt +1 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_account_deletion.py +5 -2
- phpwarden-2.5.0/tests/test_probe_auth.py +308 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_dbflow.py +26 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/LICENSE +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/setup.cfg +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/code.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/code_rules.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/data/php-cve-feed.json +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/deps.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/detect.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/engine.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/env.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/feed.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/guard.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/integrity.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/report.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/rules.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sarif.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/__init__.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/ast.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/authz.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/callgraph.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/engine.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/lexer.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/__init__.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/laravel.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/php_builtins.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/symfony.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/wordpress.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/parser.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/project.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/symbols.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/taint.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/types.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/EDGE.md +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/__init__.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/agent/phpwarden-shield.php +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/library.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/matcher.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/policy.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/report.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/thirdparty/__init__.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/thirdparty/trivy.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/verdict.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/version.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/vulndb.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/wordpress.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/dependency_links.txt +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/entry_points.txt +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/top_level.txt +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_accounts.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_advisories.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_agency.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_assess.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_assignment.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_audit.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_billing.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_business_metrics.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_cli_edges.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_code.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_connect_flow.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_dashboard_states.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_deps_and_rules.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_detection_validation.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_developer.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_discussion.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_e2e_readiness.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_email.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_engine.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_engine_feed.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_feed_license.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_feed_subscription.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_fleet.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_free_feed.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_google_oauth.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_guard_live.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_integrity.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_kev_alert.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_kev_gaps.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_livefeed.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_mentions.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_probe.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_probe_cli.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_probe_dashboard.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_reachability.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_report_proof.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_saas.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_saas_billing.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sarif.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_layer5.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_models.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_parser.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_project.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_taint.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_server.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_settings.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_cli.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_dashboard.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_plan.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_runtime.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_slack.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sso.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_store_connections.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_team.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_triage.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_trivy_import.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_waf_dryrun.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_webhook_idempotency.py +0 -0
- {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_wordpress_and_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phpwarden
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: PHP vulnerability scanner for the PHP engine, Composer, WordPress, your own code and the live site. Ranked by real-world exploitation (CISA KEV, then EPSS), not CVSS; blocks exploits inside PHP (shield). Offline, zero dependencies.
|
|
5
5
|
Author: oroma-alfred
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -142,10 +142,25 @@ files / high or critical `code` and `probe` findings (`probe` also on any
|
|
|
142
142
|
known-exploited finding) / anything to act on now or this week (`assess`),
|
|
143
143
|
`2` possibly exposed (`scan`), `3` PHP version unknown (`scan`), `4` input
|
|
144
144
|
error, `5` `--report-to` upload failed on an otherwise clean run, `64` usage
|
|
145
|
-
error. Full table: `phpwarden --help`.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
## New in 2.4.0, 2.3.0 and 2.2
|
|
145
|
+
error. `1` also means `feed update` failed. Full table: `phpwarden --help`.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
## New in 2.5.0, 2.4.0, 2.3.0 and 2.2
|
|
149
|
+
|
|
150
|
+
- **2.5.0: live check behind a login.** On a site that has proved ownership,
|
|
151
|
+
`probe --cookie` / `--cookie-file` / `--header` (or a saved test-account login
|
|
152
|
+
on the dashboard, one same-origin login POST) repeats a few read-only requests
|
|
153
|
+
signed in: on WordPress, `/wp-admin/` (version) and `plugins.php` (plugin
|
|
154
|
+
versions). Logout, delete and nonce URLs are never requested; credential values
|
|
155
|
+
are never printed or logged, are stored encrypted (AES-256-GCM) and are deleted
|
|
156
|
+
with the project. Use a low-privilege test account. [Docs](https://phpwarden.io/docs#probe-auth).
|
|
157
|
+
- **2.5.0: PhpStorm plugin** in `integrations/phpstorm-plugin`, at parity with
|
|
158
|
+
the VS Code extension. Not on JetBrains Marketplace yet: build it and install
|
|
159
|
+
from disk.
|
|
160
|
+
- **2.5.0: `phpwarden code` resolves table names fixed in the code** (constants,
|
|
161
|
+
`define()`, Laravel `config()`, Symfony parameters, concatenation, `$wpdb->prefix`
|
|
162
|
+
and core table properties, Eloquent `$table`), closing the dynamic-table-name
|
|
163
|
+
limit of 2.4.0. Benchmark results are unchanged.
|
|
149
164
|
|
|
150
165
|
- **2.4.0: `phpwarden code` follows data through the database and callbacks.**
|
|
151
166
|
Request data saved with raw SQL, `$wpdb`, Eloquent, the query builder or
|
|
@@ -178,13 +193,13 @@ error. Full table: `phpwarden --help`.
|
|
|
178
193
|
includes, redirects and HTTP clients, with a trace per finding (SARIF
|
|
179
194
|
`codeFlows`). Models Laravel, Symfony and WordPress: routes with no auth check,
|
|
180
195
|
mass assignment, raw Blade/Twig output, IDOR hints, CSRF exclusions, nonces.
|
|
181
|
-
Follows data stored in the session or WordPress options (second-order).
|
|
182
|
-
89 rules.
|
|
196
|
+
Follows data stored in the session or WordPress options, and (2.4.0; names resolved in 2.5.0) data saved to database tables (second-order), matched by table name: literal names, constants, Laravel config / Symfony parameters, `$wpdb->prefix` + name, `$wpdb->posts` etc. and Eloquent `$table` are resolved; table names built from run-time data are not matched.
|
|
197
|
+
89 rules. In our published comparison (2026-11-15) it found more than Semgrep, Psalm and Snyk Code, with far
|
|
183
198
|
fewer findings on clean framework code ([comparison](https://github.com/oroma-alfred/phpwarden/tree/main/bench/sast-compare-2026-11)).
|
|
184
199
|
- **`phpwarden shield`**: one PHP file loaded before your app that blocks exploit
|
|
185
200
|
attempts against the exploited vulnerabilities this site actually has, scoped
|
|
186
201
|
to their entry points. Logs until you promote rules (7-day burn-in, explicit
|
|
187
|
-
flag), fails open, has a kill switch, about 0.
|
|
202
|
+
flag), fails open, has a kill switch, about 0.1 ms per request in our benchmark. Not an edge
|
|
188
203
|
WAF: no DDoS, bot or TLS handling.
|
|
189
204
|
- **`phpwarden assess`**: one ranked list from every view of a site: installed
|
|
190
205
|
versions, your code, the live site from outside, and the access log, with the
|
|
@@ -199,9 +214,11 @@ error. Full table: `phpwarden --help`.
|
|
|
199
214
|
- **Coverage commitment**: in-scope CISA KEV entries are in the maintained feed
|
|
200
215
|
within 24 hours of listing, checked on every rebuild and published at
|
|
201
216
|
https://phpwarden.io/feeds/kev-status.json. Details: https://phpwarden.io/sla
|
|
202
|
-
- **Editors**: a VS Code extension
|
|
203
|
-
|
|
204
|
-
`integrations/
|
|
217
|
+
- **Editors**: a VS Code extension on the
|
|
218
|
+
[Marketplace](https://marketplace.visualstudio.com/items?itemName=phpwarden.phpwarden)
|
|
219
|
+
(`code --install-extension phpwarden.phpwarden`; source in `integrations/vscode`) and a PhpStorm plugin
|
|
220
|
+
in `integrations/phpstorm-plugin` (not on JetBrains Marketplace yet: build it and
|
|
221
|
+
use Install Plugin from Disk; External Tool steps in `integrations/phpstorm`).
|
|
205
222
|
|
|
206
223
|
## Replacing Wordfence CLI
|
|
207
224
|
|
|
@@ -267,7 +284,7 @@ see [phpwarden/laravel](https://packagist.org/packages/phpwarden/laravel).
|
|
|
267
284
|
```yaml
|
|
268
285
|
repos:
|
|
269
286
|
- repo: https://github.com/oroma-alfred/phpwarden
|
|
270
|
-
rev: v2.
|
|
287
|
+
rev: v2.5.0 # pin to a tag with `pre-commit autoupdate`
|
|
271
288
|
hooks:
|
|
272
289
|
- id: phpwarden-deps
|
|
273
290
|
# args: ["--no-reachability"] # faster: skip the reachability pass
|
|
@@ -284,7 +301,7 @@ The bundled community feed (2026.11.14.2 in this release) works forever, offline
|
|
|
284
301
|
[FriendsOfPHP](https://github.com/FriendsOfPHP/security-advisories)),
|
|
285
302
|
~12,300 WordPress (from [NVD](https://nvd.nist.gov): plugin, core and theme),
|
|
286
303
|
and 146 PHP engine/FPM CVEs, including hand-curated entries with per-branch
|
|
287
|
-
fixed versions, exposure conditions and detection signatures. About
|
|
304
|
+
fixed versions, exposure conditions and detection signatures. About 18,200 distinct vulnerabilities carry an
|
|
288
305
|
EPSS score; 54 distinct vulnerabilities were in KEV when this feed was built.
|
|
289
306
|
- **License-independent by construction.** Every source is open or
|
|
290
307
|
public-domain (OSV, NVD, FriendsOfPHP, php-src release notes, CISA KEV, FIRST EPSS). No commercial
|
|
@@ -120,10 +120,25 @@ files / high or critical `code` and `probe` findings (`probe` also on any
|
|
|
120
120
|
known-exploited finding) / anything to act on now or this week (`assess`),
|
|
121
121
|
`2` possibly exposed (`scan`), `3` PHP version unknown (`scan`), `4` input
|
|
122
122
|
error, `5` `--report-to` upload failed on an otherwise clean run, `64` usage
|
|
123
|
-
error. Full table: `phpwarden --help`.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
## New in 2.4.0, 2.3.0 and 2.2
|
|
123
|
+
error. `1` also means `feed update` failed. Full table: `phpwarden --help`.
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
## New in 2.5.0, 2.4.0, 2.3.0 and 2.2
|
|
127
|
+
|
|
128
|
+
- **2.5.0: live check behind a login.** On a site that has proved ownership,
|
|
129
|
+
`probe --cookie` / `--cookie-file` / `--header` (or a saved test-account login
|
|
130
|
+
on the dashboard, one same-origin login POST) repeats a few read-only requests
|
|
131
|
+
signed in: on WordPress, `/wp-admin/` (version) and `plugins.php` (plugin
|
|
132
|
+
versions). Logout, delete and nonce URLs are never requested; credential values
|
|
133
|
+
are never printed or logged, are stored encrypted (AES-256-GCM) and are deleted
|
|
134
|
+
with the project. Use a low-privilege test account. [Docs](https://phpwarden.io/docs#probe-auth).
|
|
135
|
+
- **2.5.0: PhpStorm plugin** in `integrations/phpstorm-plugin`, at parity with
|
|
136
|
+
the VS Code extension. Not on JetBrains Marketplace yet: build it and install
|
|
137
|
+
from disk.
|
|
138
|
+
- **2.5.0: `phpwarden code` resolves table names fixed in the code** (constants,
|
|
139
|
+
`define()`, Laravel `config()`, Symfony parameters, concatenation, `$wpdb->prefix`
|
|
140
|
+
and core table properties, Eloquent `$table`), closing the dynamic-table-name
|
|
141
|
+
limit of 2.4.0. Benchmark results are unchanged.
|
|
127
142
|
|
|
128
143
|
- **2.4.0: `phpwarden code` follows data through the database and callbacks.**
|
|
129
144
|
Request data saved with raw SQL, `$wpdb`, Eloquent, the query builder or
|
|
@@ -156,13 +171,13 @@ error. Full table: `phpwarden --help`.
|
|
|
156
171
|
includes, redirects and HTTP clients, with a trace per finding (SARIF
|
|
157
172
|
`codeFlows`). Models Laravel, Symfony and WordPress: routes with no auth check,
|
|
158
173
|
mass assignment, raw Blade/Twig output, IDOR hints, CSRF exclusions, nonces.
|
|
159
|
-
Follows data stored in the session or WordPress options (second-order).
|
|
160
|
-
89 rules.
|
|
174
|
+
Follows data stored in the session or WordPress options, and (2.4.0; names resolved in 2.5.0) data saved to database tables (second-order), matched by table name: literal names, constants, Laravel config / Symfony parameters, `$wpdb->prefix` + name, `$wpdb->posts` etc. and Eloquent `$table` are resolved; table names built from run-time data are not matched.
|
|
175
|
+
89 rules. In our published comparison (2026-11-15) it found more than Semgrep, Psalm and Snyk Code, with far
|
|
161
176
|
fewer findings on clean framework code ([comparison](https://github.com/oroma-alfred/phpwarden/tree/main/bench/sast-compare-2026-11)).
|
|
162
177
|
- **`phpwarden shield`**: one PHP file loaded before your app that blocks exploit
|
|
163
178
|
attempts against the exploited vulnerabilities this site actually has, scoped
|
|
164
179
|
to their entry points. Logs until you promote rules (7-day burn-in, explicit
|
|
165
|
-
flag), fails open, has a kill switch, about 0.
|
|
180
|
+
flag), fails open, has a kill switch, about 0.1 ms per request in our benchmark. Not an edge
|
|
166
181
|
WAF: no DDoS, bot or TLS handling.
|
|
167
182
|
- **`phpwarden assess`**: one ranked list from every view of a site: installed
|
|
168
183
|
versions, your code, the live site from outside, and the access log, with the
|
|
@@ -177,9 +192,11 @@ error. Full table: `phpwarden --help`.
|
|
|
177
192
|
- **Coverage commitment**: in-scope CISA KEV entries are in the maintained feed
|
|
178
193
|
within 24 hours of listing, checked on every rebuild and published at
|
|
179
194
|
https://phpwarden.io/feeds/kev-status.json. Details: https://phpwarden.io/sla
|
|
180
|
-
- **Editors**: a VS Code extension
|
|
181
|
-
|
|
182
|
-
`integrations/
|
|
195
|
+
- **Editors**: a VS Code extension on the
|
|
196
|
+
[Marketplace](https://marketplace.visualstudio.com/items?itemName=phpwarden.phpwarden)
|
|
197
|
+
(`code --install-extension phpwarden.phpwarden`; source in `integrations/vscode`) and a PhpStorm plugin
|
|
198
|
+
in `integrations/phpstorm-plugin` (not on JetBrains Marketplace yet: build it and
|
|
199
|
+
use Install Plugin from Disk; External Tool steps in `integrations/phpstorm`).
|
|
183
200
|
|
|
184
201
|
## Replacing Wordfence CLI
|
|
185
202
|
|
|
@@ -245,7 +262,7 @@ see [phpwarden/laravel](https://packagist.org/packages/phpwarden/laravel).
|
|
|
245
262
|
```yaml
|
|
246
263
|
repos:
|
|
247
264
|
- repo: https://github.com/oroma-alfred/phpwarden
|
|
248
|
-
rev: v2.
|
|
265
|
+
rev: v2.5.0 # pin to a tag with `pre-commit autoupdate`
|
|
249
266
|
hooks:
|
|
250
267
|
- id: phpwarden-deps
|
|
251
268
|
# args: ["--no-reachability"] # faster: skip the reachability pass
|
|
@@ -262,7 +279,7 @@ The bundled community feed (2026.11.14.2 in this release) works forever, offline
|
|
|
262
279
|
[FriendsOfPHP](https://github.com/FriendsOfPHP/security-advisories)),
|
|
263
280
|
~12,300 WordPress (from [NVD](https://nvd.nist.gov): plugin, core and theme),
|
|
264
281
|
and 146 PHP engine/FPM CVEs, including hand-curated entries with per-branch
|
|
265
|
-
fixed versions, exposure conditions and detection signatures. About
|
|
282
|
+
fixed versions, exposure conditions and detection signatures. About 18,200 distinct vulnerabilities carry an
|
|
266
283
|
EPSS score; 54 distinct vulnerabilities were in KEV when this feed was built.
|
|
267
284
|
- **License-independent by construction.** Every source is open or
|
|
268
285
|
public-domain (OSV, NVD, FriendsOfPHP, php-src release notes, CISA KEV, FIRST EPSS). No commercial
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phpwarden"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.0"
|
|
8
8
|
description = "PHP vulnerability scanner for the PHP engine, Composer, WordPress, your own code and the live site. Ranked by real-world exploitation (CISA KEV, then EPSS), not CVSS; blocks exploits inside PHP (shield). Offline, zero dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -6,4 +6,4 @@ real-world exploitation (CISA KEV, then EPSS, then CVSS), and can watch PHP-FPM
|
|
|
6
6
|
for active exploitation. The engine and the bundled feed are open; free users
|
|
7
7
|
get weekly feed updates, and a subscription adds the feed rebuilt twice a day.
|
|
8
8
|
"""
|
|
9
|
-
__version__ = "2.
|
|
9
|
+
__version__ = "2.5.0"
|
|
@@ -455,8 +455,32 @@ def _probe(args):
|
|
|
455
455
|
f" 3. A tag in the homepage <head>:\n <meta name=\"phpwarden-verify\" content=\"{token}\">\n\n"
|
|
456
456
|
f"Without it, probe only reads the homepage response (passive mode).")
|
|
457
457
|
return 0
|
|
458
|
+
auth = None
|
|
459
|
+
if args.cookie or args.cookie_file or args.header:
|
|
460
|
+
if args.passive:
|
|
461
|
+
sys.stderr.write("error: --cookie/--cookie-file need the active checks; drop --passive\n")
|
|
462
|
+
return 4
|
|
463
|
+
try:
|
|
464
|
+
raw = list(args.cookie or [])
|
|
465
|
+
if args.cookie_file:
|
|
466
|
+
with open(args.cookie_file, encoding="utf-8") as fh:
|
|
467
|
+
raw.append(fh.read())
|
|
468
|
+
cookies = {}
|
|
469
|
+
for r in raw:
|
|
470
|
+
cookies.update(pr.parse_cookies(r))
|
|
471
|
+
headers = {}
|
|
472
|
+
for hv in args.header or []:
|
|
473
|
+
k, sep, v = hv.partition(":")
|
|
474
|
+
if not sep:
|
|
475
|
+
raise ValueError("header must be 'Name: value'")
|
|
476
|
+
headers[k.strip()] = v.strip()
|
|
477
|
+
auth = pr.Auth(cookies=cookies, headers=headers)
|
|
478
|
+
except (OSError, ValueError) as e: # messages never contain the cookie value
|
|
479
|
+
sys.stderr.write(f"error: could not read the cookie: {e.__class__.__name__}"
|
|
480
|
+
f"{'' if isinstance(e, OSError) else ': ' + str(e)}\n")
|
|
481
|
+
return 4
|
|
458
482
|
res = pr.run_probe(url, token=token, active=False if args.passive else None,
|
|
459
|
-
max_requests=args.max_requests)
|
|
483
|
+
max_requests=args.max_requests, auth=auth)
|
|
460
484
|
if args.json:
|
|
461
485
|
print(json.dumps(res, indent=2))
|
|
462
486
|
elif _fmt(args) == "sarif":
|
|
@@ -883,6 +907,14 @@ def main(argv=None):
|
|
|
883
907
|
help="show how to verify ownership of the site, then exit")
|
|
884
908
|
pb.add_argument("--passive", action="store_true", help="read the homepage response only")
|
|
885
909
|
pb.add_argument("--max-requests", type=int, default=60, help="request budget (default 60)")
|
|
910
|
+
pb.add_argument("--cookie", action="append", metavar="NAME=VALUE",
|
|
911
|
+
help="session cookie for the signed-in check (repeatable; used only after ownership is "
|
|
912
|
+
"verified, only on that site, GET/HEAD only; never printed). Use a low-privilege test account")
|
|
913
|
+
pb.add_argument("--cookie-file", metavar="FILE",
|
|
914
|
+
help="read cookies from FILE (name=value lines or a Netscape cookies.txt); keeps them out of "
|
|
915
|
+
"shell history")
|
|
916
|
+
pb.add_argument("--header", action="append", metavar="'NAME: VALUE'",
|
|
917
|
+
help="extra header sent with the signed-in requests (repeatable; never printed)")
|
|
886
918
|
pb.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
887
919
|
_format_arg(pb)
|
|
888
920
|
pb.set_defaults(func=_probe)
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
SAFETY RULES (these are the design, not suggestions):
|
|
4
4
|
|
|
5
5
|
* GET/HEAD only. No POST, no payloads, no fuzzing, no brute force, no login or
|
|
6
|
-
auth attempts, no form submission
|
|
6
|
+
auth attempts, no form submission (except the opt-in signed-in check below:
|
|
7
|
+
one login POST). Nothing is ever written to the target.
|
|
7
8
|
* No crawling. Only the homepage plus a FIXED list of well-known paths is ever
|
|
8
9
|
requested (see EXPOSURE_CHECKS / ENDPOINT_CHECKS below). Links found on a
|
|
9
10
|
page are never followed; the only page-derived requests are
|
|
@@ -23,6 +24,15 @@ SAFETY RULES (these are the design, not suggestions):
|
|
|
23
24
|
in an exposed .env, the NUMBER of enumerable users) and never values.
|
|
24
25
|
* Every exposure is content-confirmed; status 200 alone never counts, and a
|
|
25
26
|
random nonexistent path is requested first to detect soft-404 sites.
|
|
27
|
+
|
|
28
|
+
AUTHENTICATED CHECK (optional, see `Auth`): the owner can hand the probe a
|
|
29
|
+
session cookie, or a TEST account's login (one form POST to obtain cookies).
|
|
30
|
+
It runs only after ownership is verified. The login POST is the only non-GET
|
|
31
|
+
request the probe ever makes, and only to the verified origin. Requests that
|
|
32
|
+
carry credentials go only to the verified origin (same scheme, host, port);
|
|
33
|
+
a redirect elsewhere is refused, and no URL matching a logout / delete /
|
|
34
|
+
state-changing pattern (or carrying a nonce/CSRF parameter) is requested
|
|
35
|
+
while signed in. Credential values are scrubbed from the whole result.
|
|
26
36
|
"""
|
|
27
37
|
import difflib
|
|
28
38
|
import hashlib
|
|
@@ -83,8 +93,9 @@ class Response:
|
|
|
83
93
|
return self.body.decode("utf-8", errors="replace")
|
|
84
94
|
|
|
85
95
|
|
|
86
|
-
def default_fetch(url, method="GET", timeout=TIMEOUT, max_bytes=MAX_BYTES):
|
|
87
|
-
"""Single HTTP request, no redirect following. Returns a Response.
|
|
96
|
+
def default_fetch(url, method="GET", timeout=TIMEOUT, max_bytes=MAX_BYTES, headers=None, data=None):
|
|
97
|
+
"""Single HTTP request, no redirect following. Returns a Response.
|
|
98
|
+
`headers`/`data` are only passed by the authenticated check."""
|
|
88
99
|
parts = urlsplit(url)
|
|
89
100
|
path = parts.path or "/"
|
|
90
101
|
if parts.query:
|
|
@@ -95,7 +106,9 @@ def default_fetch(url, method="GET", timeout=TIMEOUT, max_bytes=MAX_BYTES):
|
|
|
95
106
|
else:
|
|
96
107
|
conn = http.client.HTTPConnection(parts.hostname, parts.port or 80, timeout=timeout)
|
|
97
108
|
try:
|
|
98
|
-
|
|
109
|
+
h = {"User-Agent": USER_AGENT, "Accept": "*/*"}
|
|
110
|
+
h.update(headers or {})
|
|
111
|
+
conn.request(method, path, body=data, headers=h)
|
|
99
112
|
r = conn.getresponse()
|
|
100
113
|
body = r.read(max_bytes) if method != "HEAD" else b""
|
|
101
114
|
return Response(r.status, r.getheaders(), body, url)
|
|
@@ -103,10 +116,129 @@ def default_fetch(url, method="GET", timeout=TIMEOUT, max_bytes=MAX_BYTES):
|
|
|
103
116
|
conn.close()
|
|
104
117
|
|
|
105
118
|
|
|
119
|
+
# --------------------------------------------------------------------------- #
|
|
120
|
+
# Authenticated check: credentials and the rules for using them
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Never requested while signed in: anything that could end the session or
|
|
123
|
+
# change state. Matched against the full URL (path and query), case-insensitive.
|
|
124
|
+
UNSAFE_URL_RE = re.compile(
|
|
125
|
+
r"log[-_ ]?out|log[-_ ]?off|sign[-_ ]?out|sign[-_ ]?off|delete|remove|destroy|trash|"
|
|
126
|
+
r"unsubscribe|deactivate|uninstall|disable|revoke|purge|reset|drop|wipe|"
|
|
127
|
+
r"[?&](?:_wpnonce|nonce|_token|csrf[^=&]*)=", re.I)
|
|
128
|
+
_FORBIDDEN_HEADERS = {"host", "content-length", "transfer-encoding", "connection", "user-agent",
|
|
129
|
+
"content-type", "cookie"}
|
|
130
|
+
_TOKEN_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_unsafe_url(url) -> bool:
|
|
134
|
+
"""True for URLs the authenticated check must never request."""
|
|
135
|
+
return bool(UNSAFE_URL_RE.search(url or ""))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_cookies(text) -> dict:
|
|
139
|
+
"""'a=1; b=2' (or one name=value per line; Netscape cookies.txt lines are
|
|
140
|
+
understood too) -> {name: value}. Raises ValueError on junk. The error
|
|
141
|
+
message never contains the input."""
|
|
142
|
+
out = {}
|
|
143
|
+
for line in (text or "").replace("\r", "").split("\n"):
|
|
144
|
+
line = line.strip()
|
|
145
|
+
if line.startswith("#HttpOnly_"):
|
|
146
|
+
line = line[len("#HttpOnly_"):]
|
|
147
|
+
elif not line or line.startswith("#"):
|
|
148
|
+
continue
|
|
149
|
+
cols = line.split("\t")
|
|
150
|
+
if len(cols) == 7: # Netscape cookies.txt
|
|
151
|
+
if not _TOKEN_RE.match(cols[5].strip()):
|
|
152
|
+
raise ValueError("bad cookie name")
|
|
153
|
+
out[cols[5].strip()] = cols[6].strip()
|
|
154
|
+
continue
|
|
155
|
+
for part in line.split(";"):
|
|
156
|
+
part = part.strip()
|
|
157
|
+
if not part:
|
|
158
|
+
continue
|
|
159
|
+
name, sep, value = part.partition("=")
|
|
160
|
+
name = name.strip()
|
|
161
|
+
if not sep or not _TOKEN_RE.match(name):
|
|
162
|
+
raise ValueError("cookie must be name=value")
|
|
163
|
+
out[name] = value.strip()
|
|
164
|
+
if not out:
|
|
165
|
+
raise ValueError("no cookie given")
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Auth:
|
|
170
|
+
"""Credentials for the authenticated check. Either `cookies` (+ optional
|
|
171
|
+
extra `headers`) or `login` = {url, user_field, pass_field, username,
|
|
172
|
+
password}. repr() never shows a value."""
|
|
173
|
+
def __init__(self, cookies=None, headers=None, login=None):
|
|
174
|
+
self.cookies = dict(cookies or {})
|
|
175
|
+
self.headers = {}
|
|
176
|
+
for k, v in (headers or {}).items():
|
|
177
|
+
k, v = str(k).strip(), str(v).strip()
|
|
178
|
+
if not _TOKEN_RE.match(k) or k.lower() in _FORBIDDEN_HEADERS or "\r" in v or "\n" in v:
|
|
179
|
+
raise ValueError("header not allowed")
|
|
180
|
+
self.headers[k] = v
|
|
181
|
+
self.login = dict(login) if login else None
|
|
182
|
+
if self.login:
|
|
183
|
+
for f in ("url", "user_field", "pass_field", "username", "password"):
|
|
184
|
+
if not str(self.login.get(f) or ""):
|
|
185
|
+
raise ValueError(f"login needs {f}")
|
|
186
|
+
if not self.cookies and not self.login:
|
|
187
|
+
raise ValueError("give a cookie or a login")
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def method(self):
|
|
191
|
+
return "login" if self.login else "cookie"
|
|
192
|
+
|
|
193
|
+
def secret_values(self) -> list:
|
|
194
|
+
vals = list(self.cookies.values()) + list(self.headers.values())
|
|
195
|
+
if self.login:
|
|
196
|
+
vals += [self.login["password"], self.login["username"]]
|
|
197
|
+
return [v for v in vals if v and len(v) >= 3]
|
|
198
|
+
|
|
199
|
+
def __repr__(self):
|
|
200
|
+
return f"<Auth {self.method} [redacted]>"
|
|
201
|
+
__str__ = __repr__
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def scrub(obj, secret_values):
|
|
205
|
+
"""Replace every occurrence of a secret value in a JSON-like structure."""
|
|
206
|
+
vals = sorted({v for v in secret_values if v}, key=len, reverse=True)
|
|
207
|
+
if not vals:
|
|
208
|
+
return obj
|
|
209
|
+
|
|
210
|
+
def one(x):
|
|
211
|
+
if isinstance(x, str):
|
|
212
|
+
for v in vals:
|
|
213
|
+
if v in x:
|
|
214
|
+
x = x.replace(v, "[redacted]")
|
|
215
|
+
return x
|
|
216
|
+
if isinstance(x, dict):
|
|
217
|
+
return {one(k): one(v) for k, v in x.items()}
|
|
218
|
+
if isinstance(x, (list, tuple)):
|
|
219
|
+
return [one(v) for v in x]
|
|
220
|
+
return x
|
|
221
|
+
return one(obj)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _origin(url):
|
|
225
|
+
p = urlsplit(url)
|
|
226
|
+
scheme = (p.scheme or "").lower()
|
|
227
|
+
try:
|
|
228
|
+
port = p.port
|
|
229
|
+
except ValueError:
|
|
230
|
+
port = -1
|
|
231
|
+
return (scheme, (p.hostname or "").lower(), port or (443 if scheme == "https" else 80))
|
|
232
|
+
|
|
233
|
+
|
|
106
234
|
class BudgetExceeded(Exception):
|
|
107
235
|
pass
|
|
108
236
|
|
|
109
237
|
|
|
238
|
+
class _Refused(Exception):
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
110
242
|
class _Session:
|
|
111
243
|
"""Enforces request cap, delay, total time cap and same-host redirects."""
|
|
112
244
|
def __init__(self, base_url, fetch, max_requests, delay):
|
|
@@ -117,8 +249,22 @@ class _Session:
|
|
|
117
249
|
self.count = 0
|
|
118
250
|
self.start = time.monotonic()
|
|
119
251
|
self.notes = []
|
|
120
|
-
|
|
121
|
-
|
|
252
|
+
self.origin = _origin(base_url)
|
|
253
|
+
self.auth = None # an Auth, set only after ownership is verified
|
|
254
|
+
self.jar = {}
|
|
255
|
+
self._login_post = False
|
|
256
|
+
|
|
257
|
+
def _one(self, url, method, data=None):
|
|
258
|
+
if self.auth is not None:
|
|
259
|
+
# credentials only ever go to the verified origin, read-only
|
|
260
|
+
if _origin(url) != self.origin:
|
|
261
|
+
raise _Refused("request to another origin refused while signed in")
|
|
262
|
+
if is_unsafe_url(url):
|
|
263
|
+
raise _Refused("skipped while signed in (logout or state-changing URL)")
|
|
264
|
+
if method not in ("GET", "HEAD") and not (method == "POST" and self._login_post):
|
|
265
|
+
raise _Refused("only GET/HEAD while signed in")
|
|
266
|
+
elif method not in ("GET", "HEAD"):
|
|
267
|
+
raise _Refused("only GET/HEAD")
|
|
122
268
|
if self.count >= self.max_requests:
|
|
123
269
|
raise BudgetExceeded("request cap reached")
|
|
124
270
|
if time.monotonic() - self.start > TOTAL_TIME_CAP:
|
|
@@ -126,7 +272,25 @@ class _Session:
|
|
|
126
272
|
if self.count and self.delay:
|
|
127
273
|
time.sleep(self.delay)
|
|
128
274
|
self.count += 1
|
|
129
|
-
|
|
275
|
+
if self.auth is None:
|
|
276
|
+
return self.fetch(url, method=method, timeout=TIMEOUT, max_bytes=MAX_BYTES)
|
|
277
|
+
h = dict(self.auth.headers)
|
|
278
|
+
if self.jar:
|
|
279
|
+
h["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.jar.items())
|
|
280
|
+
if data is not None:
|
|
281
|
+
h["Content-Type"] = "application/x-www-form-urlencoded"
|
|
282
|
+
r = self.fetch(url, method=method, timeout=TIMEOUT, max_bytes=MAX_BYTES, headers=h, data=data)
|
|
283
|
+
for sc in r.all("set-cookie"): # same origin by construction
|
|
284
|
+
name, _, rest = sc.partition("=")
|
|
285
|
+
name, value = name.strip(), rest.split(";", 1)[0].strip()
|
|
286
|
+
if not _TOKEN_RE.match(name):
|
|
287
|
+
continue
|
|
288
|
+
low = sc.lower()
|
|
289
|
+
if not value or value == "deleted" or "max-age=0" in low or "01 jan 1970" in low:
|
|
290
|
+
self.jar.pop(name, None)
|
|
291
|
+
else:
|
|
292
|
+
self.jar[name] = value
|
|
293
|
+
return r
|
|
130
294
|
|
|
131
295
|
def get(self, url, follow=True, method="GET"):
|
|
132
296
|
"""GET url, following <=3 same-host redirects. Returns Response or None."""
|
|
@@ -135,6 +299,12 @@ class _Session:
|
|
|
135
299
|
hops = 0
|
|
136
300
|
while follow and r.status in (301, 302, 303, 307, 308) and r.header("location"):
|
|
137
301
|
nxt = urljoin(r.url, r.header("location"))
|
|
302
|
+
if self.auth is not None and _origin(nxt) != self.origin:
|
|
303
|
+
self.notes.append("signed in: redirect to another origin refused")
|
|
304
|
+
return r
|
|
305
|
+
if self.auth is not None and is_unsafe_url(nxt):
|
|
306
|
+
self.notes.append("signed in: redirect to a logout or state-changing URL not followed")
|
|
307
|
+
return r
|
|
138
308
|
if (urlsplit(nxt).hostname or "").lower() != self.host:
|
|
139
309
|
self.notes.append(f"redirect to another host not followed: {urlsplit(nxt).hostname}")
|
|
140
310
|
return r
|
|
@@ -146,6 +316,9 @@ class _Session:
|
|
|
146
316
|
return r
|
|
147
317
|
except BudgetExceeded:
|
|
148
318
|
raise
|
|
319
|
+
except _Refused as e:
|
|
320
|
+
self.notes.append(f"{urlsplit(url).path or '/'}: {e}")
|
|
321
|
+
return None
|
|
149
322
|
except (OSError, http.client.HTTPException, ValueError) as e:
|
|
150
323
|
self.notes.append(f"{urlsplit(url).path or '/'}: {type(e).__name__}")
|
|
151
324
|
return None
|
|
@@ -705,6 +878,102 @@ def _run_active(sess, root, fp, feed, findings):
|
|
|
705
878
|
"Disable autoindex (Options -Indexes)."))
|
|
706
879
|
|
|
707
880
|
|
|
881
|
+
_WP_ADMIN_VER_RE = re.compile(r'id=["\']footer-upgrade["\'][^>]*>\s*(?:<[^>]+>\s*)*Version\s+([\d.]+)', re.I)
|
|
882
|
+
_WP_PLUGIN_ROW_RE = re.compile(r'<tr[^>]+data-slug=["\']([a-z0-9_\-.]+)["\'][^>]*>(.*?)</tr>', re.I | re.S)
|
|
883
|
+
_WP_ROW_VER_RE = re.compile(r'Version\s+([\d][\w.\-]*)', re.I)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _login(sess, auth):
|
|
887
|
+
"""One form POST to the login URL (same origin only); cookies from the
|
|
888
|
+
response go into the jar. Returns True when new cookies were set."""
|
|
889
|
+
lg = auth.login
|
|
890
|
+
url = lg["url"]
|
|
891
|
+
if _origin(url) != sess.origin:
|
|
892
|
+
sess.notes.append("login refused: the login URL is not on the verified site")
|
|
893
|
+
return False
|
|
894
|
+
if is_unsafe_url(url):
|
|
895
|
+
sess.notes.append("login refused: the login URL looks like a logout or state-changing URL")
|
|
896
|
+
return False
|
|
897
|
+
sess.get(url, follow=False) # pre-login cookies (GET)
|
|
898
|
+
form = {lg["user_field"]: lg["username"], lg["pass_field"]: lg["password"]}
|
|
899
|
+
if urlsplit(url).path.endswith("wp-login.php"): # WordPress refuses logins without its test cookie
|
|
900
|
+
sess.jar.setdefault("wordpress_test_cookie", "WP%20Cookie%20check")
|
|
901
|
+
form.setdefault("testcookie", "1")
|
|
902
|
+
from urllib.parse import urlencode
|
|
903
|
+
before = dict(sess.jar)
|
|
904
|
+
sess._login_post = True
|
|
905
|
+
try:
|
|
906
|
+
r = sess._one(url, "POST", data=urlencode(form).encode())
|
|
907
|
+
except _Refused as e:
|
|
908
|
+
sess.notes.append(f"login: {e}")
|
|
909
|
+
return False
|
|
910
|
+
except (OSError, http.client.HTTPException, ValueError) as e:
|
|
911
|
+
sess.notes.append(f"login request failed: {type(e).__name__}")
|
|
912
|
+
return False
|
|
913
|
+
finally:
|
|
914
|
+
sess._login_post = False # never another POST
|
|
915
|
+
new = {k for k, v in sess.jar.items() if before.get(k) != v and k != "wordpress_test_cookie"}
|
|
916
|
+
if r.status >= 400 or not new:
|
|
917
|
+
sess.notes.append(f"login did not set a session cookie (HTTP {r.status}); signed-in checks skipped")
|
|
918
|
+
return False
|
|
919
|
+
return True
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _run_authenticated(sess, url, root, fp, findings):
|
|
923
|
+
"""Signed-in GETs: the homepage again, and on WordPress the dashboard and
|
|
924
|
+
the plugin list (both read-only pages). Returns the summary dict."""
|
|
925
|
+
info = {"used": True, "method": sess.auth.method, "signed_in": False, "pages": [], "seen_signed_in": []}
|
|
926
|
+
before = {"wordpress": fp["wordpress"], "php": fp["php"], "plugins": dict(fp["plugins"]),
|
|
927
|
+
"themes": dict(fp["themes"])}
|
|
928
|
+
r = sess.get(url)
|
|
929
|
+
if r is not None and r.status == 200:
|
|
930
|
+
info["pages"].append(urlsplit(r.url).path or "/")
|
|
931
|
+
_fingerprint_headers(r, fp)
|
|
932
|
+
_fingerprint_html(r.text, fp)
|
|
933
|
+
if "wordpress" in fp["frameworks"] or (sess.auth.login and "wp-login.php" in sess.auth.login["url"]):
|
|
934
|
+
r = sess.get(urljoin(root, "wp-admin/"))
|
|
935
|
+
if r is None or r.status != 200 or "wp-login.php" in (r.url or "") or 'id="loginform"' in r.text:
|
|
936
|
+
sess.notes.append("signed in: wp-admin/ did not accept the session (expired cookie or wrong login?)")
|
|
937
|
+
else:
|
|
938
|
+
info["signed_in"] = True
|
|
939
|
+
info["pages"].append("/wp-admin/")
|
|
940
|
+
_fingerprint_html(r.text, fp)
|
|
941
|
+
m = _WP_ADMIN_VER_RE.search(r.text)
|
|
942
|
+
if m and not fp["wordpress"]:
|
|
943
|
+
fp["wordpress"] = m.group(1)
|
|
944
|
+
r2 = sess.get(urljoin(root, "wp-admin/plugins.php"))
|
|
945
|
+
if r2 is not None and r2.status == 200 and "data-slug" in r2.text:
|
|
946
|
+
info["pages"].append("/wp-admin/plugins.php")
|
|
947
|
+
n = 0
|
|
948
|
+
for slug, row in _WP_PLUGIN_ROW_RE.findall(r2.text):
|
|
949
|
+
vm = _WP_ROW_VER_RE.search(row)
|
|
950
|
+
if vm and not fp["plugins"].get(slug.lower()):
|
|
951
|
+
fp["plugins"][slug.lower()] = vm.group(1)
|
|
952
|
+
n += 1
|
|
953
|
+
findings.append(_f("auth-account-can-list-plugins", "auth", "low",
|
|
954
|
+
"The test account can open the plugin list",
|
|
955
|
+
f"Signed in, /wp-admin/plugins.php listed {n} plugins. That page needs an "
|
|
956
|
+
f"administrator-level role; use a lower-privilege test account.",
|
|
957
|
+
urljoin(root, "wp-admin/plugins.php"),
|
|
958
|
+
"Give the live check an account with the lowest role that can sign in "
|
|
959
|
+
"(e.g. Subscriber)."))
|
|
960
|
+
elif r is not None and r.status == 200:
|
|
961
|
+
info["signed_in"] = True
|
|
962
|
+
for k in ("wordpress", "php"):
|
|
963
|
+
if fp[k] and not before[k]:
|
|
964
|
+
info["seen_signed_in"].append(f"{k} {fp[k]}")
|
|
965
|
+
for kind in ("plugins", "themes"):
|
|
966
|
+
for slug, v in fp[kind].items():
|
|
967
|
+
if v and not before[kind].get(slug):
|
|
968
|
+
info["seen_signed_in"].append(f"{kind[:-1]} {slug} {v}")
|
|
969
|
+
if info["seen_signed_in"]:
|
|
970
|
+
findings.append(_f("auth-versions-visible-signed-in", "auth", "info",
|
|
971
|
+
"Versions visible only when signed in",
|
|
972
|
+
"; ".join(info["seen_signed_in"][:20]), url,
|
|
973
|
+
"Matched against the feed like any other version; nothing to fix by itself."))
|
|
974
|
+
return info
|
|
975
|
+
|
|
976
|
+
|
|
708
977
|
def is_public_host(host) -> bool:
|
|
709
978
|
"""True when every address `host` resolves to is a public internet address.
|
|
710
979
|
|
|
@@ -752,9 +1021,11 @@ def local_token(host, home=None) -> str:
|
|
|
752
1021
|
|
|
753
1022
|
|
|
754
1023
|
def run_probe(url, *, token=None, active=None, feed=None, max_requests=60, delay=0.3,
|
|
755
|
-
fetch=None, tls_check=None):
|
|
1024
|
+
fetch=None, tls_check=None, auth=None):
|
|
756
1025
|
"""Probe `url`. active=None: active if ownership verifies; active=True: require
|
|
757
|
-
verification (refused otherwise); active=False: passive only.
|
|
1026
|
+
verification (refused otherwise); active=False: passive only.
|
|
1027
|
+
auth: an `Auth`; used only after ownership verifies, after the anonymous
|
|
1028
|
+
active checks. Its values are scrubbed from the result."""
|
|
758
1029
|
if "://" not in url:
|
|
759
1030
|
url = "https://" + url
|
|
760
1031
|
p = urlsplit(url)
|
|
@@ -766,6 +1037,7 @@ def run_probe(url, *, token=None, active=None, feed=None, max_requests=60, delay
|
|
|
766
1037
|
fp = {"php": None, "wordpress": None, "plugins": {}, "themes": {}, "frameworks": []}
|
|
767
1038
|
findings, ownership, mode = [], {"verified": False, "method": None}, "passive"
|
|
768
1039
|
incomplete = False
|
|
1040
|
+
authinfo = {"used": False}
|
|
769
1041
|
|
|
770
1042
|
try:
|
|
771
1043
|
# Passive: the single homepage GET, redirects NOT followed.
|
|
@@ -791,6 +1063,14 @@ def run_probe(url, *, token=None, active=None, feed=None, max_requests=60, delay
|
|
|
791
1063
|
if h2 is not None:
|
|
792
1064
|
_fingerprint_html(h2.text, fp)
|
|
793
1065
|
_run_active(sess, root, fp, feed, findings)
|
|
1066
|
+
if auth is not None:
|
|
1067
|
+
sess.auth = auth
|
|
1068
|
+
sess.jar = dict(auth.cookies)
|
|
1069
|
+
authinfo = {"used": True, "method": auth.method, "signed_in": False}
|
|
1070
|
+
if auth.login is None or _login(sess, auth):
|
|
1071
|
+
authinfo = _run_authenticated(sess, url, root, fp, findings)
|
|
1072
|
+
if auth is not None and not ownership["verified"]:
|
|
1073
|
+
sess.notes.append("signed-in check refused: ownership not verified")
|
|
794
1074
|
if active and not ownership["verified"]:
|
|
795
1075
|
sess.notes.append("active mode refused: ownership not verified")
|
|
796
1076
|
except BudgetExceeded as e:
|
|
@@ -808,13 +1088,18 @@ def run_probe(url, *, token=None, active=None, feed=None, max_requests=60, delay
|
|
|
808
1088
|
summary = {s: sum(1 for f in findings if f["severity"] == s) for s in SEV_ORDER}
|
|
809
1089
|
summary["exploited"] = sum(1 for f in findings if f.get("known_exploited"))
|
|
810
1090
|
summary["total"] = len(findings)
|
|
811
|
-
|
|
1091
|
+
sess.auth, sess.jar = None, {}
|
|
1092
|
+
result = {"tool": "phpwarden-probe", "version": __version__, "target": url, "mode": mode,
|
|
812
1093
|
"ownership": ownership, "requests_made": sess.count, "fingerprint": fp,
|
|
813
1094
|
"findings": findings, "summary": summary, "notes": sess.notes,
|
|
814
1095
|
# False when the request or time budget ran out before every check ran:
|
|
815
1096
|
# "nothing found" on an incomplete run is not a clean result.
|
|
816
1097
|
"complete": not incomplete,
|
|
817
|
-
"feed_version": getattr(feed, "feed_version", None)
|
|
1098
|
+
"feed_version": getattr(feed, "feed_version", None),
|
|
1099
|
+
"authenticated": authinfo}
|
|
1100
|
+
if auth is not None:
|
|
1101
|
+
result = scrub(result, auth.secret_values())
|
|
1102
|
+
return result
|
|
818
1103
|
|
|
819
1104
|
|
|
820
1105
|
# --------------------------------------------------------------------------- #
|
|
@@ -837,6 +1122,11 @@ def to_text(result):
|
|
|
837
1122
|
bits.append("frameworks: " + ", ".join(fp["frameworks"]))
|
|
838
1123
|
if bits:
|
|
839
1124
|
lines.append("fingerprint: " + "; ".join(bits))
|
|
1125
|
+
a = result.get("authenticated") or {}
|
|
1126
|
+
if a.get("used"):
|
|
1127
|
+
lines.append(f"signed-in check ({a.get('method')}): "
|
|
1128
|
+
+ ("signed in; pages: " + ", ".join(a.get("pages") or []) if a.get("signed_in")
|
|
1129
|
+
else "not signed in (see notes)"))
|
|
840
1130
|
lines.append("")
|
|
841
1131
|
# A version header such as "X-Powered-By: PHP/8.1.2" can match dozens of
|
|
842
1132
|
# engine CVEs, most only "possibly" (SAPI and OS are invisible from
|