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.
- {phpwarden-2.2.1 → phpwarden-2.3.0}/PKG-INFO +23 -11
- {phpwarden-2.2.1 → phpwarden-2.3.0}/README.md +22 -10
- {phpwarden-2.2.1 → phpwarden-2.3.0}/pyproject.toml +2 -2
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/__init__.py +1 -1
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/cli.py +103 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/code.py +122 -6
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/code_rules.py +73 -3
- phpwarden-2.3.0/src/phpwarden/sast/__init__.py +10 -0
- phpwarden-2.3.0/src/phpwarden/sast/ast.py +85 -0
- phpwarden-2.3.0/src/phpwarden/sast/authz.py +284 -0
- phpwarden-2.3.0/src/phpwarden/sast/callgraph.py +627 -0
- phpwarden-2.3.0/src/phpwarden/sast/engine.py +131 -0
- phpwarden-2.3.0/src/phpwarden/sast/lexer.py +354 -0
- phpwarden-2.3.0/src/phpwarden/sast/models/__init__.py +6 -0
- phpwarden-2.3.0/src/phpwarden/sast/models/laravel.py +115 -0
- phpwarden-2.3.0/src/phpwarden/sast/models/php_builtins.py +123 -0
- phpwarden-2.3.0/src/phpwarden/sast/models/symfony.py +77 -0
- phpwarden-2.3.0/src/phpwarden/sast/models/wordpress.py +28 -0
- phpwarden-2.3.0/src/phpwarden/sast/parser.py +1565 -0
- phpwarden-2.3.0/src/phpwarden/sast/project.py +633 -0
- phpwarden-2.3.0/src/phpwarden/sast/symbols.py +184 -0
- phpwarden-2.3.0/src/phpwarden/sast/taint.py +2437 -0
- phpwarden-2.3.0/src/phpwarden/sast/types.py +432 -0
- phpwarden-2.3.0/src/phpwarden/shield/__init__.py +1 -0
- phpwarden-2.3.0/src/phpwarden/shield/agent/phpwarden-shield.php +342 -0
- phpwarden-2.3.0/src/phpwarden/shield/library.py +403 -0
- phpwarden-2.3.0/src/phpwarden/shield/matcher.py +119 -0
- phpwarden-2.3.0/src/phpwarden/shield/policy.py +208 -0
- phpwarden-2.3.0/src/phpwarden/shield/report.py +73 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/PKG-INFO +23 -11
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/SOURCES.txt +30 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_code.py +14 -3
- phpwarden-2.3.0/tests/test_sast_layer5.py +82 -0
- phpwarden-2.3.0/tests/test_sast_models.py +85 -0
- phpwarden-2.3.0/tests/test_sast_parser.py +237 -0
- phpwarden-2.3.0/tests/test_sast_project.py +134 -0
- phpwarden-2.3.0/tests/test_sast_taint.py +223 -0
- phpwarden-2.3.0/tests/test_shield_cli.py +51 -0
- phpwarden-2.3.0/tests/test_shield_dashboard.py +159 -0
- phpwarden-2.3.0/tests/test_shield_runtime.py +225 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/LICENSE +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/setup.cfg +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/data/php-cve-feed.json +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/deps.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/detect.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/engine.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/env.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/feed.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/guard.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/integrity.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/probe.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/report.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/rules.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/sarif.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/verdict.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/version.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/vulndb.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden/wordpress.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/dependency_links.txt +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/entry_points.txt +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/src/phpwarden.egg-info/top_level.txt +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_account_deletion.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_accounts.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_advisories.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_agency.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_assess.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_assignment.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_audit.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_billing.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_business_metrics.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_cli_edges.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_connect_flow.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_dashboard_states.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_deps_and_rules.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_detection_validation.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_developer.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_discussion.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_e2e_readiness.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_email.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_engine.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_engine_feed.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_feed_license.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_feed_subscription.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_fleet.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_free_feed.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_google_oauth.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_guard_live.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_integrity.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_kev_alert.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_kev_gaps.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_livefeed.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_mentions.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_probe.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_probe_cli.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_probe_dashboard.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_reachability.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_report_proof.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_saas.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_saas_billing.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_sarif.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_server.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_settings.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_shield.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_shield_plan.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_slack.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_sso.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_store_connections.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_team.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_triage.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_waf_dryrun.py +0 -0
- {phpwarden-2.2.1 → phpwarden-2.3.0}/tests/test_webhook_idempotency.py +0 -0
- {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.
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|