The phpshield command line.
Find and fix PHP security exposure from your own shell. One file, one dependency-free Python standard library, no account. It inspects the machine it runs on and the paths you give it, and sends nothing about them anywhere.
Vulnerability data comes from php.net (generated 2026-10-22T19:46:34.867Z). The CLI is phpshield 1.0.0.
Install
PHPShield needs Python 3.8 or newer and nothing else: no pip packages, no compiler, no network for installation. Pick whichever path fits the box you are triaging.
Zero install (recommended for incident response)
Download the single-file application and run it with the Python already on the host. The .pyz is a standard zipapp that bundles the whole package and its vulnerability database.
curl -LO https://phpshield.live/downloads/phpshield.pyz python3 phpshield.pyz scan
Every subcommand works the same way: python3 phpshield.pyz version 8.3.12, python3 phpshield.pyz fix, and so on. Throughout these docs the command is written as phpshield; substitute python3 phpshield.pyz if you are running the bundle.
One-liner triage with quickcheck.sh
For a 3am first look, quickcheck.sh is POSIX shell with no dependencies. It only reads php -v for every PHP binary on PATH and tells you whether each is affected by PHP2Shell. It does not inspect configuration, dependencies, code or running workers; use the full scan for that.
curl -LsS https://phpshield.live/downloads/quickcheck.sh | sh
When it finds an affected binary it exits 1; an end-of-life branch exits 1 with a migration note; no PHP on PATH exits 0 with a reminder that a web server may still bundle its own PHP via FPM or mod_php.
A real command on PATH
phpshield.pyz starts with a #!/usr/bin/env python3 line, so it runs as a command once it is executable. Copy it onto PATH under the name phpshield:
sudo install -m 755 phpshield.pyz /usr/local/bin/phpshield phpshield --version
Quickstart
Three commands cover most of a response: check one version string, scan a tree, and get the fix steps for this host.
1. Check a single version
phpshield version takes any version string (8.3.12, the first line of php -v, or an X-Powered-By header value) and prints a verdict:
$ phpshield version 8.3.12
PHP 8.3.12 CRITICAL
PHP 8.3.12 is affected by a critical, actively exploited
vulnerability. Upgrade to 8.3.36 now.
Advisories:
CVE-2026-109618 PHP2Shell critical [fixed in 8.3.36] [exploited in the wild]
CVE-2026-109619 PHP2Shell critical [fixed in 8.3.36] [exploited in the wild]
Missing security releases on branch 8.3: 8.3.14, 8.3.19, 8.3.23, ... 8.3.36
Upgrade to: 8.3.36
Branches in active support: 8.5 -> 8.5.12 8.4 -> 8.4.27
data: php.net · generated 2026-10-22T19:46:34.867Z2. Scan a webroot
Point scan at a path (it defaults to the current directory). With no flags it also inspects the host's PHP binaries, /etc configuration and running workers; add --no-system to scan only the files you name. Output is grouped by category and coloured on a TTY.
phpshield 1.0.0 (data: php.net, generated 2026-10-22T19:46:34.867Z)
scanned: ./example.com
findings: 2 critical, 2 high, 1 low, 1 info
== Indicators of compromise (2) ==
[CRITICAL] COMPROMISE-EXEC-SUPERGLOBAL Command or code executed directly from request input
example.com/public/up.php:1
evidence: <?php @eval($_POST["x"]);
fix: Treat this host as compromised. Isolate it, preserve the file ...
== File integrity (2) ==
[CRITICAL] INT-ENV-EXPOSED Environment file served from a public web root
example.com/public/.env
...
RESULT: findings at or above high. (exit 1)The header line gives the counts by severity; each finding carries a stable rule id, a location, masked evidence, a concrete fix and sources. The final RESULT line and the exit code reflect --fail-on (default high): 0 = nothing at or above the threshold, 1 = findings at or above it, 2 = a usage or runtime error.
3. Get the fix steps
phpshield fix detects this host's platform, PHP branch and SAPI and prints the exact upgrade, restart and verify commands, the interim mitigations and the after-patch checklist. The output never runs anything; --format sh -o fix.sh writes a script for you to review and run yourself.
phpshield fix # detect this host and print the playbook phpshield fix --platform ubuntu-ondrej --branch 8.3 --sapi fpm-fcgi phpshield fix --format sh -o fix.sh # a reviewable script; NOT executed
phpshield scan
The full scan runs six checks in order: runtime (PHP binaries, versions, stale workers), config (php.ini, FPM pools), deps (composer.lock against Packagist advisories), code (risky code and webshell signatures), crypto (exposed keys, seed phrases, drainers) and integrity (WordPress core checksums, exposed .env and backups, PHP in uploads, .htaccess handler tricks).
phpshield scan [PATH ...] # PATH defaults to the current directory
Flags
| Flag | Effect |
|---|---|
| --php BIN | Inspect this PHP binary instead of auto-discovery. Repeatable. |
| --ini FILE | Audit this extra php.ini / FPM pool file. Repeatable. |
| --no-system | Do not inspect host binaries, /etc configs or processes: a files-only scan of the paths you give. |
| --skip CHECK | Skip a check: runtime, config, deps, code, crypto or integrity. Repeatable. |
| --offline | No network: skip the Packagist advisory API and WordPress core checksums. |
| --since YYYY-MM-DD | Flag PHP files modified on or after this date. Default 2026-10-20. |
| --exclude GLOB | Exclude paths matching GLOB. Repeatable. |
| --max-file-size MB | Skip content checks on files larger than MB. Default 2. |
| --format FMT | text, json, sarif, html or md. Default text; inferred from the -o extension when omitted. |
| -o, --output FILE | Write the report to FILE instead of stdout. |
| --fail-on SEV | Exit 1 if any finding is at or above SEV. One of info, low, medium, high, critical, or never. Default high. |
| -q, --quiet | Print only the summary header and the result line. |
| --no-color | Disable ANSI colour (also honours NO_COLOR). |
| -v, --verbose | Log progress to stderr. |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Nothing at or above --fail-on (or --fail-on never). |
| 1 | At least one finding at or above --fail-on. |
| 2 | Usage error, or the scan could not run. |
--fail-on is what makes scan useful as a gate. --fail-on never always exits 0, which is handy when you want the report but not a failing step.
All commands
Run phpshield <command> --help for the exact flags of any subcommand.
version
phpshield version VERSION [--windows] [--json] [--no-color]
Verdict for one PHP version string. --windows evaluates the host as Windows (so the php-cgi Best-Fit advisory CVE-2024-4577 applies). --json emits the full verdict (status, missing_security_releases, applicable advisories, upgrade_to, recommended_branches) for scripting.
{
"input": "8.3.12", "version": "8.3.12", "branch": "8.3", "known": true,
"branch_status": "security", "branch_security_until": "2027-12-31",
"latest_in_branch": "8.3.36", "is_latest": false,
"missing_security_releases": ["8.3.14", "8.3.19", "...", "8.3.36"],
"advisories": [{"id": "php2shell-oom", "cve": "CVE-2026-109618", "name": "PHP2Shell",
"severity": "critical", "exploited_in_wild": true,
"fixed_in": "8.3.36", "status": "vulnerable"}],
"status": "critical",
"headline": "PHP 8.3.12 is affected by a critical, actively exploited vulnerability",
"upgrade_to": "8.3.36", "recommended_branches": ["8.5", "8.4"]
}fix
phpshield fix [--platform ID] [--branch X.Y] [--sapi SAPI] [--format text|md|sh] [-o FILE]
With no flags it detects the platform, branch and SAPI on this host. It prints the upgrade, restart and verify commands with versions filled in from the release data, the interim mitigations and the after-patch checklist. --format md is ticket-ready; --format sh -o fix.sh writes a reviewable script that PHPShield does not execute. See the interactive fix generator for every platform.
monitor
phpshield monitor [PATH ...] [scan flags] [--interval DURATION] [--once] [--state FILE]
[--webhook URL] [--webhook-format json|slack|discord]Re-scans on an interval and reports what changed. Covered in Monitoring below.
wallet
phpshield wallet ADDRESS [--rpc URL] [--since YYYY-MM-DD] [--limit N] [--json]
phpshield wallet ADDRESS --watch [--interval 60s] [--once] [--state FILE]
[--webhook URL] [--webhook-format json|slack|discord]Read-only Solana activity for one address, for checking whether a hot wallet moved funds after a key exposure. It validates the address (base58, 32 bytes) before calling, and only ever reads: it issues getSignaturesForAddress and getTransaction JSON-RPC requests and never signs or sends anything. The default endpoint is https://api.mainnet-beta.solana.com; override it with --rpc.
delegates
phpshield delegates ADDRESS [--rpc URL] [--json]
Lists every token account owned by ADDRESS that still has a delegate (an approval) set. Covered in Solana wallets below.
update
phpshield update
Refreshes the release data from php.net into ~/.cache/phpshield. Every later command prefers this cache over the copy bundled in the tool, so you stay current between releases.
Solana wallets
If a vulnerable PHP worker could read a wallet key, two on-chain questions matter: can anyone still spend from your token accounts without that key, and has the wallet signed anything you did not? phpshield delegates answers the first and phpshield wallet --watch the second. Both are read-only JSON-RPC lookups against an endpoint you choose: PHPShield never builds, signs or sends a transaction, and never asks for a key.
Only the scheme and host of --rpc and --webhook are ever printed or saved, because provider RPC URLs usually carry an API key and a Slack or Discord webhook URL is itself a secret.
Token delegations: phpshield delegates
A delegate can move up to its approved amount out of a token account without the owner signing. Card and DeFi apps set them on purpose; a delegate you do not recognise is a standing spend permission someone else holds. The command validates ADDRESS, then calls getTokenAccountsByOwner with jsonParsed encoding once for the spl-token program and once for Token-2022, and lists the accounts whose parsed data has a delegate.
| Flag | Effect |
|---|---|
| --rpc URL | JSON-RPC endpoint. Default https://api.mainnet-beta.solana.com, which answers this call from a server or terminal. |
| --json | Print {"owner", "rpc", "count", "delegations": [...]}. Each delegation has account, mint, program, amount, decimals, delegate and delegated_amount. |
| Code | Meaning |
|---|---|
| 0 | No token account has a delegate. |
| 1 | At least one token account has a delegate. |
| 2 | Invalid address, or the RPC lookup failed. |
Token delegations for OWNER_WALLET
endpoint: https://api.mainnet-beta.solana.com (spl-token and Token-2022 accounts)
1 token account(s) with an active delegate:
mint EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (spl-token)
delegate DELEGATE_1
delegated amount 4 (balance 0.983234)
token account TOKEN_ACCOUNT_1
revoke spl-token revoke TOKEN_ACCOUNT_1Revoking is your action, in your wallet. spl-token revoke <TOKEN_ACCOUNT_ADDRESS> clears the delegate on one token account, signed by your default keypair (pass --owner KEYPAIR otherwise). Most wallets also have a revoke or token-approvals screen. If the owner key itself may have leaked, revoking is not enough: move the tokens to a freshly generated wallet.
Watching a wallet: phpshield wallet --watch
--watch turns phpshield wallet into a watcher. The first cycle only records the newest signature as the baseline and alerts on nothing. Every later cycle asks for signatures newer than the baseline, reads each one with getTransaction, and prints them newest first with whether the wallet signed, its SOL balance change and the status. A transaction the wallet signed is the alert that matters: it needs the private key, so one you did not make means someone else has the key.
| Flag | Effect |
|---|---|
| --watch | Keep checking and report new transactions. |
| --interval DURATION | Time between checks: 60s, 5m, 1h. Default 60s, minimum 10s. |
| --once | Run one check and exit. Use this under cron; the state file carries the baseline between runs. |
| --state FILE | Where the baseline is kept. Default ~/.cache/phpshield/wallet-ADDRESS.json. It holds the address, the newest signature and slot, and a timestamp; no RPC URL and nothing secret. |
| --webhook URL | POST the new transactions to URL whenever there is anything new. |
| --webhook-format | json ({"tool", "address", "new": [...]}), slack ({"text"}) or discord ({"content"}). Default json. |
| --limit N | Most signatures read per cycle. Default 100. |
| --json | One JSON object per cycle on stdout, for piping into other tools. |
| --rpc URL | JSON-RPC endpoint. Default https://api.mainnet-beta.solana.com. |
Exit codes: Ctrl-C stops a running watch with 0. With --once it exits 1 when the cycle found a new transaction signed by the wallet, 0 otherwise, and 2 on a bad flag or when the signature lookup fails (the baseline is left as it was).
# every 5 minutes: alert Slack when the hot wallet has new activity */5 * * * * /usr/bin/python3 /opt/phpshield.pyz wallet HOT_WALLET_ADDRESS --watch --once \ --webhook "$SLACK_WEBHOOK_URL" --webhook-format slack
The Crypto & Solana page has the same two checks in the browser. Its watcher polls every 60 seconds while the tab is open and can raise a browser notification. Browsers get a narrower choice of public endpoints: api.mainnet-beta.solana.com refuses every request that carries a browser Origin header, and PublicNode (the page's default) answers activity lookups but refuses getTokenAccountsByOwner and keeps only a few days of history, so the delegation check there needs your own RPC URL. The CLI has neither limit.
Report formats
scan renders the same findings in five shapes. Choose with --format, or let -o report.html infer it from the extension.
| Format | For |
|---|---|
| text | Reading in a terminal. Grouped by category, coloured on a TTY. |
| json | Scripting and pipelines. The canonical envelope below. |
| sarif | GitHub code scanning. SARIF 2.1.0, one rule per finding id. |
| html | A single self-contained file, for sharing. |
| md | Pasting into a ticket or pull request. |
JSON envelope
Every JSON report has the same top-level shape. summary, findings, errors and installs are always present.
{
"tool": "phpshield", "version": "1.0.0",
"generated_at": "2026-10-22T06:21:46Z", "data_generated_at": "2026-10-22T19:46:34.867Z",
"target": ["./example.com"],
"summary": {"total": 7, "by_severity": {"info": 1, "low": 2, "medium": 0, "high": 2, "critical": 2},
"by_category": {"compromise": 3, "integrity": 2, "code": 1, "dependency": 1},
"worst": "critical", "fail_on": "high"},
"findings": [{"id": "COMPROMISE-EXEC-SUPERGLOBAL", "severity": "critical", "category": "compromise",
"title": "Command or code executed directly from request input",
"location": "example.com/public/up.php", "line": 1,
"evidence": "<?php @eval($_POST[\"x\"]);", "fix": "Treat this host as compromised ...",
"references": ["https://www.php.net/manual/en/function.exec.php"],
"confidence": "high", "fingerprint": "ca22005545e240ac"}],
"errors": [], "installs": []
}fingerprint is stable across runs for the same finding, which is how monitor tells NEW from RESOLVED and how SARIF results dedupe in code scanning.
Continuous integration
In GitHub Actions, download the single file, write SARIF, upload it to GitHub code scanning, then fail the job on findings at or above your threshold. Findings appear in your repository's Security, Code scanning tab, annotated on the pull request.
name: PHPShield
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 7 * * 1" # weekly re-scan catches newly disclosed CVEs
permissions:
contents: read
security-events: write # required to upload SARIF
jobs:
phpshield:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan
id: scan
run: |
curl -LsSO https://phpshield.live/downloads/phpshield.pyz
set +e
python3 phpshield.pyz scan . --format sarif -o phpshield.sarif --fail-on high
echo "code=$?" >> "$GITHUB_OUTPUT"
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: phpshield.sarif
category: phpshield
- name: Fail on findings at or above high
if: steps.scan.outputs.code != '0'
run: exit ${{ steps.scan.outputs.code }}The SARIF is uploaded before the job fails, so results reach the Security tab even on a failing run. --fail-on takes info, low, medium, high, critical or never; add --offline to skip the Packagist and WordPress checks.
Other CI
python3 phpshield.pyz scan . --format sarif -o phpshield.sarif --fail-on high
Exit 1 fails the step when something is at or above --fail-on; exit 2 means the scan itself could not run.
Monitoring
phpshield monitor re-runs the same scan on an interval, diffs the findings against the previous run by fingerprint, and reports which are NEW and which are RESOLVED. It also refreshes php.net data each cycle and flags security releases it had not seen before, so a new PHP CVE shows up even if your files never changed.
It accepts every scan flag, plus:
| Flag | Effect |
|---|---|
| --interval DURATION | Time between cycles: 30m, 6h, 1d. Default 6h. |
| --once | Run a single cycle and exit. Use this under cron. |
| --state FILE | Where the baseline is stored. Default ~/.cache/phpshield/monitor-state.json. |
| --webhook URL | POST the delta to URL when anything changed. |
| --webhook-format | json (raw delta), slack or discord. Default json. |
[2026-10-22 02:22:01] phpshield monitor: 7 finding(s): 7 new, 0 resolved
NEW:
* [CRITICAL] COMPROMISE-EXEC-SUPERGLOBAL: Command or code executed directly from request input (example.com/public/up.php:1)
* [CRITICAL] INT-ENV-EXPOSED: Environment file served from a public web root (example.com/public/.env)
...
second run, nothing changed:
[2026-10-22 02:22:01] phpshield monitor: 7 finding(s): 0 new, 0 resolved
no change since last runUnder cron
# every 6 hours: scan /var/www, notify Slack on any change 0 */6 * * * /usr/bin/python3 /opt/phpshield.pyz monitor /var/www --once \ --webhook "$SLACK_WEBHOOK_URL" --webhook-format slack --fail-on never
Use --fail-on never under cron if you do not want a non-zero exit to be logged as a job failure; drop it if you do.
Privacy & network
PHPShield runs entirely on your machine. It reads files and runs local probes (php -v, ps, package-manager queries) to build findings, and nothing about the scanned host (no file contents, paths, versions, findings or identifiers) is ever uploaded anywhere. Detected secrets are masked before they reach a finding, a report, the console or any output.
The only outbound requests are read-only lookups of public vulnerability data, and they happen only for the commands that need them. All network I/O goes through one small module (cli/phpshield/net.py) so it is easy to audit. --offline disables every one of them.
| Endpoint | Used by | Why |
|---|---|---|
| www.php.net/releases/index.php | update, monitor | Fetch the PHP release index to refresh the vulnerability database. Read-only; sends only the request. |
| api.wordpress.org/core/checksums/1.0/ | scan (integrity), monitor | Fetch the official core checksums for a detected WordPress version to find modified core files and PHP files core never ships. Only the version and locale are sent. |
| packagist.org/api/security-advisories/ | scan (deps), monitor | Look up advisories for the package names found in composer.lock. Only package names are sent, never your code, versions or paths. |
| api.mainnet-beta.solana.com (or your --rpc) | wallet, delegates | Read-only getSignaturesForAddress, getTransaction and getTokenAccountsByOwner for a Solana address you supply. Never signs or sends transactions. |
| your --webhook URL | monitor, wallet --watch | Only if you configure it. monitor sends the finding delta (ids, severities, titles, locations); wallet --watch sends the new transaction signatures for the address you watch. |
That is the whole list. PHPShield contacts no telemetry, analytics or update server of its own. Run with --offline and it uses only the data bundled in the tool; no connection leaves the host. You can read every module in the source browser.
PHPShield is defensive tooling: it helps you find and fix exposure on systems you operate. It is not a scanner for other people's hosts, it ships no exploit code, and it is independent and not affiliated with the PHP Group.