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.
Files changed (120) hide show
  1. {phpwarden-2.4.0 → phpwarden-2.5.0}/PKG-INFO +30 -13
  2. {phpwarden-2.4.0 → phpwarden-2.5.0}/README.md +29 -12
  3. {phpwarden-2.4.0 → phpwarden-2.5.0}/pyproject.toml +1 -1
  4. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/__init__.py +1 -1
  5. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/cli.py +33 -1
  6. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/probe.py +301 -11
  7. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/dbflow.py +245 -24
  8. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/README.md +35 -48
  9. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/PKG-INFO +30 -13
  10. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/SOURCES.txt +1 -0
  11. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_account_deletion.py +5 -2
  12. phpwarden-2.5.0/tests/test_probe_auth.py +308 -0
  13. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_dbflow.py +26 -0
  14. {phpwarden-2.4.0 → phpwarden-2.5.0}/LICENSE +0 -0
  15. {phpwarden-2.4.0 → phpwarden-2.5.0}/setup.cfg +0 -0
  16. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/code.py +0 -0
  17. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/code_rules.py +0 -0
  18. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/data/php-cve-feed.json +0 -0
  19. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/deps.py +0 -0
  20. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/detect.py +0 -0
  21. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/engine.py +0 -0
  22. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/env.py +0 -0
  23. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/feed.py +0 -0
  24. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/guard.py +0 -0
  25. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/integrity.py +0 -0
  26. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/report.py +0 -0
  27. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/rules.py +0 -0
  28. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sarif.py +0 -0
  29. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/__init__.py +0 -0
  30. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/ast.py +0 -0
  31. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/authz.py +0 -0
  32. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/callgraph.py +0 -0
  33. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/engine.py +0 -0
  34. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/lexer.py +0 -0
  35. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/__init__.py +0 -0
  36. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/laravel.py +0 -0
  37. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/php_builtins.py +0 -0
  38. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/symfony.py +0 -0
  39. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/models/wordpress.py +0 -0
  40. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/parser.py +0 -0
  41. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/project.py +0 -0
  42. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/symbols.py +0 -0
  43. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/taint.py +0 -0
  44. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/sast/types.py +0 -0
  45. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/EDGE.md +0 -0
  46. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/__init__.py +0 -0
  47. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/agent/phpwarden-shield.php +0 -0
  48. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/library.py +0 -0
  49. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/matcher.py +0 -0
  50. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/policy.py +0 -0
  51. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/shield/report.py +0 -0
  52. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/thirdparty/__init__.py +0 -0
  53. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/thirdparty/trivy.py +0 -0
  54. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/verdict.py +0 -0
  55. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/version.py +0 -0
  56. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/vulndb.py +0 -0
  57. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden/wordpress.py +0 -0
  58. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/dependency_links.txt +0 -0
  59. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/entry_points.txt +0 -0
  60. {phpwarden-2.4.0 → phpwarden-2.5.0}/src/phpwarden.egg-info/top_level.txt +0 -0
  61. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_accounts.py +0 -0
  62. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_advisories.py +0 -0
  63. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_agency.py +0 -0
  64. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_assess.py +0 -0
  65. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_assignment.py +0 -0
  66. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_audit.py +0 -0
  67. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_billing.py +0 -0
  68. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_business_metrics.py +0 -0
  69. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_cli_edges.py +0 -0
  70. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_code.py +0 -0
  71. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_connect_flow.py +0 -0
  72. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_dashboard_states.py +0 -0
  73. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_deps_and_rules.py +0 -0
  74. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_detection_validation.py +0 -0
  75. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_developer.py +0 -0
  76. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_discussion.py +0 -0
  77. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_e2e_readiness.py +0 -0
  78. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_email.py +0 -0
  79. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_engine.py +0 -0
  80. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_engine_feed.py +0 -0
  81. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_feed_license.py +0 -0
  82. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_feed_subscription.py +0 -0
  83. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_fleet.py +0 -0
  84. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_free_feed.py +0 -0
  85. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_google_oauth.py +0 -0
  86. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_guard_live.py +0 -0
  87. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_integrity.py +0 -0
  88. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_kev_alert.py +0 -0
  89. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_kev_gaps.py +0 -0
  90. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_livefeed.py +0 -0
  91. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_mentions.py +0 -0
  92. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_probe.py +0 -0
  93. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_probe_cli.py +0 -0
  94. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_probe_dashboard.py +0 -0
  95. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_reachability.py +0 -0
  96. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_report_proof.py +0 -0
  97. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_saas.py +0 -0
  98. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_saas_billing.py +0 -0
  99. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sarif.py +0 -0
  100. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_layer5.py +0 -0
  101. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_models.py +0 -0
  102. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_parser.py +0 -0
  103. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_project.py +0 -0
  104. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sast_taint.py +0 -0
  105. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_server.py +0 -0
  106. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_settings.py +0 -0
  107. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield.py +0 -0
  108. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_cli.py +0 -0
  109. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_dashboard.py +0 -0
  110. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_plan.py +0 -0
  111. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_shield_runtime.py +0 -0
  112. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_slack.py +0 -0
  113. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_sso.py +0 -0
  114. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_store_connections.py +0 -0
  115. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_team.py +0 -0
  116. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_triage.py +0 -0
  117. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_trivy_import.py +0 -0
  118. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_waf_dryrun.py +0 -0
  119. {phpwarden-2.4.0 → phpwarden-2.5.0}/tests/test_webhook_idempotency.py +0 -0
  120. {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.4.0
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. On the same pinned apps it found more than Semgrep and Psalm with far
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.07 ms per request. Not an edge
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 in `integrations/vscode` (build and install
203
- the `.vsix` from that folder; not on the Marketplace yet) and PhpStorm External Tool steps in
204
- `integrations/phpstorm`.
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.4.0 # pin to a tag with `pre-commit autoupdate`
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 19,800 carry an
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. On the same pinned apps it found more than Semgrep and Psalm with far
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.07 ms per request. Not an edge
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 in `integrations/vscode` (build and install
181
- the `.vsix` from that folder; not on the Marketplace yet) and PhpStorm External Tool steps in
182
- `integrations/phpstorm`.
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.4.0 # pin to a tag with `pre-commit autoupdate`
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 19,800 carry an
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.4.0"
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.4.0"
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. Nothing is ever written to the target.
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
- conn.request(method, path, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
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
- def _one(self, url, method):
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
- return self.fetch(url, method=method, timeout=TIMEOUT, max_bytes=MAX_BYTES)
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
- return {"tool": "phpwarden-probe", "version": __version__, "target": url, "mode": mode,
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