What a portfolio audit must produce
A portfolio audit is not a command you run once. It is a register: one row per site, with the PHP version read, how it was read, the date and the degree of certainty. Without these three pieces of information a number is worthless, because a version read last month says nothing about today.
The usual objection is fair: you do not control the client's host, you cannot run SSH commands everywhere, and you must not drop a diagnostic file on a production site. The answer is one idea: measure with what you have, and record honestly what each measurement proves. A site read from outside is presumed; a site read from the server is confirmed. The register keeps both and shows where work remains.
Dated context: PHP2Shell
On 21 October 2026 the PHP project released versions 8.5.12, 8.4.27, 8.3.36 and 8.2.35, which fix CVE-2026-109618 and CVE-2026-109619 (unauthenticated remote code execution). PHP 8.1 and older branches no longer receive updates from the project; minimal source backports were published for 7.4, 8.0 and 8.1. The addition to the CISA catalog of known exploited vulnerabilities dates from 26 October 2026, with a federal deadline of 29 October 2026. The flaws and the update of a single server are covered in the dedicated article; here we deal with the portfolio.
The four measurement tiers
Scroll the table
| Tier and access | What it proves | What it does not prove |
|---|---|---|
| 1. External observation, no access | An X-Powered-By or Server header, when present, suggests a stack and a version | Nothing certain: the header is often hidden, stripped or false. It is a presumption |
| 2. wp-cli or SSH | The version of the PHP command-line binary, read on the machine | The version served on the web: the CLI binary can differ from PHP-FPM or Apache |
| 3. cPanel, WHM or Plesk panel | The version assigned to each domain, with its handler | The fix actually applied: the same number can be patched or not depending on the package |
| 4. Docker | The version in the image and in the running container | That the deployed image is the right one: compare the image with the live container |
| Reading the package changelog | That the fix is present despite an unchanged number (backport) | That the site was not compromised before the update |
| No tier possible | Nothing: the site goes to batch 4, to be asked of the client or the host | Never classify it as fixed by default |
Measure tier by tier
Tier 1: read headers with no access
Prepare
sites.txt, one URL per line, then runwhile read -r u; do printf '%s,%s\n' "$u" "$(curl -sI --max-time 10 "$u" | tr -d '\r' | grep -iE '^(server|x-powered-by):' | tr '\n' ' ')"; done < sites.txt > externe.csv. Each line gives the URL and the two headers found. An empty line is information: the header is hidden. Classify these sites as presumed and move to the next tier as soon as you can.Tier 2: query WordPress with wp-cli
On a WordPress site where wp-cli is installed,
wp cli infoprints, among others, aPHP binaryline and aPHP versionline. To extract the second:wp cli info | grep 'PHP version'. Note that this value describes the binary used on the command line. The web server may run another version, especially when the host offers several versions per domain. For a reliable check, compare with tier 3.Tier 2 bis: a batch SSH script
With
hotes.txt(one SSH alias per line):while read -r h; do printf '%s,%s\n' "$h" "$(timeout 20 ssh -n -o BatchMode=yes -o ConnectTimeout=8 "$h" "php -r 'echo PHP_VERSION;'" 2>/dev/null || echo inconnue)"; done < hotes.txt > ssh.csv. The-nstops ssh from swallowing the list,BatchModeprevents a host from asking for a password and blocking the loop,timeoutbounds each attempt. Hosts that fail show up as "inconnue" instead of vanishing: tested with a failing host and a host that does not answer, both appear in the CSV.Tier 3: read the panels
In cPanel, MultiPHP Manager lists each domain with its PHP version, or the Inherited label when it follows the system value. In WHM, the same tool gives the per-account view. In Plesk, the version and handler are set per domain under Websites & Domains, in the PHP settings section, depending on the Plesk version. Record the displayed version, then go to the next step to find out whether the fix is present.
Backports: read the changelog, not just the number
Distributions and hosts running CloudLinux often fix without changing the upstream number. AlmaLinux says so explicitly in its advisory: do not rely on
php -v, compare the installed package with the published fixed version, for example withrpm -q php-common. To look for a CVE in the package history:rpm -q --changelog php-common | grep CVE-2026-109618on the RHEL family, andapt changelogfollowed by the package name on Debian and Ubuntu. If the CVE is absent from the history, it is not fixed in that package.Tier 4: containers
A recent image does not prove that the running container is recent too. Compare the version in the image with that of the running container, then rebuild if needed. The full procedure is in the article on rebuilding PHP images, which we point to rather than copy.
Why not phpinfo, and never in public
The reflex is to drop a phpinfo() file on the site. It shows everything, but it also exposes paths, modules and server configuration to anyone who knows the address, and a forgotten file becomes a reconnaissance entry point. On a client's site there is a second problem: changing that site without written authority is a fault, even just to read a version.
By default, rule it out. If a situation forces it, a temporary file must meet four conditions: the client's written agreement, an unpredictable name, immediate deletion after reading, and a record of the operation in the register. In almost every case a tier 2 or 3 measurement makes the file unnecessary.
The register: one row per site
A shared spreadsheet is enough, provided everyone keeps the same columns. The goal is that a colleague can resume the audit without asking you anything, and that the client can receive their row as it stands.
The register columns
- Site, client, host — the identity of the row, with the canonical URL and the technical contact.
- Access type — none, wp-cli, SSH, panel or Docker: what determines the reachable tier.
- Measurement method — the command or screen used, so that the measurement can be replayed identically.
- Version read and SAPI — the number and the execution mode (FPM, Apache module, CLI), since the risk differs by mode.
- Measurement date — a version without a date is an opinion.
- Confidence level — presumed (tier 1) or confirmed (tiers 2 to 4), plus a backported mention if the changelog confirms it.
- Priority batch and status — from 1 to 4, then to do, notified, in progress, fixed, or declined by the client.
- Fix date and proof — the capture, the command output and the operator's name.
Sort into batches, not a shopping list
Once the register is filled, the question is no longer "is everything up to date?" but "where do we start?". Four batches answer it. Batch 1 groups exposed sites on a branch that no longer receives fixes from the PHP project (8.1 and older), unless a vendor fix is confirmed by the changelog. Batch 2: branch still supported but a version older than the fix. Batch 3: fixed branch, to be confirmed by a tier 2 or 3 measurement. Batch 4: unreachable site or unknown version.
Three criteria move a site up a batch: a public form, a shop, personal data, or a plugin that accepts uploaded files. A static showcase site in batch 2 comes after a shop in batch 3.
Priority grid
Scroll the table
| Batch | Criterion | Action |
|---|---|---|
| Batch 1 | Exposed, branch with no fix from the project (8.1 and older) | Migrate or demand a vendor-fixed package, notify the client the same day |
| Batch 2 | Supported branch, version older than the fix | Update to the fixed version of the branch, within the week |
| Batch 3 | Fixed branch, measurement to confirm | Tier 2 or 3 measurement, then set confidence to confirmed |
| Batch 4 | Unreachable, access refused or version unknown | Ask the client for access or the version, follow up and date each request |
| Aggravating factor | Form, shop, personal data, upload | Moves the site up one batch in the order of treatment |
Warn clients without alarming them
A client does not need the detail of the two CVEs. They need to know whether their site is affected, what you are doing and what it changes for them. Two short messages are enough, to adapt. Keep the same factual tone: the version read, the date, what comes next. Do not write that the site is protected or secured: a version read is not proof of the absence of compromise.
Two template messages
Client informed of the state of their site
"Hello, we read the PHP version of your site on [date]. It was [version], older than the fix published by the PHP project for two critical flaws. We are updating it on [date], with no downtime planned. We will then send you dated proof of the version in service. No action is needed on your side. If you have questions, reply to this message."
Client whose site cannot be migrated right away
"Hello, your site runs on a PHP version that no longer receives fixes from the project. The migration requires [work], which we propose for [date]. In the meantime we are [measure: vendor-fixed package, removal of the file upload form, filtering]. These measures reduce the risk without removing it. Please confirm the chosen date before [date]."
Prove the fix, then replay the audit
Proof of a fix is one record per site: the measurement before, the intervention, the same measurement after, the capture or command output, the version number, the date and the operator's name. Replaying the same measurement with the same method is what makes the proof comparable. Keep the log with the register.
Keep the limit in mind: a version read proves the version served at the moment of measurement, not the absence of intrusion before the update. If a site stayed exposed after 21 October 2026, hunting for traces of exploitation is a second job, described in the PHP2Shell article.
The cadence: one audit per quarter, and at every trigger
Replay the audit every quarter, plus at every new critical CVE and every branch end of life. Compare the two measurements: drift is a site whose version went down or whose confidence level went back to presumed. Record in the register the end-of-life dates that matter. For PHP 8.2 the project gives 31 December 2026; the migration checklist to 8.3 or 8.4 is in the dedicated article. The first batch of the next audit is already known: everything still on 8.2 on 1 January.
Official documentation
- PHP notice of 21 October 2026 — https://www.php.net/archive/2026.php#2026-10-21-5
- Supported PHP versions — https://www.php.net/supported-versions.php
- CISA alert of 26 October 2026 — https://www.cisa.gov/news-events/alerts/2026/10/26/cisa-adds-two-known-exploited-vulnerabilities-catalog
- NCSC-NL php2shell repository — https://github.com/NCSC-NL/php2shell
- AlmaLinux advisory on fixed packages — https://almalinux.org/blog/2026-10-21-php2shell-cve-2026-109618-cve-2026-109619/
- MultiPHP Manager (cPanel) — https://docs.cpanel.net/cpanel/software/multiphp-manager-for-cpanel/
- wp cli info — https://developer.wordpress.org/cli/commands/cli/info/