phpwarden 2.2.1__tar.gz → 2.3.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 (112) hide show
  1. {phpwarden-2.2.1 → phpwarden-2.3.0}/PKG-INFO +23 -11
  2. {phpwarden-2.2.1 → phpwarden-2.3.0}/README.md +22 -10
  3. {phpwarden-2.2.1 → phpwarden-2.3.0}/pyproject.toml +2 -2
  4. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/__init__.py +1 -1
  5. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/cli.py +103 -0
  6. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/code.py +122 -6
  7. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/code_rules.py +73 -3
  8. phpwarden-2.3.0/src/phpwarden/sast/__init__.py +10 -0
  9. phpwarden-2.3.0/src/phpwarden/sast/ast.py +85 -0
  10. phpwarden-2.3.0/src/phpwarden/sast/authz.py +284 -0
  11. phpwarden-2.3.0/src/phpwarden/sast/callgraph.py +627 -0
  12. phpwarden-2.3.0/src/phpwarden/sast/engine.py +131 -0
  13. phpwarden-2.3.0/src/phpwarden/sast/lexer.py +354 -0
  14. phpwarden-2.3.0/src/phpwarden/sast/models/__init__.py +6 -0
  15. phpwarden-2.3.0/src/phpwarden/sast/models/laravel.py +115 -0
  16. phpwarden-2.3.0/src/phpwarden/sast/models/php_builtins.py +123 -0
  17. phpwarden-2.3.0/src/phpwarden/sast/models/symfony.py +77 -0
  18. phpwarden-2.3.0/src/phpwarden/sast/models/wordpress.py +28 -0
  19. phpwarden-2.3.0/src/phpwarden/sast/parser.py +1565 -0
  20. phpwarden-2.3.0/src/phpwarden/sast/project.py +633 -0
  21. phpwarden-2.3.0/src/phpwarden/sast/symbols.py +184 -0
  22. phpwarden-2.3.0/src/phpwarden/sast/taint.py +2437 -0
  23. phpwarden-2.3.0/src/phpwarden/sast/types.py +432 -0
  24. phpwarden-2.3.0/src/phpwarden/shield/__init__.py +1 -0
  25. phpwarden-2.3.0/src/phpwarden/shield/agent/phpwarden-shield.php +342 -0
  26. phpwarden-2.3.0/src/phpwarden/shield/library.py +403 -0
  27. phpwarden-2.3.0/src/phpwarden/shield/matcher.py +119 -0
  28. phpwarden-2.3.0/src/phpwarden/shield/policy.py +208 -0
  29. phpwarden-2.3.0/src/phpwarden/shield/report.py +73 -0
  30. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/PKG-INFO +23 -11
  31. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/SOURCES.txt +30 -0
  32. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_code.py +14 -3
  33. phpwarden-2.3.0/tests/test_sast_layer5.py +82 -0
  34. phpwarden-2.3.0/tests/test_sast_models.py +85 -0
  35. phpwarden-2.3.0/tests/test_sast_parser.py +237 -0
  36. phpwarden-2.3.0/tests/test_sast_project.py +134 -0
  37. phpwarden-2.3.0/tests/test_sast_taint.py +223 -0
  38. phpwarden-2.3.0/tests/test_shield_cli.py +51 -0
  39. phpwarden-2.3.0/tests/test_shield_dashboard.py +159 -0
  40. phpwarden-2.3.0/tests/test_shield_runtime.py +225 -0
  41. {phpwarden-2.2.1 → phpwarden-2.3.0}/LICENSE +0 -0
  42. {phpwarden-2.2.1 → phpwarden-2.3.0}/setup.cfg +0 -0
  43. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/data/php-cve-feed.json +0 -0
  44. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/deps.py +0 -0
  45. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/detect.py +0 -0
  46. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/engine.py +0 -0
  47. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/env.py +0 -0
  48. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/feed.py +0 -0
  49. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/guard.py +0 -0
  50. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/integrity.py +0 -0
  51. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/probe.py +0 -0
  52. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/report.py +0 -0
  53. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/rules.py +0 -0
  54. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/sarif.py +0 -0
  55. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/verdict.py +0 -0
  56. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/version.py +0 -0
  57. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/vulndb.py +0 -0
  58. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/wordpress.py +0 -0
  59. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/dependency_links.txt +0 -0
  60. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/entry_points.txt +0 -0
  61. {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/top_level.txt +0 -0
  62. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_account_deletion.py +0 -0
  63. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_accounts.py +0 -0
  64. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_advisories.py +0 -0
  65. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_agency.py +0 -0
  66. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_assess.py +0 -0
  67. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_assignment.py +0 -0
  68. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_audit.py +0 -0
  69. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_billing.py +0 -0
  70. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_business_metrics.py +0 -0
  71. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_cli_edges.py +0 -0
  72. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_connect_flow.py +0 -0
  73. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_dashboard_states.py +0 -0
  74. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_deps_and_rules.py +0 -0
  75. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_detection_validation.py +0 -0
  76. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_developer.py +0 -0
  77. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_discussion.py +0 -0
  78. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_e2e_readiness.py +0 -0
  79. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_email.py +0 -0
  80. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_engine.py +0 -0
  81. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_engine_feed.py +0 -0
  82. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_feed_license.py +0 -0
  83. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_feed_subscription.py +0 -0
  84. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_fleet.py +0 -0
  85. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_free_feed.py +0 -0
  86. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_google_oauth.py +0 -0
  87. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_guard_live.py +0 -0
  88. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_integrity.py +0 -0
  89. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_kev_alert.py +0 -0
  90. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_kev_gaps.py +0 -0
  91. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_livefeed.py +0 -0
  92. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_mentions.py +0 -0
  93. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_probe.py +0 -0
  94. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_probe_cli.py +0 -0
  95. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_probe_dashboard.py +0 -0
  96. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_reachability.py +0 -0
  97. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_report_proof.py +0 -0
  98. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_saas.py +0 -0
  99. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_saas_billing.py +0 -0
  100. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_sarif.py +0 -0
  101. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_server.py +0 -0
  102. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_settings.py +0 -0
  103. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_shield.py +0 -0
  104. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_shield_plan.py +0 -0
  105. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_slack.py +0 -0
  106. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_sso.py +0 -0
  107. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_store_connections.py +0 -0
  108. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_team.py +0 -0
  109. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_triage.py +0 -0
  110. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_waf_dryrun.py +0 -0
  111. {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_webhook_idempotency.py +0 -0
  112. {phpwarden-2.2.1 → phpwarden-2.3.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.2.1
3
+ Version: 2.3.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. Offline, zero dependencies.
5
5
  Author: oroma-alfred
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,8 @@ $ phpwarden deps composer.lock
59
59
  | Composer dependency CVEs | `deps` | Snyk, `composer audit` |
60
60
  | WordPress core, plugin and theme CVEs | `wordpress` | Patchstack, Wordfence |
61
61
  | WordPress file integrity (wordpress.org checksums, injected files) | `integrity` | Wordfence CLI malware-scan (partly) |
62
- | Your own PHP code (75 rules) | `code` | — (not a PHPStan/Psalm replacement) |
62
+ | Your own PHP code: whole-program, Laravel/Symfony/WordPress-aware (89 rules) | `code` | Snyk Code, Semgrep, Psalm taint |
63
+ | Exploits of the vulnerabilities this site has, blocked inside PHP | `shield` | the WAF rule you would have to write |
63
64
  | Live site from outside: exposed files, debug endpoints | `probe` | — |
64
65
  | One ranked verdict across all of the above | `assess` | — |
65
66
  | Exploitation signatures in logs | `detect` | SIEM content |
@@ -120,7 +121,8 @@ phpwarden rules --format nginx --out php-block.conf # PHP-specific WAF rules (
120
121
  phpwarden rules --format nginx --dry-run # the same rules, log-only: nothing is blocked
121
122
  phpwarden rules --replay /var/log/nginx/access.log # what the rules would have blocked in an existing log
122
123
  phpwarden probe https://example.com # live check of a site you own: exposed files, debug endpoints, versions vs the feed
123
- phpwarden code . # your own PHP: 75 high-signal rules, 74 on by default (injection, unserialize, WordPress handlers)
124
+ phpwarden code . # your own PHP, whole program: 89 rules (injection, auth, mass assignment, Blade/Twig, WordPress)
125
+ phpwarden shield policy . && phpwarden shield install # block exploits of what is installed here, inside PHP (log-only first)
124
126
  phpwarden assess . --url https://example.com --log access.log # one ranked verdict; --out / --baseline shows what is fixed and verified
125
127
  phpwarden report web01.json web02.json --out fleet.html # aggregate a fleet view
126
128
  phpwarden feed status # which feed is active, how many advisories
@@ -133,7 +135,7 @@ phpwarden feed update # pull a newer feed: free weekly snapshot,
133
135
  `probe`, `code`, `assess`, `--format sarif`, `rules --dry-run` / `--replay` and
134
136
  the no-key `feed update` are new in 2.2.0.
135
137
 
136
- Every command takes `--json`. Exit codes are shared across subcommands and
138
+ Every scanning command takes `--json` (`shield install` and `shield promote` do not). Exit codes are shared across subcommands and
137
139
  built for gating: `0` clean, `1` exposed / vulnerable / signature hits / tampered
138
140
  files / high or critical `code` and `probe` findings (`probe` also on any
139
141
  known-exploited finding) / anything to act on now or this week (`assess`),
@@ -142,7 +144,7 @@ error, `5` `--report-to` upload failed on an otherwise clean run, `64` usage
142
144
  error. Full table: `phpwarden --help`.
143
145
 
144
146
 
145
- ## New in 2.2.0
147
+ ## New in 2.3.0 and 2.2
146
148
 
147
149
  - **`phpwarden probe URL`**: a live check of a running site using GET and HEAD
148
150
  requests only. It looks for exposed files (`.env`, `.git`, backups), open debug
@@ -150,11 +152,21 @@ error. Full table: `phpwarden --help`.
150
152
  matches those against the feed. Active checks run only after you prove the
151
153
  site is yours (DNS TXT record, a file, or a meta tag; `--print-token` shows
152
154
  how). Without that it reads the homepage and stops.
153
- - **`phpwarden code PATH`**: 75 rules (74 on by default) for your own PHP code, following request
154
- data to `eval`, shell commands, SQL, `unserialize`, includes and redirects.
155
- It follows data across files (helper functions and classes elsewhere in the
156
- project, fixed includes, class properties); it is not a type checker like
157
- PHPStan or Psalm. `phpwarden code --list-rules` lists every rule.
155
+ - **`phpwarden code PATH`** (whole-program engine, default in 2.3.0): parses the
156
+ project, resolves namespaces, Composer autoloading, inheritance and types, and
157
+ follows request data across files to SQL, shell, `eval`, `unserialize`,
158
+ includes, redirects and HTTP clients, with a trace per finding (SARIF
159
+ `codeFlows`). Models Laravel, Symfony and WordPress: routes with no auth check,
160
+ mass assignment, raw Blade/Twig output, IDOR hints, CSRF exclusions, nonces.
161
+ Follows data stored in the session or WordPress options (second-order).
162
+ 89 rules. On the same pinned apps it found more than Semgrep and Psalm with far
163
+ fewer findings on clean framework code ([comparison](https://github.com/oroma-alfred/phpwarden/tree/main/bench/sast-compare-2026-11)).
164
+ Data that round-trips through the database is not followed.
165
+ - **`phpwarden shield`**: one PHP file loaded before your app that blocks exploit
166
+ attempts against the exploited vulnerabilities this site actually has, scoped
167
+ to their entry points. Logs until you promote rules (7-day burn-in, explicit
168
+ flag), fails open, has a kill switch, about 0.07 ms per request. Not an edge
169
+ WAF: no DDoS, bot or TLS handling.
158
170
  - **`phpwarden assess`**: one ranked list from every view of a site: installed
159
171
  versions, your code, the live site from outside, and the access log, with the
160
172
  reason for each item ("exploited, reachable, visible from outside, already
@@ -236,7 +248,7 @@ see [phpwarden/laravel](https://packagist.org/packages/phpwarden/laravel).
236
248
  ```yaml
237
249
  repos:
238
250
  - repo: https://github.com/oroma-alfred/phpwarden
239
- rev: v2.1.0 # pin to a tag with `pre-commit autoupdate`
251
+ rev: v2.3.0 # pin to a tag with `pre-commit autoupdate`
240
252
  hooks:
241
253
  - id: phpwarden-deps
242
254
  # args: ["--no-reachability"] # faster: skip the reachability pass
@@ -37,7 +37,8 @@ $ phpwarden deps composer.lock
37
37
  | Composer dependency CVEs | `deps` | Snyk, `composer audit` |
38
38
  | WordPress core, plugin and theme CVEs | `wordpress` | Patchstack, Wordfence |
39
39
  | WordPress file integrity (wordpress.org checksums, injected files) | `integrity` | Wordfence CLI malware-scan (partly) |
40
- | Your own PHP code (75 rules) | `code` | — (not a PHPStan/Psalm replacement) |
40
+ | Your own PHP code: whole-program, Laravel/Symfony/WordPress-aware (89 rules) | `code` | Snyk Code, Semgrep, Psalm taint |
41
+ | Exploits of the vulnerabilities this site has, blocked inside PHP | `shield` | the WAF rule you would have to write |
41
42
  | Live site from outside: exposed files, debug endpoints | `probe` | — |
42
43
  | One ranked verdict across all of the above | `assess` | — |
43
44
  | Exploitation signatures in logs | `detect` | SIEM content |
@@ -98,7 +99,8 @@ phpwarden rules --format nginx --out php-block.conf # PHP-specific WAF rules (
98
99
  phpwarden rules --format nginx --dry-run # the same rules, log-only: nothing is blocked
99
100
  phpwarden rules --replay /var/log/nginx/access.log # what the rules would have blocked in an existing log
100
101
  phpwarden probe https://example.com # live check of a site you own: exposed files, debug endpoints, versions vs the feed
101
- phpwarden code . # your own PHP: 75 high-signal rules, 74 on by default (injection, unserialize, WordPress handlers)
102
+ phpwarden code . # your own PHP, whole program: 89 rules (injection, auth, mass assignment, Blade/Twig, WordPress)
103
+ phpwarden shield policy . && phpwarden shield install # block exploits of what is installed here, inside PHP (log-only first)
102
104
  phpwarden assess . --url https://example.com --log access.log # one ranked verdict; --out / --baseline shows what is fixed and verified
103
105
  phpwarden report web01.json web02.json --out fleet.html # aggregate a fleet view
104
106
  phpwarden feed status # which feed is active, how many advisories
@@ -111,7 +113,7 @@ phpwarden feed update # pull a newer feed: free weekly snapshot,
111
113
  `probe`, `code`, `assess`, `--format sarif`, `rules --dry-run` / `--replay` and
112
114
  the no-key `feed update` are new in 2.2.0.
113
115
 
114
- Every command takes `--json`. Exit codes are shared across subcommands and
116
+ Every scanning command takes `--json` (`shield install` and `shield promote` do not). Exit codes are shared across subcommands and
115
117
  built for gating: `0` clean, `1` exposed / vulnerable / signature hits / tampered
116
118
  files / high or critical `code` and `probe` findings (`probe` also on any
117
119
  known-exploited finding) / anything to act on now or this week (`assess`),
@@ -120,7 +122,7 @@ error, `5` `--report-to` upload failed on an otherwise clean run, `64` usage
120
122
  error. Full table: `phpwarden --help`.
121
123
 
122
124
 
123
- ## New in 2.2.0
125
+ ## New in 2.3.0 and 2.2
124
126
 
125
127
  - **`phpwarden probe URL`**: a live check of a running site using GET and HEAD
126
128
  requests only. It looks for exposed files (`.env`, `.git`, backups), open debug
@@ -128,11 +130,21 @@ error. Full table: `phpwarden --help`.
128
130
  matches those against the feed. Active checks run only after you prove the
129
131
  site is yours (DNS TXT record, a file, or a meta tag; `--print-token` shows
130
132
  how). Without that it reads the homepage and stops.
131
- - **`phpwarden code PATH`**: 75 rules (74 on by default) for your own PHP code, following request
132
- data to `eval`, shell commands, SQL, `unserialize`, includes and redirects.
133
- It follows data across files (helper functions and classes elsewhere in the
134
- project, fixed includes, class properties); it is not a type checker like
135
- PHPStan or Psalm. `phpwarden code --list-rules` lists every rule.
133
+ - **`phpwarden code PATH`** (whole-program engine, default in 2.3.0): parses the
134
+ project, resolves namespaces, Composer autoloading, inheritance and types, and
135
+ follows request data across files to SQL, shell, `eval`, `unserialize`,
136
+ includes, redirects and HTTP clients, with a trace per finding (SARIF
137
+ `codeFlows`). Models Laravel, Symfony and WordPress: routes with no auth check,
138
+ mass assignment, raw Blade/Twig output, IDOR hints, CSRF exclusions, nonces.
139
+ Follows data stored in the session or WordPress options (second-order).
140
+ 89 rules. On the same pinned apps it found more than Semgrep and Psalm with far
141
+ fewer findings on clean framework code ([comparison](https://github.com/oroma-alfred/phpwarden/tree/main/bench/sast-compare-2026-11)).
142
+ Data that round-trips through the database is not followed.
143
+ - **`phpwarden shield`**: one PHP file loaded before your app that blocks exploit
144
+ attempts against the exploited vulnerabilities this site actually has, scoped
145
+ to their entry points. Logs until you promote rules (7-day burn-in, explicit
146
+ flag), fails open, has a kill switch, about 0.07 ms per request. Not an edge
147
+ WAF: no DDoS, bot or TLS handling.
136
148
  - **`phpwarden assess`**: one ranked list from every view of a site: installed
137
149
  versions, your code, the live site from outside, and the access log, with the
138
150
  reason for each item ("exploited, reachable, visible from outside, already
@@ -214,7 +226,7 @@ see [phpwarden/laravel](https://packagist.org/packages/phpwarden/laravel).
214
226
  ```yaml
215
227
  repos:
216
228
  - repo: https://github.com/oroma-alfred/phpwarden
217
- rev: v2.1.0 # pin to a tag with `pre-commit autoupdate`
229
+ rev: v2.3.0 # pin to a tag with `pre-commit autoupdate`
218
230
  hooks:
219
231
  - id: phpwarden-deps
220
232
  # args: ["--no-reachability"] # faster: skip the reachability pass
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phpwarden"
7
- version = "2.2.1"
7
+ version = "2.3.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. Offline, zero dependencies."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -39,4 +39,4 @@ package-dir = { "" = "src" }
39
39
  where = ["src"]
40
40
 
41
41
  [tool.setuptools.package-data]
42
- phpwarden = ["data/*.json"]
42
+ phpwarden = ["data/*.json", "shield/agent/*.php"]
@@ -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.2.1"
9
+ __version__ = "2.3.0"
@@ -13,6 +13,7 @@
13
13
  phpwarden probe URL safe live HTTP check of a site you own
14
14
  phpwarden code PATH scan your own PHP code for high-signal security bugs
15
15
  phpwarden assess PATH --url U --log L one ranked verdict from every view
16
+ phpwarden shield policy PATH protection inside PHP for the exploited vulns installed here
16
17
 
17
18
  Exit codes (shared by every subcommand; usable as a CI gate):
18
19
  0 nothing applies / clean
@@ -562,6 +563,85 @@ def _assess(args):
562
563
  return vd.exit_code(res)
563
564
 
564
565
 
566
+ def _shield(args):
567
+ from pathlib import Path as _P
568
+ from phpwarden.shield import policy as sp, report as sr
569
+ if args.shield_cmd == "policy":
570
+ root = _P(args.path)
571
+ if not root.is_dir():
572
+ sys.stderr.write(f"error: not a directory: {args.path}\n")
573
+ return 4
574
+ pol = sp.build_policy_for_path(str(root), FeedManager().current(), php_version=args.php_version,
575
+ php_sapi=args.php_sapi, log_path=args.log)
576
+ out = args.out or "phpwarden-shield.policy.json"
577
+ j, php = sp.write_policy(pol, out)
578
+ if args.json:
579
+ print(json.dumps(pol, indent=2))
580
+ return 0
581
+ print(f"wrote {j} and {php} ({len(pol['rules'])} rule(s), mode: log)")
582
+ for r in pol["rules"]:
583
+ print(f" + {r['id']:<34} {r.get('cve') or '':<16} {r['component']}: {r['why']}")
584
+ if not pol["rules"]:
585
+ print(" No exploited vulnerability with a known request shape is installed here; the policy only has")
586
+ print(" the generic checks, if any applied. Nothing to protect against beyond those.")
587
+ print("\nNext: `phpwarden shield install` shows how to load the agent. It only logs until you promote rules.")
588
+ return 0
589
+ if args.shield_cmd == "install":
590
+ agent = _P(__file__).resolve().parent / "shield" / "agent" / "phpwarden-shield.php"
591
+ dest = _P(args.dest) if args.dest else None
592
+ if dest:
593
+ try:
594
+ dest.mkdir(parents=True, exist_ok=True)
595
+ (dest / "phpwarden-shield.php").write_bytes(agent.read_bytes())
596
+ except OSError as e:
597
+ sys.stderr.write(f"error: {e}\n")
598
+ return 4
599
+ where = (dest / "phpwarden-shield.php").resolve()
600
+ print(f"copied the agent to {where}")
601
+ else:
602
+ where = "/path/to/phpwarden-shield.php"
603
+ print(f"The agent ships with phpwarden at {agent}. Copy it next to your site, or rerun with --dest DIR.")
604
+ print(f"""
605
+ Load it before your application (pick one; nothing is changed for you):
606
+ php.ini or .user.ini: auto_prepend_file={where}
607
+ WordPress: add require '{where}'; as the first line of wp-config.php
608
+ Laravel / other apps: add require '{where}'; at the top of public/index.php
609
+
610
+ Put phpwarden-shield.policy.php (from `phpwarden shield policy`) next to the agent, or set
611
+ PHPWARDEN_SHIELD_POLICY to its path. It starts in log-only mode and fails open on any error.
612
+ Turn it off instantly: create a file named phpwarden-shield.off next to the agent, or set
613
+ PHPWARDEN_SHIELD=off.""")
614
+ return 0
615
+ if args.shield_cmd == "report":
616
+ try:
617
+ with open(args.log, errors="replace") as fh:
618
+ summ = sr.summarize(fh)
619
+ except OSError as e:
620
+ sys.stderr.write(f"error: {e}\n")
621
+ return 4
622
+ print(json.dumps(summ, indent=2, default=list) if args.json else sr.to_text(summ))
623
+ return 0
624
+ if args.shield_cmd == "promote":
625
+ try:
626
+ pol = json.loads(_P(args.policy).read_text())
627
+ with open(args.log, errors="replace") as fh:
628
+ summ = sr.summarize(fh)
629
+ except (OSError, ValueError) as e:
630
+ sys.stderr.write(f"error: {e}\n")
631
+ return 4
632
+ pol, rep = sp.promote(pol, summ, operator_ack=args.i_understand_this_blocks_requests,
633
+ rule_ids=set(args.rule) if args.rule else None)
634
+ for rid in rep["promoted"]:
635
+ print(f" BLOCK {rid}")
636
+ for r in rep["refused"]:
637
+ print(f" still logging {r['id']}: {r['reason']}")
638
+ if rep["promoted"]:
639
+ sp.write_policy(pol, args.policy)
640
+ print(f"\nupdated {args.policy} (and its .php twin). Promoted rules now return 403.")
641
+ return 0
642
+ return EX_USAGE
643
+
644
+
565
645
  def _integrity(args):
566
646
  from phpwarden import integrity as integ
567
647
  res = integ.check(args.path, locale=args.locale, plugins=not args.no_plugins)
@@ -674,6 +754,29 @@ def main(argv=None):
674
754
  _report_args(wpc)
675
755
  wpc.set_defaults(func=_wordpress)
676
756
 
757
+ sh = sub.add_parser("shield", help="protection inside PHP: block exploits of the vulnerabilities this site "
758
+ "actually has (log-only until you promote rules)")
759
+ shs = sh.add_subparsers(dest="shield_cmd", required=True, parser_class=_Parser)
760
+ shp = shs.add_parser("policy", help="build a policy for the exploited vulnerabilities installed at PATH")
761
+ shp.add_argument("path", nargs="?", default=".", help="project or WordPress root (default: .)")
762
+ shp.add_argument("--out", help="policy file (default phpwarden-shield.policy.json; a .php twin is written too)")
763
+ shp.add_argument("--php-version", help="the production PHP version, for engine CVE rules")
764
+ shp.add_argument("--php-sapi", choices=["fpm", "cgi", "cli"], help="the production SAPI")
765
+ shp.add_argument("--log", help="where the agent should write its log (default: next to the agent)")
766
+ shp.add_argument("--json", action="store_true", help="print the policy as JSON")
767
+ shi = shs.add_parser("install", help="show how to load the agent (and copy it with --dest)")
768
+ shi.add_argument("--dest", metavar="DIR", help="copy phpwarden-shield.php into this folder")
769
+ shr = shs.add_parser("report", help="summarise the agent's log")
770
+ shr.add_argument("log", help="phpwarden-shield.log")
771
+ shr.add_argument("--json", action="store_true", help="machine-readable JSON output")
772
+ shm = shs.add_parser("promote", help="switch rules from logging to blocking after a clean burn-in")
773
+ shm.add_argument("policy", help="the policy JSON to update")
774
+ shm.add_argument("--log", required=True, help="the agent log covering the burn-in")
775
+ shm.add_argument("--rule", action="append", metavar="ID", help="only this rule (repeatable)")
776
+ shm.add_argument("--i-understand-this-blocks-requests", action="store_true",
777
+ help="required: promoted rules return 403 to matching requests")
778
+ sh.set_defaults(func=_shield)
779
+
677
780
  asx = sub.add_parser("assess", help="one ranked verdict from every view: installed versions, your code, "
678
781
  "the live site from outside, and attacks in the access log")
679
782
  asx.add_argument("path", nargs="?", help="project or site root (composer.lock, WordPress, PHP code)")
@@ -1099,8 +1099,15 @@ class FileScanner:
1099
1099
  ids = {t[1].lower() for t in rng if t[0] == "id"}
1100
1100
  if ids & UPLOAD_CHECKS:
1101
1101
  return True
1102
+ # Validation rules that check the real file (Laravel "mimes:" / "image").
1103
+ if any(t[0] in ("str", "dstr") and re.search(r"(?i)(?:^|\|)(?:image(?:\||$)|mimes:|mimetypes:|extensions:)", t[1] or "")
1104
+ for t in rng):
1105
+ return True
1106
+ # An extension taken from the file name and compared with a list is a check.
1107
+ # The client-supplied MIME type ($_FILES[..]['type'], getClientMimeType())
1108
+ # is NOT: the browser, or an attacker, chooses it.
1102
1109
  ext = "pathinfo_extension" in ids or "strrchr" in ids or "strrpos" in ids or \
1103
- ("explode" in ids and "end" in ids) or any(t[0] in ("str", "dstr") and t[1] == "type" for t in rng)
1110
+ ("explode" in ids and "end" in ids)
1104
1111
  cmp_ = "in_array" in ids or "preg_match" in ids or "switch" in ids or "match" in ids or \
1105
1112
  any(t[0] == "op" and t[1] in ("==", "===", "!=", "!==") for t in rng)
1106
1113
  return ext and cmp_
@@ -2061,7 +2068,104 @@ def _build_ext(sums, resolve):
2061
2068
  "exports": exports, "resolve": resolve}, seeds
2062
2069
 
2063
2070
 
2064
- def scan_project(path, *, include_tests=False, lock_findings=None, rules=None, all_rules=False):
2071
+ DEFAULT_ENGINE = os.environ.get("PHPWARDEN_CODE_ENGINE", "auto")
2072
+
2073
+
2074
+ def _start_token_worker(path, tok_kw):
2075
+ """Run the token pass in a separate Python process (plain subprocess: it never
2076
+ re-runs the caller's script, unlike multiprocessing's spawn). None if the
2077
+ arguments are not JSON-able or the process cannot start."""
2078
+ import subprocess
2079
+ import sys as _sys
2080
+ try:
2081
+ payload = json.dumps({"path": os.path.abspath(path), "kw": tok_kw})
2082
+ except (TypeError, ValueError):
2083
+ return None
2084
+ pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
2085
+ env = dict(os.environ, PYTHONPATH=pkg_parent + os.pathsep + os.environ.get("PYTHONPATH", ""))
2086
+ code_ = ("import json,sys;from phpwarden import code as c;a=json.loads(sys.stdin.read());"
2087
+ "sys.stdout.write(json.dumps(c._scan_tokens(a['path'],**a['kw'])))")
2088
+ try:
2089
+ p = subprocess.Popen([_sys.executable, "-c", code_], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
2090
+ stderr=subprocess.DEVNULL, env=env)
2091
+ p.stdin.write(payload.encode())
2092
+ p.stdin.close()
2093
+ return p
2094
+ except OSError:
2095
+ return None
2096
+
2097
+
2098
+ def _finish_token_worker(p):
2099
+ try:
2100
+ out = p.stdout.read()
2101
+ if p.wait() != 0:
2102
+ return None
2103
+ return json.loads(out)
2104
+ except (OSError, ValueError):
2105
+ return None
2106
+
2107
+
2108
+ def _count_php(root, include_tests, limit):
2109
+ n = 0
2110
+ for dirpath, dirnames, filenames in os.walk(root):
2111
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and (include_tests or d.lower() not in TEST_DIRS)]
2112
+ n += sum(1 for f in filenames if f.endswith(PHP_EXTS))
2113
+ if n >= limit:
2114
+ break
2115
+ return n
2116
+
2117
+
2118
+ def scan_project(path, *, include_tests=False, lock_findings=None, rules=None, all_rules=False, engine=None):
2119
+ """engine: "tokens" (the token engine), "ast" (the whole-program AST taint
2120
+ engine in phpwarden.sast for taint rules, token engine for pattern rules) or
2121
+ "auto" (like "ast", but files the parser could not read cleanly keep their
2122
+ token-engine taint findings). Default: DEFAULT_ENGINE."""
2123
+ engine = engine or DEFAULT_ENGINE
2124
+ # With the AST engine deciding the taint rules, the token engine only needs
2125
+ # its per-file pass (pattern rules, and taint for files the parser could not
2126
+ # read in "auto"); its project-wide rounds would be thrown away.
2127
+ tok_kw = dict(include_tests=include_tests, lock_findings=lock_findings, rules=rules,
2128
+ all_rules=all_rules, rounds=(engine == "tokens"))
2129
+ if engine == "tokens" or not os.path.isdir(os.path.abspath(path)):
2130
+ return _scan_tokens(path, **tok_kw)
2131
+ from phpwarden.sast import engine as _ast
2132
+ enabled = set(rules) if rules else {r["id"] for r in RULES if all_rules or r.get("default", True)}
2133
+ # The token pass and the AST analysis are independent: on a large project run
2134
+ # the token pass in a second process while this one parses (its result is a
2135
+ # small dict). Small projects, or no usable process pool, run it inline.
2136
+ proc = None
2137
+ if _count_php(path, include_tests, 400) >= 400:
2138
+ proc = _start_token_worker(path, tok_kw)
2139
+ import gc as _gc
2140
+ _gc_was = _gc.isenabled()
2141
+ _gc.disable() # millions of long-lived AST nodes: generational GC only re-scans them
2142
+ try:
2143
+ found, handled, _p = _ast.analyse(path, include_tests=include_tests, enabled=enabled)
2144
+ except Exception: # noqa: BLE001 - never lose the scan to the new engine
2145
+ found = None
2146
+ finally:
2147
+ _p = None
2148
+ if _gc_was:
2149
+ _gc.enable()
2150
+ res = _finish_token_worker(proc) if proc is not None else None
2151
+ if res is None:
2152
+ res = _scan_tokens(path, **tok_kw)
2153
+ if found is None:
2154
+ return res
2155
+ keep = [f for f in res["findings"]
2156
+ if f["rule"] not in _ast.TAINT_RULES or (engine == "auto" and f["file"] not in handled)]
2157
+ have = {(f["rule"], f["file"], f["line"]) for f in keep}
2158
+ keep += [f for f in found if (f["rule"], f["file"], f["line"]) not in have]
2159
+ keep.sort(key=lambda f: (SEV_RANK.get(f["severity"], 9), f["file"], f["line"], f["column"], f["rule"]))
2160
+ res["findings"] = keep
2161
+ res["summary"] = {s: 0 for s in SEVERITIES}
2162
+ for f in keep:
2163
+ res["summary"][f["severity"]] = res["summary"].get(f["severity"], 0) + 1
2164
+ res["engine"] = engine
2165
+ return res
2166
+
2167
+
2168
+ def _scan_tokens(path, *, include_tests=False, lock_findings=None, rules=None, all_rules=False, rounds=True):
2065
2169
  """rules: only these rule ids. all_rules: also run the opt-in rules that are
2066
2170
  off by default because they are noisy (see list_rules(): "default": false).
2067
2171
 
@@ -2103,7 +2207,7 @@ def scan_project(path, *, include_tests=False, lock_findings=None, rules=None, a
2103
2207
  # project-wide rounds
2104
2208
  prev_ext = {"wrap": {}, "tret": {}, "retp": {}, "uniq": set()}
2105
2209
  prev_exports, prev_seeds = {}, {}
2106
- for _round in range(MAX_ROUNDS if len(sums) > 1 else 0):
2210
+ for _round in range(MAX_ROUNDS if len(sums) > 1 and rounds else 0):
2107
2211
  ext, seeds = _build_ext(sums, resolve)
2108
2212
  changed = set()
2109
2213
  # pass-through summaries (retp) are many and only matter one level deep
@@ -2204,8 +2308,11 @@ def scan_project(path, *, include_tests=False, lock_findings=None, rules=None, a
2204
2308
  "findings": findings, "summary": summary}
2205
2309
 
2206
2310
 
2207
- def list_rules():
2208
- return [dict(r) for r in RULES]
2311
+ def list_rules(engine=None):
2312
+ """Rules that run with the given engine (default: DEFAULT_ENGINE); the AST-only
2313
+ rules are listed with engine="ast"/"auto"."""
2314
+ engine = engine or DEFAULT_ENGINE
2315
+ return [dict(r) for r in RULES if engine in ("ast", "auto") or r.get("engine", "any") != "ast"]
2209
2316
 
2210
2317
 
2211
2318
  def exit_code(result):
@@ -2237,7 +2344,10 @@ _SEC_SCORE = {"critical": "9.5", "high": "8.0", "medium": "5.5", "low": "3.0"}
2237
2344
  def to_sarif(result):
2238
2345
  rules = []
2239
2346
  index = {}
2240
- for i, r in enumerate(RULES):
2347
+ eng = result.get("engine", DEFAULT_ENGINE)
2348
+ used = {f.get("rule") for f in result.get("findings", [])}
2349
+ listed = [r for r in RULES if eng in ("ast", "auto") or r.get("engine", "any") != "ast" or r["id"] in used]
2350
+ for i, r in enumerate(listed):
2241
2351
  index[r["id"]] = i
2242
2352
  rules.append({"id": r["id"], "name": r["id"].replace("-", "_"),
2243
2353
  "shortDescription": {"text": r["title"]},
@@ -2256,6 +2366,12 @@ def to_sarif(result):
2256
2366
  "region": {"startLine": f["line"], "startColumn": f["column"],
2257
2367
  "snippet": {"text": f["snippet"]}}}}],
2258
2368
  "properties": {"severity": f["severity"], "cwe": f["cwe"], "context": f.get("context") or []}})
2369
+ if f.get("trace"):
2370
+ results[-1]["codeFlows"] = [{"threadFlows": [{"locations": [
2371
+ {"location": {"physicalLocation": {"artifactLocation": {"uri": h["file"], "uriBaseId": "%SRCROOT%"},
2372
+ "region": {"startLine": h["line"],
2373
+ "startColumn": max(1, h.get("column") or 1)}},
2374
+ "message": {"text": h["note"]}}} for h in f["trace"]]}]}]
2259
2375
  return {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0",
2260
2376
  "runs": [{"tool": {"driver": {"name": "phpwarden-code", "version": result["version"],
2261
2377
  "informationUri": "https://phpwarden.io", "rules": rules}},
@@ -15,10 +15,11 @@ SEVERITIES = ("critical", "high", "medium", "low")
15
15
  RULES = []
16
16
 
17
17
 
18
- def _r(rid, cat, sev, cwe, title, desc, fix, default=True):
19
- """default=False: an opt-in rule, run only when selected by id or with all_rules."""
18
+ def _r(rid, cat, sev, cwe, title, desc, fix, default=True, engine="any"):
19
+ """default=False: an opt-in rule, run only when selected by id or with all_rules.
20
+ engine="ast": the rule needs the whole-program engine (engine="ast"/"auto")."""
20
21
  RULES.append({"id": rid, "category": cat, "severity": sev, "cwe": cwe,
21
- "title": title, "description": desc, "fix": fix, "default": default})
22
+ "title": title, "description": desc, "fix": fix, "default": default, "engine": engine})
22
23
 
23
24
 
24
25
  # --- code execution ---------------------------------------------------------
@@ -280,6 +281,36 @@ _r("xxe-xmlreader-subst", C, "high", "CWE-611", "XMLReader entity substitution e
280
281
  "XMLReader::SUBST_ENTITIES set to true enables XXE.",
281
282
  "Do not enable SUBST_ENTITIES.")
282
283
 
284
+ # --- found by the AST engine (whole-program taint + framework models) --------
285
+ C = "framework"
286
+ _r("mass-assignment", C, "high", "CWE-915", "Whole request mass-assigned to an Eloquent model",
287
+ "The complete request ($request->all(), except(), input()) is passed to create()/fill()/update() on a model "
288
+ "with $guarded = [] (or through forceFill/forceCreate), so a request can set any column, e.g. is_admin.",
289
+ "Pass $request->validated() or $request->only([...]) with an explicit list, and declare $fillable.", engine="ast")
290
+ _r("blade-raw", "xss", "medium", "CWE-79", "Request data printed unescaped in a Blade template",
291
+ "A value that comes from the request reaches {!! ... !!} in a Blade view, which prints it without escaping.",
292
+ "Use {{ ... }} (escaped), or sanitize with clean()/strip_tags before using {!! !!}.", engine="ast")
293
+ _r("twig-raw", "xss", "medium", "CWE-79", "Request data printed with |raw in a Twig template",
294
+ "A value that comes from the request is printed with the |raw filter or inside {% autoescape false %}.",
295
+ "Remove |raw, or sanitize the value (e.g. with an HTML sanitizer) before rendering.", engine="ast")
296
+ _r("xss-response", "xss", "medium", "CWE-79", "Request data returned as an HTML response body",
297
+ "Request data is the body of a Response with the default text/html content type.",
298
+ "Escape the value, return JSON (JsonResponse/response()->json()), or set a non-HTML Content-Type.", engine="ast")
299
+ _r("ssrf-http-client", "ssrf", "high", "CWE-918", "User-controlled URL in an HTTP client request",
300
+ "A request value is used as the URL of a Guzzle / Symfony HttpClient / PSR-18 request (server-side request forgery).",
301
+ "Allow-list hosts and schemes before requesting a URL that comes from the request.", engine="ast")
302
+ _r("cmd-process", "command-injection", "critical", "CWE-78", "User input in a shell command line (Process)",
303
+ "Request data is part of a command string given to Process::fromShellCommandline(), new Process(string) or "
304
+ "Laravel's Process::run(), which runs it through a shell.",
305
+ "Pass the command as an array of arguments (new Process(['cmd', $arg])) and validate each value.", engine="ast")
306
+ _r("mail-header-injection", "injection", "medium", "CWE-93", "User input in mail headers",
307
+ "Request data is used in the headers argument of mail()/wp_mail(); a newline lets an attacker add headers.",
308
+ "Never build mail headers from request data; strip CR/LF and validate addresses.", engine="ast")
309
+ _r("wp-option-key-tainted", "wordpress", "high", "CWE-15", "Request data chooses which option or meta key is written",
310
+ "update_option()/update_user_meta() and similar are called with a key taken from the request, so a request can "
311
+ "overwrite any setting (e.g. users_can_register, default_role, wp_capabilities).",
312
+ "Map allowed keys from a fixed list; never use a request value as the option or meta name.", engine="ast")
313
+
283
314
  RULES_BY_ID = {r["id"]: r for r in RULES}
284
315
 
285
316
  # ---------------------------------------------------------------------------
@@ -367,3 +398,42 @@ GADGET_PACKAGES = [
367
398
  "cakephp/cakephp", "thinkphp/", "topthink/framework", "drupal/core",
368
399
  "phpseclib/phpseclib", "pear/archive_tar", "wordpress/",
369
400
  ]
401
+
402
+
403
+ # --- layer 5 (whole-program engine) -------------------------------------------
404
+ C = "second-order"
405
+ _r("second-order-injection", C, "high", "CWE-74", "Stored request data used later in a sink (second-order)",
406
+ "Request data is saved under a fixed option or meta key and read back elsewhere, where it reaches a SQL query, "
407
+ "HTML output, an include or another sink without being sanitised for that use.",
408
+ "Sanitise when the value is used, not only when it is stored: prepare SQL, escape output, whitelist paths.",
409
+ engine="ast")
410
+ C = "authorization"
411
+ _r("laravel-route-no-auth", C, "medium", "CWE-862", "State-changing route with no authentication or authorization",
412
+ "A POST/PUT/PATCH/DELETE route reaches a database write, but neither the route (or its group) has auth "
413
+ "middleware nor does the controller call authorize() or a Gate.",
414
+ "Add ->middleware('auth') (or 'can:...') to the route or group, or call $this->authorize() in the controller.",
415
+ engine="ast")
416
+ _r("symfony-route-no-auth", C, "medium", "CWE-862", "Controller action writes entities with no access check",
417
+ "A #[Route] action persists or removes entities without #[IsGranted] or denyAccessUnlessGranted().",
418
+ "Add #[IsGranted('ROLE_...')] or call $this->denyAccessUnlessGranted(); or cover the path in access_control.",
419
+ engine="ast")
420
+ _r("laravel-idor-hint", C, "medium", "CWE-639", "Record from a route parameter changed without an ownership check",
421
+ "An authenticated route loads a model from a route parameter and updates or deletes it, with no policy, Gate "
422
+ "or ownership comparison, so one user may change another user's record (IDOR).",
423
+ "Authorize with a policy ($this->authorize('update', $post)) or scope the query to the current user.",
424
+ engine="ast")
425
+ C = "csrf"
426
+ _r("laravel-csrf-excluded", C, "medium", "CWE-352", "State-changing route excluded from CSRF protection",
427
+ "A URI in VerifyCsrfToken::$except (or validateCsrfTokens(except: ...)) matches a POST/PUT/PATCH/DELETE web "
428
+ "route, so another site can submit it on behalf of a signed-in user.",
429
+ "Remove it from the exception list, or verify the caller another way (a webhook signature).",
430
+ engine="ast")
431
+ _r("wp-admin-post-no-nonce", C, "medium", "CWE-352", "admin-post handler changes state without a nonce check",
432
+ "An admin_post_ action handler writes options, posts or the database without check_admin_referer() or "
433
+ "wp_verify_nonce(), so another site can trigger it for a logged-in administrator.",
434
+ "Add a nonce to the form and call check_admin_referer('action') at the top of the handler.",
435
+ engine="ast")
436
+
437
+
438
+ # rules appended after the table above (Stage C) must be indexed too
439
+ RULES_BY_ID.update({r["id"]: r for r in RULES})
@@ -0,0 +1,10 @@
1
+ """phpwarden SAST engine: PHP 8.3 parser (layer 1) and whole-program model (layer 2).
2
+
3
+ from phpwarden.sast import parse, Project
4
+ res = parse(open("x.php", encoding="latin-1").read(), "x.php")
5
+ proj = Project.load("/path/to/app")
6
+
7
+ See README.md in this directory for the data model.
8
+ """
9
+ from .parser import parse, parse_file, ParseError, ParseResult # noqa: F401
10
+ from .ast import Node, walk, find, dump # noqa: F401