diff --git a/.gitattributes b/.gitattributes
@@ -0,0 +1,5 @@
+# Files that run directly on the server keep LF line endings, even when committed from Windows.
+* text=auto eol=lf
+
+# Fonts are binary: never convert their line endings.
+*.woff2 binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -0,0 +1,43 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # Every Node.js line that package.json "engines" allows and that is still in use.
+ # pages.yml runs the same `npm test` on 22 before it deploys.
+ node: ['20', '22', '24']
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ - run: npm test
+
+ shell:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: ShellCheck (POSIX sh)
+ run: shellcheck -s sh scripts/php2shell-server-check.sh
+ - name: Run on runner
+ run: sh scripts/php2shell-server-check.sh --logs
+
+ php-lint:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php: ['5.6', '7.4', '8.4', '8.5']
+ steps:
+ - uses: actions/checkout@v4
+ - name: php -l on PHP ${{ matrix.php }}
+ run: docker run --rm -v "$PWD/check:/check:ro" php:${{ matrix.php }}-cli php -l /check/php2shell-selfcheck.php
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
@@ -0,0 +1,126 @@
+name: Deploy site
+
+on:
+ push:
+ branches: [main]
+ schedule:
+ # Fetch distribution security data again every 6 hours.
+ - cron: '17 */6 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# One run at a time, including its data commit. A newer run waits; it is never cancelled midway.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+# The site is built, tested and deployed from exactly the commit this run checked out, plus
+# freshly fetched data. Recording that data in git is a separate, last job: it never pulls new
+# code into the build, and a rejected push can never hold back a deploy.
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pages: read
+ outputs:
+ data_changed: ${{ steps.data.outputs.changed }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ # The same command as ci.yml.
+ - name: Test
+ run: npm test
+
+ # If the fixed versions in the official advisories differ from upstream.json, fail here and do not deploy.
+ - name: Update distro data
+ id: data
+ run: |
+ node tools/update-distro-data.mjs
+ if git diff --quiet -- site/data/distro.json; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ fi
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Assemble site
+ run: node tools/build-site.mjs
+
+ - uses: actions/configure-pages@v5
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ - name: Keep the new distro.json for the data commit
+ if: steps.data.outputs.changed == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: distro-data
+ path: site/data/distro.json
+ if-no-files-found: error
+ retention-days: 1
+ overwrite: true
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
+
+ # Commit the data that is now live to main, so the history of distro.json is the history of
+ # the deployed data, and the next run starts from it. Runs only after a successful deploy.
+ commit-data:
+ needs: [build, deploy]
+ if: needs.build.outputs.data_changed == 'true' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ # The latest main, not this run's commit: main may have moved on while this run waited or ran.
+ - uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: distro-data
+ path: ${{ runner.temp }}/distro-data
+
+ - name: Commit distro.json
+ env:
+ NEW_DATA: ${{ runner.temp }}/distro-data/distro.json
+ run: |
+ # Compare without updated_at, so a main that already holds the same data is left alone.
+ strip() { jq -S 'del(.updated_at)' "$1"; }
+ if [ -f site/data/distro.json ] && [ "$(strip "$NEW_DATA")" = "$(strip site/data/distro.json)" ]; then
+ echo "main already has this data"
+ exit 0
+ fi
+ cp "$NEW_DATA" site/data/distro.json
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add site/data/distro.json
+ git commit -m "data: update distro advisories"
+ # No pull and no retry: if main moved on again, or a rule on main blocks pushes from
+ # workflows, the deployed site is still current and the next run fetches and commits again.
+ if ! git push origin HEAD:main; then
+ echo "::warning title=Data commit not pushed::The new distro.json is deployed but was not pushed to main. The next run fetches the data again and retries. If a ruleset or branch protection on main blocks pushes from workflows, see the README section on self-hosting."
+ fi
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,10 @@
+# Installed or generated locally
+node_modules/
+_site/
+site/data/status.json
+npm-debug.log*
+
+# Operating system files
+.DS_Store
+Thumbs.db
+desktop.ini
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hyukjin An
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
@@ -0,0 +1,211 @@
+# PHP2Shell Scanner
+
+PHP2Shell Scanner tells you whether the PHP on your server is affected by PHP2Shell (CVE-2026-109618 and CVE-2026-109619), two unauthenticated remote code execution flaws in the PHP engine. It collects facts on the server itself, with a PHP probe and a POSIX shell script, and a static page computes the verdict entirely in your browser. It understands distribution backports, notices when PHP was patched but not restarted, and reports interim mitigations.
+
+- **Check page:** <https://sqzer-x.github.io/php2shell-scanner/> (Korean: <https://sqzer-x.github.io/php2shell-scanner/kr/>)
+- **Advisories:** [GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w) (CVE-2026-109618, memory corruption during out-of-memory handling) and [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp) (CVE-2026-109619, use-after-free of global state)
+- **Fixed upstream in:** 8.2.35, 8.3.36, 8.4.27, 8.5.12
+
+## Why it exists
+
+Remote scanners that read the PHP version from a response header get several common cases wrong.
+
+| Situation | Version-header scanner | PHP2Shell Scanner |
+|---|---|---|
+| The server hides its version header | Cannot tell | Reads the version from inside PHP |
+| The distribution backported the fix (Ubuntu 22.04 ships PHP 8.1.2; the fix is in `8.1.2-1ubuntu2.28`) | Reports vulnerable, or "no fix available" | Compares with the distribution's fixed version: **Patched** |
+| The package was updated but PHP was not restarted | Cannot see the installed package | Compares running and installed versions: **Restart needed** |
+| Interim mitigations | Unknown | Reads the effective settings |
+| Several vhosts or PHP-FPM pools | Checks one URL | Run once per vhost and per pool |
+
+The name says scanner, but it never sends attack traffic and never probes your site from outside. It reads facts from inside the server.
+
+## What is in the repository
+
+| Path | What it is |
+|---|---|
+| [`check/php2shell-selfcheck.php`](check/php2shell-selfcheck.php) | The PHP probe. Upload it to a web root and open it once in a browser. |
+| [`site/`](site/) | The check page, in English and Korean ([`site/kr/`](site/kr/)). A static site deployed to GitHub Pages. The verdict logic is in [`site/lib/`](site/lib/); its English and Korean text is in [`site/lib/messages.js`](site/lib/messages.js), the page chrome strings in [`site/i18n.js`](site/i18n.js). The data it judges with is in [`site/data/`](site/data/), and the fonts it bundles in [`site/fonts/`](site/fonts/). |
+| [`tools/update-distro-data.mjs`](tools/update-distro-data.mjs), [`.github/workflows/pages.yml`](.github/workflows/pages.yml) | The data pipeline. Refreshes distribution security data and deploys the site. |
+| [`tools/build-site.mjs`](tools/build-site.mjs), [`tools/serve.mjs`](tools/serve.mjs) | Assemble the deployable site into `_site/`, and serve it locally for a preview. |
+| [`scripts/php2shell-server-check.sh`](scripts/php2shell-server-check.sh) | The server script. Package changelogs, restart status and signs of compromise. |
+| [`test/`](test/), [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | Tests for the verdict logic, ShellCheck for the server script, and PHP lint across versions. |
+
+The deployed site also serves both server-side files under `downloads/`, with a `SHA256SUMS` file.
+
+## How to use it
+
+### The PHP probe (most accurate)
+
+1. In [`php2shell-selfcheck.php`](check/php2shell-selfcheck.php), set `$TOKEN` to a random string of 12 or more letters, digits, `-` or `_`, for example the output of `openssl rand -hex 16`. Keep the single quotes around it, and save the file as UTF-8 without a BOM.
+2. Rename the file to something hard to guess and upload it to the web root of the site you want to check.
+3. Open `https://your-site/<file>.php?k=<TOKEN>` in a browser.
+4. Copy the whole output and paste it into the check page.
+
+- The file deletes itself after it runs (`$SELF_DELETE`). If it cannot, the output says so. Delete it by hand.
+- A 404 means the file has already run and deleted itself, `$TOKEN` was not changed, `$TOKEN` is shorter than 12 characters or has other characters than those above, or `k` is wrong. Do not paste the URL into a chat or an email before you have opened it yourself: a link preview would run the probe, receive its output and leave you a 404. Upload a fresh copy to run it again.
+- Run it once per site (vhost) and once per PHP-FPM pool. Each can use a different PHP.
+- It uses PHP 5.2 syntax only, so it runs on old servers. Tested on PHP 5.6 to 8.5.
+
+| It reads | It never reads |
+|---|---|
+| PHP version and SAPI | Environment variables |
+| OS name and version (`/etc/os-release`, or the legacy release files) and the kernel release | Web root or script paths |
+| Installed PHP package versions (`/var/lib/dpkg/status`, PHP packages only) | Request headers, cookies or form data |
+| 12 relevant `php.ini` settings, and whether the session and OPcache extensions are loaded | Passwords, keys or application configuration |
+| PHP configuration directory, web server name (`SERVER_SOFTWARE`), container markers, whether `open_basedir` is set | Full `phpinfo()` output |
+
+If `open_basedir` blocks `/var/lib/dpkg/status`, the check page warns that the backport verdict may be inaccurate. Run the server script as well.
+
+### The server script (when you have a shell)
+
+```sh
+sudo sh php2shell-server-check.sh
+sudo sh php2shell-server-check.sh --logs --webroot /var/www
+```
+
+| Option | What it checks |
+|---|---|
+| (always) | Installed PHP packages (dpkg, rpm, apk) and CVE entries in their changelogs. PHP processes still using files replaced by an update (`(deleted)` in `/proc`). Config files that set mitigation settings. Other processes running as the web server account. |
+| `--logs` | Web server and PHP-FPM error logs, including rotated ones, for out-of-memory errors before any script ran (`in Unknown on line 0`) and crashes. Kernel log segfaults in PHP or Apache. Session files over 1 MB. |
+| `--webroot DIR` | PHP files (`.php`, `.phtml`, `.phar`) under `DIR` changed in the last 3 days, to spot web shells. Repeat for several directories. |
+| `-h`, `--help` | Prints the usage and exits. |
+
+- It is read-only and offline. It changes no files, restarts nothing and makes no network connections.
+- Run it as root to see other accounts' processes and logs. Without root it names the logs and directories it could not read, and a count it could not complete is given as `unknown` instead of 0.
+- Paste the `PHP2SHELL SERVERCHECK` block at the end into the check page. The block holds OS release fields, package names and versions, process names and counts only. No log lines and no file paths. Review the readable report above it on the server.
+
+### The check page
+
+Paste the probe output, the server script output, or both into the same box, and press **Run check** (or Ctrl+Enter). Without access to the server you can paste an `X-Powered-By` header (for example from `curl -I`), `php -v` output or a bare version string, but accuracy is lower. A header shows only the PHP that served that one URL, and `php -v` shows the command-line PHP, which can differ from the one serving your site. If your distribution is not detected, pick it in the **Distribution** list. To see what a result looks like first, load one of the examples above the box.
+
+The result shows the verdict and its reasons, the evidence it rests on (versions, distribution, SAPI and how exposed that SAPI is), the required actions, any signs of compromise the server script found, the state of interim mitigations, and your distribution's advisories.
+
+**Required actions** is step-by-step remediation for the setup the check detected. It names the version running now and the fixed release or package to move to (your distribution's package for your release, the upstream release, or an official Docker image tag), gives copyable commands built from what the check saw (the installed package names, the package manager, the service or container that runs PHP), and ends with how to confirm the fix, usually by running the probe or the server script again until the result says Patched. When the result is Undetermined, the first steps say what to collect to settle it. **Copy report** copies the whole result as Markdown, for a ticket or a change request.
+
+## Verdicts
+
+| Label | Title | Meaning |
+|---|---|---|
+| Patched | This PHP has the fix | At or above the upstream fixed version, or a distribution package with both fixes backported. |
+| Vulnerable | This PHP is vulnerable | Below the fixed version. Includes upstream builds of end-of-life (EOL) branches, 8.1 and older. |
+| Fix pending | Vulnerable, and your distribution has no fix yet | Your distribution tracks the CVEs for this package but has not released a fixed version. |
+| Restart needed | Fixed on disk, still vulnerable in memory | The fixed package is installed, but the running PHP is the old one. |
+| Undetermined | Not enough to decide | The evidence is missing or incomplete. |
+
+When evidence is missing, the verdict is Undetermined. The check page never reports a server as safe without evidence.
+
+## How verdicts are decided
+
+The verdict logic is in [`site/lib/judge.js`](site/lib/judge.js). It first settles which PHP it is judging. With the probe output, a header, `-v` output or a version string, that is the running PHP (`PHP_VERSION`). With server script output, it is also every installed PHP build: one per Debian source package, rpm collection or apk prefix, so `php8.2` and `php8.4` installed side by side are judged separately. The installed build that the probe's PHP was judged with is not judged a second time. A paste that holds several separate outputs is judged output by output: two probe outputs, two server script outputs, a probe output and a header, every PHP version in the `X-Powered-By` and `Server` headers of a `curl -IL` redirect chain (also several in one merged header line, such as `PleskLin, PHP/8.2.20`), several `-v` lines, or several bare version lines. Server script output from another system than the probe's is one of those outputs too (see point 11). The same output pasted twice counts once. The worst verdict is reported, with one line per build or output. A server script block whose BEGIN line was not copied is read from its first data line up to its END line.
+
+1. **Pre-releases and newer branches.** `alpha`, `beta`, `RC`, `pre` and `-dev` builds (`8.5.12-dev` is the snapshot before 8.5.12, and Alpine writes `8.5.12_rc1`) are Undetermined, and so is any Debian version with a `~` suffix before the release (`8.4.27~git20261001-1` is a snapshot taken before 8.4.27). Branches newer than the fixed-release table are Undetermined as well.
+2. **Upstream fixed releases.** A version at or above the fixed release for its branch (8.2.35, 8.3.36, 8.4.27, 8.5.12) is Patched, unless the package of that PHP on disk is older and does not have the fix (point 4).
+3. **Builds that are not distribution packages** are judged against the upstream releases:
+ - deb.sury.org builds, recognised in `PHP_VERSION` or in the installed package. A sury version string never selects an Ubuntu or Debian release. For end-of-life branches (7.4, 8.0, 8.1), deb.sury.org ships backported fixes under the same upstream version (the `8.1.34-9` changelog has "Backported from 8.2.35"), so there the changelog of exactly the installed build, read by the server script, decides: both CVEs recorded is Patched, a changelog read without them is Vulnerable, and a changelog that was not read is Undetermined. A Debian build dated before the fixed releases (`+0~20250101`) cannot have them and is Vulnerable.
+ - Remi builds on the RHEL family (`.remi` release tag, `phpNN-php-*` packages, or configuration under `/etc/opt/remi/`). Remi's collections of end-of-life branches (php74, php80, php81) carry backports under the same version number, so they follow the same changelog rule as deb.sury.org's: Patched when every package records both fixes, Vulnerable when the changelog was read without them, Undetermined when it was not read.
+ - A PHP whose configuration directory is outside `/etc`: the official Docker images use `/usr/local/etc/php`, and control panels and self-built PHP use their own prefix. Installed packages are not used for it.
+ - A build whose upstream version is newer than the package the distribution release ships (for example `8.3.20` on Ubuntu 24.04, which ships 8.3.6). An installed package of an older upstream version than a running PHP that is below the fix is ignored, because it is not what runs.
+ - On the RHEL family, a PHP when the server script output lists no PHP installed with rpm.
+ - Alpine, with a note to check Alpine's advisories, and other distributions, with a note that they may backport fixes without changing the version. SUSE (SLES, openSUSE Leap) does backport into its own PHP packages, so a version below the fix there is Undetermined, and the page asks for its advisories or the package changelog.
+
+ Below the fixed release is Vulnerable. An end-of-life branch (8.1 and older) is Vulnerable, and for 7.4, 8.0 and 8.1 the page points to the minimal backport branches in php-src, but offers a source build only for a PHP that is not a package. Versions older than 5.4 are Undetermined.
+4. **Debian and Ubuntu.** The release comes from os-release, from the parent of a derivative (Linux Mint, LMDE, Pop!_OS, Raspbian) by codename, from the Distribution list, or from a version string. An inferred release must agree with the version: `~deb12u1` and `ubuntu0.24.04` name one, in `PHP_VERSION` or in the installed package of the same branch. Otherwise it is the one Ubuntu release that ships exactly that upstream version. An interim release such as 22.10 (`8.1.7-1ubuntu3.6`) is not mapped onto 22.04 and stays Undetermined. The version compared is `PHP_VERSION` when it carries a distribution revision, otherwise the installed package of the same branch. It is compared with the release's fixed version using dpkg ordering ([`site/lib/version.js`](site/lib/version.js), tested against `dpkg --compare-versions` results), ignoring the epoch and repack markers such as `+dfsg`. When the fixed version keeps the upstream version (a backport), only a revision the distribution builds itself counts: one with `ubuntu` on Ubuntu, one with `debNNuN` on Debian. A PPA's `8.3.6-1+ppa1` or a Debian-style `8.1.2-2` sorts above Ubuntu's fixed version but is not its backport, so it is Undetermined. If the fix ships only through Ubuntu Pro (ESM), the page says so. Three more checks apply when the running PHP is the package's:
+ - **Downgrades.** An installed package of the running SAPI (`php8.2-fpm`, `libapache2-mod-php8.2`) that is older than the running PHP and does not have the fix is Vulnerable, also when `PHP_VERSION` is an upstream fixed release (8.2.35 running, 8.2.32 on disk, configuration in `/etc/php/8.2/fpm`): the next restart loads it, and for PHP-FPM so does a reload. The steps reinstall the fixed package before any restart or reload, and say not to restart the processes that still run the fixed build.
+ - **The SAPI's own package.** Debian and Ubuntu build the PHP engine into the binary of each SAPI, so the package of the SAPI that runs must be in the list: `php8.3-common` does not vouch for a `php-fpm8.3` whose `php8.3-fpm` is missing (dpkg lists only fully installed packages, and an interrupted update leaves it half-installed). That is Undetermined, with the commands that show and finish the interrupted update.
+ - **The revision in `PHP_VERSION`.** On Ubuntu 22.04 and older, the security updates report their revision (`8.1.2-1ubuntu2.28`), and only the release's original build reports the plain version. A plain `8.1.2` next to an updated package is that original build, loaded before the update: Restart needed.
+5. **Same version before and after the fix.** Ubuntu 24.04 and later report the plain upstream version (`8.3.6`) both before and after the fix. Without a package list or a revision, a version equal to the release's packaged upstream version is Undetermined, and the page asks for the server script or a probe that can read `/var/lib/dpkg/status`. A lower version is Vulnerable. When the distribution is not known at all, a version could be a distribution package with the fixes backported under the same number. Such a version is Undetermined as well. That covers a version a Debian or Ubuntu release ships (`8.3.6`, `8.1.2`), a version a Red Hat advisory names (`8.0.30` on RHEL 9), and any version below the fix of a branch that a RHEL module stream has fixed (7.4 on RHEL 8, 8.2 on RHEL 8 and 9, 8.3 on RHEL 9): a module stream keeps the upstream version it started with, which the data does not give. A `Server` header that names a RHEL rebuild (`Apache/2.4.57 (Rocky Linux)`) selects the RHEL family.
+6. **Distribution list.** A choice in the Distribution list is used unless the pasted output contradicts it. That is the case when os-release or a RHEL kernel names another distribution or release, when the version string carries another distribution's revision or names another release, or when Upstream build is chosen for a `PHP_VERSION` with a distribution revision. The detected distribution is then used, and a note says so.
+7. **RHEL family** (RHEL, CentOS, Rocky Linux, AlmaLinux, Oracle Linux, CloudLinux, EuroLinux, Amazon Linux). The family is also recognised from an `.elN` kernel release in the probe output when os-release cannot be read, but not inside a container, which reports the host's kernel. Red Hat backports fixes without changing the upstream version number. The evidence is therefore the CVE entries in the changelog of the rpm package of the PHP being judged: the one with the same upstream version, narrowed by the probe's configuration path when several match. A changelog counts only for the version it was read from, and a changelog from another PHP package on the same server never counts. A CVE that Red Hat lists as Not affected for that release counts as covered, but only for that branch: a module stream's branch, or the branch of Red Hat's own `php` package for the plain `php` state (5.3 on RHEL 6, 5.4 on RHEL 7, and the branch the advisories name for later releases). All CVEs fixed or not affected means Patched. A changelog read without a fix is Vulnerable when the build is older than the exact build a Red Hat advisory names for that branch and release (`php-0:8.0.30-8.el9_8.1`, `php8.4-0:8.4.27-1.el10_2`, compared as rpm does), and on Amazon Linux, which ships its PHP fixes as upstream releases, when it is below the upstream fix. Anything else, including a module stream build (its advisory names no package version) and a running PHP that matches no installed rpm package, is Undetermined. When the fix is a new upstream release (a Red Hat rebase, or Amazon Linux), the changelog entry names the version rather than the CVEs, so the steps confirm the update by its version.
+8. **Changelog fallback.** On Debian and Ubuntu, the distribution data may have no entry for the release or package, or the release cannot be identified. Then an installed package that already has the upstream fix, next to an older `PHP_VERSION`, is Restart needed. Otherwise, both CVEs in the changelog of exactly the package build being judged, at the version the server script read, count as Patched.
+9. **Restart detection.** The fix is on disk but not in memory (Restart needed) in two cases: the installed package has the fix and the running version is older, or a PHP process that can run the PHP being judged maps a file marked `(deleted)` in `/proc`. When the running version itself shows the fix and the stale processes are of the same SAPI and branch as the probe, with no current process of the same name, the verdict stays Patched, with a note that a newer package is waiting for a restart. One PHP-FPM master and its workers are all stale or all current, so a stale `php-fpm` next to a current one is another PHP, and that gives Restart needed, as do stale processes of another SAPI. Stale processes over a build that does not have the fix are not called a missed restart: the files may have been replaced by a downgrade, and the steps install the fixed package first. A process counts for a build only when that build's packaging can run it. Debian, Ubuntu and Alpine name FPM after the branch (`php-fpm8.2`, `php-fpm83`; `php5-fpm` on Ubuntu 14.04), and the process belongs to the build only when the build has that SAPI's package. Apache on Debian and Ubuntu runs PHP only through a `libapache2-mod-php` package. On the RHEL family every collection runs as `php-fpm` (from `php-fpm` or `php8.4-fpm`) or in `httpd` (from `php`, `mod_php` or Amazon Linux 2023's `php8.4-modphp`). A PHP process that none of the checked builds can run means a PHP that was not checked: an unversioned `php-fpm` on Debian or Ubuntu (usually a container), `apache2` with PHP loaded but no `libapache2-mod-php` package, or a `php-fpm8.3` with no `php8.3-fpm` package. Such a process turns Patched into Undetermined, or into Restart needed when it maps deleted files. `-v` output of `php-fpm` or `php-cgi` shows the binary on disk rather than the running service, so a Patched result from it says that a restart is not confirmed.
+10. **Patched on the installed package alone.** A Patched verdict can rest on the installed package rather than on the running version string: with server script output alone, or with a probe whose `PHP_VERSION` has no revision. The reasons then say that the loaded build was not confirmed, or that no running process of that build was found. The verdict is Undetermined instead when the server script ran without root and the build runs PHP as a service (FPM, CGI or an Apache module): with `hidepid`, other accounts' processes are hidden without a trace, so the processes it saw are not all that run. It is Undetermined as well when the build's service is one the current server script cannot see in `/proc` (`php5-fpm` and `php5-cgi` on Ubuntu 14.04 and Debian 8, Apache with Alpine's `mod_php83.so`) and no process of it is listed.
+11. **Mixing outputs.** Server script output is combined with probe output only when both describe the same system at the same moment. Its packages, changelogs and processes are not used for the probe's PHP in these cases, and its signs of compromise are kept:
+ - Both report an OS release and they differ. The server script names any system without os-release `rhel` with its major version, which matches the probe's CentOS, CloudLinux or Oracle Linux of the same release.
+ - The probe ran in a container, and its own OS release and dpkg package list do not match the server script's. The script may have run on the host. On the RHEL family the probe cannot list rpm packages, so this is always the case there.
+ - `open_basedir` kept the probe from seeing whether it runs in a container, and its dpkg package list does not tie the two outputs together.
+ - The probe's dpkg package list differs from the server script's, for example one taken before an update and one after.
+
+ In the first three cases the server script output describes another system, or may: it is judged as an output of its own, and the worst result is reported. In the last case which output is current is unknown, so the result is never Patched, even when the probe's `PHP_VERSION` shows the fix, and the page asks for both outputs taken together.
+12. **Missing evidence.** If `open_basedir` keeps the probe from reading os-release and the package list, a version below the upstream fix is Undetermined. The exceptions are a configuration directory that shows a build that is not a distribution package, and a distribution picked from the list. The kernel release can still identify the RHEL family, which then follows the RHEL rules. Server script output that is incomplete never gives Patched: a block without its END line, a block that starts below its first line, PHP processes the script could not read or whose status line was cut off (also next to a probe whose `PHP_VERSION` shows the fix, which vouches only for its own PHP), and a paste that was cut because it was longer than two million characters or held more than 64 outputs. When the script ran as root and still could not read some processes, which happens inside a container without `CAP_SYS_PTRACE`, the page says to run it on the host or use the probe, not to run it again with sudo.
+13. **Interim mitigations** (`memory_limit=-1`, `session.upload_progress.enabled=0` and others) are shown for reference and never change a verdict. Without the probe's settings, the actions name the advisory's mitigations. Once PHP is patched they are not recommended, and the page suggests reverting `memory_limit=-1`. The advisory still suggests keeping session upload progress off if you do not use it. After a downgrade the mitigation is not offered, because the reload that applies it would load the vulnerable build; when PHP-FPM processes still use replaced files, the mitigation step says so.
+14. **Signs of compromise.** A count the server script could not read (`unknown`), or a run without root, is never reported as "nothing suspicious". The page says which sources were not checked.
+15. **Untrusted input.** Every pasted value is text: a setting, an OS release field or a package name of another type (an object, a list) is left out, and a SAPI or OS name is never looked up as a member of a JavaScript object. A paste of several megabytes is read in time proportional to its size.
+
+## Privacy and safety
+
+- The check page's Content Security Policy blocks every connection except to the site itself, and the page uses that only to load its own data, configuration and checksum files. What you paste never leaves your browser. No analytics, no third-party scripts or fonts.
+- Nothing sends attack requests. The probe reads versions, packages and settings. The server script also reads package changelogs, the process list with command lines, the files each PHP process has mapped and PHP and web server configuration files. With `--logs` and `--webroot` it also reads logs, session file sizes and the names and dates of PHP files. Its terminal output can show command lines and log lines; the check page needs only the block between the BEGIN and END lines.
+- The probe refuses to run without a token of 12 or more letters, digits, `-` or `_`, and deletes itself after running. Where PHP allows it, it first clears its own OPcache entry, and it also clears a cached copy that no longer matches the file on disk, so a new upload under the same name runs the new code. If PHP does not allow that (`opcache.restrict_api`), upload the new copy under a new name. It sends `noindex` and `no-store` headers.
+
+## Data sources and refresh
+
+| File | Contents | Refresh |
+|---|---|---|
+| [`site/data/upstream.json`](site/data/upstream.json) | Upstream fixed versions, CVE details, EOL backport branches | Maintained by hand. Every data run cross-checks it against the php-src advisories. A mismatch fails the run and nothing is deployed. |
+| [`site/data/distro.json`](site/data/distro.json) | Debian and Ubuntu fixed versions per package, RHEL advisories and package states | Every 6 hours, and on every push to `main`. Deployed straight away; committed to `main` after the deploy, and only when it changes. |
+| `site/data/status.json` | Last check time, success per source, whether `upstream.json` matches the advisories | Written on every data run, so on every deploy. Not committed (listed in `.gitignore`). |
+
+Sources: the php-src advisories above, [OSV](https://osv.dev/) for Debian and Ubuntu, and the [Red Hat Security Data API](https://access.redhat.com/hydra/rest/securitydata/).
+
+If one source fails, its previous data is kept and the check page lists the failed source. If the advisory lookup itself fails, the cross-check is skipped for that run and recorded as a failed source. If every source fails, the run fails.
+
+A 404 from OSV or Red Hat normally means the distribution has published nothing for that CVE yet, and it is recorded as no data. If `distro.json` already holds entries for that CVE, the 404 is treated as a fault at the source instead: the previous entries are kept, the source is listed as failed, and `status.json` names the CVE under `kept_previous`. If a record really was withdrawn, delete its entries from `distro.json` by hand.
+
+Each run builds and deploys exactly the commit it checked out, after its tests pass, with the freshly fetched data. A separate job then commits the new `distro.json` to the latest `main`. If that push is rejected, because `main` moved on or a rule blocks pushes from workflows, the run shows a warning, the deployed site is still current, and the next run tries again.
+
+## Self-hosting on GitHub Pages
+
+1. Create an empty repository on GitHub. Do not push yet.
+2. In **Settings > Pages > Build and deployment > Source**, choose **GitHub Actions**. Do this first: the deploy workflow cannot turn Pages on, so a run before this step fails at `configure-pages`.
+3. Push this repository to `main`. [`pages.yml`](.github/workflows/pages.yml) runs the tests, refreshes the data, deploys, and then commits `distro.json` if it changed. After that it refreshes the data every 6 hours. If you pushed before step 2, run it by hand: **Actions > Deploy site > Run workflow**.
+4. To use your own domain, enter it in **Settings > Pages > Custom domain** and add a DNS record:
+ - Subdomain: a `CNAME` record pointing to `<user>.github.io`.
+ - Apex domain: four `A` records, `185.199.108.153`, `185.199.109.153`, `185.199.110.153` and `185.199.111.153`.
+
+ Turn on **Enforce HTTPS** once the certificate is issued. Verify the domain in your account settings (**Settings > Pages**) so nobody else can take it over.
+5. On public repositories, scheduled workflows pause after 60 days without activity. If that happens, re-enable the workflow in the **Actions** tab.
+
+The page points at your copy without edits. The download commands on the page and in the results use the address the page is served from, and the GitHub links and the footer of a copied report use the repository the workflow runs in, which [`tools/build-site.mjs`](tools/build-site.mjs) writes to `config.json`. The report link in the header, the author link in the footer and the README link shown when JavaScript is off are plain links in [`site/index.html`](site/index.html) and [`site/kr/index.html`](site/kr/index.html); change them there if you want.
+
+The data commit is pushed to `main` with the workflow's own `GITHUB_TOKEN`. If a ruleset or branch protection on `main` blocks that push, for example by requiring pull requests, the site still deploys with fresh data, but every data change ends with a warning and the committed `distro.json` falls behind the deployed one. To keep the history in git, allow GitHub Actions to push to `main`.
+
+## Local development
+
+You need Node.js 20 or later. There are no dependencies to install.
+
+```sh
+npm test # tests for verdicts, remediation steps, translations and version comparison (node --test)
+npm run update-data # fetch distribution data into site/data/ (distro.json, and status.json, which git ignores)
+npm run build # assemble the deployable site into _site/
+npm run serve # build, then serve _site/ at http://127.0.0.1:8790
+```
+
+`npm test` runs `node --test` with no arguments, so Node.js finds `test/*.test.mjs` with its default patterns on every supported version. CI runs it on Node.js 20, 22 and 24, and the deploy workflow runs the same command before it deploys.
+
+`npm run update-data` uses `GITHUB_TOKEN` from the environment if set, which avoids GitHub API rate limits. [`tools/build-site.mjs`](tools/build-site.mjs) copies `site/`, adds both server-side files under `downloads/` with a `SHA256SUMS` file, and writes `config.json`. The deploy workflow runs the same script, so a local preview matches production.
+
+[`tools/serve.mjs`](tools/serve.mjs) is a small static file server with no dependencies. It listens on `127.0.0.1` only, serves `_site/` and nothing outside it, and sends `.php` and `.sh` files as plain text. Set `PORT` to use a port other than 8790.
+
+## Limitations
+
+- Verdicts rest on versions, packages and settings. Nothing tests whether an attack actually succeeds.
+- Distribution data covers Debian, Ubuntu and the RHEL family only. Alpine and other distributions are judged by upstream version. SUSE, which backports fixes without changing the version, is Undetermined below the fix; another distribution that does the same can be reported Vulnerable when it is patched, so check its advisories.
+- On the RHEL family a Red Hat package is reported Vulnerable only when its changelog was read without the fix and it is older than the exact build a Red Hat advisory names, or, on Amazon Linux, older than the upstream fix. A module stream build without changelog evidence stays Undetermined, because the module advisories name no package version. Self-built PHP and control panel PHP with a configuration directory outside `/etc` (CloudLinux alt-php, cPanel ea-php, Plesk) are judged by upstream version, so a vendor backport into an end-of-life branch is not recognised there.
+- deb.sury.org and Remi builds of end-of-life branches can carry backports under the same version, which only their changelog shows. Without the server script's changelog reading they are Undetermined; deb.sury.org's Ubuntu builds carry no build date, so even an old one cannot be told from a new one by its version.
+- When the distribution is not known (a bare header or version string), a version below the fix of a branch that a RHEL 8 or 9 module stream has fixed (7.4, 8.2, 8.3) is Undetermined, because those streams keep their old upstream version. A version string of the last release of another end-of-life branch, such as `8.1.34`, can be a deb.sury.org build with backported fixes and is still reported Vulnerable by its upstream version; the PHP probe or the server script settles it.
+- A probe that ran in a container is combined with server script output only when its own OS release and dpkg package list match the script's, and so is a probe that `open_basedir` keeps from seeing whether it runs in one. On the RHEL family the probe cannot list rpm packages, so run the server script inside the container (`docker exec`) and paste its output on its own.
+- A build from a php-src backport branch cannot be recognised by its version number and is reported as Vulnerable.
+- On Ubuntu 24.04 and later the probe cannot see a missed restart, because `PHP_VERSION` stays the same when the fix is installed. The server script's process check, run as root, can. On Ubuntu 22.04 and older the updated builds report their revision, so a plain version next to an updated package is reported as Restart needed; this rests on the builds observed for these releases.
+- Restart detection through `/proc` needs root to see processes owned by other accounts. A verdict that rests on the installed package is Undetermined when the server script reports PHP processes it could not read, or ran without root while the build runs PHP as a service. The current server script does not list `php5-fpm`, `php5-cgi` or Apache with Alpine's `mod_php83.so`, so those builds stay Undetermined until it does.
+- dpkg lists only fully installed packages, so a SAPI package that an interrupted update left half-installed is missing from both outputs, and the result is Undetermined rather than a verdict about that package.
+- With server script output, every installed PHP build counts toward the worst verdict, also when probe output is pasted with it, including a build that no site uses.
+- The check page sees only what you paste. Other sites and FPM pools on the same server need their own check.
+- The signs-of-compromise checks are heuristics. Finding nothing does not mean nothing happened.
+- Check the upstream advisories and your distribution's security notices as well.
+
+## License
+
+[MIT](LICENSE). Copyright (c) 2026 Hyukjin An.
+
+The check page bundles two fonts under the SIL Open Font License 1.1, both served from the site itself: [Pretendard](https://github.com/orioncactus/pretendard) ([licence](site/fonts/OFL-Pretendard.txt)) for text, the variable font split into Unicode-range subsets ([`site/fonts/pretendard.css`](site/fonts/pretendard.css)) so a page loads only the glyphs it shows, and [JetBrains Mono](https://github.com/JetBrains/JetBrainsMono) ([licence](site/fonts/OFL-JetBrainsMono.txt)) for code, in regular and bold.
diff --git a/check/php2shell-selfcheck.php b/check/php2shell-selfcheck.php
@@ -0,0 +1,523 @@
+<?php
+/*
+ * php2shell-selfcheck.php
+ * The PHP probe for PHP2Shell (CVE-2026-109618, CVE-2026-109619)
+ *
+ * Usage
+ * 1. Set $TOKEN below to a random string of 12 or more characters. Use only letters,
+ * digits, '-' and '_' (A-Z a-z 0-9 - _), and keep the single quotes around it. Other
+ * characters change on the way through a URL ('+' becomes a space; '&', '#' and '%'
+ * cut or alter the value), so the probe refuses them. To make one: openssl rand -hex 16
+ * 2. Give the file a name that is hard to guess as well, then upload it to the web root
+ * of the site you want to check. Save it as UTF-8 without a BOM, with nothing before
+ * the opening PHP tag.
+ * 3. Open https://<site>/<file name>.php?k=<TOKEN> in a browser.
+ * 4. Paste everything shown on the page into the check page.
+ *
+ * - It reads the PHP version, the OS and kernel release, the installed PHP package versions
+ * and a few configuration values. It sends nothing anywhere.
+ * - The file deletes itself when it has run. If the output says it could not be deleted,
+ * delete it yourself.
+ * - A 404 means that $TOKEN was not changed, is too short or has characters other than
+ * those above, that k is wrong, or that the file has already run and deleted itself.
+ * - If you replace the file on the server, PHP may run a cached copy of the old one
+ * (OPcache). The probe clears that copy when it can and asks you to load the page again.
+ * If the new token still does not work, upload the file under a new name.
+ * - It uses PHP 5.2 syntax only, so it also runs on old servers.
+ */
+
+$TOKEN = 'CHANGE_ME';
+$SELF_DELETE = true;
+
+define('P2S_TOOL_VERSION', '1.0.0');
+
+// Before anything else: keep PHP warnings, which name file paths, out of the response.
+p2s_start();
+
+$p2s_token = p2s_token_text($TOKEN);
+$p2s_given = (isset($_GET['k']) && is_string($_GET['k'])) ? trim($_GET['k']) : '';
+if (!p2s_token_usable($p2s_token) || !p2s_equals($p2s_token, $p2s_given)) {
+ p2s_refuse($p2s_token, $p2s_given);
+}
+
+if (P2S_EARLY_OUTPUT) {
+ // Something was printed before this file started, so the headers below cannot be sent.
+ $p2s_deleted = $SELF_DELETE ? p2s_delete() : false;
+ p2s_respond(false, array(), 'php2shell-selfcheck ' . P2S_TOOL_VERSION . "\n"
+ . "The probe stopped without collecting anything. Something was sent before it started, so it could not set its own headers. The usual cause is a byte order mark (BOM) or blank space before the opening PHP tag.\n"
+ . "Save the file as UTF-8 without a BOM, with nothing before the opening PHP tag, and upload it again.\n"
+ . p2s_delete_note($SELF_DELETE, $p2s_deleted));
+}
+
+$ini_names = array(
+ 'memory_limit',
+ 'post_max_size',
+ 'max_file_uploads',
+ 'max_input_vars',
+ 'max_input_nesting_level',
+ 'max_multipart_body_parts',
+ 'enable_post_data_reading',
+ 'session.upload_progress.enabled',
+ 'session.upload_progress.name',
+ 'session.auto_start',
+ 'fatal_error_backtraces',
+ 'expose_php',
+);
+$p2s_has_ext = function_exists('extension_loaded');
+
+$report = array(
+ 'tool' => 'php2shell-selfcheck',
+ 'tool_version' => P2S_TOOL_VERSION,
+ 'schema' => 1,
+ 'generated_at' => function_exists('gmdate') ? @gmdate('Y-m-d\TH:i:s\Z') : '',
+ 'php_version' => PHP_VERSION,
+ 'php_extra_version' => defined('PHP_EXTRA_VERSION') ? PHP_EXTRA_VERSION : '',
+ 'sapi' => PHP_SAPI,
+ 'php_os' => PHP_OS,
+ 'uname_release' => p2s_kernel_release(),
+ 'config_path' => defined('PHP_CONFIG_FILE_PATH') ? PHP_CONFIG_FILE_PATH : '',
+ 'server_software' => isset($_SERVER['SERVER_SOFTWARE']) ? (string)$_SERVER['SERVER_SOFTWARE'] : '',
+ 'open_basedir' => p2s_open_basedir(),
+ 'container' => p2s_container(),
+ 'os_release' => p2s_os_release(),
+ 'dpkg_packages' => p2s_dpkg_php_packages(),
+ 'extensions' => array(
+ 'session' => $p2s_has_ext ? extension_loaded('session') : null,
+ 'opcache' => $p2s_has_ext ? extension_loaded('Zend OPcache') : null,
+ ),
+ 'ini' => p2s_ini_values($ini_names),
+);
+
+$deleted = $SELF_DELETE ? p2s_delete() : false;
+$report['self_deleted'] = $deleted;
+
+p2s_respond(false, array(
+ 'Cache-Control: no-store, max-age=0',
+ 'X-Robots-Tag: noindex, nofollow',
+ 'X-Content-Type-Options: nosniff',
+ ),
+ 'php2shell-selfcheck ' . P2S_TOOL_VERSION . "\n"
+ . "Paste everything below into the check page. It holds the PHP version, SAPI, OS and kernel release, PHP package versions and a few settings. No paths to your site, no secrets.\n"
+ . p2s_delete_note($SELF_DELETE, $deleted)
+ . "\n-----BEGIN PHP2SHELL SELFCHECK-----\n"
+ . p2s_json($report, 0) . "\n"
+ . "-----END PHP2SHELL SELFCHECK-----\n");
+
+
+// Everything below is called from above. disable_functions can remove any function, and on
+// PHP 8 calling a removed function is a fatal error, so every function a host might disable
+// is checked with function_exists() before it is called.
+
+function p2s_can($names)
+{
+ foreach (explode(' ', $names) as $name) {
+ if (!function_exists($name)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function p2s_start()
+{
+ // display_errors may be locked by php_admin_flag, or ini_set() disabled: then silence errors instead
+ if (!function_exists('ini_set') || @ini_set('display_errors', '0') === false) {
+ if (function_exists('error_reporting')) {
+ @error_reporting(0);
+ }
+ }
+ // Output before this file started (a BOM, blank space, auto_prepend_file) has already sent
+ // the headers when output buffering is off. When it is on, the bytes are still in the buffer.
+ $early = function_exists('headers_sent') && headers_sent();
+ $level = 0;
+ if (p2s_can('ob_start ob_get_level ob_get_length ob_clean ob_end_clean')) {
+ if (!$early && ob_get_level() > 0 && ob_get_length() > 0) {
+ @ob_clean();
+ }
+ // A buffer of our own catches anything printed later, so it never reaches the response
+ if (@ob_start()) {
+ $level = ob_get_level();
+ }
+ }
+ define('P2S_EARLY_OUTPUT', $early);
+ define('P2S_OB_LEVEL', $level);
+}
+
+// Drop whatever was printed so far, send the headers if still possible, print $body and stop.
+function p2s_respond($not_found, $headers, $body)
+{
+ if (P2S_OB_LEVEL > 0) {
+ while (ob_get_level() >= P2S_OB_LEVEL) {
+ if (!@ob_end_clean()) {
+ break;
+ }
+ }
+ }
+ if (p2s_can('header headers_sent') && !headers_sent()) {
+ if ($not_found) {
+ if (function_exists('http_response_code')) {
+ http_response_code(404);
+ } else {
+ header('HTTP/1.0 404 Not Found');
+ }
+ }
+ header('Content-Type: text/plain; charset=utf-8');
+ foreach ($headers as $line) {
+ header($line);
+ }
+ }
+ echo $body;
+ exit;
+}
+
+// A bare 404 for anyone without the token: no path, no PHP version.
+function p2s_not_found()
+{
+ if (p2s_can('header headers_sent') && !headers_sent()) {
+ if (function_exists('header_remove')) {
+ @header_remove('X-Powered-By');
+ } else {
+ @header('X-Powered-By:');
+ }
+ }
+ p2s_respond(true, array('Cache-Control: no-store'), "Not Found\n");
+}
+
+// The request had no usable token. If the copy of this file that ran is not the one on disk
+// (OPcache with opcache.validate_timestamps=0 keeps running a replaced file), drop the cached
+// copy so that the next request runs the file on disk. A visitor who holds the token of that
+// newer file is told to load the page again; everyone else gets the bare 404. Nothing is
+// cleared when the token on disk cannot be read: each clear wastes OPcache memory, so
+// clearing on every refused request would let any visitor force OPcache restarts.
+function p2s_refuse($token, $given)
+{
+ $disk = p2s_disk_token();
+ if ($disk !== null && $disk !== $token) {
+ $cleared = p2s_uncache();
+ if ($disk !== null && p2s_token_usable($disk) && p2s_equals($disk, $given)) {
+ p2s_respond(false, array('Cache-Control: no-store', 'X-Robots-Tag: noindex, nofollow'),
+ 'php2shell-selfcheck ' . P2S_TOOL_VERSION . "\n"
+ . "The file on the server has changed, but PHP ran an older cached copy of it.\n"
+ . ($cleared
+ ? "The cached copy has now been cleared. Load this page again. If this message comes back, upload the file under a new name.\n"
+ : "PHP did not let the probe clear the cached copy (opcache.restrict_api, or a disabled function). Upload the file under a new name.\n"));
+ }
+ }
+ p2s_not_found();
+}
+
+// $TOKEN as a string. An unquoted number still works when it fits in an integer and has no
+// leading 0; anything else counts as no token.
+function p2s_token_text($token)
+{
+ if (is_string($token)) {
+ return $token;
+ }
+ return is_int($token) ? (string)$token : '';
+}
+
+// 12 or more of A-Z a-z 0-9 - _, the characters that pass through a URL unchanged.
+function p2s_token_usable($token)
+{
+ $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
+ return is_string($token) && $token !== 'CHANGE_ME' && strlen($token) >= 12
+ && strspn($token, $chars) === strlen($token);
+}
+
+function p2s_equals($known, $given)
+{
+ if (function_exists('hash_equals')) {
+ return hash_equals($known, $given);
+ }
+ if (strlen($known) !== strlen($given)) {
+ return false;
+ }
+ $diff = 0;
+ for ($i = 0; $i < strlen($known); $i++) {
+ $diff |= ord($known[$i]) ^ ord($given[$i]);
+ }
+ return $diff === 0;
+}
+
+// The token written in this file on disk now (quoted, or an unquoted number), or null when
+// the file cannot be read, the token line is not found or its number is not one PHP keeps as written.
+function p2s_disk_token()
+{
+ $code = p2s_read(__FILE__);
+ if (!is_string($code) || !function_exists('preg_match')) {
+ return null;
+ }
+ if (preg_match('/^\s*\$TOKEN\s*=\s*(?:([\'"])([^\'"]*)\1|([0-9]+))\s*;/m', $code, $m)) {
+ if (!isset($m[3]) || $m[3] === '') {
+ return $m[2];
+ }
+ // An unquoted number counts only when PHP compiles it to the same digits. With a leading 0
+ // it is octal, and when it is too large for an integer it is a float, which is no token.
+ return (string)(int)$m[3] === $m[3] ? $m[3] : null;
+ }
+ return null;
+}
+
+// The contents of a file, or false.
+function p2s_read($path)
+{
+ if (function_exists('file_get_contents')) {
+ return @file_get_contents($path);
+ }
+ if (!p2s_can('fopen fread fclose')) {
+ return false;
+ }
+ $fh = @fopen($path, 'rb');
+ if (!$fh) {
+ return false;
+ }
+ $data = '';
+ while (strlen($data) < 1048576) {
+ $chunk = @fread($fh, 8192);
+ if ($chunk === false || $chunk === '') {
+ break;
+ }
+ $data .= $chunk;
+ }
+ fclose($fh);
+ return $data;
+}
+
+// Remove this file from the opcode cache, so that the next request compiles the file on disk.
+// Returns false when nothing was cleared: no opcode cache, the file was not cached, or the cache refused.
+function p2s_uncache()
+{
+ $cleared = false;
+ if (function_exists('opcache_invalidate') && @opcache_invalidate(__FILE__, true)) {
+ $cleared = true;
+ }
+ // APC, the opcode cache of PHP 5.2 to 5.4
+ if (function_exists('apc_delete_file') && @apc_delete_file(__FILE__)) {
+ $cleared = true;
+ }
+ return $cleared;
+}
+
+function p2s_delete()
+{
+ // Clear the cached copy first: on PHP 7.4 and older, opcache_invalidate() fails once the
+ // file is gone, and a stale copy would then answer a new upload under the same name.
+ p2s_uncache();
+ return function_exists('unlink') && @unlink(__FILE__);
+}
+
+function p2s_delete_note($self_delete, $deleted)
+{
+ if (!$self_delete) {
+ return "Warning: self-delete is turned off. Delete this file when you have finished.\n";
+ }
+ if ($deleted) {
+ return "This probe has been deleted from the server.\n";
+ }
+ return 'Warning: this probe could not be deleted. Delete it from the server yourself: ' . basename(__FILE__) . "\n";
+}
+
+// The values of the given settings, with null for a setting this PHP does not have. null
+// instead of the list when neither ini_get() nor ini_get_all() can be called.
+function p2s_ini_values($names)
+{
+ $out = array();
+ if (function_exists('ini_get')) {
+ foreach ($names as $name) {
+ $value = @ini_get($name);
+ $out[$name] = ($value === false || $value === null) ? null : (string)$value;
+ }
+ return $out;
+ }
+ if (function_exists('ini_get_all')) {
+ $all = @ini_get_all();
+ if (is_array($all)) {
+ foreach ($names as $name) {
+ if (!isset($all[$name]) || !is_array($all[$name])) {
+ $out[$name] = null;
+ } else {
+ // ini_get() returns '' where ini_get_all() has null
+ $out[$name] = isset($all[$name]['local_value']) ? (string)$all[$name]['local_value'] : '';
+ }
+ }
+ return $out;
+ }
+ }
+ return null;
+}
+
+// Whether open_basedir limits the paths PHP may open. Only the yes or no is reported, not the paths.
+function p2s_open_basedir()
+{
+ $ini = p2s_ini_values(array('open_basedir'));
+ if ($ini !== null) {
+ return is_string($ini['open_basedir']) && $ini['open_basedir'] !== '';
+ }
+ // The settings cannot be read
+ return p2s_root_visible() === false;
+}
+
+// Whether PHP may look at the root directory: it always exists, so only open_basedir hides it.
+// null when neither is_dir() nor file_exists() can be called.
+function p2s_root_visible()
+{
+ if (function_exists('is_dir')) {
+ return @is_dir('/');
+ }
+ if (function_exists('file_exists')) {
+ return @file_exists('/');
+ }
+ return null;
+}
+
+// The kernel release, as uname -r prints it, or '' when it cannot be read.
+function p2s_kernel_release()
+{
+ if (function_exists('php_uname')) {
+ $release = @php_uname('r');
+ if (is_string($release) && $release !== '') {
+ return $release;
+ }
+ }
+ $release = p2s_read('/proc/sys/kernel/osrelease');
+ if (is_string($release)) {
+ $release = trim($release);
+ if ($release !== '' && strlen($release) <= 200) {
+ return $release;
+ }
+ }
+ return '';
+}
+
+// true when a container marker exists, null when the probe cannot look for one (open_basedir
+// hides the root directory), false otherwise.
+function p2s_container()
+{
+ if (!function_exists('file_exists')) {
+ return null;
+ }
+ if (@file_exists('/.dockerenv') || @file_exists('/run/.containerenv')) {
+ return true;
+ }
+ return p2s_root_visible() === true ? false : null;
+}
+
+// Fields of /etc/os-release, or of the legacy release files. null when none could be read,
+// usually because open_basedir blocks /etc (see open_basedir in the report).
+function p2s_os_release()
+{
+ if (!function_exists('preg_match')) {
+ return null;
+ }
+ foreach (array('/etc/os-release', '/usr/lib/os-release') as $file) {
+ $content = p2s_read($file);
+ if (!is_string($content) || $content === '') {
+ continue;
+ }
+ $out = array();
+ foreach (explode("\n", $content) as $line) {
+ if (preg_match('/^(ID|ID_LIKE|VERSION_ID|VERSION_CODENAME|PRETTY_NAME)=(.*)$/', trim($line), $m)) {
+ $out[$m[1]] = trim($m[2], "\"'");
+ }
+ }
+ if (count($out) > 0) {
+ return $out;
+ }
+ }
+ // Older distributions without os-release
+ $out = array();
+ foreach (array('/etc/redhat-release', '/etc/debian_version') as $file) {
+ $content = p2s_read($file);
+ if (is_string($content) && $content !== '') {
+ $out['LEGACY_' . strtoupper(basename($file))] = trim($content);
+ }
+ }
+ return count($out) > 0 ? $out : null;
+}
+
+// Exact distribution versions of the installed PHP packages (used to check for backports).
+// null if the package list cannot be read (open_basedir, or not a dpkg system).
+function p2s_dpkg_php_packages()
+{
+ if (!p2s_can('fopen fgets fclose preg_match')) {
+ return null;
+ }
+ $fh = @fopen('/var/lib/dpkg/status', 'r');
+ if (!$fh) {
+ return null;
+ }
+ $found = array();
+ $cur = array();
+ while (true) {
+ $line = @fgets($fh);
+ if ($line === false || trim($line) === '') {
+ if (isset($cur['Package'], $cur['Version'], $cur['Status'])
+ && preg_match('/ ok installed$/', $cur['Status'])
+ && preg_match('/^(libapache2-mod-php[0-9.]+|php[0-9.]+-(common|fpm|cli|cgi))$/', $cur['Package'])
+ && count($found) < 40) {
+ $found[] = array(
+ 'name' => $cur['Package'],
+ 'version' => $cur['Version'],
+ 'source' => isset($cur['Source']) ? $cur['Source'] : '',
+ );
+ }
+ $cur = array();
+ if ($line === false) {
+ break;
+ }
+ continue;
+ }
+ if (preg_match('/^(Package|Status|Version|Source): (.*)$/', rtrim($line, "\r\n"), $m)) {
+ $cur[$m[1]] = $m[2];
+ }
+ }
+ fclose($fh);
+ return $found;
+}
+
+function p2s_json($value, $depth)
+{
+ if (is_null($value)) {
+ return 'null';
+ }
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+ if (is_int($value) || is_float($value)) {
+ return (string)$value;
+ }
+ if (is_array($value)) {
+ if (count($value) === 0) {
+ return '[]';
+ }
+ $pad = str_repeat(' ', $depth + 1);
+ $items = array();
+ $is_list = array_keys($value) === range(0, count($value) - 1);
+ foreach ($value as $k => $v) {
+ $items[] = $pad . ($is_list ? '' : p2s_json_string((string)$k) . ': ') . p2s_json($v, $depth + 1);
+ }
+ $close = str_repeat(' ', $depth);
+ return ($is_list ? '[' : '{') . "\n" . implode(",\n", $items) . "\n" . $close . ($is_list ? ']' : '}');
+ }
+ return p2s_json_string((string)$value);
+}
+
+function p2s_json_string($s)
+{
+ $out = '"';
+ $len = strlen($s);
+ for ($i = 0; $i < $len; $i++) {
+ $c = $s[$i];
+ $o = ord($c);
+ if ($c === '"') {
+ $out .= '\\"';
+ } elseif ($c === '\\') {
+ $out .= '\\\\';
+ } elseif ($o < 0x20 || $o === 0x7f) {
+ $out .= sprintf('\\u%04x', $o);
+ } else {
+ $out .= $c;
+ }
+ }
+ return $out . '"';
+}
diff --git a/package.json b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "php2shell-scanner",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server; verdicts are computed in the browser.",
+ "license": "MIT",
+ "type": "module",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "test": "node --test",
+ "update-data": "node tools/update-distro-data.mjs",
+ "build": "node tools/build-site.mjs",
+ "serve": "node tools/build-site.mjs && node tools/serve.mjs"
+ }
+}
diff --git a/scripts/php2shell-server-check.sh b/scripts/php2shell-server-check.sh
@@ -0,0 +1,545 @@
+#!/bin/sh
+# php2shell-server-check.sh
+# The server script for PHP2Shell (CVE-2026-109618, CVE-2026-109619)
+#
+# - Read-only. It changes no files, restarts no services and makes no network connections.
+# - Run it as root so it can also check processes and logs owned by other accounts (www-data etc.).
+# - Paste the "PHP2SHELL SERVERCHECK" block at the end into the check page.
+#
+# Usage: sh php2shell-server-check.sh [--logs] [--webroot DIR]...
+# --logs Search web server and PHP logs, the kernel log and session files for signs of an attack.
+# --webroot DIR List PHP files under DIR changed in the last 3 days (to spot web shells). Can be repeated.
+
+set -u
+
+TOOL_VERSION=1.0.0
+CVES="CVE-2026-109618 CVE-2026-109619"
+DO_LOGS=0
+WEBROOTS=""
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --logs) DO_LOGS=1 ;;
+ --webroot)
+ shift
+ if [ $# -eq 0 ]; then echo "--webroot needs a directory." >&2; exit 2; fi
+ WEBROOTS="$WEBROOTS
+$1"
+ ;;
+ -h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) echo "Unknown option: $1 (see --help)" >&2; exit 2 ;;
+ esac
+ shift
+done
+
+NL='
+'
+MACHINE=""
+add() { MACHINE="$MACHINE$1$NL"; }
+# Machine lines are '|'-delimited, so replace any '|' and line breaks inside values.
+clean() { printf '%s' "$1" | tr '|\r\n' '/ '; }
+section() { printf '\n== %s ==\n' "$1"; }
+have() { command -v "$1" >/dev/null 2>&1; }
+# File names, log lines and command lines can be chosen by an attacker. Replace control characters (C0, DEL and
+# UTF-8 encoded C1) before printing them, so that escape sequences cannot change what the terminal shows.
+C1_RE=$(printf '\302[\200-\237]')
+safe() { tr '\t' ' ' | LC_ALL=C sed "s/[[:cntrl:]]/?/g; s/$C1_RE/?/g"; }
+# A count in the result block is 0 only when every source behind it was read. When a source could not be read,
+# a count above 0 is still given (it is then a lower bound), and a 0 becomes "unknown".
+count_or_unknown() { if [ "$1" -gt 0 ] || [ "$2" = 0 ]; then printf '%s' "$1"; else printf 'unknown'; fi; }
+
+IS_ROOT=0
+if [ "$(id -u 2>/dev/null)" = "0" ]; then IS_ROOT=1; fi
+
+add "tool|php2shell-server-check|$TOOL_VERSION"
+add "env|root|$IS_ROOT"
+
+echo "php2shell-server-check $TOOL_VERSION"
+echo "Read-only check. Nothing is changed."
+if [ "$IS_ROOT" = 0 ]; then
+ echo "Note: not running as root, so processes owned by other accounts and some logs may not be checked. Counts that could not be checked are given as unknown."
+fi
+
+# ---------------------------------------------------------------- Operating system
+section "Operating system"
+OS_FILE=""
+for f in /etc/os-release /usr/lib/os-release; do
+ if [ -r "$f" ]; then OS_FILE=$f; break; fi
+done
+if [ -n "$OS_FILE" ]; then
+ for key in ID ID_LIKE VERSION_ID VERSION_CODENAME PRETTY_NAME; do
+ val=$(sed -n "s/^$key=//p" "$OS_FILE" | head -n 1 | sed "s/^[\"']//; s/[\"']\$//")
+ if [ -n "$val" ]; then add "os|$key|$(clean "$val")"; fi
+ if [ "$key" = PRETTY_NAME ] && [ -n "$val" ]; then echo "$val"; fi
+ done
+elif [ -r /etc/redhat-release ]; then
+ rel=$(cat /etc/redhat-release)
+ echo "$rel"
+ add "os|ID|rhel"
+ major=$(printf '%s' "$rel" | sed -n 's/.*release \([0-9][0-9]*\).*/\1/p')
+ if [ -n "$major" ]; then add "os|VERSION_ID|$major"; fi
+else
+ echo "No operating system information found."
+fi
+
+# ---------------------------------------------------------------- Packages
+section "Installed PHP packages"
+PKG_LINES=""
+if have dpkg-query; then
+ PKG_LINES=$(dpkg-query -W -f='${Status}|${Package}|${Version}|${Source}\n' 'php*' 'libapache2-mod-php*' 2>/dev/null |
+ awk -F'|' '$1 ~ / installed$/ && $2 ~ /^(libapache2-mod-php[0-9.]+|php[0-9.]+-(common|fpm|cli|cgi))$/ {
+ src = $4; sub(/ .*/, "", src); print "pkg|dpkg|" $2 "|" $3 "|" src }')
+fi
+if have rpm; then
+ # php-common: RHEL family and Fedora. php8.4-common: RHEL 10 and Amazon Linux 2023 (php8.4-modphp is the Apache
+ # module there). php82-php-common: Remi's collections. Extensions, PEAR and PECL packages are left out.
+ RPM_LINES=$(rpm -qa --qf '%{NAME}|%{EPOCH}:%{VERSION}-%{RELEASE}\n' 2>/dev/null | sed 's/|(none):/|/' |
+ awk -F'|' '$1 ~ /^(php|php-(common|fpm|cli|cgi)|php[0-9]+\.[0-9]+(-(common|fpm|cli|cgi|modphp))?|php[0-9]+-php-(common|fpm|cli)|mod_php)$/ {
+ print "pkg|rpm|" $1 "|" $2 "|" }')
+ if [ -n "$RPM_LINES" ]; then PKG_LINES="$PKG_LINES
+$RPM_LINES"; fi
+fi
+if have apk; then
+ APK_LINES=$(apk info -v 2>/dev/null | grep -E '^php[0-9]*(-(common|fpm|cgi|apache2))?-[0-9]' |
+ sed -E 's/^(.*)-([0-9][^-]*-r[0-9]+)$/pkg|apk|\1|\2|/')
+ if [ -n "$APK_LINES" ]; then PKG_LINES="$PKG_LINES
+$APK_LINES"; fi
+fi
+PKG_LINES=$(printf '%s\n' "$PKG_LINES" | sed '/^$/d')
+if [ -n "$PKG_LINES" ]; then
+ printf '%s\n' "$PKG_LINES" | awk -F'|' '{ printf " %-28s %s (%s)\n", $3, $4, $2 }'
+ add "$PKG_LINES"
+else
+ echo "No PHP installed through a package manager was found. It may have been built from source or come from a container image."
+fi
+
+# Does the package changelog record the CVE fixes? (On RHEL-family systems this is the most reliable evidence.)
+# The changelog of every matched dpkg and rpm package is read, so that the check page can use the one that belongs
+# to the PHP that actually runs. A package whose changelog cannot be read gets no changelog line.
+section "CVE entries in package changelogs"
+cve_counts() { # $1 = package, $2 = version, $3 = changelog text
+ echo " $1 $2"
+ for c in $CVES; do
+ n=$(printf '%s\n' "$3" | grep -cF "$c")
+ echo " $c: found on $n line(s)"
+ add "changelog|$(clean "$1")|$c|$n"
+ done
+}
+CL_PKGS=$(printf '%s\n' "$PKG_LINES" | awk -F'|' '($2 == "rpm" || $2 == "dpkg") && !seen[$2 "|" $3]++ { print $2 "|" $3 "|" $4 }')
+set -f
+for entry in $CL_PKGS; do
+ mgr=${entry%%|*}; rest=${entry#*|}; p=${rest%%|*}; v=${rest#*|}
+ if [ "$mgr" = rpm ]; then
+ if text=$(rpm -q --changelog "$p" 2>/dev/null); then
+ cve_counts "$p" "$v" "$text"
+ else
+ echo " $p $v: the changelog could not be read."
+ fi
+ else
+ log=""
+ for f in "/usr/share/doc/$p/changelog.Debian.gz" "/usr/share/doc/$p/changelog.gz"; do
+ if [ -r "$f" ]; then log=$f; break; fi
+ done
+ if [ -n "$log" ]; then
+ cve_counts "$p" "$v" "$(gzip -dc "$log" 2>/dev/null)"
+ else
+ echo " $p $v: no changelog installed (minimal and container images often leave out /usr/share/doc)."
+ fi
+ fi
+done
+set +f
+if [ -z "$CL_PKGS" ]; then echo " No changelogs to check."; fi
+
+# ---------------------------------------------------------------- Restart status
+# A process that was not restarted after an update replaced its files shows them as "(deleted)" in /proc.
+section "Processes running PHP"
+if [ -d /proc/self ]; then
+ PROC_LINES=""
+ UNREAD_LIST=""
+ READ_COMMS=""
+ for d in /proc/[0-9]*; do
+ comm=$(cat "$d/comm" 2>/dev/null) || continue
+ case "$comm" in
+ php-fpm*|php5-fpm*|apache2|httpd|frankenphp|lsphp*|php-cgi*|php5-cgi*) ;;
+ *) continue ;;
+ esac
+ comm=$(clean "$comm")
+ # Another account's maps file is readable only as root (inside a container, with CAP_SYS_PTRACE).
+ maps=$(cat "$d/maps" 2>/dev/null)
+ if [ -z "$maps" ]; then
+ UNREAD_LIST="$UNREAD_LIST$comm$NL"
+ continue
+ fi
+ # Remembered even when it turns out to be out of scope, so that unreadable processes of the same name
+ # (the workers of an Apache without mod_php) are not reported as PHP processes that could not be checked.
+ READ_COMMS="$READ_COMMS$comm$NL"
+ status=ok
+ case "$comm" in
+ apache2|httpd)
+ # Apache without mod_php (a proxy, or in front of PHP-FPM) is out of scope. For Apache only the PHP
+ # module and extensions count: an apache2-bin or httpd update alone leaves no old PHP code in memory.
+ printf '%s\n' "$maps" | grep -Eq 'libphp|mod_php' || continue ;;
+ *)
+ exe=$(readlink "$d/exe" 2>/dev/null)
+ case "$exe" in *" (deleted)") status=stale ;; esac ;;
+ esac
+ if printf '%s\n' "$maps" | grep -Eq '(libphp|php[^ ]*\.so|/php/).* \(deleted\)$'; then status=stale; fi
+ PROC_LINES="$PROC_LINES$comm|$status$NL"
+ done
+ if [ -n "$PROC_LINES" ]; then
+ SUMMARY=$(printf '%s' "$PROC_LINES" | sort | uniq -c |
+ awk '{ n = $1; sub(/^[ \t]*[0-9]+[ \t]/, ""); split($0, a, "|"); print "proc|" a[1] "|" n "|" a[2] }')
+ printf '%s\n' "$SUMMARY" | safe | awk -F'|' '{ s = ($4 == "stale") ? "using pre-update files, restart needed" : "using current files"; printf " %-14s %3s process(es): %s\n", $2, $3, s }'
+ add "$SUMMARY"
+ else
+ echo " No web server processes running PHP were found."
+ fi
+ # Child processes inherit the memory of their parent (master) process, so if at least one
+ # process with the same name was read, the ones that could not be read do not count.
+ if [ -n "$UNREAD_LIST" ]; then
+ MISSING=$(printf '%s' "$UNREAD_LIST" | sort | uniq -c | while read -r cnt c; do
+ printf '%s' "$READ_COMMS" | grep -qxF "$c" || echo "$cnt"
+ done | awk '{ s += $1 } END { print s + 0 }')
+ SKIPPED=$(printf '%s' "$UNREAD_LIST" | grep -c .)
+ if [ "$MISSING" -gt 0 ]; then
+ if [ "$IS_ROOT" = 1 ]; then
+ echo " ${MISSING} process(es) could not be checked, even as root. Inside a container, reading another account's"
+ echo " process memory map needs CAP_SYS_PTRACE: run the script on the host, or use the PHP probe."
+ else
+ echo " ${MISSING} process(es) could not be checked for lack of permission (run again as root)"
+ fi
+ add "proc|unreadable|$MISSING|unknown"
+ else
+ echo " (Skipped ${SKIPPED} process(es) that could not be read; a process with the same name was checked instead.)"
+ fi
+ fi
+ if [ "$IS_ROOT" = 0 ] && ! cat /proc/1/comm >/dev/null 2>&1; then
+ echo " /proc hides other accounts' processes from this account (hidepid), so PHP processes may be missing here. Run again as root."
+ fi
+else
+ echo " Could not check: /proc is not available."
+fi
+
+# ---------------------------------------------------------------- Configuration files
+section "Configuration files with interim mitigation settings"
+CONF_HITS=""
+for d in /etc/php /etc/php.ini /etc/php.d /etc/php-fpm.conf /etc/php-fpm.d /etc/php[0-9]* /etc/opt/remi \
+ /usr/local/etc/php /usr/local/etc/php-fpm.conf /usr/local/etc/php-fpm.d \
+ /opt/cpanel/ea-php*/root/etc /opt/plesk/php/*/etc /opt/alt/php*/etc /usr/local/lsws/lsphp*/etc \
+ /etc/apache2 /etc/httpd; do
+ [ -e "$d" ] || continue
+ # The setting name must stand alone: "max_memory_limit" or a commented-out line does not count.
+ # Templates and package manager leftovers (php.ini-production, *.default, *.rpmnew, *.dpkg-dist ...) are never loaded.
+ hits=$(grep -rIsnHE '^[[:space:]]*([^;#[:space:]][^;#]*[^a-z_.;#])?(memory_limit|session\.upload_progress\.enabled|session\.auto_start|fatal_error_backtraces|max_input_nesting_level)([^a-z_]|$)' "$d" 2>/dev/null |
+ grep -vE '^[^:]*\.(ini-development|ini-production|default|dist|rpmnew|rpmsave|dpkg-dist|dpkg-old|dpkg-new|ucf-dist|ucf-old):[0-9]+:')
+ if [ -n "$hits" ]; then CONF_HITS="$CONF_HITS$hits$NL"; fi
+done
+if [ -n "$CONF_HITS" ]; then
+ printf '%s' "$CONF_HITS" | head -n 60 | safe | sed 's/^/ /'
+ echo " (Each file applies to different targets. Use the PHP probe to see the values actually in effect.)"
+else
+ echo " None found in the usual configuration directories. The defaults may be in effect, or the settings are made"
+ echo " elsewhere (a hosting panel, .user.ini, a pool file in another place). The PHP probe shows the values in effect."
+fi
+
+# ---------------------------------------------------------------- Web server account processes
+section "Other processes running as the web server account"
+# A process counts as the web server only when its executable is a known server binary in a system directory,
+# owned by root. Names and arguments alone are not trusted: a miner started as "/tmp/.x/httpd" or with
+# "php-fpm" in its arguments must still be listed.
+server_name() {
+ case "$1" in php-fpm*|php5-fpm*|php-cgi*|php5-cgi*|lsphp*|apache2|httpd|httpd.*|nginx|frankenphp|lighttpd) return 0 ;; esac
+ return 1
+}
+system_path() {
+ case "$1" in /usr/*|/bin/*|/sbin/*|/opt/*) return 0 ;; esac
+ return 1
+}
+# A genuine PHP binary that runs a script named on its command line (php-cgi -f x.php, frankenphp php-cli x.php)
+# is not serving web requests. $1 = binary name, $2 = arguments.
+runs_script() {
+ case "$1" in php-cgi*|php5-cgi*|lsphp*|frankenphp) ;; *) return 1 ;; esac
+ case " $2 " in
+ *" -f "*|*" -r "*|*" -a "*|*" php-cli "*|*".php "*|*".phar "*|*".phtml "*|*".inc "*) return 0 ;;
+ esac
+ return 1
+}
+WEBUSER=unknown
+if ! have ps; then
+ echo " Could not check: ps is not installed."
+else
+ PS_OUT=$(ps -eo user=,pid=,ppid=,args= 2>/dev/null)
+ OTHERS=$(printf '%s\n' "$PS_OUT" | awk -v me="$(id -un 2>/dev/null)" 'NF && $1 != me { n++ } END { print n + 0 }')
+ if [ -z "$PS_OUT" ]; then
+ echo " Could not check: ps listed no processes."
+ elif [ "$IS_ROOT" = 0 ] && [ "$OTHERS" = 0 ]; then
+ echo " Could not check: other accounts' processes are hidden from this account (hidepid). Run again as root."
+ else
+ # Leave out this script, the processes it starts and the shells it was started from.
+ CANDIDATES=$(printf '%s\n' "$PS_OUT" | awk -v self="$$" '
+ NF >= 3 { n++; u[n] = $1; p[n] = $2; pp[$2] = $3; a = $0
+ sub(/^[ \t]*[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]*/, "", a); args[n] = a }
+ END {
+ x = self
+ while (x != "" && !(x in skip)) { skip[x] = 1; x = pp[x] }
+ mine[self] = 1
+ do { c = 0; for (i = 1; i <= n; i++) if (!(p[i] in mine) && (pp[p[i]] in mine)) { mine[p[i]] = 1; c = 1 } } while (c)
+ for (i = 1; i <= n; i++)
+ if (!(p[i] in skip) && !(p[i] in mine) && u[i] ~ /^(www-data|apache|nginx|http|wwwrun|nobody|lighttpd)$/)
+ print u[i], p[i], args[i]
+ }')
+ RES=$(printf '%s\n' "$CANDIDATES" | while read -r user pid args; do
+ [ -n "$pid" ] || continue
+ # busybox ps puts "{name} " in front of the arguments when the first argument is not the process name.
+ cmd=$args
+ # Patterns inside $(...) carry a leading "(": bash before 4.4 otherwise ends the substitution at ")".
+ case "$cmd" in ("{"*"} "*) cmd=${cmd#*"} "} ;; esac
+ exe=$(readlink "/proc/$pid/exe" 2>/dev/null)
+ if [ -n "$exe" ]; then
+ path=${exe% (deleted)}
+ if server_name "${path##*/}" && system_path "$path" && ! runs_script "${path##*/}" "$cmd" &&
+ [ -n "$(find -H "/proc/$pid/exe" -prune -user 0 2>/dev/null)" ]; then
+ continue
+ fi
+ else
+ # The executable of another account's process is readable only as root (in a container, with
+ # CAP_SYS_PTRACE). Fall back to the kernel's process name and the first argument.
+ comm=$(cat "/proc/$pid/comm" 2>/dev/null)
+ argv0=${cmd%% *}
+ by_name=0
+ if server_name "$comm" && ! runs_script "$comm" "$cmd"; then
+ case "$argv0" in
+ (/*) if system_path "$argv0" && server_name "${argv0##*/}"; then by_name=1; fi ;;
+ (*/*) ;;
+ (*) by_name=1 ;;
+ esac
+ fi
+ if [ "$by_name" = 1 ]; then echo N; continue; fi
+ exe="(not readable)"
+ fi
+ printf 'O %s %s exe=%s args=%s\n' "$user" "$pid" "$exe" "$args"
+ done)
+ ODD=$(printf '%s\n' "$RES" | sed -n 's/^O //p')
+ WEBUSER=$(printf '%s\n' "$ODD" | grep -c .)
+ BY_NAME=$(printf '%s\n' "$RES" | grep -c '^N$')
+ if [ "$WEBUSER" -gt 0 ]; then
+ printf '%s\n' "$ODD" | head -n 20 | safe | cut -c1-240 | sed 's/^/ /'
+ if [ "$WEBUSER" -gt 20 ]; then echo " ... and $((WEBUSER - 20)) more"; fi
+ echo " Check what these processes are. If a shell, download tool or coin miner is running as the web server account, suspect a compromise."
+ else
+ echo " None"
+ fi
+ if [ "$BY_NAME" -gt 0 ]; then
+ echo " (${BY_NAME} web server process(es) were accepted by name only, because their executable could not be read."
+ echo " Run as root on the host to check the executables too.)"
+ fi
+ fi
+fi
+add "webuser_proc|$WEBUSER"
+
+# ---------------------------------------------------------------- Logs (--logs)
+LOGS_READ=0
+LOGS_UNREAD=0
+LOG_DIRS_SEEN=$NL
+OOM_TOTAL=0
+CRASH_TOTAL=0
+# Succeeds when the files in directory $1 should be read: it exists, is readable and was not read under another name.
+log_dir() {
+ [ -d "$1" ] || return 1
+ if [ ! -r "$1" ] || [ ! -x "$1" ]; then
+ echo " $1: directory not readable"
+ LOGS_UNREAD=$((LOGS_UNREAD + 1))
+ return 1
+ fi
+ real=$(cd "$1" 2>/dev/null && pwd -P) || return 1
+ case "$LOG_DIRS_SEEN" in *"$NL$real$NL"*) return 1 ;; esac
+ LOG_DIRS_SEEN="$LOG_DIRS_SEEN$real$NL"
+ return 0
+}
+# Prints "<memory exhaustion count> <crash count>", then the last two matching lines.
+log_counts() {
+ LC_ALL=C awk '
+ /exhausted.*in Unknown on line 0/ { o++; hit = 1 }
+ /SIGSEGV|signal 11|Segmentation fault/ { c++; hit = 1 }
+ hit { last1 = last2; last2 = $0; hit = 0 }
+ END { print o + 0, c + 0; if (last1 != "") print last1; if (last2 != "") print last2 }'
+}
+scan_log() {
+ [ -f "$1" ] || return 0
+ case "$1" in
+ *.gz) reader=gzip ;;
+ *.xz) reader=xz ;;
+ *.bz2) reader=bzip2 ;;
+ *.zst) reader=zstd ;;
+ *) reader="cat" ;;
+ esac
+ res=""
+ if [ -r "$1" ] && have "$reader"; then
+ if [ "$reader" = cat ]; then
+ res=$(log_counts 2>/dev/null <"$1")
+ else
+ res=$("$reader" -dc 2>/dev/null <"$1" | log_counts)
+ fi
+ fi
+ if [ -z "$res" ]; then
+ printf ' %s: not readable\n' "$1" | safe
+ LOGS_UNREAD=$((LOGS_UNREAD + 1))
+ return 0
+ fi
+ LOGS_READ=$((LOGS_READ + 1))
+ counts=$(printf '%s\n' "$res" | head -n 1)
+ oom=${counts% *}
+ crash=${counts#* }
+ if [ "$oom" -gt 0 ] || [ "$crash" -gt 0 ]; then
+ printf ' %s: %s memory exhaustion error(s) before the script ran, %s crash(es)\n' "$1" "$oom" "$crash" | safe
+ printf '%s\n' "$res" | sed '1d' | safe | cut -c1-300 | sed 's/^/ /'
+ fi
+ OOM_TOTAL=$((OOM_TOTAL + oom))
+ CRASH_TOTAL=$((CRASH_TOTAL + crash))
+}
+
+if [ "$DO_LOGS" = 1 ]; then
+ section "Suspicious log entries"
+ # Web server error logs, including per-site and TLS logs (ssl_error_log, example.com-error.log) and panel paths.
+ for d in /var/log/apache2 /var/log/httpd /var/log/nginx /var/log/lighttpd /usr/local/apache/logs /etc/apache2/logs \
+ /usr/local/apache2/logs /usr/local/lsws/logs /var/www/vhosts/system/*/logs; do
+ if log_dir "$d"; then
+ for f in "$d"/*error*; do scan_log "$f"; done
+ fi
+ done
+ # PHP and PHP-FPM logs: /var/log/php-fpm/, /var/log/php83/ (Alpine), panel PHP-FPM directories, /var/log/php8.2-fpm.log.
+ for d in /var/log/php* /var/log/plesk-php* /opt/cpanel/ea-php*/root/usr/var/log/php-fpm; do
+ if log_dir "$d"; then
+ for f in "$d"/*log*; do scan_log "$f"; done
+ fi
+ done
+ for f in /var/log/php*.log*; do scan_log "$f"; done
+ if [ "$LOGS_READ" = 0 ]; then
+ echo " No web server or PHP log file could be read in the usual places. Logs may go to the journal, to a"
+ echo " hosting panel's own directory or, in a container, to stderr."
+ fi
+ FILES_INCOMPLETE=0
+ if [ "$LOGS_READ" = 0 ] || [ "$LOGS_UNREAD" -gt 0 ]; then FILES_INCOMPLETE=1; fi
+
+ KERNEL_READ=0
+ KCOUNT=0
+ if KLOG=$(dmesg 2>/dev/null); then
+ KERNEL_READ=1
+ elif have journalctl && KLOG=$(journalctl -k -q --no-pager 2>/dev/null) && [ -n "$KLOG" ]; then
+ KERNEL_READ=1
+ fi
+ if [ "$KERNEL_READ" = 1 ]; then
+ KERNEL=$(printf '%s\n' "$KLOG" | grep 'segfault' | grep -E 'php|apache2|httpd')
+ if [ -n "$KERNEL" ]; then
+ KCOUNT=$(printf '%s\n' "$KERNEL" | wc -l | tr -d ' ')
+ echo " ${KCOUNT} PHP/Apache segfault(s) in the kernel log"
+ printf '%s\n' "$KERNEL" | tail -n 3 | safe | cut -c1-300 | sed 's/^/ /'
+ fi
+ elif [ "$IS_ROOT" = 1 ]; then
+ echo " Kernel log: not readable (inside a container the kernel log is usually not available)."
+ else
+ echo " Kernel log: not readable (run as root)."
+ fi
+ CRASH_INCOMPLETE=$FILES_INCOMPLETE
+ if [ "$KERNEL_READ" = 0 ]; then CRASH_INCOMPLETE=1; fi
+
+ BIG=0
+ SESS_INCOMPLETE=0
+ for d in /var/lib/php/sessions /var/lib/php/session /var/lib/php5 /var/lib/php /tmp; do
+ [ -d "$d" ] || continue
+ if [ ! -r "$d" ] || [ ! -x "$d" ]; then
+ echo " $d: session directory not readable"
+ SESS_INCOMPLETE=1
+ continue
+ fi
+ n=$(find "$d" -maxdepth 1 -name 'sess_*' -size +1048576c 2>/dev/null | wc -l | tr -d ' ')
+ if [ "$n" -gt 0 ]; then echo " $d: ${n} session file(s) over 1 MB"; fi
+ BIG=$((BIG + n))
+ done
+
+ OOM_OUT=$(count_or_unknown "$OOM_TOTAL" "$FILES_INCOMPLETE")
+ CRASH_OUT=$(count_or_unknown "$((CRASH_TOTAL + KCOUNT))" "$CRASH_INCOMPLETE")
+ BIG_OUT=$(count_or_unknown "$BIG" "$SESS_INCOMPLETE")
+ echo " Log files read: ${LOGS_READ}. Log files or directories that could not be read: ${LOGS_UNREAD}."
+ echo " Total: memory exhaustion errors ${OOM_OUT}, crashes ${CRASH_OUT}, large session files ${BIG_OUT}"
+ case "$OOM_OUT $CRASH_OUT $BIG_OUT" in
+ *unknown*) echo " (unknown: a source for that count was not found or could not be read, so a 0 would not be reliable.)" ;;
+ esac
+ echo " (These entries can also appear in normal operation. If they repeat right after unusual multipart requests, suspect an attack.)"
+ add "log|oom|$OOM_OUT"
+ add "log|crash|$CRASH_OUT"
+ add "log|bigsession|$BIG_OUT"
+fi
+
+# ---------------------------------------------------------------- Recently changed PHP files (--webroot)
+if [ -n "$WEBROOTS" ]; then
+ section "PHP files changed in the last 3 days"
+ RECENT=0
+ WR_INCOMPLETE=0
+ OLD_IFS=$IFS
+ IFS=$NL
+ set -f
+ for dir in $WEBROOTS; do
+ [ -n "$dir" ] || continue
+ if [ ! -e "$dir" ]; then
+ echo " $dir: not found. Check the path."
+ WR_INCOMPLETE=1
+ continue
+ elif [ ! -d "$dir" ]; then
+ echo " $dir: not a directory."
+ WR_INCOMPLETE=1
+ continue
+ elif [ ! -r "$dir" ] || [ ! -x "$dir" ]; then
+ echo " $dir: not readable (run as root)."
+ WR_INCOMPLETE=1
+ continue
+ fi
+ # find -H follows the web root itself when it is a symbolic link (current -> releases/20261020).
+ # Symbolic links to directories inside it (a shared uploads/) are scanned too, one level deep,
+ # unless they point back into the web root or above it.
+ top=$(cd "$dir" 2>/dev/null && pwd -P)
+ prefix=${top%/}/
+ roots=$dir
+ seen=$NL$top$NL
+ linked=0
+ links=$(find -H "$dir" -type l 2>/dev/null)
+ for l in $links; do
+ [ -d "$l" ] || continue
+ real=$(cd "$l" 2>/dev/null && pwd -P) || continue
+ case "$real/" in "$prefix"*) continue ;; esac
+ case "$prefix" in "${real%/}/"*) continue ;; esac
+ case "$real" in /proc|/proc/*|/sys|/sys/*|/dev|/dev/*) continue ;; esac
+ case "$seen" in *"$NL$real$NL"*) continue ;; esac
+ seen=$seen$real$NL
+ roots=$roots$NL$l
+ linked=$((linked + 1))
+ done
+ files=""
+ partial=0
+ for r in $roots; do
+ out=$(find -H "$r" -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \) -mtime -3 2>/dev/null) || partial=1
+ if [ -n "$out" ]; then files=$files$out$NL; fi
+ done
+ n=$(printf '%s' "$files" | grep -c .)
+ note=""
+ if [ "$linked" -gt 0 ]; then note=" (including ${linked} linked directory(ies) outside it)"; fi
+ echo " $dir: ${n} file(s)$note"
+ printf '%s' "$files" | head -n 30 | safe | sed 's/^/ /'
+ if [ "$partial" = 1 ]; then
+ echo " Some subdirectories could not be read, so files may be missing from this list (run as root)."
+ WR_INCOMPLETE=1
+ fi
+ RECENT=$((RECENT + n))
+ done
+ set +f
+ IFS=$OLD_IFS
+ echo " (Files changed by deployments are listed too. Check the contents of any file you do not recognise.)"
+ add "webroot|recent_php|$(count_or_unknown "$RECENT" "$WR_INCOMPLETE")"
+fi
+
+# ---------------------------------------------------------------- Result block
+echo
+echo "Paste the block below into the check page. It holds OS release fields, package names and versions, process names, statuses and counts. No log lines and no file paths."
+echo "-----BEGIN PHP2SHELL SERVERCHECK-----"
+printf '%s' "$MACHINE" | sed '/^$/d' | safe
+echo "-----END PHP2SHELL SERVERCHECK-----"
diff --git a/site/app.js b/site/app.js
@@ -0,0 +1,623 @@
+import { parseInput } from './lib/parse.js';
+import * as engine from './lib/judge.js';
+import { UI } from './i18n.js';
+
+// The Korean page lives at kr/ and points data-root at the site root, so data,
+// downloads and config resolve the same from either page.
+const ROOT = new URL(document.documentElement.dataset.root || './', location.href);
+const LANG = document.documentElement.lang.startsWith('ko') ? 'ko' : 'en';
+const T = UI[LANG];
+const at = (path) => new URL(path, ROOT).href;
+
+const state = {
+ upstream: null,
+ distro: {},
+ distroLoaded: false,
+ status: null,
+ repo: 'https://github.com/sqzer-x/php2shell-scanner',
+ last: null,
+};
+const FAMILY_NAMES = { debian: 'Debian', ubuntu: 'Ubuntu' };
+// The status.json source that feeds each distribution family's data.
+const FAMILY_SOURCE = { debian: 'osv-debian', ubuntu: 'osv-ubuntu', rhel: 'redhat' };
+const RHEL_IDS = ['rhel', 'centos', 'rocky', 'almalinux', 'ol', 'cloudlinux', 'eurolinux', 'amzn'];
+const STALE_AFTER_MS = 3 * 24 * 3600 * 1000;
+const NBSP = String.fromCharCode(0xa0);
+
+const labels = () => (engine.verdictLabels ? engine.verdictLabels(LANG) : engine.VERDICT_LABELS);
+
+// The empty result pane as shipped in the page, restored by Clear.
+const EMPTY_RESULT = [...(document.getElementById('result')?.childNodes || [])].map((n) => n.cloneNode(true));
+
+// ── DOM helpers ─────────────────────────────────────────────────────
+
+function h(tag, attrs, ...children) {
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v === null || v === undefined || v === false) continue;
+ if (k === 'class') el.className = v;
+ else el.setAttribute(k, v === true ? '' : String(v));
+ }
+ for (const c of children.flat()) {
+ if (c === null || c === undefined || c === false) continue;
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
+ }
+ return el;
+}
+
+// Engine text is plain. Command-line flags and PHP setting names inside it are
+// set as code so they read as literals and never break across lines.
+const LITERAL = /(--[a-z][\w-]*|\b[a-z][a-z0-9]*(?:[._][a-z0-9]+)*_[a-z0-9]+(?:[._][a-z0-9]+)*(?:=-?\d+)?)/g;
+function rich(text) {
+ return String(text).split(LITERAL).map((part, i) => (i % 2 ? h('code', { class: 'lit' }, part) : part));
+}
+
+// A package version with a line-break opportunity after "-" and "~" and before
+// "+", so a long distribution revision can wrap between its parts and never
+// inside a number. <wbr> is not copied with the text.
+function breakable(text) {
+ const s = String(text);
+ const out = [];
+ let start = 0;
+ for (let i = 1; i < s.length; i++) {
+ if (s[i] === '+' || s[i - 1] === '-' || s[i - 1] === '~') {
+ out.push(s.slice(start, i), h('wbr'));
+ start = i;
+ }
+ }
+ out.push(s.slice(start));
+ return out;
+}
+
+const COPY_ICONS = document.querySelector('.copy')?.innerHTML || '';
+function cmdline(code) {
+ const multi = code.includes('\n');
+ const button = h('button', { class: 'copy', type: 'button', 'aria-label': T.copyCommand });
+ button.innerHTML = COPY_ICONS; // static SVG markup taken from the page itself
+ return h('div', { class: 'cmdline' }, h(multi ? 'pre' : 'code', { class: multi ? 'cmd multi' : 'cmd' }, code), button);
+}
+
+async function writeClipboard(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function copyCommand(button) {
+ const code = button.closest('.cmdline')?.querySelector('.cmd');
+ if (!code) return;
+ if (await writeClipboard(code.textContent)) {
+ const original = button.getAttribute('aria-label');
+ button.classList.add('is-done');
+ button.setAttribute('aria-label', T.copied);
+ setTimeout(() => {
+ button.classList.remove('is-done');
+ button.setAttribute('aria-label', original);
+ }, 1500);
+ } else {
+ const range = document.createRange();
+ range.selectNodeContents(code);
+ getSelection().removeAllRanges();
+ getSelection().addRange(range);
+ }
+}
+
+async function fetchOrNull(path, as) {
+ try {
+ const res = await fetch(at(path), { cache: 'no-cache' });
+ if (!res.ok) return null;
+ return as === 'text' ? await res.text() : await res.json();
+ } catch {
+ return null;
+ }
+}
+
+const dateTime = new Intl.DateTimeFormat(T.locale, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: 'UTC' });
+const dateOnly = new Intl.DateTimeFormat(T.locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
+const isDate = (iso) => typeof iso === 'string' && !Number.isNaN(Date.parse(iso));
+const formatDateTime = (iso) => (isDate(iso) ? `${dateTime.format(new Date(iso))} ${T.utc}` : String(iso));
+const formatDate = (iso) => (isDate(iso) ? dateOnly.format(new Date(iso)) : String(iso));
+const listOf = (items) => (Intl.ListFormat ? new Intl.ListFormat(T.locale, { type: 'conjunction' }).format(items) : items.join(', '));
+
+// ── Data freshness ──────────────────────────────────────────────────
+
+// How current one source in status.json is. ok is true or false for the latest
+// refresh, or null when status.json does not say. at is the last successful
+// refresh when known: a source may report last_success, otherwise a source
+// that succeeded counts as refreshed at checked_at.
+function freshness(key) {
+ const s = state.status?.sources?.[key];
+ if (!s) return { ok: null, at: null };
+ const last = isDate(s.last_success) ? s.last_success : null;
+ if (s.ok) return { ok: true, at: last || (isDate(state.status.checked_at) ? state.status.checked_at : null) };
+ return { ok: false, at: last };
+}
+
+// The distribution family whose data the verdict rests on, or null when it was
+// judged against upstream releases only. Prefers what the engine reports, then
+// the Distribution choice, then the facts in the observation.
+function resultFamily(r, obs, choice) {
+ // null from the engine means the verdict rests on upstream releases only.
+ const reported = r.distro?.family !== undefined ? r.distro.family : r.family;
+ if (typeof reported === 'string' || reported === null) return reported;
+ if (choice && choice !== 'auto') return choice === 'upstream' ? null : choice.split(':')[0];
+ const os = obs.os || {};
+ const like = `${os.id || ''} ${os.idLike || ''}`;
+ if (os.id === 'debian' || os.id === 'ubuntu') return os.id;
+ if (RHEL_IDS.includes(os.id) || (/\brhel\b/.test(like) && os.id !== 'fedora')) return 'rhel';
+ if (os.id) return null;
+ const versions = [obs.phpVersion, ...(obs.packages || []).map((p) => p.version)].filter(Boolean).join(' ');
+ if (/[~+]deb\d+u\d+/.test(versions)) return 'debian';
+ if (/ubuntu/.test(versions)) return 'ubuntu';
+ if (/\.el\d/.test(versions)) return 'rhel';
+ return null;
+}
+
+// What the page adds to the engine's result: the data line under the verdict,
+// and reasons and notes about data that did not load or is not current.
+function dataContext(r, obs, choice) {
+ const reasons = [...r.reasons];
+ const notes = [...r.notes];
+ const key = FAMILY_SOURCE[resultFamily(r, obs, choice)];
+ let line = null;
+ if (!key) {
+ const s = state.status;
+ const checked = s?.upstream_matches_advisory === true && isDate(s.checked_at);
+ line = `${T.upstreamData}: ${checked ? T.crossChecked(formatDateTime(s.checked_at)) : formatDate(state.upstream.updated)}`;
+ } else if (!state.distroLoaded) {
+ reasons.unshift(T.distroNotLoaded);
+ line = `${T.distroData}: ${T.notLoaded}`;
+ } else {
+ const f = freshness(key);
+ const when = f.at ? formatDateTime(f.at) : null;
+ if (f.ok === false) {
+ line = `${T.distroData}: ${when ? T.refreshFailedSince(when) : T.refreshFailed}`;
+ notes.push(T.sourceFailed(T.sourceNames[key] || key, when));
+ } else if (f.ok && when) {
+ line = `${T.distroData}: ${when}`;
+ if (Date.now() - Date.parse(f.at) > STALE_AFTER_MS) notes.push(T.stale(when));
+ } else if (isDate(state.distro.updated_at)) {
+ line = `${T.distroData}: ${T.dataChanged(formatDateTime(state.distro.updated_at))}`;
+ }
+ }
+ return { reasons, notes, line };
+}
+
+// ── Result ──────────────────────────────────────────────────────────
+
+function block(title, ...body) {
+ return h('section', { class: 'block' }, h('h4', {}, title), ...body);
+}
+
+const exposureLabel = (r) => (r.verdict === 'patched' ? T.exposurePatched : T.exposure);
+
+function renderResult(r, obs, choice) {
+ const out = document.getElementById('result');
+ const sources = [...new Set(obs.sources)].map((s) => T.sources[s] || s);
+ const { reasons, notes, line } = dataContext(r, obs, choice);
+ const meta = [sources.length ? `${T.input}: ${sources.join(' + ')}` : null, line].filter(Boolean).join(' · ');
+
+ const heading = h('h3', { tabindex: '-1' }, r.title);
+ const parts = [h('div', { class: 'verdict', 'data-verdict': r.verdict },
+ h('p', { class: 'verdict-label' }, labels()[r.verdict] || r.verdict),
+ heading,
+ reasons.length ? h('ul', { class: 'reasons' }, reasons.map((t) => h('li', {}, rich(t)))) : null,
+ meta ? h('p', { class: 'verdict-meta' }, meta) : null)];
+
+ const evidence = r.facts.map((f) => h('tr', {}, h('th', { scope: 'row' }, f.label), h('td', { class: 'mono' }, breakable(f.value))));
+ if (r.exposure) {
+ evidence.push(h('tr', {}, h('th', { scope: 'row' }, exposureLabel(r)),
+ h('td', {}, h('span', { class: `badge badge-${r.exposure.level}` }, T.levels[r.exposure.level] || r.exposure.level), rich(r.exposure.text))));
+ }
+ if (evidence.length) parts.push(block(T.evidence, h('table', { class: 'kv' }, h('tbody', {}, evidence))));
+
+ if (r.actions.length) {
+ parts.push(block(T.actions, h('ol', { class: 'actions' }, r.actions.map((a) => h('li', {}, rich(a.text), a.code ? cmdline(a.code) : null)))));
+ }
+
+ if (r.findings?.length) {
+ parts.push(block(T.indicators, h('ul', { class: 'indicators' }, r.findings.map((f) =>
+ h('li', {}, h('span', { class: `badge badge-${f.level}` }, T.levels[f.level] || f.level), h('span', {}, rich(f.text)))))));
+ }
+
+ if (r.mitigations.length) {
+ // On phones the value and status columns are hidden and shown under the
+ // setting name instead (.mit-inline); only one of the two is ever displayed.
+ const badge = (m) => h('span', { class: `badge badge-${m.status}` }, T.statuses[m.status] || m.status);
+ const rows = r.mitigations.flatMap((m) => [
+ h('tr', { class: 'mit-row' },
+ h('td', { class: 'setting' }, h('span', { class: 'mit-name' }, m.name),
+ h('span', { class: 'mit-inline' }, h('span', {}, m.value), badge(m))),
+ h('td', { class: 'value' }, m.value),
+ h('td', { class: 'status' }, badge(m))),
+ h('tr', { class: 'mit-note' }, h('td', { colspan: '3' }, rich(m.note))),
+ ]);
+ const patched = r.verdict === 'patched';
+ parts.push(block(patched ? T.hardening : T.mitigations,
+ h('p', {}, rich(patched ? T.hardeningIntro : T.mitIntro)),
+ h('table', { class: 'mit' },
+ h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, T.setting), h('th', { scope: 'col' }, T.value), h('th', { scope: 'col', class: 'status' }, T.status))),
+ h('tbody', {}, rows))));
+ }
+
+ if (r.advisories.length) {
+ parts.push(block(T.advisories, h('table', { class: 'kv' }, h('tbody', {},
+ r.advisories.map((a) => h('tr', {}, h('th', { scope: 'row' }, a.label), h('td', { class: 'mono' }, breakable(a.value))))))));
+ }
+
+ if (notes.length) parts.push(block(T.notes, h('ul', { class: 'plain' }, notes.map((t) => h('li', {}, rich(t))))));
+
+ out.replaceChildren(...parts);
+ state.last = { r, meta, reasons, notes };
+ document.getElementById('copy-report').hidden = false;
+ heading.focus({ preventScroll: true });
+ if (matchMedia('(max-width: 75rem)').matches) {
+ document.querySelector('.pane-result').scrollIntoView({ block: 'start' });
+ } else {
+ document.querySelector('.pane-result').scrollTop = 0;
+ }
+}
+
+function resetResult() {
+ document.getElementById('result').replaceChildren(...EMPTY_RESULT.map((n) => n.cloneNode(true)));
+ document.getElementById('copy-report').hidden = true;
+ state.last = null;
+}
+
+function reportMarkdown({ r, meta, reasons, notes }) {
+ const lines = [`## ${T.reportTitle(labels()[r.verdict])}`, '', `**${r.title}**`, ''];
+ for (const t of reasons) lines.push(`- ${t}`);
+ if (meta) lines.push('', `_${meta}_`);
+ if (r.facts.length) {
+ lines.push('', `### ${T.evidence}`);
+ for (const f of r.facts) lines.push(`- ${f.label}: \`${f.value}\``);
+ if (r.exposure) lines.push(`- ${exposureLabel(r)}: ${T.levels[r.exposure.level] || r.exposure.level}. ${r.exposure.text}`);
+ }
+ if (r.actions.length) {
+ lines.push('', `### ${T.actions}`);
+ r.actions.forEach((a, i) => {
+ lines.push(`${i + 1}. ${a.text}`);
+ if (a.code) lines.push('', ' ```sh', ...a.code.split('\n').map((c) => ` ${c}`), ' ```');
+ });
+ }
+ if (r.findings?.length) {
+ lines.push('', `### ${T.indicators}`);
+ for (const f of r.findings) lines.push(`- [${T.levels[f.level] || f.level}] ${f.text}`);
+ }
+ if (r.mitigations.length) {
+ lines.push('', `### ${r.verdict === 'patched' ? T.hardening : T.mitigations}`);
+ for (const m of r.mitigations) lines.push(`- \`${m.name}\` = \`${m.value}\`: ${T.statuses[m.status] || m.status}`);
+ }
+ if (r.advisories.length) {
+ lines.push('', `### ${T.advisories}`);
+ for (const a of r.advisories) lines.push(`- ${a.label}: ${a.value}`);
+ }
+ if (notes.length) {
+ lines.push('', `### ${T.notes}`);
+ for (const t of notes) lines.push(`- ${t}`);
+ }
+ lines.push('', T.reportFooter(state.repo, formatDateTime(new Date().toISOString())));
+ return lines.join('\n') + '\n';
+}
+
+async function loadDistro() {
+ const distro = await fetchOrNull('data/distro.json');
+ state.distro = distro || {};
+ state.distroLoaded = Boolean(distro);
+}
+
+async function runCheck(event) {
+ event?.preventDefault();
+ const input = document.getElementById('input');
+ if (!input.value.trim()) {
+ resetResult();
+ input.focus();
+ return;
+ }
+ // If the distribution data failed to load with the page, try once more
+ // rather than judge without it.
+ if (!state.distroLoaded) {
+ await loadDistro();
+ if (state.distroLoaded) renderReference();
+ }
+ const choice = document.getElementById('distro').value;
+ const obs = parseInput(input.value, { lang: LANG });
+ const result = engine.judge(obs, state.upstream, state.distro, { distro: choice, lang: LANG });
+ // Download commands in the steps follow the page's own address, like the ones in step 1.
+ if (engine.DEFAULT_DOWNLOADS) {
+ for (const a of result.actions) if (a.code) a.code = a.code.split(engine.DEFAULT_DOWNLOADS).join(at('downloads/'));
+ }
+ renderResult(result, obs, choice);
+}
+
+// ── Reference ───────────────────────────────────────────────────────
+
+function renderUpstreamTable() {
+ const { fixed, eol, backport_branches: backports } = state.upstream;
+ const rows = Object.entries(fixed).map(([branch, version]) => h('tr', {},
+ h('th', { scope: 'row' }, `PHP${NBSP}${branch}`),
+ h('td', { class: 'mono' }, `< ${version}`),
+ h('td', { class: 'mono' }, version, eol?.[branch] ? h('span', { class: 'dim eol' }, T.eol(formatDate(eol[branch]))) : null)));
+ const branches = Object.keys(backports || {});
+ rows.push(h('tr', {}, h('th', { scope: 'row' }, T.branch81), h('td', {}, T.all54), h('td', {}, T.noUpstream(branches.length ? listOf(branches) : null))));
+ document.querySelector('#upstream-table tbody').replaceChildren(...rows);
+}
+
+// A fixed version from distro.json: a version string, null when the
+// distribution tracks the CVE without a fix yet, or missing when there is no
+// record for it, which the engine treats as no data rather than as pending.
+function fixedValue(value) {
+ if (typeof value === 'string') return breakable(value);
+ return value === null
+ ? h('span', { class: 'badge badge-pending' }, T.fixPending)
+ : h('span', { class: 'badge badge-unknown' }, T.noData);
+}
+
+function fixedCell(entry, cves) {
+ const values = cves.map((c) => entry.fixed?.[c]);
+ if (values.every((v) => v === values[0])) {
+ return h('td', { class: typeof values[0] === 'string' ? 'mono ver' : null }, fixedValue(values[0]));
+ }
+ return h('td', { class: 'mono ver' }, cves.map((c, i) =>
+ h('span', { class: 'ver-line' }, `${c.replace(/^CVE-\d{4}-/, '')}:${NBSP}`, fixedValue(values[i]))));
+}
+
+const sortedReleases = (family) => Object.entries(state.distro[family] || {}).sort(([a], [b]) => parseFloat(b) - parseFloat(a));
+
+function renderDistroTables() {
+ const cves = state.upstream.cves.map((c) => c.id);
+ const rows = [];
+ for (const family of ['debian', 'ubuntu']) {
+ for (const [release, rel] of sortedReleases(family)) {
+ for (const [pkg, entry] of Object.entries(rel.packages || {})) {
+ rows.push(h('tr', {},
+ h('th', { scope: 'row' }, `${FAMILY_NAMES[family]} ${release}`, rel.codename ? h('span', { class: 'dim' }, ` ${rel.codename}`) : null,
+ entry.pro ? h('span', { class: 'badge badge-info', title: T.proTitle }, 'Pro') : null),
+ h('td', { class: 'mono' }, pkg),
+ fixedCell(entry, cves)));
+ }
+ }
+ }
+ if (!rows.length) rows.push(h('tr', {}, h('td', { colspan: '3' }, T.distroFailed)));
+ document.querySelector('#distro-table tbody').replaceChildren(...rows);
+
+ const rh = Object.entries(state.distro.redhat || {}).sort(([a], [b]) => Number(b) - Number(a));
+ const rhRows = [];
+ for (const [rel, v] of rh) {
+ const seen = new Set();
+ const items = [];
+ for (const a of v.advisories || []) {
+ const key = `${a.advisory} ${a.package}`;
+ if (!seen.has(key)) { seen.add(key); items.push(`${a.advisory} ${a.package}`); }
+ }
+ for (const s of v.states || []) {
+ const key = `${s.package} ${s.state}`;
+ if (!seen.has(key)) { seen.add(key); items.push(`${s.package}: ${s.state}`); }
+ }
+ items.forEach((item, i) => rhRows.push(h('tr', {}, i === 0 ? h('th', { scope: 'row', rowspan: String(items.length) }, `RHEL ${rel}`) : null, h('td', {}, item))));
+ }
+ document.getElementById('redhat-list').replaceChildren(...(rhRows.length
+ ? [h('p', { class: 'rh-intro' }, T.rhIntro), h('table', { class: 'rh' }, h('tbody', {}, rhRows))]
+ : [h('p', { class: 'rh-intro' }, T.rhFailed)]));
+}
+
+function renderDataStatus() {
+ const s = state.status;
+ const edited = formatDate(state.upstream.updated);
+ const parts = [];
+ if (!s) parts.push(T.statusUpstream(edited));
+ else if (s.upstream_matches_advisory === true && isDate(s.checked_at)) parts.push(T.statusUpstreamChecked(formatDateTime(s.checked_at)));
+ else if (s.upstream_matches_advisory === false) parts.push(T.statusUpstreamMismatch(edited));
+ else parts.push(T.statusUpstreamUnchecked(edited));
+
+ if (!state.distroLoaded) parts.push(T.statusDistroMissing);
+ else if (isDate(state.distro.updated_at)) {
+ parts.push(T.statusDistro(formatDateTime(state.distro.updated_at), isDate(s?.checked_at) ? formatDateTime(s.checked_at) : null));
+ }
+ // The php-src advisories only cross-check upstream.json; the line above covers them.
+ const failed = Object.entries(s?.sources || {}).filter(([k, v]) => k !== 'ghsa' && v && !v.ok);
+ const name = (k) => T.sourceNames[k] || k;
+ if (failed.length) parts.push(T.statusFailed(listOf(failed.map(([k]) => name(k)))));
+ for (const [k, v] of failed) {
+ if (isDate(v.last_success)) parts.push(T.statusLastSuccess(name(k), formatDateTime(v.last_success)));
+ }
+ document.getElementById('data-status').textContent = parts.join(' ');
+}
+
+function fillDistroSelect() {
+ const select = document.getElementById('distro');
+ const chosen = select.value;
+ select.replaceChildren(select.options[0]);
+ for (const family of ['debian', 'ubuntu']) {
+ const releases = sortedReleases(family);
+ if (!releases.length) continue;
+ select.append(h('optgroup', { label: FAMILY_NAMES[family] },
+ releases.map(([release, rel]) => h('option', { value: `${family}:${release}` }, `${FAMILY_NAMES[family]} ${release}${rel.codename ? ` (${rel.codename})` : ''}`))));
+ }
+ const rh = Object.keys(state.distro.redhat || {}).sort((a, b) => Number(b) - Number(a));
+ if (rh.length) select.append(h('optgroup', { label: T.rhelFamily }, rh.map((rel) => h('option', { value: `rhel:${rel}` }, T.rhelOption(rel)))));
+ select.append(h('option', { value: 'upstream' }, T.upstreamOption));
+ select.value = chosen;
+ if (select.value !== chosen) select.value = 'auto';
+}
+
+function renderReference() {
+ renderUpstreamTable();
+ renderDistroTables();
+ renderDataStatus();
+ fillDistroSelect();
+}
+
+// ── Page wiring ─────────────────────────────────────────────────────
+
+function setupTabs() {
+ const tabs = [...document.querySelectorAll('.tab')];
+ const select = (tab, focus) => {
+ for (const t of tabs) {
+ const on = t === tab;
+ t.setAttribute('aria-selected', String(on));
+ t.tabIndex = on ? 0 : -1;
+ document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
+ }
+ if (focus) tab.focus();
+ };
+ tabs.forEach((tab, i) => {
+ tab.addEventListener('click', () => select(tab));
+ tab.addEventListener('keydown', (e) => {
+ const next = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key];
+ if (next === undefined) return;
+ e.preventDefault();
+ select(tabs[(next + tabs.length) % tabs.length], true);
+ });
+ });
+}
+
+function setupDownloads(sums) {
+ const base = at('downloads/');
+ for (const code of document.querySelectorAll('.cmd[data-download]')) {
+ const then = code.dataset.then;
+ code.textContent = `curl -fsSLO ${base}${code.dataset.download}${then ? ` && ${then}` : ''}`;
+ }
+ for (const line of (sums || '').split('\n')) {
+ const m = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(line.trim());
+ const el = m && document.querySelector(`[data-sha-for="${m[2]}"]`);
+ if (el) el.textContent = `sha256 ${m[1]}`;
+ }
+}
+
+function setupRepoLinks(config) {
+ if (!config?.repository) return;
+ state.repo = config.repository;
+ for (const a of document.querySelectorAll('#repo-link, .repo-link')) a.href = config.repository;
+}
+
+// ── Examples ────────────────────────────────────────────────────────
+
+function probeOutput(report) {
+ const full = {
+ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, generated_at: '2026-10-23T01:00:00Z',
+ php_os: 'Linux', container: false, extensions: { session: true, opcache: true }, self_deleted: true, ...report,
+ };
+ return `php2shell-selfcheck 1.0.0\n\n-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(full, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
+}
+
+const UBUNTU_2204 = { PRETTY_NAME: 'Ubuntu 22.04.5 LTS', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy', ID: 'ubuntu', ID_LIKE: 'debian' };
+const DEFAULT_INI = {
+ memory_limit: '128M', post_max_size: '8M', max_input_nesting_level: '64',
+ 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1',
+};
+const UBUNTU_PACKAGES = [
+ { name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
+ { name: 'php8.1-common', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
+];
+
+const EXAMPLES = {
+ backport: probeOutput({
+ php_version: '8.1.2-1ubuntu2.28', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2',
+ server_software: 'Apache/2.4.52 (Ubuntu)', os_release: UBUNTU_2204, dpkg_packages: UBUNTU_PACKAGES,
+ ini: { ...DEFAULT_INI, expose_php: '' },
+ }),
+ restart: probeOutput({
+ php_version: '8.1.2-1ubuntu2.20', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2',
+ server_software: 'Apache/2.4.52 (Ubuntu)', os_release: UBUNTU_2204, dpkg_packages: UBUNTU_PACKAGES, ini: DEFAULT_INI,
+ }),
+ docker: probeOutput({
+ php_version: '8.3.12', sapi: 'apache2handler', config_path: '/usr/local/etc/php',
+ server_software: 'Apache/2.4.62 (Debian)', container: true,
+ os_release: { PRETTY_NAME: 'Debian GNU/Linux 12 (bookworm)', VERSION_ID: '12', VERSION_CODENAME: 'bookworm', ID: 'debian' },
+ dpkg_packages: [], ini: DEFAULT_INI,
+ }),
+ rhel: `php2shell-server-check 1.0.0
+-----BEGIN PHP2SHELL SERVERCHECK-----
+tool|php2shell-server-check|1.0.0
+os|ID|almalinux
+os|ID_LIKE|rhel centos fedora
+os|VERSION_ID|9.8
+os|PRETTY_NAME|AlmaLinux 9.8 (Olive Jaguar)
+pkg|rpm|php-common|8.0.30-8.el9_8.1|
+pkg|rpm|php-fpm|8.0.30-8.el9_8.1|
+changelog|php-common|CVE-2026-109618|1
+changelog|php-common|CVE-2026-109619|1
+proc|php-fpm|1|ok
+webuser_proc|0
+log|oom|0
+log|crash|0
+log|bigsession|0
+-----END PHP2SHELL SERVERCHECK-----
+`,
+};
+const EXAMPLE_TEXTS = new Set(Object.values(EXAMPLES));
+
+// Choosing an example only selects it; Load replaces the input and runs the
+// check. Pasted output is never replaced without asking, because the probe
+// has already deleted itself and cannot simply be run again.
+function loadExample() {
+ const example = document.getElementById('example');
+ const input = document.getElementById('input');
+ const text = EXAMPLES[example.value];
+ if (!text) {
+ example.focus();
+ return;
+ }
+ if (input.value.trim() && !EXAMPLE_TEXTS.has(input.value) && !confirm(T.replaceConfirm)) return;
+ input.value = text;
+ example.value = '';
+ document.getElementById('distro').value = 'auto';
+ runCheck();
+}
+
+// ── Start ───────────────────────────────────────────────────────────
+
+async function init() {
+ setupTabs();
+ document.addEventListener('click', (e) => {
+ const btn = e.target.closest('.copy');
+ if (btn) copyCommand(btn);
+ });
+
+ const [upstream, distro, status, config, sums] = await Promise.all([
+ fetchOrNull('data/upstream.json'),
+ fetchOrNull('data/distro.json'),
+ fetchOrNull('data/status.json'),
+ fetchOrNull('config.json'),
+ fetchOrNull('downloads/SHA256SUMS', 'text'),
+ ]);
+ setupDownloads(sums);
+ setupRepoLinks(config);
+
+ if (!upstream) {
+ document.getElementById('result').replaceChildren(h('div', { class: 'verdict', 'data-verdict': 'unknown' },
+ h('h3', {}, T.loadFailedTitle), h('ul', { class: 'reasons' }, h('li', {}, T.loadFailedBody))));
+ return;
+ }
+ state.upstream = upstream;
+ state.distro = distro || {};
+ state.distroLoaded = Boolean(distro);
+ state.status = status;
+ renderReference();
+
+ const input = document.getElementById('input');
+ document.getElementById('check-form').addEventListener('submit', runCheck);
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) runCheck(e);
+ });
+ document.getElementById('clear').addEventListener('click', () => {
+ input.value = '';
+ document.getElementById('example').value = '';
+ resetResult();
+ input.focus();
+ });
+ document.getElementById('load-example').addEventListener('click', loadExample);
+ const copyReport = document.getElementById('copy-report');
+ copyReport.addEventListener('click', async () => {
+ if (!state.last) return;
+ const ok = await writeClipboard(reportMarkdown(state.last));
+ copyReport.textContent = ok ? T.copied : T.copyFailed;
+ setTimeout(() => { copyReport.textContent = T.copyReport; }, 1500);
+ });
+}
+
+init();
diff --git a/site/data/distro.json b/site/data/distro.json
@@ -0,0 +1,280 @@
+{
+ "updated_at": "2026-10-23T00:52:34.437Z",
+ "debian": {
+ "12": {
+ "codename": "bookworm",
+ "packages": {
+ "php8.2": {
+ "fixed": {
+ "CVE-2026-109618": "8.2.35-1~deb12u1",
+ "CVE-2026-109619": "8.2.35-1~deb12u1"
+ },
+ "pro": false
+ }
+ }
+ },
+ "13": {
+ "codename": "trixie",
+ "packages": {
+ "php8.4": {
+ "fixed": {
+ "CVE-2026-109618": "8.4.27-1~deb13u1",
+ "CVE-2026-109619": "8.4.27-1~deb13u1"
+ },
+ "pro": false
+ }
+ }
+ },
+ "14": {
+ "codename": "forky",
+ "packages": {
+ "php8.4": {
+ "fixed": {
+ "CVE-2026-109618": null,
+ "CVE-2026-109619": null
+ },
+ "pro": false
+ }
+ }
+ }
+ },
+ "redhat": {
+ "6": {
+ "advisories": [],
+ "states": [
+ {
+ "cve": "CVE-2026-109618",
+ "package": "php",
+ "state": "Under investigation"
+ },
+ {
+ "cve": "CVE-2026-109619",
+ "package": "php",
+ "state": "Not affected"
+ }
+ ]
+ },
+ "7": {
+ "advisories": [],
+ "states": [
+ {
+ "cve": "CVE-2026-109618",
+ "package": "php",
+ "state": "Under investigation"
+ },
+ {
+ "cve": "CVE-2026-109619",
+ "package": "php",
+ "state": "Not affected"
+ }
+ ]
+ },
+ "8": {
+ "advisories": [
+ {
+ "advisory": "RHSA-2026:87073",
+ "cve": "CVE-2026-109618",
+ "package": "php:8.2-8100020261020140951.f7998665"
+ },
+ {
+ "advisory": "RHSA-2026:87080",
+ "cve": "CVE-2026-109618",
+ "package": "php:7.4-8100020261021081045.f7998665"
+ },
+ {
+ "advisory": "RHSA-2026:87073",
+ "cve": "CVE-2026-109619",
+ "package": "php:8.2-8100020261020140951.f7998665"
+ },
+ {
+ "advisory": "RHSA-2026:87080",
+ "cve": "CVE-2026-109619",
+ "package": "php:7.4-8100020261021081045.f7998665"
+ }
+ ],
+ "states": []
+ },
+ "9": {
+ "advisories": [
+ {
+ "advisory": "RHSA-2026:87072",
+ "cve": "CVE-2026-109618",
+ "package": "php:8.2-9080020261020145734.9"
+ },
+ {
+ "advisory": "RHSA-2026:87079",
+ "cve": "CVE-2026-109618",
+ "package": "php:8.3-9080020261020115403.9"
+ },
+ {
+ "advisory": "RHSA-2026:87151",
+ "cve": "CVE-2026-109618",
+ "package": "php-0:8.0.30-8.el9_8.1"
+ },
+ {
+ "advisory": "RHSA-2026:87072",
+ "cve": "CVE-2026-109619",
+ "package": "php:8.2-9080020261020145734.9"
+ },
+ {
+ "advisory": "RHSA-2026:87079",
+ "cve": "CVE-2026-109619",
+ "package": "php:8.3-9080020261020115403.9"
+ },
+ {
+ "advisory": "RHSA-2026:87151",
+ "cve": "CVE-2026-109619",
+ "package": "php-0:8.0.30-8.el9_8.1"
+ }
+ ],
+ "states": [
+ {
+ "cve": "CVE-2026-109618",
+ "package": "php:8.4/php",
+ "state": "Affected"
+ },
+ {
+ "cve": "CVE-2026-109619",
+ "package": "php:8.4/php",
+ "state": "Affected"
+ }
+ ]
+ },
+ "10": {
+ "advisories": [
+ {
+ "advisory": "RHSA-2026:87070",
+ "cve": "CVE-2026-109618",
+ "package": "php-0:8.3.36-1.el10_2"
+ },
+ {
+ "advisory": "RHSA-2026:87071",
+ "cve": "CVE-2026-109618",
+ "package": "php8.4-0:8.4.27-1.el10_2"
+ },
+ {
+ "advisory": "RHSA-2026:87070",
+ "cve": "CVE-2026-109619",
+ "package": "php-0:8.3.36-1.el10_2"
+ },
+ {
+ "advisory": "RHSA-2026:87071",
+ "cve": "CVE-2026-109619",
+ "package": "php8.4-0:8.4.27-1.el10_2"
+ }
+ ],
+ "states": []
+ }
+ },
+ "redhat_notes": {
+ "CVE-2026-109618": {
+ "cvss3": "8.1",
+ "severity": "Important",
+ "statement": "Rated Important. An unauthenticated remote attacker could corrupt engine memory and potentially execute arbitrary code, but reliable exploitation requires precise heap manipulation and timing. Standard Red Hat Enterprise Linux deployments use PHP-FPM rather than mod_php. PHP-FPM workers exit on early initialization failures before reaching the corrupted structures, so remote code execution is difficult in practice and typically requires non-default behavior such as parsing a second request body during script execution."
+ },
+ "CVE-2026-109619": {
+ "cvss3": "8.1",
+ "severity": "Important",
+ "statement": "An unauthenticated remote attacker can trigger memory corruption leading to potential arbitrary code execution. In typical Red Hat Enterprise Linux deployments, PHP applications exposed to public networks handle incoming requests directly, so no prior authentication is needed to reach the vulnerable code. Exploitation requires hitting a precise out-of-memory condition during request processing, which keeps this below Critical."
+ }
+ },
+ "schema": 1,
+ "ubuntu": {
+ "14.04": {
+ "codename": "trusty",
+ "packages": {
+ "php5": {
+ "fixed": {
+ "CVE-2026-109618": "5.5.9+dfsg-1ubuntu4.29+esm18",
+ "CVE-2026-109619": "5.5.9+dfsg-1ubuntu4.29+esm18"
+ },
+ "pro": true
+ }
+ }
+ },
+ "16.04": {
+ "codename": "xenial",
+ "packages": {
+ "php7.0": {
+ "fixed": {
+ "CVE-2026-109618": "7.0.33-0ubuntu0.16.04.16+esm22",
+ "CVE-2026-109619": "7.0.33-0ubuntu0.16.04.16+esm22"
+ },
+ "pro": true
+ }
+ }
+ },
+ "18.04": {
+ "codename": "bionic",
+ "packages": {
+ "php7.2": {
+ "fixed": {
+ "CVE-2026-109618": "7.2.24-0ubuntu0.18.04.17+esm14",
+ "CVE-2026-109619": "7.2.24-0ubuntu0.18.04.17+esm14"
+ },
+ "pro": true
+ }
+ }
+ },
+ "20.04": {
+ "codename": "focal",
+ "packages": {
+ "php7.4": {
+ "fixed": {
+ "CVE-2026-109618": "7.4.3-4ubuntu2.29+esm5",
+ "CVE-2026-109619": "7.4.3-4ubuntu2.29+esm5"
+ },
+ "pro": true
+ }
+ }
+ },
+ "22.04": {
+ "codename": "jammy",
+ "packages": {
+ "php8.1": {
+ "fixed": {
+ "CVE-2026-109618": "8.1.2-1ubuntu2.28",
+ "CVE-2026-109619": "8.1.2-1ubuntu2.28"
+ },
+ "pro": false
+ }
+ }
+ },
+ "24.04": {
+ "codename": "noble",
+ "packages": {
+ "php8.3": {
+ "fixed": {
+ "CVE-2026-109618": "8.3.6-0ubuntu0.24.04.13",
+ "CVE-2026-109619": "8.3.6-0ubuntu0.24.04.13"
+ },
+ "pro": false
+ }
+ }
+ },
+ "26.04": {
+ "codename": "resolute",
+ "packages": {
+ "php8.5": {
+ "fixed": {
+ "CVE-2026-109618": "8.5.4-0ubuntu1.5",
+ "CVE-2026-109619": "8.5.4-0ubuntu1.5"
+ },
+ "pro": false
+ }
+ }
+ },
+ "26.10": {
+ "codename": "stonking",
+ "packages": {
+ "php8.5": {
+ "fixed": {
+ "CVE-2026-109618": "8.5.9-0ubuntu2.1",
+ "CVE-2026-109619": "8.5.9-0ubuntu2.1"
+ },
+ "pro": false
+ }
+ }
+ }
+ }
+}
diff --git a/site/data/upstream.json b/site/data/upstream.json
@@ -0,0 +1,35 @@
+{
+ "schema": 1,
+ "updated": "2026-10-23",
+ "released": "2026-10-21",
+ "cves": [
+ {
+ "id": "CVE-2026-109618",
+ "ghsa": "GHSA-w8hw-w9fx-j79w",
+ "title": "OOM handling leaves request memory corrupted",
+ "cvss4": 9.2,
+ "url": "https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w"
+ },
+ {
+ "id": "CVE-2026-109619",
+ "ghsa": "GHSA-6wmf-4rv5-p3rp",
+ "title": "Freed global state reused (use-after-free)",
+ "cvss4": 9.2,
+ "url": "https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp"
+ }
+ ],
+ "fixed": {
+ "8.2": "8.2.35",
+ "8.3": "8.3.36",
+ "8.4": "8.4.27",
+ "8.5": "8.5.12"
+ },
+ "eol": {
+ "8.2": "2026-12-31"
+ },
+ "backport_branches": {
+ "7.4": "PHP-7.4-CVE-2026-109618-109619",
+ "8.0": "PHP-8.0-CVE-2026-109618-109619",
+ "8.1": "PHP-8.1-CVE-2026-109618-109619"
+ }
+}
diff --git a/site/favicon.svg b/site/favicon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#000"/><path d="M11 24V8h5.5a4.5 4.5 0 0 1 0 9H11" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
diff --git a/site/fonts/OFL-JetBrainsMono.txt b/site/fonts/OFL-JetBrainsMono.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/site/fonts/OFL-Pretendard.txt b/site/fonts/OFL-Pretendard.txt
@@ -0,0 +1,94 @@
+Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
+with Reserved Font Name Pretendard.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/site/fonts/jetbrains-mono-400.woff2 b/site/fonts/jetbrains-mono-400.woff2
Binary files differ.
diff --git a/site/fonts/jetbrains-mono-700.woff2 b/site/fonts/jetbrains-mono-700.woff2
Binary files differ.
diff --git a/site/fonts/pretendard.css b/site/fonts/pretendard.css
@@ -0,0 +1,927 @@
+/*
+Copyright (c) 2021 Kil Hyung-jin, with Reserved Font Name Pretendard.
+https://github.com/orioncactus/pretendard
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+*/
+/* [0] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.0.woff2) format('woff2-variations');
+ unicode-range: U+f9ca-fa0b, U+ff03-ff05, U+ff07, U+ff0a-ff0b, U+ff0d-ff19, U+ff1b, U+ff1d, U+ff20-ff5b, U+ff5d, U+ffe0-ffe3, U+ffe5-ffe6;
+}
+
+/* [1] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.1.woff2) format('woff2-variations');
+ unicode-range: U+d723-d728, U+d72a-d733, U+d735-d748, U+d74a-d74f, U+d752-d753, U+d755-d757, U+d75a-d75f, U+d762-d764, U+d766-d768, U+d76a-d76b, U+d76d-d76f, U+d771-d787, U+d789-d78b, U+d78d-d78f, U+d791-d797, U+d79a, U+d79c, U+d79e-d7a3, U+f900-f909, U+f90b-f92e;
+}
+
+/* [2] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.2.woff2) format('woff2-variations');
+ unicode-range: U+d679-d68b, U+d68e-d69e, U+d6a0, U+d6a2-d6a7, U+d6a9-d6c3, U+d6c6-d6c7, U+d6c9-d6cb, U+d6cd-d6d3, U+d6d5-d6d6, U+d6d8-d6e3, U+d6e5-d6e7, U+d6e9-d6fb, U+d6fd-d717, U+d719-d71f, U+d721-d722;
+}
+
+/* [3] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.3.woff2) format('woff2-variations');
+ unicode-range: U+d5bc-d5c7, U+d5ca-d5cb, U+d5cd-d5cf, U+d5d1-d5d7, U+d5d9-d5da, U+d5dc, U+d5de-d5e3, U+d5e6-d5e7, U+d5e9-d5eb, U+d5ed-d5f6, U+d5f8, U+d5fa-d5ff, U+d602-d603, U+d605-d607, U+d609-d60f, U+d612-d613, U+d616-d61b, U+d61d-d637, U+d63a-d63b, U+d63d-d63f, U+d641-d647, U+d64a-d64c, U+d64e-d653, U+d656-d657, U+d659-d65b, U+d65d-d666, U+d668, U+d66a-d678;
+}
+
+/* [4] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.4.woff2) format('woff2-variations');
+ unicode-range: U+d507, U+d509-d50b, U+d50d-d513, U+d515-d53b, U+d53e-d53f, U+d541-d543, U+d545-d54c, U+d54e, U+d550, U+d552-d557, U+d55a-d55b, U+d55d-d55f, U+d561-d564, U+d566-d567, U+d56a, U+d56c, U+d56e-d573, U+d576-d577, U+d579-d583, U+d585-d586, U+d58a-d5a4, U+d5a6-d5bb;
+}
+
+/* [5] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.5.woff2) format('woff2-variations');
+ unicode-range: U+d464-d477, U+d47a-d47b, U+d47d-d47f, U+d481-d487, U+d489-d48a, U+d48c, U+d48e-d4e7, U+d4e9-d503, U+d505-d506;
+}
+
+/* [6] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.6.woff2) format('woff2-variations');
+ unicode-range: U+d3bf-d3c7, U+d3ca-d3cf, U+d3d1-d3eb, U+d3ee-d3ef, U+d3f1-d3f3, U+d3f5-d3fb, U+d3fd-d400, U+d402-d45b, U+d45d-d463;
+}
+
+/* [7] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.7.woff2) format('woff2-variations');
+ unicode-range: U+d2ff, U+d302-d304, U+d306-d30b, U+d30f, U+d311-d313, U+d315-d31b, U+d31e, U+d322-d324, U+d326-d327, U+d32a-d32b, U+d32d-d32f, U+d331-d337, U+d339-d33c, U+d33e-d37b, U+d37e-d37f, U+d381-d383, U+d385-d38b, U+d38e-d390, U+d392-d397, U+d39a-d39b, U+d39d-d39f, U+d3a1-d3a7, U+d3a9-d3aa, U+d3ac, U+d3ae-d3b3, U+d3b5-d3b7, U+d3b9-d3bb, U+d3bd-d3be;
+}
+
+/* [8] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.8.woff2) format('woff2-variations');
+ unicode-range: U+d257-d27f, U+d281-d29b, U+d29d-d29f, U+d2a1-d2ab, U+d2ad-d2b7, U+d2ba-d2bb, U+d2bd-d2bf, U+d2c1-d2c7, U+d2c9-d2ef, U+d2f2-d2f3, U+d2f5-d2f7, U+d2f9-d2fe;
+}
+
+/* [9] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.9.woff2) format('woff2-variations');
+ unicode-range: U+d1b4, U+d1b6-d1f3, U+d1f5-d22b, U+d22e-d22f, U+d231-d233, U+d235-d23b, U+d23d-d240, U+d242-d256;
+}
+
+/* [10] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.10.woff2) format('woff2-variations');
+ unicode-range: U+d105-d12f, U+d132-d133, U+d135-d137, U+d139-d13f, U+d141-d142, U+d144, U+d146-d14b, U+d14e-d14f, U+d151-d153, U+d155-d15b, U+d15e-d187, U+d189-d19f, U+d1a2-d1a3, U+d1a5-d1a7, U+d1a9-d1af, U+d1b2-d1b3;
+}
+
+/* [11] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.11.woff2) format('woff2-variations');
+ unicode-range: U+d04b-d04f, U+d051-d057, U+d059-d06b, U+d06d-d06f, U+d071-d073, U+d075-d07b, U+d07e-d0a3, U+d0a6-d0a7, U+d0a9-d0ab, U+d0ad-d0b3, U+d0b6, U+d0b8, U+d0ba-d0bf, U+d0c2-d0c3, U+d0c5-d0c7, U+d0c9-d0cf, U+d0d2, U+d0d6-d0db, U+d0de-d0df, U+d0e1-d0e3, U+d0e5-d0eb, U+d0ee-d0f0, U+d0f2-d104;
+}
+
+/* [12] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.12.woff2) format('woff2-variations');
+ unicode-range: U+cfa2-cfc3, U+cfc5-cfdf, U+cfe2-cfe3, U+cfe5-cfe7, U+cfe9-cff4, U+cff6-cffb, U+cffd-cfff, U+d001-d003, U+d005-d017, U+d019-d033, U+d036-d037, U+d039-d03b, U+d03d-d04a;
+}
+
+/* [13] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.13.woff2) format('woff2-variations');
+ unicode-range: U+cef0-cef3, U+cef6, U+cef9-ceff, U+cf01-cf03, U+cf05-cf07, U+cf09-cf0f, U+cf11-cf12, U+cf14-cf1b, U+cf1d-cf1f, U+cf21-cf2f, U+cf31-cf53, U+cf56-cf57, U+cf59-cf5b, U+cf5d-cf63, U+cf66, U+cf68, U+cf6a-cf6f, U+cf71-cf84, U+cf86-cf8b, U+cf8d-cfa1;
+}
+
+/* [14] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.14.woff2) format('woff2-variations');
+ unicode-range: U+ce3c-ce57, U+ce5a-ce5b, U+ce5d-ce5f, U+ce61-ce67, U+ce6a, U+ce6c, U+ce6e-ce73, U+ce76-ce77, U+ce79-ce7b, U+ce7d-ce83, U+ce85-ce88, U+ce8a-ce8f, U+ce91-ce93, U+ce95-ce97, U+ce99-ce9f, U+cea2, U+cea4-ceab, U+cead-cee3, U+cee6-cee7, U+cee9-ceeb, U+ceed-ceef;
+}
+
+/* [15] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.15.woff2) format('woff2-variations');
+ unicode-range: U+cd92-cd93, U+cd96-cd97, U+cd99-cd9b, U+cd9d-cda3, U+cda6-cda8, U+cdaa-cdaf, U+cdb1-cdc3, U+cdc5-cdcb, U+cdcd-cde7, U+cde9-ce03, U+ce05-ce1f, U+ce22-ce34, U+ce36-ce3b;
+}
+
+/* [16] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.16.woff2) format('woff2-variations');
+ unicode-range: U+ccef-cd07, U+cd0a-cd0b, U+cd0d-cd1a, U+cd1c, U+cd1e-cd2b, U+cd2d-cd5b, U+cd5d-cd77, U+cd79-cd91;
+}
+
+/* [17] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.17.woff2) format('woff2-variations');
+ unicode-range: U+cc3f-cc43, U+cc46-cc47, U+cc49-cc4b, U+cc4d-cc53, U+cc55-cc58, U+cc5a-cc5f, U+cc61-cc97, U+cc9a-cc9b, U+cc9d-cc9f, U+cca1-cca7, U+ccaa, U+ccac, U+ccae-ccb3, U+ccb6-ccb7, U+ccb9-ccbb, U+ccbd-cccf, U+ccd1-cce3, U+cce5-ccee;
+}
+
+/* [18] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.18.woff2) format('woff2-variations');
+ unicode-range: U+cb91-cbd3, U+cbd5-cbe3, U+cbe5-cc0b, U+cc0e-cc0f, U+cc11-cc13, U+cc15-cc1b, U+cc1d-cc20, U+cc23-cc27, U+cc2a-cc2b, U+cc2d, U+cc2f, U+cc31-cc37, U+cc3a, U+cc3c;
+}
+
+/* [19] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.19.woff2) format('woff2-variations');
+ unicode-range: U+caf4-cb47, U+cb4a-cb90;
+}
+
+/* [20] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.20.woff2) format('woff2-variations');
+ unicode-range: U+ca4a-ca4b, U+ca4e-ca4f, U+ca51-ca53, U+ca55-ca5b, U+ca5d-ca60, U+ca62-ca83, U+ca85-cabb, U+cabe-cabf, U+cac1-cac3, U+cac5-cacb, U+cacd-cad0, U+cad2, U+cad4-cad8, U+cada-caf3;
+}
+
+/* [21] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.21.woff2) format('woff2-variations');
+ unicode-range: U+c996-c997, U+c99a-c99c, U+c99e-c9bf, U+c9c2-c9c3, U+c9c5-c9c7, U+c9c9-c9cf, U+c9d2, U+c9d4, U+c9d7-c9d8, U+c9db, U+c9de-c9df, U+c9e1-c9e3, U+c9e5-c9e6, U+c9e8-c9eb, U+c9ee-c9f0, U+c9f2-c9f7, U+c9f9-ca0b, U+ca0d-ca28, U+ca2a-ca49;
+}
+
+/* [22] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.22.woff2) format('woff2-variations');
+ unicode-range: U+c8e9-c8f4, U+c8f6-c8fb, U+c8fe-c8ff, U+c901-c903, U+c905-c90b, U+c90e-c910, U+c912-c917, U+c919-c92b, U+c92d-c94f, U+c951-c953, U+c955-c96b, U+c96d-c973, U+c975-c987, U+c98a-c98b, U+c98d-c98f, U+c991-c995;
+}
+
+/* [23] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.23.woff2) format('woff2-variations');
+ unicode-range: U+c841-c84b, U+c84d-c86f, U+c872-c873, U+c875-c877, U+c879-c87f, U+c882-c884, U+c887-c88a, U+c88d-c8c3, U+c8c5-c8df, U+c8e1-c8e8;
+}
+
+/* [24] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.24.woff2) format('woff2-variations');
+ unicode-range: U+c779-c77b, U+c77e-c782, U+c786, U+c78b, U+c78d, U+c78f, U+c792-c793, U+c795, U+c797, U+c799-c79f, U+c7a2, U+c7a7-c7ab, U+c7ae-c7bb, U+c7bd-c7c0, U+c7c2-c7c7, U+c7c9-c7dc, U+c7de-c7ff, U+c802-c803, U+c805-c807, U+c809, U+c80b-c80f, U+c812, U+c814, U+c817-c81b, U+c81e-c81f, U+c821-c823, U+c825-c82e, U+c830-c837, U+c839-c83b, U+c83d-c840;
+}
+
+/* [25] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.25.woff2) format('woff2-variations');
+ unicode-range: U+c6bb-c6bf, U+c6c2, U+c6c4, U+c6c6-c6cb, U+c6ce-c6cf, U+c6d1-c6d3, U+c6d5-c6db, U+c6dd-c6df, U+c6e1-c6e7, U+c6e9-c6eb, U+c6ed-c6ef, U+c6f1-c6f8, U+c6fa-c703, U+c705-c707, U+c709-c70b, U+c70d-c716, U+c718, U+c71a-c71f, U+c722-c723, U+c725-c727, U+c729-c734, U+c736-c73b, U+c73e-c73f, U+c741-c743, U+c745-c74b, U+c74e-c750, U+c752-c757, U+c759-c773, U+c776-c777;
+}
+
+/* [26] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.26.woff2) format('woff2-variations');
+ unicode-range: U+c5f5-c5fb, U+c5fe, U+c602-c605, U+c607, U+c609-c60f, U+c611-c61a, U+c61c-c623, U+c626-c627, U+c629-c62b, U+c62d, U+c62f-c632, U+c636, U+c638, U+c63a-c63f, U+c642-c643, U+c645-c647, U+c649-c652, U+c656-c65b, U+c65d-c65f, U+c661-c663, U+c665-c677, U+c679-c67b, U+c67d-c693, U+c696-c697, U+c699-c69b, U+c69d-c6a3, U+c6a6, U+c6a8, U+c6aa-c6af, U+c6b2-c6b3, U+c6b5-c6b7, U+c6b9-c6ba;
+}
+
+/* [27] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.27.woff2) format('woff2-variations');
+ unicode-range: U+c517-c527, U+c52a-c52b, U+c52d-c52f, U+c531-c538, U+c53a, U+c53c, U+c53e-c543, U+c546-c547, U+c54b, U+c54d-c552, U+c556, U+c55a-c55b, U+c55d, U+c55f, U+c562-c563, U+c565-c567, U+c569-c56f, U+c572, U+c574, U+c576-c57b, U+c57e-c57f, U+c581-c583, U+c585-c586, U+c588-c58b, U+c58e, U+c590, U+c592-c596, U+c599-c5b3, U+c5b6-c5b7, U+c5ba, U+c5be-c5c3, U+c5ca-c5cb, U+c5cd, U+c5cf, U+c5d2-c5d3, U+c5d5-c5d7, U+c5d9-c5df, U+c5e1-c5e2, U+c5e4, U+c5e6-c5eb, U+c5ef, U+c5f1-c5f3;
+}
+
+/* [28] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.28.woff2) format('woff2-variations');
+ unicode-range: U+c475-c4ef, U+c4f2-c4f3, U+c4f5-c4f7, U+c4f9-c4ff, U+c502-c50b, U+c50d-c516;
+}
+
+/* [29] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.29.woff2) format('woff2-variations');
+ unicode-range: U+c3d0-c3d7, U+c3da-c3db, U+c3dd-c3de, U+c3e1-c3ec, U+c3ee-c3f3, U+c3f5-c42b, U+c42d-c463, U+c466-c474;
+}
+
+/* [30] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.30.woff2) format('woff2-variations');
+ unicode-range: U+c32b-c367, U+c36a-c36b, U+c36d-c36f, U+c371-c377, U+c37a-c37b, U+c37e-c383, U+c385-c387, U+c389-c3cf;
+}
+
+/* [31] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.31.woff2) format('woff2-variations');
+ unicode-range: U+c26a-c26b, U+c26d-c26f, U+c271-c273, U+c275-c27b, U+c27e-c287, U+c289-c28f, U+c291-c297, U+c299-c29a, U+c29c-c2a3, U+c2a5-c2a7, U+c2a9-c2ab, U+c2ad-c2b3, U+c2b6, U+c2b8, U+c2ba-c2bb, U+c2bd-c2db, U+c2de-c2df, U+c2e1-c2e2, U+c2e5-c2ea, U+c2ee, U+c2f0, U+c2f2-c2f5, U+c2f7, U+c2fa-c2fb, U+c2fd-c2ff, U+c301-c307, U+c309-c30c, U+c30e-c312, U+c315-c323, U+c325-c328, U+c32a;
+}
+
+/* [32] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.32.woff2) format('woff2-variations');
+ unicode-range: U+c1bc-c1c3, U+c1c5-c1df, U+c1e1-c1fb, U+c1fd-c203, U+c205-c20c, U+c20e, U+c210-c217, U+c21a-c21b, U+c21d-c21e, U+c221-c227, U+c229-c22a, U+c22c, U+c22e, U+c230, U+c233-c24f, U+c251-c257, U+c259-c269;
+}
+
+/* [33] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.33.woff2) format('woff2-variations');
+ unicode-range: U+c101-c11b, U+c11f, U+c121-c123, U+c125-c12b, U+c12e, U+c132-c137, U+c13a-c13b, U+c13d-c13f, U+c141-c147, U+c14a, U+c14c-c153, U+c155-c157, U+c159-c15b, U+c15d-c166, U+c169-c16f, U+c171-c177, U+c179-c18b, U+c18e-c18f, U+c191-c193, U+c195-c19b, U+c19d-c19e, U+c1a0, U+c1a2-c1a4, U+c1a6-c1bb;
+}
+
+/* [34] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.34.woff2) format('woff2-variations');
+ unicode-range: U+c049-c057, U+c059-c05b, U+c05d-c05f, U+c061-c067, U+c069-c08f, U+c091-c0ab, U+c0ae-c0af, U+c0b1-c0b3, U+c0b5, U+c0b7-c0bb, U+c0be, U+c0c2-c0c7, U+c0ca-c0cb, U+c0cd-c0cf, U+c0d1-c0d7, U+c0d9-c0da, U+c0dc, U+c0de-c0e3, U+c0e5-c0eb, U+c0ed-c0f3, U+c0f6, U+c0f8, U+c0fa-c0ff;
+}
+
+/* [35] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.35.woff2) format('woff2-variations');
+ unicode-range: U+bfa7-bfaf, U+bfb1-bfc4, U+bfc6-bfcb, U+bfce-bfcf, U+bfd1-bfd3, U+bfd5-bfdb, U+bfdd-c048;
+}
+
+/* [36] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.36.woff2) format('woff2-variations');
+ unicode-range: U+bf07, U+bf09-bf3f, U+bf41-bf4f, U+bf52-bf54, U+bf56-bfa6;
+}
+
+/* [37] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.37.woff2) format('woff2-variations');
+ unicode-range: U+be56, U+be58, U+be5c-be5f, U+be62-be63, U+be65-be67, U+be69-be74, U+be76-be7b, U+be7e-be7f, U+be81-be8e, U+be90, U+be92-bea7, U+bea9-becf, U+bed2-bed3, U+bed5-bed6, U+bed9-bee3, U+bee6-bf06;
+}
+
+/* [38] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.38.woff2) format('woff2-variations');
+ unicode-range: U+bdb0-bdd3, U+bdd5-bdef, U+bdf1-be0b, U+be0d-be0f, U+be11-be13, U+be15-be43, U+be46-be47, U+be49-be4b, U+be4d-be53;
+}
+
+/* [39] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.39.woff2) format('woff2-variations');
+ unicode-range: U+bd03, U+bd06, U+bd08, U+bd0a-bd0f, U+bd11-bd22, U+bd25-bd47, U+bd49-bd58, U+bd5a-bd7f, U+bd82-bd83, U+bd85-bd87, U+bd8a-bd8f, U+bd91-bd92, U+bd94, U+bd96-bd98, U+bd9a-bdaf;
+}
+
+/* [40] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.40.woff2) format('woff2-variations');
+ unicode-range: U+bc4e-bc83, U+bc86-bc87, U+bc89-bc8b, U+bc8d-bc93, U+bc96, U+bc98, U+bc9b-bc9f, U+bca2-bca3, U+bca5-bca7, U+bca9-bcb2, U+bcb4-bcbb, U+bcbe-bcbf, U+bcc1-bcc3, U+bcc5-bccc, U+bcce-bcd0, U+bcd2-bcd4, U+bcd6-bcf3, U+bcf7, U+bcf9-bcfb, U+bcfd-bd02;
+}
+
+/* [41] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.41.woff2) format('woff2-variations');
+ unicode-range: U+bb90-bba3, U+bba5-bbab, U+bbad-bbbf, U+bbc1-bbf7, U+bbfa-bbfb, U+bbfd-bbfe, U+bc01-bc07, U+bc09-bc0a, U+bc0e, U+bc10, U+bc12-bc13, U+bc17, U+bc19-bc1a, U+bc1e, U+bc20-bc23, U+bc26, U+bc28, U+bc2a-bc2c, U+bc2e-bc2f, U+bc32-bc33, U+bc35-bc37, U+bc39-bc3f, U+bc41-bc42, U+bc44, U+bc46-bc48, U+bc4a-bc4d;
+}
+
+/* [42] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.42.woff2) format('woff2-variations');
+ unicode-range: U+bae6-bafb, U+bafd-bb17, U+bb19-bb33, U+bb37, U+bb39-bb3a, U+bb3d-bb43, U+bb45-bb46, U+bb48, U+bb4a-bb4f, U+bb51-bb53, U+bb55-bb57, U+bb59-bb62, U+bb64-bb8f;
+}
+
+/* [43] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.43.woff2) format('woff2-variations');
+ unicode-range: U+ba30-ba37, U+ba3a-ba3b, U+ba3d-ba3f, U+ba41-ba47, U+ba49-ba4a, U+ba4c, U+ba4e-ba53, U+ba56-ba57, U+ba59-ba5b, U+ba5d-ba63, U+ba65-ba66, U+ba68-ba6f, U+ba71-ba73, U+ba75-ba77, U+ba79-ba84, U+ba86, U+ba88-baa7, U+baaa, U+baad-baaf, U+bab1-bab7, U+baba, U+babc, U+babe-bae5;
+}
+
+/* [44] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.44.woff2) format('woff2-variations');
+ unicode-range: U+b96e-b973, U+b976-b977, U+b979-b97b, U+b97d-b983, U+b986, U+b988, U+b98a-b98d, U+b98f-b9ab, U+b9ae-b9af, U+b9b1-b9b3, U+b9b5-b9bb, U+b9be, U+b9c0, U+b9c2-b9c7, U+b9ca-b9cb, U+b9cd, U+b9d2-b9d7, U+b9da, U+b9dc, U+b9df-b9e0, U+b9e2, U+b9e6-b9e7, U+b9e9-b9f3, U+b9f6, U+b9f8, U+b9fb-ba2f;
+}
+
+/* [45] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.45.woff2) format('woff2-variations');
+ unicode-range: U+b8bf-b8cb, U+b8cd-b8e0, U+b8e2-b8e7, U+b8ea-b8eb, U+b8ed-b8ef, U+b8f1-b8f7, U+b8fa, U+b8fc, U+b8fe-b903, U+b905-b917, U+b919-b91f, U+b921-b93b, U+b93d-b957, U+b95a-b95b, U+b95d-b95f, U+b961-b967, U+b969-b96c;
+}
+
+/* [46] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.46.woff2) format('woff2-variations');
+ unicode-range: U+b80d-b80f, U+b811-b817, U+b81a, U+b81c-b823, U+b826-b827, U+b829-b82b, U+b82d-b833, U+b836, U+b83a-b83f, U+b841-b85b, U+b85e-b85f, U+b861-b863, U+b865-b86b, U+b86e, U+b870, U+b872-b8af, U+b8b1-b8be;
+}
+
+/* [47] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.47.woff2) format('woff2-variations');
+ unicode-range: U+b74d-b75f, U+b761-b763, U+b765-b774, U+b776-b77b, U+b77e-b77f, U+b781-b783, U+b785-b78b, U+b78e, U+b792-b796, U+b79a-b79b, U+b79d-b7a7, U+b7aa, U+b7ae-b7b3, U+b7b6-b7c8, U+b7ca-b7eb, U+b7ee-b7ef, U+b7f1-b7f3, U+b7f5-b7fb, U+b7fe, U+b802-b806, U+b80a-b80b;
+}
+
+/* [48] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.48.woff2) format('woff2-variations');
+ unicode-range: U+b6a7-b6aa, U+b6ac-b6b0, U+b6b2-b6ef, U+b6f1-b727, U+b72a-b72b, U+b72d-b72e, U+b731-b737, U+b739-b73a, U+b73c-b743, U+b745-b74c;
+}
+
+/* [49] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.49.woff2) format('woff2-variations');
+ unicode-range: U+b605-b60f, U+b612-b617, U+b619-b624, U+b626-b69b, U+b69e-b6a3, U+b6a5-b6a6;
+}
+
+/* [50] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.50.woff2) format('woff2-variations');
+ unicode-range: U+b55f, U+b562-b583, U+b585-b59f, U+b5a2-b5a3, U+b5a5-b5a7, U+b5a9-b5b2, U+b5b5-b5ba, U+b5bd-b604;
+}
+
+/* [51] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.51.woff2) format('woff2-variations');
+ unicode-range: U+b4a5-b4b6, U+b4b8-b4bf, U+b4c1-b4c7, U+b4c9-b4db, U+b4de-b4df, U+b4e1-b4e2, U+b4e5-b4eb, U+b4ee, U+b4f0, U+b4f2-b513, U+b516-b517, U+b519-b51a, U+b51d-b523, U+b526, U+b528, U+b52b-b52f, U+b532-b533, U+b535-b537, U+b539-b53f, U+b541-b544, U+b546-b54b, U+b54d-b54f, U+b551-b55b, U+b55d-b55e;
+}
+
+/* [52] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.52.woff2) format('woff2-variations');
+ unicode-range: U+b3f8-b3fb, U+b3fd-b40f, U+b411-b417, U+b419-b41b, U+b41d-b41f, U+b421-b427, U+b42a-b42b, U+b42d-b44f, U+b452-b453, U+b455-b457, U+b459-b45f, U+b462-b464, U+b466-b46b, U+b46d-b47f, U+b481-b4a3;
+}
+
+/* [53] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.53.woff2) format('woff2-variations');
+ unicode-range: U+b342-b353, U+b356-b357, U+b359-b35b, U+b35d-b35e, U+b360-b363, U+b366, U+b368, U+b36a-b36d, U+b36f, U+b372-b373, U+b375-b377, U+b379-b37f, U+b381-b382, U+b384, U+b386-b38b, U+b38d-b3c3, U+b3c6-b3c7, U+b3c9-b3ca, U+b3cd-b3d3, U+b3d6, U+b3d8, U+b3da-b3f7;
+}
+
+/* [54] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.54.woff2) format('woff2-variations');
+ unicode-range: U+b27c-b283, U+b285-b28f, U+b292-b293, U+b295-b297, U+b29a-b29f, U+b2a1-b2a4, U+b2a7-b2a9, U+b2ab, U+b2ad-b2c7, U+b2ca-b2cb, U+b2cd-b2cf, U+b2d1-b2d7, U+b2da, U+b2dc, U+b2de-b2e3, U+b2e7, U+b2e9-b2ea, U+b2ef-b2f3, U+b2f6, U+b2f8, U+b2fa-b2fb, U+b2fd-b2fe, U+b302-b303, U+b305-b307, U+b309-b30f, U+b312, U+b316-b31b, U+b31d-b341;
+}
+
+/* [55] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.55.woff2) format('woff2-variations');
+ unicode-range: U+b1d6-b1e7, U+b1e9-b1fc, U+b1fe-b203, U+b206-b207, U+b209-b20b, U+b20d-b213, U+b216-b21f, U+b221-b257, U+b259-b273, U+b275-b27b;
+}
+
+/* [56] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.56.woff2) format('woff2-variations');
+ unicode-range: U+b120-b122, U+b126-b127, U+b129-b12b, U+b12d-b133, U+b136, U+b138, U+b13a-b13f, U+b142-b143, U+b145-b14f, U+b151-b153, U+b156-b157, U+b159-b177, U+b17a-b17b, U+b17d-b17f, U+b181-b187, U+b189-b18c, U+b18e-b191, U+b195-b1a7, U+b1a9-b1cb, U+b1cd-b1d5;
+}
+
+/* [57] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.57.woff2) format('woff2-variations');
+ unicode-range: U+b05f-b07b, U+b07e-b07f, U+b081-b083, U+b085-b08b, U+b08d-b097, U+b09b, U+b09d-b09f, U+b0a2-b0a7, U+b0aa, U+b0b0, U+b0b2, U+b0b6-b0b7, U+b0b9-b0bb, U+b0bd-b0c3, U+b0c6-b0c7, U+b0ca-b0cf, U+b0d1-b0df, U+b0e1-b0e4, U+b0e6-b107, U+b10a-b10b, U+b10d-b10f, U+b111-b112, U+b114-b117, U+b119-b11a, U+b11c-b11f;
+}
+
+/* [58] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.58.woff2) format('woff2-variations');
+ unicode-range: U+afac-afb7, U+afba-afbb, U+afbd-afbf, U+afc1-afc6, U+afca-afcc, U+afce-afd3, U+afd5-afe7, U+afe9-afef, U+aff1-b00b, U+b00d-b00f, U+b011-b013, U+b015-b01b, U+b01d-b027, U+b029-b043, U+b045-b047, U+b049, U+b04b, U+b04d-b052, U+b055-b056, U+b058-b05c, U+b05e;
+}
+
+/* [59] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.59.woff2) format('woff2-variations');
+ unicode-range: U+af03-af07, U+af09-af2b, U+af2e-af33, U+af35-af3b, U+af3e-af40, U+af44-af47, U+af4a-af5c, U+af5e-af63, U+af65-af7f, U+af81-afab;
+}
+
+/* [60] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.60.woff2) format('woff2-variations');
+ unicode-range: U+ae56-ae5b, U+ae5e-ae60, U+ae62-ae64, U+ae66-ae67, U+ae69-ae6b, U+ae6d-ae83, U+ae85-aebb, U+aebf, U+aec1-aec3, U+aec5-aecb, U+aece, U+aed0, U+aed2-aed7, U+aed9-aef3, U+aef5-af02;
+}
+
+/* [61] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.61.woff2) format('woff2-variations');
+ unicode-range: U+ad9c-ada3, U+ada5-adbf, U+adc1-adc3, U+adc5-adc7, U+adc9-add2, U+add4-addb, U+addd-addf, U+ade1-ade3, U+ade5-adf7, U+adfa-adfb, U+adfd-adff, U+ae02-ae07, U+ae0a, U+ae0c, U+ae0e-ae13, U+ae15-ae2f, U+ae31-ae33, U+ae35-ae37, U+ae39-ae3f, U+ae42, U+ae44, U+ae46-ae49, U+ae4b, U+ae4f, U+ae51-ae53, U+ae55;
+}
+
+/* [62] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.62.woff2) format('woff2-variations');
+ unicode-range: U+ace2-ace3, U+ace5-ace6, U+ace9-acef, U+acf2, U+acf4, U+acf7-acfb, U+acfe-acff, U+ad01-ad03, U+ad05-ad0b, U+ad0d-ad10, U+ad12-ad1b, U+ad1d-ad33, U+ad35-ad48, U+ad4a-ad4f, U+ad51-ad6b, U+ad6e-ad6f, U+ad71-ad72, U+ad77-ad7c, U+ad7e, U+ad80, U+ad82-ad87, U+ad89-ad8b, U+ad8d-ad8f, U+ad91-ad9b;
+}
+
+/* [63] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.63.woff2) format('woff2-variations');
+ unicode-range: U+ac25-ac2c, U+ac2e, U+ac30, U+ac32-ac37, U+ac39-ac3f, U+ac41-ac4c, U+ac4e-ac6f, U+ac72-ac73, U+ac75-ac76, U+ac79-ac7f, U+ac82, U+ac84-ac88, U+ac8a-ac8b, U+ac8d-ac8f, U+ac91-ac93, U+ac95-ac9b, U+ac9d-ac9e, U+aca1-aca7, U+acab, U+acad-acaf, U+acb1-acb7, U+acba-acbb, U+acbe-acc0, U+acc2-acc3, U+acc5-acdf;
+}
+
+/* [64] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.64.woff2) format('woff2-variations');
+ unicode-range: U+99df, U+99ed, U+99f1, U+99ff, U+9a01, U+9a08, U+9a0e-9a0f, U+9a19, U+9a2b, U+9a30, U+9a36-9a37, U+9a40, U+9a43, U+9a45, U+9a4d, U+9a55, U+9a57, U+9a5a-9a5b, U+9a5f, U+9a62, U+9a65, U+9a69-9a6a, U+9aa8, U+9ab8, U+9ad3, U+9ae5, U+9aee, U+9b1a, U+9b27, U+9b2a, U+9b31, U+9b3c, U+9b41-9b45, U+9b4f, U+9b54, U+9b5a, U+9b6f, U+9b8e, U+9b91, U+9b9f, U+9bab, U+9bae, U+9bc9, U+9bd6, U+9be4, U+9be8, U+9c0d, U+9c10, U+9c12, U+9c15, U+9c25, U+9c32, U+9c3b, U+9c47, U+9c49, U+9c57, U+9ce5, U+9ce7, U+9ce9, U+9cf3-9cf4, U+9cf6, U+9d09, U+9d1b, U+9d26, U+9d28, U+9d3b, U+9d51, U+9d5d, U+9d60-9d61, U+9d6c, U+9d72, U+9da9, U+9daf, U+9db4, U+9dc4, U+9dd7, U+9df2, U+9df8-9dfa, U+9e1a, U+9e1e, U+9e75, U+9e79, U+9e7d, U+9e7f, U+9e92-9e93, U+9e97, U+9e9d, U+9e9f, U+9ea5, U+9eb4-9eb5, U+9ebb, U+9ebe, U+9ec3, U+9ecd-9ece, U+9ed4, U+9ed8, U+9edb-9edc, U+9ede, U+9ee8, U+9ef4, U+9f07-9f08, U+9f0e, U+9f13, U+9f20, U+9f3b, U+9f4a-9f4b, U+9f4e, U+9f52, U+9f5f, U+9f61, U+9f67, U+9f6a, U+9f6c, U+9f77, U+9f8d, U+9f90, U+9f95, U+9f9c, U+ac02-ac03, U+ac05-ac06, U+ac09-ac0f, U+ac17-ac18, U+ac1b, U+ac1e-ac1f, U+ac21-ac23;
+}
+
+/* [65] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.65.woff2) format('woff2-variations');
+ unicode-range: U+338c-339c, U+339f-33a0, U+33a2-33cb, U+33cf-33d0, U+33d3, U+33d6, U+33d8, U+33db-33dd, U+4e01, U+4e03, U+4e07-4e08, U+4e11, U+4e14-4e15, U+4e18-4e19, U+4e1e, U+4e32, U+4e38-4e39, U+4e42-4e43, U+4e45, U+4e4d-4e4f, U+4e56, U+4e58-4e59, U+4e5d-4e5e, U+4e6b, U+4e6d, U+4e73, U+4e76-4e77, U+4e7e, U+4e82, U+4e86, U+4e88, U+4e8e, U+4e90-4e92, U+4e94-4e95, U+4e98, U+4e9b, U+4e9e, U+4ea1-4ea2, U+4ea4-4ea6, U+4ea8, U+4eab, U+4ead-4eae, U+4eb6, U+4ec0-4ec1, U+4ec4, U+4ec7, U+4ecb, U+4ecd, U+4ed4-4ed5, U+4ed7-4ed9, U+4edd, U+4edf, U+4ee4, U+4ef0, U+4ef2, U+4ef6-4ef7, U+4efb, U+4f01, U+4f09, U+4f0b, U+4f0d-4f11, U+4f2f, U+4f34, U+4f36, U+4f38, U+4f3a, U+4f3c-4f3d;
+}
+
+/* [66] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.66.woff2) format('woff2-variations');
+ unicode-range: U+3136, U+3138, U+313a-3140, U+3143-3144, U+3150, U+3152, U+3154-3156, U+3158-315b, U+315d-315f, U+3162, U+3164-318c, U+318e, U+3200-321b, U+3231, U+3239, U+3251-325a, U+3260-327b, U+327e-327f, U+328a-3290, U+3294, U+329e, U+32a5, U+3380-3384, U+3388-338b;
+}
+
+/* [67] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.67.woff2) format('woff2-variations');
+ unicode-range: U+2f7d, U+2f7f-2f8b, U+2f8e-2f90, U+2f92-2f97, U+2f99-2fa0, U+2fa2-2fa3, U+2fa5-2fa9, U+2fac-2fb1, U+2fb3-2fbc, U+2fc1-2fca, U+2fcd-2fd4, U+3003, U+3012-3019, U+301c, U+301e-3020, U+3036, U+3041, U+3043, U+3045, U+3047, U+3049, U+304e, U+3050, U+3052, U+3056, U+305a, U+305c, U+305e, U+3062, U+3065, U+306c, U+3070-307d, U+3080, U+3085, U+3087, U+308e, U+3090-3091, U+30a1, U+30a5, U+30a9, U+30ae, U+30b1-30b2, U+30b4, U+30b6, U+30bc-30be, U+30c2, U+30c5, U+30cc, U+30d2, U+30d4, U+30d8-30dd, U+30e4, U+30e6, U+30e8, U+30ee, U+30f0-30f2, U+30f4-30f6, U+3133, U+3135;
+}
+
+/* [68] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.68.woff2) format('woff2-variations');
+ unicode-range: U+2541-254b, U+25a4-25a9, U+25b1, U+25b5, U+25b9, U+25bf, U+25c1, U+25c3, U+25c9-25ca, U+25cc, U+25ce, U+25d0-25d1, U+25e6, U+25ef, U+260f, U+261d, U+261f, U+262f, U+2660, U+2664, U+2667-2669, U+266d, U+266f, U+2716, U+271a, U+273d, U+2756, U+2776-277f, U+278a-2793, U+2963, U+2965, U+2ac5-2ac6, U+2acb-2acc, U+2f00, U+2f04, U+2f06, U+2f08, U+2f0a-2f0b, U+2f11-2f12, U+2f14, U+2f17-2f18, U+2f1c-2f1d, U+2f1f-2f20, U+2f23-2f26, U+2f28-2f29, U+2f2b, U+2f2d, U+2f2f-2f32, U+2f38, U+2f3c-2f40, U+2f42-2f4c, U+2f4f-2f52, U+2f54-2f58, U+2f5a-2f66, U+2f69-2f70, U+2f72-2f76, U+2f78, U+2f7a-2f7c;
+}
+
+/* [69] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.69.woff2) format('woff2-variations');
+ unicode-range: U+2479-2487, U+249c-24d1, U+24d3-24d7, U+24d9-24e9, U+24eb-24f4, U+2500-2501, U+2503, U+250c-2513, U+2515-2516, U+2518-2540;
+}
+
+/* [70] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.70.woff2) format('woff2-variations');
+ unicode-range: U+215b-215e, U+2162-2169, U+2170-2179, U+2195-2199, U+21b0-21b4, U+21bc, U+21c0, U+21c4-21c5, U+21cd, U+21cf-21d4, U+21e0-21e3, U+21e6-21e9, U+2200, U+2202-2203, U+2206-2209, U+220b-220c, U+220f, U+2211, U+2213, U+221a, U+221d-2220, U+2222, U+2225-2227, U+2229-222c, U+222e, U+2234-2237, U+223d, U+2243, U+2245, U+2248, U+2250-2253, U+225a, U+2260-2262, U+2264-2267, U+226a-226b, U+226e-2273, U+2276-2277, U+2279-227b, U+2280-2287, U+228a-228b, U+2295-2297, U+22a3-22a5, U+22bb-22bc, U+22ce-22cf, U+22da-22db, U+22ee-22ef, U+2306, U+2312, U+2314, U+2467-2478;
+}
+
+/* [71] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.71.woff2) format('woff2-variations');
+ unicode-range: U+81-82, U+84, U+a2-a5, U+a7-a8, U+aa, U+ac, U+b1-b3, U+b6, U+b8-ba, U+bc-be, U+c0, U+c2, U+c6-cb, U+ce-d0, U+d4, U+d8-d9, U+db-dc, U+de-df, U+e6, U+eb, U+ee-f0, U+f4, U+f7-f9, U+fb, U+fe-ff, U+111, U+126-127, U+132-133, U+138, U+13f-142, U+149-14b, U+152-153, U+166-167, U+2bc, U+2c7, U+2d0, U+2d8-2d9, U+2db-2dd, U+391-394, U+396-3a1, U+3a3-3a9, U+3b2-3b6, U+3b8, U+3bc, U+3be-3c1, U+3c3-3c9, U+2010, U+2015-2016, U+2018-2019, U+201b, U+201f-2021, U+2025, U+2030, U+2033-2036, U+203c, U+203e, U+2042, U+2074, U+207a-207f, U+2081-2084, U+2109, U+2113, U+2116, U+2121, U+2126, U+212b, U+2153-2154;
+}
+
+/* [72] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.72.woff2) format('woff2-variations');
+ unicode-range: U+e8, U+2da, U+2160, U+2194, U+3054, U+3058, U+306d, U+3086, U+308d, U+30ac, U+30bb, U+30c4, U+30cd-30ce, U+30e2, U+3132, U+3146, U+3149, U+339d, U+4e3b, U+4f0a, U+4fdd, U+4fe1, U+5409, U+540c, U+5834, U+592a-592b, U+5b9a, U+5dde, U+5e0c, U+5e73, U+5f0f, U+60f3, U+653f, U+661f, U+662f, U+667a, U+683c, U+6b4c, U+6c11, U+767c, U+76ee, U+76f4, U+77f3, U+79d1, U+7a7a, U+7b2c, U+7d22, U+8207, U+8a00, U+8a71, U+9280, U+9580, U+958b, U+96c6, U+9762, U+98df, U+9ed1, U+ac2d, U+adc8, U+add3, U+af48, U+b014, U+b134-b135, U+b158, U+b2aa, U+b35f, U+b6a4, U+b9cf, U+bb63, U+bd23, U+be91, U+c29b, U+c3f4, U+c42c, U+c55c, U+c573, U+c58f, U+c78c, U+c7dd, U+c8f5, U+cad1, U+cc48, U+cf10, U+cf20, U+d03c, U+d07d, U+d2a0, U+d30e, U+d38d, U+d3a8, U+d3c8, U+d5e5, U+d5f9, U+d6e4, U+f90a, U+ff02, U+ff1c;
+}
+
+/* [73] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.73.woff2) format('woff2-variations');
+ unicode-range: U+3b1, U+2466, U+25a1, U+25a3, U+261c, U+3008-3009, U+305b, U+305d, U+3069, U+30a7, U+30ba, U+30cf, U+30ef, U+3151, U+3157, U+4e4b, U+4e5f, U+4e8c, U+4eca, U+4ed6, U+4f5b, U+50cf, U+5149, U+5165, U+5171, U+5229, U+529b, U+5316, U+539f, U+53f2, U+571f, U+5728, U+58eb, U+591c, U+5b78, U+5c11, U+5c55, U+5ddd, U+5e02, U+5fb7, U+60c5, U+610f, U+611f, U+6625, U+66f8, U+6797, U+679c, U+682a, U+6d2a, U+706b, U+7406, U+767b, U+76f8, U+77e5, U+7acb, U+898b, U+8a69, U+8def, U+8fd1, U+901a, U+90e8, U+91cd, U+975e, U+ae14, U+ae6c, U+aec0, U+afc7, U+afc9, U+b01c, U+b028, U+b308, U+b311, U+b314, U+b31c, U+b524, U+b560, U+b764, U+b920, U+b9e3, U+bd48, U+be7d, U+c0db, U+c231, U+c270, U+c2e3, U+c37d, U+c3ed, U+c530, U+c6a5, U+c6dc, U+c7a4, U+c954, U+c974, U+d000, U+d565, U+d667, U+d6c5, U+d79d, U+ff1e;
+}
+
+/* [74] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.74.woff2) format('woff2-variations');
+ unicode-range: U+131, U+2032, U+2465, U+2642, U+3048, U+3051, U+3083-3084, U+308f, U+30c0, U+30d1, U+30d3, U+30d6, U+30df, U+30e7, U+3153, U+4e16, U+4e8b, U+4ee5, U+5206, U+52a0, U+52d5, U+53e4, U+53ef, U+54c1, U+57ce, U+597d, U+5b8c, U+5ea6, U+5f8c, U+5f97, U+6210, U+6240, U+624b, U+6728, U+6bd4, U+7236, U+7269, U+7279, U+738b, U+7528, U+7530, U+767e, U+798f, U+8005, U+8a18, U+90fd, U+91cc, U+9577, U+9593, U+98a8, U+ac20, U+acf6, U+ad90, U+af5d, U+af80, U+afcd, U+aff0, U+b0a1, U+b0b5, U+b1fd, U+b2fc, U+b380, U+b51b, U+b584, U+b5b3, U+b8fd, U+b93c, U+b9f4, U+bb44, U+bc08, U+bc27, U+bc49, U+be55, U+be64, U+bfb0, U+bfc5, U+c178, U+c21f, U+c314, U+c4f1, U+c58d, U+c664, U+c698, U+c6a7, U+c6c1, U+c9ed, U+cac0, U+cacc, U+cad9, U+ccb5, U+cdcc, U+d0e4, U+d143, U+d320, U+d330, U+d54d, U+ff06, U+ff1f, U+ff5e;
+}
+
+/* [75] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.75.woff2) format('woff2-variations');
+ unicode-range: U+b4, U+20a9, U+20ac, U+2190, U+24d8, U+2502, U+2514, U+2592, U+25c7-25c8, U+2663, U+3060, U+3064, U+3081, U+3088, U+30a3, U+30a6, U+30aa, U+30b5, U+30c7, U+30ca-30cb, U+30d0, U+30e3, U+30e5, U+339e, U+4e09, U+4eac, U+4f5c, U+5167-5168, U+516c, U+51fa, U+5408, U+540d, U+591a, U+5b57, U+6211, U+65b9, U+660e, U+6642, U+6700, U+6b63, U+6e2f, U+7063, U+7532, U+793e, U+81ea, U+8272, U+82b1, U+897f, U+8eca, U+91ce, U+ac38, U+ad76, U+ae84, U+aecc, U+b07d, U+b0b1, U+b215, U+b2a0, U+b310, U+b3d7, U+b52a, U+b618, U+b775, U+b797, U+bcd5, U+bd59, U+be80, U+bea8, U+bed1, U+bee4-bee5, U+c060, U+c2ef, U+c329, U+c3dc, U+c597, U+c5bd, U+c5e5, U+c69c, U+c9d6, U+ca29, U+ca5c, U+ca84, U+cc39, U+cc3b, U+ce89, U+cee5, U+cf65, U+cf85, U+d058, U+d145, U+d22d, U+d325, U+d37d, U+d3ad, U+d769, U+ff0c;
+}
+
+/* [76] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.76.woff2) format('woff2-variations');
+ unicode-range: U+2161, U+2228, U+2299, U+2464, U+2517, U+2640, U+3042, U+304a, U+3053, U+3061, U+307f, U+3082, U+308c, U+3092, U+30a8, U+30ab, U+30ad, U+30b0, U+30b3, U+30b7, U+30c1, U+30c6, U+30c9, U+30d5, U+30d7, U+30de, U+30e0-30e1, U+30ec-30ed, U+4e0b, U+4e0d, U+4ee3, U+53f0, U+548c, U+5b89, U+5bb6, U+5c0f, U+611b, U+6771, U+6aa2, U+6bcd, U+6c34, U+6cd5, U+6d77, U+767d, U+795e, U+8ecd, U+9999, U+9ad8, U+ac07, U+ac1a, U+ac40, U+ad0c, U+ad88, U+ada4, U+ae01, U+ae65, U+aebd, U+aec4, U+afe8, U+b139, U+b205, U+b383, U+b38c, U+b42c, U+b461, U+b55c, U+b78f, U+b8fb, U+b9f7, U+bafc, U+bc99, U+bed8, U+bfcd, U+c0bf, U+c0f9, U+c167, U+c204, U+c20f, U+c22f, U+c258, U+c298, U+c2bc, U+c388, U+c501, U+c50c, U+c5b9, U+c5ce, U+c641, U+c648, U+c73d, U+ca50, U+ca61, U+cc4c, U+ceac, U+d0d4, U+d5f7, U+d6d7, U+ff1a;
+}
+
+/* [77] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.77.woff2) format('woff2-variations');
+ unicode-range: U+2103, U+2463, U+25c6, U+25cb, U+266c, U+3001, U+300a, U+3046, U+304c-304d, U+304f, U+3055, U+3059, U+3063, U+3066-3068, U+306f, U+3089, U+30b8, U+30bf, U+314f, U+4e0a, U+570b, U+5730, U+5916, U+5929, U+5c71, U+5e74, U+5fc3, U+601d, U+6027, U+63d0, U+6709, U+6734, U+751f, U+7684, U+82f1, U+9053, U+91d1, U+97f3, U+ac2f, U+ac4d, U+adc4, U+ade4, U+ae41, U+ae4d-ae4e, U+aed1, U+afb9, U+b0e0, U+b299, U+b365, U+b46c, U+b480, U+b4c8, U+b7b4, U+b819, U+b918, U+baab, U+bab9, U+be8f, U+bed7, U+c0ec, U+c19f, U+c1a5, U+c3d9, U+c464, U+c53d, U+c553, U+c570, U+c5cc, U+c633, U+c6a4, U+c7a3, U+c7a6, U+c886, U+c9d9-c9da, U+c9ec, U+ca0c, U+cc21, U+cd1b, U+cd78, U+cdc4, U+cef8, U+cfe4, U+d0a5, U+d0b5, U+d0ec, U+d15d, U+d188, U+d23c, U+d2ac, U+d729, U+d79b, U+ff01, U+ff08-ff09, U+ff5c;
+}
+
+/* [78] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.78.woff2) format('woff2-variations');
+ unicode-range: U+2039-203a, U+223c, U+25b3, U+25b7, U+25bd, U+25cf, U+266a, U+3002, U+300b, U+304b, U+3057, U+305f, U+306a-306b, U+307e, U+308a-308b, U+3093, U+30a2, U+30af, U+30b9, U+30c3, U+30c8, U+30e9-30eb, U+33a1, U+4e00, U+524d, U+5357, U+5b50, U+7121, U+884c, U+9751, U+ac94, U+aebe, U+aecd, U+af08, U+af41, U+af49, U+b010, U+b053, U+b109, U+b11b, U+b128, U+b154, U+b291, U+b2e6, U+b301, U+b385, U+b525, U+b5b4, U+b729, U+b72f, U+b738, U+b7ff, U+b837, U+b975, U+ba67, U+bb47, U+bc1f, U+bd90, U+bfd4, U+c27c, U+c324, U+c379, U+c3e0, U+c465, U+c53b, U+c58c, U+c610, U+c653, U+c6cd, U+c813, U+c82f, U+c999, U+c9e0, U+cac4, U+cad3, U+cbd4, U+cc10, U+cc22, U+ccb8, U+ccbc, U+cda5, U+ce84, U+cea3, U+cf67, U+cfe1, U+d241, U+d30d, U+d31c, U+d391, U+d401, U+d479, U+d5c9, U+d5db, U+d649, U+d6d4;
+}
+
+/* [79] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.79.woff2) format('woff2-variations');
+ unicode-range: U+b0, U+e9, U+2193, U+2462, U+260e, U+261e, U+300e-300f, U+3044, U+30a4, U+30fb-30fc, U+314d, U+5973, U+6545, U+6708, U+7537, U+ac89, U+ac9c, U+acc1, U+ad04, U+ad75, U+ad7d, U+ae45, U+ae61, U+af42, U+b0ab, U+b0af, U+b0b3, U+b12c, U+b194, U+b1a8, U+b220, U+b258, U+b284, U+b2ff, U+b315, U+b371, U+b3d4-b3d5, U+b460, U+b527, U+b534, U+b810, U+b818, U+b98e, U+ba55, U+bbac, U+bc0b, U+bc40, U+bca1, U+bccd, U+bd93, U+be54, U+be5a, U+bf08, U+bf50, U+bf55, U+bfdc, U+c0c0, U+c0d0, U+c0f4, U+c100, U+c11e, U+c170, U+c20d, U+c274, U+c290, U+c308, U+c369, U+c539, U+c587, U+c5ff, U+c6ec, U+c70c, U+c7ad, U+c7c8, U+c83c, U+c881, U+cb48, U+cc60, U+ce69, U+ce6b, U+ce75, U+cf04, U+cf08, U+cf55, U+cf70, U+cffc, U+d0b7, U+d1a8, U+d2c8, U+d384, U+d47c, U+d48b, U+d5dd, U+d5e8, U+d720, U+d759, U+f981;
+}
+
+/* [80] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.80.woff2) format('woff2-variations');
+ unicode-range: U+e0, U+e2, U+395, U+3b7, U+3ba, U+2460-2461, U+25a0, U+3010-3011, U+306e, U+30f3, U+314a, U+314c, U+5927, U+65b0, U+7e41, U+97d3, U+9ad4, U+ad49, U+ae0b, U+ae0d, U+ae43, U+ae5d, U+aecf, U+af3c, U+af64, U+afd4, U+b080, U+b084, U+b0c5, U+b10c, U+b1e8, U+b2ac, U+b36e, U+b451, U+b515, U+b540, U+b561, U+b6ab, U+b6b1, U+b72c, U+b730, U+b744, U+b800, U+b8ec, U+b8f0, U+b904, U+b968, U+b96d, U+b987, U+b9d9, U+bb36, U+bb49, U+bc2d, U+bc43, U+bcf6, U+bd89, U+be57, U+be61, U+bed4, U+c090, U+c130, U+c148, U+c19c, U+c2f9, U+c36c, U+c37c, U+c384, U+c3df, U+c575, U+c584, U+c660, U+c719, U+c816, U+ca4d, U+ca54, U+cabc, U+cb49, U+cc14, U+cff5, U+d004, U+d038, U+d0b4, U+d0d3, U+d0e0, U+d0ed, U+d131, U+d1b0, U+d31f, U+d33d, U+d3a0, U+d3ab, U+d514, U+d584, U+d6a1, U+d6cc, U+d749, U+d760, U+d799;
+}
+
+/* [81] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.81.woff2) format('woff2-variations');
+ unicode-range: U+24, U+60, U+3b9, U+3bb, U+3bd, U+2191, U+2606, U+300c-300d, U+3131, U+3134, U+3139, U+3141-3142, U+3148, U+3161, U+3163, U+321c, U+4eba, U+5317, U+ac31, U+ac77, U+ac9f, U+acb9, U+acf0-acf1, U+acfd, U+ad73, U+af3d, U+b00c, U+b04a, U+b057, U+b0c4, U+b188, U+b1cc, U+b214, U+b2db, U+b2ee, U+b304, U+b4ed, U+b518, U+b5bc, U+b625, U+b69c-b69d, U+b7ac, U+b801, U+b86c, U+b959, U+b95c, U+b985, U+ba48, U+bb58, U+bc0c, U+bc38, U+bc85, U+bc9a, U+bf40, U+c068, U+c0bd, U+c0cc, U+c12f, U+c149, U+c1e0, U+c22b, U+c22d, U+c250, U+c2fc, U+c300, U+c313, U+c370, U+c3d8, U+c557, U+c580, U+c5e3, U+c62e, U+c634, U+c6f0, U+c74d, U+c783, U+c78e, U+c796, U+c7bc, U+c92c, U+ca4c, U+cc1c, U+cc54, U+cc59, U+ce04, U+cf30, U+cfc4, U+d140, U+d321, U+d38c, U+d399, U+d54f, U+d587, U+d5d0, U+d6e8, U+d770;
+}
+
+/* [82] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.82.woff2) format('woff2-variations');
+ unicode-range: U+d7, U+ea, U+fc, U+2192, U+25bc, U+3000, U+3137, U+3145, U+315c, U+7f8e, U+ac13, U+ac71, U+ac90, U+acb8, U+ace7, U+ad7f, U+ae50, U+aef4, U+af34, U+afbc, U+b048, U+b09a, U+b0ad, U+b0bc, U+b113, U+b125, U+b141, U+b20c, U+b2d9, U+b2ed, U+b367, U+b369, U+b374, U+b3cb, U+b4ec, U+b611, U+b760, U+b81b, U+b834, U+b8b0, U+b8e1, U+b989, U+b9d1, U+b9e1, U+b9fa, U+ba4d, U+ba78, U+bb35, U+bb54, U+bbf9, U+bc11, U+bcb3, U+bd05, U+bd95, U+bdd4, U+be10, U+bed0, U+bf51, U+c0d8, U+c232, U+c2b7, U+c2eb, U+c378, U+c500, U+c52c, U+c549, U+c568, U+c598, U+c5c9, U+c61b, U+c639, U+c67c, U+c717, U+c78a, U+c80a, U+c90c-c90d, U+c950, U+c9e7, U+cbe4, U+cca9, U+cce4, U+cdb0, U+ce78, U+ce94, U+ce98, U+cf8c, U+d018, U+d034, U+d0f1, U+d1b1, U+d280, U+d2f8, U+d338, U+d380, U+d3b4, U+d610, U+d69f, U+d6fc, U+d758;
+}
+
+/* [83] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.83.woff2) format('woff2-variations');
+ unicode-range: U+e7, U+2022, U+203b, U+25c0, U+2605, U+2661, U+3147, U+318d, U+672c, U+8a9e, U+acaa, U+acbc, U+ad1c, U+ae4a, U+ae5c, U+b044, U+b054, U+b0c8-b0c9, U+b2a6, U+b2d0, U+b35c, U+b364, U+b428, U+b454, U+b465, U+b4b7, U+b4e3, U+b51c, U+b5a1, U+b784, U+b790, U+b7ab, U+b7f4, U+b82c, U+b835, U+b8e9, U+b8f8, U+b9d8, U+b9f9, U+ba5c, U+ba64, U+babd, U+bb18, U+bb3b, U+bbff, U+bc0d, U+bc45, U+bc97, U+bcbc, U+be45, U+be75, U+be7c, U+bfcc, U+c0b6, U+c0f7, U+c14b, U+c2b4, U+c30d, U+c4f8, U+c5bb, U+c5d1, U+c5e0, U+c5ee, U+c5fd, U+c606, U+c6c5, U+c6e0, U+c708, U+c81d, U+c820, U+c824, U+c878, U+c918, U+c96c, U+c9e4, U+c9f1, U+cc2e, U+cd09, U+cea1, U+cef5, U+cef7, U+cf64, U+cf69, U+cfe8, U+d035, U+d0ac, U+d230, U+d234, U+d2f4, U+d31d, U+d575, U+d578, U+d608, U+d614, U+d718, U+d751, U+d761, U+d78c, U+d790;
+}
+
+/* [84] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.84.woff2) format('woff2-variations');
+ unicode-range: U+2665, U+3160, U+4e2d, U+6587, U+65e5, U+ac12, U+ac14, U+ac16, U+ac81, U+ad34, U+ade0, U+ae54, U+aebc, U+af2c, U+afc0, U+afc8, U+b04c, U+b08c, U+b099, U+b0a9, U+b0ac, U+b0ae, U+b0b8, U+b123, U+b179, U+b2e5, U+b2f7, U+b4c0, U+b531, U+b538, U+b545, U+b550, U+b5a8, U+b6f0, U+b728, U+b73b, U+b7ad, U+b7ed, U+b809, U+b864, U+b86d, U+b871, U+b9bf, U+b9f5, U+ba40, U+ba4b, U+ba58, U+ba87, U+baac, U+bbc0, U+bc16, U+bc34, U+bd07, U+bd99, U+be59, U+bfd0, U+c058, U+c0e4, U+c0f5, U+c12d, U+c139, U+c228, U+c529, U+c5c7, U+c635, U+c637, U+c735, U+c77d, U+c787, U+c789, U+c8c4, U+c989, U+c98c, U+c9d0, U+c9d3, U+cc0c, U+cc99, U+cd0c, U+cd2c, U+cd98, U+cda4, U+ce59, U+ce60, U+ce6d, U+cea0, U+d0d0-d0d1, U+d0d5, U+d14d, U+d1a4, U+d29c, U+d2f1, U+d301, U+d39c, U+d3bc, U+d4e8, U+d540, U+d5ec, U+d640, U+d750;
+}
+
+/* [85] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.85.woff2) format('woff2-variations');
+ unicode-range: U+5e, U+25b2, U+25b6, U+314e, U+ac24, U+ace1, U+ace4, U+ae68, U+af2d, U+b0d0, U+b0e5, U+b150, U+b155, U+b193, U+b2c9, U+b2dd, U+b3c8, U+b3fc, U+b410, U+b458, U+b4dd, U+b5a0, U+b5a4, U+b5bb, U+b7b5, U+b838, U+b840, U+b86f, U+b8f9, U+b960, U+b9e5, U+bab8, U+bb50, U+bc1d, U+bc24-bc25, U+bca8, U+bcbd, U+bd04, U+bd10, U+bd24, U+be48, U+be5b, U+be68, U+c05c, U+c12c, U+c140, U+c15c, U+c168, U+c194, U+c219, U+c27d, U+c2a8, U+c2f1, U+c2f8, U+c368, U+c554-c555, U+c559, U+c564, U+c5d8, U+c5fc, U+c625, U+c65c, U+c6b1, U+c728, U+c794, U+c84c, U+c88c, U+c8e0, U+c8fd, U+c998, U+c9dd, U+cc0d, U+cc30, U+ceec, U+cf13, U+cf1c, U+cf5c, U+d050, U+d07c, U+d0a8, U+d134, U+d138, U+d154, U+d1f4, U+d2bc, U+d329, U+d32c, U+d3d0, U+d3f4, U+d3fc, U+d56b, U+d5cc, U+d600-d601, U+d639, U+d6c8, U+d754, U+d765;
+}
+
+/* [86] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.86.woff2) format('woff2-variations');
+ unicode-range: U+3c-3d, U+2026, U+24d2, U+314b, U+ac11, U+acf3, U+ad74, U+ad81, U+adf9, U+ae34, U+af43, U+afb8, U+b05d, U+b07c, U+b110, U+b118, U+b17c, U+b180, U+b18d, U+b192, U+b2cc, U+b355, U+b378, U+b4a4, U+b4ef, U+b78d, U+b799, U+b7a9, U+b7fd, U+b807, U+b80c, U+b839, U+b9b4, U+b9db, U+ba3c, U+bab0, U+bba4, U+bc94, U+be4c, U+c154, U+c1c4, U+c26c, U+c2ac, U+c2ed, U+c4f4, U+c55e, U+c561, U+c571, U+c5b5, U+c5c4, U+c654-c655, U+c695, U+c6e8, U+c6f9, U+c724, U+c751, U+c775, U+c7a0, U+c7c1, U+c874, U+c880, U+c9d5, U+c9f8, U+cabd, U+cc29, U+cc2c, U+cca8, U+ccab, U+ccd0, U+ce21, U+ce35, U+ce7c, U+ce90, U+cee8, U+cef4, U+cfe0, U+d070, U+d0b9, U+d0c1, U+d0c4, U+d0c8, U+d15c, U+d1a1, U+d2c0, U+d300, U+d314, U+d3ed, U+d478, U+d480, U+d48d, U+d508, U+d53d, U+d5e4, U+d611, U+d61c, U+d68d, U+d6a8, U+d798;
+}
+
+/* [87] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.87.woff2) format('woff2-variations');
+ unicode-range: U+23, U+25, U+5f, U+a9, U+ac08, U+ac78, U+aca8, U+acac, U+ace8, U+ad70, U+adc0, U+addc, U+b137, U+b140, U+b208, U+b290, U+b2f5, U+b3c5, U+b3cc, U+b420, U+b429, U+b529, U+b530, U+b77d, U+b79c, U+b7a8, U+b7c9, U+b7f0, U+b7fc, U+b828, U+b860, U+b9ad, U+b9c1, U+b9c9, U+b9dd-b9de, U+b9e8, U+ba38-ba39, U+babb, U+bc00, U+bc8c, U+bca0, U+bca4, U+bcd1, U+bcfc, U+bd09, U+bdf0, U+be60, U+c0ad, U+c0b4, U+c0bc, U+c190, U+c1fc, U+c220, U+c288, U+c2b9, U+c2f6, U+c528, U+c545, U+c558, U+c5bc, U+c5d4, U+c600, U+c644, U+c6c0, U+c6c3, U+c721, U+c798, U+c7a1, U+c811, U+c838, U+c871, U+c904, U+c990, U+c9dc, U+cc38, U+cc44, U+cca0, U+cd1d, U+cd95, U+cda9, U+ce5c, U+cf00, U+cf58, U+d150, U+d22c, U+d305, U+d328, U+d37c, U+d3f0, U+d551, U+d5a5, U+d5c8, U+d5d8, U+d63c, U+d64d, U+d669, U+d734, U+d76c;
+}
+
+/* [88] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.88.woff2) format('woff2-variations');
+ unicode-range: U+26, U+2b, U+3e, U+40, U+7e, U+ac01, U+ac19, U+ac1d, U+aca0, U+aca9, U+acb0, U+ad8c, U+ae09, U+ae38, U+ae40, U+aed8, U+b09c, U+b0a0, U+b108, U+b204, U+b298, U+b2d8, U+b2eb-b2ec, U+b2f4, U+b313, U+b358, U+b450, U+b4e0, U+b54c, U+b610, U+b780, U+b78c, U+b791, U+b8e8, U+b958, U+b974, U+b984, U+b9b0, U+b9bc-b9bd, U+b9ce, U+ba70, U+bbfc, U+bc0f, U+bc15, U+bc1b, U+bc31, U+bc95, U+bcc0, U+bcc4, U+bd81, U+bd88, U+c0c8, U+c11d, U+c13c, U+c158, U+c18d, U+c1a1, U+c21c, U+c4f0, U+c54a, U+c560, U+c5b8, U+c5c8, U+c5f4, U+c628, U+c62c, U+c678, U+c6cc, U+c808, U+c810, U+c885, U+c88b, U+c900, U+c988, U+c99d, U+c9c8, U+cc3d-cc3e, U+cc45, U+cd08, U+ce20, U+cee4, U+d074, U+d0a4, U+d0dd, U+d2b9, U+d3b8, U+d3c9, U+d488, U+d544, U+d559, U+d56d, U+d588, U+d615, U+d648, U+d655, U+d658, U+d65c;
+}
+
+/* [89] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.89.woff2) format('woff2-variations');
+ unicode-range: U+d, U+7c, U+ac10, U+ac15, U+ac74, U+ac80, U+ac83, U+acc4, U+ad11, U+ad50, U+ad6d, U+adfc, U+ae00, U+ae08, U+ae4c, U+b0a8, U+b124, U+b144, U+b178, U+b274, U+b2a5, U+b2e8, U+b2f9, U+b354, U+b370, U+b418, U+b41c, U+b4f1, U+b514, U+b798, U+b808, U+b824-b825, U+b8cc, U+b978, U+b9d0, U+b9e4, U+baa9, U+bb3c, U+bc18, U+bc1c, U+bc30, U+bc84, U+bcf5, U+bcf8, U+bd84, U+be0c, U+be14, U+c0b0, U+c0c9, U+c0dd, U+c124, U+c2dd, U+c2e4, U+c2ec, U+c54c, U+c57c-c57d, U+c591, U+c5c5-c5c6, U+c5ed, U+c608, U+c640, U+c6b8, U+c6d4, U+c784, U+c7ac, U+c800-c801, U+c9c1, U+c9d1, U+cc28, U+cc98, U+cc9c, U+ccad, U+cd5c, U+cd94, U+cd9c, U+cde8, U+ce68, U+cf54, U+d0dc, U+d14c, U+d1a0, U+d1b5, U+d2f0, U+d30c, U+d310, U+d398, U+d45c, U+d50c, U+d53c, U+d560, U+d568, U+d589, U+d604, U+d6c4, U+d788;
+}
+
+/* [90] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.90.woff2) format('woff2-variations');
+ unicode-range: U+ac04, U+ac1c, U+ac70, U+ac8c, U+acbd, U+acf5, U+acfc, U+ad00, U+ad6c, U+adf8, U+b098, U+b0b4, U+b294, U+b2c8, U+b300, U+b3c4, U+b3d9, U+b4dc, U+b4e4, U+b77c, U+b7ec, U+b85d, U+b97c, U+b9c8, U+b9cc, U+ba54, U+ba74, U+ba85, U+baa8, U+bb34, U+bb38, U+bbf8, U+bc14, U+bc29, U+bc88, U+bcf4, U+bd80, U+be44, U+c0c1, U+c11c, U+c120, U+c131, U+c138, U+c18c, U+c218, U+c2b5, U+c2e0, U+c544, U+c548, U+c5b4, U+c5d0, U+c5ec, U+c5f0, U+c601, U+c624, U+c694, U+c6a9, U+c6b0, U+c6b4, U+c6d0, U+c704, U+c720, U+c73c, U+c740, U+c744, U+c74c, U+c758, U+c77c, U+c785, U+c788, U+c790-c791, U+c7a5, U+c804, U+c815, U+c81c, U+c870, U+c8fc, U+c911, U+c9c4, U+ccb4, U+ce58, U+ce74, U+d06c, U+d0c0, U+d130, U+d2b8, U+d3ec, U+d504, U+d55c, U+d569, U+d574, U+d638, U+d654, U+d68c;
+}
+
+/* [91] */
+@font-face {
+ font-family: 'Pretendard Variable';
+ font-style: normal;
+ font-display: swap;
+ font-weight: 45 920;
+ src: url(pretendard/PretendardVariable.subset.91.woff2) format('woff2-variations');
+ unicode-range: U+20-22, U+27-2a, U+2c-39, U+3a-3b, U+3f, U+41-4e, U+4f-5d, U+61-7b, U+7d, U+a0-a1, U+ab, U+ad-ae, U+b7, U+bb, U+bf, U+2013-2014, U+201c-201d, U+2122, U+ac00, U+ace0, U+ae30, U+b2e4, U+b85c, U+b9ac, U+c0ac, U+c2a4, U+c2dc, U+c774, U+c778, U+c9c0, U+d558;
+}
diff --git a/site/fonts/pretendard/PretendardVariable.subset.0.woff2 b/site/fonts/pretendard/PretendardVariable.subset.0.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.1.woff2 b/site/fonts/pretendard/PretendardVariable.subset.1.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.10.woff2 b/site/fonts/pretendard/PretendardVariable.subset.10.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.11.woff2 b/site/fonts/pretendard/PretendardVariable.subset.11.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.12.woff2 b/site/fonts/pretendard/PretendardVariable.subset.12.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.13.woff2 b/site/fonts/pretendard/PretendardVariable.subset.13.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.14.woff2 b/site/fonts/pretendard/PretendardVariable.subset.14.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.15.woff2 b/site/fonts/pretendard/PretendardVariable.subset.15.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.16.woff2 b/site/fonts/pretendard/PretendardVariable.subset.16.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.17.woff2 b/site/fonts/pretendard/PretendardVariable.subset.17.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.18.woff2 b/site/fonts/pretendard/PretendardVariable.subset.18.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.19.woff2 b/site/fonts/pretendard/PretendardVariable.subset.19.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.2.woff2 b/site/fonts/pretendard/PretendardVariable.subset.2.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.20.woff2 b/site/fonts/pretendard/PretendardVariable.subset.20.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.21.woff2 b/site/fonts/pretendard/PretendardVariable.subset.21.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.22.woff2 b/site/fonts/pretendard/PretendardVariable.subset.22.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.23.woff2 b/site/fonts/pretendard/PretendardVariable.subset.23.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.24.woff2 b/site/fonts/pretendard/PretendardVariable.subset.24.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.25.woff2 b/site/fonts/pretendard/PretendardVariable.subset.25.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.26.woff2 b/site/fonts/pretendard/PretendardVariable.subset.26.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.27.woff2 b/site/fonts/pretendard/PretendardVariable.subset.27.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.28.woff2 b/site/fonts/pretendard/PretendardVariable.subset.28.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.29.woff2 b/site/fonts/pretendard/PretendardVariable.subset.29.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.3.woff2 b/site/fonts/pretendard/PretendardVariable.subset.3.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.30.woff2 b/site/fonts/pretendard/PretendardVariable.subset.30.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.31.woff2 b/site/fonts/pretendard/PretendardVariable.subset.31.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.32.woff2 b/site/fonts/pretendard/PretendardVariable.subset.32.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.33.woff2 b/site/fonts/pretendard/PretendardVariable.subset.33.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.34.woff2 b/site/fonts/pretendard/PretendardVariable.subset.34.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.35.woff2 b/site/fonts/pretendard/PretendardVariable.subset.35.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.36.woff2 b/site/fonts/pretendard/PretendardVariable.subset.36.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.37.woff2 b/site/fonts/pretendard/PretendardVariable.subset.37.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.38.woff2 b/site/fonts/pretendard/PretendardVariable.subset.38.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.39.woff2 b/site/fonts/pretendard/PretendardVariable.subset.39.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.4.woff2 b/site/fonts/pretendard/PretendardVariable.subset.4.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.40.woff2 b/site/fonts/pretendard/PretendardVariable.subset.40.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.41.woff2 b/site/fonts/pretendard/PretendardVariable.subset.41.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.42.woff2 b/site/fonts/pretendard/PretendardVariable.subset.42.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.43.woff2 b/site/fonts/pretendard/PretendardVariable.subset.43.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.44.woff2 b/site/fonts/pretendard/PretendardVariable.subset.44.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.45.woff2 b/site/fonts/pretendard/PretendardVariable.subset.45.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.46.woff2 b/site/fonts/pretendard/PretendardVariable.subset.46.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.47.woff2 b/site/fonts/pretendard/PretendardVariable.subset.47.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.48.woff2 b/site/fonts/pretendard/PretendardVariable.subset.48.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.49.woff2 b/site/fonts/pretendard/PretendardVariable.subset.49.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.5.woff2 b/site/fonts/pretendard/PretendardVariable.subset.5.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.50.woff2 b/site/fonts/pretendard/PretendardVariable.subset.50.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.51.woff2 b/site/fonts/pretendard/PretendardVariable.subset.51.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.52.woff2 b/site/fonts/pretendard/PretendardVariable.subset.52.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.53.woff2 b/site/fonts/pretendard/PretendardVariable.subset.53.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.54.woff2 b/site/fonts/pretendard/PretendardVariable.subset.54.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.55.woff2 b/site/fonts/pretendard/PretendardVariable.subset.55.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.56.woff2 b/site/fonts/pretendard/PretendardVariable.subset.56.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.57.woff2 b/site/fonts/pretendard/PretendardVariable.subset.57.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.58.woff2 b/site/fonts/pretendard/PretendardVariable.subset.58.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.59.woff2 b/site/fonts/pretendard/PretendardVariable.subset.59.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.6.woff2 b/site/fonts/pretendard/PretendardVariable.subset.6.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.60.woff2 b/site/fonts/pretendard/PretendardVariable.subset.60.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.61.woff2 b/site/fonts/pretendard/PretendardVariable.subset.61.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.62.woff2 b/site/fonts/pretendard/PretendardVariable.subset.62.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.63.woff2 b/site/fonts/pretendard/PretendardVariable.subset.63.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.64.woff2 b/site/fonts/pretendard/PretendardVariable.subset.64.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.65.woff2 b/site/fonts/pretendard/PretendardVariable.subset.65.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.66.woff2 b/site/fonts/pretendard/PretendardVariable.subset.66.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.67.woff2 b/site/fonts/pretendard/PretendardVariable.subset.67.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.68.woff2 b/site/fonts/pretendard/PretendardVariable.subset.68.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.69.woff2 b/site/fonts/pretendard/PretendardVariable.subset.69.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.7.woff2 b/site/fonts/pretendard/PretendardVariable.subset.7.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.70.woff2 b/site/fonts/pretendard/PretendardVariable.subset.70.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.71.woff2 b/site/fonts/pretendard/PretendardVariable.subset.71.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.72.woff2 b/site/fonts/pretendard/PretendardVariable.subset.72.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.73.woff2 b/site/fonts/pretendard/PretendardVariable.subset.73.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.74.woff2 b/site/fonts/pretendard/PretendardVariable.subset.74.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.75.woff2 b/site/fonts/pretendard/PretendardVariable.subset.75.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.76.woff2 b/site/fonts/pretendard/PretendardVariable.subset.76.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.77.woff2 b/site/fonts/pretendard/PretendardVariable.subset.77.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.78.woff2 b/site/fonts/pretendard/PretendardVariable.subset.78.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.79.woff2 b/site/fonts/pretendard/PretendardVariable.subset.79.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.8.woff2 b/site/fonts/pretendard/PretendardVariable.subset.8.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.80.woff2 b/site/fonts/pretendard/PretendardVariable.subset.80.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.81.woff2 b/site/fonts/pretendard/PretendardVariable.subset.81.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.82.woff2 b/site/fonts/pretendard/PretendardVariable.subset.82.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.83.woff2 b/site/fonts/pretendard/PretendardVariable.subset.83.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.84.woff2 b/site/fonts/pretendard/PretendardVariable.subset.84.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.85.woff2 b/site/fonts/pretendard/PretendardVariable.subset.85.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.86.woff2 b/site/fonts/pretendard/PretendardVariable.subset.86.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.87.woff2 b/site/fonts/pretendard/PretendardVariable.subset.87.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.88.woff2 b/site/fonts/pretendard/PretendardVariable.subset.88.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.89.woff2 b/site/fonts/pretendard/PretendardVariable.subset.89.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.9.woff2 b/site/fonts/pretendard/PretendardVariable.subset.9.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.90.woff2 b/site/fonts/pretendard/PretendardVariable.subset.90.woff2
Binary files differ.
diff --git a/site/fonts/pretendard/PretendardVariable.subset.91.woff2 b/site/fonts/pretendard/PretendardVariable.subset.91.woff2
Binary files differ.
diff --git a/site/i18n.js b/site/i18n.js
@@ -0,0 +1,146 @@
+// Page chrome strings. Verdict text comes from the engine's own catalog
+// (lib/messages.js); this file only covers what app.js renders around it.
+
+// Keeps "PHP 8.1" on one line in narrow table cells.
+const NBSP = String.fromCharCode(0xa0);
+
+export const UI = {
+ en: {
+ locale: 'en-GB',
+ sources: { selfcheck: 'PHP probe', servercheck: 'server script', header: 'X-Powered-By header', cli: 'php -v output', version: 'version string' },
+ levels: { high: 'High', medium: 'Medium', info: 'Note', ok: 'Clear', warn: 'Check', bad: 'Risk' },
+ statuses: { ok: 'Applied', warn: 'Not applied', info: 'Note' },
+ input: 'Input',
+ distroData: 'Distribution data',
+ evidence: 'Evidence',
+ exposure: 'SAPI exposure',
+ exposurePatched: 'Exposure without the fix',
+ actions: 'Required actions',
+ indicators: 'Compromise indicators',
+ mitigations: 'Interim mitigations',
+ hardening: 'Hardening',
+ mitIntro: 'These narrow the known attack paths while you patch; none of them is a fix. Values are effective for the directory the probe ran in. Set them with php_admin_value or php_admin_flag so application code cannot override them.',
+ hardeningIntro: 'The fix is present. Shown for reference; the upstream advisory suggests keeping upload progress off where it is not used.',
+ setting: 'Setting',
+ value: 'Value',
+ status: 'Status',
+ advisories: 'Distribution advisories',
+ notes: 'Notes',
+ stale: (when) => `The distribution data was last refreshed on ${when}. Confirm against your distribution's current notices.`,
+ sourceFailed: (name, when) => (when
+ ? `The latest refresh of the ${name} data failed. The last successful refresh was on ${when}. Check your distribution's current security notices as well.`
+ : `The latest refresh of the ${name} data failed, so this result uses data from an earlier refresh. Check your distribution's current security notices as well.`),
+ distroNotLoaded: 'The distribution data did not load, so this result could not use it. Reload the page and run the check again.',
+ upstreamData: 'Fixed releases',
+ crossChecked: (when) => `cross-checked ${when}`,
+ refreshFailed: 'latest refresh failed',
+ refreshFailedSince: (when) => `latest refresh failed, last successful refresh ${when}`,
+ dataChanged: (when) => `last changed ${when}`,
+ notLoaded: 'did not load',
+ sourceNames: { 'osv-debian': 'Debian (OSV)', 'osv-ubuntu': 'Ubuntu (OSV)', redhat: 'Red Hat', ghsa: 'php-src advisories' },
+ replaceConfirm: 'Replace the text in the box with this example? The output you pasted will be lost.',
+ copyCommand: 'Copy command',
+ copied: 'Copied',
+ copyReport: 'Copy report',
+ copyFailed: 'Copy failed',
+ reportTitle: (label) => `PHP2Shell check: ${label}`,
+ reportFooter: (repo, when) => `Checked with php2shell-scanner (${repo}) on ${when}. Verdicts rest on versions, packages and settings; they are not proof that an attack would fail.`,
+ loadFailedTitle: 'Version data did not load',
+ loadFailedBody: 'Reload the page. If it keeps failing, open an issue on GitHub.',
+ branch81: `PHP${NBSP}8.1 and older`,
+ all54: 'All from 5.4; older not determined',
+ noUpstream: (branches) => (branches
+ ? `No upstream release. php-src has minimal backport branches for ${branches}; see also the distribution packages.`
+ : 'No upstream release; see the distribution packages.'),
+ eol: (date) => `EOL ${date}`,
+ fixPending: 'Fix pending',
+ noData: 'No data',
+ proTitle: 'Fix ships through Ubuntu Pro (ESM)',
+ distroFailed: 'Distribution data could not be loaded.',
+ rhIntro: 'Red Hat backports fixes into module streams without changing the upstream version, so these are not compared by version. On the RHEL family the server script reads the CVE entries in the package changelog instead.',
+ rhFailed: 'Red Hat data could not be loaded.',
+ statusUpstream: (date) => `Upstream fixed versions last edited ${date}.`,
+ statusUpstreamChecked: (when) => `Upstream fixed versions cross-checked against the php-src advisories on ${when}.`,
+ statusUpstreamUnchecked: (date) => `Upstream fixed versions last edited ${date}; the latest refresh could not cross-check them against the php-src advisories.`,
+ statusUpstreamMismatch: (date) => `Upstream fixed versions last edited ${date}; the latest refresh found a difference from the php-src advisories, so check the advisories directly.`,
+ statusDistro: (changed, checked) => (checked
+ ? `Distribution data last changed ${changed} and last checked ${checked}; it is refreshed every 6 hours from OSV and Red Hat.`
+ : `Distribution data last changed ${changed}; it is refreshed every 6 hours from OSV and Red Hat.`),
+ statusDistroMissing: 'Distribution data did not load.',
+ statusFailed: (list) => `The latest refresh failed for ${list}, so data from an earlier refresh is shown.`,
+ statusLastSuccess: (name, when) => `The last successful refresh of ${name} was on ${when}.`,
+ rhelFamily: 'RHEL family',
+ rhelOption: (rel) => `RHEL ${rel} / Rocky / AlmaLinux`,
+ upstreamOption: 'Self-built or official Docker image',
+ utc: 'UTC',
+ },
+ ko: {
+ locale: 'ko-KR',
+ sources: { selfcheck: 'PHP 프로브', servercheck: '서버 스크립트', header: 'X-Powered-By 헤더', cli: 'php -v 출력', version: '버전 문자열' },
+ levels: { high: '높음', medium: '보통', info: '참고', ok: '이상 없음', warn: '확인 필요', bad: '위험' },
+ statuses: { ok: '적용됨', warn: '미적용', info: '참고' },
+ input: '입력',
+ distroData: '배포판 데이터',
+ evidence: '근거',
+ exposure: 'SAPI 노출도',
+ exposurePatched: '수정 전 노출도',
+ actions: '필요한 조치',
+ indicators: '침해 지표',
+ mitigations: '임시 완화 설정',
+ hardening: '추가 보안 설정',
+ mitIntro: '패치를 마칠 때까지 알려진 공격 경로를 좁혀 주는 설정이며, 어느 것도 패치를 대신하지 않습니다. 값은 프로브가 실행된 디렉터리 기준의 실제 적용값입니다. 애플리케이션 코드가 되돌리지 못하도록 php_admin_value 또는 php_admin_flag로 설정하세요.',
+ hardeningIntro: '수정이 적용되어 있습니다. 참고용으로 표시합니다. 업스트림 공식 권고는 업로드 진행률 기능을 쓰지 않는다면 꺼 두기를 권합니다.',
+ setting: '설정',
+ value: '값',
+ status: '상태',
+ advisories: '배포판 보안 권고',
+ notes: '참고 사항',
+ stale: (when) => `배포판 데이터를 마지막으로 갱신한 시각은 ${when}입니다. 배포판의 최신 보안 권고도 함께 확인하세요.`,
+ sourceFailed: (name, when) => (when
+ ? `${name} 데이터의 최근 갱신이 실패했습니다. 마지막으로 성공한 갱신은 ${when}입니다. 배포판의 최신 보안 권고도 함께 확인하세요.`
+ : `${name} 데이터의 최근 갱신이 실패해 이전에 받은 데이터로 확인했습니다. 배포판의 최신 보안 권고도 함께 확인하세요.`),
+ distroNotLoaded: '배포판 데이터를 불러오지 못해 이 결과에 반영하지 못했습니다. 페이지를 새로 고친 뒤 다시 확인하세요.',
+ upstreamData: '수정 릴리스',
+ crossChecked: (when) => `${when}에 공식 권고와 대조`,
+ refreshFailed: '최근 갱신 실패',
+ refreshFailedSince: (when) => `최근 갱신 실패, 마지막 성공 ${when}`,
+ dataChanged: (when) => `${when}에 마지막 변경`,
+ notLoaded: '불러오지 못함',
+ sourceNames: { 'osv-debian': 'Debian(OSV)', 'osv-ubuntu': 'Ubuntu(OSV)', redhat: 'Red Hat', ghsa: 'php-src 공식 권고' },
+ replaceConfirm: '입력란의 내용을 이 예시로 바꾸시겠습니까? 붙여넣은 출력은 사라집니다.',
+ copyCommand: '명령 복사',
+ copied: '복사됨',
+ copyReport: '결과 복사',
+ copyFailed: '복사 실패',
+ reportTitle: (label) => `PHP2Shell 확인 결과: ${label}`,
+ reportFooter: (repo, when) => `php2shell-scanner(${repo})로 ${when}에 확인했습니다. 결과는 버전, 패키지, 설정을 근거로 하며 공격이 실패한다는 증명이 아닙니다.`,
+ loadFailedTitle: '버전 데이터를 불러오지 못했습니다',
+ loadFailedBody: '페이지를 새로 고치세요. 계속 실패하면 GitHub에 이슈를 남겨 주세요.',
+ branch81: `PHP${NBSP}8.1 이하`,
+ all54: '5.4 이상 전체, 그 이전은 판단 불가',
+ noUpstream: (branches) => (branches
+ ? `업스트림 수정 릴리스가 없습니다. php-src에 ${branches}용 최소 백포트 브랜치가 있습니다. 배포판 패키지도 확인하세요.`
+ : '업스트림 수정 릴리스가 없습니다. 배포판 패키지를 확인하세요.'),
+ eol: (date) => `지원 종료 ${date}`,
+ fixPending: '수정 대기',
+ noData: '데이터 없음',
+ proTitle: 'Ubuntu Pro(ESM)로만 배포되는 수정입니다',
+ distroFailed: '배포판 데이터를 불러오지 못했습니다.',
+ rhIntro: 'Red Hat은 업스트림 버전 번호를 바꾸지 않고 모듈 스트림에 수정을 백포트하므로 버전으로 비교하지 않습니다. RHEL 계열에서는 서버 스크립트가 패키지 변경 이력의 CVE 기록을 대신 확인합니다.',
+ rhFailed: 'Red Hat 데이터를 불러오지 못했습니다.',
+ statusUpstream: (date) => `업스트림 수정 버전 목록은 ${date}에 마지막으로 변경했습니다.`,
+ statusUpstreamChecked: (when) => `업스트림 수정 버전 목록은 ${when}에 php-src 공식 권고와 대조했습니다.`,
+ statusUpstreamUnchecked: (date) => `업스트림 수정 버전 목록은 ${date}에 마지막으로 변경했으며, 최근 갱신에서는 php-src 공식 권고와 대조하지 못했습니다.`,
+ statusUpstreamMismatch: (date) => `업스트림 수정 버전 목록은 ${date}에 마지막으로 변경했으며, 최근 갱신에서 php-src 공식 권고와 다른 점이 발견됐습니다. 공식 권고를 직접 확인하세요.`,
+ statusDistro: (changed, checked) => (checked
+ ? `배포판 데이터는 ${changed}에 마지막으로 바뀌었고 ${checked}에 마지막으로 확인했습니다. OSV와 Red Hat에서 6시간마다 갱신합니다.`
+ : `배포판 데이터는 ${changed}에 마지막으로 바뀌었고, OSV와 Red Hat에서 6시간마다 갱신합니다.`),
+ statusDistroMissing: '배포판 데이터를 불러오지 못했습니다.',
+ statusFailed: (list) => `최근 갱신에서 ${list} 데이터를 받지 못해 이전에 받은 데이터를 표시합니다.`,
+ statusLastSuccess: (name, when) => `${name}의 마지막 갱신 성공 시각은 ${when}입니다.`,
+ rhelFamily: 'RHEL 계열',
+ rhelOption: (rel) => `RHEL ${rel} / Rocky / AlmaLinux`,
+ upstreamOption: '직접 빌드 또는 공식 Docker 이미지',
+ utc: 'UTC',
+ },
+};
diff --git a/site/index.html b/site/index.html
@@ -0,0 +1,206 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'">
+ <meta name="referrer" content="no-referrer">
+ <meta name="color-scheme" content="light">
+ <meta name="theme-color" content="#ffffff">
+ <title>PHP2Shell exposure check · CVE-2026-109618, CVE-2026-109619</title>
+ <meta name="description" content="Decide whether a server's PHP is affected by PHP2Shell (CVE-2026-109618, CVE-2026-109619) from facts collected on the server. Handles distribution backports, missed restarts and interim mitigations. Runs in your browser.">
+ <meta property="og:type" content="website">
+ <meta property="og:title" content="PHP2Shell exposure check">
+ <meta property="og:description" content="CVE-2026-109618 and CVE-2026-109619, checked from inside the server. Backports, missed restarts and interim mitigations included.">
+ <link rel="alternate" hreflang="en" href="./">
+ <link rel="alternate" hreflang="ko" href="kr/">
+ <link rel="icon" href="favicon.svg" type="image/svg+xml">
+ <link rel="preload" href="fonts/jetbrains-mono-400.woff2" as="font" type="font/woff2" crossorigin>
+ <link rel="stylesheet" href="fonts/pretendard.css">
+ <link rel="stylesheet" href="style.css">
+ <script type="module" src="app.js"></script>
+</head>
+<body>
+ <a class="skip" href="#input">Skip to input</a>
+
+ <header class="topbar">
+ <div class="topbar-in">
+ <a class="brand" href="./">
+ <svg viewBox="0 0 32 32" width="22" height="22" aria-hidden="true" focusable="false"><rect width="32" height="32" rx="7" fill="#000"/><path d="M11 24V8h5.5a4.5 4.5 0 0 1 0 9H11" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
+ <span>php2shell-scanner</span>
+ </a>
+ <nav class="cves" aria-label="Advisories">
+ <a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noreferrer">CVE-2026-109618</a>
+ <a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noreferrer">CVE-2026-109619</a>
+ </nav>
+ <div class="topbar-end">
+ <a class="lang-toggle" href="kr/" hreflang="ko" lang="ko" aria-label="한국어로 보기" title="한국어로 보기">
+ <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>
+ <span>KR</span>
+ </a>
+ <a class="report-link" href="https://sqzer.com/writing/2026/php2shell-cve-2026-109618-cve-2026-109619/" target="_blank" rel="noopener" aria-label="View report" title="Opens the write-up on sqzer.com in a new tab"><svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/></svg><span>View report</span></a>
+ <a class="source" id="repo-link" href="https://github.com/sqzer-x/php2shell-scanner" aria-label="Source on GitHub" title="Source on GitHub">
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
+ </a>
+ </div>
+ </div>
+ </header>
+
+ <main class="workspace">
+ <section class="pane" aria-labelledby="title">
+ <h1 id="title">PHP2Shell exposure check</h1>
+ <p class="lede">Decides whether a server's PHP is affected by CVE-2026-109618 and CVE-2026-109619 from facts collected on the server itself, including distribution backports, missed restarts and interim mitigations. The verdict is computed in this tab; what you paste is not sent anywhere.</p>
+
+ <div class="step">
+ <h2 class="step-head"><span class="step-n">1</span>Collect</h2>
+ <div class="collector">
+ <div class="tabs" role="tablist" aria-label="Collection method">
+ <button class="tab" type="button" role="tab" id="tab-probe" aria-controls="panel-probe" aria-selected="true">PHP probe <span class="tab-hint">recommended</span></button>
+ <button class="tab" type="button" role="tab" id="tab-script" aria-controls="panel-script" aria-selected="false" tabindex="-1">Server script</button>
+ <button class="tab" type="button" role="tab" id="tab-header" aria-controls="panel-header" aria-selected="false" tabindex="-1">Response header</button>
+ </div>
+
+ <div class="tab-panel" role="tabpanel" id="panel-probe" aria-labelledby="tab-probe">
+ <h3 class="panel-title">PHP probe</h3>
+ <div class="get">
+ <a class="btn btn-download" href="downloads/php2shell-selfcheck.php" download aria-label="Download php2shell-selfcheck.php" title="Download"><svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 21h16"/></svg>php2shell-selfcheck.php</a>
+ </div>
+ <p class="or">or fetch it on the server</p>
+ <div class="cmdline"><code class="cmd" data-download="php2shell-selfcheck.php">curl -fsSLO https://sqzer-x.github.io/php2shell-scanner/downloads/php2shell-selfcheck.php</code><button class="copy" type="button" aria-label="Copy command"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <p class="sha" data-sha-for="php2shell-selfcheck.php"></p>
+ <ol class="howto">
+ <li>Set <code>$TOKEN</code> in the file to 12 or more random letters, digits, <code>-</code> or <code>_</code>, for example the output of <code>openssl rand -hex 16</code>.</li>
+ <li>Give the file an unguessable name and upload it to the site's web root.</li>
+ <li>Open <code>https://<site>/<file>.php?k=<TOKEN></code> once. The file then tries to delete itself and says so if it cannot.</li>
+ <li>Repeat per vhost and per PHP-FPM pool; each may run a different PHP.</li>
+ </ol>
+ </div>
+
+ <div class="tab-panel" role="tabpanel" id="panel-script" aria-labelledby="tab-script" hidden>
+ <h3 class="panel-title">Server script</h3>
+ <div class="get">
+ <a class="btn btn-download" href="downloads/php2shell-server-check.sh" download aria-label="Download php2shell-server-check.sh" title="Download"><svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 21h16"/></svg>php2shell-server-check.sh</a>
+ </div>
+ <p class="or">or fetch it on the server</p>
+ <div class="cmdline"><code class="cmd" data-download="php2shell-server-check.sh" data-then="sha256sum php2shell-server-check.sh">curl -fsSLO https://sqzer-x.github.io/php2shell-scanner/downloads/php2shell-server-check.sh && sha256sum php2shell-server-check.sh</code><button class="copy" type="button" aria-label="Copy command"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <p class="sha" data-sha-for="php2shell-server-check.sh"></p>
+ <p class="or">read it, then run it</p>
+ <div class="cmdline"><code class="cmd">sudo sh php2shell-server-check.sh --logs</code><button class="copy" type="button" aria-label="Copy command"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <ol class="howto">
+ <li>POSIX sh, read-only, no network access. Compare the printed hash with the one shown above (also in <a href="downloads/SHA256SUMS">SHA256SUMS</a>) and read the script before you run it.</li>
+ <li>Reports installed PHP packages and the CVE entries in their changelogs, and PHP processes still mapped to files replaced by an update.</li>
+ <li><code class="lit">--logs</code> scans web server, PHP-FPM and kernel logs for indicators; <code class="lit">--webroot DIR</code> lists PHP files changed in the last 3 days.</li>
+ <li>Run as root to inspect processes owned by other accounts.</li>
+ </ol>
+ </div>
+
+ <div class="tab-panel" role="tabpanel" id="panel-header" aria-labelledby="tab-header" hidden>
+ <h3 class="panel-title">Response header</h3>
+ <div class="cmdline"><code class="cmd">curl -sI https://<site>/ | grep -i x-powered-by</code><button class="copy" type="button" aria-label="Copy command"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <ol class="howto">
+ <li>Use only when you have no access to the server.</li>
+ <li>Works only if the header is exposed, and describes only the PHP behind that URL.</li>
+ <li>Debian and Ubuntu package revisions in the header are still recognised. Settings, other vhosts and restarts are not visible.</li>
+ </ol>
+ </div>
+ </div>
+ </div>
+
+ <form class="step" id="check-form" novalidate>
+ <div class="step-head-row">
+ <h2 class="step-head"><span class="step-n">2</span><label for="input">Paste output</label></h2>
+ <div class="examples">
+ <select id="example" class="select-sm" aria-label="Example input">
+ <option value="">Choose an example</option>
+ <option value="backport">Ubuntu 22.04, backported fix</option>
+ <option value="restart">Ubuntu 22.04, not restarted</option>
+ <option value="docker">Official Docker image, old tag</option>
+ <option value="rhel">AlmaLinux 9, server script</option>
+ </select>
+ <button type="button" id="load-example" class="btn btn-sm">Load</button>
+ </div>
+ </div>
+ <textarea id="input" rows="12" spellcheck="false" autocomplete="off" autocapitalize="off" placeholder="Probe output (-----BEGIN PHP2SHELL SELFCHECK-----), server script output (-----BEGIN PHP2SHELL SERVERCHECK-----), both, an X-Powered-By header or a version string."></textarea>
+ <div class="form-row">
+ <label class="field">
+ <span>Distribution</span>
+ <select id="distro"><option value="auto">Auto-detect</option></select>
+ </label>
+ <div class="form-buttons">
+ <button type="button" id="clear" class="btn">Clear</button>
+ <button type="submit" class="btn btn-primary">Run check</button>
+ </div>
+ </div>
+ </form>
+ </section>
+
+ <section class="pane pane-result" aria-labelledby="result-title">
+ <div class="step-head-row">
+ <h2 class="step-head" id="result-title"><span class="step-n">3</span>Result</h2>
+ <button type="button" class="btn btn-sm" id="copy-report" hidden>Copy report</button>
+ </div>
+ <noscript><p class="noscript">The check runs as JavaScript in your browser. Enable JavaScript, or apply the rules in the <a href="https://github.com/sqzer-x/php2shell-scanner#readme">README</a> by hand.</p></noscript>
+ <div id="result">
+ <div class="empty">
+ <p>No result yet. Collect facts on the server, paste the output and run the check.</p>
+ <table class="legend">
+ <caption class="sr-only">Possible verdicts</caption>
+ <tbody>
+ <tr><th scope="row"><span class="badge badge-patched">Patched</span></th><td>At or above the upstream fixed release, or a distribution package carrying both fixes.</td></tr>
+ <tr><th scope="row"><span class="badge badge-vulnerable">Vulnerable</span></th><td>Below the fixed release, including upstream builds of end-of-life branches.</td></tr>
+ <tr><th scope="row"><span class="badge badge-pending">Fix pending</span></th><td>The distribution tracks the CVEs but has not shipped a fixed package.</td></tr>
+ <tr><th scope="row"><span class="badge badge-restart">Restart needed</span></th><td>The fixed package is installed; the running PHP is still the old build.</td></tr>
+ <tr><th scope="row"><span class="badge badge-unknown">Undetermined</span></th><td>Not enough evidence. Never reported as safe.</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </section>
+ </main>
+
+ <section class="reference" aria-labelledby="ref-title">
+ <div class="reference-in">
+ <div class="ref-col">
+ <h2 id="ref-title">Affected releases</h2>
+ <noscript><p class="noscript">The release and package tables need JavaScript. Enable it, or read the data in <a href="data/upstream.json">upstream.json</a> and <a href="data/distro.json">distro.json</a> directly.</p></noscript>
+ <table class="data" id="upstream-table">
+ <thead><tr><th scope="col">Branch</th><th scope="col">Affected</th><th scope="col">Fixed</th></tr></thead>
+ <tbody></tbody>
+ </table>
+ <details class="disclosure">
+ <summary>Distribution packages</summary>
+ <table class="data" id="distro-table">
+ <thead><tr><th scope="col">Distribution</th><th scope="col">Source package</th><th scope="col">Fixed in</th></tr></thead>
+ <tbody></tbody>
+ </table>
+ </details>
+ <details class="disclosure">
+ <summary>Red Hat advisories</summary>
+ <div id="redhat-list"></div>
+ </details>
+ <p class="fine" id="data-status"></p>
+ </div>
+
+ <div class="ref-col">
+ <h2>Method</h2>
+ <ol class="method">
+ <li>A version at or above the upstream fixed release for its branch is patched.</li>
+ <li>Debian and Ubuntu packages are compared with the distribution's fixed version using dpkg ordering.</li>
+ <li>On the RHEL family the evidence is the CVE entries in the package changelog, collected by the server script.</li>
+ <li>A running version older than the installed package, or a PHP process mapping a deleted file, means restart needed.</li>
+ <li>deb.sury.org builds, official Docker images and self-built PHP are judged against upstream releases.</li>
+ <li>Interim mitigations are reported but never change a verdict.</li>
+ <li>Missing evidence gives Undetermined, never safe.</li>
+ </ol>
+ <h2>Scope</h2>
+ <p class="fine-block">No request is sent to your server. The probe reads versions, packages and settings. The server script also reads package changelogs, the process list with command lines, the files each PHP process has mapped and PHP and web server configuration files. With <code class="lit">--logs</code> and <code class="lit">--webroot</code> it also reads logs, session file sizes and the names and dates of PHP files. Its terminal output can show command lines and log lines; the page needs only the block between the BEGIN and END lines.</p>
+ <p class="fine-block">A verdict is not proof that an attack would fail. Read the upstream advisories and your distribution's notices as well.</p>
+ </div>
+ </div>
+ </section>
+
+ <footer class="footer">
+ <p>php2shell-scanner · MIT · <a class="repo-link" href="https://github.com/sqzer-x/php2shell-scanner">Source</a> · <a href="data/distro.json">distro.json</a> · <a href="data/upstream.json">upstream.json</a> · <a href="downloads/SHA256SUMS">SHA256SUMS</a> · by <a href="https://sqzer.com">Hyukjin An</a></p>
+ </footer>
+</body>
+</html>
diff --git a/site/kr/index.html b/site/kr/index.html
@@ -0,0 +1,206 @@
+<!doctype html>
+<html lang="ko" data-root="../">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'">
+ <meta name="referrer" content="no-referrer">
+ <meta name="color-scheme" content="light">
+ <meta name="theme-color" content="#ffffff">
+ <title>PHP2Shell 노출 점검 · CVE-2026-109618, CVE-2026-109619</title>
+ <meta name="description" content="서버에서 직접 수집한 정보로 PHP가 PHP2Shell(CVE-2026-109618, CVE-2026-109619)의 영향을 받는지 확인합니다. 배포판 백포트, 재시작 누락, 임시 완화 설정까지 반영하며 브라우저 안에서만 실행됩니다.">
+ <meta property="og:type" content="website">
+ <meta property="og:title" content="PHP2Shell 노출 점검">
+ <meta property="og:description" content="서버에서 수집한 정보로 CVE-2026-109618, CVE-2026-109619의 영향을 확인합니다. 배포판 백포트, 재시작 누락, 임시 완화 설정까지 반영합니다.">
+ <link rel="alternate" hreflang="en" href="../">
+ <link rel="alternate" hreflang="ko" href="./">
+ <link rel="icon" href="../favicon.svg" type="image/svg+xml">
+ <link rel="preload" href="../fonts/jetbrains-mono-400.woff2" as="font" type="font/woff2" crossorigin>
+ <link rel="stylesheet" href="../fonts/pretendard.css">
+ <link rel="stylesheet" href="../style.css">
+ <script type="module" src="../app.js"></script>
+</head>
+<body>
+ <a class="skip" href="#input">입력으로 건너뛰기</a>
+
+ <header class="topbar">
+ <div class="topbar-in">
+ <a class="brand" href="./">
+ <svg viewBox="0 0 32 32" width="22" height="22" aria-hidden="true" focusable="false"><rect width="32" height="32" rx="7" fill="#000"/><path d="M11 24V8h5.5a4.5 4.5 0 0 1 0 9H11" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
+ <span>php2shell-scanner</span>
+ </a>
+ <nav class="cves" aria-label="취약점 공식 권고">
+ <a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noreferrer">CVE-2026-109618</a>
+ <a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noreferrer">CVE-2026-109619</a>
+ </nav>
+ <div class="topbar-end">
+ <a class="lang-toggle" href="../" hreflang="en" lang="en" aria-label="View in English" title="View in English">
+ <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>
+ <span>EN</span>
+ </a>
+ <a class="report-link" href="https://sqzer.com/writing/2026/php2shell-cve-2026-109618-cve-2026-109619_kr/" target="_blank" rel="noopener" aria-label="보고서 보기" title="sqzer.com의 보고서를 새 탭에서 엽니다"><svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/></svg><span>보고서 보기</span></a>
+ <a class="source" id="repo-link" href="https://github.com/sqzer-x/php2shell-scanner" aria-label="GitHub 소스 코드" title="GitHub 소스 코드">
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
+ </a>
+ </div>
+ </div>
+ </header>
+
+ <main class="workspace">
+ <section class="pane" aria-labelledby="title">
+ <h1 id="title">PHP2Shell 노출 점검</h1>
+ <p class="lede">서버에서 직접 수집한 정보로 PHP가 CVE-2026-109618, CVE-2026-109619의 영향을 받는지 확인합니다. 배포판 백포트, 재시작 누락, 임시 완화 설정까지 반영합니다. 확인은 이 탭 안에서만 이루어지며, 붙여넣은 내용은 어디에도 전송되지 않습니다.</p>
+
+ <div class="step">
+ <h2 class="step-head"><span class="step-n">1</span>수집</h2>
+ <div class="collector">
+ <div class="tabs" role="tablist" aria-label="수집 방법">
+ <button class="tab" type="button" role="tab" id="tab-probe" aria-controls="panel-probe" aria-selected="true">PHP 프로브 <span class="tab-hint">권장</span></button>
+ <button class="tab" type="button" role="tab" id="tab-script" aria-controls="panel-script" aria-selected="false" tabindex="-1">서버 스크립트</button>
+ <button class="tab" type="button" role="tab" id="tab-header" aria-controls="panel-header" aria-selected="false" tabindex="-1">응답 헤더</button>
+ </div>
+
+ <div class="tab-panel" role="tabpanel" id="panel-probe" aria-labelledby="tab-probe">
+ <h3 class="panel-title">PHP 프로브</h3>
+ <div class="get">
+ <a class="btn btn-download" href="../downloads/php2shell-selfcheck.php" download aria-label="php2shell-selfcheck.php 내려받기" title="내려받기"><svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 21h16"/></svg>php2shell-selfcheck.php</a>
+ </div>
+ <p class="or">또는 서버에서 바로 받기</p>
+ <div class="cmdline"><code class="cmd" data-download="php2shell-selfcheck.php">curl -fsSLO https://sqzer-x.github.io/php2shell-scanner/downloads/php2shell-selfcheck.php</code><button class="copy" type="button" aria-label="명령 복사"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <p class="sha" data-sha-for="php2shell-selfcheck.php"></p>
+ <ol class="howto">
+ <li>파일 안의 <code>$TOKEN</code>을 영문자, 숫자, <code>-</code>, <code>_</code>로 이루어진 12자 이상의 임의 문자열로 바꿉니다. 예를 들어 <code>openssl rand -hex 16</code>의 출력을 쓰면 됩니다.</li>
+ <li>파일 이름을 추측하기 어렵게 바꿔 사이트의 웹 루트에 올립니다.</li>
+ <li><code>https://<사이트>/<파일>.php?k=<TOKEN></code>을 한 번 엽니다. 실행 후 파일은 스스로 삭제를 시도하며, 삭제하지 못하면 출력에 그 사실을 표시합니다.</li>
+ <li>vhost와 PHP-FPM 풀마다 반복합니다. 각각 다른 PHP를 쓸 수 있습니다.</li>
+ </ol>
+ </div>
+
+ <div class="tab-panel" role="tabpanel" id="panel-script" aria-labelledby="tab-script" hidden>
+ <h3 class="panel-title">서버 스크립트</h3>
+ <div class="get">
+ <a class="btn btn-download" href="../downloads/php2shell-server-check.sh" download aria-label="php2shell-server-check.sh 내려받기" title="내려받기"><svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 21h16"/></svg>php2shell-server-check.sh</a>
+ </div>
+ <p class="or">또는 서버에서 바로 받기</p>
+ <div class="cmdline"><code class="cmd" data-download="php2shell-server-check.sh" data-then="sha256sum php2shell-server-check.sh">curl -fsSLO https://sqzer-x.github.io/php2shell-scanner/downloads/php2shell-server-check.sh && sha256sum php2shell-server-check.sh</code><button class="copy" type="button" aria-label="명령 복사"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <p class="sha" data-sha-for="php2shell-server-check.sh"></p>
+ <p class="or">내용을 확인한 뒤 실행</p>
+ <div class="cmdline"><code class="cmd">sudo sh php2shell-server-check.sh --logs</code><button class="copy" type="button" aria-label="명령 복사"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <ol class="howto">
+ <li>POSIX sh로 작성됐고 읽기만 하며 네트워크에 접속하지 않습니다. 실행하기 전에 출력된 해시를 위에 표시된 값(<a href="../downloads/SHA256SUMS">SHA256SUMS</a>에도 있음)과 비교하고 스크립트 내용을 읽어 보세요.</li>
+ <li>설치된 PHP 패키지와 변경 이력의 CVE 기록, 업데이트로 교체된 파일을 아직 쓰는 PHP 프로세스를 출력합니다.</li>
+ <li><code class="lit">--logs</code>는 웹 서버, PHP-FPM, 커널 로그에서 침해 지표를 찾고, <code class="lit">--webroot DIR</code>은 최근 3일 안에 바뀐 PHP 파일을 보여 줍니다.</li>
+ <li>다른 계정의 프로세스까지 확인하려면 root로 실행하세요.</li>
+ </ol>
+ </div>
+
+ <div class="tab-panel" role="tabpanel" id="panel-header" aria-labelledby="tab-header" hidden>
+ <h3 class="panel-title">응답 헤더</h3>
+ <div class="cmdline"><code class="cmd">curl -sI https://<사이트>/ | grep -i x-powered-by</code><button class="copy" type="button" aria-label="명령 복사"><svg class="i-copy" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><svg class="i-check" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></button></div>
+ <ol class="howto">
+ <li>서버에 접근할 수 없을 때만 쓰세요.</li>
+ <li>헤더가 노출된 경우에만 동작하며, 그 URL을 처리한 PHP 하나만 알 수 있습니다.</li>
+ <li>헤더에 담긴 Debian·Ubuntu 패키지 리비전은 인식하지만 설정, 다른 vhost, 재시작 여부는 알 수 없습니다.</li>
+ </ol>
+ </div>
+ </div>
+ </div>
+
+ <form class="step" id="check-form" novalidate>
+ <div class="step-head-row">
+ <h2 class="step-head"><span class="step-n">2</span><label for="input">출력 붙여넣기</label></h2>
+ <div class="examples">
+ <select id="example" class="select-sm" aria-label="예시 입력">
+ <option value="">예시 선택</option>
+ <option value="backport">Ubuntu 22.04, 백포트된 수정</option>
+ <option value="restart">Ubuntu 22.04, 재시작 안 함</option>
+ <option value="docker">공식 Docker 이미지, 오래된 태그</option>
+ <option value="rhel">AlmaLinux 9, 서버 스크립트</option>
+ </select>
+ <button type="button" id="load-example" class="btn btn-sm">불러오기</button>
+ </div>
+ </div>
+ <textarea id="input" rows="12" spellcheck="false" autocomplete="off" autocapitalize="off" placeholder="프로브 출력(-----BEGIN PHP2SHELL SELFCHECK-----), 서버 스크립트 출력(-----BEGIN PHP2SHELL SERVERCHECK-----), 또는 둘 다 붙여넣으세요. X-Powered-By 헤더나 버전 문자열만 붙여넣어도 됩니다."></textarea>
+ <div class="form-row">
+ <label class="field">
+ <span>배포판</span>
+ <select id="distro"><option value="auto">자동 감지</option></select>
+ </label>
+ <div class="form-buttons">
+ <button type="button" id="clear" class="btn">지우기</button>
+ <button type="submit" class="btn btn-primary">결과확인</button>
+ </div>
+ </div>
+ </form>
+ </section>
+
+ <section class="pane pane-result" aria-labelledby="result-title">
+ <div class="step-head-row">
+ <h2 class="step-head" id="result-title"><span class="step-n">3</span>결과</h2>
+ <button type="button" class="btn btn-sm" id="copy-report" hidden>결과 복사</button>
+ </div>
+ <noscript><p class="noscript">확인은 브라우저의 JavaScript로 실행됩니다. JavaScript를 켜거나 <a href="https://github.com/sqzer-x/php2shell-scanner#readme">README</a>의 확인 규칙을 직접 적용하세요.</p></noscript>
+ <div id="result">
+ <div class="empty">
+ <p>아직 결과가 없습니다. 서버에서 정보를 수집하고 출력을 붙여넣은 뒤 결과를 확인하세요.</p>
+ <table class="legend">
+ <caption class="sr-only">확인 결과 종류</caption>
+ <tbody>
+ <tr><th scope="row"><span class="badge badge-patched">패치됨</span></th><td>업스트림 수정 릴리스 이상이거나, 두 수정이 모두 들어간 배포판 패키지입니다.</td></tr>
+ <tr><th scope="row"><span class="badge badge-vulnerable">취약</span></th><td>수정 릴리스보다 낮습니다. 지원 종료(EOL) 브랜치의 업스트림 빌드도 포함합니다.</td></tr>
+ <tr><th scope="row"><span class="badge badge-pending">수정 대기</span></th><td>배포판이 CVE를 추적하고 있지만 수정 패키지를 아직 내놓지 않았습니다.</td></tr>
+ <tr><th scope="row"><span class="badge badge-restart">재시작 필요</span></th><td>수정 패키지는 설치됐지만 실행 중인 PHP는 여전히 이전 빌드입니다.</td></tr>
+ <tr><th scope="row"><span class="badge badge-unknown">판단 불가</span></th><td>근거가 부족합니다. 근거 없이 안전하다는 결과를 내지 않습니다.</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </section>
+ </main>
+
+ <section class="reference" aria-labelledby="ref-title">
+ <div class="reference-in">
+ <div class="ref-col">
+ <h2 id="ref-title">영향 릴리스</h2>
+ <noscript><p class="noscript">릴리스 표와 패키지 표는 JavaScript가 있어야 표시됩니다. JavaScript를 켜거나 <a href="../data/upstream.json">upstream.json</a>과 <a href="../data/distro.json">distro.json</a>의 데이터를 직접 확인하세요.</p></noscript>
+ <table class="data" id="upstream-table">
+ <thead><tr><th scope="col">브랜치</th><th scope="col">영향 범위</th><th scope="col">수정 버전</th></tr></thead>
+ <tbody></tbody>
+ </table>
+ <details class="disclosure">
+ <summary>배포판 패키지</summary>
+ <table class="data" id="distro-table">
+ <thead><tr><th scope="col">배포판</th><th scope="col">소스 패키지</th><th scope="col">수정 버전</th></tr></thead>
+ <tbody></tbody>
+ </table>
+ </details>
+ <details class="disclosure">
+ <summary>Red Hat 보안 권고</summary>
+ <div id="redhat-list"></div>
+ </details>
+ <p class="fine" id="data-status"></p>
+ </div>
+
+ <div class="ref-col">
+ <h2>확인 방법</h2>
+ <ol class="method">
+ <li>브랜치별 업스트림 수정 릴리스 이상이면 패치된 것으로 봅니다.</li>
+ <li>Debian·Ubuntu 패키지는 dpkg 버전 비교 규칙으로 배포판 수정 버전과 비교합니다.</li>
+ <li>RHEL 계열은 서버 스크립트가 수집한 패키지 변경 이력의 CVE 기록을 근거로 씁니다.</li>
+ <li>실행 중인 버전이 설치된 패키지보다 오래됐거나 PHP 프로세스가 삭제된 파일을 매핑하고 있으면 재시작 필요로 봅니다.</li>
+ <li>deb.sury.org 빌드, 공식 Docker 이미지, 직접 빌드한 PHP는 업스트림 릴리스 기준으로 확인합니다.</li>
+ <li>임시 완화 설정은 보고만 하며 결과를 바꾸지 않습니다.</li>
+ <li>근거가 없으면 판단 불가로 두며, 안전하다는 결과를 내지 않습니다.</li>
+ </ol>
+ <h2>범위</h2>
+ <p class="fine-block">이 페이지는 서버에 요청을 보내지 않습니다. 프로브는 버전, 패키지, 설정을 읽습니다. 서버 스크립트는 여기에 더해 패키지 변경 이력, 명령줄을 포함한 프로세스 목록, 각 PHP 프로세스가 매핑한 파일, PHP와 웹 서버 설정 파일을 읽습니다. <code class="lit">--logs</code>와 <code class="lit">--webroot</code> 옵션을 주면 로그, 세션 파일 크기, PHP 파일의 이름과 변경 날짜도 읽습니다. 터미널 출력에는 명령줄과 로그 내용이 나타날 수 있으며, 이 페이지에는 BEGIN 줄부터 END 줄까지만 붙여넣으면 됩니다.</p>
+ <p class="fine-block">확인 결과는 공격이 실패한다는 증명이 아닙니다. 업스트림 공식 권고와 배포판 보안 권고도 함께 확인하세요.</p>
+ </div>
+ </div>
+ </section>
+
+ <footer class="footer">
+ <p>php2shell-scanner · MIT · <a class="repo-link" href="https://github.com/sqzer-x/php2shell-scanner">소스</a> · <a href="../data/distro.json">distro.json</a> · <a href="../data/upstream.json">upstream.json</a> · <a href="../downloads/SHA256SUMS">SHA256SUMS</a> · by <a href="https://sqzer.com">Hyukjin An</a></p>
+ </footer>
+</body>
+</html>
diff --git a/site/lib/judge.js b/site/lib/judge.js
@@ -0,0 +1,2723 @@
+// Computes the verdict from an observation (parse.js) and the fixed-version data.
+// Rule: when the evidence is not enough, never call it "safe". Leave the verdict as "unknown".
+// All user-facing text comes from messages.js, in the language given by options.lang.
+//
+// Guards that keep a vulnerable PHP from being reported as patched:
+// - A distribution package vouches for the running PHP only when its upstream version matches.
+// - A release inferred from the version string is accepted only when it ships that upstream version.
+// - A backport under the same upstream version counts only for the distribution's own revisions (ubuntu,
+// ~debNNuN): a PPA's or another vendor's build that merely sorts above the fixed version does not.
+// - Changelog evidence counts only for the package of the PHP being judged, at the version that was read.
+// - A PHP whose configuration lives outside /etc is not a distribution package.
+// - A container probe is never judged by host packages, and outputs of different systems or moments are not
+// mixed (parse.js keeps the server script's system data out then).
+// - -dev, alpha, beta, RC and pre builds are pre-releases.
+// - Every PHP in the input counts: several outputs, and the other installed builds next to the probe's PHP, give
+// the worst verdict. A running PHP process that no checked build accounts for rules out Patched.
+// - Patched on the installed package alone needs the running processes to have been checked: as root, readable,
+// and not cut off.
+// - A Distribution list choice that contradicts the OS release or the version string is not used.
+
+import { parsePhpVersion, comparePhp, compareDebian, compareRpm, splitDebian, debUpstream, debComparable, isSuryBuild, suryBuildDate } from './version.js';
+import { translator, formatDate } from './messages.js';
+
+// table[key] for a key that comes from the input (a SAPI name, an OS id): only the table's own entries, never a
+// member of Object.prototype such as "constructor" or "toString".
+function own(table, key) {
+ return table && typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
+}
+
+const RHEL_IDS = ['rhel', 'centos', 'rocky', 'almalinux', 'ol', 'cloudlinux', 'eurolinux', 'amzn'];
+const VERDICTS = ['patched', 'vulnerable', 'pending', 'restart', 'unknown'];
+// When several PHP builds are judged, the worst one is reported.
+const SEVERITY = { patched: 0, unknown: 1, restart: 2, pending: 3, vulnerable: 4 };
+
+// Red Hat fix_state values that have a message; any other value is shown as it is.
+const RHEL_STATES = {
+ Affected: 'rhelState.affected',
+ 'Under investigation': 'rhelState.underInvestigation',
+ 'Fix deferred': 'rhelState.fixDeferred',
+ 'Will not fix': 'rhelState.willNotFix',
+ 'Not affected': 'rhelState.notAffected',
+ 'Out of support scope': 'rhelState.outOfSupportScope',
+};
+
+// The branch of Red Hat's own php package in releases whose advisories do not name it. A Red Hat state for the
+// plain "php" package describes that branch only.
+const RHEL_BASE_BRANCH = { 6: '5.3', 7: '5.4' };
+
+// Which source of compromise indicators each server script count comes from.
+const FINDING_SOURCE = { oom: 'logs', crash: 'logs', bigsession: 'sessions', webuser_proc: 'processes', recent_php: 'webroot' };
+
+export function verdictTitles(lang = 'en') {
+ const t = translator(lang);
+ return Object.fromEntries(VERDICTS.map((v) => [v, t(`verdict.title.${v}`)]));
+}
+
+export function verdictLabels(lang = 'en') {
+ const t = translator(lang);
+ return Object.fromEntries(VERDICTS.map((v) => [v, t(`verdict.label.${v}`)]));
+}
+
+export const VERDICT_TITLES = verdictTitles('en');
+
+export const VERDICT_LABELS = verdictLabels('en');
+
+export function judge(obs, upstream, distro, options = {}) {
+ try {
+ const r = Array.isArray(obs.parts) && obs.parts.length > 1 ? judgeParts(obs, upstream, distro, options) : judgeOne(obs, upstream, distro, options);
+ // Part of the input was not read (it was too long, or held too many outputs): what was left out may be worse.
+ if (obs.inputCut && r.verdict === 'patched') {
+ const t = translator(options.lang || 'en');
+ r.verdict = 'unknown';
+ r.title = t('verdict.title.unknown');
+ r.reasons.push(t('reason.inputCut'));
+ r.actions = [{ text: t('step.collect.shorter') }];
+ }
+ return r;
+ } catch {
+ // Never leave the page without a result. Nothing is concluded from an input the engine could not handle.
+ const t = translator(options.lang || 'en');
+ return {
+ verdict: 'unknown',
+ title: t('verdict.title.unknown'),
+ reasons: [t('reason.notRead')],
+ actions: [],
+ facts: [],
+ mitigations: [],
+ notes: [],
+ advisories: [],
+ findings: [],
+ family: null,
+ };
+ }
+}
+
+// Several separate outputs in one paste: each is judged on its own, and the worst result is reported.
+function judgeParts(obs, upstream, distro, options) {
+ const lang = options.lang || 'en';
+ const t = translator(lang);
+ const labels = verdictLabels(lang);
+ const results = obs.parts.map((part) => ({ part, r: judgeOne(part, upstream, distro, options) }));
+ const worst = results.reduce((w, x) => (SEVERITY[x.r.verdict] > SEVERITY[w.r.verdict] ? x : w));
+ const notes = [t('note.severalOutputs', { n: results.length })];
+ // Notes about the input as a whole (part of it was cut, too many outputs) come with the combined observation.
+ for (const n of obs.notes || []) if (!notes.includes(n)) notes.push(n);
+ const advisories = [];
+ const findings = [];
+ for (const { r } of results) {
+ for (const n of r.notes) if (!notes.includes(n)) notes.push(n);
+ for (const x of r.advisories) if (!advisories.some((y) => y.label === x.label && y.value === x.value)) advisories.push(x);
+ for (const f of r.findings) if (!findings.some((g) => g.level === f.level && g.text === f.text)) findings.push(f);
+ }
+ // "Nothing suspicious" from one output must not stand next to a finding, or a source another output could not read.
+ const clear = findings.every((f) => f.level === 'ok');
+ const r = {
+ verdict: worst.r.verdict,
+ title: worst.r.title,
+ reasons: results.map(({ part, r: x }) => t('reason.forPart', { part: partLabel(t, part), verdict: labels[x.verdict], reasons: x.reasons.join(' ') })),
+ actions: worst.r.actions,
+ facts: worst.r.facts,
+ mitigations: worst.r.mitigations,
+ notes,
+ advisories,
+ findings: clear ? findings : findings.filter((f) => f.level !== 'ok'),
+ family: worst.r.family,
+ };
+ if (worst.r.exposure) r.exposure = worst.r.exposure;
+ if (worst.r.eol) r.eol = true;
+ return r;
+}
+
+function partLabel(t, part) {
+ const version = part.phpVersion;
+ if (part.sources.includes('selfcheck')) return version ? t('part.probe', { version }) : t('part.probeNoVersion');
+ if (part.sources.includes('header')) return t('part.header', { version });
+ if (part.sources.includes('cli')) return t('part.cli', { version, sapi: part.sapi });
+ if (part.sources.includes('servercheck')) {
+ const os = part.os || {};
+ return t('part.server', { system: os.pretty || [os.id, os.versionId].filter(Boolean).join(' ') || t('distro.unknown') });
+ }
+ return version ? t('part.version', { version }) : t('part.probeNoVersion');
+}
+
+function judgeOne(obs, upstream, distro, options) {
+ const lang = options.lang || 'en';
+ const t = translator(lang);
+ const r = {
+ verdict: 'unknown',
+ reasons: [],
+ actions: [],
+ facts: [],
+ mitigations: [],
+ notes: [...(obs.notes || [])],
+ advisories: [],
+ };
+ // With only the server script output, infer the SAPI from running processes (the script reports Apache only when it has libphp loaded).
+ const inferredSapi = obs.sapi ? null : inferSapi(obs.processes);
+ const ctx = {
+ t,
+ lang,
+ upstream,
+ distro: distro || {},
+ cves: upstream.cves.map((c) => c.id),
+ sapi: obs.sapi || inferredSapi,
+ };
+
+ // The running PHP comes from the probe, a header, -v output or a version string. With server script output,
+ // every installed PHP build is judged as well (one per Debian source package, rpm collection or apk prefix),
+ // and the worst verdict is reported.
+ const running = parsePhpVersion(obs.phpVersion);
+ const installed = running && !obs.serverData ? [] : installedBuilds(obs);
+ if (!running && !installed.length) {
+ r.reasons.push(t('reason.noVersion'));
+ r.family = null;
+ return finish(ctx, r, obs, {}, null, { noVersion: true });
+ }
+ const results = [];
+ if (running) {
+ const d = detectDistro(obs, running, ctx.distro, options.distro);
+ results.push({ ver: running, build: null, d, a: assess(ctx, obs, running, null, d) });
+ }
+ // The installed build the running PHP was judged with is not judged a second time.
+ const used = running ? results[0].a.usedKeys : [];
+ for (const b of installed) {
+ if (running && (used.includes(b.key) || (!used.length && b.ver.upstream === running.upstream))) continue;
+ const d = detectDistro(obs, b.ver, ctx.distro, options.distro, b);
+ results.push({ ver: b.ver, build: b, d, a: assess(ctx, obs, b.ver, b, d) });
+ }
+
+ // The PHP processes each of them may run. A process none of them can run is a PHP that was not checked.
+ const procs = obs.processes || [];
+ for (const x of results) {
+ x.mine = procs.filter((p) => mayRun(p, x, installed));
+ if (x.a.path !== 'prerelease') checkRunning(ctx, x.a, obs, x.ver, x.build, x.mine);
+ }
+ const orphans = procs.filter((p) => !results.some((x) => x.mine.includes(p)));
+ const main = results.reduce((worst, x) => (SEVERITY[x.a.verdict] > SEVERITY[worst.a.verdict] ? x : worst));
+
+ if (running) r.facts.push({ label: t('fact.phpVersion'), value: running.raw });
+ const others = results.filter((x) => x.build);
+ if (others.length) {
+ r.facts.push({ label: t(running ? 'fact.otherBuilds' : 'fact.installedVersion'), value: others.map((x) => pkgLabel(x.build.main)).join(', ') });
+ }
+ if (ctx.sapi) {
+ const label = sapiLabel(ctx.sapi, lang);
+ r.facts.push({ label: t('fact.sapi'), value: inferredSapi ? t('fact.sapiInferred', { sapi: label }) : label });
+ }
+ r.facts.push({ label: t('fact.distribution'), value: distroLabel(t, main.d, obs) });
+ if (obs.serverSoftware) r.facts.push({ label: t('fact.webServer'), value: obs.serverSoftware });
+
+ r.verdict = main.a.verdict;
+ r.family = dataFamily(main.d, main.a.path);
+ if (main.a.eol) r.eol = true;
+ const labels = verdictLabels(lang);
+ for (const x of results) {
+ const reasons = x.a.reasons.join(' ');
+ if (results.length === 1) r.reasons.push(...x.a.reasons);
+ else if (x.build) r.reasons.push(t('reason.forBuild', { build: pkgLabel(x.build.main), verdict: labels[x.a.verdict], reasons }));
+ else r.reasons.push(t('reason.forRunning', { version: x.ver.raw, verdict: labels[x.a.verdict], reasons }));
+ for (const n of x.a.notes) if (!r.notes.includes(n)) r.notes.push(n);
+ for (const y of x.a.advisories) if (!r.advisories.some((z) => z.label === y.label && z.value === y.value)) r.advisories.push(y);
+ r.facts.push(...x.a.facts);
+ }
+
+ // What the remediation steps work from: the worst result's assessment, the processes of it that still use files
+ // from before an update, and all of its processes (their names give the services to restart and configure).
+ const info = {
+ path: main.a.path,
+ build: main.build,
+ a: main.a,
+ stale: [...new Set(main.mine.filter((p) => p.status === 'stale').map((p) => p.comm))],
+ running: [...new Set(main.mine.map((p) => p.comm))],
+ };
+ // Stale processes of another PHP than the probe's: what started them is not known, so their names are given.
+ if (main.a.staleOther && info.stale.length) info.genericRestart = info.stale.join(', ');
+ const stale = orphans.filter((p) => p.status === 'stale');
+ const unaccounted = orphans.filter((p) => p.status !== 'stale');
+ if (stale.length) {
+ r.reasons.push(t('reason.staleUnaccounted', { names: commNames(stale) }));
+ if (r.verdict === 'patched') {
+ r.verdict = 'restart';
+ info.genericRestart = commNames(stale);
+ }
+ }
+ // A Debian or Ubuntu PHP-FPM or CGI process of a branch whose other packages are installed but whose own package is
+ // not listed: an interrupted update may have left that package half-installed (dpkg lists only installed ones).
+ const halfInstalled = unaccounted.map((p) => {
+ const m = /^php-(fpm|cgi)(\d+\.\d+)$/.exec(p.comm);
+ const b = m && installed.find((x) => x.packages[0]?.manager === 'dpkg' && x.ver.branch === m[2]);
+ return b ? { comm: p.comm, pkg: `php${m[2]}-${m[1]}`, src: debSource(b.main) } : null;
+ }).filter(Boolean);
+ if (unaccounted.length) {
+ r.reasons.push(t('reason.unaccountedProcesses', { names: commNames(unaccounted) }));
+ if (r.verdict === 'patched' && halfInstalled.length) {
+ const h = halfInstalled[0];
+ r.verdict = 'unknown';
+ r.reasons.push(t('reason.sapiPackageMissingProc', { comm: h.comm, pkg: h.pkg, src: h.src }));
+ info.need = 'sapiPackage';
+ info.sapiPackage = h.pkg;
+ } else if (r.verdict === 'patched') {
+ r.verdict = 'unknown';
+ info.path = 'undetermined';
+ info.need = 'elsewhere';
+ info.genericRestart = commNames(unaccounted);
+ }
+ }
+ // PHP processes the server script could not check (unreadable, or a status line cut off) may be any PHP, also one
+ // that was not restarted after an update. A PHP_VERSION that shows the fix vouches only for the probe's own PHP.
+ if (obs.serverData && obs.procUnreadable > 0 && r.verdict === 'patched') {
+ r.verdict = 'unknown';
+ r.reasons.push(t(obs.root === true ? 'reason.processesUnreadableRoot' : 'reason.processesUnreadable', { n: obs.procUnreadable }));
+ info.path = 'undetermined';
+ info.need = 'unreadable';
+ }
+ // The probe and the server script describe different moments (see parse.js): which one is current is unknown, so
+ // neither the probe's PHP_VERSION nor the installed packages settle it.
+ if (obs.serverConflict && r.verdict === 'patched') {
+ r.verdict = 'unknown';
+ if (!r.reasons.includes(t('reason.serverConflict'))) r.reasons.push(t('reason.serverConflict'));
+ info.path = 'undetermined';
+ info.need = 'together';
+ }
+ if ((obs.truncated || obs.headCut) && r.verdict === 'patched') {
+ r.verdict = 'unknown';
+ r.reasons.push(t(obs.truncated ? 'reason.serverTruncated' : 'reason.serverHeadCut'));
+ info.path = 'undetermined';
+ info.need = 'whole';
+ }
+ // A probe that could not tell whether it runs in a container, next to server script output nothing ties to it: the
+ // next step is the one that ties them, not the same server script output again.
+ if (obs.unlinkedHidden && r.verdict === 'unknown') {
+ info.path = 'undetermined';
+ info.need = 'hiddenLink';
+ }
+ return finish(ctx, r, obs, main.d, main.ver, info);
+}
+
+// Judges one PHP build. build is null for the running PHP, or an installed build (server script output).
+function assess(ctx, obs, ver, build, d) {
+ const { t, upstream } = ctx;
+ const a = {
+ verdict: 'unknown',
+ reasons: [],
+ notes: [],
+ advisories: [],
+ facts: [],
+ eol: false,
+ // How the verdict was reached; it picks the update advice.
+ path: 'upstream',
+ // The running version string itself shows the fix (upstream release, or a fixed distribution revision).
+ proven: false,
+ // A Patched verdict rests on the installed package, not on what is loaded in memory.
+ restsOnPackage: false,
+ // Keys of the installed builds (see installedBuilds) this verdict was reached with.
+ usedKeys: [],
+ // When the verdict stays open: what to collect next (see collectSteps).
+ need: null,
+ };
+ if (d.overridden) a.notes.push(overrideNote(t, d, obs, ver));
+ const branches = Object.keys(upstream.fixed).sort(compareBranch);
+ const fixedUp = upstream.fixed[ver.branch];
+
+ if (ver.pre) {
+ a.path = 'prerelease';
+ a.reasons.push(t('reason.preRelease', { version: ver.raw }));
+ return a;
+ }
+ if (!fixedUp && compareBranch(ver.branch, branches[branches.length - 1]) > 0) {
+ a.path = 'prerelease';
+ a.reasons.push(t('reason.newerBranch', { branch: ver.branch, branches: branches.join(', ') }));
+ return a;
+ }
+ if (fixedUp && comparePhp(ver, fixedUp) >= 0) {
+ a.verdict = 'patched';
+ a.reasons.push(t('reason.upstreamPatched', { version: ver.upstream, fixed: fixedUp }));
+ a.proven = !build;
+ a.restsOnPackage = Boolean(build);
+ if (!build) checkDowngrade(ctx, a, obs, ver, d);
+ } else {
+ judgeBuild(ctx, a, obs, ver, build, d);
+ }
+ return a;
+}
+
+// The running PHP has the fix, but the package that provides it on disk is older: it was downgraded (or replaced by
+// another repository's build) after the service started, and the next restart loads that package's build. On
+// Debian and Ubuntu the package of a SAPI (php8.2-fpm, libapache2-mod-php8.2) carries the whole PHP engine, and
+// the configuration directory of the branch (/etc/php/8.2/fpm) ties the running PHP to it.
+function checkDowngrade(ctx, a, obs, ver, d) {
+ const { t } = ctx;
+ if (d.family !== 'debian' && d.family !== 'ubuntu') return;
+ if (obs.configPath && !debLayoutPath(obs.configPath, ver)) return;
+ const pkg = sapiPackage(obs.packages, ver, ctx.sapi);
+ if (!pkg || cmpUp(debUpstream(pkg.version), ver.upstream) >= 0) return;
+ a.usedKeys.push(buildKey(pkg));
+ a.path = isSuryBuild(pkg.version) ? 'sury' : 'deb';
+ a.facts.push({ label: t('fact.packageChecked'), value: pkgLabel(pkg) });
+ if (debPackageFixed(ctx, d, pkg, ver)) {
+ a.notes.push(t('note.olderPackageFixed', { pkg: pkgLabel(pkg), version: ver.raw }));
+ return;
+ }
+ a.verdict = 'vulnerable';
+ a.downgraded = true;
+ a.reasons = [t('reason.installedOlderUnfixed', { installed: pkgLabel(pkg), running: ver.raw })];
+}
+
+// Whether a Debian or Ubuntu package version has both fixes: by the release's fixed version when the data has one
+// (and the revision is the distribution's own), otherwise by its upstream version.
+function debPackageFixed(ctx, d, pkg, ver) {
+ const { cves, upstream } = ctx;
+ const fixedUp = upstream.fixed[ver.branch];
+ if (fixedUp && cmpUp(debUpstream(pkg.version), fixedUp) >= 0) return true;
+ if (isSuryBuild(pkg.version)) return false;
+ const entry = own(own(ctx.distro[d.family], d.release)?.packages, debSource(pkg));
+ const fixed = cves.map((c) => entry?.fixed?.[c]);
+ if (!fixed.every(Boolean)) return false;
+ // Below the upstream fix, only the release's own backport has the fixes: the same upstream version as its fixed
+ // package, at or above its fixed revision, and a revision the distribution builds itself.
+ if (cmpUp(debUpstream(pkg.version), debUpstream(fixed[0])) !== 0) return false;
+ return fixed.every((f) => debAtLeast(pkg.version, f)) && vendorBuild(d.family, pkg.version);
+}
+
+// The configuration directory of a Debian or Ubuntu PHP package of this branch: /etc/php/8.2/..., /etc/php5/...
+function debLayoutPath(path, ver) {
+ const base = ver.major >= 7 ? `/etc/php/${ver.branch}` : '/etc/php5';
+ return path === base || path.startsWith(`${base}/`);
+}
+
+// The Debian or Ubuntu package that provides this SAPI of this branch: php8.2-fpm, libapache2-mod-php8.2, php8.2-cgi.
+function sapiPackageName(ver, sapi) {
+ const src = srcFor(ver);
+ return own({ apache2handler: `libapache2-mod-${src}`, 'fpm-fcgi': `${src}-fpm`, 'cgi-fcgi': `${src}-cgi`, cgi: `${src}-cgi`, cli: `${src}-cli` }, sapi) || null;
+}
+
+function sapiPackage(packages, ver, sapi) {
+ const name = sapiPackageName(ver, sapi);
+ return name ? packages.find((p) => p.manager === 'dpkg' && p.name === name) || null : null;
+}
+
+function judgeBuild(ctx, a, obs, ver, build, d) {
+ const { t } = ctx;
+ const debLike = d.family === 'debian' || d.family === 'ubuntu';
+ const pkg = debLike ? pickDebPackage(obs, ver, ctx.sapi, build) : null;
+ if (isSuryBuild(ver.raw) || (pkg && isSuryBuild(pkg.version))) {
+ judgeSury(ctx, a, obs, ver, build, pkg && isSuryBuild(pkg.version) ? pkg : null);
+ return;
+ }
+ // Debian, Ubuntu and RHEL packages keep their configuration under /etc. A PHP configured elsewhere
+ // (/usr/local/etc/php in the official Docker images, a control panel's or a self-built prefix) is not
+ // a distribution package, so installed packages say nothing about it.
+ if (!build && obs.configPath && !/^\/etc(\/|$)/.test(obs.configPath) && (debLike || d.family === 'rhel' || !d.family)) {
+ a.notes.push(t('note.configOutsideEtc', { path: obs.configPath }));
+ judgeUpstream(ctx, a, ver);
+ return;
+ }
+ if (debLike) {
+ judgeDeb(ctx, a, obs, ver, build, d, pkg);
+ return;
+ }
+ if (d.family === 'rhel') {
+ judgeRhel(ctx, a, obs, ver, build, d);
+ return;
+ }
+ if (d.family === 'alpine') {
+ a.notes.push(t('note.alpine'));
+ judgeUpstream(ctx, a, ver);
+ a.path = 'alpine';
+ return;
+ }
+ if (d.family === 'other') {
+ if (BACKPORTING_VENDORS.test(d.vendor || '')) {
+ // SUSE keeps the upstream version of its PHP packages and backports fixes into them: a version below the
+ // upstream fix may have them, and only the vendor's advisories or the package changelog can tell.
+ a.path = 'other';
+ a.need = 'vendor';
+ a.reasons.push(t('reason.vendorBackports', { vendor: obs.os?.pretty || d.vendor, version: ver.raw }));
+ return;
+ }
+ a.notes.push(t('note.otherDistro', { vendor: d.vendor || 'other' }));
+ judgeUpstream(ctx, a, ver);
+ a.path = 'other';
+ return;
+ }
+ if (!d.family) {
+ // The probe could not read the OS release: this may be a RHEL-family or other distribution package whose
+ // version number stays the same when a fix is backported, so an upstream-looking version is not Vulnerable.
+ // The distribution is unknown. Do not judge on the upstream version when this could be a
+ // distribution package that has the fix backported under the same version number.
+ const releases = sameUpstreamReleases(ctx, ver);
+ if (obs.osBlocked || releases.length) {
+ a.path = 'undetermined';
+ a.need = 'serverOrDistro';
+ if (obs.osBlocked) a.reasons.push(t(obs.openBasedir ? 'reason.osBlocked' : 'reason.osUnread', { version: ver.raw }));
+ if (releases.length) a.reasons.push(t('reason.couldBeDistro', { version: ver.raw, releases: releases.join(', ') }));
+ return;
+ }
+ }
+ judgeUpstream(ctx, a, ver);
+}
+
+// Distributions outside the data that keep the upstream version of their PHP packages and backport fixes into them.
+const BACKPORTING_VENDORS = /^(sles|sled|sles_sap|opensuse-leap)$/;
+
+// deb.sury.org follows upstream releases on supported branches. For end-of-life branches it ships backported fixes
+// under the same upstream version (the 8.1.34-9 changelog has "Backported from 8.2.35"), so there the changelog of
+// exactly the installed build decides. A Debian build dated before the upstream fix release cannot have them.
+function judgeSury(ctx, a, obs, ver, build, pkg) {
+ const { t, cves, upstream } = ctx;
+ const version = pkg ? pkg.version : ver.raw;
+ if (pkg) a.usedKeys.push(buildKey(pkg));
+ if (upstream.fixed[ver.branch] || comparePhp(ver, '5.4.0') < 0) {
+ a.notes.push(t('note.sury', { version }));
+ judgeUpstream(ctx, a, ver);
+ a.path = 'sury';
+ return;
+ }
+ a.path = 'sury';
+ a.notes.push(t('note.suryEol', { version, branch: ver.branch }));
+ const evidence = obs.packages.filter((p) => p.manager === 'dpkg' && debSource(p) === srcFor(ver) && sameDeb(p.version, version));
+ const logs = evidence.filter((p) => p.changelog);
+ const found = changelogFound(evidence, cves);
+ if (found && found.length === cves.length) {
+ a.verdict = 'patched';
+ a.reasons.push(t('reason.suryChangelogFixed', { pkg: pkgLabel(logs[0]), cves: cves.join(', ') }));
+ // PHP_VERSION with the deb.sury.org revision is exactly that build; a plain one rests on the installed package.
+ a.proven = !build && Boolean(ver.revision) && sameDeb(ver.raw, version);
+ a.restsOnPackage = !a.proven;
+ return;
+ }
+ const missing = cves.filter((c) => logs.some((p) => p.changelog[c] === 0));
+ const date = suryBuildDate(version);
+ if (missing.length || (date && upstream.released && date < upstream.released)) {
+ judgeUpstream(ctx, a, ver);
+ a.path = 'sury';
+ a.reasons.push(missing.length ? t('reason.suryChangelogMissing', { pkg: pkgLabel(logs[0]), cves: missing.join(', ') })
+ : t('reason.suryBuiltBefore', { version, date: formatDate(date, ctx.lang), released: formatDate(upstream.released, ctx.lang) }));
+ return;
+ }
+ a.verdict = 'unknown';
+ a.need = logs.length ? 'whole' : 'server';
+ a.reasons.push(t('reason.suryEolUnread', { version, branch: ver.branch }));
+}
+
+function judgeUpstream(ctx, a, ver) {
+ const { t, upstream } = ctx;
+ a.path = 'upstream';
+ const fixedUp = upstream.fixed[ver.branch];
+ if (fixedUp) {
+ a.verdict = 'vulnerable';
+ a.reasons.push(t('reason.upstreamVulnerable', { version: ver.upstream, fixed: fixedUp }));
+ return;
+ }
+ if (comparePhp(ver, '5.4.0') < 0) {
+ a.verdict = 'unknown';
+ a.reasons.push(t('reason.predates54', { version: ver.upstream }));
+ return;
+ }
+ a.verdict = 'vulnerable';
+ a.eol = true;
+ a.reasons.push(t('reason.eol', { branch: ver.branch }));
+ const branch = upstream.backport_branches?.[ver.branch];
+ if (branch) a.notes.push(t('note.backportBranch', { branch }));
+}
+
+function judgeDeb(ctx, a, obs, ver, build, d, pkg) {
+ const { t, distro, cves, upstream } = ctx;
+ const family = d.family === 'debian' ? 'Debian' : 'Ubuntu';
+ a.path = 'deb';
+ // PHP_VERSION with a distribution revision identifies the running build exactly.
+ const running = !build && ver.revision ? ver.raw : null;
+ // A package older than the running PHP is not what runs (a self-built, panel or container PHP next to it).
+ if (pkg && !build && cmpUp(debUpstream(pkg.version), ver.upstream) < 0) {
+ a.notes.push(t('note.packageNotRunning', { pkg: pkgLabel(pkg), version: ver.raw }));
+ pkg = null;
+ }
+ if (!pkg && !running) {
+ judgeDebVersionOnly(ctx, a, ver, d, family);
+ return;
+ }
+ if (pkg) a.usedKeys.push(buildKey(pkg));
+
+ const src = pkg ? debSource(pkg) : srcFor(ver);
+ const installed = pkg ? pkg.version : null;
+ const judged = running || installed;
+ const fixedUp = upstream.fixed[ver.branch];
+
+ // When the distribution data cannot decide, an installed package that already has the upstream fix shows a
+ // missed restart; otherwise fall back to the changelog of exactly the build being judged.
+ const evidence = obs.packages.filter((p) => p.manager === 'dpkg' && debSource(p) === src && sameDeb(p.version, judged));
+ const noData = (reason) => {
+ if (!build && installed && fixedUp && cmpUp(debUpstream(installed), fixedUp) >= 0) {
+ a.verdict = 'restart';
+ a.reasons.push(t('reason.restartInstalledFixed', { installed, running: running || ver.upstream }));
+ return;
+ }
+ const found = changelogFound(evidence, cves);
+ if (found && found.length === cves.length) {
+ a.verdict = 'patched';
+ a.reasons.push(t('reason.noDataChangelogFixed', { reason }));
+ a.proven = Boolean(running);
+ a.restsOnPackage = !running;
+ } else {
+ a.verdict = 'unknown';
+ a.reasons.push(reason);
+ }
+ };
+ if (!d.release) {
+ a.need = 'serverOrDistro';
+ noData(t('reason.debReleaseUnknown', { family }));
+ return;
+ }
+ const rel = own(distro[d.family], d.release);
+ if (!rel) {
+ a.need = 'advisories';
+ noData(t('reason.debNoRelease', { family, release: d.release }));
+ return;
+ }
+ const entry = rel.packages?.[src];
+ if (!entry) {
+ a.need = 'advisories';
+ noData(t('reason.debNoPackage', { src, family, release: d.release }));
+ return;
+ }
+
+ // A build newer upstream than the release's fixed package (and still below the upstream fix) is not
+ // that release's package: another repository, a PPA, or a package from another release.
+ const distroUp = entryUpstream(entry, cves);
+ const judgedUp = debUpstream(judged);
+ if (distroUp && cmpUp(judgedUp, distroUp) > 0 && !(fixedUp && cmpUp(judgedUp, fixedUp) >= 0)) {
+ a.notes.push(t('note.notDistroBuild', { version: judged, family, release: d.release, src, shipped: distroUp }));
+ judgeUpstream(ctx, a, ver);
+ return;
+ }
+
+ if (pkg) a.facts.push({ label: t('fact.packageChecked'), value: pkgLabel(pkg) });
+ const states = cves.map((cve) => {
+ const fixed = entry.fixed?.[cve];
+ if (fixed === undefined) return { cve, state: 'nodata' };
+ if (fixed === null) return { cve, state: 'pending' };
+ return { cve, fixed, state: debAtLeast(judged, fixed) ? 'fixed' : 'vulnerable' };
+ });
+ pushDebAdvisories(t, a, family, d.release, src, entry, cves);
+ if (entry.pro) a.notes.push(t(UBUNTU_LEGACY.includes(d.release) ? 'note.ubuntuProLegacy' : 'note.ubuntuPro'));
+
+ // The same upstream version as the release's fixed package, below the upstream fix: the fix is a backport,
+ // which only the distribution's own builds carry. A revision of another vendor that merely sorts above the
+ // fixed one ("8.3.6-1+ppa1", "8.1.2-2", a Debian "+deb9u12" judged as Ubuntu) is not that backport.
+ const backport = distroUp && cmpUp(judgedUp, distroUp) === 0 && !(fixedUp && cmpUp(judgedUp, fixedUp) >= 0);
+ if (backport && states.every((s) => s.state === 'fixed') && !vendorBuild(d.family, judged)) {
+ a.verdict = 'unknown';
+ a.need = 'supplier';
+ a.reasons.push(t('reason.foreignRevision', { version: judged, family, release: d.release, src }));
+ return;
+ }
+
+ if (states.every((s) => s.state === 'fixed')) {
+ a.verdict = 'patched';
+ a.reasons.push(t('reason.debBackported', { family, src, version: judged, upstream: ver.upstream }));
+ a.proven = Boolean(running);
+ a.restsOnPackage = !running;
+ } else if (states.some((s) => s.state === 'vulnerable')) {
+ a.verdict = 'vulnerable';
+ a.reasons.push(t('reason.debOlder', { src, version: judged, family, fixed: maxFixed(states) }));
+ } else if (states.some((s) => s.state === 'pending')) {
+ a.verdict = 'pending';
+ a.reasons.push(t('reason.debPending', { src, family, release: d.release }));
+ } else {
+ a.verdict = 'unknown';
+ a.need = 'advisories';
+ a.reasons.push(t('reason.debNoCveData'));
+ }
+
+ // Running and installed versions differ.
+ const fixedFor = (v) => states.every((s) => s.fixed && debAtLeast(v, s.fixed));
+ const order = running && installed ? compareDebian(debComparable(installed), debComparable(running)) : 0;
+ if (order > 0) {
+ // A newer package is installed: the service was not restarted after an update.
+ if (fixedFor(running)) {
+ // The running build already has the fix; the newer package only waits for a restart.
+ a.notes.push(t('note.newerPackageWaiting', { installed, running }));
+ } else if (fixedFor(installed)) {
+ a.verdict = 'restart';
+ a.reasons = [t('reason.restartInstalledFixed', { installed, running })];
+ } else {
+ a.reasons.push(t('reason.installedNewer', { installed, running }));
+ }
+ } else if (order < 0 && a.verdict === 'patched' && !fixedFor(installed)) {
+ // An older package is installed (a downgrade): the next restart loads the vulnerable build.
+ a.verdict = 'vulnerable';
+ a.downgraded = true;
+ a.reasons.push(t('reason.installedOlderUnfixed', { installed: pkgLabel(pkg), running }));
+ } else if (!running && installed && !build && cmpUp(debUpstream(installed), ver.upstream) > 0) {
+ // PHP_VERSION without a revision: only the upstream parts can be compared.
+ a.reasons.push(t('reason.installedNewer', { installed, running: ver.upstream }));
+ if (a.verdict === 'patched') {
+ a.verdict = 'restart';
+ a.restsOnPackage = false;
+ }
+ }
+ if (!build && !running && pkg && a.verdict === 'patched') checkPlainVersion(ctx, a, obs, ver, d, family, pkg);
+}
+
+// The PHP probe's PHP_VERSION has no revision, and the verdict rests on the installed package of its branch.
+// - Debian and Ubuntu build the PHP engine into each SAPI's own binary, so the package of the SAPI that runs
+// (php8.3-fpm, libapache2-mod-php8.3) must be installed: the other packages of the branch do not show which build
+// that binary is. dpkg lists only fully installed packages, so one that an interrupted update left half-configured
+// is missing from the list.
+// - On Ubuntu 22.04 and older, the security updates report their revision in PHP_VERSION (8.1.2-1ubuntu2.28); only
+// the release's original build reports the plain version. A plain version next to an updated package is therefore
+// the original build, loaded before the update.
+function checkPlainVersion(ctx, a, obs, ver, d, family, pkg) {
+ const { t } = ctx;
+ const sapiName = sapiPackageName(ver, ctx.sapi);
+ const complete = obs.serverData || (obs.probeDpkg !== null && obs.probeDpkg.length < PROBE_DPKG_MAX);
+ if (sapiName && ctx.sapi !== 'cli' && complete && !obs.packages.some((p) => p.manager === 'dpkg' && p.name === sapiName)) {
+ a.verdict = 'unknown';
+ a.restsOnPackage = false;
+ a.need = 'sapiPackage';
+ a.sapiPackage = sapiName;
+ a.reasons = [t('reason.sapiPackageMissing', { sapi: sapiLabel(ctx.sapi, ctx.lang), pkg: sapiName, src: srcFor(ver) })];
+ return;
+ }
+ if (d.family === 'ubuntu' && UBUNTU_REVISION_IN_VERSION.includes(d.release) && /ubuntu\d+\./.test(splitDebian(debComparable(pkg.version)).revision)) {
+ a.verdict = 'restart';
+ a.restsOnPackage = false;
+ a.reasons = [t('reason.revisionMissing', { version: ver.raw, family, release: d.release, installed: pkgLabel(pkg), expected: debComparable(pkg.version) })];
+ }
+}
+
+// A revision the distribution itself builds: Ubuntu's carry "ubuntu", Debian's stable updates "debNNuN".
+function vendorBuild(family, version) {
+ const { revision } = splitDebian(debComparable(version));
+ return family === 'ubuntu' ? /ubuntu/.test(revision) : /deb\d+u\d+/.test(revision);
+}
+
+// Debian or Ubuntu, with no distribution revision in PHP_VERSION and no package of this branch to compare.
+function judgeDebVersionOnly(ctx, a, ver, d, family) {
+ const { t, distro, cves } = ctx;
+ const src = srcFor(ver);
+ if (!d.release) {
+ const releases = Object.keys(distro[d.family] || {}).filter((rel) => entryUpstream(distro[d.family][rel].packages?.[src], cves) === ver.upstream);
+ if (releases.length) {
+ a.need = 'serverOrDistro';
+ a.reasons.push(t('reason.debReleaseUnknown', { family }));
+ return;
+ }
+ a.notes.push(t('note.upstreamBuild'));
+ judgeUpstream(ctx, a, ver);
+ return;
+ }
+ const rel = own(distro[d.family], d.release);
+ if (!rel) {
+ a.need = 'advisories';
+ a.reasons.push(t('reason.debNoRelease', { family, release: d.release }));
+ return;
+ }
+ const entry = rel.packages?.[src];
+ if (!entry) {
+ // The release does not ship this source package, so this PHP comes from somewhere else.
+ a.notes.push(t('note.upstreamBuild'));
+ judgeUpstream(ctx, a, ver);
+ return;
+ }
+ const distroUp = entryUpstream(entry, cves);
+ if (distroUp && cmpUp(ver.upstream, distroUp) > 0) {
+ a.notes.push(t('note.notDistroBuild', { version: ver.raw, family, release: d.release, src, shipped: distroUp }));
+ judgeUpstream(ctx, a, ver);
+ return;
+ }
+ pushDebAdvisories(t, a, family, d.release, src, entry, cves);
+ if (entry.pro) a.notes.push(t(UBUNTU_LEGACY.includes(d.release) ? 'note.ubuntuProLegacy' : 'note.ubuntuPro'));
+ if (!distroUp) {
+ const pending = cves.some((c) => entry.fixed?.[c] === null);
+ a.verdict = pending ? 'pending' : 'unknown';
+ if (!pending) a.need = 'advisories';
+ a.reasons.push(pending ? t('reason.debPending', { src, family, release: d.release }) : t('reason.debNoCveData'));
+ return;
+ }
+ const fixed = cves.map((c) => entry.fixed?.[c]).filter(Boolean).sort(compareDebian).pop();
+ if (cmpUp(ver.upstream, distroUp) < 0) {
+ a.verdict = 'vulnerable';
+ a.reasons.push(t('reason.debOlderUpstream', { version: ver.raw, family, release: d.release, src, fixed }));
+ return;
+ }
+ // Same upstream version before and after the fix: only the package version can tell.
+ a.need = 'server';
+ a.reasons.push(t('reason.debNeedPackage', { version: ver.raw, family, release: d.release, src }));
+}
+
+function pushDebAdvisories(t, a, family, release, src, entry, cves) {
+ for (const cve of cves) {
+ const fixed = entry.fixed?.[cve];
+ a.advisories.push({
+ label: `${family} ${release} ${src} · ${cve}`,
+ value: fixed ? t('advisory.fixedIn', { version: fixed }) : fixed === null ? t('advisory.pending') : t('advisory.noData'),
+ });
+ }
+}
+
+function judgeRhel(ctx, a, obs, ver, build, d) {
+ const { t, distro, cves } = ctx;
+ a.path = 'rhel';
+ const rh = d.release ? own(distro.redhat, d.release) : null;
+ for (const x of rh?.advisories || []) {
+ if (!x?.cve || !x.advisory) continue;
+ a.advisories.push({ label: `RHEL ${d.release} · ${x.cve}`, value: x.package ? `${x.advisory} (${x.package})` : String(x.advisory) });
+ }
+ for (const s of rh?.states || []) {
+ if (!s?.cve || !s.state) continue;
+ const state = own(RHEL_STATES, s.state) ? t(RHEL_STATES[s.state]) : String(s.state);
+ a.advisories.push({ label: `RHEL ${d.release} · ${s.cve}`, value: s.package ? `${s.package}: ${state}` : state });
+ }
+ if (d.vendor && d.vendor !== 'rhel') a.notes.push(t('note.rhelCompatible'));
+
+ // Remi's software collections live under /etc/opt/remi.
+ const remiPath = !build && /^\/etc\/opt\/remi\//.test(obs.configPath || '');
+ const rpms = obs.packages.filter((p) => p.manager === 'rpm');
+ // The packages of the PHP being judged: the same upstream version, and the same layout as the probe's config path.
+ let candidates = build ? build.packages : rpms.filter((p) => debUpstream(p.version) === ver.upstream);
+ if (!build && obs.configPath && candidates.length > 1) {
+ const scl = /^\/etc\/opt\/remi\/(php\d+)(\/|$)/.exec(obs.configPath);
+ const narrowed = candidates.filter((p) => (scl ? p.name.startsWith(`${scl[1]}-`) : !/^php\d+-php(-|$)/.test(p.name)));
+ if (narrowed.length) candidates = narrowed;
+ }
+ if (candidates.length) a.usedKeys.push(...new Set(candidates.map(buildKey)));
+ // Every package of this PHP whose changelog was read must record the fix. A package with no changelog lines was
+ // not checked, which is not the same as a count of 0; it is covered only by a package of exactly the same version,
+ // whose changelog is the same (every package built from one source package carries its changelog).
+ const logged = candidates.filter((p) => p.changelog);
+ const unlogged = candidates.filter((p) => !p.changelog && !logged.some((q) => q.version === p.version));
+ const status = Object.fromEntries(cves.map((cve) => [cve, changelogStatus(logged, unlogged, cve)]));
+ const found = cves.filter((cve) => status[cve] === 'found');
+ const names = logged.map(pkgLabel).join(', ');
+
+ if (remiPath || (candidates.length && candidates.every(isRemi))) {
+ a.path = 'remi';
+ const pkg = candidates.length ? pkgLabel(candidates[0]) : obs.configPath;
+ // Remi follows upstream releases, but its collections of end-of-life branches (php74, php80, php81) carry
+ // backported fixes under the same version number, and their changelogs record them.
+ if (found.length === cves.length) {
+ a.verdict = 'patched';
+ a.restsOnPackage = true;
+ a.reasons.push(t('reason.remiChangelogFixed', { pkg: names, cves: found.join(', ') }));
+ return;
+ }
+ if (!ctx.upstream.fixed[ver.branch] && comparePhp(ver, '5.4.0') >= 0) {
+ // An end-of-life collection: only its changelog can show the backport. A changelog that was read and has no
+ // entry (a count of 0) shows a build without it; a changelog that was not read shows nothing.
+ a.notes.push(t('note.remiEol', { pkg }));
+ const missing = cves.filter((cve) => status[cve] === 'missing');
+ if (missing.length) {
+ judgeUpstream(ctx, a, ver);
+ a.path = 'remi';
+ a.reasons.push(t('reason.remiChangelogMissing', { pkg: names, cves: missing.join(', ') }));
+ return;
+ }
+ a.verdict = 'unknown';
+ a.need = logged.length ? 'whole' : 'server';
+ if (logged.length) {
+ a.reasons.push(t('reason.rhelChangelogUnread', { pkg: (unlogged.length ? unlogged : logged).map(pkgLabel).join(', '), cves: cves.filter((c) => status[c] !== 'found').join(', ') }));
+ } else {
+ a.reasons.push(obs.serverData && candidates.length ? t('reason.rhelChangelogNotChecked', { pkg }) : t('reason.remiEolUnread', { pkg, branch: ver.branch }));
+ }
+ return;
+ }
+ a.notes.push(t('note.remi', { pkg }));
+ judgeUpstream(ctx, a, ver);
+ a.path = 'remi';
+ return;
+ }
+ if (!obs.serverData) {
+ a.need = 'server';
+ a.reasons.push(t('reason.rhelNeedServerScript'));
+ return;
+ }
+ if (!rpms.length) {
+ // No PHP installed with rpm at all: this PHP was built from source or comes from a panel.
+ a.notes.push(t('note.noRpmPackage'));
+ judgeUpstream(ctx, a, ver);
+ return;
+ }
+ if (!candidates.length) {
+ a.need = 'elsewhere';
+ a.reasons.push(t('reason.rhelNoMatchingPackage', { version: ver.upstream, packages: rpms.map(pkgLabel).join(', ') }));
+ return;
+ }
+ if (!logged.length) {
+ a.need = 'server';
+ a.reasons.push(t('reason.rhelChangelogNotChecked', { pkg: pkgLabel(candidates[0]) }));
+ return;
+ }
+ const notAffected = cves.filter((cve) => !found.includes(cve) && rhelNotAffected(rh, d.release, cve, ver));
+ const missing = cves.filter((cve) => status[cve] === 'missing' && !notAffected.includes(cve));
+ const unread = cves.filter((cve) => status[cve] === 'unread' && !notAffected.includes(cve));
+ if (!missing.length && !unread.length) {
+ a.verdict = 'patched';
+ a.restsOnPackage = true;
+ if (found.length) a.reasons.push(t('reason.rhelChangelogFixed', { pkg: names, cves: found.join(', ') }));
+ } else {
+ if (missing.length) a.reasons.push(t('reason.rhelChangelogMissing', { pkg: names, cves: missing.join(', ') }));
+ if (unread.length) {
+ if (!missing.length) a.need = 'whole';
+ a.reasons.push(t('reason.rhelChangelogUnread', { pkg: (unlogged.length ? unlogged : logged).map(pkgLabel).join(', '), cves: unread.join(', ') }));
+ }
+ // A changelog that was read and has no entry, on a build older than the one that fixes it, is not ambiguous.
+ if (missing.length) rhelOlderThanFix(ctx, a, ver, d, rh, logged);
+ }
+ if (notAffected.length) a.reasons.push(t('reason.rhelNotAffected', { cves: notAffected.join(', '), release: d.release }));
+}
+
+// Vulnerable when the installed build is older than the fixed build: the exact build a Red Hat advisory names for this
+// branch and release (php-0:8.0.30-8.el9_8.1, php8.4-0:8.4.27-1.el10_2), or, on Amazon Linux, which ships its PHP
+// fixes as upstream releases, the upstream fixed release. A module stream advisory names no package version, so a
+// module build stays Undetermined.
+function rhelOlderThanFix(ctx, a, ver, d, rh, logged) {
+ const { t } = ctx;
+ const main = logged.find((p) => /-common$/.test(p.name)) || logged[0];
+ if (!main) return;
+ if (d.vendor === 'amzn') {
+ const fixedUp = ctx.upstream.fixed[ver.branch];
+ if (fixedUp && cmpUp(debUpstream(main.version), fixedUp) < 0) {
+ a.verdict = 'vulnerable';
+ a.reasons.push(t('reason.amazonOlder', { pkg: pkgLabel(main), fixed: fixedUp }));
+ }
+ return;
+ }
+ const builds = (rh?.advisories || [])
+ .map((x) => ({ advisory: x?.advisory, m: /^php[\d.]*-((?:\d+:)?\d+\.\d+\.\d+-.+)$/.exec(x?.package || '') }))
+ .filter((x) => x.advisory && x.m && advisoryBranch(x.m[0]) === ver.branch)
+ .sort((x, y) => compareRpm(x.m[1], y.m[1]));
+ const fixed = builds[builds.length - 1];
+ if (fixed && compareRpm(main.version, fixed.m[1]) < 0) {
+ a.verdict = 'vulnerable';
+ a.reasons.push(t('reason.rhelOlderThanFixed', { pkg: pkgLabel(main), fixed: fixed.m[1].replace(/^0:/, ''), advisory: fixed.advisory }));
+ }
+}
+
+// What the changelogs of one PHP's rpm packages show for a CVE: 'found' (every package records the fix), 'missing'
+// (a package was read and has no entry: a count of 0), or 'unread' (a count is unknown, or a package was not read).
+function changelogStatus(logged, unlogged, cve) {
+ if (!logged.length) return 'unread';
+ const counts = logged.map((p) => (typeof p.changelog[cve] === 'number' ? p.changelog[cve] : null));
+ if (counts.some((n) => n === 0)) return 'missing';
+ if (counts.every((n) => n > 0)) return unlogged.length ? 'unread' : 'found';
+ return 'unread';
+}
+
+// Red Hat lists the CVE as "Not affected" for this release and this PHP. A module stream state applies to its
+// branch only, and a state for the plain "php" package to the branch of Red Hat's own php package in that release.
+function rhelNotAffected(rh, release, cve, ver) {
+ return (rh?.states || []).some((s) => {
+ if (s.cve !== cve || s.state !== 'Not affected') return false;
+ const stream = /^php:(\d+\.\d+)(\/|$)/.exec(s.package) || /^php(\d+\.\d+)$/.exec(s.package);
+ if (stream) return stream[1] === ver.branch;
+ return s.package === 'php' && rhelBaseBranch(rh, release) === ver.branch;
+ });
+}
+
+// The branch of Red Hat's own php package: from an advisory for it (php-0:8.0.30-8.el9_8.1), else the table.
+function rhelBaseBranch(rh, release) {
+ for (const x of rh?.advisories || []) {
+ const m = /^php-(?:\d+:)?(\d+\.\d+)\./.exec(x.package || '');
+ if (m) return m[1];
+ }
+ return RHEL_BASE_BRANCH[release] || null;
+}
+
+function isRemi(p) {
+ return /\.remi(\.|$)/.test(p.version) || /^php\d+-php(-|$)/.test(p.name);
+}
+
+// Was the PHP that was judged restarted after its last update, and was that checked at all?
+// mine: the processes the server script saw that may run this PHP.
+function checkRunning(ctx, a, obs, ver, build, mine) {
+ const { t } = ctx;
+ const stale = mine.filter((p) => p.status === 'stale');
+ if (a.downgraded) {
+ // The running build has the fix and the package on disk is older: stale processes still run that fixed build,
+ // and a restart would replace it with the vulnerable one.
+ if (stale.length) a.notes.push(t('note.staleDowngraded', { names: commNames(stale) }));
+ return;
+ }
+ if (stale.length) {
+ // The probe's own PHP_VERSION shows the fix, and the stale processes are that same SAPI and branch: what runs has
+ // the fix, and only a newer package waits to be loaded. One PHP-FPM master and its workers share their memory
+ // maps, so they are all stale or all current: a stale process next to a current one of the same name is another
+ // PHP, which the probe does not vouch for.
+ const procs = obs.processes || [];
+ const self = a.proven && obs.sapi && stale.every((p) => commSapi(p.comm) === sapiKind(obs.sapi)
+ && !procs.some((q) => q.comm === p.comm && q.status === 'ok'));
+ if (a.verdict === 'patched' && self) {
+ a.notes.push(t('note.staleFixed', { names: commNames(stale) }));
+ } else if (a.verdict === 'patched' || a.verdict === 'restart') {
+ a.reasons.push(t(a.proven ? 'reason.staleOtherProcesses' : 'reason.staleProcesses', { names: commNames(stale) }));
+ if (a.proven) a.staleOther = true;
+ a.verdict = 'restart';
+ } else {
+ // The build installed now is not shown to have the fix, so a restart alone would not help: it would load that
+ // build, and if the files were replaced by a downgrade, it would replace a fixed build with it.
+ a.reasons.push(t('reason.staleUnfixed', { names: commNames(stale) }));
+ }
+ return;
+ }
+ // -v output of php-fpm or php-cgi shows the binary on disk, not what the running service loaded.
+ if (!build && a.verdict === 'patched' && obs.sources.includes('cli') && obs.sapi && obs.sapi !== 'cli') {
+ a.reasons.push(t('reason.binaryOnDisk', { sapi: obs.sapi }));
+ return;
+ }
+ if (a.verdict !== 'patched' || !a.restsOnPackage) return;
+ if (obs.serverData) {
+ const unseen = build ? unseenServices(build, mine) : [];
+ if (obs.procUnreadable > 0) {
+ a.verdict = 'unknown';
+ a.need = 'root';
+ a.reasons.push(t(obs.root === true ? 'reason.runningUncheckedRoot' : 'reason.runningUnchecked'));
+ } else if (obs.root === false && (!build || servesWeb(build))) {
+ // Without root, /proc mounted with hidepid hides other accounts' processes without a trace: the processes the
+ // script saw, if any, are not all that may run this build.
+ a.verdict = 'unknown';
+ a.need = 'root';
+ a.reasons.push(t(mine.length ? 'reason.runningPartlyHidden' : 'reason.runningHidden'));
+ } else if (unseen.length) {
+ a.verdict = 'unknown';
+ a.need = 'unseen';
+ a.unseen = unseen;
+ a.reasons.push(t('reason.serviceUnseen', { names: unseen.join(', ') }));
+ } else if (!mine.length) {
+ if (obs.root === false) {
+ // No proc lines without root mean the processes were not checked, not that none run. A build with no web
+ // service package cannot keep an old build loaded in a web server, so the installed package decides.
+ a.reasons.push(t('reason.processesNotCheckedNoService'));
+ } else {
+ a.reasons.push(build ? t('reason.noRunningPhpSeen') : t('reason.probeProcessNotFound', { version: ver.raw }));
+ }
+ }
+ } else if (obs.serverConflict) {
+ a.verdict = 'unknown';
+ a.need = 'together';
+ a.reasons.push(t('reason.serverConflict'));
+ } else {
+ a.reasons.push(t('reason.loadedBuildUnconfirmed', { version: ver.raw }));
+ }
+}
+
+// The packages of a build whose processes the server script cannot see in /proc, when it saw none of them: php5-fpm
+// and php5-cgi (Ubuntu 14.04 and Debian 8 name the processes after the package, which the script's process list
+// does not include), and an Alpine Apache module (mod_php83.so, which the script's libphp test does not match).
+function unseenServices(build, mine) {
+ const manager = build.packages[0]?.manager;
+ const out = [];
+ for (const p of build.packages) {
+ if (manager === 'dpkg' && /^php5-(fpm|cgi)$/.test(p.name) && !mine.some((q) => q.comm === p.name)) out.push(p.name);
+ if (manager === 'apk' && /-apache2$/.test(p.name) && !mine.some((q) => q.comm === 'httpd' || q.comm === 'apache2')) out.push(p.name);
+ }
+ return [...new Set(out)];
+}
+
+// Whether process p may run the PHP of result x. The running PHP judged with an installed build runs what that
+// build can run; otherwise any process of its branch may be it.
+function mayRun(p, x, installed) {
+ if (x.build) return procRunsBuild(p, x.build);
+ const used = installed.filter((b) => x.a.usedKeys.includes(b.key));
+ return used.length ? used.some((b) => procRunsBuild(p, b)) : procMayRun(p.comm, x.ver);
+}
+
+// Whether a process the server script saw can run this installed build, by how each packaging names it.
+// Debian and Ubuntu name FPM and CGI after the branch (php-fpm8.2, from the php8.2-fpm package; php5-fpm on the
+// oldest releases) and run PHP in Apache only through a libapache2-mod-php package. Each SAPI binary carries the
+// whole PHP engine, so a process belongs to a build only when that build has the SAPI's own package. Alpine names
+// them php-fpm83. On the RHEL family every collection runs as php-fpm or in httpd: php-fpm from a php-fpm or
+// php8.4-fpm package, httpd from php or mod_php, or php8.4-modphp on Amazon Linux 2023. An unversioned php-fpm on
+// Debian is a container's, a panel's or a self-built PHP.
+function procRunsBuild(p, build) {
+ const comm = p.comm;
+ const names = build.packages.map((x) => x.name);
+ const branch = build.ver.branch;
+ switch (build.packages[0]?.manager) {
+ case 'dpkg': {
+ const m = /^php-(fpm|cgi)(\d+\.\d+)$/.exec(comm);
+ if (m) return m[2] === branch && names.includes(`php${branch}-${m[1]}`);
+ if (/^php5-(fpm|cgi)$/.test(comm)) return names.includes(comm);
+ return comm === 'apache2' && names.some((n) => /^libapache2-mod-php/.test(n));
+ }
+ case 'apk': {
+ const m = /^php-(fpm|cgi)(\d)(\d+)$/.exec(comm);
+ if (m) return `${m[2]}.${m[3]}` === branch && names.some((n) => n.endsWith(`-${m[1]}`));
+ return (comm === 'httpd' || comm === 'apache2') && names.some((n) => /-apache2$/.test(n));
+ }
+ case 'rpm':
+ if (comm === 'php-fpm') return names.some((n) => /(^|-)php(\d+\.\d+)?-fpm$/.test(n));
+ // A collection's Apache module package (php74-php) is not in the server script's list.
+ if (comm === 'httpd') return names.some((n) => n === 'php' || n === 'mod_php' || /-modphp$/.test(n)) || /^rpm:php\d+$/.test(build.key);
+ if (comm === 'php-cgi') return names.some((n) => /(^|-)php(\d+\.\d+)?-(cli|cgi)$/.test(n));
+ return false;
+ default:
+ return procMayRun(comm, build.ver);
+ }
+}
+
+// A process name with a version (php-fpm8.2) runs only that branch; others (apache2, httpd, php-fpm) may run any.
+function procMayRun(comm, ver) {
+ const m = /^php-(?:fpm|cgi)(\d+\.\d+)$/.exec(comm);
+ return m ? m[1] === ver.branch : true;
+}
+
+function commSapi(comm) {
+ if (/^php\d*-fpm/.test(comm)) return 'fpm';
+ if (comm === 'apache2' || comm === 'httpd') return 'apache';
+ if (/^lsphp/.test(comm)) return 'litespeed';
+ if (comm === 'frankenphp') return 'frankenphp';
+ if (/^php\d*-cgi/.test(comm)) return 'cgi';
+ return comm;
+}
+
+function sapiKind(sapi) {
+ return own({ 'fpm-fcgi': 'fpm', apache2handler: 'apache', litespeed: 'litespeed', frankenphp: 'frankenphp', 'cgi-fcgi': 'cgi', cgi: 'cgi' }, sapi) || sapi;
+}
+
+const commNames = (procs) => [...new Set(procs.map((p) => p.comm))].join(', ');
+
+function inferSapi(processes) {
+ const comms = (processes || []).map((p) => p.comm);
+ if (comms.some((c) => /^php\d*-fpm/.test(c))) return 'fpm-fcgi';
+ if (comms.some((c) => c === 'apache2' || c === 'httpd')) return 'apache2handler';
+ if (comms.includes('frankenphp')) return 'frankenphp';
+ if (comms.some((c) => /^lsphp/.test(c))) return 'litespeed';
+ return null;
+}
+
+// Whether a build has a package that runs PHP as a service (FPM, CGI, an Apache module): only such a service can
+// keep an old build loaded in a process the server script checks. A CLI-only build has none.
+function servesWeb(build) {
+ return build.packages.some((p) => /-(fpm|cgi|apache2|modphp)$/.test(p.name) || /^libapache2-mod-php/.test(p.name)
+ || (p.manager === 'rpm' && /^(php|mod_php)$/.test(p.name)));
+}
+
+// The PHP SAPI an installed build's packages provide, for the restart command.
+function buildSapi(build) {
+ const names = build.packages.map((p) => p.name);
+ if (names.some((n) => /-fpm$/.test(n))) return 'fpm-fcgi';
+ if (names.some((n) => /^libapache2-mod-php|^mod_php$|^php$|-apache2$|-modphp$/.test(n))) return 'apache2handler';
+ return null;
+}
+
+// CVEs with a fix recorded in the changelogs of the given packages. null when none of them has a changelog.
+function changelogFound(packages, cves) {
+ const logs = packages.map((p) => p.changelog).filter(Boolean);
+ if (!logs.length) return null;
+ return cves.filter((cve) => logs.some((hits) => hits[cve] > 0));
+}
+
+// The installed PHP builds in the server script output: one per Debian source package, rpm
+// collection or apk prefix (php8.2 and php8.4 side by side are two builds).
+function installedBuilds(obs) {
+ const groups = new Map();
+ for (const p of obs.packages) {
+ const key = buildKey(p);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(p);
+ }
+ const builds = [];
+ for (const [key, packages] of groups) {
+ const main = packages.find((p) => /-common$/.test(p.name)) || packages[0];
+ const ver = parsePhpVersion(debComparable(main.version));
+ if (ver) builds.push({ key, packages, main, ver });
+ }
+ return builds.sort((x, y) => compareBranch(x.ver.branch, y.ver.branch) || x.key.localeCompare(y.key));
+}
+
+function buildKey(p) {
+ if (p.manager === 'dpkg') return `dpkg:${debSource(p)}`;
+ const scl = /^(php\d+)-php(-|$)/.exec(p.name);
+ if (p.manager === 'rpm') return `rpm:${scl ? scl[1] : 'php'}`;
+ const apk = /^(php\d*)/.exec(p.name);
+ return `${p.manager}:${apk ? apk[1] : p.name}`;
+}
+
+// The distribution family whose data the verdict rests on, which the page names as the data source: 'debian',
+// 'ubuntu' or 'rhel'. null when it rests on upstream releases (an upstream or Docker build, a PHP configured outside
+// /etc, deb.sury.org, Remi, Alpine, other distributions) or on no data at all (pre-releases, undetermined builds).
+function dataFamily(d, path) {
+ if (path === 'deb' && (d.family === 'debian' || d.family === 'ubuntu')) return d.family;
+ if (path === 'rhel' && d.family === 'rhel') return 'rhel';
+ return null;
+}
+
+function finish(ctx, r, obs, d, ver, info) {
+ const { t, lang } = ctx;
+ r.title = t(`verdict.title.${r.verdict}`);
+ if (ctx.sapi) r.exposure = sapiExposure(ctx.sapi, lang);
+ if (obs.ini) r.mitigations = evaluateMitigations(obs.ini, obs.extensions, ver, r.verdict === 'patched', lang);
+ r.findings = describeFindings(t, obs.findings || {}, obs.root);
+ buildActions(ctx, r, obs, d || {}, ver, info);
+ // The page shows these values as text: never null, a number or an object.
+ r.facts = textRows(r.facts);
+ r.advisories = textRows(r.advisories);
+ return r;
+}
+
+function textRows(rows) {
+ return rows
+ .filter((x) => x && x.value !== null && x.value !== undefined && String(x.value).trim() !== '')
+ .map((x) => ({ label: String(x.label), value: String(x.value) }));
+}
+
+// Results of the server script's checks for signs of compromise (--logs, --webroot).
+// A count of null means the source could not be read; it is never reported as "nothing found".
+function describeFindings(t, f, root) {
+ const out = [];
+ const keys = Object.keys(FINDING_SOURCE).filter((k) => k in f);
+ if (f.oom > 0) out.push({ level: 'warn', text: t('finding.oom', { n: f.oom }) });
+ if (f.crash > 0) out.push({ level: 'warn', text: t('finding.crash', { n: f.crash }) });
+ if (f.bigsession > 0) out.push({ level: 'warn', text: t('finding.bigSession', { n: f.bigsession }) });
+ if (f.webuser_proc > 0) out.push({ level: 'warn', text: t('finding.webUserProc', { n: f.webuser_proc }) });
+ if (f.recent_php > 0) out.push({ level: 'warn', text: t('finding.recentPhp', { n: f.recent_php }) });
+ // A count of null is "unknown": the source was missing or unreadable. It is never "nothing found".
+ const unread = [...new Set(keys.filter((k) => f[k] === null || f[k] === undefined).map((k) => FINDING_SOURCE[k]))];
+ const checked = [...new Set(keys.map((k) => FINDING_SOURCE[k]))];
+ const sources = (list) => list.map((s) => t(`finding.source.${s}`)).join(', ');
+ if (unread.length) {
+ out.push({ level: 'info', text: t('finding.unread', { items: sources(unread) }) });
+ } else if (keys.length && root === false) {
+ out.push({ level: 'info', text: t('finding.notRoot') });
+ }
+ // Name what was checked: the server script always counts web account processes, and logs, session files and
+ // the web root only with --logs and --webroot.
+ if (keys.length && !out.length) out.push({ level: 'ok', text: t('finding.none', { items: sources(checked) }) });
+ if (out.some((x) => x.level === 'warn' || x.level === 'bad')) {
+ out.push({ level: 'info', text: t('finding.incident') });
+ }
+ return out;
+}
+
+// ── Remediation steps ──────────────────────────────────────────────
+// r.actions is a numbered list of steps, each { text, code? }: a sentence or two of plain explanation and, where
+// useful, commands to copy. Versions, package names, image tags, service names and paths come from the observation
+// and the data files. A placeholder in angle brackets (<container>, <your-image>) stands only for a value the input
+// cannot show. Commands never delete anything and restart only the services that run this PHP.
+
+// Debian releases the official PHP Docker images are still built on (php:8.3.36-apache-bookworm exists,
+// php:8.3.36-apache-bullseye does not), and the Alpine releases their tags name (php:8.4.27-fpm-alpine3.24).
+const DOCKER_DEBIAN = [{ release: 12, codename: 'bookworm' }, { release: 13, codename: 'trixie' }];
+const DOCKER_ALPINE = ['3.23', '3.24'];
+const DEBIAN_RELEASES = { jessie: 8, stretch: 9, buster: 10, bullseye: 11, bookworm: 12, trixie: 13, forky: 14 };
+// Ubuntu releases past their ten-year ESM window: their fixes come only with the paid Legacy Support add-on.
+const UBUNTU_LEGACY = ['14.04', '16.04'];
+// Ubuntu releases whose PHP security updates report their package revision in PHP_VERSION (8.1.2-1ubuntu2.28). From
+// 24.04 on, PHP_VERSION is the plain upstream version before and after the fix.
+const UBUNTU_REVISION_IN_VERSION = ['14.04', '16.04', '18.04', '20.04', '22.04'];
+// The probe lists at most this many packages, so a list of this length may be incomplete.
+const PROBE_DPKG_MAX = 40;
+// Where the page publishes the probe and the server script (the download lines on its tabs).
+// The published download location. The page swaps it for its own address, so a
+// self-hosted copy points at its own downloads/.
+export const DEFAULT_DOWNLOADS = 'https://sqzer-x.github.io/php2shell-scanner/downloads/';
+const DOWNLOADS = DEFAULT_DOWNLOADS;
+const FETCH_SCRIPT = `curl -fsSLO ${DOWNLOADS}php2shell-server-check.sh\nsha256sum php2shell-server-check.sh`;
+const RHEL_NAMES = {
+ rhel: 'RHEL', centos: 'CentOS', rocky: 'Rocky Linux', almalinux: 'AlmaLinux', ol: 'Oracle Linux',
+ cloudlinux: 'CloudLinux', eurolinux: 'EuroLinux', amzn: 'Amazon Linux',
+};
+// Rebuilds that republish Red Hat's fixes in advisories of their own.
+const REBUILD_PREFIX = { rocky: 'RLSA', almalinux: 'ALSA', ol: 'ELSA', cloudlinux: 'CLSA' };
+const CVE_GREP = (cves) => cves.map((c) => `-e ${c}`).join(' ');
+
+function buildActions(ctx, r, obs, d, ver, info) {
+ const s = actionContext(ctx, r, obs, d, ver, info);
+ let steps;
+ if (info.noVersion) steps = collectSteps(s, 'noVersion');
+ else if (r.verdict === 'patched') steps = patchedSteps(s);
+ else if (r.verdict === 'restart') steps = restartSteps(s);
+ else steps = fixSteps(s);
+ r.actions.push(...steps.filter(Boolean));
+}
+
+function step(s, key, vars = {}, code = null) {
+ const text = s.t(key, vars).trim();
+ return code ? { text, code } : { text };
+}
+
+// Marks how many steps at the start explain the situation; an interim mitigation that is the only thing to do
+// for now goes straight after them.
+function lead(steps, n) {
+ steps.lead = n;
+ return steps;
+}
+
+function actionContext(ctx, r, obs, d, ver, info) {
+ const a = info.a || {};
+ const build = info.build || null;
+ const official = obs.configPath === '/usr/local/etc/php';
+ const s = {
+ ctx,
+ t: ctx.t,
+ r,
+ obs,
+ d: d || {},
+ ver,
+ info,
+ a,
+ build,
+ // When the worst result is another installed build next to the probe's PHP, that build's service matters.
+ sapi: build && (obs.phpVersion || !ctx.sapi) ? buildSapi(build) || ctx.sapi : ctx.sapi,
+ official,
+ container: obs.container === true || official,
+ path: info.path,
+ fixed: ver ? ctx.upstream.fixed[ver.branch] || null : null,
+ pkgs: build ? build.packages : obs.packages.filter((p) => (a.usedKeys || []).includes(buildKey(p))),
+ need: info.need || a.need || null,
+ // A header, -v output or a bare version: nothing shows where this PHP comes from.
+ fromOutside: !obs.sources.includes('selfcheck') && !obs.sources.includes('servercheck'),
+ };
+ s.names = [...new Set(s.pkgs.map((p) => p.name))];
+ // Which PHP an undetermined result is about is open, so no service or file of it is named.
+ s.layout = ver && s.path !== 'undetermined' ? phpLayout(s) : null;
+ // An end-of-life branch with no package that carries the fix: the move takes time, the mitigation does not.
+ s.eolNoFix = Boolean(ver && a.eol && !s.fixed && s.path !== 'remi');
+ return s;
+}
+
+// Alpine has no sudo, and its steps assume a root shell.
+function rootShell(s) {
+ return s.layout?.init === 'openrc' || s.d.family === 'alpine' || s.obs.os?.id === 'alpine';
+}
+
+function serverScript(s, args = '') {
+ return `${asRootIn(s)}sh php2shell-server-check.sh${args}`;
+}
+
+// "sudo " for a command that needs root, except where the steps assume a root shell.
+function asRootIn(s) {
+ return rootShell(s) ? '' : 'sudo ';
+}
+
+function inContainer(args = '') {
+ return `docker cp php2shell-server-check.sh <container>:/tmp/\ndocker exec -u 0 <container> sh /tmp/php2shell-server-check.sh${args}`;
+}
+
+// "a, b and c" in the language of the result.
+function listText(s, items) {
+ if (items.length < 2) return items.join('');
+ return `${items.slice(0, -1).join(s.t('list.separator'))}${s.t('list.and')}${items[items.length - 1]}`;
+}
+
+// ── Steps by verdict ────────────────────────────────────────────────
+
+// Vulnerable, Fix pending and Undetermined: what to collect (when open), how to update, restart and confirm,
+// the interim mitigations, and the compromise check.
+function fixSteps(s) {
+ const out = [];
+ if (s.r.verdict === 'unknown') out.push(...collectSteps(s, s.need || (s.path === 'undetermined' ? 'server' : null)));
+ let body = [];
+ if (s.path === 'prerelease') {
+ body = [s.fixed ? step(s, 'step.prereleaseFixed', { version: s.ver.raw, fixed: s.fixed, branch: s.ver.branch })
+ : step(s, 'action.checkReleaseNotes', { version: s.ver.raw }), restartAfterUpdate(s)];
+ } else if (['root', 'together', 'unreadable', 'unseen'].includes(s.need)) {
+ // The installed package has the fix; only the running processes (or which output is current) are open.
+ if (s.need === 'root' || s.need === 'unseen') body = [restartIfUnsure(s)];
+ } else if (s.need === 'sapiPackage') {
+ // The package of the running SAPI is not fully installed: finishing the update (the step above) comes first.
+ body = [restartAfterUpdate(s), recheckStep(s)];
+ } else if (s.path !== 'undetermined') {
+ body = pathSteps(s);
+ }
+ const timing = mitigationTiming(s, body);
+ body = body.filter(Boolean);
+ // With the fixed package installed (the processes, or the end of the output, are what is open), the interim
+ // mitigations are not the next step. Nor are they after a downgrade: the running PHP has the fix, and the reload
+ // that applies them would load the vulnerable build on disk.
+ const fixedOnDisk = ['root', 'together', 'unreadable', 'unseen'].includes(s.need) || (s.need === 'whole' && s.a.verdict === 'patched')
+ || Boolean(s.a.downgraded);
+ if (!fixedOnDisk) body.splice(timing.at === null ? body.length : Math.min(timing.at, body.length), 0, ...mitigationSteps(s, timing.when));
+ out.push(...body, compromiseStep(s));
+ return out;
+}
+
+// Where the interim mitigation goes, and how it starts. When nothing can be installed yet (a pending fix, an
+// end-of-life branch, a release with no Red Hat fix), it is the only thing that lowers the risk now, so it follows
+// the steps that explain the situation instead of the update steps that cannot do anything yet.
+function mitigationTiming(s, body) {
+ const at = body.lead || 1;
+ if (s.r.verdict === 'pending') return { when: 'when.pending', at };
+ if (s.eolNoFix) return { when: 'when.eol', at };
+ if (body.waiting) return { when: body.waiting, at };
+ return { when: 'when.cannotUpdate', at: null };
+}
+
+function pathSteps(s) {
+ if (s.container) return containerSteps(s);
+ if (s.path === 'deb') return debSteps(s);
+ if (s.path === 'sury') return surySteps(s);
+ if (s.path === 'rhel') return rhelSteps(s);
+ if (s.path === 'remi') return remiSteps(s);
+ if (s.path === 'alpine') return alpineSteps(s);
+ if (s.path === 'other') return otherSteps(s);
+ // A RHEL-family PHP configured under /etc that no rpm package in the server script output accounts for (the
+ // php8.4 packages of RHEL 10, for example) is still most likely Red Hat's.
+ if (s.d.family === 'rhel' && /^\/etc(\/|$)/.test(s.obs.configPath || '')) return rhelSteps(s);
+ return upstreamSteps(s);
+}
+
+// Restart needed: why the old build is still loaded, the restart itself, how to confirm it, and the compromise
+// check (the vulnerable build has been serving requests until now).
+function restartSteps(s) {
+ const out = [];
+ if (s.container) {
+ out.push(step(s, 'step.container.restart', {}, 'docker restart <container>'));
+ const run = containerRunLine(s);
+ if (run) out.push(step(s, 'step.container.persist', {}, run));
+ } else {
+ const plan = s.info.genericRestart ? null : restartPlan(s);
+ if (plan) out.push(step(s, 'action.restartNow', { extra: plan.extra }, plan.code));
+ else if (s.info.genericRestart || s.info.stale?.length) {
+ const names = s.info.genericRestart ? s.info.genericRestart.split(', ') : s.info.stale;
+ out.push(step(s, 'action.restartProcesses', { names: names.join(', ') }, `ps -o pid,user,cgroup,args -C ${names.join(',')}`));
+ } else out.push(step(s, 'action.restartGeneric'));
+ out.push(s.obs.sources.includes('servercheck') ? step(s, 'step.restart.confirmServer', {}, serverScript(s)) : step(s, 'step.restart.confirm'));
+ }
+ out.push(compromiseStep(s));
+ return out;
+}
+
+// Patched: other sites and pools, and optional hardening.
+function patchedSteps(s) {
+ const out = [step(s, s.container ? 'action.checkOtherContainers' : 'action.checkOtherSites')];
+ const ini = s.obs.ini;
+ if ((ini && isOn(ini.expose_php)) || s.obs.sources.includes('header')) out.push(exposeStep(s));
+ if (!(s.obs.extensions && s.obs.extensions.session === false)) {
+ out.push(step(s, ini && isOff(ini['session.upload_progress.enabled']) ? 'step.hardening.uploadOff' : 'step.hardening.uploadOn'));
+ }
+ return out;
+}
+
+// The server script with --logs and --webroot, which looks for signs of an attack.
+function compromiseStep(s) {
+ const args = ' --logs --webroot <webroot>';
+ return step(s, 'action.lookForCompromise', {}, s.container ? inContainer(args) : serverScript(s, args));
+}
+
+// ── What to collect when the verdict is open ───────────────────────
+
+function collectSteps(s, need) {
+ const hostServer = () => step(s, 'step.collect.server', {}, `${FETCH_SCRIPT}\n${serverScript(s)}`);
+ const server = () => (s.container ? step(s, 'step.collect.serverInContainer', {}, `${FETCH_SCRIPT}\n${inContainer()}`) : hostServer());
+ const probe = (next) => probeStep(s, next);
+ switch (need) {
+ case 'noVersion':
+ return [probe(true), hostServer()];
+ case 'server':
+ return s.fromOutside ? [probe(true), server()] : [server()];
+ case 'serverOrDistro':
+ // The server script output is already here when it is the OS release, not the packages, that is missing.
+ if (s.obs.serverData) return [step(s, 'step.collect.distroOnly')];
+ if (s.container) return [server()];
+ return s.fromOutside ? [probe(true), server(), step(s, 'step.collect.distro')] : [server(), step(s, 'step.collect.distro')];
+ case 'root':
+ case 'unreadable':
+ // As root, a process the script cannot read is usually in a container without CAP_SYS_PTRACE: running it
+ // again with sudo gives the same output.
+ if (s.obs.root === true) return [step(s, 'step.collect.unreadableAsRoot', {}, `${FETCH_SCRIPT}\n${serverScript(s)}`)];
+ return [s.container ? server() : step(s, 'step.collect.root', {}, serverScript(s))];
+ case 'unseen': {
+ const names = s.a.unseen || [];
+ const code = names.some((n) => /-apache2$/.test(n)) ? "ps -o pid,args | grep '[h]ttpd'" : `ps -o pid,lstart,args -C ${names.join(',')}`;
+ return [step(s, 'step.collect.unseen', { names: names.join(', ') }, code)];
+ }
+ case 'sapiPackage': {
+ const src = debSrc(s);
+ return [step(s, 'step.collect.sapiPackage', { pkg: s.info.sapiPackage || s.a.sapiPackage || `${src}-fpm`, src },
+ `dpkg -l '${src}-*' 'libapache2-mod-${src}'\nsudo dpkg --audit\nsudo apt-get update\nsudo apt-get install -f`)];
+ }
+ case 'vendor': {
+ const vendor = s.obs.os?.pretty || s.d.vendor || s.obs.os?.id || 'other';
+ return [step(s, 'step.collect.vendor', { vendor }, `rpm -qa 'php*'\nrpm -q --changelog php${s.ver.major} | grep ${CVE_GREP(s.ctx.cves)}`)];
+ }
+ case 'whole':
+ return [step(s, 'step.collect.whole')];
+ case 'together':
+ return [step(s, 'step.collect.together', {}, serverScript(s))];
+ case 'elsewhere':
+ return [step(s, 'step.collect.elsewhere', {}, inContainer())];
+ case 'hiddenLink':
+ return [step(s, 'step.collect.hiddenLink', {}, `${FETCH_SCRIPT}\n${inContainer()}`)];
+ case 'advisories': {
+ const family = s.d.family === 'ubuntu' ? 'ubuntu' : 'debian';
+ return [step(s, 'step.collect.advisories', { family: familyName(family), src: debSrc(s) }, trackerUrls(family, s.ctx.cves))];
+ }
+ case 'supplier': {
+ const pkg = s.names[0] || `${debSrc(s)}-common`;
+ return [step(s, 'step.collect.supplier', { family: familyName(s.d.family) }, `apt-cache policy ${pkg}`)];
+ }
+ default:
+ return [];
+ }
+}
+
+// The PHP probe through the web server; next adds that the server script follows in the next step.
+function probeStep(s, next) {
+ return step(s, 'step.collect.probe', { next: next ? s.t('collect.probeNext') : '' },
+ `curl -fsSLO ${DOWNLOADS}php2shell-selfcheck.php\ncurl -s 'https://<site>/<file>.php?k=<TOKEN>'`);
+}
+
+// ── Debian and Ubuntu packages ──────────────────────────────────────
+
+function debSteps(s) {
+ const D = debInfo(s);
+ if (s.r.verdict === 'pending') {
+ return lead([
+ step(s, 'step.pending.watch', { family: D.family, release: D.release, src: D.src }, trackerUrls(s.d.family, s.ctx.cves)),
+ step(s, 'step.pending.check', { src: D.src }, `sudo apt-get update\napt list --upgradable 2>/dev/null | grep ${D.src}\nsudo apt-get install --only-upgrade ${aptList(s, D.src)}`),
+ restartAfterUpdate(s),
+ ], 1);
+ }
+ if (s.need === 'supplier') return supplierSteps(s, D);
+ const out = [];
+ if (D.pro) out.push(proStep(s, D));
+ let fix;
+ if (s.a.downgraded) fix = s.t('fix.debDowngraded', { pkg: D.installed || D.src, version: s.ver.raw, fixed: D.fixed ? `${D.src} ${D.fixed}` : `PHP ${s.fixed}` });
+ else if (s.r.verdict === 'vulnerable' && D.fixed && D.installed) fix = s.t('fix.deb', { pkg: D.installed, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
+ else if (s.r.verdict === 'vulnerable' && D.fixed) fix = s.t('fix.debRunning', { version: s.ver.raw, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
+ else fix = s.t('fix.debOpen', { family: D.family, src: D.src });
+ out.push(aptUpgradeStep(s, fix, D.src));
+ if (s.need === 'advisories') out.push(step(s, 'step.apt.confirmCompare', { family: D.family }, dpkgVersions(s, D.src)));
+ else out.push(D.fixed ? step(s, 'step.apt.confirmFixed', { fixed: D.fixed }, dpkgVersions(s, D.src)) : step(s, 'step.apt.confirm', {}, dpkgVersions(s, D.src)));
+ out.push(restartAfterUpdate(s));
+ // The data has no record to compare with, but the server script reads the changelog of the updated package.
+ if (s.need === 'advisories') out.push(step(s, 'step.recheckChangelog', {}, serverScript(s)));
+ // The server script output did not name the release, and running it again will not either.
+ else if (s.need === 'serverOrDistro' && s.obs.serverData) out.push(step(s, 'step.recheckServerDistro', {}, serverScript(s)));
+ else out.push(recheckStep(s));
+ return out;
+}
+
+// A build from another repository with the same upstream version: apt never replaces it with the distribution's
+// lower-numbered revision, so the distribution's build is installed explicitly, at its exact version.
+function supplierSteps(s, D) {
+ const foreign = D.installedVersion || s.ver.raw;
+ return [
+ step(s, 'step.apt.switchToDistro', { family: D.family, fixed: D.fixed, foreign, src: D.src }, switchToDistroCode(D.src, foreign, D.fixed)),
+ step(s, 'step.apt.confirmExact', { family: D.family, fixed: D.fixed }, `dpkg -l '${D.src}-*' 'libapache2-mod-${D.src}' | grep '^ii'`),
+ restartAfterUpdate(s),
+ recheckStep(s),
+ ];
+}
+
+// Reinstalls every package of this source that is now at the foreign version at the distribution's fixed version.
+function switchToDistroCode(src, foreign, fixed) {
+ const lookup = `$(dpkg-query -W -f='\${Package} \${Version}\\n' '${src}-*' 'libapache2-mod-${src}' 2>/dev/null | awk '$2 == "${foreign}" {print $1"=${fixed}"}')`;
+ return `sudo apt-get update\nsudo apt-get install --allow-downgrades ${lookup}`;
+}
+
+function proStep(s, D) {
+ const cmd = D.release === '14.04' ? 'sudo ua attach <token>' : 'sudo pro attach <token>';
+ return step(s, UBUNTU_LEGACY.includes(D.release) ? 'step.ubuntuProLegacy' : 'step.ubuntuPro', { release: D.release }, cmd);
+}
+
+// deb.sury.org follows upstream releases: the same apt commands, with the upstream fixed release as the target.
+function surySteps(s) {
+ const src = debSrc(s);
+ if (!s.fixed) {
+ // deb.sury.org ships backported fixes for end-of-life branches: its latest build may have them, which its
+ // changelog shows. The move to a supported branch, or to the distribution's own fixed package, follows.
+ const fix = s.t('fix.suryEol', { branch: s.ver.branch, src });
+ return lead([aptUpgradeStep(s, fix, src), restartAfterUpdate(s), step(s, 'step.recheckChangelog', {}, serverScript(s)), ...eolSteps(s, { restart: false })], 1);
+ }
+ const pkg = pickDebPackage(s.obs, s.ver, s.sapi, s.build);
+ const fix = s.t('fix.sury', { pkg: pkg ? pkgLabel(pkg) : `PHP ${s.ver.raw}`, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
+ return [
+ aptUpgradeStep(s, fix, src),
+ step(s, 'step.apt.confirmUpstream', { fixed: s.fixed, branch: s.ver.branch }, dpkgVersions(s, src)),
+ restartAfterUpdate(s),
+ recheckStep(s),
+ ];
+}
+
+function aptUpgradeStep(s, fix, src) {
+ const dpkg = s.names.length > 0;
+ return step(s, dpkg ? 'step.apt.upgrade' : 'step.apt.upgradeFind', { fix, names: s.names.join(', '), src },
+ `sudo apt-get update\nsudo apt-get install --only-upgrade ${aptList(s, src)}`);
+}
+
+// The installed packages of this PHP, or, when the input does not list them, a lookup of the installed ones
+// (dpkg -l marks them "ii"; dpkg-query -W with a pattern also lists names that are not installed, which apt refuses).
+function aptList(s, src) {
+ if (s.names.length) return s.names.join(' ');
+ return `$(dpkg -l '${src}-*' 'libapache2-mod-${src}' 2>/dev/null | awk '/^ii/ {print $2}')`;
+}
+
+// The command that shows the installed versions of this PHP's packages.
+function dpkgVersions(s, src) {
+ return s.names.length ? `dpkg-query -W ${s.names.join(' ')}` : `dpkg -l '${src}-*' 'libapache2-mod-${src}' | grep '^ii'`;
+}
+
+function debInfo(s) {
+ const src = debSrc(s);
+ const entry = own(own(own(s.ctx.distro, s.d.family), s.d.release)?.packages, src);
+ const fixed = entry ? s.ctx.cves.map((c) => entry.fixed?.[c]).filter(Boolean).sort(compareDebian).pop() || null : null;
+ const pkg = pickDebPackage(s.obs, s.ver, s.sapi, s.build);
+ const mine = pkg && s.pkgs.includes(pkg);
+ return {
+ src,
+ fixed,
+ pro: Boolean(entry?.pro),
+ family: familyName(s.d.family),
+ release: s.d.release || '',
+ installed: mine ? pkgLabel(pkg) : null,
+ installedVersion: mine ? pkg.version : null,
+ };
+}
+
+function debSrc(s) {
+ const p = s.pkgs.find((x) => x.manager === 'dpkg');
+ return p ? debSource(p) : srcFor(s.ver);
+}
+
+function familyName(family) {
+ return own({ debian: 'Debian', ubuntu: 'Ubuntu' }, family) || family || '';
+}
+
+// The pages that show the fixed version of each release, one per CVE.
+function trackerUrls(family, cves) {
+ return cves.map((c) => (family === 'ubuntu' ? `https://ubuntu.com/security/${c}` : `https://security-tracker.debian.org/tracker/${c}`)).join('\n');
+}
+
+// ── RHEL family ─────────────────────────────────────────────────────
+
+function rhelSteps(s) {
+ const R = rhelInfo(s);
+ const out = [];
+ // Releases past the end of their support come first: they decide what the update steps can still bring.
+ if (R.vendor === 'amzn') {
+ if (R.release === '2') out.push(step(s, 'step.amazon.al2Eol'));
+ else if (/^201\d/.test(R.release)) out.push(step(s, 'step.amazon.amiEol', { release: R.release }));
+ } else if (R.vendor === 'centos' && R.major && R.major <= 8) {
+ // CentOS Linux 6 to 8 and CentOS Stream 8 get no updates from their own repositories; the package commands
+ // below help only with a third-party extended support subscription.
+ out.push(step(s, 'step.rhel.centosEol', { release: R.release }));
+ } else if (R.major && R.major <= 7) {
+ out.push(step(s, 'step.rhel.old', { name: R.name, release: R.release }));
+ }
+ const context = rhelContext(s, R);
+ const what = s.t(s.names.length ? 'rhel.what.names' : 'rhel.what.all', { names: s.names.join(', ') });
+ const target = s.names.length ? s.names.join(' ') : R.glob;
+ const check = R.cves.map((c) => `--cve ${c}`).join(' ');
+ const old = R.yum && R.major <= 6;
+ const update = old ? `sudo yum update ${target}` : R.yum ? `sudo yum updateinfo list ${check}\nsudo yum update ${target}`
+ : `sudo dnf updateinfo list ${check}\nsudo dnf upgrade --refresh ${target}`;
+ const upgradeKey = old ? 'step.rhel.update' : R.yum ? 'step.rhel.upgradeYum' : 'step.rhel.upgrade';
+ const moduleCheck = R.module && !R.yum;
+ if (R.vendor === 'amzn') {
+ out.push(step(s, 'step.amazon.watch', { context, release: R.release }, R.cves.map((c) => `https://explore.alas.aws.amazon.com/${c}.html`).join('\n')));
+ lead(out, out.length);
+ out.push(step(s, upgradeKey, { context: '', what }, update));
+ } else if (R.noFix) {
+ // Red Hat has published nothing for this branch and release: watch for it, and say that the update steps
+ // have nothing to bring yet.
+ out.push(step(s, 'step.rhel.watch', { context, release: R.release }, R.cves.map((c) => `https://access.redhat.com/security/cve/${c}`).join('\n')));
+ lead(out, out.length);
+ if (R.switchTo) {
+ out.push(step(s, 'step.rhel.switchStream', { release: R.release, branch: s.ver.branch, streams: listText(s, R.switchTo.streams), target: R.switchTo.branch, build: R.switchTo.build },
+ `sudo dnf module switch-to php:${R.switchTo.branch}`));
+ }
+ out.push(step(s, old ? 'step.rhel.updateNone' : R.switchTo ? 'step.rhel.upgradeNoneStay' : 'step.rhel.upgradeNone', { what, branch: s.ver.branch }, update));
+ if (moduleCheck && !R.switchTo) out.push(step(s, 'step.rhel.moduleNoBuild', { branch: s.ver.branch }, 'dnf module list php'));
+ } else {
+ if (moduleCheck && R.stream) out.push(step(s, 'step.rhel.module', { branch: s.ver.branch, build: R.stream }, 'dnf module list php'));
+ out.push(step(s, upgradeKey, { context, what }, update));
+ if (!R.release && !R.fixedElsewhere.length) out.push(step(s, 'step.rhel.watchAny', {}, R.cves.map((c) => `https://access.redhat.com/security/cve/${c}`).join('\n')));
+ }
+ const query = s.names.length ? `rpm -q ${s.names.join(' ')}` : `rpm -qa ${R.glob}`;
+ const grep = `rpm -q --changelog ${R.main} | grep ${CVE_GREP(R.grepCves)}`;
+ if (R.rebase) {
+ // The fix comes as a new upstream release (a Red Hat rebase, or an Amazon Linux update), whose changelog entry
+ // names the version, not the CVEs: the installed version shows it.
+ out.push(R.fixedNvr ? step(s, 'step.rpm.confirmVersion', { fixed: R.fixedNvr }, query)
+ : step(s, 'step.rpm.confirmUpstream', { fixed: s.fixed, branch: s.ver.branch }, query));
+ } else if (R.notAffected.length) {
+ out.push(step(s, 'step.rhel.confirmSome', { pkg: R.main, cves: R.grepCves.join(', '), notAffected: R.notAffected.join(', '), release: R.release }, `${query}\n${grep}`));
+ } else {
+ out.push(R.fixedNvr ? step(s, 'step.rhel.confirmFixed', { pkg: R.main, fixed: R.fixedNvr }, `${query}\n${grep}`)
+ : step(s, 'step.rhel.confirm', { pkg: R.main }, `${query}\n${grep}`));
+ }
+ out.push(restartAfterUpdate(s));
+ out.push(step(s, R.noFix ? 'step.recheckServerLater' : 'step.recheckServer', {}, serverScript(s)));
+ // With no fix to install (or no support left), the interim mitigation is what to do now.
+ if (R.eolRelease) out.waiting = 'when.eolRelease';
+ else if (R.noFix) out.waiting = 'when.pending';
+ if (!out.lead) out.lead = 1;
+ return out;
+}
+
+function rhelInfo(s) {
+ const { d, ver, ctx } = s;
+ const major = rhelMajor(d);
+ const release = d.release || '';
+ const rh = release ? ctx.distro.redhat?.[release] : null;
+ const scl = sclName(s);
+ const vendor = d.vendor || 'rhel';
+ const branchOf = (x) => x?.advisory && advisoryBranch(x.package) === ver.branch;
+ const advisories = vendor === 'amzn' ? [] : (rh?.advisories || []).filter(branchOf);
+ const stream = advisories.map((x) => x.package).find((p) => /^php:\d+\.\d+-/.test(p)) || null;
+ const nvr = advisories.map((x) => /^php[\d.]*-(?:\d+:)?(\d+\.\d+\.\d+-.+)$/.exec(x.package || '')).find(Boolean);
+ // The package name prefix of the fixed build when no package was listed: php8.4 on RHEL 10, php elsewhere.
+ const prefix = advisories.map((x) => /^(php[\d.]*)-\d+:/.exec(x.package || '')).find(Boolean)?.[1] || 'php';
+ const versions = [ver.raw, ...s.pkgs.map((p) => p.version)].join(' ');
+ const main = s.names.find((n) => /-common$/.test(n)) || s.names[0] || (scl ? `${scl}-php-common` : `${prefix}-common`);
+ const module = Boolean(stream) || /\.module[+_]/.test(versions);
+ const notAffected = release ? ctx.cves.filter((c) => rhelNotAffected(rh, release, c, ver)) : [];
+ // Red Hat advisories for this branch on the other releases, for a server whose release is unknown.
+ const fixedElsewhere = release ? [] : Object.entries(ctx.distro.redhat || {})
+ .map(([rel, x]) => ({ release: rel, ids: [...new Set((x?.advisories || []).filter(branchOf).map((y) => y.advisory))] }))
+ .filter((x) => x.ids.length);
+ const noFix = Boolean(release) && vendor !== 'amzn' && !advisories.length;
+ // Newer module streams of this release that have the fix, when this branch has none.
+ let switchTo = null;
+ if (noFix && module && !(major && major < 8)) {
+ const byBranch = new Map();
+ for (const x of rh?.advisories || []) {
+ const m = /^php:(\d+\.\d+)-/.exec(x?.package || '');
+ if (m && x.advisory && compareBranch(m[1], ver.branch) > 0 && !byBranch.has(m[1])) byBranch.set(m[1], { branch: m[1], advisory: x.advisory, build: x.package });
+ }
+ const list = [...byBranch.values()].sort((a, b) => compareBranch(a.branch, b.branch));
+ if (list.length) switchTo = { branch: list[0].branch, build: list[0].build, streams: list.map((x) => `php:${x.branch} (${x.advisory})`) };
+ }
+ return {
+ major,
+ release,
+ vendor,
+ name: own(RHEL_NAMES, vendor) || 'RHEL',
+ yum: Boolean(major && major < 8),
+ cves: ctx.cves,
+ grepCves: ctx.cves.filter((c) => !notAffected.includes(c)),
+ notAffected,
+ advisories,
+ fixedElsewhere,
+ noFix,
+ switchTo,
+ eolRelease: (vendor === 'centos' && Boolean(major) && major <= 8) || (vendor === 'amzn' && (release === '2' || /^201\d/.test(release))),
+ stream,
+ fixedNvr: nvr ? nvr[1] : null,
+ // The fix is an upstream release at or above the upstream fixed version: a Red Hat rebase (php8.4 8.4.27 on
+ // RHEL 10), or Amazon Linux, which ships its PHP fixes as upstream releases.
+ rebase: Boolean(ctx.upstream.fixed[ver.branch]) && (vendor === 'amzn'
+ || Boolean(nvr && cmpUp(nvr[1].split('-')[0], ctx.upstream.fixed[ver.branch]) >= 0)),
+ module,
+ glob: scl ? `'${scl}-php*'` : "'php*'",
+ main,
+ rh,
+ };
+}
+
+// What Red Hat (or Amazon) has published for this branch and release, as one or two sentences.
+function rhelContext(s, R) {
+ if (R.vendor === 'amzn') return s.t('rhel.context.amazon', { release: R.release || '' }).trim();
+ const vars = { branch: s.ver.branch, release: R.release };
+ if (!R.release) {
+ if (!R.fixedElsewhere.length) return s.t('rhel.context.noReleaseNone', { branch: s.ver.branch });
+ const fixes = R.fixedElsewhere.map((x) => s.t('rhel.context.noReleaseItem', { release: x.release, advisories: x.ids.join(', ') }));
+ return s.t('rhel.context.noRelease', { branch: s.ver.branch, fixes: listText(s, fixes) });
+ }
+ let text;
+ if (R.advisories.length) {
+ const ids = [...new Set(R.advisories.map((x) => `${x.advisory}${x.package ? ` (${x.package})` : ''}`))];
+ text = s.t('rhel.context.advisory', { ...vars, advisories: ids.join(', ') });
+ } else {
+ const states = (R.rh?.states || []).filter((x) => x?.cve && x.state && stateBranch(R.rh, R.release, x.package) === s.ver.branch)
+ .map((x) => `${x.cve}: ${own(RHEL_STATES, x.state) ? s.t(RHEL_STATES[x.state]) : x.state}`);
+ text = states.length ? s.t('rhel.context.state', { ...vars, states: states.join(', ') }) : s.t('rhel.context.none', vars);
+ }
+ if (own(REBUILD_PREFIX, R.vendor) && R.advisories.length) text += ` ${s.t('rhel.rebuild', { name: R.name, prefix: REBUILD_PREFIX[R.vendor] })}`;
+ return text;
+}
+
+// The PHP branch of a Red Hat package or module stream name: php:8.2-9080020261020145734.9, php-0:8.0.30-8.el9_8.1,
+// php8.4-0:8.4.27-1.el10_2.
+function advisoryBranch(pkg) {
+ const m = /^php:(\d+\.\d+)-/.exec(pkg || '') || /^php[\d.]*-(?:\d+:)?(\d+\.\d+)\.\d+-/.exec(pkg || '');
+ return m ? m[1] : null;
+}
+
+// The branch a Red Hat state applies to: a module stream (php:8.4/php) names it, the plain php package is the
+// release's own branch.
+function stateBranch(rh, release, pkg) {
+ const m = /^php:(\d+\.\d+)(\/|$)/.exec(pkg || '') || /^php(\d+\.\d+)$/.exec(pkg || '');
+ if (m) return m[1];
+ return pkg === 'php' ? rhelBaseBranch(rh, release) : null;
+}
+
+// Remi's software collection of this PHP (php82), from the build or the configuration path.
+function sclName(s) {
+ const key = /^rpm:(php\d+)$/.exec(s.build?.key || '');
+ if (key) return key[1];
+ const pkg = s.pkgs.map((p) => /^(php\d+)-php(-|$)/.exec(p.name)).find(Boolean);
+ if (pkg) return pkg[1];
+ const path = /^\/etc\/opt\/remi\/(php\d+)(\/|$)/.exec(s.obs.configPath || '');
+ return path ? path[1] : null;
+}
+
+// RHEL major version, for commands: Amazon Linux 2 is EL7-like, 2023 EL9-like, the 201x releases EL6-like.
+function rhelMajor(d) {
+ const rel = String(d.release || '');
+ if (d.vendor === 'amzn') return rel === '2' ? 7 : rel === '2023' ? 9 : /^201\d/.test(rel) ? 6 : null;
+ return Number.parseInt(rel, 10) || null;
+}
+
+// ── Remi, Alpine and other distributions ────────────────────────────
+
+function remiSteps(s) {
+ const R = rhelInfo(s);
+ const what = s.t(s.names.length ? 'rhel.what.names' : 'rhel.what.all', { names: s.names.join(', ') });
+ const target = s.names.length ? s.names.join(' ') : R.glob;
+ const upgrade = R.yum ? `sudo yum update ${target}` : `sudo dnf upgrade --refresh ${target}`;
+ const pkg = s.pkgs.find((p) => p.manager === 'rpm');
+ const label = pkg ? pkgLabel(pkg) : s.obs.configPath || `PHP ${s.ver.raw}`;
+ const query = s.names.length ? `rpm -q ${s.names.join(' ')}` : `rpm -qa ${R.glob}`;
+ const out = [];
+ if (!s.fixed) {
+ // Remi's collections of end-of-life branches carry backported fixes under the same version number, so a
+ // self-built backport is not offered next to them.
+ out.push(step(s, 'step.remi.eol', { pkg: label, branch: s.ver.branch, what }, upgrade));
+ out.push(step(s, 'step.rhel.confirm', { pkg: R.main }, `${query}\nrpm -q --changelog ${R.main} | grep ${CVE_GREP(R.cves)}`));
+ out.push(restartAfterUpdate(s));
+ out.push(step(s, 'step.recheckServer', {}, serverScript(s)));
+ out.push(...eolSteps(s, { restart: false, backport: false }));
+ return out;
+ }
+ out.push(step(s, 'step.remi.upgrade', { fix: s.t('fix.remi', { pkg: label, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s), what }, upgrade));
+ out.push(step(s, 'step.rpm.confirmUpstream', { fixed: s.fixed, branch: s.ver.branch }, query));
+ out.push(restartAfterUpdate(s));
+ out.push(recheckStep(s));
+ return out;
+}
+
+// Every installed package of this PHP: the PHP-FPM and Apache packages depend on the plain -common package without
+// a version, so updating only some of them would leave a mix of old and new.
+function apkInstalled(name) {
+ return `$(apk info | grep '^${name}')`;
+}
+
+function alpineSteps(s) {
+ if (!s.fixed) return eolSteps(s);
+ const name = alpineName(s);
+ return [
+ step(s, 'step.apk.upgrade', { fix: s.t('fix.alpine', { names: s.names.length ? s.names.join(', ') : name, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s), fixed: s.fixed, name },
+ `apk update\napk upgrade ${apkInstalled(name)}`),
+ step(s, 'step.apk.confirm', { fixed: s.fixed, branch: s.ver.branch }, `apk list --installed '${name}*'`),
+ restartAfterUpdate(s),
+ recheckStep(s),
+ ];
+}
+
+// Package update commands for distributions the data does not cover, when the command is certain.
+function otherUpdateCommand(vendor, ver) {
+ if (vendor === 'fedora') return "sudo dnf upgrade --refresh 'php*'";
+ if (/^(opensuse|sles|sled)/.test(vendor || '')) return `sudo zypper refresh\nsudo zypper update 'php${ver.major}*'`;
+ if (vendor === 'arch') return 'sudo pacman -Syu';
+ return null;
+}
+
+function otherSteps(s) {
+ const vendor = s.d.vendor || s.obs.os?.id || 'other';
+ const cmd = otherUpdateCommand(vendor, s.ver);
+ const vars = { vendor: s.obs.os?.pretty || vendor, fixed: s.fixed };
+ if (!s.fixed) {
+ // A distribution that backports fixes into its own PHP packages (SUSE): its update may carry them, and the move
+ // to a supported branch is still the real fix. A source build over its packages is not offered.
+ if (cmd && BACKPORTING_VENDORS.test(vendor)) {
+ return lead([step(s, 'step.other.updateEol', { vendor: vars.vendor, branch: s.ver.branch }, cmd), restartAfterUpdate(s),
+ eolSteps(s, { restart: false, backport: false })[0]], 1);
+ }
+ return eolSteps(s);
+ }
+ // A backport keeps the version, so running the probe again cannot show it there: the changelog check of the
+ // first step does.
+ if (BACKPORTING_VENDORS.test(vendor)) return [cmd ? step(s, 'step.other.update', vars, cmd) : step(s, 'step.other.updateNoCode', vars), restartAfterUpdate(s)];
+ return [
+ cmd ? step(s, 'step.other.update', vars, cmd) : step(s, 'step.other.updateNoCode', vars),
+ restartAfterUpdate(s),
+ recheckStep(s),
+ ];
+}
+
+// ── Upstream builds: self-built, panels, other repositories ─────────
+
+// The binary of this SAPI whose -i output shows the configure options, and whose -v shows its version: the name the
+// distribution gives it where the layout is known (php-fpm8.2 on Debian and Ubuntu, php-fpm83 on Alpine).
+function sapiBinary(s) {
+ const L = s.layout;
+ if (s.sapi === 'fpm-fcgi' && L?.fpmBinary) return L.fpmBinary;
+ if ((s.sapi === 'cgi-fcgi' || s.sapi === 'cgi') && L?.kind === 'deb') return s.ver.major >= 7 ? `php-cgi${s.ver.branch}` : 'php5-cgi';
+ return own({ 'fpm-fcgi': 'php-fpm', 'cgi-fcgi': 'php-cgi', cgi: 'php-cgi' }, s.sapi) || 'php';
+}
+
+function upstreamSteps(s) {
+ // With only a header, -v output or a version, the probe shows whether this is a distribution package first.
+ const out = s.fromOutside ? [probeStep(s, false)] : [];
+ if (!s.fixed) {
+ out.push(...eolSteps(s));
+ return lead(out, out.length > 1 && s.fromOutside ? 2 : 1);
+ }
+ const fixed = s.fixed;
+ out.push(step(s, s.fromOutside ? 'step.upstream.getUnknown' : 'step.upstream.get', { version: s.ver.raw, branch: s.ver.branch, fixed, extra: eolSoon(s) },
+ `curl -fsSLO https://www.php.net/distributions/php-${fixed}.tar.xz\nsha256sum php-${fixed}.tar.xz`));
+ out.push(step(s, 'step.upstream.build', { fixed },
+ `${sapiBinary(s)} -i | grep -i 'configure command'\ntar -xf php-${fixed}.tar.xz\ncd php-${fixed}\n./configure <current-options>\nmake -j"$(nproc)"\n${asRootIn(s)}make install`));
+ out.push(restartAfterUpdate(s));
+ // The binary that serves the site; the command-line php may be another build, and mod_php has no binary.
+ const binary = own({ 'fpm-fcgi': 'php-fpm', 'cgi-fcgi': 'php-cgi', cgi: 'php-cgi' }, s.sapi);
+ out.push(binary ? step(s, 'step.upstream.confirmBinary', { fixed, binary }, `${binary} -v`) : step(s, 'step.upstream.confirmProbe', { fixed }));
+ return out;
+}
+
+// An end-of-life branch with no fixed release: move to a supported branch, use a package that carries the
+// backport (the closest one on this very server first), or build the php-src backport branch. A build from source is
+// offered only for a PHP that is not a package: make install over a packaged PHP would overwrite the package's files
+// (deb.sury.org builds use the prefix /usr), and its service would keep running the packaged binary.
+function eolSteps(s, { restart = true, backport = s.path === 'upstream' } = {}) {
+ const { ver, ctx } = s;
+ const [target, alt] = supportedBranches(ctx);
+ const out = [step(s, 'step.eol.move', { branch: ver.branch, target, targetFixed: ctx.upstream.fixed[target], alt, altFixed: ctx.upstream.fixed[alt] })];
+ const host = hostPackage(s);
+ const carriers = eolCarriers(ctx, ver, host ? `${host.family} ${host.release}` : null);
+ if (host) {
+ const others = carriers.length ? ` ${s.t('eol.others', { branch: ver.branch, carriers: carriers.join('; ') })}` : '';
+ const pro = host.pro ? ` ${s.t('eol.hostPro')}` : '';
+ const sury = host.sury ? ` ${s.t('eol.hostSury', { src: host.src, fixed: host.fixed })}` : '';
+ const vars = { system: `${host.family} ${host.release}`, src: host.src, fixed: host.fixed, family: host.family, pkg: host.pkg, branch: ver.branch, extra: `${pro}${sury}${others}` };
+ if (host.foreign) {
+ // The installed packages of this very branch come from another repository: the distribution's own build of the
+ // same packages replaces them, which apt does only when asked for that exact version.
+ out.push(step(s, 'step.eol.hostSwitch', { ...vars, foreign: host.foreign, extra: `${pro}${others}` }, switchToDistroCode(host.src, host.foreign, host.fixed)));
+ } else {
+ out.push(host.code ? step(s, 'step.eol.host', vars, host.code) : step(s, 'step.eol.hostNoCode', vars));
+ }
+ } else {
+ out.push(carriers.length ? step(s, 'step.eol.distro', { branch: ver.branch, carriers: carriers.join('; ') }) : step(s, 'step.eol.vendor', { branch: ver.branch }));
+ }
+ const branch = ctx.upstream.backport_branches?.[ver.branch];
+ if (branch && backport) {
+ out.push(step(s, 'step.eol.backport', { branch: ver.branch, name: branch, version: ver.upstream },
+ `${sapiBinary(s)} -i | grep -i 'configure command'\ngit clone --depth 1 --branch ${branch} https://github.com/php/php-src.git\ncd php-src\n./buildconf --force\n./configure <current-options>\nmake -j"$(nproc)"\n${asRootIn(s)}make install`));
+ }
+ if (restart) out.push(restartAfterUpdate(s));
+ return lead(out, 1);
+}
+
+// The fixed PHP package of this server's own Debian or Ubuntu release, on this branch or the nearest newer one.
+function hostPackage(s) {
+ const os = s.obs.os || {};
+ const family = os.id === 'debian' || os.id === 'ubuntu' ? os.id : null;
+ const release = family ? (s.d.family === family && s.d.release) || os.versionId : null;
+ const rel = family && release ? own(s.ctx.distro[family], release) : null;
+ if (!rel || s.container) return null;
+ const list = Object.entries(rel.packages || {}).map(([src, entry]) => {
+ if (!s.ctx.cves.every((c) => entry?.fixed?.[c])) return null;
+ const fixed = s.ctx.cves.map((c) => entry.fixed[c]).sort(compareDebian).pop();
+ const branch = parsePhpVersion(debUpstream(fixed))?.branch;
+ return branch && compareBranch(branch, s.ver.branch) >= 0 ? { src, fixed, branch, pro: Boolean(entry.pro) } : null;
+ }).filter(Boolean).sort((a, b) => compareBranch(a.branch, b.branch));
+ if (!list.length) return null;
+ const { src, fixed, pro } = list[0];
+ const pkg = own({ apache2handler: `libapache2-mod-${src}`, 'cgi-fcgi': `${src}-cgi`, cgi: `${src}-cgi`, cli: `${src}-cli` }, s.sapi) || `${src}-fpm`;
+ // deb.sury.org's packages carry the epoch 1:, so apt prefers them over the distribution's while that repository is
+ // on: the step says to turn it off, and apt-cache policy shows which version apt would install.
+ const sury = isSuryBuild(s.ver.raw) || s.obs.packages.some((p) => isSuryBuild(p.version));
+ // This very branch is the distribution's fixed package, but another repository's build of it is installed.
+ const mine = src === srcFor(s.ver) ? pickDebPackage(s.obs, s.ver, s.sapi, s.build) : null;
+ const foreign = mine && !sameDeb(mine.version, fixed) ? mine.version : null;
+ // Installing the Apache module package enables it right away, next to the PHP module loaded now, so no
+ // command is given for it.
+ const code = s.sapi === 'apache2handler' ? null : `sudo apt-get update${sury ? `\napt-cache policy ${pkg}` : ''}\nsudo apt-get install ${pkg}`;
+ return { family: familyName(family), release, src, fixed, pro, pkg, code, sury, foreign };
+}
+
+// Two supported branches to move to: those with a fixed release and no announced end of life, newest first,
+// leaving out the newest branch when there are more than two (applications usually need the most time for it).
+function supportedBranches(ctx) {
+ const eol = ctx.upstream.eol || {};
+ let list = Object.keys(ctx.upstream.fixed).filter((b) => !eol[b]).sort(compareBranch).reverse();
+ if (list.length > 2) list = list.slice(1);
+ return [list[0], list[1] || list[0]];
+}
+
+// Distribution packages in the data that carry both fixes for this branch: "Ubuntu 22.04 (php8.1 8.1.2-1ubuntu2.28)".
+function eolCarriers(ctx, ver, skip = null) {
+ const out = [];
+ const src = srcFor(ver);
+ for (const [family, name] of [['debian', 'Debian'], ['ubuntu', 'Ubuntu']]) {
+ for (const [release, rel] of Object.entries(ctx.distro[family] || {})) {
+ const entry = rel?.packages?.[src];
+ if (!entry || !ctx.cves.every((c) => entry.fixed?.[c]) || `${name} ${release}` === skip) continue;
+ const fixed = ctx.cves.map((c) => entry.fixed[c]).sort(compareDebian).pop();
+ if (parsePhpVersion(debUpstream(fixed))?.branch !== ver.branch) continue;
+ out.push(`${name} ${release} (${src} ${fixed}${entry.pro ? ', Ubuntu Pro' : ''})`);
+ }
+ }
+ for (const [release, rel] of Object.entries(ctx.distro.redhat || {})) {
+ const advs = (rel?.advisories || []).filter((x) => x?.advisory && advisoryBranch(x.package) === ver.branch);
+ if (!ctx.cves.every((c) => advs.some((x) => x.cve === c))) continue;
+ out.push(`RHEL ${release} (${advs[0].package}, ${[...new Set(advs.map((x) => x.advisory))].join(', ')})`);
+ }
+ return out;
+}
+
+// " PHP 8.2 reaches end of life on 31 December 2026, so plan the move to 8.3 or 8.4 as well." for a branch whose
+// end of life is announced in upstream.json; empty otherwise.
+function eolSoon(s) {
+ const date = s.ver && s.fixed ? s.ctx.upstream.eol?.[s.ver.branch] : null;
+ if (!date) return '';
+ const [high, low] = supportedBranches(s.ctx);
+ const past = String(s.ctx.upstream.updated || '') >= date;
+ return ` ${s.t(past ? 'branch.eolPast' : 'branch.eolSoon', { branch: s.ver.branch, date: formatDate(date, s.ctx.lang), low, high })}`;
+}
+
+// ── Containers ──────────────────────────────────────────────────────
+
+function containerSteps(s) {
+ // The official image keeps PHP outside the package manager, whatever its base system.
+ if (s.official && !s.pkgs.length && ['upstream', 'alpine', 'other'].includes(s.path)) return dockerSteps(s);
+ const out = [];
+ let pro = false;
+ if (['deb', 'sury', 'rhel', 'remi', 'alpine'].includes(s.path)) {
+ if (s.r.verdict === 'pending') {
+ const D = debInfo(s);
+ out.push(step(s, 'step.pending.watch', { family: D.family, release: D.release, src: D.src }, trackerUrls(s.d.family, s.ctx.cves)));
+ return lead(out, 1);
+ }
+ const D = s.path === 'deb' ? debInfo(s) : null;
+ if (D?.pro && D.release !== '14.04') {
+ // The fix is only in Ubuntu Pro, which an image build sees only while it is attached.
+ pro = true;
+ out.push(step(s, 'step.container.updatePro', { fix: containerFix(s) }, containerProLines(s)));
+ } else {
+ out.push(step(s, 'step.container.update', { fix: containerFix(s) }, containerRunLine(s)));
+ }
+ } else if (s.fixed && s.path === 'other') {
+ out.push(step(s, 'step.container.other', { vendor: s.obs.os?.pretty || s.d.vendor || s.obs.os?.id || 'other', fixed: s.fixed }));
+ } else if (s.fixed) {
+ out.push(step(s, 'step.container.upstream', { version: s.ver.raw, branch: s.ver.branch, fixed: s.fixed, extra: eolSoon(s) }));
+ } else {
+ const [target, alt] = supportedBranches(s.ctx);
+ out.push(step(s, 'step.container.eol', { version: s.ver.raw, branch: s.ver.branch, target, targetFixed: s.ctx.upstream.fixed[target], alt, altFixed: s.ctx.upstream.fixed[alt] }));
+ }
+ out.push(rebuildStep(s, pro));
+ out.push(containerConfirm(s));
+ return lead(out, 1);
+}
+
+// What fixes the PHP of a container that comes from distribution packages, as one sentence.
+function containerFix(s) {
+ if (s.path === 'deb') {
+ const D = debInfo(s);
+ if (D.fixed && D.installed) return s.t('fix.deb', { pkg: D.installed, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
+ if (D.fixed) return s.t('fix.debRunning', { version: s.ver.raw, family: D.family, release: D.release, src: D.src, fixed: D.fixed });
+ return s.t('fix.debOpen', { family: D.family, src: D.src });
+ }
+ if (s.path === 'rhel') return rhelContext(s, rhelInfo(s));
+ const pkg = s.pkgs[0];
+ const label = pkg ? pkgLabel(pkg) : `PHP ${s.ver.raw}`;
+ if (!s.fixed) return s.t('reason.eol', { branch: s.ver.branch });
+ if (s.path === 'sury') return s.t('fix.sury', { pkg: label, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
+ if (s.path === 'remi') return s.t('fix.remi', { pkg: label, branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
+ return s.t('fix.alpine', { names: s.names.join(', ') || alpineName(s), branch: s.ver.branch, fixed: s.fixed }) + eolSoon(s);
+}
+
+// The Dockerfile line that updates this PHP's packages in the image.
+function containerRunLine(s) {
+ if (s.path === 'deb' || s.path === 'sury') {
+ return s.names.length ? `RUN apt-get update && apt-get install -y --only-upgrade ${s.names.join(' ')}` : 'RUN apt-get update && apt-get -y upgrade';
+ }
+ if (s.path === 'rhel' || s.path === 'remi') {
+ const target = s.names.length ? s.names.join(' ') : rhelInfo(s).glob;
+ return (rhelMajor(s.d) || 9) < 8 ? `RUN yum -y update ${target}` : `RUN dnf -y upgrade --refresh ${target}`;
+ }
+ if (s.path === 'alpine') return `RUN apk upgrade --no-cache ${apkInstalled(alpineName(s))}`;
+ return null;
+}
+
+// The Dockerfile lines that attach Ubuntu Pro for the build only (the token comes in as a build secret and is
+// never stored in the image), update the PHP packages, and detach again.
+function containerProLines(s) {
+ const names = s.names.length ? s.names.join(' ') : `$(dpkg -l '${debSrc(s)}-*' 'libapache2-mod-${debSrc(s)}' 2>/dev/null | awk '/^ii/ {print $2}')`;
+ return [
+ 'RUN --mount=type=secret,id=pro-attach-config \\',
+ ' apt-get update && apt-get install -y --no-install-recommends ubuntu-advantage-tools ca-certificates && \\',
+ ' pro attach --attach-config /run/secrets/pro-attach-config && \\',
+ ` apt-get update && apt-get install -y --only-upgrade ${names} && \\`,
+ ' pro detach --assume-yes',
+ ].join('\n');
+}
+
+function containerConfirm(s) {
+ let code = null;
+ if (s.path === 'deb' || s.path === 'sury') code = `docker exec <container> ${dpkgVersions(s, debSrc(s))}`;
+ else if (s.path === 'remi') code = `docker exec <container> rpm -q ${s.names.length ? s.names.join(' ') : rhelInfo(s).main}`;
+ else if (s.path === 'alpine') code = `docker exec <container> apk list --installed '${alpineName(s)}*'`;
+ else if (s.path === 'rhel') {
+ // Red Hat backports the fix without changing the upstream version, so the changelog shows it.
+ const R = rhelInfo(s);
+ const query = `docker exec <container> rpm -q ${s.names.length ? s.names.join(' ') : R.main}`;
+ // A fix that is a new upstream release shows in the version, not in the changelog.
+ if (R.rebase) return step(s, 'step.container.confirmFixed', { fixed: R.fixedNvr || s.fixed }, query);
+ const grep = `docker exec <container> sh -c "rpm -q --changelog ${R.main} | grep ${CVE_GREP(R.grepCves)}"`;
+ const cves = R.grepCves.join(', ');
+ return R.fixedNvr ? step(s, 'step.container.confirmChangelogFixed', { pkg: R.main, fixed: R.fixedNvr, cves }, `${query}\n${grep}`)
+ : step(s, 'step.container.confirmChangelog', { pkg: R.main, cves }, `${query}\n${grep}`);
+ }
+ if (!code) {
+ const fixed = s.fixed || s.ctx.upstream.fixed[supportedBranches(s.ctx)[0]];
+ return step(s, 'step.docker.confirm', { fixed }, "docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'");
+ }
+ const fixed = s.path === 'deb' ? debInfo(s).fixed : s.fixed;
+ return fixed ? step(s, 'step.container.confirmFixed', { fixed }, code) : step(s, 'step.container.confirm', {}, code);
+}
+
+// Rebuild with --pull and recreate: plain Docker, and Compose for services that build an image and for those
+// that use one directly (image: in compose.yaml).
+function rebuildStep(s, pro = false) {
+ const secret = pro ? ' --secret id=pro-attach-config,src=pro-attach-config.yaml' : '';
+ return step(s, 'step.docker.rebuild', {},
+ `# Docker\ndocker build --pull${secret} -t <your-image> .\n# Docker Compose\ndocker compose build --pull\ndocker compose pull --ignore-buildable\ndocker compose up -d`);
+}
+
+// The official PHP image: the exact tag of the fixed release in the same variant, rebuild, confirm.
+function dockerSteps(s) {
+ const { ver } = s;
+ const out = [];
+ let expect = s.fixed;
+ if (!s.fixed) {
+ const [target, alt] = supportedBranches(s.ctx);
+ expect = s.ctx.upstream.fixed[target];
+ const v = dockerVariant(s, expect);
+ out.push(step(s, 'step.docker.eol', { version: ver.raw, branch: ver.branch, targetFixed: expect, altFixed: s.ctx.upstream.fixed[alt], extra: v.extra },
+ `FROM php:${expect}${v.sapi ? `-${v.sapi}` : '-<variant>'}${v.os}`));
+ } else if (s.sapi === 'frankenphp') {
+ out.push(step(s, 'step.docker.frankenphp', { version: ver.raw, branch: ver.branch, fixed: s.fixed }));
+ } else {
+ const v = dockerVariant(s, s.fixed);
+ const extra = `${v.extra}${eolSoon(s)}`;
+ if (v.sapi) {
+ const suffix = `-${v.sapi}${v.os}`;
+ out.push(step(s, 'step.docker.tag', { version: ver.raw, branch: ver.branch, fixed: s.fixed, variant: v.kept ? suffix : `-${v.sapi}`, floating: `${ver.branch}${suffix}`, extra },
+ `FROM php:${s.fixed}${suffix}`));
+ } else {
+ out.push(step(s, 'step.docker.tagNoVariant', { version: ver.raw, branch: ver.branch, fixed: s.fixed, extra }, `FROM php:${s.fixed}-<variant>${v.os}`));
+ }
+ }
+ out.push(rebuildStep(s));
+ out.push(step(s, 'step.docker.confirm', { fixed: expect }, "docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'"));
+ return lead(out, 1);
+}
+
+// The variant part of an official PHP image tag: the SAPI (apache, fpm, cli) and the base system: the Alpine
+// release or Debian codename the image uses now when tags for it are still published, otherwise the nearest newer
+// one, with a sentence that says so.
+function dockerVariant(s, fixed) {
+ const sapi = own({ apache2handler: 'apache', 'fpm-fcgi': 'fpm', cli: 'cli' }, s.sapi) || null;
+ const os = s.obs.os || {};
+ let suffix = '';
+ let extra = '';
+ let kept = true;
+ if (os.id === 'alpine') {
+ const rel = /^(\d+\.\d+)/.exec(os.versionId || '')?.[1] || null;
+ if (rel && DOCKER_ALPINE.includes(rel)) suffix = `-alpine${rel}`;
+ else {
+ suffix = '-alpine';
+ if (rel) extra = ` ${s.t('docker.alpineFloating', { release: rel, example: `php:${fixed}-${sapi || 'cli'}-alpine${DOCKER_ALPINE[DOCKER_ALPINE.length - 1]}` })}`;
+ }
+ } else if (os.id === 'debian') {
+ const known = own(s.ctx.distro.debian, os.versionId)?.codename;
+ const number = Number.parseInt(os.versionId, 10) || own(DEBIAN_RELEASES, os.codename) || own(DEBIAN_RELEASES, known) || null;
+ const codename = os.codename || known || Object.keys(DEBIAN_RELEASES).find((k) => DEBIAN_RELEASES[k] === number) || null;
+ const hit = DOCKER_DEBIAN.find((x) => x.codename === codename || x.release === number);
+ if (hit) suffix = `-${hit.codename}`;
+ else if (number && number < DOCKER_DEBIAN[0].release) {
+ const to = DOCKER_DEBIAN[0];
+ suffix = `-${to.codename}`;
+ kept = false;
+ extra = ` ${s.t('docker.oldDebian', { from: number, fromName: codename || number, to: to.release, toName: to.codename })}`;
+ } else if (codename) extra = ` ${s.t('docker.otherDebian', { codename })}`;
+ }
+ return { sapi, os: suffix, extra, kept };
+}
+
+// ── Restart ─────────────────────────────────────────────────────────
+
+// The restart step after an update: the exact command when the service is known, a marked placeholder for a
+// self-built PHP-FPM, otherwise the advice alone.
+function restartAfterUpdate(s) {
+ if (s.container) return null;
+ const plan = restartPlan(s) || restartPlaceholder(s);
+ if (plan) return step(s, 'action.restartAfterUpdate', { extra: plan.extra }, plan.code);
+ return step(s, 'action.restartAfterUpdateGeneric');
+}
+
+// A restart as a precaution, when the installed package has the fix but the running processes could not be checked.
+function restartIfUnsure(s) {
+ const plan = s.container ? null : restartPlan(s);
+ return plan ? step(s, 'step.restart.ifUnsure', { extra: plan.extra }, plan.code) : null;
+}
+
+// The services to restart, as commands for the init system of this distribution: those of the processes still
+// using old files, else those of every process the server script saw running this build (Apache with mod_php and
+// PHP-FPM can both run it), else the one of the SAPI. null when the layout is unknown (self-built, panels, others).
+function restartPlan(s) {
+ const L = s.layout;
+ if (!L || L.kind === 'docker') return null;
+ const stale = s.info.stale || [];
+ const comms = stale.length ? stale : s.info.running || [];
+ if (comms.length) {
+ const services = [...new Set(comms.map((c) => serviceForComm(L, c)))];
+ if (!services.includes(null)) {
+ return { code: services.map((x) => serviceCommand(L, 'restart', x)).join('\n'), extra: services.includes(L.apacheService) ? s.t('restart.modPhp') : '' };
+ }
+ if (stale.length) return null;
+ }
+ if (s.sapi === 'apache2handler') return { code: serviceCommand(L, 'restart', L.apacheService), extra: s.t('restart.modPhp') };
+ if (s.sapi === 'fpm-fcgi') return { code: serviceCommand(L, 'restart', L.fpmService), extra: '' };
+ if (s.sapi === 'cgi-fcgi' || s.sapi === 'cgi') {
+ // php-cgi is started by the web server (mod_fcgid) or a FastCGI process manager, not by a service of its own.
+ return {
+ code: `${serviceCommand(L, 'restart', L.apacheService)} # Apache mod_fcgid\nps -eo pid,lstart,args | grep -E '[p]hp-cgi|[c]gi-bin/php'`,
+ extra: s.t('restart.cgi'),
+ };
+ }
+ return {
+ code: `${serviceCommand(L, 'restart', L.apacheService)} # Apache mod_php\n${serviceCommand(L, 'restart', L.fpmService)} # PHP-FPM`,
+ extra: s.t('restart.choose'),
+ };
+}
+
+// The service names a distribution gives Apache and PHP-FPM, where they are fixed. A self-built PHP-FPM has a
+// service of the admin's choosing.
+function serviceNames(s) {
+ const vendor = s.d.vendor || s.obs.os?.id || '';
+ if (s.d.family === 'debian' || s.d.family === 'ubuntu') return { apache: 'apache2', fpm: null };
+ if (s.d.family === 'rhel') return { apache: 'httpd', fpm: null };
+ if (s.d.family === 'other' && s.path === 'other') {
+ if (vendor === 'fedora' || vendor === 'arch') return { apache: 'httpd', fpm: 'php-fpm' };
+ if (/^(opensuse|sles|sled)/.test(vendor)) return { apache: 'apache2', fpm: 'php-fpm' };
+ }
+ return { apache: null, fpm: null };
+}
+
+// A restart command for a PHP whose layout is not known: the names the distribution fixes, else a marked placeholder.
+function restartPlaceholder(s) {
+ const names = serviceNames(s);
+ if (s.sapi === 'apache2handler') {
+ const apache = names.apache || '<apache-service>';
+ const cmd = s.d.family === 'rhel' && (rhelMajor(s.d) || 9) < 7 ? `sudo service ${apache} restart` : `sudo systemctl restart ${apache}`;
+ return { code: cmd, extra: s.t('restart.modPhp') };
+ }
+ if (s.sapi === 'fpm-fcgi') return { code: `sudo systemctl restart ${names.fpm || '<php-fpm-service>'}`, extra: '' };
+ return null;
+}
+
+function serviceCommand(L, action, service) {
+ if (L.init === 'openrc') return `rc-service ${service} ${action}`;
+ if (L.init === 'sysv') return `sudo service ${service} ${action}`;
+ return `sudo systemctl ${action} ${service}`;
+}
+
+// The service that starts a process the server script saw, in this distribution's naming.
+function serviceForComm(L, comm) {
+ if (L.kind === 'deb') {
+ const m = /^php-fpm(\d+\.\d+)$/.exec(comm);
+ if (m) return `php${m[1]}-fpm`;
+ if (comm === 'php5-fpm') return 'php5-fpm';
+ return comm === 'apache2' ? 'apache2' : null;
+ }
+ if (L.kind === 'rhel' || L.kind === 'scl') {
+ if (comm === 'php-fpm') return L.fpmService;
+ return comm === 'httpd' ? 'httpd' : null;
+ }
+ if (L.kind === 'alpine') {
+ const m = /^php-fpm(\d+)$/.exec(comm);
+ if (m) return `php-fpm${m[1]}`;
+ return comm === 'httpd' || comm === 'apache2' ? 'apache2' : null;
+ }
+ return null;
+}
+
+// Where this PHP keeps its files and what its services are called, for the distributions whose layout is known.
+function phpLayout(s) {
+ const { d, ver, path } = s;
+ const cp = s.obs.configPath || '';
+ const sapiDir = own({ apache2handler: 'apache2', 'fpm-fcgi': 'fpm', 'cgi-fcgi': 'cgi', cgi: 'cgi', cli: 'cli' }, s.sapi);
+ if (s.official) return { kind: 'docker' };
+ // A packaged build judged on upstream releases (its version already has the fix) keeps its distribution layout.
+ const by = (manager) => s.pkgs.some((p) => p.manager === manager);
+ const underEtc = /^\/etc(\/|$)/.test(cp);
+ if ((d.family === 'debian' || d.family === 'ubuntu') && (path === 'deb' || path === 'sury' || by('dpkg') || /^\/etc\/php5?\//.test(cp))) {
+ const base = ver.major >= 7 ? `/etc/php/${ver.branch}` : '/etc/php5';
+ // A release that is not known (Debian testing has none) is a current, systemd one.
+ const old = (d.family === 'ubuntu' && d.release === '14.04') || (d.family === 'debian' && d.release && Number(d.release) <= 7);
+ return {
+ kind: 'deb',
+ init: old ? 'sysv' : 'systemd',
+ confDir: sapiDir ? `${base}/${sapiDir}/conf.d` : null,
+ poolDir: `${base}/fpm/pool.d`,
+ fpmService: `${srcFor(ver)}-fpm`,
+ fpmBinary: ver.major >= 7 ? `php-fpm${ver.branch}` : 'php5-fpm',
+ apacheService: 'apache2',
+ apacheFile: '/etc/apache2/conf-available/php2shell-mitigation.conf',
+ apacheEnable: 'sudo a2enconf php2shell-mitigation',
+ apacheTest: 'sudo apachectl configtest',
+ };
+ }
+ const rhelLike = { apacheService: 'httpd', apacheFile: '/etc/httpd/conf.d/php2shell-mitigation.conf', apacheEnable: null, apacheTest: 'sudo apachectl configtest' };
+ if (d.family === 'rhel' && (path === 'rhel' || path === 'remi' || by('rpm') || (path === 'upstream' && underEtc))) {
+ const init = (rhelMajor(d) || 9) < 7 ? 'sysv' : 'systemd';
+ const scl = sclName(s);
+ if (scl) {
+ return { ...rhelLike, init, kind: 'scl', confDir: `/etc/opt/remi/${scl}/php.d`, poolDir: `/etc/opt/remi/${scl}/php-fpm.d`, fpmService: `${scl}-php-fpm`, fpmBinary: `/opt/remi/${scl}/root/usr/sbin/php-fpm` };
+ }
+ return { ...rhelLike, init, kind: 'rhel', confDir: '/etc/php.d', poolDir: '/etc/php-fpm.d', fpmService: 'php-fpm', fpmBinary: 'php-fpm' };
+ }
+ // Fedora packages PHP the way RHEL does.
+ if (d.family === 'other' && d.vendor === 'fedora' && path === 'other' && (!cp || underEtc)) {
+ return { ...rhelLike, init: 'systemd', kind: 'rhel', confDir: '/etc/php.d', poolDir: '/etc/php-fpm.d', fpmService: 'php-fpm', fpmBinary: 'php-fpm' };
+ }
+ if (d.family === 'alpine' && (path === 'alpine' || by('apk'))) {
+ const name = alpineName(s);
+ const n = name.slice(3);
+ return {
+ kind: 'alpine',
+ init: 'openrc',
+ confDir: `/etc/${name}/conf.d`,
+ poolDir: `/etc/${name}/php-fpm.d`,
+ fpmService: `php-fpm${n}`,
+ fpmBinary: `php-fpm${n}`,
+ apacheService: 'apache2',
+ apacheFile: '/etc/apache2/conf.d/php2shell-mitigation.conf',
+ apacheEnable: null,
+ apacheTest: 'httpd -t',
+ };
+ }
+ return null;
+}
+
+// The Alpine package prefix of this PHP: php83 (from the installed packages, the configuration path or the version).
+function alpineName(s) {
+ const pkg = s.pkgs.map((p) => /^(php\d*)/.exec(p.name)).find(Boolean);
+ if (pkg) return pkg[1];
+ const path = /^\/etc\/(php\d+)$/.exec(s.obs.configPath || '');
+ return path ? path[1] : `php${s.ver.major}${s.ver.minor}`;
+}
+
+// ── Confirmation, mitigations, hardening ────────────────────────────
+
+function recheckStep(s) {
+ return s.obs.sources.includes('servercheck') ? step(s, 'step.recheckServer', {}, serverScript(s)) : step(s, 'step.recheck');
+}
+
+// The interim mitigations of the official advisory, ready to paste where the place is known: an Apache mod_php
+// configuration file and a PHP-FPM pool file (both when the server script saw both running this PHP), an ini file
+// for CGI, the official image's ini directory, or a self-built PHP's php.ini; otherwise as text. They never replace
+// the fix, and the text says so.
+function mitigationSteps(s, whenKey) {
+ const when = s.t(whenKey);
+ const ini = s.obs.ini;
+ const php85 = s.ver?.branch === '8.5';
+ const sessionOff = s.obs.extensions && s.obs.extensions.session === false;
+ const inPlace = ini && isOff(ini['session.upload_progress.enabled']) && (!php85 || isOff(ini.fatal_error_backtraces));
+ const generic = [step(s, s.r.mitigations.length ? 'action.applyMitigations' : 'action.applyMitigationsAdvisory', { when })];
+ if (!s.ver || sessionOff || inPlace) return generic;
+ const extra = php85 ? s.t('mitigate.fatalBacktraces') : '';
+ const settings = ['session.upload_progress.enabled = 0', ...(php85 ? ['fatal_error_backtraces = 0'] : [])];
+ if (s.official && s.container) {
+ const file = '"$PHP_INI_DIR/conf.d/zz-php2shell-mitigation.ini"';
+ const lines = settings.join('\\n');
+ return [step(s, 'step.mitigate.docker', { when, extra }, [
+ `docker exec -u 0 <container> sh -c 'printf "${lines}\\n" > ${file}'`,
+ 'docker restart <container>',
+ '# Dockerfile',
+ `RUN printf '${lines}\\n' > ${file}`,
+ ].join('\n'))];
+ }
+ const L = s.layout;
+ if (L && L.kind !== 'docker' && !s.container) {
+ const running = s.info.running || [];
+ const apache = s.sapi === 'apache2handler' || running.some((c) => c === 'apache2' || c === 'httpd');
+ const fpm = s.sapi === 'fpm-fcgi' || running.some((c) => /^php-fpm/.test(c));
+ const out = [];
+ if (apache && L.apacheFile) {
+ const module = s.ver.major >= 8 ? 'php_module' : s.ver.major === 7 ? 'php7_module' : 'php5_module';
+ const lines = [`<IfModule ${module}>`, ' php_admin_flag session.upload_progress.enabled off'];
+ if (php85) lines.push(' php_admin_flag fatal_error_backtraces off');
+ lines.push(' # php_admin_value memory_limit -1', '</IfModule>');
+ const code = [`${asRoot(L, `tee ${L.apacheFile}`)} >/dev/null <<'EOF'`, ...lines, 'EOF'];
+ if (L.apacheEnable) code.push(L.apacheEnable);
+ code.push(`${L.apacheTest} && ${serviceCommand(L, 'reload', L.apacheService)}`);
+ out.push(step(s, 'step.mitigate.apache', { when, extra }, code.join('\n')));
+ }
+ if (fpm && L.poolDir) {
+ const lines = ['[www]', 'php_admin_flag[session.upload_progress.enabled] = off'];
+ if (php85) lines.push('php_admin_flag[fatal_error_backtraces] = off');
+ lines.push('; php_admin_value[memory_limit] = -1');
+ const reload = serviceCommand(L, 'reload', L.fpmService);
+ const code = [`${asRoot(L, `tee ${L.poolDir}/zz-php2shell-mitigation.conf`)} >/dev/null <<'EOF'`, ...lines, 'EOF',
+ L.fpmBinary ? `${asRoot(L, `${L.fpmBinary} -t`)} && ${reload}` : reload];
+ // A reload makes PHP-FPM run its binary from disk again. When its processes still use files from before the
+ // last package change, and that change may have been a downgrade, the step says so.
+ const staleFpm = (s.info.stale || []).filter((c) => /^php\d*-fpm/.test(c));
+ const fpmExtra = staleFpm.length ? `${extra}${s.t('mitigate.staleReload', { names: staleFpm.join(', ') })}` : extra;
+ // After the Apache step, the same text again would only repeat it.
+ out.push(out.length ? step(s, 'step.mitigate.fpmAlso', { extra: fpmExtra }, code.join('\n')) : step(s, 'step.mitigate.fpm', { when, extra: fpmExtra }, code.join('\n')));
+ }
+ if (!out.length && (s.sapi === 'cgi-fcgi' || s.sapi === 'cgi') && L.confDir) {
+ const plan = restartPlan(s);
+ const file = `${L.confDir}/99-php2shell-mitigation.ini`;
+ const code = settings.map((x, i) => `echo '${x}' | ${asRoot(L, `tee ${i ? '-a ' : ''}${file}`)} >/dev/null`);
+ out.push(plan ? step(s, 'step.mitigate.ini', { when, extra, file }, [...code, plan.code].join('\n'))
+ : step(s, 'step.mitigate.iniNoRestart', { when, extra, file }, code.join('\n')));
+ }
+ if (out.length) return out;
+ }
+ // A self-built PHP reads php.ini in its own configuration directory.
+ if (!s.container && s.path === 'upstream' && s.obs.configPath && !/^\/etc(\/|$)/.test(s.obs.configPath)) {
+ const file = `${s.obs.configPath.replace(/\/+$/, '')}/php.ini`;
+ const plan = restartPlaceholder(s);
+ const code = settings.map((x) => `echo '${x}' | ${asRootIn(s)}tee -a ${file} >/dev/null`);
+ return [plan ? step(s, 'step.mitigate.ini', { when, extra, file }, [...code, plan.code].join('\n'))
+ : step(s, 'step.mitigate.iniNoRestart', { when, extra, file }, code.join('\n'))];
+ }
+ return generic;
+}
+
+function asRoot(L, cmd) {
+ return L.init === 'openrc' ? cmd : `sudo ${cmd}`;
+}
+
+// expose_php = Off for this PHP: an ini file in the directory this SAPI reads, then a restart of its service.
+function exposeStep(s) {
+ if (s.official) return step(s, 'step.hardening.exposeDocker', {}, 'RUN echo \'expose_php = Off\' > "$PHP_INI_DIR/conf.d/zz-hardening.ini"');
+ const L = s.layout;
+ const plan = L && L.confDir ? restartPlan(s) : null;
+ if (plan && !s.container) {
+ return step(s, 'step.hardening.expose', {}, `echo 'expose_php = Off' | ${asRoot(L, `tee ${L.confDir}/99-hardening.ini`)}\n${plan.code}`);
+ }
+ if (s.obs.configPath) return step(s, 'step.hardening.exposePath', { path: s.obs.configPath });
+ return step(s, 'step.hardening.exposeManual');
+}
+
+// When patched is true, the interim mitigations meant for unpatched servers are shown for reference only.
+export function evaluateMitigations(ini, extensions, ver, patched = false, lang = 'en') {
+ const t = translator(lang);
+ const items = [];
+ const sessionLoaded = !extensions || extensions.session !== false;
+ const add = (name, value, status, note) => items.push({
+ name,
+ value: value === null || value === undefined ? t('mit.value.notSet') : value === '' ? t('mit.value.emptyOff') : String(value),
+ status,
+ note: t(note),
+ });
+
+ const mem = ini.memory_limit;
+ if (patched) {
+ add('memory_limit', mem, 'info', mem === '-1' ? 'mit.memoryLimit.patchedUnlimited' : 'mit.noChangeNeeded');
+ } else {
+ add('memory_limit', mem, mem === '-1' ? 'ok' : 'warn', mem === '-1' ? 'mit.memoryLimit.unlimited' : 'mit.memoryLimit.suggest');
+ }
+
+ const up = ini['session.upload_progress.enabled'];
+ if (!sessionLoaded) {
+ add('session.upload_progress.enabled', up, 'ok', 'mit.uploadProgress.noSession');
+ } else {
+ add('session.upload_progress.enabled', up, isOff(up) ? 'ok' : patched ? 'info' : 'warn',
+ isOff(up)
+ ? 'mit.uploadProgress.off'
+ : patched
+ ? 'mit.uploadProgress.patchedOn'
+ : 'mit.uploadProgress.suggest');
+ const auto = ini['session.auto_start'];
+ if (!patched || !isOff(auto)) {
+ add('session.auto_start', auto, isOff(auto) ? 'ok' : patched ? 'info' : 'warn',
+ isOff(auto)
+ ? 'mit.autoStart.off'
+ : patched
+ ? 'mit.noChangeNeeded'
+ : 'mit.autoStart.suggest');
+ }
+ }
+
+ const fb = ini.fatal_error_backtraces;
+ if (!patched && fb !== null && fb !== undefined && ver && ver.branch === '8.5') {
+ add('fatal_error_backtraces', fb, isOff(fb) ? 'ok' : 'warn', isOff(fb) ? 'mit.fatalBacktraces.off' : 'mit.fatalBacktraces.suggest');
+ }
+
+ const nest = ini.max_input_nesting_level;
+ if (nest !== null && nest !== undefined) {
+ add('max_input_nesting_level', nest, 'info', 'mit.nestingLevel');
+ }
+ if (ini.post_max_size !== null && ini.post_max_size !== undefined) {
+ add('post_max_size', ini.post_max_size, 'info', 'mit.postMaxSize');
+ }
+ if (ini.expose_php !== null && ini.expose_php !== undefined) {
+ add('expose_php', ini.expose_php, isOff(ini.expose_php) ? 'ok' : 'info', isOff(ini.expose_php) ? 'mit.exposePhp.off' : 'mit.exposePhp.on');
+ }
+ return items;
+}
+
+export function sapiExposure(sapi, lang = 'en') {
+ const t = translator(lang);
+ switch (sapi) {
+ case 'apache2handler':
+ return { level: 'high', text: t('exposure.apache2handler') };
+ case 'frankenphp':
+ return { level: 'high', text: t('exposure.frankenphp') };
+ case 'fpm-fcgi':
+ return { level: 'medium', text: t('exposure.fpm') };
+ case 'litespeed':
+ return { level: 'medium', text: t('exposure.litespeed') };
+ case 'cli':
+ return { level: 'info', text: t('exposure.cli') };
+ default:
+ return { level: 'medium', text: t('exposure.other', { sapi }) };
+ }
+}
+
+function pickDebPackage(obs, ver, sapi, build) {
+ const pool = build ? build.packages : obs.packages;
+ const same = pool.filter((p) => p.manager === 'dpkg' && debSource(p) === srcFor(ver));
+ const prefer = own({
+ apache2handler: /^libapache2-mod-php/,
+ 'fpm-fcgi': /-fpm$/,
+ 'cgi-fcgi': /-cgi$/,
+ cgi: /-cgi$/,
+ cli: /-cli$/,
+ }, sapi);
+ return (prefer && same.find((p) => prefer.test(p.name))) || same.find((p) => /-common$/.test(p.name)) || same[0] || null;
+}
+
+function debSource(p) {
+ return p.source || p.name.replace(/^libapache2-mod-/, '').replace(/-(common|fpm|cli|cgi)$/, '');
+}
+
+// The Debian/Ubuntu source package name of a PHP branch: php8.2, php7.4, php5.
+function srcFor(ver) {
+ return ver.major >= 7 ? `php${ver.branch}` : `php${ver.major}`;
+}
+
+// The upstream version of a release's fixed package ("8.3.6" for 8.3.6-0ubuntu0.24.04.13), or null.
+function entryUpstream(entry, cves) {
+ const fixed = cves.map((c) => entry?.fixed?.[c]).find(Boolean);
+ return fixed ? debUpstream(fixed) : null;
+}
+
+// Releases that may ship exactly this version with the fixes backported, as labels: Debian and Ubuntu releases
+// whose package has this upstream version, and RHEL releases with a fix that keeps the version number (an
+// advisory for exactly this version, or a module stream fix for this branch: a stream keeps the upstream version it
+// started with, which the data does not give, so any version of that branch below the upstream fix may be one).
+function sameUpstreamReleases(ctx, ver) {
+ const out = [];
+ for (const [family, name] of [['debian', 'Debian'], ['ubuntu', 'Ubuntu']]) {
+ for (const [release, rel] of Object.entries(ctx.distro[family] || {})) {
+ if (entryUpstream(rel.packages?.[srcFor(ver)], ctx.cves) === ver.upstream) out.push(`${name} ${release}`);
+ }
+ }
+ for (const [release, rel] of Object.entries(ctx.distro.redhat || {})) {
+ const keeps = (rel.advisories || []).some((x) => {
+ const nevra = /^php[\d.]*-(?:\d+:)?(\d+\.\d+\.\d+)-/.exec(x.package || '');
+ if (nevra) return nevra[1] === ver.upstream;
+ const stream = /^php:(\d+\.\d+)-/.exec(x.package || '');
+ return Boolean(stream) && stream[1] === ver.branch;
+ });
+ if (keeps) out.push(`RHEL ${release}`);
+ }
+ return out;
+}
+
+function debAtLeast(version, fixed) {
+ return compareDebian(debComparable(version), debComparable(fixed)) >= 0;
+}
+
+function sameDeb(a, b) {
+ return compareDebian(debComparable(a), debComparable(b)) === 0;
+}
+
+function maxFixed(states) {
+ return states.filter((s) => s.fixed).map((s) => s.fixed).sort(compareDebian).pop();
+}
+
+// Compares two upstream version strings; null when either cannot be read.
+function cmpUp(a, b) {
+ const pa = parsePhpVersion(a);
+ const pb = parsePhpVersion(b);
+ return pa && pb ? comparePhp(pa, pb) : null;
+}
+
+const pkgLabel = (p) => `${p.name} ${p.version}`;
+
+// The distribution to judge against. A choice in the Distribution list is used unless the pasted output
+// contradicts it: then the detected distribution is used, and d.overridden says what was chosen and why.
+export function detectDistro(obs, ver, distro, override, build) {
+ distro = distro || {};
+ const auto = autoDistro(obs, ver, distro, build);
+ if (!override || override === 'auto') return auto;
+ const [family, release] = override.split(':');
+ const manual = { family, release: release || null, how: 'manual' };
+ const by = manualConflict(ver, auto, manual);
+ return by ? { ...auto, overridden: { chosen: manual, by } } : manual;
+}
+
+// What in the pasted output contradicts a Distribution list choice: the OS release or a RHEL kernel ('os'), a
+// distribution revision in the version string ('version'), or nothing (null). A distribution the data does not
+// cover ('other') does not contradict a choice, nor does a release that could not be told.
+function manualConflict(ver, auto, manual) {
+ const revision = ver?.revision || '';
+ if (manual.family === 'upstream') {
+ // An upstream build never carries a distribution revision (deb.sury.org builds are judged by upstream anyway).
+ return /ubuntu|[~+]deb\d+u\d+|\.el\d/.test(revision) && !isSuryBuild(revision) ? 'version' : null;
+ }
+ const known = ['debian', 'ubuntu', 'rhel', 'alpine'];
+ if (['os', 'derived', 'kernel'].includes(auto.how) && known.includes(auto.family)) {
+ if (auto.family !== manual.family) return 'os';
+ return auto.release && manual.release && auto.release !== manual.release ? 'os' : null;
+ }
+ if (auto.how === 'suffix' && auto.family) {
+ if (auto.family !== manual.family) return 'version';
+ return auto.release && manual.release !== auto.release ? 'version' : null;
+ }
+ return null;
+}
+
+function overrideNote(t, d, obs, ver) {
+ const chosen = plainDistroLabel(t, d.overridden.chosen);
+ return d.overridden.by === 'os'
+ ? t('note.manualConflictOs', { chosen, detected: distroLabel(t, d, obs) })
+ : t('note.manualConflictVersion', { chosen, detected: plainDistroLabel(t, d), version: ver.raw });
+}
+
+function autoDistro(obs, ver, distro, build) {
+ const os = obs.os || {};
+ const like = `${os.id || ''} ${os.idLike || ''}`;
+ // Installed package versions of this branch, which can name the release ("8.2.35-1~deb12u1").
+ const pkgVersions = ver
+ ? (build ? build.packages : obs.packages || []).filter((p) => p.manager === 'dpkg' && debSource(p) === srcFor(ver)).map((p) => p.version)
+ : [];
+ if (os.id === 'debian' || os.id === 'ubuntu') {
+ const release = os.versionId || codenameToRelease(distro[os.id], os.codename);
+ if (release) return { family: os.id, release, how: 'os' };
+ const inferred = ver ? inferRelease(os.id, ver, distro, pkgVersions) : null;
+ return { family: os.id, release: inferred, how: inferred ? 'suffix' : 'os' };
+ }
+ if (RHEL_IDS.includes(os.id) || (/\brhel\b/.test(like) && os.id !== 'fedora')) {
+ return { family: 'rhel', release: (os.versionId || '').split('.')[0] || null, how: 'os', vendor: os.id };
+ }
+ if (os.id === 'alpine') return { family: 'alpine', release: os.versionId || null, how: 'os' };
+ if (os.id) {
+ // Derivatives (Linux Mint, LMDE, Pop!_OS, Raspbian and others) use their parent's PHP packages.
+ const parent = /\bubuntu\b/.test(os.idLike || '') ? 'ubuntu' : /\bdebian\b/.test(os.idLike || '') ? 'debian' : null;
+ if (parent) {
+ const release = codenameToRelease(distro[parent], os.codename) || (ver ? inferRelease(parent, ver, distro, pkgVersions) : null);
+ return { family: parent, release, how: 'derived', vendor: os.id };
+ }
+ return { family: 'other', release: os.versionId || null, how: 'os', vendor: os.id };
+ }
+
+ // A RHEL-family kernel (5.14.0-427.el9_4.x86_64). Not inside a container: it reports the host's kernel.
+ const kernel = /\.el(\d+)/.exec(obs.kernel || '');
+ if (kernel && obs.container !== true) return { family: 'rhel', release: kernel[1], how: 'kernel' };
+
+ // Without os-release, infer the distribution from the distribution revision in the version string.
+ const rev = ver?.revision || '';
+ // deb.sury.org builds name a distribution but are not its packages: they never select a release.
+ if (isSuryBuild(rev)) return { family: /ubuntu/.test(rev) ? 'ubuntu' : 'debian', release: null, how: 'suffix' };
+ if (/[~+]deb\d+u\d+/.test(rev)) return { family: 'debian', release: inferRelease('debian', ver, distro), how: 'suffix' };
+ if (/ubuntu/.test(rev)) return { family: 'ubuntu', release: inferRelease('ubuntu', ver, distro), how: 'suffix' };
+
+ // A Server header that names a RHEL rebuild ("Apache/2.4.57 (Rocky Linux)") selects the RHEL family, whose
+ // version numbers cannot settle the verdict. It is used for nothing else: it may come from a reverse proxy on
+ // another system, and it never names the release.
+ if (/\((?:Red Hat|Rocky Linux|AlmaLinux|CentOS|Oracle Linux|CloudLinux)[^)]*\)/i.test(obs.serverSoftware || '')) {
+ return { family: 'rhel', release: null, how: 'server' };
+ }
+ return { family: null, release: null, how: 'none' };
+}
+
+// The release named in, or consistent with, the version string; null when it cannot be told.
+// versions: installed package versions of the same branch, used when the version string names no release.
+function inferRelease(family, ver, distro, versions = []) {
+ const rev = ver.revision || '';
+ if (isSuryBuild(rev)) return null;
+ const named = namedRelease(family, rev);
+ if (named) return named;
+ const fromPackages = [...new Set(versions.filter((v) => !isSuryBuild(v)).map((v) => namedRelease(family, splitDebian(v).revision)).filter(Boolean))];
+ if (fromPackages.length === 1) return fromPackages[0];
+ if (family === 'debian') return null;
+ // Otherwise the only release that ships this source package at the same upstream version.
+ const releases = Object.entries(distro.ubuntu || {})
+ .filter(([, v]) => {
+ const entry = v.packages?.[srcFor(ver)];
+ return entry && entryUpstream(entry, Object.keys(entry.fixed || {})) === ver.upstream;
+ })
+ .map(([k]) => k);
+ return releases.length === 1 ? releases[0] : null;
+}
+
+// The release a revision names: "~deb12u1" names Debian 12, "0ubuntu0.24.04.13" names Ubuntu 24.04.
+function namedRelease(family, revision) {
+ const m = family === 'debian' ? /[~+]deb(\d+)u\d+/.exec(revision) : /ubuntu\d*\.(\d{2}\.\d{2})/.exec(revision);
+ return m ? m[1] : null;
+}
+
+function codenameToRelease(table, codename) {
+ if (!table || !codename) return null;
+ const hit = Object.entries(table).find(([, v]) => v.codename === codename);
+ return hit ? hit[0] : null;
+}
+
+// A distribution as the Distribution list names it: "Ubuntu 22.04", "RHEL family 9", "Upstream build (...)".
+function plainDistroLabel(t, d) {
+ if (d.family === 'upstream') return t('distro.upstream');
+ if (d.family === 'rhel') return d.release ? t('distro.rhelRelease', { release: d.release }) : t('distro.rhel');
+ const names = { debian: 'Debian', ubuntu: 'Ubuntu', alpine: 'Alpine' };
+ return `${names[d.family] || d.family}${d.release ? ' ' + d.release : ''}`;
+}
+
+function distroLabel(t, d, obs) {
+ const names = { debian: 'Debian', ubuntu: 'Ubuntu', rhel: t('distro.rhel'), alpine: 'Alpine', upstream: t('distro.upstream'), other: obs.os?.id || t('distro.other') };
+ if (!d.family) return t('distro.unknown');
+ const plain = `${names[d.family] || d.family}${d.release ? ' ' + d.release : ''}`;
+ if (d.how === 'derived') return t('distro.derived', { label: obs.os?.pretty || d.vendor, parent: plain });
+ const base = obs.os?.pretty && d.how === 'os'
+ ? obs.os.pretty
+ : d.family === 'rhel' && d.release
+ ? t('distro.rhelRelease', { release: d.release })
+ : plain;
+ if (d.how === 'suffix') return t('distro.inferred', { label: base });
+ if (d.how === 'kernel') return t('distro.kernel', { label: base });
+ if (d.how === 'server') return t('distro.serverHeader', { label: base });
+ if (d.how === 'manual') return t('distro.manual', { label: base });
+ return base;
+}
+
+export function sapiLabel(sapi, lang = 'en') {
+ const names = {
+ apache2handler: 'Apache mod_php',
+ 'fpm-fcgi': 'PHP-FPM',
+ 'cgi-fcgi': 'CGI/FastCGI',
+ cgi: 'CGI',
+ litespeed: 'LiteSpeed (LSAPI)',
+ frankenphp: 'FrankenPHP',
+ cli: translator(lang)('sapi.cli'),
+ };
+ return own(names, sapi) ? `${names[sapi]} (${sapi})` : String(sapi ?? '');
+}
+
+function compareBranch(a, b) {
+ const [am, an] = a.split('.').map(Number);
+ const [bm, bn] = b.split('.').map(Number);
+ return am - bm || an - bn;
+}
+
+export function isOff(v) {
+ if (v === null || v === undefined) return false;
+ return v === '' || v === '0' || /^(off|false|no|none)$/i.test(String(v).trim());
+}
+
+function isOn(v) {
+ return v !== null && v !== undefined && !isOff(v);
+}
diff --git a/site/lib/messages.js b/site/lib/messages.js
@@ -0,0 +1,857 @@
+// User-facing text of the verdict engine (judge.js, parse.js), in every supported language.
+// A message is either a string with {name} placeholders or a function (vars) => string
+// for plurals and conditional wording. Both languages must have the same keys.
+
+export const LANGS = ['en', 'ko'];
+
+// "1 file", "3 files"
+function count(n, one, many) {
+ return `${n} ${n === 1 ? one : many}`;
+}
+
+const en = {
+ // Verdicts
+ 'verdict.title.patched': 'This PHP has the fix',
+ 'verdict.title.vulnerable': 'This PHP is vulnerable',
+ 'verdict.title.pending': 'Vulnerable, and your distribution has no fix yet',
+ 'verdict.title.restart': 'Fixed on disk, still vulnerable in memory',
+ 'verdict.title.unknown': 'Not enough to decide',
+ 'verdict.label.patched': 'Patched',
+ 'verdict.label.vulnerable': 'Vulnerable',
+ 'verdict.label.pending': 'Fix pending',
+ 'verdict.label.restart': 'Restart needed',
+ 'verdict.label.unknown': 'Undetermined',
+
+ // Facts
+ 'fact.phpVersion': 'PHP version',
+ 'fact.installedVersion': 'Installed package version',
+ 'fact.sapi': 'SAPI',
+ 'fact.sapiInferred': '{sapi} (inferred from running processes)',
+ 'fact.distribution': 'Distribution',
+ 'fact.webServer': 'Web server',
+ 'fact.packageChecked': 'Package checked',
+ 'fact.otherBuilds': 'Other installed PHP builds',
+
+ // Distribution and SAPI labels
+ 'distro.rhel': 'RHEL family',
+ 'distro.rhelRelease': 'RHEL family {release}',
+ 'distro.upstream': 'Upstream build (self-compiled or official Docker image)',
+ 'distro.other': 'Other',
+ 'distro.unknown': 'Unknown',
+ 'distro.inferred': '{label} (inferred from the version string)',
+ 'distro.kernel': '{label} (inferred from the kernel version)',
+ 'distro.serverHeader': '{label} (inferred from the Server header)',
+ 'distro.derived': '{label} (based on {parent})',
+ 'distro.manual': '{label} (chosen manually)',
+ 'sapi.cli': 'Command line (CLI)',
+
+ // One of several separate outputs in the input
+ 'part.probe': 'PHP probe (PHP {version})',
+ 'part.probeNoVersion': 'PHP probe output',
+ 'part.header': 'Header X-Powered-By: PHP/{version}',
+ 'part.cli': '-v output (PHP {version}, {sapi})',
+ 'part.server': 'Server script output ({system})',
+ 'part.version': 'Version string {version}',
+
+ // Reasons: upstream
+ 'reason.noVersion': 'No PHP version was found.',
+ 'reason.preRelease': '{version} is a pre-release (alpha, beta, RC or a -dev snapshot), so it is not covered by the fixed releases. Check its release notes or commits to see whether both CVE fixes are included.',
+ 'reason.newerBranch': 'PHP {branch} is newer than every branch in the table of fixed releases ({branches}). Check its release notes to see whether both CVE fixes are included.',
+ 'reason.upstreamPatched': 'PHP {version} is at or above the upstream fixed release {fixed}.',
+ 'reason.upstreamVulnerable': 'PHP {version} is older than the upstream fixed release {fixed}.',
+ 'reason.predates54': 'PHP {version} predates the published attack path (session upload progress, PHP 5.4 and later). However, the official advisory does not state a lower bound for the affected versions, and this version reached end of life long ago and has many other vulnerabilities.',
+ 'reason.eol': 'PHP {branch} has reached end of life (EOL) and has no official fix release, so an upstream build of it is vulnerable. Only a distribution package with the fixes backported can have them.',
+ 'reason.osBlocked': 'open_basedir kept the probe from reading the OS release and the installed package list, so it cannot tell whether PHP {version} is a distribution package with the fixes backported under the same version number. Run the server script, or pick your distribution in the Distribution list and press Run check again.',
+ 'reason.osUnread': 'The probe could not read the OS release, so it cannot tell whether PHP {version} is a distribution package with the fixes backported under the same version number, as the RHEL family and others do. Run the server script, or pick your distribution in the Distribution list and press Run check again.',
+ 'reason.couldBeDistro': 'PHP {version} may also be a distribution package ({releases}) with the fixes backported under the same version number, so the version alone cannot settle it. Run the server script, which reads the installed packages and their changelogs. On Debian and Ubuntu the PHP probe can read the package version as well.',
+
+ // Reasons: Debian and Ubuntu
+ 'reason.noDataChangelogFixed': "{reason} However, the installed package's changelog records fixes for both CVEs, so it is treated as fixed.",
+ 'reason.debReleaseUnknown': 'The {family} release is unknown, so the backport could not be checked. Pick it in the Distribution list and press Run check again.',
+ 'reason.debNoRelease': 'The distribution data has no record for {family} {release}. It may have reached end of life, or no advisory may have been published yet. Check the {family} security advisories yourself.',
+ 'reason.debNoPackage': 'The distribution data has no record for the {src} package in {family} {release}. The package may come from another repository, or the advisory may not be out yet.',
+ 'reason.debBackported': '{family} has backported the fixes for both CVEs to {src} {version}. The fix is included even though the version number ({upstream}) is lower.',
+ 'reason.debOlder': '{src} {version} is older than the {family} fixed version {fixed}.',
+ 'reason.debOlderUpstream': 'PHP {version} is older than the upstream version of the {family} {release} fixed package for {src} ({fixed}), so it does not have the fix.',
+ 'reason.debNeedPackage': 'PHP_VERSION ({version}) is the upstream version of the {src} package in {family} {release}, and it stays the same when the fix is installed, so the version alone cannot show whether this server has it. Run the server script, or the PHP probe where it can read the installed package list (/var/lib/dpkg/status).',
+ 'reason.debPending': "There is no fixed package for {src} in {family} {release} yet. Watch your distribution's security advisories and update as soon as the fix is published.",
+ 'reason.debNoCveData': 'The distribution data has no record for at least one of the two CVEs.',
+ 'reason.restartInstalledFixed': 'The installed package ({installed}) contains the fixes for both CVEs, but the running PHP is still the old version ({running}). It looks like the service was not restarted after the update.',
+ 'reason.installedNewer': 'The installed package ({installed}) is newer than the running PHP ({running}). It looks like the service was not restarted after the update.',
+ 'reason.installedOlderUnfixed': 'The running PHP ({running}) has the fix, but the installed package ({installed}) is older and does not: the package was downgraded, or replaced by another build, after the service started. The next restart of the service loads the vulnerable build (for PHP-FPM, so does a reload). Install the fixed package again before the service restarts.',
+ 'reason.foreignRevision': "{version} has the same upstream version as the {src} package of {family} {release}, but its revision is not one that {family} builds, so it comes from another repository (a PPA or another vendor). Sorting above the fixed version does not mean it has the backported fixes. Check the advisories of whoever supplies this package, or install the {family} package.",
+
+ // Reasons: RHEL family
+ 'reason.rhelChangelogFixed': 'The changelog of {pkg}, the package of this PHP, records the fix for {cves}.',
+ 'reason.rhelChangelogMissing': 'The changelog of {pkg}, the package of this PHP, has no record of a fix for {cves}. Most likely the package has not been updated yet.',
+ 'reason.rhelNotAffected': 'Red Hat lists {cves} as not affected for RHEL {release}.',
+ 'reason.rhelNoMatchingPackage': 'The server script found no installed PHP package of version {version}, the PHP being checked. It found: {packages}. This PHP probably comes from another repository, a control panel or a container, so those changelogs do not apply. Check the advisories of whoever supplies this PHP, or run the server script where it runs.',
+ 'reason.rhelChangelogNotChecked': 'The changelog of {pkg}, the package of this PHP, was not checked: the server script output has no changelog lines for it (an older version of the script, or the changelog could not be read). Run the current server script, which reads the changelog of every PHP package.',
+ 'reason.rhelChangelogUnread': 'The changelog of {pkg} was not checked for {cves}: the server script output has no readable count for it, so it is unknown whether this PHP has those fixes. Copy the whole server script output again, or run the current server script.',
+ 'reason.rhelNeedServerScript': 'On the RHEL family, the version number alone does not show whether a fix was backported. Run the server script to check the package changelogs.',
+ 'reason.remiChangelogFixed': 'This PHP comes from the Remi repository. The changelog of {pkg} records the fix for {cves}, backported without changing the version number.',
+
+ // Reasons: processes and the running PHP
+ 'reason.staleProcesses': 'Running processes ({names}) are still using PHP files from before the update.',
+ 'reason.staleOtherProcesses': 'The PHP checked here has the fix, but other PHP processes ({names}) are still using files from before an update, and they may be running an older, vulnerable build. Restart them.',
+ 'reason.runningUnchecked': 'The installed package has the fix, but the running PHP could not be checked: the server script could not read some PHP processes. Run the server script as root, or run the PHP probe, to confirm that the running PHP was restarted after the update.',
+ 'reason.noRunningPhpSeen': 'No running PHP process of this build was found, so this rests on the installed package alone. If PHP runs in a container or is started some other way, check it with the PHP probe.',
+ 'reason.loadedBuildUnconfirmed': 'PHP_VERSION ({version}) is the same for the old and the fixed build, so this rests on the installed package. The probe cannot see whether PHP was restarted after the update: run the server script as root as well, which checks the running processes.',
+ 'reason.runningHidden': "The installed package has the fix, but the server script did not run as root and found no PHP process it could check. Without root, /proc can hide other accounts' processes completely (hidepid), so a PHP that was not restarted after the update would not show up. Run the server script again with sudo.",
+ 'reason.processesNotCheckedNoService': 'The server script did not run as root, so running processes were not checked. This build has no PHP-FPM, CGI or Apache module package, so no web server process can keep an old build of it loaded, and this rests on the installed package.',
+ 'reason.probeProcessNotFound': 'PHP_VERSION ({version}) is the same for the old and the fixed build, so this rests on the installed package, and the server script found no running process of this PHP to check for a missed restart. If it runs in a container or under another name, restart it after the update to be sure.',
+ 'reason.serverConflict': 'The PHP probe and the server script list different PHP packages (see the notes), so neither the installed package nor the restart check can be relied on. Run the server script again right after the probe and paste both outputs together.',
+ 'reason.binaryOnDisk': 'This version comes from -v output of the {sapi} binary, which shows the binary installed on disk, not the PHP the running service loaded. If the service was not restarted after the update, it still runs the old build. Run the PHP probe, or the server script as root, to confirm.',
+ 'reason.staleUnaccounted': 'PHP processes that none of the PHP builds checked here accounts for ({names}) are still using files from before an update. They may run an older, vulnerable build. Restart them, and check that PHP with the probe.',
+ 'reason.unaccountedProcesses': 'Running PHP processes ({names}) belong to none of the PHP builds checked here, so the PHP they run was not checked. They may come from a container, a control panel or a self-built PHP. Check that PHP with the PHP probe (inside the container if it runs in one).',
+ 'reason.serverTruncated': 'The server script output was cut off before its END line, so its later lines (running processes and signs of compromise) are missing, and Patched cannot be confirmed. Copy the whole output again.',
+ // One installed PHP build, when several are judged
+ 'reason.forBuild': '{build} ({verdict}): {reasons}',
+ // The running PHP, when installed builds are judged next to it
+ 'reason.forRunning': 'Running PHP {version} ({verdict}): {reasons}',
+ // One of several separate outputs in the input
+ 'reason.forPart': '{part} ({verdict}): {reasons}',
+
+ // Notes
+ 'note.sury': 'This PHP ({version}) is a deb.sury.org build. That repository follows upstream versions directly, so the verdict uses the upstream fixed releases.',
+ 'note.upstreamBuild': 'This looks like an upstream build (self-compiled, or an official Docker image), not a distribution package. The verdict uses the upstream fixed releases.',
+ 'note.configOutsideEtc': 'PHP reads its configuration from {path}, outside /etc, so it is not a distribution package: it was built from source, or comes from a Docker image or a control panel. Installed distribution packages do not describe it, and the verdict uses the upstream fixed releases.',
+ 'note.packageNotRunning': 'The installed package {pkg} is older than the running PHP ({version}), so it is not the PHP that runs. It was left out, and the running PHP is judged on its own.',
+ 'note.notDistroBuild': "{version} is newer than the {src} package that {family} {release} ships ({shipped}), so it is not that release's package. It probably comes from another repository or was built from source, and the verdict uses the upstream fixed releases.",
+ 'note.alpine': 'Alpine packages usually follow upstream versions, so the verdict uses the upstream fixed releases. A package revision (-rN) may carry a separate patch, so check the Alpine security advisories as well.',
+ 'note.otherDistro': 'Your distribution ({vendor}) may backport fixes without changing the version number. The verdict uses the upstream fixed releases, so check its security advisories as well.',
+ 'note.backportBranch': 'The PHP security team has published a minimal backport branch ({branch}) in the php-src repository. You can use it if you build PHP yourself, but moving to a supported version is the real fix. This check cannot recognise a build from that branch by its version number.',
+ 'note.ubuntuPro': 'This fix is published only in the Ubuntu Pro (ESM) repository. You need to attach an Ubuntu Pro subscription (free for personal use) to receive it.',
+ 'note.ubuntuProLegacy': 'This fix is published only through the Ubuntu Pro Legacy Support add-on, a paid add-on that the free personal Ubuntu Pro subscription does not include.',
+ 'note.rhelCompatible': 'RHEL-compatible distributions publish their own security advisories (Rocky RLSA, AlmaLinux ALSA, Amazon Linux ALAS and others) based on the Red Hat advisories (RHSA). Release timing can differ slightly.',
+ 'note.remi': 'This PHP ({pkg}) comes from the Remi repository, which follows upstream versions, so the verdict uses the upstream fixed releases.',
+ 'note.noRpmPackage': 'The server script output lists no PHP package installed with rpm, so this PHP was probably built from source, or comes from a control panel or a container. The verdict uses the upstream fixed releases. If PHP was installed with dnf or yum, run the current server script: an older copy of it did not list packages named like php8.3-common.',
+ 'note.newerPackageWaiting': 'A newer package ({installed}) is installed than the running PHP ({running}). The running PHP already has the fix; restart the service when convenient to load the update.',
+ 'note.staleFixed': 'Processes ({names}) are still using files from before a later update. The PHP that ran the probe already has the fix; restart the service when convenient to load the update.',
+ 'note.severalOutputs': 'The input holds {n} separate outputs. Each was checked on its own, the reasons list each one, and the worst result is shown.',
+ 'note.manualConflictOs': 'The Distribution list is set to {chosen}, but the pasted output shows {detected}, so {detected} was used. If the output comes from the server you meant, set the list back to Auto-detect.',
+ 'note.manualConflictVersion': 'The Distribution list is set to {chosen}, but the version string {version} is a build of {detected}, so {detected} was used.',
+
+ // Distribution advisories
+ 'advisory.fixedIn': 'fixed in {version}',
+ 'advisory.pending': 'fix pending',
+ 'advisory.noData': 'no data',
+ // Red Hat fix_state values, shown as they are in English
+ 'rhelState.affected': 'Affected',
+ 'rhelState.underInvestigation': 'Under investigation',
+ 'rhelState.fixDeferred': 'Fix deferred',
+ 'rhelState.willNotFix': 'Will not fix',
+ 'rhelState.notAffected': 'Not affected',
+ 'rhelState.outOfSupportScope': 'Out of support scope',
+ 'list.separator': ', ',
+ 'list.and': ' and ',
+
+ // Remediation steps (r.actions). Each is a sentence or two; the commands go in the step's code block.
+ // Restart
+ 'action.restartNow': 'The running PHP loaded the old build before the update and keeps it in memory until its processes stop. Restart the service that runs PHP with the command below, so that it loads the new build.{extra}',
+ 'action.restartAfterUpdate': 'After the update, restart the service that runs PHP with the command below, so that it loads the new build.{extra}',
+ 'action.restartAfterUpdateGeneric': 'After the update, restart the service that runs PHP (the web server or PHP-FPM), so that it loads the new build. With Apache mod_php, only a full restart of Apache, not a reload or a graceful restart, loads it.',
+ 'action.restartGeneric': 'Restart the service that runs PHP (the web server or PHP-FPM), so that it loads the new build. With Apache mod_php, only a full restart of Apache, not a reload or a graceful restart, loads it.',
+ 'action.restartProcesses': 'Restart whatever runs these PHP processes ({names}), a service or a container, so that they load the new build. The command below shows what started them: in the CGROUP column, a name ending in .service is a systemd service (restart it with systemctl), and docker-<id>.scope is a container (restart that container).',
+ 'restart.modPhp': ' With mod_php, PHP runs inside the Apache processes, so only a full restart of Apache, not a reload or a graceful restart, loads the new build.',
+ 'restart.cgi': ' PHP runs here as CGI/FastCGI: the web server (Apache with mod_fcgid, as in the first command) or a separate FastCGI process manager starts the php-cgi processes, so restart whichever starts them. The second command lists the php-cgi processes with their start times: none should be older than the restart.',
+ 'restart.choose': ' Run the line for the way PHP runs on this server: Apache mod_php or PHP-FPM.',
+ 'step.restart.ifUnsure': 'The installed package already has the fix. If you are not sure that PHP was restarted after that update, restart it now with the command below.{extra}',
+ 'step.restart.confirm': 'Then run the PHP probe again through the web server and press Run check: it should no longer say Restart needed.',
+ 'step.restart.confirmServer': 'Then run the server script again as root and press Run check: it should list no PHP process as still using files from before the update. If you used the PHP probe, run it again as well.',
+ 'step.recheck': 'Then run the PHP probe again through the web server and press Run check: the result should be Patched.',
+ 'step.recheckServer': 'Then run the server script again as root (and the PHP probe, if you used it) and press Run check: the result should be Patched.',
+ 'step.recheckServerLater': 'Once the update is installed, run the server script again as root (and the PHP probe, if you used it) and press Run check: the result should be Patched.',
+ 'step.recheckChangelog': 'Then run the server script again as root and press Run check: it reads the changelog of the updated package, which shows whether both fixes are in it.',
+ 'step.recheckServerDistro': 'Then run the server script again as root, paste its output here with the release picked in the Distribution list, and press Run check: the result should be Patched.',
+
+ // What to collect when the verdict is open
+ 'step.collect.server': 'Run the server script as root on the server where this PHP runs. The first two commands download it and print its hash: compare the hash with the one on the Server script tab of this page, and read the script before you run it with the last command. Paste its output here (with the PHP probe output, if you have it) and press Run check. The script reads the OS release, the installed PHP packages and their changelogs, and the running PHP processes.',
+ 'step.collect.serverInContainer': 'This PHP runs in a container, so run the server script inside that container. The first two commands download it and print its hash (compare it with the one on the Server script tab of this page); the last two copy it into the container and run it there as root (-u 0). Paste that output here on its own and press Run check.',
+ 'step.collect.root': 'Run the server script again as root (with sudo), paste the new output here and press Run check. Without root it cannot read the PHP processes of other accounts, so a PHP that was not restarted after an update stays hidden.',
+ 'step.collect.distro': 'Or, if you know the distribution, pick it and its release in the Distribution list next to Run check, and press Run check again.',
+ 'step.collect.distroOnly': 'The server script output does not name the release of this system. Pick the distribution and its release in the Distribution list next to Run check, and press Run check again.',
+ 'step.collect.probe': 'Run the PHP probe through the web server. Download it (the first command, or the PHP probe tab of this page), set $TOKEN at the top of the file to 12 or more random letters and digits (openssl rand -hex 16 makes one), upload it to the web root of the site under a name that is hard to guess, and open it once, in a browser or with the second command. Paste everything it prints here and press Run check.{next}',
+ 'collect.probeNext': ' To check the installed packages and running processes as well, also run the server script as in the next step.',
+ 'step.collect.whole': 'Copy the whole server script output again, from its BEGIN line to its END line, paste it here and press Run check.',
+ 'step.collect.together': 'Run the PHP probe and, right after it, the server script as root, then paste both outputs here together and press Run check, so that both describe the same moment.',
+ 'step.collect.elsewhere': 'Check the PHP these processes run where it runs. If it is in a container, copy the server script into the container and run it there as root, as below (-u 0 runs it as root inside the container), and paste that output on its own. Otherwise, run the PHP probe through a site that this PHP serves.',
+ 'step.collect.advisories': 'The distribution data has no record for this case, so check the {family} pages for both CVEs yourself (below). They list the fixed {src} version for each release. In the meantime, update and restart as in the next steps.',
+ 'step.collect.supplier': 'Find out which repository this package comes from: apt-cache policy marks the installed version with *** and lists its source below it. Then check that supplier\'s security advisories for both CVEs, or install the {family} package instead.',
+
+ // Debian and Ubuntu packages
+ 'fix.deb': '{pkg} is installed, and the fixed version for {family} {release} is {src} {fixed}.',
+ 'fix.debRunning': 'This server runs PHP {version}, and the fixed version for {family} {release} is {src} {fixed}.',
+ 'fix.debOpen': 'Even while the result is Undetermined, install the latest {family} updates for {src} now.',
+ 'fix.sury': '{pkg} comes from deb.sury.org, which ships upstream releases, and the {branch} branch is fixed in PHP {fixed}.',
+ 'step.ubuntuPro': 'The fixed package for Ubuntu {release} is published only in Ubuntu Pro (ESM). Ubuntu Pro is free for personal use on up to five machines: get a token at https://ubuntu.com/pro and attach this server with the command below, then update as in the next step.',
+ 'step.ubuntuProLegacy': 'Ubuntu {release} is past its ten-year ESM window: its fixed package is published only through the Ubuntu Pro Legacy Support add-on, which is paid and not part of the free personal subscription. If you have it, attach this server with the command below and update as in the next step; otherwise plan a move to a supported Ubuntu release.',
+ 'step.apt.upgrade': '{fix} The commands below update the packages this check found ({names}); --only-upgrade installs nothing new, and apt updates the PHP extension packages that must keep the same version along with them. To install every pending update instead, run sudo apt full-upgrade and read its list of changes before you confirm.',
+ 'step.apt.upgradeFind': '{fix} The commands below look up the installed {src} packages with dpkg -l and update only those (--only-upgrade installs nothing new). To install every pending update instead, run sudo apt full-upgrade and read its list of changes before you confirm.',
+ 'step.apt.switchToDistro': 'If you would rather use the {family} package, first turn off the repository that apt-cache policy names for this one (its file in /etc/apt/sources.list.d/). Then run the commands below: the second reinstalls every {src} package that is now at {foreign} at the {family} version {fixed}, and --allow-downgrades lets apt replace the higher-numbered build.',
+ 'step.apt.confirmExact': 'Afterwards each package below must show exactly {fixed}, the {family} revision: a higher version from another repository does not count.',
+ 'step.apt.confirmFixed': 'Check that the update is installed: each package below should now show {fixed} or later.',
+ 'step.apt.confirmUpstream': 'Check that the update is installed: each package version below should now start with {fixed} or a later {branch} release.',
+ 'step.apt.confirm': 'Check which versions are installed now. Once the release is picked in the Distribution list, Run check compares them with the fixed version for that release.',
+ 'step.apt.confirmCompare': 'Compare the installed versions (command below) with the fixed version for your release on the {family} pages in the first step: the data of this check has no record for this case, so it cannot compare them for you.',
+ 'step.pending.watch': '{family} {release} has no fixed {src} package yet: the CVEs are known, but the fixed package has not been published. Watch the {family} pages for both CVEs (below), which show the state for each release, and install the fix as soon as {family} {release} has one.',
+ 'step.pending.check': 'To see whether the fix has arrived, refresh the package lists and look for {src} updates with the first two commands. When {src} packages are listed, the last command installs them.',
+
+ // RHEL family
+ 'rhel.context.advisory': 'Red Hat fixed both CVEs for PHP {branch} on RHEL {release} in {advisories}.',
+ 'rhel.context.state': 'The distribution data has no Red Hat fix for PHP {branch} on RHEL {release} yet (Red Hat lists {states}).',
+ 'rhel.context.none': 'The distribution data has no Red Hat advisory for PHP {branch} on RHEL {release}.',
+ 'rhel.context.noRelease': 'The RHEL release of this server is not known. Red Hat has fixed PHP {branch} {fixes}; the server script shows which one applies.',
+ 'rhel.context.noReleaseItem': 'on RHEL {release} in {advisories}',
+ 'rhel.context.noReleaseNone': 'The RHEL release of this server is not known, and the distribution data has no Red Hat advisory for PHP {branch} on any release.',
+ 'rhel.context.amazon': 'Amazon Linux {release} publishes its PHP fixes in ALAS advisories, not in Red Hat ones.',
+ 'rhel.rebuild': '{name} ships the same fix in its own {prefix} advisory.',
+ 'rhel.what.names': 'the packages of this PHP ({names})',
+ 'rhel.what.all': 'every installed PHP package',
+ 'step.rhel.upgrade': '{context} The first command lists the updates that fix these CVEs; the second updates {what}. --refresh makes dnf download the latest repository metadata first.',
+ 'step.rhel.upgradeYum': '{context} The first command lists the updates that fix these CVEs; the second updates {what}.',
+ 'step.rhel.update': '{context} Update {what} with the command below.',
+ 'step.rhel.upgradeNone': 'There is nothing to install for this yet. The first command shows whether an update for these CVEs has been published; as soon as it lists one, the second updates {what} to it, and the steps after it confirm the fix.',
+ 'step.rhel.upgradeNoneStay': 'If you stay on php:{branch}, there is nothing to install for it yet. The first command shows whether an update for these CVEs has been published; as soon as it lists one, the second updates {what} to it, and the steps after it confirm the fix.',
+ 'step.rhel.updateNone': 'There is nothing to install for this yet. Once an update is published, the command below updates {what} to it, and the steps after it confirm the fix.',
+ 'step.rhel.switchStream': 'Red Hat fixed these CVEs on RHEL {release} in other module streams, {streams}, but not in php:{branch}. Test your application with php:{target}, then switch to that stream with the command below: dnf replaces the PHP packages with its fixed build ({build}).',
+ 'step.rhel.watch': '{context} Watch the Red Hat pages for both CVEs (below), which show the state for each RHEL release, and update as soon as a fix is listed for RHEL {release}.',
+ 'step.rhel.watchAny': 'Watch the Red Hat pages for both CVEs (below), which show the state for each RHEL release, and update as soon as a fix is listed for yours.',
+ 'step.rhel.module': 'Before you update, check with the command below that the php:{branch} stream is enabled (marked [e]). The fix comes as a newer build of that stream ({build}), which the upgrade in the next step installs; you do not need to switch streams.',
+ 'step.rhel.moduleNoBuild': 'This PHP comes from the php:{branch} module stream; the command below shows the enabled stream (marked [e]). A fix for it will come as a newer build of that stream, which the upgrade above installs while the stream stays enabled.',
+ 'step.rhel.confirm': 'Confirm the update: rpm -q shows the installed versions, and the changelog of {pkg} should now have a line for each CVE (the second command prints them).',
+ 'step.rhel.confirmFixed': 'Confirm the update: rpm -q should show {fixed} or later, and the changelog of {pkg} should now have a line for each CVE (the second command prints them).',
+ 'step.rhel.confirmSome': 'Confirm the update: rpm -q shows the installed versions, and the changelog of {pkg} should now have a line for {cves} (the second command prints it). Red Hat lists {notAffected} as not affected for RHEL {release}, so no line is expected for it.',
+ 'step.rhel.old': '{name} {release} is past the end of its regular support: fixes come only through a paid extended support subscription, and not for every CVE. Plan a move to a supported release (RHEL 8 or later, or a rebuild of it).',
+ 'step.rhel.centosEol': 'CentOS {release} has reached end of life, and its own repositories get no more updates: the update commands below can bring this fix only with a third-party extended support subscription. Plan a move to a supported release, for example RHEL 9, Rocky Linux 9 or AlmaLinux 9, and until then apply the mitigation below.',
+ 'step.amazon.al2Eol': 'Amazon Linux 2 reached the end of its support on 30 June 2026 and gets no more fixes: plan a move to Amazon Linux 2023.',
+ 'step.amazon.amiEol': 'Amazon Linux AMI {release} is past the end of its support and gets no more fixes: plan a move to Amazon Linux 2023.',
+ 'step.amazon.watch': '{context} Watch the Amazon Linux pages for both CVEs (below) and update as soon as a fix is listed for Amazon Linux {release}.',
+ 'step.rpm.confirmUpstream': 'Check that the update is installed: each package version below should now start with {fixed} or a later {branch} release.',
+
+ // Remi, Alpine and other distributions
+ 'fix.remi': '{pkg} comes from the Remi repository, which ships upstream releases, and the {branch} branch is fixed in PHP {fixed}.',
+ 'step.remi.upgrade': '{fix} Update {what} with the command below.',
+ 'step.remi.eol': 'This PHP ({pkg}) comes from the Remi repository. PHP {branch} has reached end of life upstream, but Remi backports security fixes to its PHP {branch} packages without changing the version number. Update {what} with the command below; the changelog should then list both CVEs.',
+ 'fix.alpine': 'This PHP comes from Alpine packages ({names}), which follow upstream releases, and the {branch} branch is fixed in PHP {fixed}.',
+ 'step.apk.upgrade': '{fix} Update every installed {name} package as root with the commands below (apk info lists them). Alpine updates PHP in its community repository only until the next stable Alpine release, so on an older release you may need to move to a newer Alpine release to get PHP {fixed}.',
+ 'step.apk.confirm': 'Check the installed versions: they should now start with {fixed} or a later {branch} release.',
+ 'step.other.update': 'This PHP comes from {vendor}. Install its latest PHP update with its package manager, as below, and check the {vendor} security advisories for both CVEs: the fix may be backported without changing the version number, or come as PHP {fixed} or later.',
+ 'step.other.updateNoCode': 'This PHP comes from {vendor}. Install its latest PHP update with its package manager, and check the {vendor} security advisories for both CVEs: the fix may be backported without changing the version number, or come as PHP {fixed} or later.',
+
+ // Upstream builds and end-of-life branches
+ 'step.upstream.get': 'PHP {version} does not match a distribution package in the data of this check, so it is treated as an upstream build: built from source, or installed by a control panel or a repository such as deb.sury.org or Remi. The {branch} branch is fixed in PHP {fixed}.{extra} If a control panel or a repository installed it, update it there. If you build PHP yourself, download {fixed} from php.net (or set your build pipeline to {fixed}) and compare its checksum with the one listed on php.net (https://www.php.net/downloads).',
+ 'step.upstream.getUnknown': 'Nothing in this input shows where PHP {version} comes from. If it is a distribution package, the PHP probe in the previous step gives the exact update steps. If it was built from source, or a control panel or another repository installed it, update it to PHP {fixed}, the fixed {branch} release: in the panel or repository, or by downloading {fixed} from php.net (or setting your build pipeline to it) and comparing its checksum with the one listed on php.net (https://www.php.net/downloads).{extra}',
+ 'step.upstream.build': 'Build and install PHP {fixed} with the same configure options as the current build, so that the same extensions and SAPIs are built. The first command prints the current options: put them in place of <current-options>.',
+ 'step.upstream.confirmBinary': 'Check the version of the binary that serves the site, not the command-line php, which can be another build: it should print PHP {fixed}. If {binary} is not on your PATH, use the full path that the service starts. Then run the PHP probe again through the web server and press Run check.',
+ 'step.upstream.confirmProbe': 'Run the PHP probe again through the web server and press Run check: it should show PHP {fixed}. Do not rely on php -v on the command line, which can be another build than the one the web server loads.',
+ 'step.eol.move': 'PHP {branch} has reached end of life, so php.net will not publish a fixed {branch} release. The real fix is to move to a supported branch: PHP {target} (fixed in {targetFixed}) or PHP {alt} (fixed in {altFixed}). Code written for {branch} may need changes on a newer branch, so test your application on a copy of the site or a staging server before you switch.',
+ 'step.eol.distro': 'If you cannot move yet, PHP {branch} with both fixes backported is available as a distribution package in {carriers}. A commercial long-term support vendor for PHP {branch} is another option; check that its build lists both CVEs.',
+ 'step.eol.vendor': 'If you cannot move yet, use PHP {branch} from a distribution or a commercial long-term support vendor that backports security fixes, and check that its package lists both CVEs.',
+ 'step.eol.host': 'On this {system} server, the closest fixed PHP is the distribution\'s own {src} package ({fixed}), which gets {family} security updates: install {pkg} with the commands below, switch the site over to it and test the application. A commercial long-term support vendor for PHP {branch} is another option.{extra}',
+ 'step.eol.hostNoCode': 'On this {system} server, the closest fixed PHP is the distribution\'s own {src} package ({fixed}), which gets {family} security updates: turn off the Apache PHP module loaded now (two PHP modules cannot be loaded together), install {pkg} and test the application. A commercial long-term support vendor for PHP {branch} is another option.{extra}',
+ 'eol.others': 'Other distributions that carry the {branch} backport: {carriers}.',
+ 'eol.hostPro': 'This package is published only in Ubuntu Pro (ESM), so attach an Ubuntu Pro subscription first.',
+ 'branch.eolSoon': 'PHP {branch} reaches end of life on {date}, so plan the move to {low} or {high} as well.',
+ 'branch.eolPast': 'PHP {branch} reached end of life on {date}, so plan the move to {low} or {high} as well.',
+ 'step.eol.backport': 'As a last resort, build PHP {branch} yourself from {name}, a php-src branch in which the PHP security team published only these two fixes, with the configure options of your current build (the first command prints them: put them in place of <current-options>). A build from git also needs autoconf, bison and re2c. It gets no other fixes, and this check cannot recognise such a build: its version stays {version}, so the result will still say Vulnerable.',
+ 'step.prereleaseFixed': 'Check the release notes or commits of PHP {version} for both CVE fixes. For production, use a stable release that has them: PHP {fixed} or a later {branch} release.',
+ 'action.checkReleaseNotes': 'Check the release notes or commits of PHP {version} for both CVE fixes. For production, use a stable release that has them.',
+
+ // Containers
+ 'step.docker.tag': 'This container runs PHP {version} from the official PHP image; the {branch} branch is fixed in PHP {fixed}. Change the php image tag in your Dockerfile (FROM), compose.yaml (image:) or docker run command to the one below, which keeps your variant ({variant}). A tag such as php:{floating} also gets {fixed} when you pull it again, but it does not pin the version, so a later rebuild can change PHP without notice.{extra} If you use an image built on PHP instead, such as wordpress, drupal or nextcloud, keep that image and move to a newer tag of it that ships PHP {fixed} (docker run --rm <your-image> php -v shows its PHP).',
+ 'step.docker.tagNoVariant': 'This container runs PHP {version} from the official PHP image; the {branch} branch is fixed in PHP {fixed}. Change the php image tag in your Dockerfile (FROM), compose.yaml (image:) or docker run command to PHP {fixed}, with the variant of your current tag (apache, fpm or cli, for example) in place of <variant>. A tag such as php:{branch} follows the branch but does not pin the version.{extra} If you use an image built on PHP instead, such as wordpress, drupal or nextcloud, keep that image and move to a newer tag of it that ships PHP {fixed}.',
+ 'step.docker.frankenphp': 'This container runs FrankenPHP with PHP {version}. The {branch} branch is fixed in PHP {fixed}. FrankenPHP images (dunglas/frankenphp) are tagged by FrankenPHP version and PHP branch, not by PHP release, so pull your tag again once it ships PHP {fixed}, or build FrankenPHP with PHP {fixed}.',
+ 'step.docker.eol': 'This container runs the official PHP image with PHP {version}. PHP {branch} has reached end of life, so there will be no fixed {branch} image. Move to a supported branch: the tag below uses PHP {targetFixed}, and PHP {altFixed} is the other choice. Code written for {branch} may need changes on a newer branch, so test your application with the new image before you deploy it.{extra}',
+ 'docker.oldDebian': 'Your image is based on Debian {from} ({fromName}), for which no new PHP images are published, so the tag below moves it to Debian {to} ({toName}), the nearest release that still gets them. Packages your Dockerfile installs with apt may have other names or versions there, so test the build.',
+ 'docker.otherDebian': 'No PHP images are published for Debian {codename}, so the tag uses the default Debian release of the image: test your Dockerfile with it.',
+ 'docker.alpineFloating': 'The -alpine tag follows the newest Alpine release, which is newer than the Alpine {release} your image uses now: test your Dockerfile with it, or pin an Alpine release with a tag that Docker Hub lists, such as {example}.',
+ 'step.docker.rebuild': 'Rebuild with --pull, so that Docker fetches the new base image instead of using its cached copy, and replace the running containers: restarting an existing container keeps the old image. With plain Docker, build as below and start a new container from <your-image> in place of the old one; if you run the php image directly, docker pull the new tag instead. With Docker Compose, the last three commands rebuild the services that have a build: section, pull the images named in image: lines, and recreate the containers whose image changed.',
+ 'step.docker.confirm': 'Check the PHP version in a new container: the command below should print {fixed}. Then run the PHP probe again through the web server and press Run check.',
+ 'step.container.update': 'PHP in this container comes from packages, not from the official PHP image. {fix} Add the line below to your Dockerfile after the line that installs PHP (or move to a newer base image that already has the fix), then rebuild the image and replace the containers as in the next step.',
+ 'step.container.updatePro': 'PHP in this container comes from Ubuntu packages, not from the official PHP image. {fix} That version is published only in Ubuntu Pro, which an image build can use only while it is attached: add the lines below to your Dockerfile after the line that installs PHP, put the line token: <token> in a file named pro-attach-config.yaml, and build with the --secret option as in the next step, so that the token never ends up in the image. With Docker Compose, declare that file as a build secret in compose.yaml.',
+ 'step.container.upstream': 'This container runs PHP {version}, which is neither a distribution package nor the official PHP image. The {branch} branch is fixed in PHP {fixed}.{extra} Update the base image or the build that provides PHP to {fixed}, then rebuild the image and replace the containers as in the next step.',
+ 'step.container.other': 'PHP in this container comes from {vendor} packages. Update them in your Dockerfile with its package manager (or move to a newer base image), and check the {vendor} security advisories for both CVEs: the fix may be backported without changing the version number, or come as PHP {fixed} or later. Then rebuild the image and replace the containers as in the next step.',
+ 'step.container.eol': 'This container runs PHP {version}. PHP {branch} has reached end of life, so there will be no fixed {branch} release. Move the image to a supported branch, PHP {target} (fixed in {targetFixed}) or PHP {alt} (fixed in {altFixed}), and test your application with it before you deploy it.',
+ 'step.container.confirmFixed': 'Check the installed versions in a new container: they should show {fixed} or later. Then run the PHP probe again through the web server and press Run check.',
+ 'step.container.confirm': 'Check the installed versions in a new container, then run the PHP probe again through the web server and press Run check.',
+ 'step.container.confirmChangelog': 'Check in a new container that the changelog of {pkg} lists {cves} (the second command prints them), then run the PHP probe again through the web server and press Run check.',
+ 'step.container.confirmChangelogFixed': 'Check in a new container that rpm -q shows {fixed} or later and that the changelog of {pkg} lists {cves} (the second command prints them), then run the PHP probe again through the web server and press Run check.',
+ 'step.container.restart': 'The PHP packages in this container were updated, but its PHP processes still run the old build, which they loaded before the update. Restart the container to load the new build, then run the PHP probe again and press Run check.',
+ 'step.container.persist': 'The update was made inside the running container, so a new container from the same image goes back to the old build. Put the update into the image as well: add the line below to your Dockerfile after the line that installs PHP, and rebuild.',
+
+ // Interim mitigations and hardening
+ 'when.cannotUpdate': 'If you cannot update right away',
+ 'when.pending': 'Until the fixed package is published',
+ 'when.eol': 'Until this site runs on a supported branch',
+ 'when.eolRelease': 'Until this server runs a supported release',
+ 'step.mitigate.apache': '{when}, turn off session upload progress, the main interim mitigation in the official advisory: the commands below set it for every site that Apache mod_php serves, test the configuration and reload Apache (a reload is enough for a configuration change).{extra} Upload progress bars stop updating, but uploads keep working. Enable the commented memory_limit -1 only with a memory cap outside PHP, such as MemoryMax= on the Apache service, never with ulimit -v. This lowers the risk but does not replace the patch.',
+ 'step.mitigate.fpm': '{when}, turn off session upload progress, the main interim mitigation in the official advisory: the commands below set it for the www pool of PHP-FPM in a separate file, test the configuration and reload PHP-FPM (a reload is enough for a configuration change).{extra} Upload progress bars stop updating, but uploads keep working. If the test says "[pool www] user has not been defined", this server has no www pool: put your pool\'s name in the brackets, and add a section like it for each other pool. Enable the commented memory_limit -1 only with a memory cap outside PHP, such as MemoryMax= on the service, never with ulimit -v. This lowers the risk but does not replace the patch.',
+ 'step.mitigate.fpmAlso': 'PHP-FPM runs this PHP as well: the commands below set the same for its www pool in a separate file, test the configuration and reload PHP-FPM (a reload is enough for a configuration change).{extra} If the test says "[pool www] user has not been defined", put your pool\'s name in the brackets. This interim mitigation lowers the risk but does not replace the patch.',
+ 'step.mitigate.docker': '{when}, turn off session upload progress, the main interim mitigation in the official advisory. The first two commands add the setting to the running container and restart it; the RUN line keeps it in your image, so add it to your Dockerfile as well.{extra} Upload progress bars stop updating, but uploads keep working. This lowers the risk but does not replace the patch.',
+ 'step.mitigate.ini': '{when}, turn off session upload progress, the main interim mitigation in the official advisory: the commands below add the setting to {file}, which this PHP reads, and restart PHP so that it takes effect.{extra} Upload progress bars stop updating, but uploads keep working. This lowers the risk but does not replace the patch.',
+ 'step.mitigate.iniNoRestart': '{when}, turn off session upload progress, the main interim mitigation in the official advisory: the command below adds the setting to {file}, which this PHP reads; then restart the service that runs PHP so that it takes effect.{extra} Upload progress bars stop updating, but uploads keep working. This lowers the risk but does not replace the patch.',
+ 'mitigate.fatalBacktraces': ' On PHP 8.5 they also turn off fatal_error_backtraces, which blocks the confirmed RCE path.',
+ 'action.applyMitigations': '{when}, apply the interim mitigations listed in this result. They do not replace the patch.',
+ 'action.applyMitigationsAdvisory': '{when}, apply the interim mitigations from the official advisory: session.upload_progress.enabled=0, and memory_limit=-1 only together with a memory cap outside PHP (a cgroup limit such as MemoryMax= on the service), never with ulimit -v. Run the PHP probe to see their current values. They do not replace the patch.',
+ 'action.lookForCompromise': 'If the server was exposed before you patched, look for signs of compromise: logs, recently changed PHP files, and unusual processes running as the web server user. Run the server script with --logs and with --webroot pointing at the site\'s directory (for example /var/www), as below, and paste its output here.',
+ 'action.checkOtherSites': 'Check every other site (vhost) and PHP-FPM pool on this server as well: each one can run a different PHP. Run the PHP probe once for each.',
+ 'action.checkOtherContainers': 'Check every other container and image that runs PHP as well: each can have its own PHP version. Run the PHP probe once in each.',
+ 'step.hardening.expose': 'Optional hardening: turn off expose_php, so that responses stop showing the PHP version in an X-Powered-By header. It gives attackers less information but does not replace updates. The commands below add the setting for this PHP and restart its service so that it takes effect.',
+ 'step.hardening.exposeDocker': 'Optional hardening: turn off expose_php, so that responses stop showing the PHP version in an X-Powered-By header. Add the line below to your Dockerfile and rebuild the image. It gives attackers less information but does not replace updates.',
+ 'step.hardening.exposePath': 'Optional hardening: set expose_php = Off in the php.ini of this PHP (in {path}) and restart the service that runs it, so that responses stop showing the PHP version in an X-Powered-By header. It gives attackers less information but does not replace updates.',
+ 'step.hardening.exposeManual': 'Optional hardening: set expose_php = Off in the php.ini of the PHP that serves this site and restart the service that runs it, so that responses stop showing the PHP version in an X-Powered-By header. It gives attackers less information but does not replace updates.',
+ 'step.hardening.uploadOff': 'session.upload_progress.enabled is off. If nothing uses the upload progress feature, keep it off now that PHP is patched: the official advisory recommends this, because attackers are looking for other flaws in this feature.',
+ 'step.hardening.uploadOn': 'If nothing on this server uses the upload progress feature, you can turn off session.upload_progress.enabled (set it to 0) even now that PHP is patched: the official advisory recommends this, because attackers are looking for other flaws in this feature.',
+
+ // Interim mitigations
+ 'mit.value.notSet': '(not set)',
+ 'mit.value.emptyOff': '(empty = Off)',
+ 'mit.noChangeNeeded': 'No change is needed on the fixed version.',
+ 'mit.memoryLimit.patchedUnlimited': 'The fixed version does not need -1. With no PHP memory limit there is a denial-of-service (DoS) risk, so consider reverting it to the original value.',
+ 'mit.memoryLimit.unlimited': 'The main configuration mitigation for CVE-2026-109618 is in place. PHP itself now has no memory limit, so set a limit elsewhere, for example with cgroup (memory.max), pm.max_children or MaxRequestWorkers. Do not use RLIMIT_AS or RLIMIT_DATA: they trigger the vulnerable path instead.',
+ 'mit.memoryLimit.suggest': 'Setting it to -1 removes the predictable memory limit that CVE-2026-109618 attacks rely on (the first configuration mitigation in the official advisory, and not a complete one). It creates a denial-of-service (DoS) risk, so cap memory elsewhere, with a cgroup limit (memory.max), pm.max_children or MaxRequestWorkers. Do not use RLIMIT_AS or RLIMIT_DATA (ulimit -v): they trigger the vulnerable path.',
+ 'mit.uploadProgress.noSession': 'The session extension is not loaded, so the session path does not apply.',
+ 'mit.uploadProgress.off': 'The confirmed session path for CVE-2026-109619 is blocked. The official advisory recommends keeping it off after patching if you do not use it.',
+ 'mit.uploadProgress.patchedOn': 'If you do not use the upload progress feature, the official advisory recommends keeping it off even after patching, because attackers are looking for other flaws in this feature.',
+ 'mit.uploadProgress.suggest': 'Setting it to Off (0) blocks the session path confirmed for CVE-2026-109619. Check first whether you use the upload progress feature.',
+ 'mit.autoStart.off': 'It is off.',
+ 'mit.autoStart.suggest': 'According to the Xint reproduction, PHP is vulnerable while this is on, even with upload_progress off. Before you turn it off, check where session_start() is called and test your login flow.',
+ 'mit.fatalBacktraces.off': 'The confirmed CVE-2026-109619 RCE path in PHP 8.5 is blocked.',
+ 'mit.fatalBacktraces.suggest': 'On PHP 8.5, setting it to 0 blocks the confirmed RCE path of CVE-2026-109619 (official advisory).',
+ 'mit.nestingLevel': 'The default is 64. The attack uses deeply nested field names, so the lower you set it within what your application allows, the lower the risk (official advisory).',
+ 'mit.postMaxSize': 'Keep it conservative, and limit the request size in the web server as well (Apache LimitRequestBody, Nginx client_max_body_size).',
+ 'mit.exposePhp.off': 'The PHP version is not exposed in response headers.',
+ 'mit.exposePhp.on': 'The PHP version is exposed in a response header (X-Powered-By). Off is recommended after patching.',
+
+ // SAPI exposure
+ 'exposure.apache2handler': 'With Apache mod_php, an attack can succeed while the request body is being parsed, whatever your application code does. The official advisory verified RCE with default settings on this setup, which makes it the most dangerous one.',
+ 'exposure.frankenphp': 'FrankenPHP always runs request shutdown handling, as mod_php does, so it is likely vulnerable to attacks that do not depend on the application (official advisory).',
+ 'exposure.fpm': 'PHP-FPM ends the worker when request startup fails, which blocks some paths, but attacks through application paths are still possible. The session-path RCE (CVE-2026-109619) was also verified on FPM.',
+ 'exposure.litespeed': 'LiteSpeed (LSAPI) behaves much like FPM, so attacks through application paths are expected to be possible (official advisory, not verified).',
+ 'exposure.cli': 'This result comes from the command line (CLI). It may differ from the PHP your web service uses, so upload the PHP probe to your web root and run it from a browser.',
+ 'exposure.other': 'The official advisory does not mention {sapi} separately. Treat it as exposed until it is patched.',
+
+ // Compromise indicators from the server script (--logs, --webroot)
+ 'finding.oom': ({ n }) => `The logs contain ${count(n, 'out-of-memory error', 'out-of-memory errors')} raised before any script ran ("in Unknown on line 0"). This may be an attack attempt using malformed multipart requests.`,
+ 'finding.crash': ({ n }) => `The logs contain ${count(n, 'web server or PHP crash (segfault)', 'web server or PHP crashes (segfaults)')}. This may be an attempted memory corruption attack.`,
+ 'finding.bigSession': ({ n }) => `Found ${count(n, 'session file', 'session files')} larger than 1 MB. This may be a trace of an attack on the session upload progress path.`,
+ 'finding.webUserProc': ({ n }) => `Found ${count(n, 'other process', 'other processes')} running as a web server account (www-data, apache, nginx, nobody or similar). Check the server script output to see what they are: a shell, a download tool or a coin miner there suggests a compromise.`,
+ 'finding.recentPhp': ({ n }) => `Found ${count(n, 'PHP file', 'PHP files')} changed in the last 3 days. Treat any file you did not deploy as a possible web shell.`,
+ 'finding.unread': 'Some sources could not be read ({items}), so they were not checked. Run the server script as root. Zero counts from the other sources do not cover these.',
+ 'finding.notRoot': 'The server script did not run as root, so logs, session files and processes owned by other accounts may not have been read. Run it again with sudo before you rely on these counts.',
+ 'finding.source.logs': 'logs',
+ 'finding.source.sessions': 'session files',
+ 'finding.source.processes': 'web server account processes',
+ 'finding.source.webroot': 'web root',
+ 'finding.none': 'Nothing suspicious was found in the items checked ({items}). The absence of traces does not mean there was no compromise.',
+ 'finding.incident': 'If you suspect a compromise, isolate the server from the network and preserve the logs. Then rotate all database passwords, API keys, and session and encryption keys. You can also report the incident to your national CERT/CSIRT.',
+
+ // Notes from reading the pasted input (parse.js)
+ 'parse.badJson': 'Could not read the JSON part of the PHP probe output. Make sure you copied the whole output and nothing was cut off.',
+ 'parse.notSelfcheck': 'This is not in the php2shell-selfcheck output format.',
+ 'parse.dpkgUnreadable': 'Could not read the installed package list (/var/lib/dpkg/status), for example because of open_basedir. The distribution backport check may be inaccurate, so run the server script as well.',
+ 'parse.openBasedir': 'open_basedir is set for this site, so the probe could not read the OS release or the installed package list. Without them the probe alone cannot check distribution backports: run the server script as well, or pick your distribution in the Distribution list and press Run check again.',
+ 'parse.osReleaseUnread': 'The probe could not read the OS release (/etc/os-release or the older release files), so the distribution and its installed packages are unknown. Without them the probe alone cannot check distribution backports: run the server script as well, or pick your distribution in the Distribution list and press Run check again.',
+ 'parse.serverOtherSystem': "The PHP probe ran inside a container, but the server script output comes from another system ({os}), probably the host. Host packages, changelogs and processes do not describe the PHP in the container, so they were not used. To check the container's packages, run the server script inside it (docker exec).",
+ 'parse.serverOtherOs': 'The server script output comes from another system ({os}) than the PHP probe ({probe}), so its packages, changelogs and processes do not describe this PHP and were not used. Run both on the server where this PHP runs.',
+ 'parse.serverUnlinked': "The PHP probe ran inside a container, and nothing in its output shows that the server script ran in the same container: the probe could not read its own OS release or package list, or its package list differs from the server script's. The server script may have run on the host, so its packages, changelogs and processes were not used. To check the container's packages, run the server script inside the container (docker exec) and paste that output on its own.",
+ 'parse.serverConflict': "The PHP probe and the server script list different PHP packages, so they were taken at different times (for example before and after an update) or on different systems. The server script's packages, changelogs and processes were not used.",
+ 'parse.serverTruncated': 'The server script output has no END line, so part of it was lost when it was copied. Copy everything from the BEGIN line to the END line.',
+ 'parse.procBadStatus': 'The restart status of {names} in the server script output is not readable (the line may have been cut off), so those processes were not checked. Copy the whole output again.',
+ 'parse.probeNotDeleted': 'The PHP probe was not deleted from the server automatically. Delete it yourself now.',
+ // n is the count exactly as the server script printed it (a string)
+ 'parse.procUnreadable': ({ n }) => `Could not check ${n} PHP ${n === '1' ? 'process' : 'processes'} for a missed restart because of missing permissions. Run the server script again as root.`,
+ 'parse.headerOnly': 'A response header shows only the PHP that handled that URL. Check other sites and FPM pools on the same server separately.',
+ 'parse.cliVersion': 'php -v shows the version of the command-line PHP. It may differ from the PHP your web service uses, so check again with the PHP probe.',
+ 'parse.noVersion': 'No PHP version was found. Paste the full PHP probe output, an X-Powered-By header, or a version string such as 8.2.26.',
+ // Input that could not be read, or was cut
+ 'reason.notRead': 'The pasted text could not be read as any of the supported outputs, so nothing could be checked. Paste the PHP probe output or the server script output again, from its BEGIN line to its END line.',
+ 'reason.inputCut': 'Part of the input was not read (it is too long, or holds too many separate outputs), and what was left out may be a PHP without the fix, so Patched cannot be confirmed.',
+ 'step.collect.shorter': 'Paste less at a time: the PHP probe output and the server script output of one server, each from its BEGIN line to its END line, and press Run check.',
+ 'parse.inputTooLong': 'The input is longer than 2 million characters, so only its beginning was read. Paste the outputs of one server at a time.',
+ 'parse.tooManyOutputs': 'The input holds more than {n} separate outputs, so only the first {n} were checked. Paste fewer outputs at a time.',
+ 'parse.serverNoBegin': 'The server script output has no BEGIN line, so it was read from its first data line to its END line. Next time, copy it from the BEGIN line.',
+ 'parse.serverHeadCut': 'The server script output has no BEGIN line and does not start with its first line, so its first lines are missing. Copy everything from the BEGIN line to the END line.',
+ 'reason.serverHeadCut': 'The server script output has no BEGIN line and starts below its first line, so its first lines (whether it ran as root, the OS release) may be missing, and Patched cannot be confirmed. Copy the whole output again.',
+ 'parse.serverUnlinkedHidden': "open_basedir kept the PHP probe from seeing whether it runs in a container, and nothing in its output (its OS release or its package list) ties it to the server script output, which may come from the host of a container. The server script's packages, changelogs and processes were not used for this PHP, and its output was checked on its own. If this PHP runs in a container, run the server script inside it (docker exec); otherwise run the PHP probe on a site of the same PHP without open_basedir.",
+
+ // Downgrades, missing SAPI packages and the revision in PHP_VERSION (Debian and Ubuntu)
+ 'reason.sapiPackageMissing': 'PHP runs here as {sapi}, but its package ({pkg}) is not in the list of installed packages. An interrupted update can leave it half-installed, and dpkg lists only packages that are fully installed; otherwise this PHP comes from somewhere else. Debian and Ubuntu build the PHP engine into the binary of each SAPI, so the other {src} packages do not show which build runs here.',
+ 'reason.revisionMissing': 'PHP_VERSION ({version}) has no {family} revision, but on {family} {release} an updated PHP build reports it: the installed package ({installed}) would show {expected}. Only the original build of the release reports the plain version, so the running PHP is an older build that was loaded before the update, and it does not have the fix.',
+ 'reason.sapiPackageMissingProc': 'A PHP process of this branch runs ({comm}), but its package ({pkg}) is not in the list of installed packages. An interrupted update can leave it half-installed, and dpkg lists only packages that are fully installed. Debian and Ubuntu build the PHP engine into the binary of each SAPI, so the other {src} packages do not show which build {comm} runs.',
+ 'note.olderPackageFixed': 'The installed package {pkg} is older than the running PHP ({version}), but it has the fix too, so the next restart loads a fixed build.',
+ 'fix.debDowngraded': '{pkg} is installed, which is older than the running PHP {version} and does not have the fix: install {fixed} or later again before the service restarts.',
+ 'note.staleDowngraded': 'Processes ({names}) still run the build that was loaded before the package was downgraded, which has the fix. Do not restart or reload them before the fixed package is installed again: they would load the vulnerable build.',
+ 'reason.staleUnfixed': 'Running processes ({names}) are still using PHP files that were replaced after they started, by an update or a downgrade. The build installed now is not shown to have the fix, so a restart alone does not help: install the fixed package first, then restart.',
+ 'mitigate.staleReload': ' Processes ({names}) still use files from before the last package change, and a reload makes PHP-FPM load the build installed now: if that change was a downgrade, install the fixed package first.',
+ 'step.collect.sapiPackage': 'Check the state of {pkg} with the commands below: dpkg -l shows every {src} package with its state (ii is fully installed), and dpkg --audit lists packages that an interrupted update left unfinished. The last two commands finish such an update (apt-get install -f installs what is missing, and asks before it changes anything). Then restart PHP as in the next step and run the check again.',
+
+ // deb.sury.org and Remi builds of end-of-life branches
+ 'note.suryEol': 'This PHP ({version}) is a deb.sury.org build of PHP {branch}, a branch that has reached end of life upstream. For such branches deb.sury.org ships backported security fixes under the same upstream version, so only the changelog of the installed package shows whether a build has them.',
+ 'fix.suryEol': 'deb.sury.org ships backported security fixes for PHP {branch}, an end-of-life branch, under the same version number, so its latest {src} build may have them.',
+ 'step.collect.hiddenLink': 'Nothing in the PHP probe output ties it to the server script output (see the notes). If this PHP runs in a container, copy the server script into the container and run it there as root, as below, and paste that output on its own. If it does not, the server script output pasted on its own already covers the PHP builds and processes of this server; or run the PHP probe through a site of the same PHP that has no open_basedir setting, so that the probe can read the OS release and the package list, and paste that output together with the server script output.',
+ 'reason.suryChangelogFixed': 'The changelog of {pkg}, read by the server script at the installed version, records the fixes for {cves}, backported by deb.sury.org.',
+ 'reason.suryChangelogMissing': 'The changelog of {pkg}, read by the server script at the installed version, has no record of a fix for {cves}: this build does not have the backport yet.',
+ 'reason.suryBuiltBefore': 'This build ({version}) was made on {date}, before the fixed releases of {released}, so it cannot have the backported fixes.',
+ 'reason.suryEolUnread': 'Whether this deb.sury.org build of PHP {branch} ({version}) has the backported fixes shows only in its package changelog, which was not read here. Run the server script, which reads the changelog of every installed PHP package.',
+ 'note.remiEol': 'This PHP ({pkg}) comes from the Remi repository. Its collections of end-of-life branches (php74, php80, php81) get backported security fixes under the same version number, so only the package changelog shows whether a build has them.',
+ 'reason.remiChangelogMissing': 'The changelog of {pkg} has no record of a fix for {cves}: this Remi build does not have the backport yet.',
+ 'reason.remiEolUnread': 'Whether this Remi build of PHP {branch} ({pkg}) has the backported fixes shows only in its package changelog, which was not read here. Run the server script, which reads the changelog of every installed PHP package.',
+ 'eol.hostSury': "deb.sury.org is configured on this server, and apt prefers its packages over the distribution's: turn that repository off (its file in /etc/apt/sources.list.d/) before you install, and check that apt-cache policy shows {fixed} as the candidate, or apt installs deb.sury.org's {src}, which is not this fixed package.",
+ 'step.eol.hostSwitch': "If you cannot move yet: on this {system} server, the distribution's own {src} package ({fixed}) has both fixes backported, but the {src} packages installed now ({foreign}) come from another repository. Turn that repository off first (its file in /etc/apt/sources.list.d/; apt-cache policy names it), then run the commands below: the second reinstalls every {src} package that is now at {foreign} at version {fixed}, and --allow-downgrades lets apt replace the higher-numbered build. Test the application afterwards.{extra}",
+
+ // RHEL family, Amazon Linux and other distributions
+ 'reason.rhelOlderThanFixed': '{pkg} is older than {fixed}, the build that fixes both CVEs ({advisory}).',
+ 'reason.amazonOlder': '{pkg} is older than PHP {fixed}, the upstream fixed release. Amazon Linux ships its PHP fixes as upstream releases.',
+ 'step.rpm.confirmVersion': 'Check that the update is installed: rpm -q should now show {fixed} or later. This fix is a new upstream release, and its changelog entry names the version rather than the CVEs, so the version is what shows it.',
+ 'reason.vendorBackports': '{vendor} keeps the upstream version of its PHP packages and backports security fixes into them, so PHP {version} may have both fixes even though it is older than the upstream fixed release. The version alone cannot settle it: check the {vendor} security advisories, or the changelog of the installed PHP package, for both CVEs.',
+ 'step.collect.vendor': 'Check the {vendor} security advisories for both CVEs, or the changelog of the installed PHP package: the commands below list the PHP packages and print the CVE lines of the changelog. Each CVE should have a line once the fix is installed.',
+ 'step.other.updateEol': 'This PHP comes from {vendor}, which backports security fixes to its PHP {branch} packages without changing the version number. Install its latest PHP update with the commands below, then check the {vendor} security advisories, or the package changelog as in the first step, for both CVEs.',
+
+ // Processes the server script could not check
+ 'reason.processesUnreadable': 'The server script could not check some PHP processes ({n}) for a missed restart: it could not read them, or their status line was cut off. They may run another PHP than the one checked here, one that was not restarted after an update, so Patched cannot be confirmed. Run the server script as root and paste the whole output.',
+ 'reason.processesUnreadableRoot': 'The server script could not check some PHP processes ({n}) for a missed restart, even as root. They may run another PHP than the one checked here, one that was not restarted after an update, so Patched cannot be confirmed. Inside a container without CAP_SYS_PTRACE, run the server script on the host instead, or check each PHP with the PHP probe.',
+ 'reason.runningUncheckedRoot': 'The installed package has the fix, but the server script could not read some PHP processes, even as root, so the running PHP could not be checked. Inside a container without CAP_SYS_PTRACE, run the server script on the host, or run the PHP probe, to confirm that the running PHP was restarted after the update.',
+ 'reason.runningPartlyHidden': "The installed package has the fix, but the server script did not run as root, so it saw only the PHP processes this account can read. With hidepid, /proc hides other accounts' processes completely, so a PHP that was not restarted after the update may not show up. Run the server script again with sudo.",
+ 'reason.serviceUnseen': 'The installed package has the fix, but the server script cannot see the processes of {names} on this system, so it could not check whether they were restarted after the update.',
+ 'step.collect.unreadableAsRoot': 'The server script ran as root but still could not read some PHP processes. That happens inside a container that lacks CAP_SYS_PTRACE, and running it again with sudo gives the same output. Run it on the host instead (the commands below; compare the hash with the one on the Server script tab of this page), or run the PHP probe through each site, and paste the new output here.',
+ 'step.collect.unseen': 'The server script cannot see the processes of {names} on this system. Check with the command below whether they run and when they started. If one started before the update, restart it as in the next step, or check that PHP with the PHP probe.',
+ 'parse.procUnreadableRoot': ({ n }) => `Could not check ${n} PHP ${n === '1' ? 'process' : 'processes'} for a missed restart, even as root. Inside a container, reading another account's process memory map needs CAP_SYS_PTRACE: run the server script on the host, or use the PHP probe.`,
+};
+
+const ko = {
+ // Verdicts
+ 'verdict.title.patched': '수정이 적용된 PHP입니다',
+ 'verdict.title.vulnerable': '취약한 PHP입니다',
+ 'verdict.title.pending': '취약하며, 배포판의 수정 패키지가 아직 없습니다',
+ 'verdict.title.restart': '설치된 PHP는 수정됐지만 실행 중인 PHP는 여전히 취약합니다',
+ 'verdict.title.unknown': '판단할 근거가 부족합니다',
+ 'verdict.label.patched': '패치됨',
+ 'verdict.label.vulnerable': '취약',
+ 'verdict.label.pending': '수정 대기',
+ 'verdict.label.restart': '재시작 필요',
+ 'verdict.label.unknown': '판단 불가',
+
+ // Facts
+ 'fact.phpVersion': 'PHP 버전',
+ 'fact.installedVersion': '설치된 패키지 버전',
+ 'fact.sapi': 'SAPI',
+ 'fact.sapiInferred': '{sapi} (실행 중인 프로세스로 추정)',
+ 'fact.distribution': '배포판',
+ 'fact.webServer': '웹 서버',
+ 'fact.packageChecked': '확인한 패키지',
+ 'fact.otherBuilds': '그 밖에 설치된 PHP 빌드',
+
+ // Distribution and SAPI labels
+ 'distro.rhel': 'RHEL 계열',
+ 'distro.rhelRelease': 'RHEL {release} 계열',
+ 'distro.upstream': '업스트림 빌드(직접 컴파일 또는 공식 Docker 이미지)',
+ 'distro.other': '기타',
+ 'distro.unknown': '알 수 없음',
+ 'distro.inferred': '{label} (버전 문자열로 추정)',
+ 'distro.kernel': '{label} (커널 버전으로 추정)',
+ 'distro.serverHeader': '{label} (Server 헤더로 추정)',
+ 'distro.derived': '{label} ({parent} 기반)',
+ 'distro.manual': '{label} (수동 선택)',
+ 'sapi.cli': '명령줄(CLI)',
+
+ // One of several separate outputs in the input
+ 'part.probe': 'PHP 프로브(PHP {version})',
+ 'part.probeNoVersion': 'PHP 프로브 출력',
+ 'part.header': 'X-Powered-By 헤더(PHP/{version})',
+ 'part.cli': '-v 출력(PHP {version}, {sapi})',
+ 'part.server': '서버 스크립트 출력({system})',
+ 'part.version': '버전 문자열 {version}',
+
+ // Reasons: upstream
+ 'reason.noVersion': 'PHP 버전을 찾지 못했습니다.',
+ 'reason.preRelease': '{version} 버전은 사전 릴리스(alpha, beta, RC 또는 -dev 스냅숏)이므로 수정 릴리스 기준으로 확인할 수 없습니다. 릴리스 노트나 커밋에서 두 CVE의 수정이 모두 들어갔는지 확인하세요.',
+ 'reason.newerBranch': 'PHP {branch} 브랜치는 수정 릴리스 목록({branches})의 어느 브랜치보다도 새롭습니다. 릴리스 노트에서 두 CVE의 수정이 모두 들어갔는지 확인하세요.',
+ 'reason.upstreamPatched': 'PHP {version} 버전은 업스트림 수정 릴리스 {fixed} 이상입니다.',
+ 'reason.upstreamVulnerable': 'PHP {version} 버전은 업스트림 수정 릴리스 {fixed}보다 낮습니다.',
+ 'reason.predates54': 'PHP {version} 버전은 공개된 공격 경로(세션 업로드 진행률 기능, PHP 5.4 이상)보다 먼저 나왔습니다. 다만 공식 권고에는 영향받는 버전의 하한이 명시되어 있지 않으며, 이 버전은 오래전에 지원 종료(EOL)되어 다른 취약점도 많습니다.',
+ 'reason.eol': 'PHP {branch} 브랜치는 지원 종료(EOL)되어 공식 수정 릴리스가 없으므로, 이 브랜치의 업스트림 빌드는 취약합니다. 두 수정은 이를 백포트한 배포판 패키지에만 들어 있을 수 있습니다.',
+ 'reason.osBlocked': 'open_basedir 때문에 프로브가 OS 릴리스 정보와 설치된 패키지 목록을 읽지 못했습니다. 그래서 PHP {version} 버전이 같은 버전 번호로 수정을 백포트한 배포판 패키지인지 알 수 없습니다. 서버 스크립트를 실행하거나, 배포판 목록에서 배포판을 고른 뒤 결과확인을 다시 누르세요.',
+ 'reason.osUnread': '프로브가 OS 릴리스 정보를 읽지 못했습니다. 그래서 PHP {version} 버전이 RHEL 계열 등에서처럼 같은 버전 번호로 수정을 백포트한 배포판 패키지인지 알 수 없습니다. 서버 스크립트를 실행하거나, 배포판 목록에서 배포판을 고른 뒤 결과확인을 다시 누르세요.',
+ 'reason.couldBeDistro': 'PHP {version} 버전은 같은 버전 번호로 수정을 백포트한 배포판 패키지({releases})일 수도 있으므로 버전만으로는 확인할 수 없습니다. 설치된 패키지와 그 changelog를 읽는 서버 스크립트를 실행하세요. Debian과 Ubuntu에서는 PHP 프로브로도 패키지 버전을 읽을 수 있습니다.',
+
+ // Reasons: Debian and Ubuntu
+ 'reason.noDataChangelogFixed': '{reason} 다만 설치된 패키지의 changelog에 두 CVE의 수정이 모두 기록되어 있으므로 수정된 것으로 간주합니다.',
+ 'reason.debReleaseUnknown': '{family} 릴리스를 알 수 없어 백포트 여부를 확인하지 못했습니다. 배포판 목록에서 릴리스를 고른 뒤 결과확인을 다시 누르세요.',
+ 'reason.debNoRelease': '배포판 데이터에 {family} {release}에 대한 기록이 없습니다. 지원 종료(EOL)됐거나 아직 권고가 공개되지 않았을 수 있습니다. {family} 보안 권고를 직접 확인하세요.',
+ 'reason.debNoPackage': '배포판 데이터에 {family} {release}의 {src} 패키지에 대한 기록이 없습니다. 다른 저장소에서 설치한 패키지이거나 아직 권고가 나오지 않았을 수 있습니다.',
+ 'reason.debBackported': '{family}에서 두 CVE의 수정을 {src} {version} 패키지에 백포트했습니다. 버전 번호({upstream})는 낮지만 수정이 포함되어 있습니다.',
+ 'reason.debOlder': '{src} {version} 패키지는 {family} 수정 버전 {fixed}보다 오래되었습니다.',
+ 'reason.debOlderUpstream': 'PHP {version} 버전은 {family} {release}의 {src} 수정 패키지({fixed})보다 업스트림 버전이 낮으므로 수정이 포함되어 있지 않습니다.',
+ 'reason.debNeedPackage': 'PHP_VERSION({version})은 {family} {release} {src} 패키지의 업스트림 버전으로, 수정 패키지를 설치해도 바뀌지 않습니다. 그래서 버전만으로는 이 서버에 수정이 있는지 알 수 없습니다. 서버 스크립트를 실행하거나, 설치된 패키지 목록(/var/lib/dpkg/status)을 읽을 수 있는 곳에서 PHP 프로브를 실행하세요.',
+ 'reason.debPending': '{family} {release}에는 아직 {src}의 수정 패키지가 없습니다. 배포판 보안 권고를 계속 확인하고, 수정이 나오는 즉시 업데이트하세요.',
+ 'reason.debNoCveData': '배포판 데이터에 두 CVE 중 하나 이상에 대한 기록이 없습니다.',
+ 'reason.restartInstalledFixed': '설치된 패키지({installed})에는 두 CVE의 수정이 포함되어 있지만, 실행 중인 PHP는 여전히 이전 버전({running})입니다. 업데이트 후 서비스를 재시작하지 않은 것으로 보입니다.',
+ 'reason.installedNewer': '설치된 패키지({installed})가 실행 중인 PHP({running})보다 새 버전입니다. 업데이트 후 서비스를 재시작하지 않은 것으로 보입니다.',
+ 'reason.installedOlderUnfixed': '실행 중인 PHP({running})에는 수정이 적용되어 있지만, 설치된 패키지({installed})는 그보다 오래되어 수정이 없습니다. 서비스가 시작된 뒤 패키지가 다운그레이드되었거나 다른 빌드로 바뀐 것입니다. 다음에 서비스를 재시작하면(PHP-FPM은 reload해도) 취약한 빌드가 로드됩니다. 서비스가 재시작되기 전에 수정 패키지를 다시 설치하세요.',
+ 'reason.foreignRevision': '{version} 버전은 {family} {release}의 {src} 패키지와 업스트림 버전이 같지만, {family}에서 빌드한 리비전이 아니므로 다른 저장소(PPA나 다른 공급자)에서 온 패키지입니다. 버전 비교에서 수정 버전보다 높게 나온다고 해서 백포트된 수정이 들어 있다는 뜻은 아닙니다. 이 패키지를 제공한 곳의 보안 권고를 확인하거나 {family} 패키지를 설치하세요.',
+
+ // Reasons: RHEL family
+ 'reason.rhelChangelogFixed': '이 PHP를 제공하는 {pkg} 패키지의 changelog에 {cves} 수정이 기록되어 있습니다.',
+ 'reason.rhelChangelogMissing': '이 PHP를 제공하는 {pkg} 패키지의 changelog에 {cves} 수정 기록이 없습니다. 패키지가 아직 업데이트되지 않았을 가능성이 높습니다.',
+ 'reason.rhelNotAffected': 'Red Hat은 RHEL {release}에서 {cves}의 영향이 없다고 밝혔습니다.',
+ 'reason.rhelNoMatchingPackage': '서버 스크립트가 확인 대상 PHP와 같은 버전({version})의 PHP 패키지를 찾지 못했습니다. 찾은 패키지: {packages}. 이 PHP는 다른 저장소, 컨트롤 패널 또는 컨테이너에서 온 것으로 보이므로 이 changelog는 해당되지 않습니다. 이 PHP를 제공한 곳의 보안 권고를 확인하거나, 이 PHP가 실행되는 곳에서 서버 스크립트를 실행하세요.',
+ 'reason.rhelChangelogNotChecked': '이 PHP를 제공하는 {pkg} 패키지의 changelog를 확인하지 못했습니다. 서버 스크립트 출력에 이 패키지의 changelog 줄이 없습니다(이전 버전의 스크립트이거나 changelog를 읽지 못한 경우입니다). 모든 PHP 패키지의 changelog를 읽는 최신 서버 스크립트를 실행하세요.',
+ 'reason.rhelChangelogUnread': '{pkg}의 changelog에서 {cves} 항목을 확인하지 못했습니다. 서버 스크립트 출력에 이 항목의 건수가 제대로 기록되어 있지 않아 이 PHP에 해당 수정이 있는지 알 수 없습니다. 서버 스크립트 출력 전체를 다시 복사하거나 최신 서버 스크립트를 실행하세요.',
+ 'reason.rhelNeedServerScript': 'RHEL 계열에서는 버전 번호만으로 수정이 백포트됐는지 알 수 없습니다. 서버 스크립트를 실행해 패키지 changelog를 확인하세요.',
+ 'reason.remiChangelogFixed': '이 PHP는 Remi 저장소에서 설치한 것입니다. {pkg}의 changelog에 {cves}의 수정이 기록되어 있으며, 버전 번호를 바꾸지 않고 백포트되었습니다.',
+
+ // Reasons: processes and the running PHP
+ 'reason.staleProcesses': '실행 중인 프로세스({names})가 아직 업데이트 이전의 PHP 파일을 사용하고 있습니다.',
+ 'reason.staleOtherProcesses': '여기서 확인한 PHP에는 수정이 적용되어 있지만, 다른 PHP 프로세스({names})가 아직 업데이트 이전의 파일을 사용하고 있으며 취약한 이전 빌드일 수 있습니다. 이 프로세스들을 재시작하세요.',
+ 'reason.runningUnchecked': '설치된 패키지에는 수정이 포함되어 있지만, 서버 스크립트가 일부 PHP 프로세스를 읽지 못해 실행 중인 PHP는 확인하지 못했습니다. 업데이트 후 실행 중인 PHP가 재시작됐는지 확인하려면 root 권한으로 서버 스크립트를 실행하거나 PHP 프로브를 실행하세요.',
+ 'reason.noRunningPhpSeen': '이 빌드의 실행 중인 PHP 프로세스를 찾지 못했으므로 이 결과는 설치된 패키지만을 근거로 합니다. PHP가 컨테이너 안이나 다른 방식으로 실행된다면 PHP 프로브로 확인하세요.',
+ 'reason.loadedBuildUnconfirmed': 'PHP_VERSION({version})은 이전 빌드와 수정된 빌드가 같으므로 이 결과는 설치된 패키지를 근거로 합니다. 업데이트 후 PHP가 재시작됐는지는 프로브로 알 수 없으니, 실행 중인 프로세스를 확인하는 서버 스크립트도 root 권한으로 실행하세요.',
+ 'reason.runningHidden': '설치된 패키지에는 수정이 포함되어 있지만, 서버 스크립트를 root 권한으로 실행하지 않아 확인할 수 있는 PHP 프로세스를 하나도 찾지 못했습니다. root가 아니면 /proc이 다른 계정의 프로세스를 완전히 숨길 수 있으므로(hidepid), 업데이트 후 재시작하지 않은 PHP가 있어도 보이지 않습니다. sudo로 서버 스크립트를 다시 실행하세요.',
+ 'reason.processesNotCheckedNoService': '서버 스크립트를 root 권한으로 실행하지 않아 실행 중인 프로세스는 확인하지 못했습니다. 이 빌드에는 PHP-FPM, CGI, Apache 모듈 패키지가 없어 웹 서버 프로세스가 이 빌드의 이전 버전을 메모리에 유지할 수 없으므로, 이 결과는 설치된 패키지를 근거로 합니다.',
+ 'reason.probeProcessNotFound': 'PHP_VERSION({version})은 이전 빌드와 수정된 빌드가 같으므로 이 결과는 설치된 패키지를 근거로 합니다. 서버 스크립트도 이 PHP의 실행 중인 프로세스를 찾지 못해 재시작 누락 여부를 확인할 수 없었습니다. 이 PHP가 컨테이너 안에서 또는 다른 이름으로 실행된다면, 확실히 해 두기 위해 업데이트 후 재시작하세요.',
+ 'reason.serverConflict': 'PHP 프로브와 서버 스크립트가 서로 다른 PHP 패키지를 보고하므로(참고 사항 참조) 설치된 패키지도, 재시작 확인도 믿을 수 없습니다. 프로브를 실행한 직후 서버 스크립트를 다시 실행하고 두 출력을 함께 붙여 넣으세요.',
+ 'reason.binaryOnDisk': '이 버전은 {sapi} 바이너리의 -v 출력에서 나온 것으로, 실행 중인 서비스가 로드한 PHP가 아니라 디스크에 설치된 바이너리의 버전입니다. 업데이트 후 서비스를 재시작하지 않았다면 여전히 이전 빌드가 실행됩니다. PHP 프로브를 실행하거나 root 권한으로 서버 스크립트를 실행해 확인하세요.',
+ 'reason.staleUnaccounted': '여기서 확인한 어느 PHP 빌드에도 속하지 않는 PHP 프로세스({names})가 아직 업데이트 이전의 파일을 사용하고 있습니다. 취약한 이전 빌드가 실행 중일 수 있습니다. 이 프로세스들을 재시작하고, 그 PHP도 프로브로 확인하세요.',
+ 'reason.unaccountedProcesses': '실행 중인 PHP 프로세스({names})가 여기서 확인한 어느 PHP 빌드에도 속하지 않으므로, 이 프로세스가 실행하는 PHP는 확인하지 못했습니다. 컨테이너, 컨트롤 패널 또는 직접 빌드한 PHP일 수 있습니다. 그 PHP를 PHP 프로브로 확인하세요(컨테이너에서 실행된다면 컨테이너 안에서).',
+ 'reason.serverTruncated': '서버 스크립트 출력이 END 줄 전에 잘려 뒷부분(실행 중인 프로세스, 침해 지표)이 빠졌으므로 패치됨으로 확인할 수 없습니다. 출력 전체를 다시 복사하세요.',
+ 'reason.forBuild': '[{verdict}] {build}: {reasons}',
+ 'reason.forRunning': '[{verdict}] 실행 중인 PHP {version}: {reasons}',
+ 'reason.forPart': '[{verdict}] {part}: {reasons}',
+
+ // Notes
+ 'note.sury': '이 PHP({version})는 deb.sury.org 빌드입니다. 이 저장소는 업스트림 버전을 그대로 따르므로 업스트림 수정 릴리스를 기준으로 확인합니다.',
+ 'note.upstreamBuild': '배포판 패키지가 아닌 업스트림 빌드(직접 컴파일한 PHP 또는 공식 Docker 이미지)로 보입니다. 업스트림 수정 릴리스를 기준으로 확인합니다.',
+ 'note.configOutsideEtc': 'PHP가 /etc 밖의 {path}에서 설정을 읽으므로 배포판 패키지가 아닙니다. 소스에서 빌드했거나 Docker 이미지 또는 컨트롤 패널에서 온 PHP입니다. 설치된 배포판 패키지는 이 PHP와 관계가 없으므로 업스트림 수정 릴리스를 기준으로 확인합니다.',
+ 'note.packageNotRunning': '설치된 {pkg} 패키지는 실행 중인 PHP({version})보다 오래된 버전이므로, 실행 중인 PHP는 이 패키지에서 온 것이 아닙니다. 이 패키지는 제외하고 실행 중인 PHP만으로 확인했습니다.',
+ 'note.notDistroBuild': '{version} 버전은 {family} {release}에서 제공하는 {src} 패키지({shipped})보다 새 버전이므로 그 릴리스의 패키지가 아닙니다. 다른 저장소에서 설치했거나 소스에서 빌드한 것으로 보이며, 업스트림 수정 릴리스를 기준으로 확인합니다.',
+ 'note.alpine': 'Alpine 패키지는 대개 업스트림 버전을 따르므로 업스트림 수정 릴리스를 기준으로 확인합니다. 패키지 리비전(-rN)에 별도 패치가 들어갈 수 있으므로 Alpine 보안 권고도 함께 확인하세요.',
+ 'note.otherDistro': '사용 중인 배포판({vendor})은 버전 번호를 바꾸지 않고 수정을 백포트할 수 있습니다. 이 결과는 업스트림 수정 릴리스를 기준으로 하므로 배포판의 보안 권고도 함께 확인하세요.',
+ 'note.backportBranch': 'PHP 보안팀이 php-src 저장소에 최소한의 백포트 브랜치({branch})를 공개했습니다. PHP를 직접 빌드한다면 이 브랜치를 사용할 수 있지만, 근본적인 해결책은 지원되는 버전으로 업그레이드하는 것입니다. 이 확인 도구는 버전 번호만으로 그 브랜치에서 빌드한 PHP를 구별하지 못합니다.',
+ 'note.ubuntuPro': '이 수정은 Ubuntu Pro(ESM) 저장소에만 공개되어 있습니다. 받으려면 Ubuntu Pro 구독(개인 사용은 무료)을 연결(attach)해야 합니다.',
+ 'note.ubuntuProLegacy': '이 수정은 Ubuntu Pro Legacy Support 애드온으로만 공개됩니다. 이 애드온은 유료이며 무료 개인 Ubuntu Pro 구독에는 포함되지 않습니다.',
+ 'note.rhelCompatible': 'RHEL 호환 배포판은 Red Hat 권고(RHSA)를 바탕으로 자체 보안 권고(Rocky RLSA, AlmaLinux ALSA, Amazon Linux ALAS 등)를 발행합니다. 공개 시점은 조금씩 다를 수 있습니다.',
+ 'note.remi': '이 PHP({pkg})는 업스트림 버전을 따르는 Remi 저장소에서 설치한 것이므로 업스트림 수정 릴리스를 기준으로 확인합니다.',
+ 'note.noRpmPackage': '서버 스크립트 출력에 rpm으로 설치된 PHP 패키지가 없으므로, 이 PHP는 소스에서 빌드했거나 컨트롤 패널 또는 컨테이너에서 온 것으로 보입니다. 업스트림 수정 릴리스를 기준으로 확인합니다. PHP를 dnf나 yum으로 설치했다면 최신 서버 스크립트를 실행하세요. 이전 버전의 서버 스크립트는 php8.3-common 같은 이름의 패키지를 목록에 넣지 않았습니다.',
+ 'note.newerPackageWaiting': '실행 중인 PHP({running})보다 새 패키지({installed})가 설치되어 있습니다. 실행 중인 PHP에는 이미 수정이 적용되어 있으니, 적당한 때에 서비스를 재시작해 업데이트를 반영하세요.',
+ 'note.staleFixed': '프로세스({names})가 그 뒤에 설치된 업데이트 이전의 파일을 아직 사용하고 있습니다. 프로브를 실행한 PHP에는 이미 수정이 적용되어 있으니, 적당한 때에 서비스를 재시작해 업데이트를 반영하세요.',
+ 'note.severalOutputs': '입력에 서로 다른 출력이 {n}개 들어 있습니다. 출력마다 따로 확인해 출력별 결과를 함께 적었고, 그중 가장 나쁜 결과를 표시합니다.',
+ 'note.manualConflictOs': '배포판 목록에서 고른 배포판({chosen})이 붙여 넣은 출력의 배포판({detected})과 다르므로 {detected} 기준으로 확인했습니다. 출력이 의도한 서버의 것이 맞다면 배포판 목록을 자동 감지로 되돌리세요.',
+ 'note.manualConflictVersion': '배포판 목록에서 고른 배포판({chosen})이 버전 문자열 {version}의 배포판({detected})과 다르므로 {detected} 기준으로 확인했습니다.',
+
+ // Distribution advisories
+ 'advisory.fixedIn': '{version}에서 수정됨',
+ 'advisory.pending': '수정 대기',
+ 'advisory.noData': '데이터 없음',
+ 'rhelState.affected': '영향받음',
+ 'rhelState.underInvestigation': '조사 중',
+ 'rhelState.fixDeferred': '수정 보류',
+ 'rhelState.willNotFix': '수정 계획 없음',
+ 'rhelState.notAffected': '영향 없음',
+ 'rhelState.outOfSupportScope': '지원 범위 밖',
+ 'list.separator': ', ',
+ 'list.and': ', ',
+
+ // Remediation steps (r.actions)
+ // Restart
+ 'action.restartNow': '실행 중인 PHP는 업데이트 전에 이전 빌드를 로드했고, 프로세스가 종료될 때까지 그 빌드를 메모리에 유지합니다. 아래 명령으로 PHP를 실행하는 서비스를 재시작해 새 빌드를 로드하게 하세요.{extra}',
+ 'action.restartAfterUpdate': '업데이트한 뒤에는 아래 명령으로 PHP를 실행하는 서비스를 재시작해 새 빌드를 로드하게 하세요.{extra}',
+ 'action.restartAfterUpdateGeneric': '업데이트한 뒤에는 PHP를 실행하는 서비스(웹 서버 또는 PHP-FPM)를 재시작해 새 빌드를 로드하게 하세요. Apache mod_php에서는 reload나 graceful 재시작이 아니라 Apache를 완전히 재시작해야 새 빌드가 적용됩니다.',
+ 'action.restartGeneric': 'PHP를 실행하는 서비스(웹 서버 또는 PHP-FPM)를 재시작해 새 빌드를 로드하게 하세요. Apache mod_php에서는 reload나 graceful 재시작이 아니라 Apache를 완전히 재시작해야 새 빌드가 적용됩니다.',
+ 'action.restartProcesses': '이 PHP 프로세스({names})를 실행하는 서비스나 컨테이너를 재시작해 새 빌드를 로드하게 하세요. 아래 명령은 프로세스를 시작한 주체를 보여 줍니다. CGROUP 열의 이름이 .service로 끝나면 systemd 서비스이고(systemctl로 재시작), docker-<id>.scope이면 컨테이너입니다(그 컨테이너를 재시작).',
+ 'restart.modPhp': ' mod_php에서는 PHP가 Apache 프로세스 안에서 실행되므로 reload나 graceful 재시작이 아니라 Apache를 완전히 재시작해야 새 빌드가 적용됩니다.',
+ 'restart.cgi': ' 이 서버에서는 PHP가 CGI/FastCGI로 실행됩니다. php-cgi 프로세스는 웹 서버(첫 번째 명령처럼 mod_fcgid를 쓰는 Apache)나 별도의 FastCGI 프로세스 관리자가 시작하므로, 그 프로세스를 시작하는 쪽을 재시작하세요. 두 번째 명령은 php-cgi 프로세스와 시작 시각을 보여 주며, 재시작 이전에 시작된 프로세스가 없어야 합니다.',
+ 'restart.choose': ' 이 서버에서 PHP가 실행되는 방식(Apache mod_php 또는 PHP-FPM)에 맞는 줄을 실행하세요.',
+ 'step.restart.ifUnsure': '설치된 패키지에는 이미 수정이 들어 있습니다. 이 패키지로 업데이트한 뒤 PHP를 재시작했는지 확실하지 않다면 지금 아래 명령으로 재시작하세요.{extra}',
+ 'step.restart.confirm': '그런 다음 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요. 더 이상 재시작 필요로 나오지 않아야 합니다.',
+ 'step.restart.confirmServer': '그런 다음 서버 스크립트를 root 권한으로 다시 실행하고 결과확인을 누르세요. 업데이트 이전의 파일을 아직 사용하는 PHP 프로세스가 하나도 없어야 합니다. PHP 프로브를 사용했다면 프로브도 다시 실행하세요.',
+ 'step.recheck': '그런 다음 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요. 결과가 패치됨으로 나와야 합니다.',
+ 'step.recheckServer': '그런 다음 서버 스크립트를 root 권한으로 다시 실행하고(PHP 프로브를 사용했다면 프로브도) 결과확인을 누르세요. 결과가 패치됨으로 나와야 합니다.',
+ 'step.recheckServerLater': '업데이트를 설치한 뒤 서버 스크립트를 root 권한으로 다시 실행하고(PHP 프로브를 사용했다면 프로브도) 결과확인을 누르세요. 결과가 패치됨으로 나와야 합니다.',
+ 'step.recheckChangelog': '그런 다음 서버 스크립트를 root 권한으로 다시 실행하고 결과확인을 누르세요. 서버 스크립트는 업데이트된 패키지의 changelog를 읽어 두 수정이 들어 있는지 보여 줍니다.',
+ 'step.recheckServerDistro': '그런 다음 서버 스크립트를 root 권한으로 다시 실행하고, 배포판 목록에서 릴리스를 고른 상태로 출력을 여기에 붙여 넣은 뒤 결과확인을 누르세요. 결과가 패치됨으로 나와야 합니다.',
+
+ // What to collect when the verdict is open
+ 'step.collect.server': '이 PHP가 실행되는 서버에서 서버 스크립트를 root 권한으로 실행하세요. 앞의 두 명령은 스크립트를 내려받아 해시를 출력합니다. 이 해시를 이 페이지의 서버 스크립트 탭에 나온 값과 비교하고, 스크립트 내용을 읽어 본 뒤 마지막 명령으로 실행하세요. 출력을 여기에 붙여 넣고(PHP 프로브 출력이 있으면 함께) 결과확인을 누르세요. 서버 스크립트는 OS 릴리스 정보, 설치된 PHP 패키지와 그 changelog, 실행 중인 PHP 프로세스를 읽습니다.',
+ 'step.collect.serverInContainer': '이 PHP는 컨테이너 안에서 실행되므로 서버 스크립트를 그 컨테이너 안에서 실행하세요. 앞의 두 명령은 스크립트를 내려받아 해시를 출력하고(이 페이지의 서버 스크립트 탭에 나온 값과 비교하세요), 뒤의 두 명령은 스크립트를 컨테이너에 복사해 그 안에서 root로 실행합니다(-u 0). 그 출력만 여기에 붙여 넣고 결과확인을 누르세요.',
+ 'step.collect.root': '서버 스크립트를 root 권한(sudo)으로 다시 실행하고, 새 출력을 여기에 붙여 넣은 뒤 결과확인을 누르세요. root가 아니면 다른 계정의 PHP 프로세스를 읽을 수 없으므로, 업데이트 후 재시작하지 않은 PHP가 보이지 않습니다.',
+ 'step.collect.distro': '배포판을 알고 있다면, 결과확인 옆의 배포판 목록에서 배포판과 릴리스를 고른 뒤 결과확인을 다시 눌러도 됩니다.',
+ 'step.collect.distroOnly': '서버 스크립트 출력에 이 시스템의 릴리스가 나오지 않습니다. 결과확인 옆의 배포판 목록에서 배포판과 릴리스를 고른 뒤 결과확인을 다시 누르세요.',
+ 'step.collect.probe': '웹 서버를 통해 PHP 프로브를 실행하세요. 프로브를 내려받고(첫 번째 명령 또는 이 페이지의 PHP 프로브 탭), 파일 맨 위의 $TOKEN을 영문자와 숫자로 된 12자 이상의 임의 문자열로 바꾼 뒤(openssl rand -hex 16으로 만들 수 있습니다), 추측하기 어려운 이름으로 사이트의 웹 루트에 올리고 브라우저나 두 번째 명령으로 한 번 여세요. 출력된 내용 전체를 여기에 붙여 넣고 결과확인을 누르세요.{next}',
+ 'collect.probeNext': ' 설치된 패키지와 실행 중인 프로세스도 확인하려면 다음 단계처럼 서버 스크립트도 실행하세요.',
+ 'step.collect.whole': '서버 스크립트 출력 전체를 BEGIN 줄부터 END 줄까지 다시 복사해 여기에 붙여 넣고 결과확인을 누르세요.',
+ 'step.collect.together': 'PHP 프로브를 실행한 직후 서버 스크립트를 root 권한으로 실행하고, 두 출력이 같은 시점을 나타내도록 함께 붙여 넣은 뒤 결과확인을 누르세요.',
+ 'step.collect.elsewhere': '이 프로세스들이 실행하는 PHP는 그 PHP가 실행되는 곳에서 확인하세요. 컨테이너 안이라면 아래처럼 서버 스크립트를 컨테이너에 복사해 그 안에서 root로 실행하고(-u 0은 컨테이너 안에서 root로 실행합니다) 그 출력만 붙여 넣으세요. 컨테이너가 아니라면 그 PHP가 처리하는 사이트에서 PHP 프로브를 실행하세요.',
+ 'step.collect.advisories': '배포판 데이터에 이 경우에 대한 기록이 없으므로, 두 CVE에 대한 {family} 페이지(아래)를 직접 확인하세요. 각 릴리스의 {src} 수정 버전이 나와 있습니다. 그동안 다음 단계처럼 업데이트하고 재시작하세요.',
+ 'step.collect.supplier': '이 패키지가 어느 저장소에서 왔는지 확인하세요. apt-cache policy는 설치된 버전에 ***를 표시하고 그 아래에 출처를 보여 줍니다. 그런 다음 그 공급자의 보안 권고에서 두 CVE를 확인하거나, {family} 패키지를 대신 설치하세요.',
+
+ // Debian and Ubuntu packages
+ 'fix.deb': '{pkg} 패키지가 설치되어 있으며, {family} {release}의 수정 버전은 {src} {fixed}입니다.',
+ 'fix.debRunning': '이 서버에서 실행 중인 PHP는 {version} 버전이며, {family} {release}의 수정 버전은 {src} {fixed}입니다.',
+ 'fix.debOpen': '결과가 판단 불가이더라도 {family}의 최신 {src} 업데이트는 지금 설치하세요.',
+ 'fix.sury': '{pkg} 패키지는 업스트림 릴리스를 그대로 제공하는 deb.sury.org에서 설치한 것이며, {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다.',
+ 'step.ubuntuPro': 'Ubuntu {release}의 수정 패키지는 Ubuntu Pro(ESM)에만 공개되어 있습니다. Ubuntu Pro는 개인 사용이면 기기 5대까지 무료입니다. https://ubuntu.com/pro 에서 토큰을 받아 아래 명령으로 이 서버를 연결(attach)한 뒤, 다음 단계처럼 업데이트하세요.',
+ 'step.ubuntuProLegacy': 'Ubuntu {release} 릴리스는 10년 ESM 기간이 끝나, 수정 패키지가 유료인 Ubuntu Pro Legacy Support 애드온으로만 공개되며 무료 개인 구독에는 포함되지 않습니다. 이 애드온이 있다면 아래 명령으로 이 서버를 연결(attach)한 뒤 다음 단계처럼 업데이트하고, 없다면 지원되는 Ubuntu 릴리스로 옮길 계획을 세우세요.',
+ 'step.apt.upgrade': '{fix} 아래 명령은 이 확인에서 찾은 패키지({names})를 업데이트합니다. --only-upgrade는 새 패키지를 설치하지 않으며, 버전이 같아야 하는 PHP 확장 패키지는 apt가 함께 업데이트합니다. 대기 중인 업데이트를 모두 설치하려면 sudo apt full-upgrade를 실행하되, 승인하기 전에 변경 목록을 읽어 보세요.',
+ 'step.apt.upgradeFind': '{fix} 아래 명령은 dpkg -l로 설치된 {src} 패키지를 찾아 그 패키지만 업데이트합니다(--only-upgrade는 새 패키지를 설치하지 않습니다). 대기 중인 업데이트를 모두 설치하려면 sudo apt full-upgrade를 실행하되, 승인하기 전에 변경 목록을 읽어 보세요.',
+ 'step.apt.switchToDistro': '{family} 패키지를 쓰려면 먼저 apt-cache policy가 이 패키지의 출처로 보여 준 저장소를 끄세요(/etc/apt/sources.list.d/ 안의 해당 파일). 그런 다음 아래 명령을 실행하세요. 두 번째 명령은 지금 {foreign} 버전인 {src} 패키지를 모두 {family} 버전({fixed})으로 다시 설치하며, --allow-downgrades는 apt가 번호가 더 높은 빌드를 바꿀 수 있게 합니다.',
+ 'step.apt.confirmExact': '설치 후 아래 패키지는 모두 정확히 {fixed} 버전, 즉 {family} 리비전으로 나와야 합니다. 다른 저장소의 버전은 번호가 더 높아도 인정되지 않습니다.',
+ 'step.apt.confirmFixed': '업데이트가 설치됐는지 확인하세요. 아래 패키지가 모두 {fixed} 이상으로 나와야 합니다.',
+ 'step.apt.confirmUpstream': '업데이트가 설치됐는지 확인하세요. 아래 패키지 버전이 모두 {fixed} 또는 그 이후의 {branch} 릴리스로 시작해야 합니다.',
+ 'step.apt.confirm': '지금 설치된 버전을 확인하세요. 배포판 목록에서 릴리스를 고르면 결과확인이 그 릴리스의 수정 버전과 비교합니다.',
+ 'step.apt.confirmCompare': '아래 명령으로 설치된 버전을 확인해, 첫 번째 단계의 {family} 페이지에서 사용 중인 릴리스의 수정 버전과 직접 비교하세요. 이 확인 도구의 데이터에는 이 경우에 대한 기록이 없어 도구가 대신 비교할 수 없습니다.',
+ 'step.pending.watch': '{family} {release}에는 아직 {src} 수정 패키지가 없습니다. CVE는 알려졌지만 수정 패키지가 아직 공개되지 않았다는 뜻입니다. 릴리스별 상태가 나오는 두 CVE의 {family} 페이지(아래)를 지켜보다가, {family} {release}에 수정 버전이 나오면 바로 설치하세요.',
+ 'step.pending.check': '수정 패키지가 나왔는지는 앞의 두 명령으로 패키지 목록을 새로 받아 {src} 업데이트가 있는지 보면 알 수 있습니다. {src} 패키지가 목록에 나오면 마지막 명령으로 설치하세요.',
+
+ // RHEL family
+ 'rhel.context.advisory': 'Red Hat은 RHEL {release}의 PHP {branch}에 대해 {advisories}에서 두 CVE를 수정했습니다.',
+ 'rhel.context.state': '배포판 데이터에는 RHEL {release}의 PHP {branch}에 대한 Red Hat 수정이 아직 없습니다(Red Hat이 밝힌 상태: {states}).',
+ 'rhel.context.none': '배포판 데이터에는 RHEL {release}의 PHP {branch}에 대한 Red Hat 보안 권고가 없습니다.',
+ 'rhel.context.noRelease': '이 서버의 RHEL 릴리스를 알 수 없습니다. Red Hat은 {fixes}의 PHP {branch}를 수정했으며, 어느 쪽이 해당되는지는 서버 스크립트로 확인할 수 있습니다.',
+ 'rhel.context.noReleaseItem': 'RHEL {release}({advisories})',
+ 'rhel.context.noReleaseNone': '이 서버의 RHEL 릴리스를 알 수 없으며, 배포판 데이터에는 어느 릴리스에도 PHP {branch}에 대한 Red Hat 보안 권고가 없습니다.',
+ 'rhel.context.amazon': 'Amazon Linux {release}의 PHP 수정은 Red Hat 권고가 아니라 자체 ALAS 권고로 공개됩니다.',
+ 'rhel.rebuild': '{name}도 같은 수정을 자체 {prefix} 권고로 제공합니다.',
+ 'rhel.what.names': '이 PHP의 패키지({names})',
+ 'rhel.what.all': '설치된 모든 PHP 패키지',
+ 'step.rhel.upgrade': '{context} 첫 번째 명령은 두 CVE를 수정하는 업데이트를 보여 주고, 두 번째 명령은 {what}를 업데이트합니다. --refresh를 붙이면 dnf가 먼저 최신 저장소 메타데이터를 받습니다.',
+ 'step.rhel.upgradeYum': '{context} 첫 번째 명령은 두 CVE를 수정하는 업데이트를 보여 주고, 두 번째 명령은 {what}를 업데이트합니다.',
+ 'step.rhel.update': '{context} 아래 명령으로 {what}를 업데이트하세요.',
+ 'step.rhel.upgradeNone': '아직 설치할 수정이 없습니다. 첫 번째 명령은 두 CVE에 대한 업데이트가 공개됐는지 보여 주며, 업데이트가 나오면 두 번째 명령이 {what}를 그 버전으로 업데이트합니다. 수정 여부는 그다음 단계에서 확인합니다.',
+ 'step.rhel.upgradeNoneStay': 'php:{branch} 스트림을 계속 쓴다면 아직 설치할 수정이 없습니다. 첫 번째 명령은 두 CVE에 대한 업데이트가 공개됐는지 보여 주며, 업데이트가 나오면 두 번째 명령이 {what}를 그 버전으로 업데이트합니다. 수정 여부는 그다음 단계에서 확인합니다.',
+ 'step.rhel.updateNone': '아직 설치할 수정이 없습니다. 업데이트가 공개되면 아래 명령이 {what}를 그 버전으로 업데이트합니다. 수정 여부는 그다음 단계에서 확인합니다.',
+ 'step.rhel.switchStream': 'Red Hat은 RHEL {release}에서 두 CVE를 php:{branch} 스트림이 아니라 다른 모듈 스트림에서 수정했습니다. 수정된 스트림: {streams}. php:{target} 스트림에서 애플리케이션을 테스트한 뒤 아래 명령으로 그 스트림으로 전환하세요. 그러면 dnf가 PHP 패키지를 그 스트림의 수정 빌드({build})로 교체합니다.',
+ 'step.rhel.watch': '{context} 릴리스별 상태가 나오는 두 CVE의 Red Hat 페이지(아래)를 지켜보다가, RHEL {release}에 수정이 나오면 바로 업데이트하세요.',
+ 'step.rhel.watchAny': '릴리스별 상태가 나오는 두 CVE의 Red Hat 페이지(아래)를 지켜보다가, 사용 중인 릴리스에 수정이 나오면 바로 업데이트하세요.',
+ 'step.rhel.module': '업데이트하기 전에 아래 명령으로 php:{branch} 스트림이 활성화되어 있는지([e] 표시) 확인하세요. 수정은 그 스트림의 더 새로운 빌드({build})로 나오며 다음 단계의 업그레이드가 이를 설치하므로, 스트림을 바꿀 필요는 없습니다.',
+ 'step.rhel.moduleNoBuild': '이 PHP는 php:{branch} 모듈 스트림에서 설치한 것이며, 아래 명령은 활성화된 스트림을 보여 줍니다([e] 표시). 이 스트림의 수정은 같은 스트림의 더 새로운 빌드로 나오며, 스트림이 활성화된 상태에서 위의 업그레이드 명령이 이를 설치합니다.',
+ 'step.rhel.confirm': '업데이트를 확인하세요. rpm -q는 설치된 버전을 보여 주며, {pkg}의 changelog에는 이제 CVE마다 한 줄씩 있어야 합니다(두 번째 명령이 출력합니다).',
+ 'step.rhel.confirmFixed': '업데이트를 확인하세요. rpm -q가 {fixed} 이상을 보여 줘야 하며, {pkg}의 changelog에는 이제 CVE마다 한 줄씩 있어야 합니다(두 번째 명령이 출력합니다).',
+ 'step.rhel.confirmSome': '업데이트를 확인하세요. rpm -q는 설치된 버전을 보여 주며, {pkg}의 changelog에는 이제 {cves} 줄이 있어야 합니다(두 번째 명령이 출력합니다). Red Hat은 RHEL {release}에서 {notAffected}의 영향이 없다고 밝혔으므로 그 줄은 나오지 않습니다.',
+ 'step.rhel.old': '{name} {release} 릴리스는 정규 지원이 끝났습니다. 수정은 유료 연장 지원 구독으로만 나오며, 그마저 모든 CVE에 나오지는 않습니다. 지원되는 릴리스(RHEL 8 이상 또는 그 재빌드 배포판)로 옮길 계획을 세우세요.',
+ 'step.rhel.centosEol': 'CentOS {release} 릴리스는 지원이 종료(EOL)되어 자체 저장소에 더 이상 업데이트가 나오지 않습니다. 아래 업데이트 명령은 외부 업체의 연장 지원 구독이 있을 때만 이 수정을 가져올 수 있습니다. RHEL 9, Rocky Linux 9, AlmaLinux 9 같은 지원되는 릴리스로 옮길 계획을 세우고, 그때까지는 아래 완화 설정을 적용하세요.',
+ 'step.amazon.al2Eol': 'Amazon Linux 2는 2026년 6월 30일에 지원이 종료되어 더 이상 수정이 나오지 않습니다. Amazon Linux 2023으로 옮길 계획을 세우세요.',
+ 'step.amazon.amiEol': 'Amazon Linux AMI {release} 릴리스는 지원이 끝나 더 이상 수정이 나오지 않습니다. Amazon Linux 2023으로 옮길 계획을 세우세요.',
+ 'step.amazon.watch': '{context} 두 CVE의 Amazon Linux 페이지(아래)를 지켜보다가, Amazon Linux {release}에 수정이 나오면 바로 업데이트하세요.',
+ 'step.rpm.confirmUpstream': '업데이트가 설치됐는지 확인하세요. 아래 패키지 버전이 모두 {fixed} 또는 그 이후의 {branch} 릴리스로 시작해야 합니다.',
+
+ // Remi, Alpine and other distributions
+ 'fix.remi': '{pkg} 패키지는 업스트림 릴리스를 그대로 제공하는 Remi 저장소에서 설치한 것이며, {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다.',
+ 'step.remi.upgrade': '{fix} 아래 명령으로 {what}를 업데이트하세요.',
+ 'step.remi.eol': '이 PHP({pkg})는 Remi 저장소에서 설치한 것입니다. PHP {branch} 브랜치는 업스트림 지원이 종료(EOL)되었지만, Remi는 버전 번호를 바꾸지 않고 PHP {branch} 패키지에 보안 수정을 백포트합니다. 아래 명령으로 {what}를 업데이트하세요. 그러면 changelog에 두 CVE가 나와야 합니다.',
+ 'fix.alpine': '이 PHP는 업스트림 릴리스를 따르는 Alpine 패키지({names})이며, {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다.',
+ 'step.apk.upgrade': '{fix} 아래 명령을 root 권한으로 실행해 설치된 {name} 패키지를 모두 업데이트하세요(apk info로 목록을 만듭니다). Alpine은 PHP가 들어 있는 community 저장소를 다음 안정 릴리스가 나올 때까지만 업데이트하므로, 오래된 릴리스에서는 PHP {fixed} 버전을 받으려면 더 새로운 Alpine 릴리스로 옮겨야 할 수 있습니다.',
+ 'step.apk.confirm': '설치된 버전을 확인하세요. 버전이 {fixed} 또는 그 이후의 {branch} 릴리스로 시작해야 합니다.',
+ 'step.other.update': '이 PHP는 {vendor}에서 제공한 것입니다. 아래처럼 패키지 관리자로 최신 PHP 업데이트를 설치하고, {vendor}의 보안 권고에서 두 CVE를 확인하세요. 수정은 버전 번호를 바꾸지 않은 백포트로 나오거나 PHP {fixed} 이상 버전으로 나올 수 있습니다.',
+ 'step.other.updateNoCode': '이 PHP는 {vendor}에서 제공한 것입니다. 패키지 관리자로 최신 PHP 업데이트를 설치하고, {vendor}의 보안 권고에서 두 CVE를 확인하세요. 수정은 버전 번호를 바꾸지 않은 백포트로 나오거나 PHP {fixed} 이상 버전으로 나올 수 있습니다.',
+
+ // Upstream builds and end-of-life branches
+ 'step.upstream.get': 'PHP {version} 버전은 이 확인 도구의 데이터에 있는 배포판 패키지와 일치하지 않으므로 업스트림 빌드로 간주합니다. 소스에서 빌드했거나 컨트롤 패널 또는 deb.sury.org, Remi 같은 저장소에서 설치한 것입니다. {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다.{extra} 컨트롤 패널이나 저장소에서 설치했다면 그곳에서 업데이트하세요. 직접 빌드한다면 php.net에서 {fixed} 버전을 내려받고(또는 빌드 파이프라인을 {fixed} 버전으로 바꾸고), 체크섬을 php.net(https://www.php.net/downloads)에 나온 값과 비교하세요.',
+ 'step.upstream.getUnknown': '이 입력만으로는 PHP {version} 버전을 어디서 설치했는지 알 수 없습니다. 배포판 패키지라면 앞 단계의 PHP 프로브로 정확한 업데이트 단계를 확인하세요. 소스에서 빌드했거나 컨트롤 패널 또는 다른 저장소에서 설치했다면 {branch} 브랜치의 수정 릴리스인 PHP {fixed} 버전으로 업데이트하세요. 컨트롤 패널이나 저장소에서 업데이트하거나, php.net에서 {fixed} 버전을 내려받고(또는 빌드 파이프라인을 그 버전으로 바꾸고) 체크섬을 php.net(https://www.php.net/downloads)에 나온 값과 비교하세요.{extra}',
+ 'step.upstream.build': '현재 빌드와 같은 configure 옵션으로 PHP {fixed} 버전을 빌드하고 설치해야 같은 확장과 SAPI가 만들어집니다. 첫 번째 명령이 현재 옵션을 보여 주니, <current-options> 자리에 넣으세요.',
+ 'step.upstream.confirmBinary': '명령줄 php가 아니라 사이트를 처리하는 바이너리의 버전을 확인하세요. 명령줄 php는 다른 빌드일 수 있습니다. PHP {fixed} 버전이 출력되어야 합니다. {binary} 명령이 PATH에 없다면 서비스가 실행하는 전체 경로를 사용하세요. 그런 다음 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.upstream.confirmProbe': '웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요. PHP {fixed} 버전으로 나와야 합니다. 명령줄의 php -v는 웹 서버가 로드한 빌드와 다른 빌드일 수 있으니 기준으로 삼지 마세요.',
+ 'step.eol.move': 'PHP {branch} 브랜치는 지원이 종료(EOL)되어 php.net에서 {branch} 수정 릴리스가 나오지 않습니다. 근본적인 해결책은 지원되는 브랜치로 옮기는 것입니다. PHP {target} 브랜치(수정 버전 {targetFixed})나 PHP {alt} 브랜치(수정 버전 {altFixed})로 옮기세요. {branch} 브랜치용으로 작성한 코드는 새 브랜치에서 수정이 필요할 수 있으니, 전환하기 전에 사이트 사본이나 스테이징 서버에서 애플리케이션을 테스트하세요.',
+ 'step.eol.distro': '당장 옮길 수 없다면, 두 수정이 백포트된 PHP {branch} 배포판 패키지를 쓸 수 있습니다: {carriers}. PHP {branch} 브랜치를 장기 지원하는 상용 업체를 이용하는 방법도 있으며, 이 경우 그 업체의 빌드에 두 CVE가 나와 있는지 확인하세요.',
+ 'step.eol.vendor': '당장 옮길 수 없다면, 보안 수정을 백포트하는 배포판이나 상용 장기 지원 업체의 PHP {branch} 패키지를 사용하고, 그 패키지에 두 CVE가 나와 있는지 확인하세요.',
+ 'step.eol.host': '이 {system} 서버에서 가장 가까운 수정된 PHP는 배포판 자체의 {src} 패키지({fixed})이며, {family} 보안 업데이트를 받습니다. 아래 명령으로 {pkg} 패키지를 설치하고, 사이트를 그 PHP로 옮긴 뒤 애플리케이션을 테스트하세요. PHP {branch} 브랜치를 장기 지원하는 상용 업체를 이용하는 방법도 있습니다.{extra}',
+ 'step.eol.hostNoCode': '이 {system} 서버에서 가장 가까운 수정된 PHP는 배포판 자체의 {src} 패키지({fixed})이며, {family} 보안 업데이트를 받습니다. 지금 로드된 Apache PHP 모듈을 끈 뒤(PHP 모듈 두 개를 동시에 로드할 수 없습니다) {pkg} 패키지를 설치하고 애플리케이션을 테스트하세요. PHP {branch} 브랜치를 장기 지원하는 상용 업체를 이용하는 방법도 있습니다.{extra}',
+ 'eol.others': 'PHP {branch} 백포트가 들어 있는 다른 배포판: {carriers}.',
+ 'eol.hostPro': '이 패키지는 Ubuntu Pro(ESM)에만 공개되어 있으므로 먼저 Ubuntu Pro 구독을 연결(attach)하세요.',
+ 'branch.eolSoon': 'PHP {branch} 브랜치는 {date}에 지원이 종료되므로 {low} 또는 {high} 브랜치로 옮길 계획도 세우세요.',
+ 'branch.eolPast': 'PHP {branch} 브랜치는 {date}에 지원이 종료되었으므로 {low} 또는 {high} 브랜치로 옮길 계획도 세우세요.',
+ 'step.eol.backport': '마지막 수단으로, PHP 보안팀이 이 두 수정만 넣어 공개한 php-src 브랜치 {name}에서 현재 빌드의 configure 옵션으로 PHP {branch} 브랜치를 직접 빌드할 수 있습니다(첫 번째 명령이 현재 옵션을 보여 주니 <current-options> 자리에 넣으세요). git에서 빌드하려면 autoconf, bison, re2c도 필요합니다. 이 브랜치에는 다른 수정이 없으며, 이 확인 도구는 그렇게 빌드한 PHP를 구별하지 못합니다. 버전이 {version} 그대로이므로 결과는 계속 취약으로 나옵니다.',
+ 'step.prereleaseFixed': 'PHP {version}의 릴리스 노트나 커밋에서 두 CVE의 수정을 확인하세요. 운영 환경에는 수정이 포함된 안정 릴리스인 PHP {fixed} 또는 그 이후의 {branch} 릴리스를 사용하세요.',
+ 'action.checkReleaseNotes': 'PHP {version}의 릴리스 노트나 커밋에서 두 CVE의 수정을 확인하세요. 운영 환경에는 수정이 포함된 안정 릴리스를 사용하세요.',
+
+ // Containers
+ 'step.docker.tag': '이 컨테이너는 공식 PHP 이미지의 PHP {version} 버전을 실행하며, {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다. Dockerfile(FROM), compose.yaml(image:) 또는 docker run 명령에 적힌 php 이미지 태그를 아래 태그로 바꾸세요. 아래 태그는 지금 쓰는 이미지 종류({variant})를 그대로 유지합니다. php:{floating}처럼 브랜치만 지정한 태그도 다시 받으면 {fixed} 버전이 되지만, 버전을 고정하지 않으므로 나중에 다시 빌드할 때 PHP가 예고 없이 바뀔 수 있습니다.{extra} wordpress, drupal, nextcloud처럼 PHP 이미지를 바탕으로 만든 이미지를 쓴다면 이미지 이름은 그대로 두고, PHP {fixed} 버전이 들어 있는 그 이미지의 더 새로운 태그로 옮기세요(docker run --rm <your-image> php -v로 그 이미지의 PHP 버전을 볼 수 있습니다).',
+ 'step.docker.tagNoVariant': '이 컨테이너는 공식 PHP 이미지의 PHP {version} 버전을 실행하며, {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다. Dockerfile(FROM), compose.yaml(image:) 또는 docker run 명령에 적힌 php 이미지 태그를 PHP {fixed} 버전으로 바꾸고, <variant> 자리에는 지금 태그의 이미지 종류(예: apache, fpm, cli)를 넣으세요. php:{branch}처럼 브랜치만 지정한 태그는 브랜치를 따라가지만 버전을 고정하지 않습니다.{extra} wordpress, drupal, nextcloud처럼 PHP 이미지를 바탕으로 만든 이미지를 쓴다면 이미지 이름은 그대로 두고, PHP {fixed} 버전이 들어 있는 그 이미지의 더 새로운 태그로 옮기세요.',
+ 'step.docker.frankenphp': '이 컨테이너는 PHP {version} 버전의 FrankenPHP를 실행합니다. {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다. FrankenPHP 이미지(dunglas/frankenphp)는 PHP 릴리스가 아니라 FrankenPHP 버전과 PHP 브랜치로 태그가 붙으므로, 그 태그에 PHP {fixed} 버전이 들어가면 다시 받거나 PHP {fixed} 버전으로 FrankenPHP를 빌드하세요.',
+ 'step.docker.eol': '이 컨테이너는 공식 PHP 이미지의 PHP {version} 버전을 실행합니다. PHP {branch} 브랜치는 지원이 종료(EOL)되어 수정된 {branch} 이미지가 나오지 않습니다. 지원되는 브랜치로 옮기세요. 아래 태그는 PHP {targetFixed} 버전을 사용하며, PHP {altFixed} 버전도 선택할 수 있습니다. {branch} 브랜치용으로 작성한 코드는 새 브랜치에서 수정이 필요할 수 있으니, 배포하기 전에 새 이미지로 애플리케이션을 테스트하세요.{extra}',
+ 'docker.oldDebian': '이 이미지는 Debian {from}({fromName}) 기반인데, 이 릴리스용 PHP 이미지는 더 이상 나오지 않으므로 아래 태그는 아직 이미지가 나오는 가장 가까운 릴리스인 Debian {to}({toName}) 기반 이미지를 씁니다. Dockerfile에서 apt로 설치하는 패키지는 이름이나 버전이 다를 수 있으니 빌드를 테스트하세요.',
+ 'docker.otherDebian': 'Debian {codename}용 PHP 이미지는 나오지 않으므로 태그에는 이미지의 기본 Debian 릴리스를 사용했습니다. 이 태그로 Dockerfile을 테스트하세요.',
+ 'docker.alpineFloating': '-alpine 태그는 최신 Alpine 릴리스를 따르며, 이는 지금 이미지가 쓰는 Alpine {release}보다 새 릴리스입니다. 이 태그로 Dockerfile을 테스트하거나, {example}처럼 Docker Hub에 있는 태그로 Alpine 릴리스를 고정하세요.',
+ 'step.docker.rebuild': '--pull 옵션으로 다시 빌드해 Docker가 캐시된 베이스 이미지 대신 새 베이스 이미지를 받게 하고, 실행 중인 컨테이너를 교체하세요. 기존 컨테이너를 재시작하면 이전 이미지가 그대로 쓰입니다. Docker만 쓴다면 아래처럼 빌드한 뒤 <your-image>로 새 컨테이너를 시작해 이전 컨테이너와 교체하세요. 빌드 없이 php 이미지를 그대로 실행한다면 빌드 대신 새 태그를 docker pull로 받으세요. Docker Compose에서는 마지막 세 명령이 build: 항목이 있는 서비스를 다시 빌드하고, image: 줄에 적힌 이미지를 받은 뒤, 이미지가 바뀐 컨테이너를 다시 만듭니다.',
+ 'step.docker.confirm': '새 컨테이너에서 PHP 버전을 확인하세요. 아래 명령은 {fixed} 버전을 출력해야 합니다. 그런 다음 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.container.update': '이 컨테이너의 PHP는 공식 PHP 이미지가 아니라 패키지로 설치한 것입니다. {fix} 아래 줄을 Dockerfile에서 PHP를 설치하는 줄 뒤에 넣고(또는 수정이 이미 들어 있는 더 새로운 베이스 이미지로 옮기고), 다음 단계처럼 이미지를 다시 빌드해 컨테이너를 교체하세요.',
+ 'step.container.updatePro': '이 컨테이너의 PHP는 공식 PHP 이미지가 아니라 Ubuntu 패키지로 설치한 것입니다. {fix} 이 버전은 Ubuntu Pro에만 공개되어 있어 이미지 빌드 중에 Pro를 연결(attach)해야 받을 수 있습니다. 아래 줄을 Dockerfile에서 PHP를 설치하는 줄 뒤에 넣고, pro-attach-config.yaml 파일에 token: <token> 한 줄을 적은 뒤, 토큰이 이미지에 남지 않도록 다음 단계처럼 --secret 옵션으로 빌드하세요. Docker Compose에서는 compose.yaml에서 이 파일을 빌드 secret으로 지정하세요.',
+ 'step.container.upstream': '이 컨테이너는 PHP {version} 버전을 실행하는데, 배포판 패키지도 공식 PHP 이미지도 아닙니다. {branch} 브랜치는 PHP {fixed} 버전에서 수정되었습니다.{extra} PHP를 제공하는 베이스 이미지나 빌드를 {fixed} 버전으로 올린 뒤, 다음 단계처럼 이미지를 다시 빌드해 컨테이너를 교체하세요.',
+ 'step.container.other': '이 컨테이너의 PHP는 {vendor} 패키지입니다. Dockerfile에서 패키지 관리자로 업데이트하고(또는 더 새로운 베이스 이미지로 옮기고), {vendor}의 보안 권고에서 두 CVE를 확인하세요. 수정은 버전 번호를 바꾸지 않은 백포트로 나오거나 PHP {fixed} 이상 버전으로 나올 수 있습니다. 그런 다음 이어지는 단계처럼 이미지를 다시 빌드해 컨테이너를 교체하세요.',
+ 'step.container.eol': '이 컨테이너는 PHP {version} 버전을 실행합니다. PHP {branch} 브랜치는 지원이 종료(EOL)되어 {branch} 수정 릴리스가 나오지 않습니다. 이미지를 지원되는 PHP {target} 브랜치(수정 버전 {targetFixed})나 PHP {alt} 브랜치(수정 버전 {altFixed})로 옮기고, 배포하기 전에 애플리케이션을 테스트하세요.',
+ 'step.container.confirmFixed': '새 컨테이너에서 설치된 버전을 확인하세요. {fixed} 이상으로 나와야 합니다. 그런 다음 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.container.confirm': '새 컨테이너에서 설치된 버전을 확인한 뒤, 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.container.confirmChangelog': '새 컨테이너에서 {pkg}의 changelog에 {cves} 항목이 나오는지 확인한 뒤(두 번째 명령이 출력합니다), 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.container.confirmChangelogFixed': '새 컨테이너에서 rpm -q가 {fixed} 이상을 보여 주고 {pkg}의 changelog에 {cves} 항목이 나오는지 확인한 뒤(두 번째 명령이 출력합니다), 웹 서버를 통해 PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.container.restart': '이 컨테이너의 PHP 패키지는 업데이트됐지만, PHP 프로세스는 업데이트 전에 로드한 이전 빌드를 아직 실행하고 있습니다. 컨테이너를 재시작해 새 빌드를 로드하게 한 뒤, PHP 프로브를 다시 실행하고 결과확인을 누르세요.',
+ 'step.container.persist': '이 업데이트는 실행 중인 컨테이너 안에서만 이루어졌으므로, 같은 이미지로 새 컨테이너를 만들면 이전 빌드로 돌아갑니다. 이미지에도 업데이트를 넣으세요. 아래 줄을 Dockerfile에서 PHP를 설치하는 줄 뒤에 넣고 다시 빌드합니다.',
+
+ // Interim mitigations and hardening
+ 'when.cannotUpdate': '바로 업데이트할 수 없다면',
+ 'when.pending': '수정 패키지가 공개될 때까지는',
+ 'when.eol': '지원되는 브랜치로 옮길 때까지는',
+ 'when.eolRelease': '지원되는 릴리스로 옮길 때까지는',
+ 'step.mitigate.apache': '{when} 공식 권고의 주요 임시 완화 설정인 세션 업로드 진행률 기능을 끄세요. 아래 명령은 Apache mod_php가 처리하는 모든 사이트에 이 설정을 적용하고, 설정을 검사한 뒤 Apache를 reload합니다(설정 변경에는 reload로 충분합니다).{extra} 업로드 진행률 표시만 멈출 뿐 업로드 자체는 그대로 동작합니다. 주석 처리된 memory_limit -1 줄은 Apache 서비스의 MemoryMax= 같은 PHP 바깥의 메모리 제한을 함께 둘 때만 주석을 해제하고, ulimit -v와는 함께 쓰지 마세요. 이 설정은 위험을 줄일 뿐 패치를 대신하지 않습니다.',
+ 'step.mitigate.fpm': '{when} 공식 권고의 주요 임시 완화 설정인 세션 업로드 진행률 기능을 끄세요. 아래 명령은 별도 파일로 PHP-FPM의 www 풀에 이 설정을 넣고, 설정을 검사한 뒤 PHP-FPM을 reload합니다(설정 변경에는 reload로 충분합니다).{extra} 업로드 진행률 표시만 멈출 뿐 업로드 자체는 그대로 동작합니다. 설정 검사가 "[pool www] user has not been defined" 오류를 내면 이 서버에는 www 풀이 없는 것이니, 대괄호 안에 사용하는 풀의 이름을 넣고, 다른 풀마다 같은 섹션을 추가하세요. 주석 처리된 memory_limit -1 줄은 서비스의 MemoryMax= 같은 PHP 바깥의 메모리 제한을 함께 둘 때만 주석을 해제하고, ulimit -v와는 함께 쓰지 마세요. 이 설정은 위험을 줄일 뿐 패치를 대신하지 않습니다.',
+ 'step.mitigate.fpmAlso': '이 PHP는 PHP-FPM에서도 실행됩니다. 아래 명령은 별도 파일로 PHP-FPM의 www 풀에도 같은 설정을 넣고, 설정을 검사한 뒤 PHP-FPM을 reload합니다(설정 변경에는 reload로 충분합니다).{extra} 설정 검사가 "[pool www] user has not been defined" 오류를 내면 대괄호 안에 사용하는 풀의 이름을 넣으세요. 이 임시 완화 설정은 위험을 줄일 뿐 패치를 대신하지 않습니다.',
+ 'step.mitigate.docker': '{when} 공식 권고의 주요 임시 완화 설정인 세션 업로드 진행률 기능을 끄세요. 앞의 두 명령은 실행 중인 컨테이너에 이 설정을 넣고 컨테이너를 재시작하며, RUN 줄은 이미지에도 설정이 남도록 Dockerfile에 넣는 줄입니다.{extra} 업로드 진행률 표시만 멈출 뿐 업로드 자체는 그대로 동작합니다. 이 설정은 위험을 줄일 뿐 패치를 대신하지 않습니다.',
+ 'step.mitigate.ini': '{when} 공식 권고의 주요 임시 완화 설정인 세션 업로드 진행률 기능을 끄세요. 아래 명령은 이 PHP가 읽는 {file}에 설정을 추가하고, 적용되도록 PHP를 재시작합니다.{extra} 업로드 진행률 표시만 멈출 뿐 업로드 자체는 그대로 동작합니다. 이 설정은 위험을 줄일 뿐 패치를 대신하지 않습니다.',
+ 'step.mitigate.iniNoRestart': '{when} 공식 권고의 주요 임시 완화 설정인 세션 업로드 진행률 기능을 끄세요. 아래 명령은 이 PHP가 읽는 {file}에 설정을 추가합니다. 그런 다음 적용되도록 PHP를 실행하는 서비스를 재시작하세요.{extra} 업로드 진행률 표시만 멈출 뿐 업로드 자체는 그대로 동작합니다. 이 설정은 위험을 줄일 뿐 패치를 대신하지 않습니다.',
+ 'mitigate.fatalBacktraces': ' PHP 8.5에서는 확인된 RCE 경로를 막는 fatal_error_backtraces도 끕니다.',
+ 'action.applyMitigations': '{when} 이 결과에 나온 임시 완화 설정을 적용하세요. 임시 완화 설정은 패치를 대신하지 않습니다.',
+ 'action.applyMitigationsAdvisory': '{when} 공식 권고의 임시 완화 설정을 적용하세요. session.upload_progress.enabled=0으로 설정하고, memory_limit=-1은 PHP 바깥의 메모리 제한(서비스의 MemoryMax= 같은 cgroup 제한)을 함께 둘 때만 쓰고 ulimit -v와는 함께 쓰지 마세요. 현재 값은 PHP 프로브로 확인할 수 있습니다. 임시 완화 설정은 패치를 대신하지 않습니다.',
+ 'action.lookForCompromise': '패치 전에 서버가 노출되어 있었다면 로그, 최근 변경된 PHP 파일, 웹 서버 계정으로 실행 중인 비정상 프로세스 등 침해 지표를 점검하세요. 아래처럼 서버 스크립트를 --logs와 --webroot(사이트 디렉터리, 예: /var/www) 옵션으로 실행하고 출력을 여기에 붙여 넣으세요.',
+ 'action.checkOtherSites': '같은 서버의 다른 사이트(vhost)와 PHP-FPM 풀도 모두 확인하세요. 저마다 다른 PHP를 실행할 수 있으므로 각각에서 PHP 프로브를 한 번씩 실행하세요.',
+ 'action.checkOtherContainers': 'PHP를 실행하는 다른 컨테이너와 이미지도 모두 확인하세요. 저마다 PHP 버전이 다를 수 있으므로 각 컨테이너에서 PHP 프로브를 한 번씩 실행하세요.',
+ 'step.hardening.expose': '선택 사항으로 expose_php를 꺼서 응답의 X-Powered-By 헤더에 PHP 버전이 나오지 않게 하세요. 공격자에게 주는 정보는 줄지만 업데이트를 대신하지는 않습니다. 아래 명령은 이 PHP에 설정을 추가하고, 적용되도록 서비스를 재시작합니다.',
+ 'step.hardening.exposeDocker': '선택 사항으로 expose_php를 꺼서 응답의 X-Powered-By 헤더에 PHP 버전이 나오지 않게 하세요. 아래 줄을 Dockerfile에 넣고 이미지를 다시 빌드합니다. 공격자에게 주는 정보는 줄지만 업데이트를 대신하지는 않습니다.',
+ 'step.hardening.exposePath': '선택 사항으로 이 PHP의 php.ini({path} 안)에 expose_php = Off를 설정하고 PHP를 실행하는 서비스를 재시작해, 응답의 X-Powered-By 헤더에 PHP 버전이 나오지 않게 하세요. 공격자에게 주는 정보는 줄지만 업데이트를 대신하지는 않습니다.',
+ 'step.hardening.exposeManual': '선택 사항으로 이 사이트를 처리하는 PHP의 php.ini에 expose_php = Off를 설정하고 PHP를 실행하는 서비스를 재시작해, 응답의 X-Powered-By 헤더에 PHP 버전이 나오지 않게 하세요. 공격자에게 주는 정보는 줄지만 업데이트를 대신하지는 않습니다.',
+ 'step.hardening.uploadOff': 'session.upload_progress.enabled가 꺼져 있습니다. 업로드 진행률 기능을 쓰지 않는다면 패치한 지금도 꺼 두세요. 공격자들이 이 기능의 다른 결함을 찾고 있어 공식 권고도 이를 권장합니다.',
+ 'step.hardening.uploadOn': '이 서버에서 업로드 진행률 기능을 쓰지 않는다면, 패치한 지금도 session.upload_progress.enabled를 0으로 설정해 꺼 둘 수 있습니다. 공격자들이 이 기능의 다른 결함을 찾고 있어 공식 권고도 이를 권장합니다.',
+
+ // Interim mitigations
+ 'mit.value.notSet': '(설정 안 됨)',
+ 'mit.value.emptyOff': '(빈 값 = Off)',
+ 'mit.noChangeNeeded': '수정된 버전에서는 변경할 필요가 없습니다.',
+ 'mit.memoryLimit.patchedUnlimited': '수정된 버전에서는 -1이 필요하지 않습니다. PHP 메모리 제한이 없으면 서비스 거부(DoS) 위험이 있으므로 원래 값으로 되돌리는 것을 검토하세요.',
+ 'mit.memoryLimit.unlimited': 'CVE-2026-109618에 대한 주요 완화 설정이 적용되어 있습니다. 이제 PHP 자체에는 메모리 제한이 없으므로 cgroup(memory.max), pm.max_children, MaxRequestWorkers 등으로 PHP 바깥에서 메모리를 제한하세요. RLIMIT_AS나 RLIMIT_DATA는 사용하지 마세요. 오히려 취약한 경로가 실행됩니다.',
+ 'mit.memoryLimit.suggest': '-1로 설정하면 CVE-2026-109618 공격이 의존하는 예측 가능한 메모리 제한이 사라집니다. 공식 권고가 첫 번째로 제시한 완화 설정이지만 완전한 대책은 아닙니다. 서비스 거부(DoS) 위험이 생기므로 cgroup(memory.max), pm.max_children, MaxRequestWorkers 등으로 PHP 바깥에서 메모리를 제한하세요. RLIMIT_AS나 RLIMIT_DATA(ulimit -v)는 사용하지 마세요. 오히려 취약한 경로가 실행됩니다.',
+ 'mit.uploadProgress.noSession': 'session 확장이 로드되지 않았으므로 세션 경로는 해당되지 않습니다.',
+ 'mit.uploadProgress.off': 'CVE-2026-109619에서 확인된 세션 경로가 차단되어 있습니다. 공식 권고는 이 기능을 사용하지 않는 경우 패치 후에도 꺼 둘 것을 권장합니다.',
+ 'mit.uploadProgress.patchedOn': '업로드 진행률 기능을 사용하지 않는 경우, 공식 권고는 패치 후에도 이 설정을 꺼 둘 것을 권장합니다. 공격자들이 이 기능의 다른 결함을 찾고 있기 때문입니다.',
+ 'mit.uploadProgress.suggest': 'Off(0)로 설정하면 CVE-2026-109619에서 확인된 세션 경로가 차단됩니다. 먼저 업로드 진행률 기능을 사용하는지 확인하세요.',
+ 'mit.autoStart.off': '꺼져 있습니다.',
+ 'mit.autoStart.suggest': 'Xint의 재현 결과에 따르면 이 설정이 켜져 있으면 upload_progress가 꺼져 있어도 PHP가 취약합니다. 끄기 전에 session_start()를 호출하는 위치를 확인하고 로그인 흐름을 테스트하세요.',
+ 'mit.fatalBacktraces.off': 'PHP 8.5에서 확인된 CVE-2026-109619 RCE 경로가 차단되어 있습니다.',
+ 'mit.fatalBacktraces.suggest': 'PHP 8.5에서는 0으로 설정하면 CVE-2026-109619에서 확인된 RCE 경로가 차단됩니다(공식 권고).',
+ 'mit.nestingLevel': '기본값은 64입니다. 공격에 깊게 중첩된 필드 이름이 사용되므로, 애플리케이션이 허용하는 범위 안에서 낮게 설정할수록 위험이 줄어듭니다(공식 권고).',
+ 'mit.postMaxSize': '보수적인 값으로 유지하고, 웹 서버에서도 요청 크기를 제한하세요(Apache LimitRequestBody, Nginx client_max_body_size).',
+ 'mit.exposePhp.off': '응답 헤더에 PHP 버전이 노출되지 않습니다.',
+ 'mit.exposePhp.on': '응답 헤더(X-Powered-By)에 PHP 버전이 노출됩니다. 패치 후에는 Off로 설정하는 것을 권장합니다.',
+
+ // SAPI exposure
+ 'exposure.apache2handler': 'Apache mod_php에서는 애플리케이션 코드와 관계없이 요청 본문을 파싱하는 단계에서 공격이 성공할 수 있습니다. 공식 권고가 이 구성의 기본 설정에서 RCE를 검증했으므로 가장 위험한 구성입니다.',
+ 'exposure.frankenphp': 'FrankenPHP는 mod_php처럼 요청 종료 처리를 항상 실행하므로 애플리케이션과 무관한 공격에 취약할 가능성이 높습니다(공식 권고).',
+ 'exposure.fpm': 'PHP-FPM은 요청 시작 단계가 실패하면 워커를 종료하므로 일부 경로는 막히지만, 애플리케이션 경로를 통한 공격은 여전히 가능합니다. 세션 경로를 통한 RCE(CVE-2026-109619)도 FPM에서 검증되었습니다.',
+ 'exposure.litespeed': 'LiteSpeed(LSAPI)는 FPM과 비슷하게 동작하므로 애플리케이션 경로를 통한 공격이 가능할 것으로 예상됩니다(공식 권고, 검증되지 않음).',
+ 'exposure.cli': '이 결과는 명령줄(CLI)에서 얻은 것입니다. 웹 서비스가 사용하는 PHP와 다를 수 있으므로 PHP 프로브를 웹 루트에 올리고 브라우저에서 실행하세요.',
+ 'exposure.other': '공식 권고는 {sapi}에 대해 따로 언급하지 않습니다. 패치를 적용할 때까지는 노출된 것으로 간주해야 합니다.',
+
+ // Compromise indicators from the server script (--logs, --webroot)
+ 'finding.oom': ({ n }) => `로그에 스크립트 실행 전에 발생한 메모리 부족 오류("in Unknown on line 0")가 ${n}건 있습니다. 형식이 잘못된 multipart 요청을 이용한 공격 시도일 수 있습니다.`,
+ 'finding.crash': ({ n }) => `로그에 웹 서버 또는 PHP 비정상 종료(segfault)가 ${n}건 있습니다. 메모리 손상 공격 시도일 수 있습니다.`,
+ 'finding.bigSession': ({ n }) => `1 MB보다 큰 세션 파일을 ${n}개 발견했습니다. 세션 업로드 진행률 경로를 노린 공격의 흔적일 수 있습니다.`,
+ 'finding.webUserProc': ({ n }) => `웹 서버 계정(www-data, apache, nginx, nobody 등)으로 실행 중인 다른 프로세스를 ${n}개 발견했습니다. 서버 스크립트 출력에서 무엇인지 확인하세요. 셸, 다운로드 도구, 코인 채굴기가 있다면 침해를 의심하세요.`,
+ 'finding.recentPhp': ({ n }) => `최근 3일 이내에 변경된 PHP 파일을 ${n}개 발견했습니다. 직접 배포하지 않은 파일은 모두 웹셸일 가능성이 있는 것으로 간주하세요.`,
+ 'finding.unread': '일부 항목({items})을 읽지 못해 점검하지 못했습니다. root 권한으로 서버 스크립트를 실행하세요. 다른 항목이 0건이라고 해서 이 항목들까지 이상이 없다는 뜻은 아닙니다.',
+ 'finding.notRoot': '서버 스크립트를 root 권한으로 실행하지 않아 다른 계정이 소유한 로그, 세션 파일, 프로세스를 읽지 못했을 수 있습니다. 이 수치를 믿기 전에 sudo로 다시 실행하세요.',
+ 'finding.source.logs': '로그',
+ 'finding.source.sessions': '세션 파일',
+ 'finding.source.processes': '웹 서버 계정 프로세스',
+ 'finding.source.webroot': '웹 루트',
+ 'finding.none': '확인한 항목({items})에서는 의심스러운 흔적을 찾지 못했습니다. 흔적이 없다고 해서 침해가 없었다는 뜻은 아닙니다.',
+ 'finding.incident': '침해가 의심되면 서버를 네트워크에서 격리하고 로그를 보존하세요. 그런 다음 모든 데이터베이스 비밀번호, API 키, 세션 키와 암호화 키를 교체하세요. 해당 국가의 CERT/CSIRT(한국은 KISA 인터넷침해대응센터, 국번 없이 118)에 침해 사고를 신고할 수도 있습니다.',
+
+ // Notes from reading the pasted input (parse.js)
+ 'parse.badJson': 'PHP 프로브 출력의 JSON 부분을 읽지 못했습니다. 출력 전체를 잘림 없이 복사했는지 확인하세요.',
+ 'parse.notSelfcheck': 'php2shell-selfcheck 출력 형식이 아닙니다.',
+ 'parse.dpkgUnreadable': 'open_basedir 등의 이유로 설치된 패키지 목록(/var/lib/dpkg/status)을 읽지 못했습니다. 배포판 백포트 확인이 부정확할 수 있으므로 서버 스크립트도 함께 실행하세요.',
+ 'parse.openBasedir': '이 사이트에 open_basedir가 설정되어 있어 프로브가 OS 릴리스 정보와 설치된 패키지 목록을 읽지 못했습니다. 이 정보 없이는 프로브만으로 배포판 백포트를 확인할 수 없습니다. 서버 스크립트도 함께 실행하거나, 배포판 목록에서 배포판을 고른 뒤 결과확인을 다시 누르세요.',
+ 'parse.osReleaseUnread': '프로브가 OS 릴리스 정보(/etc/os-release 또는 이전 방식의 릴리스 파일)를 읽지 못해 배포판과 설치된 패키지를 알 수 없습니다. 이 정보 없이는 프로브만으로 배포판 백포트를 확인할 수 없습니다. 서버 스크립트도 함께 실행하거나, 배포판 목록에서 배포판을 고른 뒤 결과확인을 다시 누르세요.',
+ 'parse.serverOtherSystem': 'PHP 프로브는 컨테이너 안에서 실행됐지만, 서버 스크립트 출력은 다른 시스템({os}, 아마도 호스트)에서 나왔습니다. 호스트의 패키지, changelog, 프로세스는 컨테이너 안의 PHP와 관계가 없으므로 사용하지 않았습니다. 컨테이너의 패키지를 확인하려면 컨테이너 안에서 서버 스크립트를 실행하세요(docker exec).',
+ 'parse.serverOtherOs': '서버 스크립트 출력은 PHP 프로브({probe})와 다른 시스템({os})에서 나왔습니다. 그 패키지, changelog, 프로세스는 이 PHP와 관계가 없으므로 사용하지 않았습니다. 이 PHP가 실행되는 서버에서 둘 다 실행하세요.',
+ 'parse.serverUnlinked': 'PHP 프로브는 컨테이너 안에서 실행됐지만, 서버 스크립트가 같은 컨테이너에서 실행됐다는 근거가 프로브 출력에 없습니다. 프로브가 자신의 OS 릴리스 정보나 패키지 목록을 읽지 못했거나, 패키지 목록이 서버 스크립트의 목록과 다릅니다. 서버 스크립트가 호스트에서 실행됐을 수 있으므로 그 패키지, changelog, 프로세스는 사용하지 않았습니다. 컨테이너의 패키지를 확인하려면 컨테이너 안에서 서버 스크립트를 실행하고(docker exec) 그 출력만 붙여 넣으세요.',
+ 'parse.serverConflict': 'PHP 프로브와 서버 스크립트가 서로 다른 PHP 패키지를 보고하므로, 두 출력은 다른 시점(예: 업데이트 전과 후)이나 다른 시스템에서 나온 것입니다. 서버 스크립트의 패키지, changelog, 프로세스는 사용하지 않았습니다.',
+ 'parse.serverTruncated': '서버 스크립트 출력에 END 줄이 없습니다. 복사하는 과정에서 일부가 잘린 것입니다. BEGIN 줄부터 END 줄까지 모두 복사하세요.',
+ 'parse.procBadStatus': '서버 스크립트 출력에서 {names}의 재시작 상태를 읽을 수 없어(줄이 잘렸을 수 있습니다) 이 프로세스들은 확인하지 못했습니다. 출력 전체를 다시 복사하세요.',
+ 'parse.probeNotDeleted': 'PHP 프로브가 서버에서 자동으로 삭제되지 않았습니다. 지금 직접 삭제하세요.',
+ 'parse.procUnreadable': ({ n }) => `권한이 부족해 PHP 프로세스${n ? ` ${n}개` : ''}의 재시작 누락 여부를 확인하지 못했습니다. root 권한으로 서버 스크립트를 다시 실행하세요.`,
+ 'parse.headerOnly': '응답 헤더는 해당 URL을 처리한 PHP만 보여 줍니다. 같은 서버의 다른 사이트와 PHP-FPM 풀은 따로 확인하세요.',
+ 'parse.cliVersion': 'php -v는 명령줄 PHP의 버전을 보여 줍니다. 웹 서비스가 사용하는 PHP와 다를 수 있으므로 PHP 프로브로 다시 확인하세요.',
+ 'parse.noVersion': 'PHP 버전을 찾지 못했습니다. PHP 프로브 출력 전체, X-Powered-By 헤더 또는 8.2.26 같은 버전 문자열을 붙여 넣으세요.',
+ // Input that could not be read, or was cut
+ 'reason.notRead': '붙여 넣은 내용을 지원하는 출력 형식으로 읽지 못해 아무것도 확인하지 못했습니다. PHP 프로브 출력이나 서버 스크립트 출력을 BEGIN 줄부터 END 줄까지 다시 붙여 넣으세요.',
+ 'reason.inputCut': '입력이 너무 길거나 서로 다른 출력이 너무 많아 일부를 읽지 못했습니다. 읽지 않은 부분에 수정이 없는 PHP가 있을 수 있으므로 패치됨으로 확인할 수 없습니다.',
+ 'step.collect.shorter': '한 번에 붙여 넣는 양을 줄이세요. 서버 한 대의 PHP 프로브 출력과 서버 스크립트 출력을 각각 BEGIN 줄부터 END 줄까지 붙여 넣고 결과확인을 누르세요.',
+ 'parse.inputTooLong': '입력이 200만 자보다 길어 앞부분만 읽었습니다. 서버 한 대씩 출력을 붙여 넣으세요.',
+ 'parse.tooManyOutputs': '입력에 서로 다른 출력이 {n}개보다 많아 처음 {n}개만 확인했습니다. 한 번에 붙여 넣는 출력 수를 줄이세요.',
+ 'parse.serverNoBegin': '서버 스크립트 출력에 BEGIN 줄이 없어 첫 데이터 줄부터 END 줄까지 읽었습니다. 다음에는 BEGIN 줄부터 복사하세요.',
+ 'parse.serverHeadCut': '서버 스크립트 출력에 BEGIN 줄이 없고 첫 줄부터 시작하지도 않아 앞부분이 빠졌습니다. BEGIN 줄부터 END 줄까지 모두 복사하세요.',
+ 'reason.serverHeadCut': '서버 스크립트 출력에 BEGIN 줄이 없고 첫 줄부터 시작하지도 않아 앞부분(root 권한 실행 여부, OS 릴리스 정보)이 빠졌을 수 있으므로 패치됨으로 확인할 수 없습니다. 출력 전체를 다시 복사하세요.',
+ 'parse.serverUnlinkedHidden': 'open_basedir 때문에 PHP 프로브는 자신이 컨테이너 안에서 실행되는지 알 수 없었고, 출력에도 서버 스크립트 출력과 연결할 근거(OS 릴리스 정보나 패키지 목록)가 없습니다. 서버 스크립트 출력은 컨테이너의 호스트에서 나왔을 수 있으므로 그 패키지, changelog, 프로세스는 이 PHP에 사용하지 않았고, 그 출력은 따로 확인했습니다. 이 PHP가 컨테이너 안에서 실행된다면 컨테이너 안에서 서버 스크립트를 실행하고(docker exec), 그렇지 않다면 같은 PHP를 쓰면서 open_basedir가 설정되지 않은 사이트에서 PHP 프로브를 실행하세요.',
+
+ // Downgrades, missing SAPI packages and the revision in PHP_VERSION (Debian and Ubuntu)
+ 'reason.sapiPackageMissing': '이 서버에서 PHP는 {sapi}로 실행되지만, 그 패키지({pkg})가 설치된 패키지 목록에 없습니다. 업데이트가 중단되면 패키지가 반쯤 설치된 상태로 남을 수 있으며, dpkg는 완전히 설치된 패키지만 목록에 보여 줍니다. 그렇지 않다면 이 PHP는 다른 곳에서 온 것입니다. Debian과 Ubuntu는 PHP 엔진을 SAPI마다 각자의 바이너리에 넣어 빌드하므로, 다른 {src} 패키지로는 여기서 실행되는 빌드를 알 수 없습니다.',
+ 'reason.revisionMissing': 'PHP_VERSION({version})에는 {family} 리비전이 없습니다. 그런데 {family} {release}에서는 업데이트된 PHP 빌드가 리비전을 함께 보고하므로, 설치된 패키지({installed})가 실행 중이라면 {expected}로 나와야 합니다. 리비전이 없는 버전은 그 릴리스의 최초 빌드만 보고하므로, 실행 중인 PHP는 업데이트 전에 로드된 이전 빌드이며 수정이 들어 있지 않습니다.',
+ 'reason.sapiPackageMissingProc': '이 브랜치의 PHP 프로세스({comm})가 실행 중이지만, 그 패키지({pkg})가 설치된 패키지 목록에 없습니다. 업데이트가 중단되면 패키지가 반쯤 설치된 상태로 남을 수 있으며, dpkg는 완전히 설치된 패키지만 목록에 보여 줍니다. Debian과 Ubuntu는 PHP 엔진을 SAPI마다 각자의 바이너리에 넣어 빌드하므로, 다른 {src} 패키지로는 {comm} 프로세스가 실행하는 빌드를 알 수 없습니다.',
+ 'note.olderPackageFixed': '설치된 {pkg} 패키지는 실행 중인 PHP({version})보다 오래되었지만 수정이 들어 있으므로, 다음에 재시작해도 수정된 빌드가 로드됩니다.',
+ 'fix.debDowngraded': '설치된 {pkg} 패키지는 실행 중인 PHP {version}보다 오래되었고 수정이 없습니다. 서비스가 재시작되기 전에 {fixed} 이상 버전을 다시 설치하세요.',
+ 'note.staleDowngraded': '프로세스({names})는 패키지가 다운그레이드되기 전에 로드한, 수정이 들어 있는 빌드를 아직 실행하고 있습니다. 수정 패키지를 다시 설치하기 전에는 이 프로세스들을 재시작하거나 reload하지 마세요. 취약한 빌드가 로드됩니다.',
+ 'reason.staleUnfixed': '실행 중인 프로세스({names})가 시작된 뒤 업데이트나 다운그레이드로 교체된 PHP 파일을 아직 사용하고 있습니다. 지금 설치된 빌드에 수정이 있는지는 확인되지 않았으므로 재시작만으로는 해결되지 않습니다. 먼저 수정 패키지를 설치한 뒤 재시작하세요.',
+ 'mitigate.staleReload': ' 프로세스({names})가 마지막 패키지 변경 이전의 파일을 아직 사용하고 있으며, reload하면 PHP-FPM이 지금 설치된 빌드를 로드합니다. 그 변경이 다운그레이드였다면 먼저 수정 패키지를 설치하세요.',
+ 'step.collect.sapiPackage': '아래 명령으로 {pkg} 패키지의 상태를 확인하세요. dpkg -l은 {src} 패키지마다 상태를 보여 주며(ii는 완전히 설치된 상태입니다), dpkg --audit는 업데이트가 중단되어 마무리되지 않은 패키지를 보여 줍니다. 마지막 두 명령은 그런 업데이트를 마무리합니다(apt-get install -f는 빠진 것을 설치하며, 무엇이든 바꾸기 전에 확인을 받습니다). 그런 다음 다음 단계처럼 PHP를 재시작하고 다시 확인하세요.',
+
+ // deb.sury.org and Remi builds of end-of-life branches
+ 'note.suryEol': '이 PHP({version})는 업스트림 지원이 종료(EOL)된 PHP {branch} 브랜치의 deb.sury.org 빌드입니다. deb.sury.org는 이런 브랜치에 업스트림 버전을 그대로 둔 채 보안 수정을 백포트하므로, 빌드에 수정이 들어 있는지는 설치된 패키지의 changelog로만 알 수 있습니다.',
+ 'fix.suryEol': 'deb.sury.org는 지원 종료(EOL)된 PHP {branch} 브랜치에도 버전 번호를 그대로 둔 채 보안 수정을 백포트하므로, 최신 {src} 빌드에는 수정이 들어 있을 수 있습니다.',
+ 'step.collect.hiddenLink': 'PHP 프로브 출력에는 서버 스크립트 출력과 연결할 근거가 없습니다(참고 사항 참조). 이 PHP가 컨테이너 안에서 실행된다면 아래처럼 서버 스크립트를 컨테이너에 복사해 그 안에서 root로 실행하고, 그 출력만 붙여 넣으세요. 컨테이너가 아니라면 서버 스크립트 출력만 붙여 넣어도 이 서버의 PHP 빌드와 프로세스를 모두 확인할 수 있습니다. 또는 같은 PHP를 쓰면서 open_basedir가 설정되지 않은 사이트에서 PHP 프로브를 실행해 OS 릴리스 정보와 패키지 목록을 읽게 한 뒤, 그 출력을 서버 스크립트 출력과 함께 붙여 넣으세요.',
+ 'reason.suryChangelogFixed': '서버 스크립트가 설치된 버전에서 읽은 {pkg}의 changelog에 deb.sury.org가 백포트한 {cves} 수정이 기록되어 있습니다.',
+ 'reason.suryChangelogMissing': '서버 스크립트가 설치된 버전에서 읽은 {pkg}의 changelog에 {cves} 수정 기록이 없습니다. 이 빌드에는 아직 백포트가 들어 있지 않습니다.',
+ 'reason.suryBuiltBefore': '이 빌드({version})는 수정 릴리스가 나온 {released}보다 앞선 {date}에 만들어졌으므로 백포트된 수정이 들어 있을 수 없습니다.',
+ 'reason.suryEolUnread': '이 PHP {branch} deb.sury.org 빌드({version})에 백포트된 수정이 있는지는 패키지 changelog로만 알 수 있는데, 여기서는 changelog를 읽지 못했습니다. 설치된 모든 PHP 패키지의 changelog를 읽는 서버 스크립트를 실행하세요.',
+ 'note.remiEol': '이 PHP({pkg})는 Remi 저장소에서 설치한 것입니다. Remi의 지원 종료(EOL) 브랜치 컬렉션(php74, php80, php81)에는 버전 번호를 바꾸지 않고 보안 수정이 백포트되므로, 빌드에 수정이 들어 있는지는 패키지 changelog로만 알 수 있습니다.',
+ 'reason.remiChangelogMissing': '{pkg}의 changelog에 {cves} 수정 기록이 없습니다. 이 Remi 빌드에는 아직 백포트가 들어 있지 않습니다.',
+ 'reason.remiEolUnread': '이 PHP {branch} Remi 빌드({pkg})에 백포트된 수정이 있는지는 패키지 changelog로만 알 수 있는데, 여기서는 changelog를 읽지 못했습니다. 설치된 모든 PHP 패키지의 changelog를 읽는 서버 스크립트를 실행하세요.',
+ 'eol.hostSury': '이 서버에는 deb.sury.org 저장소가 설정되어 있어 apt가 배포판 패키지보다 그 저장소의 패키지를 우선합니다. 설치하기 전에 그 저장소를 끄고(/etc/apt/sources.list.d/ 안의 해당 파일), apt-cache policy에서 설치 후보가 {fixed}인지 확인하세요. 그러지 않으면 apt가 이 수정 패키지가 아닌 deb.sury.org의 {src}를 설치합니다.',
+ 'step.eol.hostSwitch': '당장 옮길 수 없다면, 이 {system} 서버에서는 배포판 자체의 {src} 패키지({fixed})에 두 수정이 백포트되어 있지만, 지금 설치된 {src} 패키지({foreign})는 다른 저장소에서 온 것입니다. 먼저 그 저장소를 끄고(/etc/apt/sources.list.d/ 안의 해당 파일이며, apt-cache policy로 알 수 있습니다) 아래 명령을 실행하세요. 두 번째 명령은 지금 {foreign} 버전인 {src} 패키지를 모두 {fixed} 버전으로 다시 설치하며, --allow-downgrades는 apt가 번호가 더 높은 빌드를 교체할 수 있게 합니다. 그런 다음 애플리케이션을 테스트하세요.{extra}',
+
+ // RHEL family, Amazon Linux and other distributions
+ 'reason.rhelOlderThanFixed': '{pkg} 패키지는 두 CVE를 수정한 빌드 {fixed}({advisory})보다 오래되었습니다.',
+ 'reason.amazonOlder': '{pkg} 패키지는 업스트림 수정 릴리스인 PHP {fixed} 버전보다 오래되었습니다. Amazon Linux는 PHP 수정을 업스트림 릴리스로 제공합니다.',
+ 'step.rpm.confirmVersion': '업데이트가 설치됐는지 확인하세요. 이제 rpm -q가 {fixed} 이상을 보여 줘야 합니다. 이 수정은 새 업스트림 릴리스로 나왔고 changelog 항목에는 CVE가 아니라 버전이 적혀 있으므로 버전으로 확인합니다.',
+ 'reason.vendorBackports': '{vendor} 배포판은 PHP 패키지의 업스트림 버전을 그대로 두고 보안 수정을 백포트하므로, PHP {version} 버전은 업스트림 수정 릴리스보다 낮아도 두 수정이 들어 있을 수 있습니다. 버전만으로는 확인할 수 없으니 {vendor}의 보안 권고나 설치된 PHP 패키지의 changelog에서 두 CVE를 확인하세요.',
+ 'step.collect.vendor': '{vendor}의 보안 권고나 설치된 PHP 패키지의 changelog에서 두 CVE를 확인하세요. 아래 명령은 PHP 패키지 목록과 changelog의 CVE 줄을 보여 줍니다. 수정이 설치되어 있다면 CVE마다 한 줄씩 있어야 합니다.',
+ 'step.other.updateEol': '이 PHP는 {vendor}에서 제공한 것이며, 이 배포판은 버전 번호를 바꾸지 않고 PHP {branch} 패키지에 보안 수정을 백포트합니다. 아래 명령으로 최신 PHP 업데이트를 설치한 뒤, {vendor}의 보안 권고나 첫 번째 단계처럼 패키지 changelog에서 두 CVE를 확인하세요.',
+
+ // Processes the server script could not check
+ 'reason.processesUnreadable': '서버 스크립트가 일부 PHP 프로세스({n}개)의 재시작 누락 여부를 확인하지 못했습니다. 프로세스를 읽지 못했거나 상태 줄이 잘렸습니다. 이 프로세스들은 여기서 확인한 PHP가 아니라 업데이트 후 재시작하지 않은 다른 PHP일 수 있으므로 패치됨으로 확인할 수 없습니다. root 권한으로 서버 스크립트를 실행하고 출력 전체를 붙여 넣으세요.',
+ 'reason.processesUnreadableRoot': '서버 스크립트가 root 권한으로도 일부 PHP 프로세스({n}개)의 재시작 누락 여부를 확인하지 못했습니다. 이 프로세스들은 여기서 확인한 PHP가 아니라 업데이트 후 재시작하지 않은 다른 PHP일 수 있으므로 패치됨으로 확인할 수 없습니다. CAP_SYS_PTRACE 권한이 없는 컨테이너 안이라면 호스트에서 서버 스크립트를 실행하거나, PHP마다 PHP 프로브로 확인하세요.',
+ 'reason.runningUncheckedRoot': '설치된 패키지에는 수정이 포함되어 있지만, 서버 스크립트가 root 권한으로도 일부 PHP 프로세스를 읽지 못해 실행 중인 PHP는 확인하지 못했습니다. CAP_SYS_PTRACE 권한이 없는 컨테이너 안이라면 호스트에서 서버 스크립트를 실행하거나 PHP 프로브를 실행해, 업데이트 후 실행 중인 PHP가 재시작됐는지 확인하세요.',
+ 'reason.runningPartlyHidden': '설치된 패키지에는 수정이 포함되어 있지만, 서버 스크립트를 root 권한으로 실행하지 않아 이 계정이 읽을 수 있는 PHP 프로세스만 확인했습니다. hidepid가 설정되어 있으면 /proc이 다른 계정의 프로세스를 완전히 숨기므로, 업데이트 후 재시작하지 않은 PHP가 있어도 보이지 않을 수 있습니다. sudo로 서버 스크립트를 다시 실행하세요.',
+ 'reason.serviceUnseen': '설치된 패키지에는 수정이 포함되어 있지만, 이 시스템에서는 서버 스크립트가 {names}의 프로세스를 볼 수 없어 업데이트 후 재시작됐는지 확인하지 못했습니다.',
+ 'step.collect.unreadableAsRoot': '서버 스크립트를 root 권한으로 실행했지만 일부 PHP 프로세스를 읽지 못했습니다. CAP_SYS_PTRACE 권한이 없는 컨테이너 안에서 실행하면 이렇게 되며, sudo로 다시 실행해도 결과는 같습니다. 대신 호스트에서 아래 명령으로 실행하거나(해시를 이 페이지의 서버 스크립트 탭에 나온 값과 비교하세요), 각 사이트에서 PHP 프로브를 실행하고 새 출력을 여기에 붙여 넣으세요.',
+ 'step.collect.unseen': '이 시스템에서는 서버 스크립트가 {names}의 프로세스를 볼 수 없습니다. 아래 명령으로 이 프로세스가 실행 중인지, 언제 시작됐는지 확인하세요. 업데이트 전에 시작된 프로세스가 있다면 다음 단계처럼 재시작하거나, 그 PHP를 PHP 프로브로 확인하세요.',
+ 'parse.procUnreadableRoot': ({ n }) => `root 권한으로도 PHP 프로세스${n ? ` ${n}개` : ''}의 재시작 누락 여부를 확인하지 못했습니다. 컨테이너 안에서 다른 계정의 프로세스 메모리 맵을 읽으려면 CAP_SYS_PTRACE 권한이 필요하므로, 호스트에서 서버 스크립트를 실행하거나 PHP 프로브를 사용하세요.`,
+};
+
+export const MESSAGES = { en, ko };
+
+const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+// A date from the data files (2026-12-31) as the language writes it: "31 December 2026", "2026년 12월 31일".
+export function formatDate(iso, lang) {
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
+ if (!m) return String(iso || '');
+ const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
+ return lang === 'ko' ? `${year}년 ${month}월 ${day}일` : `${day} ${MONTHS[month - 1]} ${year}`;
+}
+
+const has = (table, key) => Object.prototype.hasOwnProperty.call(table, key);
+
+// Returns t(key, vars) for the language. Unknown languages use English, and so does a key
+// missing in the language. A key missing everywhere, or a placeholder with no value, throws.
+export function translator(lang) {
+ const table = LANGS.includes(lang) ? MESSAGES[lang] : MESSAGES.en;
+ return function t(key, vars = {}) {
+ const msg = has(table, key) ? table[key] : has(MESSAGES.en, key) ? MESSAGES.en[key] : undefined;
+ if (msg === undefined) throw new Error('Missing message: ' + key);
+ if (typeof msg === 'function') return msg(vars);
+ return msg.replace(/\{(\w+)\}/g, (_, name) => {
+ const value = vars[name];
+ if (value === undefined || value === null) throw new Error(`Missing value {${name}} for message: ${key}`);
+ return String(value);
+ });
+ };
+}
diff --git a/site/lib/parse.js b/site/lib/parse.js
@@ -0,0 +1,569 @@
+// Turns pasted text into an observation.
+// Accepted formats: PHP probe output, server script output, response headers (curl -I or curl -IL), php -v output,
+// a version string. Text that holds several separate outputs (two probe outputs, two server script outputs, a probe
+// output and a header, several X-Powered-By headers, several -v lines) gives one observation per output in
+// obs.parts, and judge.js reports the worst of them. The top-level fields then describe the first one.
+// Notes for the user come from messages.js, in the language given by options.lang.
+// Every value read from the input is untrusted: it is used only as text of the expected type, and never as a key
+// that could reach an object's prototype.
+
+import { translator } from './messages.js';
+
+const MARKER = /-----(BEGIN|END) PHP2SHELL (SELFCHECK|SERVERCHECK)-----/g;
+// The first line of the server script's result block, and the line kinds that follow it.
+const SERVER_LINE = /^(?:tool|env|os|pkg|changelog|proc|log|webroot|webuser_proc)\|/m;
+const RHEL_IDS = ['rhel', 'centos', 'rocky', 'almalinux', 'ol', 'cloudlinux', 'eurolinux', 'amzn'];
+// Longer input is cut, so that a paste of several megabytes cannot keep the page busy.
+const MAX_INPUT = 2000000;
+// At most this many separate outputs are judged.
+const MAX_PARTS = 64;
+
+export function emptyObservation() {
+ return {
+ sources: [],
+ phpVersion: null,
+ sapi: null,
+ os: {},
+ // { manager, name, version, source, changelog? }. changelog (CVE -> number of changelog lines, null when the
+ // count is unknown) is the server script's reading of that package at exactly that version.
+ packages: [],
+ ini: null,
+ extensions: null,
+ container: null,
+ // True when the probe could not tell whether it runs in a container (open_basedir hides the markers)
+ containerHidden: false,
+ configPath: null,
+ serverSoftware: null,
+ // php_uname('r') from the probe: the kernel release, e.g. "5.14.0-427.el9_4.x86_64"
+ kernel: null,
+ // Whether open_basedir was set where the probe ran (true, false, or null when not reported)
+ openBasedir: null,
+ // True when the probe could not read the OS release at all (open_basedir, or no readable release file), so the
+ // distribution and its packages are hidden from it
+ osBlocked: false,
+ // The probe's own dpkg package list ([{ name, version }]), or null when it could not be read or was not reported
+ probeDpkg: null,
+ // Whether the server script ran as root (true, false, or null when not reported)
+ root: null,
+ // True when the server script's OS, packages and processes describe the system this PHP runs on
+ serverData: false,
+ // The probe and the server script list different PHP packages: taken at different times or on different systems
+ serverConflict: false,
+ // The server script output ends without its END line, so its later lines are missing
+ truncated: false,
+ // The server script output starts without its BEGIN line and without its first lines
+ headCut: false,
+ // The input was longer than MAX_INPUT, or held more than MAX_PARTS outputs, so part of it was not read
+ inputCut: false,
+ processes: [],
+ // PHP processes the server script could not check for a missed restart
+ procUnreadable: 0,
+ // Compromise indicator counts; null means the source could not be read ("unknown")
+ findings: {},
+ notes: [],
+ };
+}
+
+// Every probe and server script block, in one pass over the text. A block without its END line runs to the next
+// marker or to the end. An END line without its BEGIN line (the output was copied from below its first line) ends
+// a block that starts where its first data line is.
+function findBlocks(text) {
+ const blocks = [];
+ let open = null;
+ let after = 0;
+ const close = (end, closed, stop) => {
+ blocks.push({ kind: open.kind, body: text.slice(open.from, end), closed, start: open.start, stop, headless: false });
+ open = null;
+ after = stop;
+ };
+ for (const m of text.matchAll(MARKER)) {
+ const kind = m[2] === 'SELFCHECK' ? 'self' : 'server';
+ const stop = m.index + m[0].length;
+ if (m[1] === 'BEGIN') {
+ if (open) close(m.index, false, m.index);
+ open = { kind, from: stop, start: m.index };
+ } else if (open) {
+ // The END line of another kind of output ends the open block too, which then has no END line of its own.
+ close(m.index, open.kind === kind, stop);
+ } else {
+ const region = text.slice(after, m.index);
+ const first = kind === 'server' ? region.search(SERVER_LINE) : region.indexOf('{');
+ const start = after + Math.max(first, 0);
+ blocks.push({ kind, body: text.slice(start, m.index), closed: true, start, stop, headless: true });
+ after = stop;
+ }
+ }
+ if (open) close(text.length, false, text.length);
+ return blocks;
+}
+
+// The same output pasted twice counts once.
+function unique(blocks) {
+ const seen = new Set();
+ return blocks.filter((b) => {
+ const key = b.body.trim();
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+export function parseInput(text, options = {}) {
+ const t = translator(options.lang || 'en');
+ try {
+ return parseText(t, String(text ?? ''));
+ } catch {
+ // Never leave the page without a result: an input this parser cannot handle gives no version.
+ const obs = emptyObservation();
+ obs.notes.push(t('parse.noVersion'));
+ return obs;
+ }
+}
+
+function parseText(t, raw) {
+ let input = raw.replace(/\r\n?/g, '\n');
+ if (!input.trim()) return emptyObservation();
+ let cut = false;
+ if (input.length > MAX_INPUT) {
+ input = input.slice(0, MAX_INPUT);
+ cut = true;
+ }
+ const finish = (obs) => {
+ if (cut) {
+ obs.inputCut = true;
+ obs.notes.push(t('parse.inputTooLong'));
+ }
+ return obs;
+ };
+
+ const blocks = findBlocks(input);
+ if (!blocks.length) {
+ const trimmed = input.trim();
+ if (trimmed.startsWith('{')) {
+ const obs = emptyObservation();
+ applySelfCheck(t, obs, trimmed);
+ return finish(obs);
+ }
+ const loose = looseObservations(t, input, false);
+ if (!loose.length) {
+ const obs = emptyObservation();
+ obs.notes.push(t('parse.noVersion'));
+ return finish(obs);
+ }
+ return finish(combine(t, loose));
+ }
+
+ const probes = unique(blocks.filter((b) => b.kind === 'self'));
+ const servers = unique(blocks.filter((b) => b.kind === 'server'));
+ const parts = [];
+ if (servers.length <= 1 && probes.length) {
+ // One server script output next to one or more probe outputs (several sites or pools on one server).
+ const sc = servers.length ? serverObservation(t, servers[0]) : null;
+ let used = false;
+ let otherSystem = false;
+ for (const b of probes) {
+ const obs = emptyObservation();
+ applySelfCheck(t, obs, b.body);
+ if (sc) {
+ const drop = mergeServerCheck(t, obs, sc);
+ if (!drop) used = true;
+ else if (drop !== 'parse.serverConflict') otherSystem = true;
+ }
+ parts.push(obs);
+ }
+ // Server script output from another system (the host of a container, another server) is judged on its own:
+ // the PHP it shows runs somewhere too. Output of the same system at another moment is not (see judge.js).
+ if (sc && otherSystem && !used) parts.push(sc);
+ } else {
+ // Several server script outputs: which probe belongs to which is unknown, so each output stands alone.
+ for (const b of probes) {
+ const obs = emptyObservation();
+ applySelfCheck(t, obs, b.body);
+ parts.push(obs);
+ }
+ for (const b of servers) parts.push(serverObservation(t, b));
+ }
+ // Headers and -v lines pasted next to the blocks. Only lines that start at the left margin count, so the
+ // server script's own indented text (package lists, log lines) is never read as one.
+ let rest = '';
+ let at = 0;
+ for (const b of blocks) {
+ rest += `${input.slice(at, Math.max(at, b.start))}\n`;
+ at = Math.max(at, b.stop);
+ }
+ rest += input.slice(at);
+ parts.push(...looseObservations(t, rest, true));
+ return finish(combine(t, parts));
+}
+
+function combine(t, parts) {
+ if (parts.length === 1) return parts[0];
+ let list = parts;
+ let tooMany = false;
+ if (list.length > MAX_PARTS) {
+ list = list.slice(0, MAX_PARTS);
+ tooMany = true;
+ }
+ const notes = [...new Set(list.flatMap((p) => p.notes))];
+ if (tooMany) notes.push(t('parse.tooManyOutputs', { n: MAX_PARTS }));
+ return {
+ ...list[0],
+ sources: [...new Set(list.flatMap((p) => p.sources))],
+ notes,
+ inputCut: tooMany || list[0].inputCut,
+ parts: list,
+ };
+}
+
+function applySelfCheck(t, obs, jsonText) {
+ let data;
+ try {
+ data = JSON.parse(jsonText);
+ } catch {
+ obs.notes.push(t('parse.badJson'));
+ return;
+ }
+ if (!data || typeof data !== 'object' || data.tool !== 'php2shell-selfcheck') {
+ obs.notes.push(t('parse.notSelfcheck'));
+ return;
+ }
+ obs.sources.push('selfcheck');
+ obs.phpVersion = field(data.php_version) || obs.phpVersion;
+ obs.sapi = field(data.sapi) || obs.sapi;
+ const release = isRecord(data.os_release) ? normalizeOs(data.os_release) : {};
+ obs.os = { ...release, ...obs.os };
+ obs.kernel = field(data.uname_release);
+ obs.openBasedir = typeof data.open_basedir === 'boolean' ? data.open_basedir : null;
+ // os_release is null when no release file could be read (older probes sent an empty list), and under
+ // open_basedir an empty OS release means it could not be read, not that there is none.
+ const unread = data.os_release === null || (Array.isArray(data.os_release) && !data.os_release.length);
+ if (!Object.keys(release).length && (obs.openBasedir || unread)) {
+ obs.osBlocked = true;
+ obs.notes.push(t(obs.openBasedir ? 'parse.openBasedir' : 'parse.osReleaseUnread'));
+ }
+ if (Array.isArray(data.dpkg_packages)) {
+ obs.probeDpkg = [];
+ for (const p of data.dpkg_packages) {
+ const name = isRecord(p) ? field(p.name) : null;
+ const version = isRecord(p) ? field(p.version) : null;
+ if (name && version) {
+ obs.probeDpkg.push({ name, version });
+ addPackage(obs, { manager: 'dpkg', name, version, source: cleanSource(p.source) });
+ }
+ }
+ } else if (data.dpkg_packages === null && !obs.osBlocked && /debian|ubuntu/.test(`${release.id || ''} ${release.idLike || ''}`)) {
+ obs.notes.push(t('parse.dpkgUnreadable'));
+ }
+ // An empty list is what PHP sends for an empty array: no settings were read.
+ obs.ini = cleanIni(data.ini);
+ obs.extensions = isRecord(data.extensions) ? data.extensions : null;
+ // open_basedir also hides the container markers, so a "false" there proves nothing.
+ obs.container = data.container === true ? true : data.container === false && !obs.openBasedir ? false : null;
+ // The probe reported that it could not tell (null), or open_basedir hid the markers from it.
+ obs.containerHidden = obs.container === null && Object.prototype.hasOwnProperty.call(data, 'container');
+ obs.configPath = field(data.config_path);
+ obs.serverSoftware = field(data.server_software);
+ if (data.self_deleted === false) {
+ obs.notes.push(t('parse.probeNotDeleted'));
+ }
+}
+
+// The server script output on its own: it describes the system it ran on.
+function serverObservation(t, block) {
+ const sc = emptyObservation();
+ applyServerCheck(t, sc, block.body);
+ sc.serverData = true;
+ if (!block.closed) {
+ sc.truncated = true;
+ sc.notes.push(t('parse.serverTruncated'));
+ }
+ if (block.headless) {
+ // Copied from below its BEGIN line. Its first line is the tool line: without it, earlier lines are missing too.
+ const whole = /^tool\|php2shell-server-check\|/m.test(block.body);
+ sc.headCut = !whole;
+ sc.notes.push(t(whole ? 'parse.serverNoBegin' : 'parse.serverHeadCut'));
+ }
+ return sc;
+}
+
+function applyServerCheck(t, obs, block) {
+ obs.sources.push('servercheck');
+ const os = Object.create(null);
+ const changelog = new Map();
+ const badStatus = [];
+ let unreadable = 0;
+ for (const raw of block.split('\n')) {
+ const line = raw.trim();
+ if (!line) continue;
+ const f = line.split('|');
+ switch (f[0]) {
+ case 'os':
+ if (f[1]) os[f[1]] = f.slice(2).join('|');
+ break;
+ case 'env':
+ if (f[1] === 'root') obs.root = f[2] === '1' ? true : f[2] === '0' ? false : null;
+ break;
+ case 'pkg':
+ if (f[2] && f[3]) {
+ addPackage(obs, { manager: f[1], name: f[2], version: f[3], source: cleanSource(f[4]) });
+ }
+ break;
+ case 'changelog':
+ if (f[1] && /^CVE-\d{4}-\d+$/.test(f[2] || '')) {
+ if (!changelog.has(f[1])) changelog.set(f[1], new Map());
+ changelog.get(f[1]).set(f[2], count(f[3]));
+ }
+ break;
+ case 'proc':
+ if (f[1] === 'unreadable') {
+ unreadable += count(f[2]) ?? 1;
+ obs.procUnreadable += count(f[2]) ?? 1;
+ } else if (f[1] && (f[3] === 'ok' || f[3] === 'stale')) {
+ obs.processes.push({ comm: f[1], count: Number(f[2]) || 0, status: f[3] });
+ } else if (f[1]) {
+ // A status other than ok or stale (a line cut off while copying): this process was not checked.
+ obs.procUnreadable += count(f[2]) || 1;
+ badStatus.push(f[1]);
+ }
+ break;
+ case 'log':
+ case 'webroot':
+ if (f[1] && f[1] !== '__proto__') obs.findings[f[1]] = count(f[2]);
+ break;
+ case 'webuser_proc':
+ obs.findings.webuser_proc = count(f[1]);
+ break;
+ default:
+ break;
+ }
+ }
+ // As root, a process the script cannot read is usually in a container without CAP_SYS_PTRACE: running it again as
+ // root does not help there.
+ if (unreadable) obs.notes.push(t(obs.root === true ? 'parse.procUnreadableRoot' : 'parse.procUnreadable', { n: String(unreadable) }));
+ if (badStatus.length) obs.notes.push(t('parse.procBadStatus', { names: [...new Set(badStatus)].join(', ') }));
+ // The script reads the changelog of each installed package, so a changelog belongs to the version listed with
+ // it. A name listed with two versions is ambiguous, and its changelog is not used.
+ for (const [name, hits] of changelog) {
+ const pkgs = obs.packages.filter((p) => p.name === name && (p.manager === 'dpkg' || p.manager === 'rpm'));
+ if (pkgs.length === 1) pkgs[0].changelog = Object.fromEntries(hits);
+ }
+ obs.os = normalizeOs(os);
+}
+
+// Adds the server script's observation to the probe's, and returns null, or the message key of the reason it was
+// not used. Its compromise indicators are always kept. Its OS, packages, changelogs and processes are used only
+// when nothing shows that the two outputs come from different systems or different moments (see unlinked).
+function mergeServerCheck(t, obs, sc) {
+ obs.sources.push('servercheck');
+ obs.root = sc.root;
+ obs.findings = { ...obs.findings, ...sc.findings };
+ const drop = obs.sources.includes('selfcheck') ? unlinked(obs, sc) : null;
+ if (drop) {
+ obs.notes.push(t(drop, { os: osLabel(t, sc.os), probe: osLabel(t, obs.os) }));
+ if (drop === 'parse.serverConflict') obs.serverConflict = true;
+ if (drop === 'parse.serverUnlinkedHidden') obs.unlinkedHidden = true;
+ return drop;
+ }
+ obs.serverData = true;
+ obs.truncated = sc.truncated;
+ obs.headCut = sc.headCut;
+ // The server script read the OS release and the packages the probe could not see.
+ if (obs.osBlocked && Object.keys(sc.os || {}).length) {
+ obs.osBlocked = false;
+ const hidden = [t('parse.openBasedir'), t('parse.osReleaseUnread')];
+ obs.notes = obs.notes.filter((n) => !hidden.includes(n));
+ }
+ obs.os = { ...obs.os, ...sc.os };
+ for (const p of sc.packages) addPackage(obs, p);
+ obs.processes.push(...sc.processes);
+ obs.procUnreadable += sc.procUnreadable;
+ for (const n of sc.notes) if (!obs.notes.includes(n)) obs.notes.push(n);
+ return null;
+}
+
+// Why the server script output does not describe the probe's system, as a message key, or null:
+// - both report an OS release, and they differ: another system (the host of a container, or another server);
+// - the probe ran in a container, and its own OS release and dpkg package list do not tie the server script
+// output to that container (the script may have run on the host);
+// - the probe could not tell whether it runs in a container (open_basedir hides the markers), and its dpkg package
+// list does not tie the two together;
+// - the probe read its dpkg package list and it differs from the server script's: different times or systems.
+function unlinked(obs, sc) {
+ const probeOs = obs.os || {};
+ const serverOs = sc.os || {};
+ const probeHasOs = Object.keys(probeOs).length > 0;
+ if (probeHasOs && Object.keys(serverOs).length > 0 && !sameOs(probeOs, serverOs)) {
+ return obs.container === true ? 'parse.serverOtherSystem' : 'parse.serverOtherOs';
+ }
+ const serverDpkg = sc.packages.filter((p) => p.manager === 'dpkg');
+ const agree = obs.probeDpkg !== null && samePackages(obs.probeDpkg, serverDpkg);
+ if (obs.container === true && (!probeHasOs || !agree)) return 'parse.serverUnlinked';
+ if (obs.containerHidden && !agree) return 'parse.serverUnlinkedHidden';
+ if (obs.probeDpkg !== null && !agree) return 'parse.serverConflict';
+ return null;
+}
+
+// Whether two OS releases are the same. Without os-release the server script writes "rhel" and the major version for
+// every /etc/redhat-release, where the probe names the vendor from the file's text (CentOS, CloudLinux, Oracle Linux).
+function sameOs(a, b) {
+ if (a.id === b.id) return (a.versionId || '') === (b.versionId || '');
+ const legacy = (o) => o.id === 'rhel' && !o.idLike && /^\d+$/.test(o.versionId || '');
+ const rhelish = (o) => RHEL_IDS.includes(o.id) || /\brhel\b/.test(o.idLike || '');
+ if ((legacy(a) && rhelish(b)) || (legacy(b) && rhelish(a))) {
+ const major = (o) => String(o.versionId || '').split('.')[0];
+ return major(a) !== '' && major(a) === major(b);
+ }
+ return false;
+}
+
+// The probe lists at most 40 packages; a full list only has to be part of the server script's.
+const PROBE_DPKG_MAX = 40;
+
+function samePackages(probeList, serverList) {
+ const key = (p) => `${p.name} ${p.version}`;
+ const sa = new Set(probeList.map(key));
+ const sb = new Set(serverList.map(key));
+ return (sa.size === sb.size || probeList.length >= PROBE_DPKG_MAX) && [...sa].every((k) => sb.has(k));
+}
+
+function osLabel(t, os) {
+ return os?.pretty || [os?.id, os?.versionId].filter(Boolean).join(' ') || t('distro.unknown');
+}
+
+function addPackage(obs, p) {
+ const same = obs.packages.find((q) => q.manager === p.manager && q.name === p.name && q.version === p.version);
+ if (!same) obs.packages.push({ ...p });
+ else if (p.changelog && !same.changelog) same.changelog = p.changelog;
+}
+
+const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+// A string field of the probe output, trimmed, or null when it is missing, empty or not a string.
+function field(value) {
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
+}
+
+// A text field of an OS release: a string, or a number written as text; anything else (an object, a list) is not used.
+function osField(value) {
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+ return field(value);
+}
+
+// The probe's ini settings: text values only. A value of another type (an object, a list) is left out, as if the
+// setting had not been read.
+function cleanIni(ini) {
+ if (!isRecord(ini)) return null;
+ const out = {};
+ for (const [name, value] of Object.entries(ini)) {
+ if (name === '__proto__') continue;
+ if (value === null || typeof value === 'string') out[name] = value;
+ else if (typeof value === 'number' || typeof value === 'boolean') out[name] = String(value);
+ }
+ return out;
+}
+
+// A count from the server script: a non-negative integer, or null for "unknown" (the source could not be read).
+function count(value) {
+ const s = String(value ?? '').trim();
+ return /^\d{1,9}$/.test(s) ? Number(s) : null;
+}
+
+// A PHP version, as it appears in headers, -v output and version strings. The lengths are bounded, so that a long
+// run of digits cannot make the search slow.
+const VERSION = '(\\d{1,2}\\.\\d{1,3}\\.\\d{1,5}[^\\s,;()]{0,100})';
+
+// X-Powered-By and Server headers, -v output lines and, when nothing else is found and strict is false, version
+// strings. One observation per distinct PHP version. strict: only lines that start at the left margin count.
+function looseObservations(t, text, strict) {
+ const out = [];
+ const lead = strict ? '' : '[ \\t]*';
+ // curl -IL prints one header block per redirect hop, each starting with a status line.
+ const headers = new Map();
+ for (const response of text.split(/^(?=[ \t]*HTTP\/\d)/m)) {
+ const serverLine = new RegExp(`^${lead}Server:[ \\t]*([^\\n]*)$`, 'im').exec(response);
+ const server = serverLine && serverLine[1].trim() ? serverLine[1].trim().slice(0, 200) : null;
+ // A PHP version anywhere in an X-Powered-By or Server header line: a proxy may merge several values into one
+ // line ("PleskLin, PHP/8.2.20", "PHP/8.4.27, PHP/8.2.20"), and some servers name PHP in the Server header.
+ for (const line of response.matchAll(new RegExp(`^${lead}(?:X-Powered-By|Server):[ \\t]*([^\\n]*)$`, 'gim'))) {
+ for (const m of line[1].matchAll(new RegExp(`\\bPHP\\/${VERSION}`, 'gi'))) {
+ if (!headers.has(m[1]) || (!headers.get(m[1]) && server)) headers.set(m[1], server);
+ }
+ }
+ }
+ for (const [version, server] of headers) {
+ const obs = emptyObservation();
+ obs.sources.push('header');
+ obs.phpVersion = version;
+ obs.serverSoftware = server;
+ obs.notes.push(t('parse.headerOnly'));
+ out.push(obs);
+ }
+ // php -v, php-fpm8.2 -v, php-cgi -v: "PHP 8.2.20 (fpm-fcgi) (built: ...)"
+ const seen = new Set();
+ const cli = `^${lead}PHP[ \\t]+(\\d{1,2}\\.\\d{1,3}\\.\\d{1,5}\\S{0,100})[ \\t]+\\(([A-Za-z0-9-]{1,40})\\)`;
+ for (const m of text.matchAll(new RegExp(cli, 'gim'))) {
+ const sapi = m[2].toLowerCase();
+ if (seen.has(`${m[1]} ${sapi}`)) continue;
+ seen.add(`${m[1]} ${sapi}`);
+ const obs = emptyObservation();
+ obs.sources.push('cli');
+ obs.phpVersion = m[1];
+ obs.sapi = sapi;
+ if (sapi === 'cli') obs.notes.push(t('parse.cliVersion'));
+ out.push(obs);
+ }
+ if (!strict && !out.length) {
+ // Version strings: at the start of the input, on a line of their own, or after "PHP" ("PHP/8.2.20", "PHP 8.2.20").
+ const versions = new Set();
+ const trimmed = text.trim();
+ const first = new RegExp(`^${VERSION}`).exec(trimmed);
+ if (first) versions.add(first[1]);
+ for (const m of trimmed.matchAll(new RegExp(`^[ \\t]*${VERSION}[ \\t]*$|\\bPHP\\/?[ \\t]*${VERSION}`, 'gim'))) {
+ versions.add(m[1] || m[2]);
+ }
+ for (const version of versions) {
+ const obs = emptyObservation();
+ obs.sources.push('version');
+ obs.phpVersion = version;
+ out.push(obs);
+ }
+ }
+ return out;
+}
+
+function normalizeOs(o) {
+ const out = {};
+ const id = osField(o.ID);
+ const idLike = osField(o.ID_LIKE);
+ const versionId = osField(o.VERSION_ID);
+ const codename = osField(o.VERSION_CODENAME);
+ const pretty = osField(o.PRETTY_NAME);
+ if (id) out.id = id.toLowerCase();
+ if (idLike) out.idLike = idLike.toLowerCase();
+ if (versionId) out.versionId = versionId;
+ if (codename) out.codename = codename.toLowerCase();
+ if (pretty) out.pretty = pretty;
+ // The probe names the legacy keys after the file: LEGACY_REDHAT-RELEASE, LEGACY_DEBIAN_VERSION.
+ const redhat = osField(o['LEGACY_REDHAT-RELEASE']) || osField(o.LEGACY_REDHAT_RELEASE);
+ if (!out.pretty && redhat) out.pretty = redhat;
+ if (!out.id && redhat) {
+ // Fedora writes the same file but is not part of the RHEL family.
+ out.id = /centos/i.test(redhat) ? 'centos' : /rocky/i.test(redhat) ? 'rocky' : /alma/i.test(redhat) ? 'almalinux'
+ : /cloudlinux/i.test(redhat) ? 'cloudlinux' : /oracle/i.test(redhat) ? 'ol' : /amazon/i.test(redhat) ? 'amzn'
+ : /fedora/i.test(redhat) ? 'fedora' : 'rhel';
+ const m = /release (\d+)/.exec(redhat);
+ if (m) out.versionId = m[1];
+ }
+ const debian = osField(o['LEGACY_DEBIAN-VERSION']) || osField(o.LEGACY_DEBIAN_VERSION);
+ if (!out.id && debian) {
+ out.id = 'debian';
+ const m = /^(\d+)/.exec(debian);
+ if (m) out.versionId = m[1];
+ }
+ return out;
+}
+
+// The dpkg Source field sometimes carries a version, as in "php8.1 (8.1.2-1ubuntu2.28)".
+function cleanSource(source) {
+ return (typeof source === 'string' ? source : '').replace(/\s*\(.*\)\s*$/, '').trim();
+}
diff --git a/site/lib/version.js b/site/lib/version.js
@@ -0,0 +1,203 @@
+// Compares upstream PHP versions and Debian/Ubuntu package versions.
+
+export function parsePhpVersion(text) {
+ const m = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(String(text || '').trim());
+ if (!m) return null;
+ const suffix = m[4];
+ // Pre-releases: "8.6.0RC3", "8.5.12-dev" (a branch snapshot before that release), "8.2.0~rc1-1" (a Debian package of one),
+ // "8.5.12_rc1-r0" and "8.5.0_pre1" (Alpine writes them with an underscore; its "_p1" is a patch level, not a pre-release).
+ // In a Debian version any other "~" suffix of the upstream part ("8.4.27~git20261001-1") sorts before the release as
+ // well: it is a snapshot taken before it. Repack markers ("~dfsg") are not pre-releases.
+ const pre = /^[-.~_]?((?:alpha|beta|RC|dev|pre)\d*)/i.exec(suffix)
+ || /^~(?!(?:dfsg|ds|repack)\d*(?:$|[-+~]))([^-+~]+)/i.exec(suffix);
+ const rest = pre ? suffix.slice(pre[0].length) : suffix;
+ return {
+ major: Number(m[1]),
+ minor: Number(m[2]),
+ patch: Number(m[3]),
+ branch: `${m[1]}.${m[2]}`,
+ upstream: `${m[1]}.${m[2]}.${m[3]}`,
+ pre: pre ? pre[1] : '',
+ // The distribution revision, if one is attached, as in "-1ubuntu2.20" or "-1~deb12u1"
+ revision: rest.startsWith('-') ? rest.slice(1) : '',
+ raw: String(text).trim(),
+ };
+}
+
+// Compares major.minor.patch only. For the same numbers, a pre-release such as RC sorts lower.
+export function comparePhp(a, b) {
+ const pa = typeof a === 'string' ? parsePhpVersion(a) : a;
+ const pb = typeof b === 'string' ? parsePhpVersion(b) : b;
+ if (!pa || !pb) throw new Error(`Cannot parse PHP version: ${a} / ${b}`);
+ for (const k of ['major', 'minor', 'patch']) {
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
+ }
+ if (pa.pre && !pb.pre) return -1;
+ if (!pa.pre && pb.pre) return 1;
+ return 0;
+}
+
+// A direct port of dpkg's version comparison (lib/dpkg/version.c).
+export function compareDebian(a, b) {
+ const pa = splitDebian(a);
+ const pb = splitDebian(b);
+ if (pa.epoch !== pb.epoch) return pa.epoch < pb.epoch ? -1 : 1;
+ const c = verrevcmp(pa.upstream, pb.upstream);
+ if (c !== 0) return c;
+ return verrevcmp(pa.revision, pb.revision);
+}
+
+export function splitDebian(version) {
+ let v = String(version || '').trim();
+ let epoch = 0;
+ const colon = v.indexOf(':');
+ if (colon >= 0) {
+ epoch = Number.parseInt(v.slice(0, colon), 10) || 0;
+ v = v.slice(colon + 1);
+ }
+ const hyphen = v.lastIndexOf('-');
+ if (hyphen < 0) return { epoch, upstream: v, revision: '' };
+ return { epoch, upstream: v.slice(0, hyphen), revision: v.slice(hyphen + 1) };
+}
+
+const isDigit = (ch) => ch >= '0' && ch <= '9';
+
+function order(ch) {
+ if (ch === '') return 0;
+ if (isDigit(ch)) return 0;
+ if (/[A-Za-z]/.test(ch)) return ch.charCodeAt(0);
+ if (ch === '~') return -1;
+ return ch.charCodeAt(0) + 256;
+}
+
+function verrevcmp(a, b) {
+ let i = 0;
+ let j = 0;
+ const at = (s, k) => (k < s.length ? s[k] : '');
+ while (i < a.length || j < b.length) {
+ let firstDiff = 0;
+ while ((i < a.length && !isDigit(a[i])) || (j < b.length && !isDigit(b[j]))) {
+ const ac = order(at(a, i));
+ const bc = order(at(b, j));
+ if (ac !== bc) return ac < bc ? -1 : 1;
+ i++;
+ j++;
+ }
+ while (at(a, i) === '0') i++;
+ while (at(b, j) === '0') j++;
+ while (isDigit(at(a, i)) && isDigit(at(b, j))) {
+ if (!firstDiff) firstDiff = a.charCodeAt(i) - b.charCodeAt(j);
+ i++;
+ j++;
+ }
+ if (isDigit(at(a, i))) return 1;
+ if (isDigit(at(b, j))) return -1;
+ if (firstDiff) return firstDiff < 0 ? -1 : 1;
+ }
+ return 0;
+}
+
+// Repack markers in a Debian upstream version ("5.5.9+dfsg"). PHP_VERSION never carries them.
+const REPACK = /[+~](?:dfsg|ds|repack)\d*$/i;
+
+// The upstream part of a package version or a PHP_VERSION string, without epoch and repack
+// markers: "5.5.9+dfsg-1ubuntu4.29+esm18" -> "5.5.9", "1:8.2.30-1.el9_6" -> "8.2.30".
+export function debUpstream(version) {
+ return splitDebian(version).upstream.replace(REPACK, '');
+}
+
+// A package version or PHP_VERSION string in a form that compares like for like: the epoch
+// (never in PHP_VERSION; PHP source packages do not use one) and repack markers are dropped.
+export function debComparable(version) {
+ const { upstream, revision } = splitDebian(version);
+ const up = upstream.replace(REPACK, '');
+ return revision ? `${up}-${revision}` : up;
+}
+
+// Builds from Ondřej Surý's repository (deb.sury.org) follow upstream versions directly.
+export function isSuryBuild(version) {
+ return /deb\.sury\.org|\+0~20\d{6}|\.gbp[0-9a-f]+/.test(String(version || ''));
+}
+
+// The build date of a deb.sury.org Debian build ("8.1.34-9+0~20261021.82+debian12~1.gbp4e430a" -> "2026-10-21"), or
+// null. Its Ubuntu builds ("+ubuntu22.04.1+deb.sury.org+1") carry no date.
+export function suryBuildDate(version) {
+ const m = /\+0~(20\d{2})(\d{2})(\d{2})/.exec(String(version || ''));
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
+}
+
+// rpm's version comparison (rpmvercmp in rpmio/rpmvercmp.c) of one version or release string.
+function rpmvercmp(a, b) {
+ if (a === b) return 0;
+ const isAlnum = (ch) => /[A-Za-z0-9]/.test(ch);
+ const isAlpha = (ch) => /[A-Za-z]/.test(ch);
+ let i = 0;
+ let j = 0;
+ while (i < a.length || j < b.length) {
+ while (i < a.length && !isAlnum(a[i]) && a[i] !== '~' && a[i] !== '^') i++;
+ while (j < b.length && !isAlnum(b[j]) && b[j] !== '~' && b[j] !== '^') j++;
+ // "~" sorts before everything, even the end of the string.
+ if (a[i] === '~' || b[j] === '~') {
+ if (a[i] !== '~') return 1;
+ if (b[j] !== '~') return -1;
+ i++;
+ j++;
+ continue;
+ }
+ // "^" sorts after the end of the string but before anything else.
+ if (a[i] === '^' || b[j] === '^') {
+ if (i >= a.length) return -1;
+ if (j >= b.length) return 1;
+ if (a[i] !== '^') return 1;
+ if (b[j] !== '^') return -1;
+ i++;
+ j++;
+ continue;
+ }
+ if (i >= a.length || j >= b.length) break;
+ const si = i;
+ const sj = j;
+ const numeric = isDigit(a[i]);
+ if (numeric) {
+ while (i < a.length && isDigit(a[i])) i++;
+ while (j < b.length && isDigit(b[j])) j++;
+ } else {
+ while (i < a.length && isAlpha(a[i])) i++;
+ while (j < b.length && isAlpha(b[j])) j++;
+ }
+ let x = a.slice(si, i);
+ let y = b.slice(sj, j);
+ // A numeric segment is newer than an alphabetic one.
+ if (!y.length) return numeric ? 1 : -1;
+ if (numeric) {
+ x = x.replace(/^0+/, '');
+ y = y.replace(/^0+/, '');
+ if (x.length !== y.length) return x.length < y.length ? -1 : 1;
+ }
+ if (x !== y) return x < y ? -1 : 1;
+ }
+ if (i >= a.length && j >= b.length) return 0;
+ return i >= a.length ? -1 : 1;
+}
+
+// Compares two rpm versions "[epoch:]version-release" as rpm does. A missing epoch is 0; when one side has no release,
+// only the versions are compared.
+export function compareRpm(a, b) {
+ const split = (s) => {
+ let v = String(s || '').trim();
+ let epoch = 0;
+ const colon = v.indexOf(':');
+ if (colon >= 0) {
+ epoch = Number.parseInt(v.slice(0, colon), 10) || 0;
+ v = v.slice(colon + 1);
+ }
+ const hyphen = v.lastIndexOf('-');
+ return hyphen < 0 ? { epoch, version: v, release: '' } : { epoch, version: v.slice(0, hyphen), release: v.slice(hyphen + 1) };
+ };
+ const pa = split(a);
+ const pb = split(b);
+ if (pa.epoch !== pb.epoch) return pa.epoch < pb.epoch ? -1 : 1;
+ const c = rpmvercmp(pa.version, pb.version);
+ if (c !== 0 || !pa.release || !pb.release) return c;
+ return rpmvercmp(pa.release, pb.release);
+}
diff --git a/site/style.css b/site/style.css
@@ -0,0 +1,549 @@
+/* PHP2Shell exposure check.
+ A working tool, not a landing page: input on the left, result on the right,
+ reference data underneath. Light only. The crimson and warm neutrals come
+ from sqzer.com; status colours are kept separate from the brand colour so
+ "vulnerable" never reads as decoration. No inline styles: the CSP forbids
+ them. */
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("fonts/jetbrains-mono-400.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("fonts/jetbrains-mono-700.woff2") format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+:root {
+ --bg: #f6f4ef;
+ --panel: #ffffff;
+ --sunken: #f9f8f5;
+ --code: #f3f1ec;
+ --line: #e3ded4;
+ --line-2: #cfc8bb;
+ /* Edges of form controls: 3.8:1 on the white pane, 3.6:1 on the textarea fill
+ (WCAG 1.4.11 asks for 3:1). --line and --line-2 stay for dividers. */
+ --control: #8a8275;
+ --text: #1c1915;
+ --muted: #57524a;
+ --faint: #6f695f;
+ --brand: #a51c30;
+ --brand-dark: #771120;
+ --ink: #23201b;
+
+ --ok: #067647; --ok-bg: #ecfdf3; --ok-line: #abefc6;
+ --bad: #b42318; --bad-bg: #fef3f2; --bad-line: #fecdca;
+ --warn: #b54708; --warn-bg: #fffaeb; --warn-line: #fedf89;
+ --info: #344054; --info-bg: #f2f4f7; --info-line: #d0d5dd;
+
+ /* Pretendard covers Hangul and Latin with one design and stays crisp at small sizes on Windows. */
+ --sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", "Segoe UI", Roboto, sans-serif;
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --radius: 6px;
+ --max: 84rem;
+ color-scheme: light;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+/* Everything is sized in rem, so the root size scales text and spacing together. */
+html { -webkit-text-size-adjust: 100%; font-size: 125%; }
+[hidden] { display: none !important; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--sans);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+a { color: var(--brand); text-decoration-thickness: 1px; text-underline-offset: 2px; }
+a:hover { color: var(--brand-dark); }
+:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+
+code, pre, textarea, .mono { font-family: var(--mono); }
+code { font-size: 0.86em; }
+:where(p, li, td, dd) code { padding: 0.05em 0.3em; border-radius: 3px; background: var(--code); }
+code.lit { white-space: nowrap; }
+
+h1, h2, h3, h4 { margin: 0; line-height: 1.25; }
+
+/* Korean: break between words, not inside them, and prefer Korean system faces. */
+html[lang="ko"] body { word-break: keep-all; overflow-wrap: anywhere; }
+html[lang="ko"] .badge { letter-spacing: 0; }
+html[lang="ko"] .block h4, html[lang="ko"] .verdict-label { letter-spacing: 0.02em; }
+/* In tables, "anywhere" would let automatic column sizing squeeze a cell down
+ to one character; break-word still breaks a word that cannot fit, but only
+ after the columns are sized around whole words. */
+table { overflow-wrap: break-word; }
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+.skip { position: absolute; left: -999px; top: 0; z-index: 10; padding: 0.4rem 0.8rem; background: var(--ink); color: #fff; }
+.skip:focus { left: 0.5rem; top: 0.5rem; }
+
+/* ── Top bar ─────────────────────────────────────────────────────── */
+
+.topbar { background: var(--panel); border-bottom: 1px solid var(--line); }
+.topbar-in {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem 1.25rem;
+ flex-wrap: wrap;
+ max-width: var(--max);
+ margin: 0 auto;
+ padding: 0.7rem 1.5rem;
+}
+.brand { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--brand); text-decoration: none; }
+.brand span { font-family: var(--mono); font-size: 0.9rem; font-weight: 700; color: var(--text); letter-spacing: -0.01em; }
+.cves { display: flex; gap: 0.4rem; }
+.cves a {
+ padding: 0.12rem 0.45rem;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ color: var(--muted);
+ text-decoration: none;
+}
+.cves a:hover { border-color: var(--brand); color: var(--brand); }
+.topbar-end { display: flex; align-items: center; gap: 1rem; margin-left: auto; }
+.lang-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ height: 2rem;
+ padding: 0 0.55rem;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+.lang-toggle:hover { color: var(--brand); border-color: var(--brand); }
+.topbar-end { gap: 0.5rem; }
+.report-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ height: 2rem;
+ padding: 0 0.7rem;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 0.8rem;
+ font-weight: 600;
+ white-space: nowrap;
+ text-decoration: none;
+}
+.report-link svg { flex: none; color: var(--brand); }
+.report-link:hover { color: var(--brand); border-color: var(--brand); }
+.source { display: inline-grid; place-items: center; width: 2rem; height: 2rem; border-radius: 4px; color: var(--text); }
+.source:hover { color: var(--brand); background: var(--sunken); }
+
+/* ── Workspace ───────────────────────────────────────────────────── */
+
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
+ gap: 1.25rem;
+ align-items: start;
+ max-width: var(--max);
+ margin: 0 auto;
+ padding: 1.5rem;
+}
+.pane {
+ min-width: 0;
+ padding: 1.4rem 1.5rem 1.5rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+}
+.pane-result { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow: auto; }
+
+h1 { font-size: 1.3rem; font-weight: 650; letter-spacing: -0.01em; }
+.lede { margin: 0.45rem 0 0; color: var(--muted); font-size: 0.9rem; max-width: 62ch; }
+
+.step { margin-top: 1.4rem; }
+.step-head { display: flex; align-items: center; gap: 0.55rem; font-size: 0.95rem; font-weight: 650; }
+.step-head label { cursor: pointer; }
+.step-n {
+ display: inline-grid;
+ place-items: center;
+ width: 1.3rem;
+ height: 1.3rem;
+ border-radius: 50%;
+ background: var(--ink);
+ color: #fff;
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ font-weight: 700;
+}
+.step > .step-head, .step-head-row { margin-bottom: 0.65rem; }
+.step-head-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
+
+/* Collector */
+/* The tab strip never scrolls: when the labels do not fit, each tab shrinks and
+ its label wraps onto a second line, so every tab stays visible. */
+.collector { border: 1px solid var(--line); border-radius: var(--radius); container-type: inline-size; }
+.tabs { display: flex; border-bottom: 1px solid var(--line); background: var(--sunken); border-radius: var(--radius) var(--radius) 0 0; }
+.tab {
+ flex: 0 1 auto;
+ position: relative;
+ padding: 0.6rem 0.9rem;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font: inherit;
+ font-size: 0.85rem;
+ font-weight: 600;
+ line-height: 1.35;
+ cursor: pointer;
+}
+.tab:first-child { border-top-left-radius: calc(var(--radius) - 1px); }
+.tab:hover { color: var(--text); }
+.tab:focus-visible { outline-offset: -2px; }
+.tab[aria-selected="true"] { color: var(--text); background: var(--panel); }
+.tab[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--brand); }
+.tab-hint { margin-left: 0.25rem; font-size: 0.7rem; font-weight: 500; color: var(--ok); }
+@container (max-width: 28rem) {
+ .tab-hint { display: none; }
+}
+.tab-panel { padding: 0.9rem; }
+.tab-panel[hidden] { display: none; }
+.panel-title { display: none; }
+
+.get { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; }
+.btn-download { gap: 0.45rem; text-decoration: none; }
+.btn-download:hover { color: var(--text); }
+.btn-download svg { flex: none; color: var(--brand); }
+.or {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin: 0.8rem 0 0.5rem;
+ font-size: 0.74rem;
+ color: var(--faint);
+}
+.or::after { content: ""; flex: 1; height: 1px; background: var(--line); }
+
+.cmdline {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+ padding: 0.2rem 0.25rem 0.2rem 0.7rem;
+ background: var(--code);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+}
+.cmd {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ padding: 0.4rem 0;
+ font-size: 0.78rem;
+ color: var(--text);
+ white-space: nowrap;
+ scrollbar-width: thin;
+}
+.cmd::before { content: "$ "; color: var(--faint); }
+.cmd.multi { margin: 0; white-space: pre; line-height: 1.7; }
+.copy {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 1.9rem;
+ height: 1.9rem;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+}
+.copy:hover { background: var(--line); color: var(--text); }
+.copy .i-check { display: none; }
+.copy.is-done { color: var(--ok); }
+.copy.is-done .i-copy { display: none; }
+.copy.is-done .i-check { display: block; }
+
+.howto { margin: 0.75rem 0 0; padding-left: 1.15rem; font-size: 0.84rem; color: var(--muted); }
+.howto li + li { margin-top: 0.25rem; }
+.sha { margin: 0.6rem 0 0; font-family: var(--mono); font-size: 0.68rem; color: var(--faint); word-break: break-all; }
+.sha:empty { display: none; }
+
+/* Form */
+textarea {
+ display: block;
+ width: 100%;
+ min-height: 15rem;
+ padding: 0.7rem 0.8rem;
+ border: 1px solid var(--control);
+ border-radius: 4px;
+ background: var(--sunken);
+ color: var(--text);
+ font-size: 0.78rem;
+ line-height: 1.55;
+ resize: vertical;
+}
+textarea::placeholder { color: var(--faint); }
+textarea:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(165, 28, 48, 0.12); background: var(--panel); }
+
+select {
+ min-height: 2.25rem;
+ max-width: 100%;
+ padding: 0 2rem 0 0.6rem;
+ border: 1px solid var(--control);
+ border-radius: 4px;
+ background: var(--panel) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2357524a' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 0.55rem center / 0.9rem;
+ color: var(--text);
+ font: inherit;
+ font-size: 0.85rem;
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: pointer;
+}
+.select-sm { min-height: 1.9rem; font-size: 0.8rem; color: var(--muted); }
+
+/* Example inputs: picking one does nothing until Load is pressed. */
+.examples { display: flex; flex: 0 1 auto; align-items: center; gap: 0.4rem; min-width: 0; margin-left: auto; }
+.examples select { flex: 0 1 auto; min-width: 0; }
+.examples .btn { flex: none; }
+/* Keep the examples beside the heading on wider screens: the select gives up
+ width before the row wraps. Phones put them on their own row instead. */
+#check-form > .step-head-row { flex-wrap: nowrap; }
+#check-form > .step-head-row > .step-head { flex: none; }
+
+.form-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; }
+.field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; font-size: 0.78rem; font-weight: 600; color: var(--muted); }
+.field select { min-width: 14rem; }
+.form-buttons { display: flex; gap: 0.5rem; }
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 2.25rem;
+ padding: 0 0.95rem;
+ border: 1px solid var(--control);
+ border-radius: 4px;
+ background: var(--panel);
+ color: var(--text);
+ font: inherit;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+.btn:hover { background: var(--sunken); border-color: var(--muted); }
+.btn-primary { background: var(--ink); border-color: var(--ink); color: #fff; }
+.btn-primary:hover { background: #000; border-color: #000; }
+.btn-sm { min-height: 1.9rem; padding: 0 0.7rem; font-size: 0.8rem; }
+
+.fine { display: flex; align-items: center; gap: 0.35rem; margin: 0.7rem 0 0; font-size: 0.76rem; color: var(--faint); }
+.fine svg { flex: none; }
+.fine-block { margin: 0.4rem 0 0; font-size: 0.84rem; color: var(--muted); }
+.noscript { margin: 0 0 1rem; padding: 0.75rem 0.9rem; border: 1px solid var(--warn-line); border-radius: 4px; background: var(--warn-bg); color: var(--warn); font-size: 0.85rem; }
+.noscript a { color: inherit; }
+
+/* Without JavaScript. A browser parses the contents of <noscript> as elements
+ only when scripting is off, so p.noscript exists only then and these rules
+ never apply to the scripted page. All collection methods are shown one after
+ another, and controls and tables that need the script are left out. */
+body:has(p.noscript) .tabs,
+body:has(p.noscript) .copy,
+body:has(p.noscript) .examples,
+body:has(p.noscript) #upstream-table,
+body:has(p.noscript) .ref-col .disclosure { display: none; }
+body:has(p.noscript) .tab-panel[hidden] { display: block !important; }
+body:has(p.noscript) .tab-panel + .tab-panel { border-top: 1px solid var(--line); }
+body:has(p.noscript) .panel-title { display: block; margin: 0 0 0.7rem; font-size: 0.85rem; font-weight: 650; }
+
+/* ── Result ──────────────────────────────────────────────────────── */
+
+.empty p { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.88rem; }
+.legend { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
+.legend th, .legend td { padding: 0.5rem 0; border-top: 1px solid var(--line); text-align: left; vertical-align: top; }
+.legend th { width: 8.5rem; padding-right: 0.75rem; font-weight: 400; }
+.legend td { color: var(--muted); }
+
+.badge {
+ display: inline-block;
+ padding: 0.1rem 0.45rem;
+ border: 1px solid;
+ border-radius: 4px;
+ font-family: var(--sans);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+.badge-patched, .badge-ok { color: var(--ok); background: var(--ok-bg); border-color: var(--ok-line); }
+.badge-vulnerable, .badge-pending, .badge-bad, .badge-high { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-line); }
+.badge-restart, .badge-warn, .badge-medium { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-line); }
+.badge-unknown, .badge-info { color: var(--info); background: var(--info-bg); border-color: var(--info-line); }
+
+.verdict {
+ --v: var(--info); --v-bg: var(--info-bg); --v-line: var(--info-line);
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--v-line);
+ border-left: 4px solid var(--v);
+ border-radius: var(--radius);
+ background: var(--v-bg);
+}
+.verdict[data-verdict="patched"] { --v: var(--ok); --v-bg: var(--ok-bg); --v-line: var(--ok-line); }
+.verdict[data-verdict="vulnerable"], .verdict[data-verdict="pending"] { --v: var(--bad); --v-bg: var(--bad-bg); --v-line: var(--bad-line); }
+.verdict[data-verdict="restart"] { --v: var(--warn); --v-bg: var(--warn-bg); --v-line: var(--warn-line); }
+.verdict-label { margin: 0 0 0.3rem; font-family: var(--sans); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--v); }
+.verdict h3 { font-size: 1.15rem; font-weight: 650; }
+.verdict h3:focus { outline: none; }
+.reasons { margin: 0.55rem 0 0; padding-left: 1.1rem; font-size: 0.88rem; }
+.reasons li + li { margin-top: 0.3rem; }
+.verdict-meta { margin: 0.7rem 0 0; font-size: 0.74rem; color: var(--muted); }
+
+.block { margin-top: 1.25rem; }
+.block h4 { margin-bottom: 0.5rem; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
+.block > p { margin: 0 0 0.6rem; font-size: 0.84rem; color: var(--muted); }
+
+.kv { width: 100%; border-collapse: collapse; font-size: 0.84rem; }
+.kv th, .kv td { padding: 0.42rem 0; border-top: 1px solid var(--line); text-align: left; vertical-align: top; }
+.kv tr:last-child > * { border-bottom: 1px solid var(--line); }
+.kv th { width: 9.5rem; padding-right: 0.75rem; font-weight: 500; color: var(--muted); }
+.kv td { overflow-wrap: anywhere; }
+.kv td.mono { font-size: 0.8rem; }
+.kv .badge { margin-right: 0.4rem; }
+
+.actions { margin: 0; padding-left: 1.2rem; font-size: 0.88rem; }
+.actions li + li { margin-top: 0.6rem; }
+.actions .cmdline { margin-top: 0.35rem; }
+
+.mit { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
+.mit th, .mit td { padding: 0.45rem 0.6rem 0.45rem 0; text-align: left; vertical-align: top; }
+.mit thead th { font-size: 0.7rem; font-weight: 600; color: var(--faint); border-bottom: 1px solid var(--line); }
+.mit tbody tr.mit-row > * { border-top: 1px solid var(--line); }
+.mit tbody tr.mit-row:first-child > * { border-top: 0; }
+.mit td.setting { font-family: var(--mono); font-size: 0.78rem; font-weight: 700; overflow-wrap: break-word; word-break: normal; }
+.mit td.value { font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; color: var(--muted); }
+.mit td.status { text-align: right; padding-right: 0; }
+/* On phones the value and status move under the setting name (see the 40rem query). */
+.mit-inline { display: none; }
+.mit tr.mit-note td { padding-top: 0; color: var(--muted); font-size: 0.8rem; }
+
+.plain { margin: 0; padding-left: 1.1rem; font-size: 0.86rem; }
+.plain li + li { margin-top: 0.35rem; }
+.indicators { list-style: none; margin: 0; padding: 0; font-size: 0.86rem; }
+.indicators li { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.55rem; align-items: start; }
+.indicators li + li { margin-top: 0.45rem; }
+.indicators .badge { margin-top: 0.1rem; }
+
+/* ── Reference ───────────────────────────────────────────────────── */
+
+.reference { border-top: 1px solid var(--line); background: var(--panel); }
+.reference-in {
+ display: grid;
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
+ gap: 2.5rem;
+ max-width: var(--max);
+ margin: 0 auto;
+ padding: 1.75rem 1.5rem 2rem;
+}
+.ref-col h2 { margin: 0 0 0.6rem; font-size: 0.95rem; font-weight: 650; }
+.ref-col h2:not(:first-child) { margin-top: 1.4rem; }
+
+.data { width: 100%; border-collapse: collapse; font-size: 0.84rem; }
+.data th, .data td { padding: 0.45rem 0.75rem 0.45rem 0; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
+.data thead th { font-size: 0.72rem; font-weight: 600; color: var(--faint); }
+.data tbody th { font-weight: 600; white-space: nowrap; }
+.data td.mono { font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; }
+/* Long distribution versions may wrap, but only at the <wbr> that app.js puts
+ after "-" and "~" and before "+", never inside a number. */
+.data td.ver { white-space: normal; overflow-wrap: normal; word-break: normal; }
+.data .ver-line { display: block; }
+.data .dim { color: var(--faint); font-weight: 400; }
+.data .eol::before { content: " · "; }
+.data .badge { margin-left: 0.35rem; }
+
+.disclosure { margin-top: 0.8rem; border: 1px solid var(--line); border-radius: var(--radius); }
+.disclosure > summary { padding: 0.55rem 0.8rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
+.disclosure > summary::marker { color: var(--faint); }
+.disclosure[open] > summary { border-bottom: 1px solid var(--line); }
+.disclosure > table, .disclosure > div { padding: 0 0.8rem 0.6rem; }
+.disclosure > table { display: block; overflow-x: auto; }
+.rh-intro { margin: 0.6rem 0; font-size: 0.82rem; color: var(--muted); }
+.rh { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
+.rh th, .rh td { padding: 0.35rem 0.75rem 0.35rem 0; border-top: 1px solid var(--line); text-align: left; vertical-align: top; }
+.rh th { white-space: nowrap; font-weight: 600; }
+.rh td { font-family: var(--mono); overflow-wrap: anywhere; }
+
+.method { margin: 0; padding-left: 1.2rem; font-size: 0.84rem; color: var(--muted); }
+.method li + li { margin-top: 0.3rem; }
+
+.footer { max-width: var(--max); margin: 0 auto; padding: 1rem 1.5rem 2rem; }
+.footer p { margin: 0; font-size: 0.78rem; color: var(--faint); }
+.footer a { color: var(--muted); }
+
+/* ── Narrow screens ──────────────────────────────────────────────── */
+
+/* Media queries count rem from the browser default (16px), not the root size above. */
+@media (max-width: 75rem) {
+ .workspace { grid-template-columns: minmax(0, 1fr); }
+ .pane-result { position: static; max-height: none; overflow: visible; }
+ .reference-in { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
+}
+@media (max-width: 40rem) {
+ html { font-size: 112.5%; }
+ .topbar-in, .workspace, .reference-in, .footer { padding-left: 1rem; padding-right: 1rem; }
+ .workspace { padding-top: 1rem; gap: 1rem; }
+ .pane { padding: 1.1rem 1rem 1.2rem; }
+ .tab-hint { display: none; }
+ .tab { padding: 0.6rem; font-size: 0.8rem; }
+ /* 16px or more, or iOS Safari zooms the page when the field gets focus. */
+ textarea, select, select.select-sm { font-size: 1rem; }
+ #check-form > .step-head-row { flex-wrap: wrap; }
+ .examples { flex: 1 1 100%; }
+ .examples select { flex: 1 1 auto; }
+ .form-row { flex-direction: column; align-items: stretch; }
+ .field select { min-width: 0; width: 100%; }
+ .form-buttons .btn { flex: 1; }
+ .kv th { width: 7rem; }
+ .legend th { width: 7.5rem; }
+ .data th, .data td { padding-right: 0.5rem; }
+ .data th:last-child, .data td:last-child { padding-right: 0; }
+ .disclosure > table, .disclosure > div { padding-left: 0.6rem; padding-right: 0.6rem; }
+ .data .eol { display: block; white-space: normal; }
+ .data .eol::before { content: none; }
+ .data tbody th { white-space: normal; }
+ .mit td.value, .mit td.status, .mit thead th:not(:first-child) { display: none; }
+ .mit-inline { display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.5rem; margin-top: 0.15rem; font-weight: 400; color: var(--muted); }
+}
+
+/* Phones: buttons share the brand row, the report button shrinks to its icon,
+ and the CVE links take the second row. */
+@media (max-width: 30rem) {
+ .topbar-end { order: 2; }
+ .cves { order: 3; width: 100%; }
+ .report-link { padding: 0; width: 2rem; justify-content: center; }
+ .report-link span { display: none; }
+ .lang-toggle svg { display: none; }
+ .brand span { font-size: 0.8rem; }
+}
diff --git a/test/contract.test.mjs b/test/contract.test.mjs
@@ -0,0 +1,275 @@
+// Regression tests for the engine side of the server script, PHP probe and page contract: what the script and the
+// probe now emit (env|root, changelog lines, unknown counts, os_release null, open_basedir, the legacy release keys)
+// and what the page reads from a result (r.family, string facts and advisories). Real data files in site/data/.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge, sapiExposure } from '../site/lib/judge.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+
+const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
+const server = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${['tool|php2shell-server-check|1.0.0', ...lines].join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
+const run = (text, options = {}, data = distro) => judge(parseInput(text, { lang: options.lang }), upstream, data, options);
+const both = (text, options = {}) => ['en', 'ko'].map((lang) => run(text, { ...options, lang }));
+const reasons = (r) => r.reasons.join(' ');
+const notes = (r) => r.notes.join(' ');
+const fact = (r, label) => r.facts.find((f) => f.label === label)?.value;
+const actionText = (r) => r.actions.map((a) => `${a.text} ${a.code || ''}`).join(' ');
+
+const ROCKY94 = ['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4'];
+const NOBLE = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble'];
+const MODULE = '8.2.13-1.module+el9.4.0+20827+3f2b0b48';
+const CVES = ['CVE-2026-109618', 'CVE-2026-109619'];
+const logged = (pkg, n) => CVES.map((cve) => `changelog|${pkg}|${cve}|${n}`);
+
+// ── Changelog lines ─────────────────────────────────────────────────
+
+test('a package with no changelog lines was not checked, which is never a count of 0', () => {
+ // php-fpm, the package that runs, is another build than the php-common whose changelog was read.
+ const mixed = server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, 'pkg|rpm|php-fpm|8.2.13-1.module+el9.2.0+19000+aaaaaaaa|', ...logged('php-common', 1), 'proc|php-fpm|4|ok']);
+ for (const r of both(mixed)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(reasons(r), /php-fpm 8\.2\.13-1\.module\+el9\.2\.0/);
+ }
+ assert.match(reasons(run(mixed)), /was not checked/);
+ assert.doesNotMatch(reasons(run(mixed)), /has no record of a fix/);
+ // Positive control: a package of exactly the same build carries the same changelog.
+ const same = server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, ...logged('php-common', 1), 'proc|php-fpm|4|ok']);
+ assert.equal(run(same).verdict, 'patched');
+
+ // No changelog lines at all: not checked, not "not updated yet".
+ const none = run(server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, 'proc|php-fpm|4|ok']));
+ assert.equal(none.verdict, 'unknown');
+ assert.match(reasons(none), /was not checked/);
+ assert.doesNotMatch(reasons(none), /has no record|not been updated/);
+
+ // A Debian package without changelog lines is not credited with a fix it was never checked for.
+ const deb = run(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|11', 'pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4', 'proc|php-fpm7.4|3|ok']));
+ assert.equal(deb.verdict, 'unknown');
+ assert.doesNotMatch(reasons(deb), /treated as fixed|has no record of a fix/);
+ const obs = parseInput(server(['env|root|1', 'pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4']));
+ assert.equal(obs.packages[0].changelog, undefined);
+});
+
+test('a changelog count that is missing or not a number is not checked, never 0', () => {
+ for (const lines of [
+ ['changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|'],
+ ['changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|unknown'],
+ ['changelog|php-common|CVE-2026-109618|1'],
+ ]) {
+ const block = server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, ...lines, 'proc|php-fpm|4|ok']);
+ for (const r of both(block)) assert.equal(r.verdict, 'unknown', lines.join(' '));
+ const r = run(block);
+ assert.match(reasons(r), /was not checked for CVE-2026-109619/, lines.join(' '));
+ assert.doesNotMatch(reasons(r), /has no record of a fix/, lines.join(' '));
+ }
+ // A count of 0 is a changelog that was read and has no entry.
+ const zero = run(server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0']));
+ assert.match(reasons(zero), /has no record of a fix for CVE-2026-109619/);
+ assert.equal(parseInput(server(['pkg|rpm|php-common|8.2.13-1.el9|', 'changelog|php-common|CVE-2026-109619|unknown'])).packages[0].changelog['CVE-2026-109619'], null);
+});
+
+// ── Unknown counts ──────────────────────────────────────────────────
+
+test('an unknown count never gives a "nothing suspicious" (Clear) finding', () => {
+ const base = [...NOBLE, 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|3|ok'];
+ const counts = { 'log|oom': 'logs', 'log|crash': 'logs', 'log|bigsession': 'session files', webuser_proc: 'web server account processes', 'webroot|recent_php': 'web root' };
+ for (const [key, source] of Object.entries(counts)) {
+ const lines = Object.keys(counts).map((k) => `${k}|${k === key ? 'unknown' : 0}`);
+ for (const r of both(server([...base, ...lines]))) {
+ assert.ok(!r.findings.some((f) => f.level === 'ok'), key);
+ assert.ok(r.findings.some((f) => f.level === 'info'), key);
+ }
+ assert.match(run(server([...base, ...lines])).findings.map((f) => f.text).join(' '), new RegExp(`could not be read \\(${source}\\)`), key);
+ }
+ // Several outputs: one clean output does not make the other's unread source "nothing suspicious".
+ const two = run(server([...base, 'webuser_proc|0']) + server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok', 'webuser_proc|unknown']));
+ assert.ok(two.parts === undefined && two.findings.length > 0);
+ assert.ok(!two.findings.some((f) => f.level === 'ok'));
+ // Positive control: zero counts from sources that were all read, as root, are Clear, and say what was checked.
+ const clean = run(server([...base, 'webuser_proc|0']));
+ assert.deepEqual(clean.findings.map((f) => f.level), ['ok']);
+ assert.match(clean.findings[0].text, /\(web server account processes\)/);
+ assert.doesNotMatch(clean.findings[0].text, /logs|web root/);
+ assert.match(run(server([...base, 'webuser_proc|0']), { lang: 'ko' }).findings[0].text, /웹 서버 계정 프로세스/);
+});
+
+// ── env|root|0 ──────────────────────────────────────────────────────
+
+test('env|root|0 with no proc lines means the processes were not checked', () => {
+ const nonRoot = ['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble'];
+ for (const r of both(server([...nonRoot, 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'webuser_proc|unknown']))) {
+ assert.equal(r.verdict, 'unknown');
+ }
+ // A CLI-only build has no web service process, so it stays Patched, but never with "no PHP process was found".
+ const [en, ko] = both(server([...nonRoot, 'pkg|dpkg|php8.3-cli|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3']));
+ assert.equal(en.verdict, 'patched');
+ assert.equal(ko.verdict, 'patched');
+ assert.match(reasons(en), /did not run as root, so running processes were not checked/);
+ assert.doesNotMatch(reasons(en), /No running PHP process/);
+ assert.match(reasons(ko), /실행 중인 프로세스는 확인하지 못했습니다/);
+ // Positive control: as root, no proc lines do mean that no PHP process of this build runs.
+ const root = run(server([...NOBLE, 'pkg|dpkg|php8.3-cli|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3']));
+ assert.equal(root.verdict, 'patched');
+ assert.match(reasons(root), /No running PHP process of this build was found/);
+});
+
+// ── os_release null, open_basedir true ──────────────────────────────
+
+test('os_release null or open_basedir true never makes an upstream-looking version Vulnerable on RHEL-like or unknown systems', () => {
+ const base = { php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', container: false, uname_release: '', dpkg_packages: null };
+ for (const [fields, pattern] of [
+ [{ os_release: null, open_basedir: false }, /could not read the OS release/],
+ [{ os_release: [], open_basedir: false }, /could not read the OS release/],
+ [{ os_release: null, open_basedir: true, container: null }, /open_basedir/],
+ [{ os_release: null, open_basedir: false, php_version: '8.3.12', uname_release: '6.8.0-45-generic' }, /could not read the OS release/],
+ ]) {
+ const text = probe({ ...base, ...fields });
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'unknown', JSON.stringify(fields));
+ assert.ok(r.notes.length > 0, 'the hidden OS is explained');
+ }
+ const r = run(text);
+ assert.match(reasons(r), pattern);
+ assert.match(notes(r), /could not read the OS release/);
+ assert.equal(r.family, null);
+ }
+ // A RHEL kernel outside a container still names the family, and the version alone does not settle it.
+ const kernel = run(probe({ ...base, os_release: null, open_basedir: false, uname_release: '5.14.0-427.el9_4.x86_64' }));
+ assert.equal(kernel.verdict, 'unknown');
+ assert.match(fact(kernel, 'Distribution'), /RHEL family 9/);
+ // Positive controls: a fixed upstream release, an official Docker image and a readable Debian release are still judged.
+ assert.equal(run(probe({ ...base, os_release: null, open_basedir: false, php_version: '8.2.35' })).verdict, 'patched');
+ assert.equal(run(probe({ ...base, os_release: null, open_basedir: false, container: true, config_path: '/usr/local/etc/php' })).verdict, 'vulnerable');
+ assert.equal(run(probe({ ...base, php_version: '8.2.26', config_path: '/etc/php/8.2/fpm', os_release: { ID: 'debian', VERSION_ID: '12' }, open_basedir: false })).verdict, 'vulnerable');
+});
+
+test('the server script supplies the OS and packages the probe could not read, and the warning goes away', () => {
+ const text = probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', container: false, os_release: null, open_basedir: false, uname_release: '', dpkg_packages: null })
+ + server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, ...logged('php-common', 1), ...logged('php-fpm', 1), 'proc|php-fpm|4|ok']);
+ const r = run(text);
+ assert.equal(r.verdict, 'patched');
+ assert.doesNotMatch(notes(r), /could not read the OS release/);
+ assert.equal(r.family, 'rhel');
+});
+
+// ── r.family ────────────────────────────────────────────────────────
+
+test('r.family names the distribution data the verdict rests on, and null for upstream-only verdicts', () => {
+ const cases = [
+ ['debian', server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.29-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok'])],
+ ['ubuntu', 'X-Powered-By: PHP/8.1.2-1ubuntu2.20'],
+ ['rhel', server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, ...logged('php-common', 1)])],
+ [null, probe({ php_version: '8.3.12', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, dpkg_packages: [] })],
+ [null, 'X-Powered-By: PHP/8.3.12'],
+ [null, 'X-Powered-By: PHP/8.3.20-1+ubuntu22.04.1+deb.sury.org+1'],
+ [null, server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.0', 'pkg|apk|php84|8.4.20-r0|', 'pkg|apk|php84-fpm|8.4.20-r0|', 'proc|php-fpm84|2|ok'])],
+ [null, server(['env|root|1', 'os|ID|centos', 'os|VERSION_ID|7', 'pkg|rpm|php83-php-common|8.3.20-1.el7.remi|'])],
+ [null, probe({ php_version: '8.2.20', os_release: { ID: 'opensuse-leap', ID_LIKE: 'suse opensuse', VERSION_ID: '15.6' } })],
+ [null, '8.6.0RC3'],
+ [null, 'hello world'],
+ ];
+ for (const [family, text] of cases) {
+ for (const r of both(text)) assert.equal(r.family, family, text);
+ }
+ // Several outputs: the family of the worst one.
+ assert.equal(run('X-Powered-By: PHP/8.3.12\n\nX-Powered-By: PHP/8.1.2-1ubuntu2.28\n').family, null);
+});
+
+// ── Strings for the page ────────────────────────────────────────────
+
+test('facts and advisories values are always strings', () => {
+ const odd = run(probe({ php_version: '8.2.20', sapi: 7, server_software: { name: 'x' }, config_path: 5, os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: [] }));
+ assert.equal(odd.verdict, 'vulnerable');
+ assert.equal(odd.mitigations.length, 0, 'an empty ini list is no settings');
+ // Advisory data with fields missing never shows "undefined" or "null".
+ const data = JSON.parse(JSON.stringify(distro));
+ data.redhat['9'].advisories.push({ advisory: 'RHSA-2026:99999', cve: 'CVE-2026-109618' });
+ data.redhat['9'].states.push({ cve: 'CVE-2026-109619', state: 'Affected' }, { cve: 'CVE-2026-109619', package: 'php' });
+ const rhel = run(server([...ROCKY94, `pkg|rpm|php-common|${MODULE}|`, ...logged('php-common', 1)]), {}, data);
+ assert.ok(rhel.advisories.some((a) => a.value === 'RHSA-2026:99999'));
+ for (const r of [odd, rhel, run('X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4'), run('8.1.30'), run('hello world')]) {
+ for (const row of [...r.facts, ...r.advisories]) {
+ assert.equal(typeof row.label, 'string');
+ assert.equal(typeof row.value, 'string');
+ assert.doesNotMatch(row.value, /^(null|undefined)$|\[object |undefined/, JSON.stringify(row));
+ }
+ }
+ assert.equal(fact(odd, 'Web server'), undefined);
+ assert.equal(fact(odd, 'SAPI'), undefined);
+});
+
+// ── Texts ───────────────────────────────────────────────────────────
+
+test('the default exposure text says to treat the SAPI as exposed until it is patched', () => {
+ assert.match(sapiExposure('cgi-fcgi').text, /Treat it as exposed until it is patched\.$/);
+ assert.match(sapiExposure('embed').text, /^The official advisory does not mention embed separately\./);
+ assert.match(sapiExposure('cgi-fcgi', 'ko').text, /패치를 적용할 때까지는 노출된 것으로 간주해야 합니다\.$/);
+ assert.equal(sapiExposure('cgi-fcgi').level, 'medium');
+});
+
+test('restart advice uses a full restart, and says a reload or graceful restart keeps the old build only for mod_php', () => {
+ const restart = both(probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'apache2handler', os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }));
+ for (const r of restart) {
+ assert.equal(r.verdict, 'restart');
+ const step = r.actions.find((a) => a.code === 'sudo systemctl restart apache2');
+ assert.ok(step, 'a full restart command, not a reload');
+ }
+ assert.match(actionText(restart[0]), /mod_php.*only a full restart of Apache, not a reload or a graceful restart, loads the new build/);
+ assert.match(actionText(restart[1]), /reload나 graceful 재시작이 아니라 Apache를 완전히 재시작해야/);
+ // PHP-FPM: a reload re-executes the master from disk, so the mod_php sentence is not given.
+ const fpm = run(probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'fpm-fcgi', os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
+ dpkg_packages: [{ name: 'php8.1-fpm', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], config_path: '/etc/php/8.1/fpm' }));
+ assert.equal(fpm.actions[0].code, 'sudo systemctl restart php8.1-fpm');
+ assert.doesNotMatch(actionText(fpm), /graceful|keeps the old PHP build/);
+ // Without a known service name, the advice names what started the processes.
+ const generic = run(server(['env|root|1', 'os|ID|ubuntu', 'os|VERSION_ID|22.04', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|5|ok', 'proc|php-fpm8.3|4|stale']));
+ assert.equal(generic.verdict, 'restart');
+ assert.match(actionText(generic), /Restart whatever runs these PHP processes \(php-fpm8\.3\)/);
+ // After an update, the restart step names both possible services when the SAPI is not known.
+ assert.match(actionText(run('X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4')), /After the update, restart the service that runs PHP with the command below/);
+ for (const r of [...restart, generic]) {
+ for (const a of r.actions) assert.doesNotMatch(a.code || '', /reload|graceful/);
+ }
+});
+
+// ── Legacy release files ────────────────────────────────────────────
+
+test('the probe\'s LEGACY_REDHAT-RELEASE key (with a hyphen) is recognised', () => {
+ const rhel7 = probe({ php_version: '5.4.16', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'Red Hat Enterprise Linux Server release 7.9 (Maipo)' }, dpkg_packages: null });
+ const obs = parseInput(rhel7);
+ assert.equal(obs.os.id, 'rhel');
+ assert.equal(obs.os.versionId, '7');
+ assert.equal(obs.osBlocked, false);
+ const r = run(rhel7);
+ assert.equal(r.family, 'rhel');
+ assert.match(fact(r, 'Distribution'), /Red Hat Enterprise Linux Server release 7\.9/);
+ assert.ok(r.advisories.some((a) => /^RHEL 7 /.test(a.label)));
+ // RHEL 7 has systemd; RHEL 6 does not.
+ assert.match(actionText(r), /systemctl restart httpd/);
+ const centos6 = run(probe({ php_version: '5.3.3', sapi: 'apache2handler', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' } }));
+ assert.match(actionText(centos6), /service httpd restart/);
+ assert.equal(parseInput(probe({ php_version: '8.2.13', os_release: { 'LEGACY_REDHAT-RELEASE': 'Rocky Linux release 8.9 (Green Obsidian)' } })).os.id, 'rocky');
+ assert.equal(parseInput(probe({ php_version: '8.2.13', os_release: { LEGACY_REDHAT_RELEASE: 'AlmaLinux release 9.4 (Seafoam Ocelot)' } })).os.id, 'almalinux');
+ // Fedora writes the same file but is not the RHEL family.
+ const fedora = run(probe({ php_version: '8.3.12', os_release: { 'LEGACY_REDHAT-RELEASE': 'Fedora release 40 (Forty)' } }));
+ assert.equal(parseInput(probe({ php_version: '8.3.12', os_release: { 'LEGACY_REDHAT-RELEASE': 'Fedora release 40 (Forty)' } })).os.id, 'fedora');
+ assert.doesNotMatch(fact(fedora, 'Distribution'), /RHEL/);
+ // LEGACY_DEBIAN_VERSION keeps its underscore.
+ assert.deepEqual(parseInput(probe({ php_version: '8.2.26', os_release: { LEGACY_DEBIAN_VERSION: '12.7' } })).os, { id: 'debian', versionId: '12' });
+});
+
+test('the probe\'s plain-text notices without markers give no version', () => {
+ for (const text of [
+ 'php2shell-selfcheck 1.0.0\nThe file on the server has changed, but PHP ran an older cached copy of it.\nThe cached copy has now been cleared. Load this page again. If this message comes back, upload the file under a new name.\n',
+ 'php2shell-selfcheck 1.0.0\nThe probe stopped without collecting anything. Something was sent before it started, so it could not set its own headers. The usual cause is a byte order mark (BOM) or blank space before the opening PHP tag.\nSave the file as UTF-8 without a BOM, with nothing before the opening PHP tag, and upload it again.\n',
+ ]) {
+ const r = run(text);
+ assert.equal(r.verdict, 'unknown');
+ assert.equal(parseInput(text).phpVersion, null);
+ }
+});
diff --git a/test/findings.test.mjs b/test/findings.test.mjs
@@ -0,0 +1,484 @@
+// Regression tests for the verdict-engine review findings. Each test reproduces the
+// reviewer's scenario against the real data files in site/data/.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge, evaluateMitigations } from '../site/lib/judge.js';
+import { parsePhpVersion } from '../site/lib/version.js';
+import { MESSAGES } from '../site/lib/messages.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+
+function selfcheck(fields) {
+ const report = {
+ tool: 'php2shell-selfcheck',
+ tool_version: '1.0.0',
+ schema: 1,
+ php_version: '8.4.26',
+ sapi: 'apache2handler',
+ os_release: {},
+ dpkg_packages: [],
+ extensions: { session: true, opcache: true },
+ ini: { memory_limit: '128M', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1' },
+ self_deleted: true,
+ ...fields,
+ };
+ return `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(report, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
+}
+const servercheck = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
+const run = (text, options = {}) => judge(parseInput(text, { lang: options.lang }), upstream, distro, options);
+const all = (r) => [...r.reasons, ...r.notes].join(' ');
+const fact = (r, label) => r.facts.find((f) => f.label === label)?.value;
+const actionText = (r) => r.actions.map((a) => `${a.text} ${a.code || ''}`).join(' ');
+
+const UBUNTU_2404 = { ID: 'ubuntu', VERSION_ID: '24.04', VERSION_CODENAME: 'noble', PRETTY_NAME: 'Ubuntu 24.04.3 LTS' };
+const UBUNTU_2204 = { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' };
+
+// ── #1 deb.sury.org builds ───────────────────────────────────────────
+
+test('#1 a deb.sury.org revision in PHP_VERSION is judged by upstream, never as an Ubuntu backport', () => {
+ const header = run('X-Powered-By: PHP/8.3.20-1+ubuntu22.04.1+deb.sury.org+1');
+ assert.equal(header.verdict, 'vulnerable');
+ assert.match(header.notes.join(' '), /sury/);
+ assert.doesNotMatch(fact(header, 'Distribution'), /24\.04/);
+ assert.doesNotMatch(all(header), /backported/);
+
+ // deb.sury.org builds of end-of-life branches may carry backported fixes (round 3), which only the changelog shows:
+ // without it they are Undetermined, never Patched, and still never an Ubuntu backport.
+ const cli = run('PHP 7.4.33-1+ubuntu20.04.1+deb.sury.org+1 (cli) (built: Nov 2 2026)');
+ assert.equal(cli.verdict, 'unknown');
+ assert.match(all(cli), /changelog/);
+ assert.doesNotMatch(cli.notes.join(' '), /Ubuntu Pro/);
+
+ const old = run('X-Powered-By: PHP/5.6.40-1+ubuntu20.04.1+deb.sury.org+1');
+ assert.equal(old.verdict, 'unknown');
+ assert.doesNotMatch(fact(old, 'Distribution'), /14\.04/);
+ assert.doesNotMatch(all(old), /Ubuntu has backported/);
+
+ const probe = run(selfcheck({ php_version: '8.3.20-1+ubuntu24.04.1+deb.sury.org+1', sapi: 'fpm-fcgi', os_release: UBUNTU_2404, dpkg_packages: null }));
+ assert.equal(probe.verdict, 'vulnerable');
+});
+
+test('#1 general guard: a build newer upstream than the release package is not that release\'s build', () => {
+ const r = run(selfcheck({ php_version: '8.3.20-1ubuntu1', os_release: UBUNTU_2404, dpkg_packages: null }));
+ assert.notEqual(r.verdict, 'patched');
+ assert.equal(r.verdict, 'vulnerable');
+});
+
+// ── #2 RHEL changelog must belong to the running PHP ─────────────────
+
+const ALMA9 = ['os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.6', 'env|root|1'];
+
+test('#2 RHEL: a patched base php-common does not vouch for a remi PHP that is running', () => {
+ const block = servercheck([...ALMA9,
+ 'pkg|rpm|php-common|8.2.30-1.module_el9.6.0+120+abcdef12|',
+ 'pkg|rpm|php83-php-common|8.3.20-1.el9.remi|',
+ 'pkg|rpm|php83-php-fpm|8.3.20-1.el9.remi|',
+ 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2',
+ 'changelog|php83-php-common|CVE-2026-109618|0', 'changelog|php83-php-common|CVE-2026-109619|0',
+ 'proc|php-fpm|6|ok']);
+ const probe = selfcheck({ php_version: '8.3.20', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php83', os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.6' } });
+ const r = run(probe + block);
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.notes.join(' '), /remi/);
+ assert.doesNotMatch(r.reasons.join(' '), /records fixes for both/);
+});
+
+test('#2 RHEL: a changelog from another build is not evidence for the PHP that ran the probe', () => {
+ const block = servercheck(['os|ID|cloudlinux', 'os|ID_LIKE|rhel fedora centos', 'os|VERSION_ID|9.4', 'env|root|1',
+ 'pkg|rpm|php-common|8.2.30-1.module_el9.4.0+1+abc|',
+ 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2']);
+ const r = run(selfcheck({ php_version: '8.3.20', sapi: 'litespeed', os_release: { ID: 'cloudlinux', VERSION_ID: '9.4' } }) + block);
+ assert.equal(r.verdict, 'unknown');
+ assert.match(r.reasons.join(' '), /php-common 8\.2\.30/);
+});
+
+test('#2 RHEL: the changelog of the package that matches the running PHP still counts', () => {
+ // The probe and the server script ran on the same system, so they report the same os-release.
+ const block = servercheck([...ALMA9, 'pkg|rpm|php-common|8.2.13-1.module_el9.4.0+20844+7c41d8d0|', 'pkg|rpm|php-fpm|8.2.13-1.module_el9.4.0+20844+7c41d8d0|',
+ 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|1',
+ 'changelog|php-fpm|CVE-2026-109618|2', 'changelog|php-fpm|CVE-2026-109619|1', 'proc|php-fpm|4|ok']);
+ const r = run(selfcheck({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.6' }, dpkg_packages: null }) + block);
+ assert.equal(r.verdict, 'patched');
+});
+
+test('#2 server script only: each RHEL build is judged with its own changelog', () => {
+ const r = run(servercheck([...ALMA9,
+ 'pkg|rpm|php-common|8.2.30-1.module_el9.6.0+120+abcdef12|',
+ 'pkg|rpm|php83-php-common|8.3.20-1.el9.remi|',
+ 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2',
+ 'changelog|php83-php-common|CVE-2026-109618|0', 'changelog|php83-php-common|CVE-2026-109619|0']));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.reasons.join(' '), /php83-php-common/);
+});
+
+test('#2 Debian changelog fallback only uses the changelog of the running build', () => {
+ const block = servercheck(['os|ID|debian', 'os|VERSION_ID|11', 'env|root|1',
+ 'pkg|dpkg|php7.4-common|7.4.33-1+deb11u5|php7.4',
+ 'pkg|dpkg|php8.1-common|8.1.30-1+deb11u1|php8.1',
+ 'changelog|php7.4-common|CVE-2026-109618|0', 'changelog|php7.4-common|CVE-2026-109619|0',
+ 'changelog|php8.1-common|CVE-2026-109618|1', 'changelog|php8.1-common|CVE-2026-109619|1']);
+ // The probe reads the same package list as the server script on the same system.
+ const pkgs = [{ name: 'php7.4-common', version: '7.4.33-1+deb11u5', source: 'php7.4' }, { name: 'php8.1-common', version: '8.1.30-1+deb11u1', source: 'php8.1' }];
+ const r = run(selfcheck({ php_version: '7.4.33', os_release: { ID: 'debian', VERSION_ID: '11' }, dpkg_packages: pkgs }) + block);
+ assert.equal(r.verdict, 'unknown');
+ // The php8.1 build is judged on its own changelog; the running 7.4 PHP is not credited with it.
+ assert.match(r.reasons[0], /^Running PHP 7\.4\.33/);
+ assert.doesNotMatch(r.reasons[0], /treated as fixed/);
+});
+
+// ── #3 inferred Ubuntu release must match the version string ─────────
+
+test('#3 EOL Ubuntu interim releases are not mapped onto the LTS package', () => {
+ for (const text of ['X-Powered-By: PHP/8.1.7-1ubuntu3.6', 'X-Powered-By: PHP/8.1.12-1ubuntu4.3', 'X-Powered-By: PHP/7.4.9-1ubuntu1.3', 'PHP 7.4.16-1ubuntu2.3 (cli) (built: Mar 1 2022)']) {
+ const r = run(text);
+ assert.equal(r.verdict, 'unknown', text);
+ assert.doesNotMatch(fact(r, 'Distribution'), /22\.04|20\.04/, text);
+ assert.doesNotMatch(r.notes.join(' '), /Ubuntu Pro/, text);
+ }
+ assert.equal(run('X-Powered-By: PHP/8.1.2-1ubuntu2.28').verdict, 'patched');
+});
+
+test('#3 picking the LTS by hand for an interim build does not make it Patched', () => {
+ const r = run('X-Powered-By: PHP/8.1.7-1ubuntu3.6', { distro: 'ubuntu:22.04' });
+ assert.equal(r.verdict, 'vulnerable');
+});
+
+// ── #4 a PHP that is not the installed distribution package ──────────
+
+test('#4 a panel PHP is not judged by an unrelated distribution package of the same branch', () => {
+ const pkgs = [{ name: 'php8.3-cli', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }, { name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
+ const panel = run(selfcheck({ php_version: '8.3.12', sapi: 'fpm-fcgi', config_path: '/www/server/php/83/etc', os_release: UBUNTU_2404, dpkg_packages: pkgs }));
+ assert.equal(panel.verdict, 'vulnerable');
+ const noPath = run(selfcheck({ php_version: '8.3.12', sapi: 'fpm-fcgi', os_release: UBUNTU_2404, dpkg_packages: pkgs }));
+ assert.equal(noPath.verdict, 'vulnerable');
+});
+
+test('#4 a container probe is not judged by host packages', () => {
+ const host = servercheck(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-cli|8.3.6-0ubuntu0.24.04.13|php8.3']);
+ const r = run(selfcheck({ php_version: '8.3.12', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12' } }) + host);
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.notes.join(' '), /container/);
+
+ const sameOs = servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|libapache2-mod-php8.2|8.2.35-1~deb12u1|php8.2']);
+ const c = run(selfcheck({ php_version: '8.2.30', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12' } }) + sameOs);
+ assert.equal(c.verdict, 'vulnerable');
+ assert.ok(c.actions.length > 0);
+ assert.match(actionText(c), /Dockerfile/);
+});
+
+// ── #5 same version before and after an Ubuntu fix ──────────────────
+
+test('#5 Ubuntu 24.04 without a package list is Undetermined, not Vulnerable', () => {
+ const manual = run('X-Powered-By: PHP/8.3.6', { distro: 'ubuntu:24.04' });
+ assert.equal(manual.verdict, 'unknown');
+ assert.match(manual.reasons.join(' '), /server script/);
+
+ const probe = run(selfcheck({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: UBUNTU_2404, dpkg_packages: null }));
+ assert.equal(probe.verdict, 'unknown');
+ assert.doesNotMatch(probe.notes.join(' '), /upstream build/);
+
+ const resolute = run(selfcheck({ php_version: '8.5.4', os_release: { ID: 'ubuntu', VERSION_ID: '26.04' }, dpkg_packages: null }));
+ assert.equal(resolute.verdict, 'unknown');
+
+ const bare = run('X-Powered-By: PHP/8.3.6');
+ assert.equal(bare.verdict, 'unknown');
+ assert.match(bare.reasons.join(' '), /Ubuntu 24\.04/);
+});
+
+test('#5 a version Ubuntu 24.04 never shipped falls back to upstream', () => {
+ assert.equal(run('X-Powered-By: PHP/8.3.12', { distro: 'ubuntu:24.04' }).verdict, 'vulnerable');
+ // Without the distribution, 8.3.12 may also be a build of the RHEL 9 php:8.3 module stream, whose fix keeps the
+ // upstream version (round 3): Undetermined. A branch with no such stream fix is still judged by upstream.
+ const bare = run('X-Powered-By: PHP/8.3.12');
+ assert.equal(bare.verdict, 'unknown');
+ assert.match(bare.reasons.join(' '), /RHEL 9/);
+ assert.equal(run('X-Powered-By: PHP/8.4.20').verdict, 'vulnerable');
+});
+
+// ── #6 every installed branch is judged ─────────────────────────────
+
+test('#6 server script only: a vulnerable second branch is not hidden by a patched first one', () => {
+ const r = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1',
+ 'pkg|dpkg|php8.2-common|8.2.35-1~deb12u1|php8.2',
+ 'pkg|dpkg|php8.4-common|8.4.20-1+0~20260301.30+debian12~1.gbp1a2b3c|php8.4',
+ 'pkg|dpkg|php8.4-fpm|8.4.20-1+0~20260301.30+debian12~1.gbp1a2b3c|php8.4',
+ 'proc|php-fpm8.4|5|ok']));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.reasons.join(' '), /php8\.4/);
+ assert.match(r.reasons.join(' '), /php8\.2/);
+});
+
+// ── #7 -dev snapshots ───────────────────────────────────────────────
+
+test('#7 -dev snapshots are pre-releases', () => {
+ assert.equal(parsePhpVersion('8.5.12-dev').pre, 'dev');
+ assert.equal(parsePhpVersion('8.5.12-dev').revision, '');
+ assert.equal(run('PHP 8.5.12-dev (cli) (built: Oct 20 2026)').verdict, 'unknown');
+ assert.equal(run('X-Powered-By: PHP/8.4.27-dev').verdict, 'unknown');
+});
+
+// ── #9 the running PHP was never checked ─────────────────────────────
+
+test('#9 server script without root: the installed package alone is not Patched', () => {
+ const r = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|0',
+ 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1',
+ 'proc|unreadable|9|unknown']));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(r.reasons.join(' '), /running PHP could not be checked/);
+ assert.ok(fact(r, 'Installed package version'));
+ assert.equal(fact(r, 'PHP version'), undefined);
+});
+
+test('#9 server script as root with the processes checked is Patched', () => {
+ const r = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|1',
+ 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1', 'proc|apache2|3|ok']));
+ assert.equal(r.verdict, 'patched');
+ const none = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|1', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1']));
+ assert.equal(none.verdict, 'patched');
+ assert.match(none.reasons.join(' '), /No running PHP process/);
+});
+
+// ── #10 Ubuntu 24.04+: the loaded build cannot be seen ──────────────
+
+test('#10 Ubuntu 24.04 probe: Patched says the loaded build could not be confirmed', () => {
+ const probe = selfcheck({ php_version: '8.3.6', sapi: 'fpm-fcgi', os_release: UBUNTU_2404, dpkg_packages: [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }] });
+ const r = run(probe);
+ assert.equal(r.verdict, 'patched');
+ assert.match(r.reasons.join(' '), /server script/);
+ // The server script on the same system lists the same package as the probe.
+ const host = ['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3'];
+ const checked = run(probe + servercheck([...host, 'proc|php-fpm8.3|4|ok']));
+ assert.equal(checked.verdict, 'patched');
+ assert.doesNotMatch(checked.reasons.join(' '), /server script/);
+ const stale = run(probe + servercheck([...host, 'proc|php-fpm8.3|4|stale']));
+ assert.equal(stale.verdict, 'restart');
+});
+
+// ── #11 Ubuntu 14.04 +dfsg ──────────────────────────────────────────
+
+test('#11 Ubuntu 14.04: PHP_VERSION without +dfsg compares equal to the fixed package', () => {
+ assert.equal(run('X-Powered-By: PHP/5.5.9-1ubuntu4.29+esm18').verdict, 'patched');
+ const os = { ID: 'ubuntu', VERSION_ID: '14.04' };
+ const pkg = [{ name: 'libapache2-mod-php5', version: '5.5.9+dfsg-1ubuntu4.29+esm18', source: 'php5' }];
+ assert.equal(run(selfcheck({ php_version: '5.5.9-1ubuntu4.29+esm18', os_release: os, dpkg_packages: pkg })).verdict, 'patched');
+ assert.equal(run(selfcheck({ php_version: '5.5.9-1ubuntu4.29+esm17', os_release: os, dpkg_packages: pkg })).verdict, 'restart');
+});
+
+// ── #12 a running build that has the fix ────────────────────────────
+
+test('#12 a fixed running build with a newer package waiting is Patched, with a note', () => {
+ const r = run(selfcheck({
+ php_version: '8.1.2-1ubuntu2.28', os_release: UBUNTU_2204,
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.29', source: 'php8.1' }],
+ }));
+ assert.equal(r.verdict, 'patched');
+ assert.match(r.notes.join(' '), /8\.1\.2-1ubuntu2\.29/);
+
+ const fpm = run(selfcheck({ php_version: '8.4.27', sapi: 'fpm-fcgi' }) + servercheck(['env|root|1', 'proc|php-fpm8.4|4|stale']));
+ assert.equal(fpm.verdict, 'patched');
+ assert.match(fpm.notes.join(' '), /php-fpm8\.4/);
+
+ // Stale processes of another SAPI may be an older PHP: the server still needs a restart.
+ const other = run(selfcheck({ php_version: '8.4.27', sapi: 'apache2handler' }) + servercheck(['env|root|1', 'proc|php-fpm8.4|4|stale']));
+ assert.equal(other.verdict, 'restart');
+ assert.ok(other.actions.length > 0, 'a restart verdict always has a next step');
+});
+
+// ── #22 #23 mitigation advice ────────────────────────────────────────
+
+test('#22 the memory_limit advice never points to RLIMIT_AS', () => {
+ const [mem] = evaluateMitigations({ memory_limit: '128M' }, null, null);
+ assert.doesNotMatch(mem.note, /OS-level memory limit/);
+ assert.match(mem.note, /cgroup/);
+ assert.match(mem.note, /Do not use RLIMIT_AS or RLIMIT_DATA/);
+ const [ko] = evaluateMitigations({ memory_limit: '128M' }, null, null, false, 'ko');
+ assert.match(ko.note, /RLIMIT_AS/);
+});
+
+test('#23 without probe settings the mitigation action names the mitigations', () => {
+ const r = run('X-Powered-By: PHP/8.4.26');
+ assert.equal(r.mitigations.length, 0);
+ assert.doesNotMatch(actionText(r), /listed in this result/);
+ assert.match(actionText(r), /memory_limit=-1/);
+ assert.match(actionText(r), /session\.upload_progress\.enabled=0/);
+
+ const none = run('hello world');
+ assert.doesNotMatch(actionText(none), /mitigation|compromise/);
+
+ const pending = run(selfcheck({ php_version: '8.4.26', ini: null, os_release: { ID: 'debian', VERSION_CODENAME: 'forky' }, dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }] }));
+ assert.equal(pending.verdict, 'pending');
+ // With a known SAPI and layout, the mitigations come as a configuration snippet that names them.
+ assert.match(actionText(pending), /php_admin_value memory_limit -1/);
+ assert.match(actionText(pending), /php_admin_flag session\.upload_progress\.enabled off/);
+});
+
+// ── #24 other distributions ─────────────────────────────────────────
+
+test('#24 other distributions get a note to check their own advisories', () => {
+ // SUSE backports into its own PHP packages, so a version below the fix is Undetermined there (round 3).
+ const r = run(selfcheck({ php_version: '8.2.20', os_release: { ID: 'opensuse-leap', ID_LIKE: 'suse opensuse', VERSION_ID: '15.6' } }));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(all(r), /opensuse-leap/);
+ assert.match(all(r), /advisories/);
+ const arch = run(selfcheck({ php_version: '8.2.20', os_release: { ID: 'arch' } }));
+ assert.equal(arch.verdict, 'vulnerable');
+ assert.match(arch.notes.join(' '), /\(arch\).*advisories/);
+});
+
+// ── #26 Ubuntu and Debian derivatives ────────────────────────────────
+
+test('#26 Ubuntu derivatives are judged with their parent release', () => {
+ const mint = run(selfcheck({
+ php_version: '8.1.2-1ubuntu2.28',
+ os_release: { ID: 'linuxmint', ID_LIKE: 'ubuntu debian', VERSION_ID: '21.3', VERSION_CODENAME: 'virginia' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
+ }));
+ assert.equal(mint.verdict, 'patched');
+ const pop = run(servercheck(['os|ID|pop', 'os|ID_LIKE|ubuntu debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'env|root|1',
+ 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1', 'proc|apache2|2|ok']));
+ assert.equal(pop.verdict, 'patched');
+});
+
+// ── #27 legacy redhat-release ───────────────────────────────────────
+
+test('#27 the probe\'s LEGACY_REDHAT-RELEASE key is recognised', () => {
+ const r = run(selfcheck({ php_version: '5.6.25', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' } }));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(fact(r, 'Distribution'), /CentOS release 6\.10/);
+ assert.ok(r.advisories.some((a) => /RHEL 6/.test(a.label)));
+});
+
+// ── #28 container actions ───────────────────────────────────────────
+
+test('#28 a distribution PHP in a container is told to update its packages, not FROM php:', () => {
+ const r = run(selfcheck({
+ php_version: '8.3.6', container: true, config_path: '/etc/php/8.3/apache2', os_release: UBUNTU_2404,
+ dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.6-0ubuntu0.24.04.4', source: 'php8.3' }],
+ }));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.doesNotMatch(actionText(r), /FROM php:/);
+ assert.match(actionText(r), /apt-get/);
+ assert.match(actionText(r), /rebuild/);
+});
+
+test('#28 a restart verdict in a container says to recreate the container', () => {
+ const r = run(selfcheck({
+ php_version: '8.1.2-1ubuntu2.20', container: true, config_path: '/etc/php/8.1/apache2', os_release: UBUNTU_2204,
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
+ }));
+ assert.equal(r.verdict, 'restart');
+ assert.match(actionText(r), /container/);
+});
+
+// ── #38 EOL wording ─────────────────────────────────────────────────
+
+test('#38 the EOL reason says it is about upstream builds', () => {
+ const r = run('8.1.30');
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.reasons.join(' '), /upstream build/);
+});
+
+// ── #39 #40 RHEL details ────────────────────────────────────────────
+
+const CENTOS7 = ['os|ID|centos', 'os|ID_LIKE|rhel fedora', 'os|VERSION_ID|7', 'env|root|1', 'pkg|rpm|php-common|5.4.16-48.el7|', 'pkg|rpm|php|5.4.16-48.el7|'];
+
+test('#39 RHEL: a CVE Red Hat lists as not affected does not count as missing', () => {
+ const r = run(servercheck([...CENTOS7, 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0', 'proc|httpd|5|ok']));
+ assert.equal(r.verdict, 'patched');
+ assert.match(r.reasons.join(' '), /not affected/);
+ assert.doesNotMatch(r.reasons.join(' '), /has not been updated/);
+});
+
+test('#40 RHEL 6 and 7 get yum and service commands', () => {
+ const r7 = run(servercheck([...CENTOS7, 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|httpd|5|ok']));
+ assert.match(actionText(r7), /yum/);
+ assert.doesNotMatch(actionText(r7), /dnf/);
+ const r6 = run(selfcheck({ php_version: '5.3.3', sapi: 'apache2handler', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' } }));
+ assert.match(actionText(r6), /service httpd restart/);
+ assert.doesNotMatch(actionText(r6), /systemctl/);
+ const r9 = run(servercheck([...ALMA9, 'pkg|rpm|php-common|8.2.13-1.module_el9.4.0+1+ab|', 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0']));
+ assert.match(actionText(r9), /dnf/);
+});
+
+// ── #41 pre-releases and newer branches ─────────────────────────────
+
+test('#41 pre-releases and newer branches get the right reason and action', () => {
+ const rc = run('8.3.12RC1');
+ assert.equal(rc.verdict, 'unknown');
+ assert.match(rc.reasons.join(' '), /pre-release/);
+ assert.doesNotMatch(rc.reasons.join(' '), /development version/);
+ const next = run('8.6.0');
+ assert.equal(next.verdict, 'unknown');
+ assert.match(next.reasons.join(' '), /newer than/);
+ assert.doesNotMatch(actionText(next), /fixed release of the 8\.6 branch/);
+ const eol = run('7.4.33');
+ assert.doesNotMatch(actionText(eol), /fixed release of the 7\.4 branch/);
+});
+
+// ── #43 findings wording ────────────────────────────────────────────
+
+test('#43 crash and web-account findings do not over-attribute', () => {
+ const r = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok', 'log|crash|2', 'webuser_proc|2']));
+ const crash = r.findings.find((f) => /crash/.test(f.text));
+ assert.match(crash.text, /web server or PHP/);
+ const proc = r.findings.find((f) => /process/.test(f.text));
+ assert.equal(proc.level, 'warn');
+ assert.match(proc.text, /nobody/);
+});
+
+// ── Contracts: env|root, unknown counts, uname_release, open_basedir ─
+
+test('contract: unknown counts and non-root runs are never reported as clean', () => {
+ const unknown = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok',
+ 'log|oom|unknown', 'log|crash|0', 'log|bigsession|unknown', 'webuser_proc|0']));
+ assert.ok(!unknown.findings.some((f) => f.level === 'ok'), 'no "nothing suspicious" line');
+ assert.ok(unknown.findings.some((f) => f.level === 'info' && /could not be read/.test(f.text)));
+ assert.ok(!unknown.findings.some((f) => /isolate/.test(f.text)), 'no incident advice without a finding');
+
+ const notRoot = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|0', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2',
+ 'log|oom|0', 'log|crash|0', 'log|bigsession|0', 'webuser_proc|0']));
+ assert.ok(!notRoot.findings.some((f) => f.level === 'ok'));
+ assert.ok(notRoot.findings.some((f) => /root/.test(f.text)));
+
+ const clean = run(servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok',
+ 'log|oom|0', 'log|crash|0', 'log|bigsession|0', 'webuser_proc|0', 'webroot|recent_php|0']));
+ assert.deepEqual(clean.findings.map((f) => f.level), ['ok']);
+
+ const webroot = run(servercheck(['env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'webroot|recent_php|unknown']));
+ assert.ok(!webroot.findings.some((f) => f.level === 'ok'));
+});
+
+test('contract: a RHEL kernel in uname_release identifies the RHEL family when os-release is empty', () => {
+ const r = run(selfcheck({ php_version: '8.2.13', sapi: 'fpm-fcgi', os_release: [], open_basedir: true, uname_release: '5.14.0-427.el9_4.x86_64', dpkg_packages: null }));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(fact(r, 'Distribution'), /RHEL family 9/);
+ assert.match(r.notes.join(' '), /open_basedir/);
+ // A container reports the host kernel, so it is not used there.
+ const c = run(selfcheck({ php_version: '8.2.13', container: true, config_path: '/usr/local/etc/php', os_release: [], uname_release: '5.14.0-427.el9_4.x86_64' }));
+ assert.equal(c.verdict, 'vulnerable');
+});
+
+test('contract: with open_basedir hiding the OS, a version below the fix is Undetermined', () => {
+ const r = run(selfcheck({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: [], open_basedir: true, uname_release: '', dpkg_packages: null }));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(r.reasons.join(' '), /open_basedir/);
+ assert.equal(run(selfcheck({ php_version: '8.2.35', os_release: [], open_basedir: true, uname_release: '' })).verdict, 'patched');
+});
+
+// ── Korean catalog rules ────────────────────────────────────────────
+
+// The word the Korean text must never use for a check, written as escapes so that it appears nowhere in the repository.
+const BANNED_KO = /\uD310\uC815/;
+
+test('the Korean catalog says 확인 for a check, and names the page controls', () => {
+ for (const [key, msg] of Object.entries(MESSAGES.ko)) {
+ const text = typeof msg === 'function' ? msg({ n: 2, items: 'x' }) : msg;
+ assert.doesNotMatch(text, BANNED_KO, key);
+ }
+ assert.match(MESSAGES.ko['reason.debReleaseUnknown'], /배포판 목록에서 릴리스를 고른 뒤 결과확인을 다시 누르세요/);
+ assert.match(MESSAGES.en['reason.debReleaseUnknown'], /Distribution list.*Run check/);
+ assert.match(MESSAGES.ko['finding.incident'], /KISA/);
+});
diff --git a/test/fixtures/distro.json b/test/fixtures/distro.json
@@ -0,0 +1,47 @@
+{
+ "updated_at": "2026-10-23T00:00:00.000Z",
+ "schema": 1,
+ "debian": {
+ "12": {
+ "codename": "bookworm",
+ "packages": {
+ "php8.2": { "pro": false, "fixed": { "CVE-2026-109618": "8.2.35-1~deb12u1", "CVE-2026-109619": "8.2.35-1~deb12u1" } }
+ }
+ },
+ "14": {
+ "codename": "forky",
+ "packages": {
+ "php8.4": { "pro": false, "fixed": { "CVE-2026-109618": null, "CVE-2026-109619": null } }
+ }
+ }
+ },
+ "ubuntu": {
+ "20.04": {
+ "codename": "focal",
+ "packages": {
+ "php7.4": { "pro": true, "fixed": { "CVE-2026-109618": "7.4.3-4ubuntu2.29+esm5", "CVE-2026-109619": "7.4.3-4ubuntu2.29+esm5" } }
+ }
+ },
+ "22.04": {
+ "codename": "jammy",
+ "packages": {
+ "php8.1": { "pro": false, "fixed": { "CVE-2026-109618": "8.1.2-1ubuntu2.28", "CVE-2026-109619": "8.1.2-1ubuntu2.28" } }
+ }
+ },
+ "24.04": {
+ "codename": "noble",
+ "packages": {
+ "php8.3": { "pro": false, "fixed": { "CVE-2026-109618": "8.3.6-0ubuntu0.24.04.13", "CVE-2026-109619": "8.3.6-0ubuntu0.24.04.13" } }
+ }
+ }
+ },
+ "redhat": {
+ "9": {
+ "advisories": [
+ { "cve": "CVE-2026-109618", "advisory": "RHSA-2026:87151", "package": "php-0:8.0.30-8.el9_8.1" }
+ ],
+ "states": []
+ }
+ },
+ "redhat_notes": {}
+}
diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs
@@ -0,0 +1,278 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { LANGS, MESSAGES, translator } from '../site/lib/messages.js';
+import { parseInput } from '../site/lib/parse.js';
+import {
+ judge, VERDICT_TITLES, VERDICT_LABELS, verdictTitles, verdictLabels, sapiLabel, sapiExposure, evaluateMitigations,
+} from '../site/lib/judge.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro.json', import.meta.url), 'utf8'));
+
+const HANGUL = /[가-힣]/;
+const LEFTOVER = /\{\w+\}|undefined|NaN/;
+const placeholders = (s) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
+
+const KO_TITLES = {
+ patched: '수정이 적용된 PHP입니다',
+ vulnerable: '취약한 PHP입니다',
+ pending: '취약하며, 배포판의 수정 패키지가 아직 없습니다',
+ restart: '설치된 PHP는 수정됐지만 실행 중인 PHP는 여전히 취약합니다',
+ unknown: '판단할 근거가 부족합니다',
+};
+const KO_LABELS = {
+ patched: '패치됨',
+ vulnerable: '취약',
+ pending: '수정 대기',
+ restart: '재시작 필요',
+ unknown: '판단 불가',
+};
+
+function selfcheck(fields) {
+ const report = {
+ tool: 'php2shell-selfcheck',
+ tool_version: '1.0.0',
+ schema: 1,
+ php_version: '8.4.26',
+ sapi: 'apache2handler',
+ os_release: {},
+ dpkg_packages: [],
+ extensions: { session: true, opcache: true },
+ ini: {
+ memory_limit: '128M',
+ 'session.upload_progress.enabled': '1',
+ 'session.auto_start': '0',
+ expose_php: '1',
+ },
+ self_deleted: true,
+ ...fields,
+ };
+ return `php2shell-selfcheck 1.0.0\n\n-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(report, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
+}
+const servercheck = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n-----END PHP2SHELL SERVERCHECK-----`;
+
+const run = (text, lang, options = {}) => judge(parseInput(text, { lang }), upstream, distro, { ...options, lang });
+
+test('the supported languages are English and Korean', () => {
+ assert.deepEqual(LANGS, ['en', 'ko']);
+ assert.deepEqual(Object.keys(MESSAGES).sort(), [...LANGS].sort());
+});
+
+test('English and Korean have the same keys, each of the same kind', () => {
+ const en = Object.keys(MESSAGES.en).sort();
+ const ko = Object.keys(MESSAGES.ko).sort();
+ assert.deepEqual(ko.filter((k) => !en.includes(k)), [], 'keys only in ko');
+ assert.deepEqual(en.filter((k) => !ko.includes(k)), [], 'keys only in en');
+ for (const key of en) {
+ assert.equal(typeof MESSAGES.ko[key], typeof MESSAGES.en[key], `kind of ${key}`);
+ assert.ok(['string', 'function'].includes(typeof MESSAGES.en[key]), `kind of ${key}`);
+ }
+});
+
+test('string messages use the same placeholders in both languages', () => {
+ for (const [key, en] of Object.entries(MESSAGES.en)) {
+ if (typeof en !== 'string') continue;
+ assert.deepEqual(placeholders(MESSAGES.ko[key]), placeholders(en), `placeholders of ${key}`);
+ }
+});
+
+test('function messages produce clean text for singular and plural counts', () => {
+ for (const lang of LANGS) {
+ const t = translator(lang);
+ for (const [key, msg] of Object.entries(MESSAGES[lang])) {
+ if (typeof msg !== 'function') continue;
+ for (const n of [1, 3, '1', '4']) {
+ const out = t(key, { n });
+ assert.equal(typeof out, 'string');
+ assert.ok(out.includes(String(n)), `${lang} ${key} shows the count ${n}`);
+ assert.doesNotMatch(out, LEFTOVER, `${lang} ${key}`);
+ }
+ }
+ }
+ const en = translator('en');
+ assert.match(en('finding.recentPhp', { n: 1 }), /^Found 1 PHP file changed/);
+ assert.match(en('finding.recentPhp', { n: 2 }), /^Found 2 PHP files changed/);
+ assert.match(en('parse.procUnreadable', { n: '1' }), /check 1 PHP process for/);
+ assert.match(en('parse.procUnreadable', { n: '4' }), /check 4 PHP processes for/);
+});
+
+test('the translator falls back to English and rejects unknown keys and missing values', () => {
+ for (const lang of ['xx', 'kr', '', undefined, null]) {
+ assert.equal(translator(lang)('verdict.title.patched'), 'This PHP has the fix', `lang ${lang}`);
+ }
+ assert.equal(translator('ko')('verdict.title.patched'), KO_TITLES.patched);
+ assert.throws(() => translator('ko')('no.such.key'), { message: 'Missing message: no.such.key' });
+ assert.throws(() => translator('xx')('no.such.key'), { message: 'Missing message: no.such.key' });
+ assert.throws(() => translator('ko')('toString'), { message: 'Missing message: toString' });
+ assert.throws(() => translator('en')('reason.eol'), /\{branch\}/);
+
+ MESSAGES.en['test.onlyInEnglish'] = 'Only {what}';
+ try {
+ assert.equal(translator('ko')('test.onlyInEnglish', { what: 'English' }), 'Only English');
+ } finally {
+ delete MESSAGES.en['test.onlyInEnglish'];
+ }
+});
+
+test('placeholder values are inserted as they are, without being read as placeholders', () => {
+ assert.equal(translator('en')('reason.eol', { branch: '{x}' }), 'PHP {x} has reached end of life (EOL) and has no official fix release, so an upstream build of it is vulnerable. Only a distribution package with the fixes backported can have them.');
+ assert.equal(translator('ko')('advisory.fixedIn', { version: '8.2.35-1~deb12u1' }), '8.2.35-1~deb12u1에서 수정됨');
+});
+
+test('verdict titles and labels are available in both languages', () => {
+ assert.deepEqual(verdictTitles('ko'), KO_TITLES);
+ assert.deepEqual(verdictLabels('ko'), KO_LABELS);
+ assert.deepEqual(verdictTitles('en'), VERDICT_TITLES);
+ assert.deepEqual(verdictLabels('en'), VERDICT_LABELS);
+ assert.deepEqual(verdictTitles(), VERDICT_TITLES);
+ assert.deepEqual(verdictLabels('xx'), VERDICT_LABELS);
+ assert.notEqual(verdictLabels('en'), VERDICT_LABELS, 'a fresh object every call');
+});
+
+test('exported helpers keep English by default and accept a language', () => {
+ assert.equal(sapiLabel('cli'), 'Command line (CLI) (cli)');
+ assert.equal(sapiLabel('cli', 'ko'), '명령줄(CLI) (cli)');
+ assert.equal(sapiLabel('fpm-fcgi', 'ko'), 'PHP-FPM (fpm-fcgi)');
+ assert.equal(sapiExposure('apache2handler').level, sapiExposure('apache2handler', 'ko').level);
+ assert.match(sapiExposure('embed', 'ko').text, /embed/);
+ assert.match(sapiExposure('embed', 'ko').text, HANGUL);
+ const ini = { memory_limit: null, 'session.upload_progress.enabled': '' };
+ const en = evaluateMitigations(ini, null, null);
+ const ko = evaluateMitigations(ini, null, null, false, 'ko');
+ assert.deepEqual(en.map((m) => m.value), ['(not set)', '(empty = Off)', '(not set)']);
+ assert.deepEqual(ko.map((m) => m.value), ['(설정 안 됨)', '(빈 값 = Off)', '(설정 안 됨)']);
+ assert.deepEqual(ko.map((m) => [m.name, m.status]), en.map((m) => [m.name, m.status]));
+});
+
+test('parseInput writes its notes in the requested language', () => {
+ assert.match(parseInput('hello world').notes[0], /^No PHP version was found/);
+ assert.match(parseInput('hello world', { lang: 'ko' }).notes[0], /^PHP 버전을 찾지 못했습니다/);
+ assert.match(parseInput('PHP 8.3.6 (cli) (built: Mar 1 2026) (NTS)', { lang: 'ko' }).notes[0], /명령줄/);
+ assert.deepEqual(parseInput('8.2.26', { lang: 'ko' }), parseInput('8.2.26'));
+});
+
+const SCENARIOS = {
+ 'upstream vulnerable (Docker)': [selfcheck({ php_version: '8.4.26', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '13' } })],
+ 'upstream patched': [selfcheck({ php_version: '8.4.27' })],
+ 'Ubuntu backport': [selfcheck({
+ php_version: '8.1.2-1ubuntu2.28',
+ os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
+ })],
+ 'Ubuntu restart': [selfcheck({
+ php_version: '8.1.2-1ubuntu2.20',
+ os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
+ })],
+ 'Ubuntu vulnerable from a header': ['X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4'],
+ 'Ubuntu Pro': [selfcheck({
+ php_version: '7.4.3-4ubuntu2.29+esm5',
+ os_release: { ID: 'ubuntu', VERSION_ID: '20.04' },
+ dpkg_packages: [{ name: 'libapache2-mod-php7.4', version: '7.4.3-4ubuntu2.29+esm5', source: 'php7.4' }],
+ })],
+ 'Debian pending': [selfcheck({
+ php_version: '8.4.26',
+ os_release: { ID: 'debian', VERSION_CODENAME: 'forky' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }],
+ })],
+ 'Debian release unknown': [servercheck(['os|ID|debian', 'pkg|dpkg|php8.2-common|8.2.26-1~deb12u1|php8.2'])],
+ 'Sury build': [selfcheck({
+ php_version: '8.3.14',
+ os_release: { ID: 'debian', VERSION_ID: '12' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.14-1+0~20241122.43+debian12~1.gbp9f1e3a', source: 'php8.3' }],
+ })],
+ 'EOL with a backport branch': ['8.1.30'],
+ 'PHP 5.3': ['5.3.29'],
+ 'PHP 8.6 RC': ['8.6.0RC3'],
+ 'CLI': ['PHP 8.3.6 (cli) (built: Mar 1 2026) (NTS)'],
+ 'RHEL changelog fixed': [servercheck(['os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|', 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2'])],
+ 'RHEL changelog missing': [servercheck(['os|ID|rocky', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|', 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0'])],
+ 'server script findings': [servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'webuser_proc|2', 'log|oom|5', 'log|crash|1', 'log|bigsession|0', 'webroot|recent_php|3', 'proc|unreadable|4|unknown', 'proc|php-fpm8.2|812|stale'])],
+ 'server script clean': [servercheck(['os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'webuser_proc|0', 'log|oom|0'])],
+ 'mitigations unpatched': [selfcheck({ php_version: '8.5.11', ini: { memory_limit: '-1', 'session.upload_progress.enabled': '', 'session.auto_start': '1', fatal_error_backtraces: '1', max_input_nesting_level: '64', post_max_size: '8M', expose_php: '' } })],
+ 'mitigations patched': [selfcheck({ php_version: '8.5.12', ini: { memory_limit: '-1', 'session.upload_progress.enabled': '1', 'session.auto_start': '1', fatal_error_backtraces: '1', expose_php: 'On' } })],
+ 'manual distribution': ['X-Powered-By: PHP/8.1.2-1ubuntu2.28', { distro: 'ubuntu:22.04' }],
+ 'unknown input': ['hello world'],
+};
+
+for (const [name, [text, options]] of Object.entries(SCENARIOS)) {
+ test(`Korean result: ${name}`, () => {
+ const en = run(text, 'en', options);
+ const ko = run(text, 'ko', options);
+ assert.equal(ko.verdict, en.verdict, 'same verdict in both languages');
+ assert.equal(ko.title, KO_TITLES[ko.verdict]);
+ assert.equal(en.title, VERDICT_TITLES[en.verdict]);
+
+ // Same shape: the same number of entries everywhere, the same data fields.
+ for (const k of ['reasons', 'notes', 'actions', 'facts', 'mitigations', 'findings', 'advisories']) {
+ assert.equal(ko[k].length, en[k].length, `${k} count`);
+ }
+ assert.deepEqual(ko.actions.map((a) => Boolean(a.code)), en.actions.map((a) => Boolean(a.code)));
+ assert.deepEqual(ko.mitigations.map((m) => [m.name, m.status]), en.mitigations.map((m) => [m.name, m.status]));
+ assert.deepEqual(ko.findings.map((f) => f.level), en.findings.map((f) => f.level));
+ assert.equal(ko.exposure?.level, en.exposure?.level);
+ assert.equal(ko.eol, en.eol);
+
+ const prose = [
+ ...ko.reasons, ...ko.notes, ...ko.actions.map((a) => a.text), ...ko.mitigations.map((m) => m.note),
+ ...ko.findings.map((f) => f.text), ...(ko.exposure ? [ko.exposure.text] : []),
+ ];
+ assert.ok(prose.length > 0);
+ for (const s of prose) assert.match(s, HANGUL, s);
+
+ const everything = [
+ ko.title, ...prose, ...ko.facts.flatMap((f) => [f.label, f.value]), ...ko.advisories.flatMap((a) => [a.label, a.value]),
+ ...ko.mitigations.map((m) => String(m.value)), ...ko.actions.map((a) => a.code || ''),
+ ];
+ for (const s of everything) assert.doesNotMatch(s, LEFTOVER, s);
+ });
+}
+
+test('Korean results keep data values and commands as they are', () => {
+ const vulnerable = run(SCENARIOS['Ubuntu vulnerable from a header'][0], 'ko');
+ assert.match(vulnerable.reasons.join(' '), /8\.3\.6-0ubuntu0\.24\.04\.13/);
+ assert.ok(vulnerable.actions.some((a) => /^sudo apt-get update\nsudo apt-get install --only-upgrade \$\(dpkg -l 'php8\.3-\*'/.test(a.code || '')));
+ assert.ok(vulnerable.actions.some((a) => a.code === 'sudo systemctl restart apache2 # Apache mod_php\nsudo systemctl restart php8.3-fpm # PHP-FPM'));
+
+ const restart = run(SCENARIOS['Ubuntu restart'][0], 'ko');
+ assert.ok(restart.actions.some((a) => a.code === 'sudo systemctl restart apache2'));
+ assert.match(restart.reasons[0], /8\.1\.2-1ubuntu2\.28.*8\.1\.2-1ubuntu2\.20/);
+
+ const eol = run('8.1.30', 'ko');
+ assert.match(eol.notes.join(' '), /PHP-8\.1-CVE-2026-109618-109619/);
+ assert.match(eol.notes.join(' '), /백포트/);
+
+ const rhel = run(SCENARIOS['RHEL changelog fixed'][0], 'ko');
+ assert.ok(rhel.advisories.some((a) => /RHSA-2026:87151/.test(a.value)));
+ assert.equal(rhel.facts.find((f) => f.label === '배포판').value, 'RHEL 9 계열');
+
+ const inferred = run(servercheck(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1', 'proc|apache2|1|stale']), 'ko');
+ assert.equal(inferred.facts.find((f) => f.label === 'SAPI').value, 'Apache mod_php (apache2handler) (실행 중인 프로세스로 추정)');
+
+ const pending = run(SCENARIOS['Debian pending'][0], 'ko');
+ assert.deepEqual(pending.advisories.map((a) => a.value), ['수정 대기', '수정 대기']);
+});
+
+test('English and Korean give the same commands, and English keeps the raw Red Hat fix states', () => {
+ const en = run(SCENARIOS['Ubuntu vulnerable from a header'][0], 'en');
+ const ko = run(SCENARIOS['Ubuntu vulnerable from a header'][0], 'ko');
+ assert.deepEqual(ko.actions.map((a) => a.code || null), en.actions.map((a) => a.code || null));
+ assert.ok(en.actions.some((a) => a.code === 'sudo systemctl restart apache2 # Apache mod_php\nsudo systemctl restart php8.3-fpm # PHP-FPM'));
+ const t = translator('en');
+ for (const [state, key] of [['Affected', 'rhelState.affected'], ['Under investigation', 'rhelState.underInvestigation'], ['Fix deferred', 'rhelState.fixDeferred'], ['Will not fix', 'rhelState.willNotFix'], ['Not affected', 'rhelState.notAffected'], ['Out of support scope', 'rhelState.outOfSupportScope']]) {
+ assert.equal(t(key), state);
+ }
+});
+
+test('Korean mitigation notes never present a mitigation as a fix', () => {
+ const unpatched = run(SCENARIOS['mitigations unpatched'][0], 'ko');
+ const patched = run(SCENARIOS['mitigations patched'][0], 'ko');
+ for (const r of [unpatched, patched]) {
+ for (const s of [...r.mitigations.map((m) => m.note), ...r.actions.map((a) => a.text)]) {
+ assert.doesNotMatch(s, /안전/, s);
+ }
+ }
+ assert.ok(unpatched.actions.some((a) => /패치를 대신하지 않습니다/.test(a.text)));
+ assert.ok(patched.mitigations.every((m) => m.status !== 'warn'));
+});
diff --git a/test/judge.test.mjs b/test/judge.test.mjs
@@ -0,0 +1,292 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge, VERDICT_TITLES, VERDICT_LABELS } from '../site/lib/judge.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('./fixtures/distro.json', import.meta.url), 'utf8'));
+
+function selfcheck(fields) {
+ const report = {
+ tool: 'php2shell-selfcheck',
+ tool_version: '1.0.0',
+ schema: 1,
+ php_version: '8.4.26',
+ sapi: 'apache2handler',
+ os_release: {},
+ dpkg_packages: [],
+ extensions: { session: true, opcache: true },
+ ini: {
+ memory_limit: '128M',
+ 'session.upload_progress.enabled': '1',
+ 'session.auto_start': '0',
+ expose_php: '1',
+ },
+ self_deleted: true,
+ ...fields,
+ };
+ return `php2shell-selfcheck 1.0.0\n\n-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify(report, null, 2)}\n-----END PHP2SHELL SELFCHECK-----\n`;
+}
+
+const run = (text, options) => judge(parseInput(text), upstream, distro, options);
+
+test('verdict titles and short labels cover the five canonical verdicts', () => {
+ assert.deepEqual(VERDICT_LABELS, {
+ patched: 'Patched',
+ vulnerable: 'Vulnerable',
+ pending: 'Fix pending',
+ restart: 'Restart needed',
+ unknown: 'Undetermined',
+ });
+ assert.deepEqual(Object.keys(VERDICT_TITLES).sort(), Object.keys(VERDICT_LABELS).sort());
+});
+
+test('upstream build: older than the fixed release is vulnerable', () => {
+ const r = run(selfcheck({ php_version: '8.4.26', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '13' } }));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.actions[0].text, /Dockerfile/);
+ assert.equal(r.exposure.level, 'high');
+});
+
+test('upstream build: the fixed release or later is patched', () => {
+ assert.equal(run(selfcheck({ php_version: '8.4.27' })).verdict, 'patched');
+ assert.equal(run(selfcheck({ php_version: '8.5.12' })).verdict, 'patched');
+});
+
+test('Ubuntu 22.04: patched when the fix is backported, even though the version number is 8.1.2', () => {
+ const r = run(selfcheck({
+ php_version: '8.1.2-1ubuntu2.28',
+ os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' },
+ dpkg_packages: [
+ { name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
+ { name: 'php8.1-common', version: '8.1.2-1ubuntu2.28', source: 'php8.1' },
+ ],
+ }));
+ assert.equal(r.verdict, 'patched');
+ assert.match(r.reasons.join(' '), /backported/);
+});
+
+test('Ubuntu 22.04: restart needed when the package is new but the running PHP is old', () => {
+ const r = run(selfcheck({
+ php_version: '8.1.2-1ubuntu2.20',
+ os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }],
+ }));
+ assert.equal(r.verdict, 'restart');
+ assert.ok(r.actions.some((a) => a.code === 'sudo systemctl restart apache2'));
+});
+
+test('Ubuntu 22.04: vulnerable when the package is old as well', () => {
+ const r = run(selfcheck({
+ php_version: '8.1.2-1ubuntu2.20',
+ sapi: 'fpm-fcgi',
+ os_release: { ID: 'ubuntu', VERSION_ID: '22.04' },
+ dpkg_packages: [{ name: 'php8.1-fpm', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }],
+ }));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.reasons.join(' '), /8\.1\.2-1ubuntu2\.28/);
+ assert.ok(r.actions.some((a) => a.code === 'sudo systemctl restart php8.1-fpm'));
+});
+
+test('a header alone is enough to infer the distribution from the Ubuntu revision', () => {
+ assert.equal(run('HTTP/1.1 200 OK\nServer: Apache/2.4.52 (Ubuntu)\nX-Powered-By: PHP/8.1.2-1ubuntu2.28\n').verdict, 'patched');
+ assert.equal(run('X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4').verdict, 'vulnerable');
+});
+
+test('Debian testing: fix pending when there is no fixed package', () => {
+ const r = run(selfcheck({
+ php_version: '8.4.26',
+ os_release: { ID: 'debian', VERSION_CODENAME: 'forky' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }],
+ }));
+ assert.equal(r.verdict, 'pending');
+});
+
+test('Debian 12: catches a missed restart by comparing with the installed package, even without a revision in PHP_VERSION', () => {
+ const r = run(selfcheck({
+ php_version: '8.2.26',
+ os_release: { ID: 'debian', VERSION_ID: '12' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1~deb12u1', source: 'php8.2' }],
+ }));
+ assert.equal(r.verdict, 'restart');
+});
+
+test('Debian 12: vulnerable when not updated', () => {
+ const r = run(selfcheck({
+ php_version: '8.2.26',
+ os_release: { ID: 'debian', VERSION_ID: '12' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.26-1~deb12u1', source: 'php8.2' }],
+ }));
+ assert.equal(r.verdict, 'vulnerable');
+});
+
+test('a fix available only through Ubuntu Pro is pointed out', () => {
+ const r = run(selfcheck({
+ php_version: '7.4.3-4ubuntu2.29+esm5',
+ os_release: { ID: 'ubuntu', VERSION_ID: '20.04' },
+ dpkg_packages: [{ name: 'libapache2-mod-php7.4', version: '7.4.3-4ubuntu2.29+esm5', source: 'php7.4' }],
+ }));
+ assert.equal(r.verdict, 'patched');
+ assert.match(r.notes.join(' '), /Ubuntu Pro/);
+});
+
+test('deb.sury.org builds are checked against upstream', () => {
+ const r = run(selfcheck({
+ php_version: '8.3.14',
+ os_release: { ID: 'debian', VERSION_ID: '12' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.14-1+0~20241122.43+debian12~1.gbp9f1e3a', source: 'php8.3' }],
+ }));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.notes.join(' '), /sury/);
+});
+
+test('an upstream build of an end-of-life branch is vulnerable and points to the backport branch', () => {
+ const r = run('8.1.30');
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.notes.join(' '), /PHP-8\.1-CVE-2026-109618-109619/);
+});
+
+test('PHP older than 5.4 and development versions stay undetermined', () => {
+ assert.equal(run('5.3.29').verdict, 'unknown');
+ assert.equal(run('8.6.0RC3').verdict, 'unknown');
+});
+
+test('php -v output gets a CLI warning', () => {
+ // 8.3.20 was never a distribution's backport version (8.3.6 is Ubuntu 24.04's, so it is Undetermined; see findings.test.mjs #5).
+ const r = run('PHP 8.3.20 (cli) (built: Mar 1 2026) (NTS)');
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.notes.join(' '), /command-line/);
+});
+
+test('RHEL: patched when the package changelog has both CVEs, undetermined when it does not', () => {
+ const block = (hits) => `-----BEGIN PHP2SHELL SERVERCHECK-----
+tool|php2shell-server-check|1.0.0
+os|ID|rocky
+os|ID_LIKE|rhel centos fedora
+os|VERSION_ID|9.6
+pkg|rpm|php-common|8.0.30-8.el9_8.1|
+changelog|php-common|CVE-2026-109618|${hits}
+changelog|php-common|CVE-2026-109619|${hits}
+-----END PHP2SHELL SERVERCHECK-----`;
+ const fixed = run(block(2));
+ assert.equal(fixed.verdict, 'patched');
+ assert.ok(fixed.advisories.some((a) => /RHSA-2026:87151/.test(a.value)));
+ assert.equal(run(block(0)).verdict, 'unknown');
+});
+
+test('with only the server script, the SAPI is inferred from processes to pick the restart command', () => {
+ const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
+os|ID|ubuntu
+os|VERSION_ID|22.04
+pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1
+proc|apache2|1|stale
+-----END PHP2SHELL SERVERCHECK-----`);
+ assert.equal(r.verdict, 'restart');
+ assert.ok(r.actions.some((a) => a.code === 'sudo systemctl restart apache2'));
+ assert.match(r.facts.find((f) => f.label === 'SAPI').value, /inferred/);
+});
+
+test('patched when the changelog has both CVEs, even without distribution data', () => {
+ const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
+os|ID|debian
+os|VERSION_ID|11
+pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4
+changelog|php7.4-fpm|CVE-2026-109618|1
+changelog|php7.4-fpm|CVE-2026-109619|1
+-----END PHP2SHELL SERVERCHECK-----`);
+ assert.equal(r.verdict, 'patched');
+ assert.match(r.reasons.join(' '), /changelog/);
+});
+
+test('restart needed when the server script finds processes using old PHP files', () => {
+ const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
+os|ID|debian
+os|VERSION_ID|12
+pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2
+proc|php-fpm8.2|812|stale
+proc|php-fpm8.2|813|ok
+-----END PHP2SHELL SERVERCHECK-----`);
+ assert.equal(r.verdict, 'restart');
+});
+
+test('pasting the PHP probe and server script output together uses both', () => {
+ const text = (sapi) => selfcheck({ php_version: '8.2.35', sapi, os_release: { ID: 'debian', VERSION_ID: '12' } })
+ + '\n-----BEGIN PHP2SHELL SERVERCHECK-----\nproc|php-fpm8.2|900|stale\n-----END PHP2SHELL SERVERCHECK-----\n';
+ const obs = parseInput(text('fpm-fcgi'));
+ assert.deepEqual(obs.sources, ['selfcheck', 'servercheck']);
+ // The FPM that ran the probe reports 8.2.35, so what runs has the fix: a newer package only waits for a restart.
+ const fpm = judge(obs, upstream, distro);
+ assert.equal(fpm.verdict, 'patched');
+ assert.match(fpm.notes.join(' '), /php-fpm8\.2/);
+ // Stale FPM processes next to a probe that ran under Apache may be an older build: restart needed.
+ assert.equal(judge(parseInput(text('apache2handler')), upstream, distro).verdict, 'restart');
+});
+
+test('interim mitigations are evaluated', () => {
+ const r = run(selfcheck({
+ php_version: '8.5.11',
+ ini: {
+ memory_limit: '-1',
+ 'session.upload_progress.enabled': '',
+ 'session.auto_start': '0',
+ fatal_error_backtraces: '1',
+ expose_php: '',
+ },
+ }));
+ const byName = Object.fromEntries(r.mitigations.map((m) => [m.name, m.status]));
+ assert.equal(byName.memory_limit, 'ok');
+ assert.equal(byName['session.upload_progress.enabled'], 'ok');
+ assert.equal(byName['session.auto_start'], 'ok');
+ assert.equal(byName.fatal_error_backtraces, 'warn');
+ assert.equal(r.verdict, 'vulnerable', 'interim mitigations do not change the verdict');
+});
+
+test('interim mitigations are not recommended on a patched server', () => {
+ const r = run(selfcheck({ php_version: '8.5.12', ini: { memory_limit: '-1', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', fatal_error_backtraces: '1' } }));
+ assert.equal(r.verdict, 'patched');
+ assert.ok(r.mitigations.every((m) => m.status !== 'warn'));
+ assert.match(r.mitigations.find((m) => m.name === 'memory_limit').note, /reverting/);
+ assert.ok(!r.mitigations.some((m) => m.name === 'fatal_error_backtraces'));
+});
+
+test('a Docker image of an end-of-life version is told to move to a supported version', () => {
+ const r = run(selfcheck({ php_version: '7.4.33', container: true, config_path: '/usr/local/etc/php' }));
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(r.actions[0].text, /end of life/);
+ assert.equal(r.actions[0].code, 'FROM php:8.4.27-apache');
+ assert.match(r.actions[0].text, /8\.3\.36/);
+});
+
+test('signs of compromise found by the server script are shown', () => {
+ const r = run(`-----BEGIN PHP2SHELL SERVERCHECK-----
+os|ID|debian
+os|VERSION_ID|12
+pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2
+webuser_proc|2
+log|oom|5
+log|crash|0
+log|bigsession|0
+proc|unreadable|4|unknown
+-----END PHP2SHELL SERVERCHECK-----`);
+ const levels = r.findings.map((f) => f.level);
+ // Web-account processes are a "check" item: daemons running as nobody are common (see findings.test.mjs #43).
+ assert.deepEqual(levels, ['warn', 'warn', 'info']);
+ assert.match(r.notes.join(' '), /root/);
+});
+
+test('a manually chosen distribution is used', () => {
+ const r = run('X-Powered-By: PHP/8.1.2-1ubuntu2.28', { distro: 'ubuntu:22.04' });
+ assert.equal(r.verdict, 'patched');
+});
+
+test('unrecognisable input is undetermined', () => {
+ const r = run('hello world');
+ assert.equal(r.verdict, 'unknown');
+ assert.ok(r.notes.length > 0);
+});
+
+test('a failed self-delete is flagged', () => {
+ const r = run(selfcheck({ php_version: '8.4.27', self_deleted: false }));
+ assert.match(r.notes.join(' '), /Delete it yourself/);
+});
diff --git a/test/remediation.test.mjs b/test/remediation.test.mjs
@@ -0,0 +1,838 @@
+// Remediation steps (r.actions): for each kind of result, the steps name what runs now, the release or package that
+// fixes it, the exact commands built from the observation (package names, image tag, service names) and how to
+// confirm the fix, in English and Korean. Real data files in site/data/.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge } from '../site/lib/judge.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+
+const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
+const server = (lines, closed = true) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${['tool|php2shell-server-check|1.0.0', ...lines].join('\n')}\n${closed ? '-----END PHP2SHELL SERVERCHECK-----\n' : ''}`;
+const run = (text, lang = 'en', data = distro, options = {}) => judge(parseInput(text, { lang }), upstream, data, { ...options, lang });
+const both = (text, data = distro, options = {}) => ['en', 'ko'].map((lang) => run(text, lang, data, options));
+const codes = (r) => r.actions.map((a) => a.code || '');
+const all = (r) => r.actions.map((a) => `${a.text}\n${a.code || ''}`).join('\n');
+// The step whose code block matches re (a string is matched exactly).
+const stepWith = (r, re) => r.actions.find((a) => (typeof re === 'string' ? a.code === re : re.test(a.code || '')));
+const indexWith = (r, re) => r.actions.findIndex((a) => (typeof re === 'string' ? a.code === re : re.test(a.code || '')));
+
+const INI = { memory_limit: '128M', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1' };
+const CVES = 'CVE-2026-109618 --cve CVE-2026-109619';
+const GREP = 'grep -e CVE-2026-109618 -e CVE-2026-109619';
+const MODULE = '8.2.13-1.module+el9.4.0+20827+3f2b0b48';
+// The download lines of the page's Server script and PHP probe tabs.
+const FETCH = 'curl -fsSLO https://sqzer-x.github.io/php2shell-scanner/downloads/php2shell-server-check.sh\nsha256sum php2shell-server-check.sh';
+const PROBE = "curl -fsSLO https://sqzer-x.github.io/php2shell-scanner/downloads/php2shell-selfcheck.php\ncurl -s 'https://<site>/<file>.php?k=<TOKEN>'";
+
+// Every fixture used below, with the verdict it must keep: the remediation work changes no verdict.
+const F = {
+ dockerApache: probe({ php_version: '8.3.12', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, ini: INI }),
+ dockerFpmAlpine: probe({ php_version: '8.4.20', sapi: 'fpm-fcgi', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'alpine', VERSION_ID: '3.20.3' }, dpkg_packages: null, ini: INI }),
+ dockerCliTrixie: probe({ php_version: '8.4.20', sapi: 'cli', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '13' }, ini: INI }),
+ dockerBullseye: probe({ php_version: '8.3.12', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '11', VERSION_CODENAME: 'bullseye' }, ini: INI }),
+ dockerEol: probe({ php_version: '7.4.33', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, ini: INI }),
+ containerDeb: probe({ php_version: '8.3.6', sapi: 'apache2handler', container: true, config_path: '/etc/php/8.3/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '24.04', VERSION_CODENAME: 'noble' }, dpkg_packages: [{ name: 'libapache2-mod-php8.3', version: '8.3.6-0ubuntu0.24.04.4', source: 'php8.3' }, { name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.4', source: 'php8.3' }], ini: INI }),
+ containerRestart: probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'apache2handler', container: true, config_path: '/etc/php/8.1/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '22.04' }, dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], ini: INI }),
+ jammyFpm: probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' }, dpkg_packages: [{ name: 'php8.1-fpm', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }, { name: 'php8.1-common', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }, { name: 'php8.1-cli', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }], ini: INI }),
+ focalPro: probe({ php_version: '7.4.3-4ubuntu2.20', sapi: 'apache2handler', config_path: '/etc/php/7.4/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '20.04', VERSION_CODENAME: 'focal' }, dpkg_packages: [{ name: 'libapache2-mod-php7.4', version: '7.4.3-4ubuntu2.20', source: 'php7.4' }, { name: 'php7.4-common', version: '7.4.3-4ubuntu2.20', source: 'php7.4' }], ini: INI }),
+ trusty: probe({ php_version: '5.5.9-1ubuntu4.20', sapi: 'apache2handler', config_path: '/etc/php5/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '14.04' }, dpkg_packages: [{ name: 'libapache2-mod-php5', version: '5.5.9+dfsg-1ubuntu4.20', source: 'php5' }], ini: INI }),
+ nobleHeader: 'X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4',
+ resolute85: probe({ php_version: '8.5.4-0ubuntu1.2', sapi: 'apache2handler', config_path: '/etc/php/8.5/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '26.04' }, dpkg_packages: [{ name: 'libapache2-mod-php8.5', version: '8.5.4-0ubuntu1.2', source: 'php8.5' }], ini: { ...INI, fatal_error_backtraces: '1' } }),
+ rockyModule: server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php-fpm|${MODULE}|`, `pkg|rpm|php-cli|${MODULE}|`, 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|php-fpm|5|ok']),
+ rhel9Base: server(['env|root|1', 'os|ID|rhel', 'os|ID_LIKE|fedora', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.0.30-3.el9_6|', 'pkg|rpm|php|8.0.30-3.el9_6|', 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|httpd|8|ok']),
+ rhel7Probe: probe({ php_version: '5.4.16', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'Red Hat Enterprise Linux Server release 7.9 (Maipo)' }, dpkg_packages: null, ini: INI }),
+ centos6: probe({ php_version: '5.3.3', sapi: 'apache2handler', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' }, ini: INI }),
+ alma84: server(['env|root|1', 'os|ID|almalinux', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.4.20-1.module_el9.6.0+1+aa|', 'pkg|rpm|php-fpm|8.4.20-1.module_el9.6.0+1+aa|', 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|php-fpm|5|ok']),
+ remiScl: server(['env|root|1', 'os|ID|almalinux', 'os|VERSION_ID|9.6', 'pkg|rpm|php82-php-common|8.2.20-1.el9.remi|', 'pkg|rpm|php82-php-fpm|8.2.20-1.el9.remi|', 'proc|php-fpm|4|ok']),
+ selfFpm: probe({ php_version: '8.3.20', sapi: 'fpm-fcgi', config_path: '/usr/local/lib', os_release: { ID: 'ubuntu', VERSION_ID: '24.04' }, dpkg_packages: [], ini: INI }),
+ selfApache: probe({ php_version: '8.4.10', sapi: 'apache2handler', config_path: '/opt/php/etc', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: INI }),
+ eol81: probe({ php_version: '8.1.30', sapi: 'fpm-fcgi', config_path: '/usr/local/lib', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: INI }),
+ eol80: probe({ php_version: '8.0.30', sapi: 'fpm-fcgi', config_path: '/usr/local/lib', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: INI }),
+ eol73: probe({ php_version: '7.3.33', sapi: 'fpm-fcgi', config_path: '/usr/local/lib', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: INI }),
+ jammyRestart: probe({ php_version: '8.1.2-1ubuntu2.20', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '22.04' }, dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], ini: INI }),
+ debianStale: server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'pkg|dpkg|php8.2-common|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|6|stale']),
+ rockyStale: server(['env|root|1', 'os|ID|rocky', 'os|VERSION_ID|9.4', `pkg|rpm|php-common|${MODULE}|`, `pkg|rpm|php|${MODULE}|`, 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|1', 'proc|httpd|6|stale']),
+ forkyApache: probe({ php_version: '8.4.26', sapi: 'apache2handler', config_path: '/etc/php/8.4/apache2', os_release: { ID: 'debian', VERSION_CODENAME: 'forky' }, dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: '8.4.26-1', source: 'php8.4' }, { name: 'php8.4-common', version: '8.4.26-1', source: 'php8.4' }], ini: INI }),
+ forkyFpm: probe({ php_version: '8.4.26', sapi: 'fpm-fcgi', config_path: '/etc/php/8.4/fpm', os_release: { ID: 'debian', VERSION_CODENAME: 'forky' }, dpkg_packages: [{ name: 'php8.4-fpm', version: '8.4.26-1', source: 'php8.4' }], ini: INI }),
+ osUnread: probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: null, open_basedir: false, dpkg_packages: null, ini: INI }),
+ hidden: server(['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'webuser_proc|unknown']),
+ truncated: server(['env|root|1', 'os|ID|rocky', 'os|VERSION_ID|9.4', `pkg|rpm|php-common|${MODULE}|`, 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|1'], false),
+ unaccounted: server(['env|root|1', 'os|ID|ubuntu', 'os|VERSION_ID|22.04', 'pkg|dpkg|php8.1-cli|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm|12|ok']),
+ conflict: probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ID: 'ubuntu', ID_LIKE: 'debian', VERSION_ID: '24.04', VERSION_CODENAME: 'noble' }, dpkg_packages: [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }] })
+ + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.5|php8.3', 'proc|php-fpm8.3|3|ok']),
+ foreign: probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '24.04' }, dpkg_packages: [{ name: 'php8.3-fpm', version: '8.3.6-1+ppa1', source: 'php8.3' }] }),
+ debianNoRelease: server(['env|root|1', 'os|ID|debian', 'pkg|dpkg|php8.2-common|8.2.26-1|php8.2', 'proc|php-fpm8.2|3|ok']),
+ noVersion: 'hello world',
+ rhelProbe: probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.4' }, dpkg_packages: null, ini: INI }),
+ patchedFpm: probe({ php_version: '8.3.6-0ubuntu0.24.04.13', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '24.04' }, dpkg_packages: [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }], ini: INI }),
+ patchedDocker: probe({ php_version: '8.4.27', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '13', VERSION_CODENAME: 'trixie' }, ini: { ...INI, 'session.upload_progress.enabled': '0' } }),
+ patchedQuiet: probe({ php_version: '8.4.27', sapi: 'fpm-fcgi', config_path: '/etc/php/8.4/fpm', os_release: {}, ini: { ...INI, expose_php: '0' } }),
+ patchedHeader: 'X-Powered-By: PHP/8.4.27',
+ // Cases from the review of the remediation steps.
+ dockerWp: probe({ php_version: '8.2.20', sapi: 'apache2handler', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, ini: INI }),
+ dockerAlpine324: probe({ php_version: '8.4.20', sapi: 'fpm-fcgi', container: true, config_path: '/usr/local/etc/php', os_release: { ID: 'alpine', VERSION_ID: '3.24.1' }, ini: INI }),
+ focalContainer: probe({ php_version: '7.4.3-4ubuntu2.20', sapi: 'apache2handler', container: true, config_path: '/etc/php/7.4/apache2', os_release: { ID: 'ubuntu', VERSION_ID: '20.04' }, dpkg_packages: [{ name: 'libapache2-mod-php7.4', version: '7.4.3-4ubuntu2.20', source: 'php7.4' }], ini: INI }),
+ rhelContainer: probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', container: true, config_path: '/etc', os_release: { ID: 'rocky', VERSION_ID: '9.4' }, dpkg_packages: null, ini: INI }),
+ deb13both: server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|13', 'pkg|dpkg|php8.4-fpm|8.4.20-1~deb13u1|php8.4', 'pkg|dpkg|libapache2-mod-php8.4|8.4.20-1~deb13u1|php8.4', 'pkg|dpkg|php8.4-common|8.4.20-1~deb13u1|php8.4', 'proc|php-fpm8.4|4|ok', 'proc|apache2|5|ok']),
+ deb11: probe({ php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/php/7.4/fpm', os_release: { ID: 'debian', VERSION_ID: '11' }, dpkg_packages: [{ name: 'php7.4-fpm', version: '7.4.33-1+deb11u5', source: 'php7.4' }], ini: INI }),
+ cgi: probe({ php_version: '8.2.20', sapi: 'cgi-fcgi', config_path: '/etc/php/8.2/cgi', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [{ name: 'php8.2-cgi', version: '8.2.20-1~deb12u1', source: 'php8.2' }], ini: INI }),
+ alpine: server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.20.3', 'pkg|apk|php83|8.3.15-r0|', 'pkg|apk|php83-fpm|8.3.15-r0|', 'pkg|apk|php83-common|8.3.15-r0|', 'proc|php-fpm83|2|ok']),
+ fedora: probe({ php_version: '8.3.12', sapi: 'fpm-fcgi', config_path: '/etc', os_release: { ID: 'fedora', VERSION_ID: '41', PRETTY_NAME: 'Fedora Linux 41 (Server Edition)' }, ini: INI }),
+ suse: probe({ php_version: '8.2.20', sapi: 'apache2handler', config_path: '/etc/php8/apache2', os_release: { ID: 'opensuse-leap', ID_LIKE: 'suse opensuse', VERSION_ID: '15.6', PRETTY_NAME: 'openSUSE Leap 15.6' }, ini: INI }),
+ rhel9php81: server(['env|root|1', 'os|ID|almalinux', 'os|VERSION_ID|9.6', 'pkg|rpm|php-common|8.1.32-1.module_el9.6.0+1+aa|', 'pkg|rpm|php-fpm|8.1.32-1.module_el9.6.0+1+aa|', 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|php-fpm|5|ok']),
+ centos7srv: server(['env|root|1', 'os|ID|centos', 'os|ID_LIKE|rhel fedora', 'os|VERSION_ID|7', 'pkg|rpm|php-common|5.4.16-48.el7|', 'pkg|rpm|php|5.4.16-48.el7|', 'changelog|php-common|CVE-2026-109618|0', 'changelog|php-common|CVE-2026-109619|0', 'proc|httpd|5|ok']),
+ rhel10: probe({ php_version: '8.4.20', sapi: 'fpm-fcgi', config_path: '/etc', os_release: { ID: 'almalinux', VERSION_ID: '10.2' }, dpkg_packages: null, ini: INI })
+ + server(['env|root|1', 'os|ID|almalinux', 'os|VERSION_ID|10.2', 'proc|php-fpm|3|ok']),
+ rockyHeader: 'Server: Apache/2.4.57 (Rocky Linux)\nX-Powered-By: PHP/8.2.13',
+ amzn2023: server(['env|root|1', 'os|ID|amzn', 'os|VERSION_ID|2023', 'pkg|rpm|php8.3-common|8.3.10-1.amzn2023|', 'pkg|rpm|php8.3-fpm|8.3.10-1.amzn2023|', 'proc|php-fpm|3|ok']),
+ amzn2: server(['env|root|1', 'os|ID|amzn', 'os|VERSION_ID|2', 'pkg|rpm|php-common|7.4.33-1.amzn2|', 'proc|httpd|3|ok']),
+ remi74: server(['env|root|1', 'os|ID|rocky', 'os|VERSION_ID|9.4', 'pkg|rpm|php74-php-common|7.4.33-20.el9.remi|', 'pkg|rpm|php74-php-fpm|7.4.33-20.el9.remi|', 'proc|php-fpm|4|ok']),
+ eol74apache: probe({ php_version: '7.4.33', sapi: 'apache2handler', config_path: '/usr/local/lib', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [], ini: INI }),
+ cliVersion: 'PHP 8.4.20 (cli) (built: Mar 1 2026) (NTS)',
+ staleOther: server(['env|root|1', 'os|ID|ubuntu', 'os|VERSION_ID|22.04', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|5|ok', 'proc|php-fpm8.3|4|stale']),
+ patchedApache: probe({ php_version: '8.2.35-1~deb12u1', sapi: 'apache2handler', config_path: '/etc/php/8.2/apache2', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1~deb12u1', source: 'php8.2' }], ini: INI }),
+};
+const VERDICTS = {
+ dockerApache: 'vulnerable', dockerFpmAlpine: 'vulnerable', dockerCliTrixie: 'vulnerable', dockerBullseye: 'vulnerable', dockerEol: 'vulnerable',
+ containerDeb: 'vulnerable', containerRestart: 'restart', jammyFpm: 'vulnerable', focalPro: 'vulnerable', trusty: 'vulnerable', nobleHeader: 'vulnerable',
+ resolute85: 'vulnerable', rockyModule: 'unknown', rhel9Base: 'vulnerable', rhel7Probe: 'unknown', centos6: 'unknown', alma84: 'unknown', remiScl: 'vulnerable',
+ selfFpm: 'vulnerable', selfApache: 'vulnerable', eol81: 'vulnerable', eol80: 'vulnerable', eol73: 'vulnerable', jammyRestart: 'restart',
+ debianStale: 'restart', rockyStale: 'restart', forkyApache: 'pending', forkyFpm: 'pending', osUnread: 'unknown', hidden: 'unknown', truncated: 'unknown',
+ unaccounted: 'unknown', conflict: 'unknown', foreign: 'unknown', debianNoRelease: 'unknown', noVersion: 'unknown', rhelProbe: 'unknown',
+ patchedFpm: 'patched', patchedDocker: 'patched', patchedQuiet: 'patched', patchedHeader: 'patched',
+ dockerWp: 'vulnerable', dockerAlpine324: 'vulnerable', focalContainer: 'vulnerable', rhelContainer: 'unknown', deb13both: 'vulnerable',
+ deb11: 'unknown', cgi: 'vulnerable', alpine: 'vulnerable', fedora: 'vulnerable', suse: 'unknown', rhel9php81: 'unknown', centos7srv: 'unknown',
+ rhel10: 'vulnerable', rockyHeader: 'unknown', amzn2023: 'unknown', amzn2: 'unknown', remi74: 'unknown', eol74apache: 'vulnerable',
+ cliVersion: 'vulnerable', staleOther: 'restart', patchedApache: 'patched',
+};
+
+test('the remediation work changes no verdict, and both languages agree', () => {
+ assert.deepEqual(Object.keys(VERDICTS).sort(), Object.keys(F).sort());
+ for (const [name, text] of Object.entries(F)) {
+ const [en, ko] = both(text);
+ assert.equal(en.verdict, VERDICTS[name], name);
+ assert.equal(ko.verdict, VERDICTS[name], name);
+ }
+});
+
+// ── 1. Official Docker image and other containers ──────────────────
+
+test('1. official image: the exact tag of the fixed release in the same variant, rebuilt with --pull, confirmed inside', () => {
+ for (const [text, tag, current, floating] of [
+ [F.dockerApache, 'php:8.3.36-apache-bookworm', '8.3.12', 'php:8.3-apache-bookworm'],
+ [F.dockerFpmAlpine, 'php:8.4.27-fpm-alpine', '8.4.20', 'php:8.4-fpm-alpine'],
+ [F.dockerCliTrixie, 'php:8.4.27-cli-trixie', '8.4.20', 'php:8.4-cli-trixie'],
+ ]) {
+ for (const r of both(text)) {
+ const from = stepWith(r, `FROM ${tag}`);
+ assert.ok(from, `${r.verdict}: FROM ${tag}`);
+ assert.ok(from.text.includes(current), 'names the running version');
+ assert.ok(from.text.includes(tag.split(':')[1].split('-')[0]), 'names the fixed release');
+ assert.ok(from.text.includes(floating), 'explains the floating tag');
+ // Images built on the PHP image keep their own name; services that use an image directly are pulled.
+ assert.match(from.text, /wordpress/);
+ assert.match(from.text, /docker run --rm <your-image> php -v/);
+ const rebuild = indexWith(r, /^# Docker\ndocker build --pull -t <your-image> \.\n# Docker Compose\ndocker compose build --pull\ndocker compose pull --ignore-buildable\ndocker compose up -d$/);
+ const confirm = indexWith(r, "docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'");
+ assert.ok(indexWith(r, `FROM ${tag}`) < rebuild && rebuild < confirm, 'tag, rebuild, confirm in that order');
+ assert.ok(r.actions[confirm].text.includes(tag.split(':')[1].split('-')[0]), 'the confirm step names the version to expect');
+ assert.doesNotMatch(all(r), /apt-get|dnf |apk upgrade|systemctl/, 'no package or host service commands for the official image');
+ }
+ }
+ const [en, ko] = both(F.dockerApache);
+ assert.match(stepWith(en, /^FROM/).text, /does not pin the version/);
+ assert.match(stepWith(ko, /^FROM/).text, /버전을 고정하지 않으므로/);
+ assert.match(stepWith(ko, /^FROM/).text, /이미지 종류\(-apache-bookworm\)/);
+ assert.doesNotMatch(stepWith(ko, /^FROM/).text, /변형|유동 태그/);
+ assert.match(stepWith(ko, /docker exec <container> php -r/).text, /결과확인/);
+ // The interim mitigation goes into the running container and into the image.
+ for (const r of [en, ko]) {
+ assert.equal(stepWith(r, /PHP_INI_DIR/).code, [
+ 'docker exec -u 0 <container> sh -c \'printf "session.upload_progress.enabled = 0\\n" > "$PHP_INI_DIR/conf.d/zz-php2shell-mitigation.ini"\'',
+ 'docker restart <container>',
+ '# Dockerfile',
+ 'RUN printf \'session.upload_progress.enabled = 0\\n\' > "$PHP_INI_DIR/conf.d/zz-php2shell-mitigation.ini"',
+ ].join('\n'));
+ }
+ assert.match(stepWith(en, /PHP_INI_DIR/).text, /does not replace the patch/);
+});
+
+test('1. an image on a Debian release that gets no new PHP images moves to the nearest one that does, and says so', () => {
+ const [en, ko] = both(F.dockerBullseye);
+ for (const r of [en, ko]) {
+ const from = stepWith(r, 'FROM php:8.3.36-apache-bookworm');
+ assert.ok(from);
+ assert.match(from.text, /Debian 11 ?\(bullseye\)/);
+ assert.match(from.text, /Debian 12 ?\(bookworm\)/);
+ }
+ assert.match(stepWith(en, /^FROM/).text, /keeps your variant \(-apache\)\./);
+});
+
+test('1. Alpine images: the Alpine release is kept when its tags are published, otherwise the floating -alpine tag is explained', () => {
+ for (const r of both(F.dockerAlpine324)) {
+ assert.ok(stepWith(r, 'FROM php:8.4.27-fpm-alpine3.24'));
+ assert.doesNotMatch(stepWith(r, /^FROM/).text, /newest Alpine|최신 Alpine/);
+ }
+ const [en, ko] = both(F.dockerFpmAlpine);
+ assert.match(stepWith(en, /^FROM/).text, /newer than the Alpine 3\.20 your image uses now.*php:8\.4\.27-fpm-alpine3\.24/);
+ assert.match(stepWith(ko, /^FROM/).text, /Alpine 3\.20보다 새 릴리스/);
+});
+
+test('1. a branch with an announced end of life says so next to its fixed release', () => {
+ const [en, ko] = both(F.dockerWp);
+ assert.ok(stepWith(en, 'FROM php:8.2.35-apache-bookworm'));
+ assert.match(stepWith(en, /^FROM/).text, /PHP 8\.2 reaches end of life on 31 December 2026, so plan the move to 8\.3 or 8\.4 as well\./);
+ assert.match(stepWith(ko, /^FROM/).text, /PHP 8\.2 브랜치는 2026년 12월 31일에 지원이 종료되므로 8\.3 또는 8\.4 브랜치로/);
+ for (const r of both(F.remiScl)) assert.match(stepWith(r, /^sudo dnf upgrade/).text, /31 December 2026|2026년 12월 31일/);
+ for (const r of both(F.dockerApache)) assert.doesNotMatch(all(r), /end of life on|지원이 종료되므로/);
+});
+
+test('1. an end-of-life official image moves to a supported branch and is tested first', () => {
+ const [en, ko] = both(F.dockerEol);
+ for (const r of [en, ko]) {
+ const from = stepWith(r, 'FROM php:8.4.27-apache-bookworm');
+ assert.ok(from);
+ assert.match(from.text, /7\.4\.33/);
+ assert.match(from.text, /8\.3\.36/);
+ assert.ok(stepWith(r, /docker exec <container> php -r/).text.includes('8.4.27'));
+ }
+ assert.match(stepWith(en, /^FROM/).text, /end of life.*test your application/);
+ assert.match(stepWith(ko, /^FROM/).text, /지원이 종료\(EOL\).*테스트하세요/);
+});
+
+test('1. PHP from distribution packages inside a container gets the package route, not a FROM tag', () => {
+ for (const r of both(F.containerDeb)) {
+ assert.doesNotMatch(all(r), /FROM php:/);
+ const update = stepWith(r, 'RUN apt-get update && apt-get install -y --only-upgrade libapache2-mod-php8.3 php8.3-common');
+ assert.ok(update);
+ assert.ok(update.text.includes('8.3.6-0ubuntu0.24.04.4') && update.text.includes('8.3.6-0ubuntu0.24.04.13'), 'installed and fixed versions');
+ assert.ok(stepWith(r, /docker build --pull/));
+ const confirm = stepWith(r, 'docker exec <container> dpkg-query -W libapache2-mod-php8.3 php8.3-common');
+ assert.ok(confirm.text.includes('8.3.6-0ubuntu0.24.04.13'));
+ }
+});
+
+test('1. a container whose packages were updated in place is restarted, and the update goes into the image', () => {
+ const [en, ko] = both(F.containerRestart);
+ for (const r of [en, ko]) {
+ assert.ok(stepWith(r, 'docker restart <container>'));
+ assert.ok(stepWith(r, 'RUN apt-get update && apt-get install -y --only-upgrade libapache2-mod-php8.1'));
+ assert.doesNotMatch(all(r), /systemctl/);
+ }
+ assert.match(stepWith(en, /^RUN/).text, /goes back to the old build/);
+ assert.match(stepWith(ko, /^RUN/).text, /이전 빌드로 돌아갑니다/);
+ // The vulnerable build served requests until the restart.
+ for (const r of [en, ko]) assert.match(r.actions[r.actions.length - 1].code, /--logs --webroot <webroot>$/);
+});
+
+test('1. an Ubuntu Pro fix in a container is installed while Pro is attached for the build only', () => {
+ for (const r of both(F.focalContainer)) {
+ assert.equal(stepWith(r, /^RUN/).code, [
+ 'RUN --mount=type=secret,id=pro-attach-config \\',
+ ' apt-get update && apt-get install -y --no-install-recommends ubuntu-advantage-tools ca-certificates && \\',
+ ' pro attach --attach-config /run/secrets/pro-attach-config && \\',
+ ' apt-get update && apt-get install -y --only-upgrade libapache2-mod-php7.4 && \\',
+ ' pro detach --assume-yes',
+ ].join('\n'));
+ assert.match(stepWith(r, /^RUN/).text, /pro-attach-config\.yaml/);
+ assert.ok(stepWith(r, /^# Docker\ndocker build --pull --secret id=pro-attach-config,src=pro-attach-config\.yaml -t <your-image> \./));
+ }
+});
+
+test('1. a RHEL-family container is confirmed by its changelog, since the fixed build keeps its version', () => {
+ for (const r of both(F.rhelContainer)) {
+ const confirm = stepWith(r, /rpm -q --changelog/);
+ assert.equal(confirm.code, 'docker exec <container> rpm -q php-common\ndocker exec <container> sh -c "rpm -q --changelog php-common | grep -e CVE-2026-109618 -e CVE-2026-109619"');
+ assert.doesNotMatch(confirm.text, /8\.2\.35/);
+ }
+});
+
+// ── 2. Debian and Ubuntu packages ───────────────────────────────────
+
+test('2. Ubuntu package below the fixed version: installed and fixed versions, apt with the detected names, dpkg-query, restart, re-check', () => {
+ const [en, ko] = both(F.jammyFpm);
+ for (const r of [en, ko]) {
+ const update = indexWith(r, 'sudo apt-get update\nsudo apt-get install --only-upgrade php8.1-fpm php8.1-common php8.1-cli');
+ const confirm = indexWith(r, 'dpkg-query -W php8.1-fpm php8.1-common php8.1-cli');
+ const restart = indexWith(r, 'sudo systemctl restart php8.1-fpm');
+ assert.ok(update >= 0 && update < confirm && confirm < restart, 'update, confirm, restart in that order');
+ assert.ok(r.actions[update].text.includes('php8.1-fpm 8.1.2-1ubuntu2.20'), 'the installed package');
+ assert.ok(r.actions[update].text.includes('8.1.2-1ubuntu2.28'), 'the fixed package');
+ assert.ok(r.actions[confirm].text.includes('8.1.2-1ubuntu2.28'));
+ assert.match(r.actions[update].text, /apt full-upgrade/);
+ assert.match(r.actions[restart + 1].text, en === r ? /Run check.*Patched/ : /결과확인.*패치됨/);
+ }
+ assert.match(en.actions[0].text, /extension packages/);
+ assert.match(ko.actions[0].text, /확장 패키지/);
+});
+
+test('2. Ubuntu Pro: attach first (free for personal use), then update to the ESM version', () => {
+ const [en, ko] = both(F.focalPro);
+ for (const r of [en, ko]) {
+ assert.equal(r.actions[0].code, 'sudo pro attach <token>');
+ assert.ok(stepWith(r, 'sudo apt-get update\nsudo apt-get install --only-upgrade libapache2-mod-php7.4 php7.4-common').text.includes('7.4.3-4ubuntu2.29+esm5'));
+ assert.ok(stepWith(r, 'sudo systemctl restart apache2'));
+ }
+ assert.match(en.actions[0].text, /free for personal use/);
+ assert.match(ko.actions[0].text, /개인 사용이면.*무료/);
+ // Ubuntu 14.04 has the older client (ua) and no systemd, and is past its ESM window: the fix needs the paid
+ // Legacy Support add-on, not the free personal subscription.
+ const [enT, koT] = both(F.trusty);
+ for (const r of [enT, koT]) {
+ assert.equal(r.actions[0].code, 'sudo ua attach <token>');
+ assert.ok(stepWith(r, 'sudo service apache2 restart'));
+ assert.doesNotMatch(all(r), /systemctl/);
+ assert.match(all(r), /<IfModule php5_module>/);
+ }
+ assert.match(enT.actions[0].text, /Legacy Support add-on, which is paid and not part of the free personal subscription/);
+ assert.doesNotMatch(enT.actions[0].text, /free for personal use/);
+ assert.match(koT.actions[0].text, /유료인 Ubuntu Pro Legacy Support 애드온/);
+ assert.match(enT.notes.join(' '), /Legacy Support add-on/);
+});
+
+test('2. a build from another repository is not "updated": the distribution build is installed at its exact version', () => {
+ const [en, ko] = both(F.foreign);
+ for (const r of [en, ko]) {
+ assert.equal(r.actions[0].code, 'apt-cache policy php8.3-fpm');
+ assert.doesNotMatch(all(r), /--only-upgrade|or later|이상으로/);
+ const install = stepWith(r, /--allow-downgrades/);
+ assert.equal(install.code, "sudo apt-get update\nsudo apt-get install --allow-downgrades $(dpkg-query -W -f='${Package} ${Version}\\n' 'php8.3-*' 'libapache2-mod-php8.3' 2>/dev/null | awk '$2 == \"8.3.6-1+ppa1\" {print $1\"=8.3.6-0ubuntu0.24.04.13\"}')");
+ assert.ok(stepWith(r, "dpkg -l 'php8.3-*' 'libapache2-mod-php8.3' | grep '^ii'"));
+ }
+ assert.match(stepWith(en, /--allow-downgrades/).text, /turn off the repository/);
+ assert.match(stepWith(en, /grep '\^ii'/).text, /exactly 8\.3\.6-0ubuntu0\.24\.04\.13, the Ubuntu revision/);
+ assert.match(stepWith(ko, /grep '\^ii'/).text, /정확히 8\.3\.6-0ubuntu0\.24\.04\.13 버전/);
+});
+
+test('2. a release the data does not cover: compare with the tracker by hand, then let the server script read the changelog', () => {
+ const [en, ko] = both(F.deb11);
+ for (const r of [en, ko]) {
+ assert.match(r.actions[0].code, /^https:\/\/security-tracker\.debian\.org\/tracker\/CVE-2026-109618/);
+ assert.ok(stepWith(r, 'dpkg-query -W php7.4-fpm'));
+ assert.equal(stepWith(r, 'sudo sh php2shell-server-check.sh').text.includes('changelog'), true);
+ assert.doesNotMatch(all(r), /PHP probe again through the web server and press Run check: the result should be Patched/);
+ }
+ assert.match(stepWith(en, 'dpkg-query -W php7.4-fpm').text, /Debian pages in the first step/);
+ // The fix.debOpen wording: no jargon, and no "before the result" in Korean.
+ assert.match(all(en), /Even while the result is Undetermined, install the latest Debian updates for php7\.4 now\./);
+ assert.match(all(ko), /결과가 판단 불가이더라도 Debian의 최신 php7\.4 업데이트는 지금 설치하세요\./);
+});
+
+test('2. Apache mod_php and PHP-FPM both running this build: both are restarted, and both get the mitigation', () => {
+ const [en, ko] = both(F.deb13both);
+ for (const r of [en, ko]) {
+ const restart = stepWith(r, 'sudo systemctl restart php8.4-fpm\nsudo systemctl restart apache2');
+ assert.ok(restart);
+ assert.match(restart.text, /full restart of Apache|Apache를 완전히 재시작/);
+ assert.ok(stepWith(r, /conf-available\/php2shell-mitigation\.conf/));
+ assert.ok(stepWith(r, /pool\.d\/zz-php2shell-mitigation\.conf/));
+ }
+ // The second mitigation step does not repeat the first one's introduction.
+ assert.match(stepWith(en, /pool\.d\/zz-php2shell/).text, /^PHP-FPM runs this PHP as well/);
+});
+
+test('2. without a package list the installed packages are looked up, and the restart names both services', () => {
+ for (const r of both(F.nobleHeader)) {
+ const update = stepWith(r, "sudo apt-get update\nsudo apt-get install --only-upgrade $(dpkg -l 'php8.3-*' 'libapache2-mod-php8.3' 2>/dev/null | awk '/^ii/ {print $2}')");
+ assert.ok(update);
+ assert.ok(update.text.includes('8.3.6-0ubuntu0.24.04.4') && update.text.includes('8.3.6-0ubuntu0.24.04.13'));
+ assert.ok(stepWith(r, "dpkg -l 'php8.3-*' 'libapache2-mod-php8.3' | grep '^ii'"));
+ assert.ok(stepWith(r, 'sudo systemctl restart apache2 # Apache mod_php\nsudo systemctl restart php8.3-fpm # PHP-FPM'));
+ }
+});
+
+// ── 3. RHEL family ──────────────────────────────────────────────────
+
+test('3. RHEL module stream: the advisory, dnf updateinfo and upgrade with the detected names, the stream, the changelog', () => {
+ const [en, ko] = both(F.rockyModule);
+ for (const r of [en, ko]) {
+ const upgrade = stepWith(r, `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh php-common php-fpm php-cli`);
+ assert.ok(upgrade);
+ assert.match(upgrade.text, /RHSA-2026:87072/);
+ assert.match(upgrade.text, /RLSA/);
+ assert.doesNotMatch(upgrade.text, /same number|번호는 대개/);
+ assert.match(stepWith(r, 'dnf module list php').text, /php:8\.2-9080020261020145734\.9/);
+ // The stream is checked before the upgrade.
+ assert.ok(indexWith(r, 'dnf module list php') < indexWith(r, /^sudo dnf updateinfo/));
+ assert.ok(stepWith(r, `rpm -q php-common php-fpm php-cli\nrpm -q --changelog php-common | ${GREP}`));
+ assert.ok(stepWith(r, 'sudo systemctl restart php-fpm'));
+ assert.ok(stepWith(r, 'sudo sh php2shell-server-check.sh'), 'the server script confirms the changelog');
+ }
+ assert.match(stepWith(en, 'dnf module list php').text, /^Before you update.*do not need to switch streams/);
+});
+
+test('3. a stream with no fix next to newer streams that have one: switch streams after testing', () => {
+ const [en, ko] = both(F.rhel9php81);
+ for (const r of [en, ko]) {
+ const sw = stepWith(r, 'sudo dnf module switch-to php:8.2');
+ assert.match(sw.text, /RHSA-2026:87072/);
+ assert.match(sw.text, /RHSA-2026:87079/);
+ assert.match(sw.text, /php:8\.2-9080020261020145734\.9/);
+ }
+ assert.match(stepWith(en, 'sudo dnf module switch-to php:8.2').text, /Test your application with php:8\.2/);
+ assert.match(stepWith(en, /^sudo dnf updateinfo/).text, /^If you stay on php:8\.1, there is nothing to install for it yet/);
+});
+
+test('3. RHEL base package: the fixed NVR from the advisory is what rpm -q should show', () => {
+ for (const r of both(F.rhel9Base)) {
+ assert.match(stepWith(r, /^sudo dnf updateinfo/).text, /RHSA-2026:87151/);
+ assert.ok(stepWith(r, `rpm -q php-common php\nrpm -q --changelog php-common | ${GREP}`).text.includes('8.0.30-8.el9_8.1'));
+ assert.ok(stepWith(r, 'sudo systemctl restart httpd'));
+ }
+});
+
+test('3. RHEL 7 uses yum and says it is out of regular support; CentOS 6 uses service and says it is end of life', () => {
+ const [en7, ko7] = both(F.rhel7Probe);
+ for (const r of [en7, ko7]) {
+ assert.match(r.actions[0].code, /\nsudo sh php2shell-server-check\.sh$/, 'the probe alone cannot read the changelog');
+ assert.ok(stepWith(r, `sudo yum updateinfo list --cve ${CVES}\nsudo yum update 'php*'`));
+ assert.ok(stepWith(r, 'sudo systemctl restart httpd'));
+ assert.doesNotMatch(all(r), /dnf/);
+ // Red Hat lists CVE-2026-109619 as not affected for RHEL 7: no changelog line is expected for it.
+ assert.ok(stepWith(r, "rpm -qa 'php*'\nrpm -q --changelog php-common | grep -e CVE-2026-109618"));
+ }
+ assert.match(all(en7), /RHEL 7 is past the end of its regular support/);
+ assert.match(all(ko7), /RHEL 7 릴리스는 정규 지원이 끝났습니다/);
+ assert.match(stepWith(en7, /grep -e CVE-2026-109618$/).text, /CVE-2026-109619 as not affected for RHEL 7, so no line is expected/);
+ const [en6, ko6] = both(F.centos6);
+ for (const r of [en6, ko6]) {
+ assert.ok(stepWith(r, "sudo yum update 'php*'"));
+ assert.ok(stepWith(r, 'sudo service httpd restart'));
+ assert.match(all(r), /sudo service httpd reload/);
+ assert.doesNotMatch(all(r), /systemctl|dnf/);
+ }
+ assert.match(all(en6), /CentOS 6 has reached end of life/);
+ assert.match(all(en6), /RHEL 9, Rocky Linux 9 or AlmaLinux 9/);
+ assert.match(all(ko6), /CentOS 6 릴리스는 지원이 종료/);
+ // No updates come any more: the mitigation follows the steps that explain that, before the update steps.
+ for (const r of [en6, ko6, ...both(F.centos7srv)]) {
+ const watch = indexWith(r, /^https:\/\/access\.redhat\.com/);
+ assert.match(r.actions[watch + 1].code, /php2shell-mitigation\.conf/);
+ assert.ok(watch + 1 < indexWith(r, /yum update/));
+ }
+ assert.match(stepWith(en6, /php2shell-mitigation/).text, /^Until this server runs a supported release/);
+ assert.match(stepWith(ko6, /php2shell-mitigation/).text, /^지원되는 릴리스로 옮길 때까지는/);
+});
+
+test('3. no Red Hat fix for this stream yet: the state and where to watch, the mitigation now, and an update step that says it has nothing yet', () => {
+ const [en, ko] = both(F.alma84);
+ for (const r of [en, ko]) {
+ const watch = stepWith(r, 'https://access.redhat.com/security/cve/CVE-2026-109618\nhttps://access.redhat.com/security/cve/CVE-2026-109619');
+ assert.match(watch.text, /Affected|영향받음/);
+ assert.doesNotMatch(all(r), /ALSA/);
+ assert.equal(r.actions.indexOf(watch), 0);
+ assert.match(r.actions[1].code, /zz-php2shell-mitigation\.conf/);
+ assert.match(stepWith(r, /^sudo dnf updateinfo/).text, /nothing to install for this yet|아직 설치할 수정이 없습니다/);
+ assert.match(stepWith(r, 'sudo sh php2shell-server-check.sh').text, /^Once the update is installed|^업데이트를 설치한 뒤/);
+ }
+ assert.match(en.actions[1].text, /^Until the fixed package is published/);
+});
+
+test('3. a RHEL release that is not known: the advisories of every release for this branch, never "RHEL ?"', () => {
+ const [en, ko] = both(F.rockyHeader);
+ for (const r of [en, ko]) assert.doesNotMatch(all(r), /RHEL \?|RHEL \{/);
+ assert.match(stepWith(en, /^sudo dnf updateinfo/).text, /The RHEL release of this server is not known\. Red Hat has fixed PHP 8\.2 on RHEL 8 in RHSA-2026:87073 and on RHEL 9 in RHSA-2026:87072/);
+ assert.match(stepWith(ko, /^sudo dnf updateinfo/).text, /RHEL 8\(RHSA-2026:87073\), RHEL 9\(RHSA-2026:87072\)의 PHP 8\.2를 수정했으며/);
+});
+
+test('3. Amazon Linux: its own ALAS pages, never "RHEL 2023", and Amazon Linux 2 is out of support', () => {
+ for (const name of ['amzn2023', 'amzn2']) {
+ const [en, ko] = both(F[name]);
+ for (const r of [en, ko]) {
+ assert.ok(stepWith(r, 'https://explore.alas.aws.amazon.com/CVE-2026-109618.html\nhttps://explore.alas.aws.amazon.com/CVE-2026-109619.html'));
+ assert.doesNotMatch(all(r), /RHEL 20|RHEL 2\b|access\.redhat\.com/);
+ }
+ assert.match(all(en), /ALAS advisories, not in Red Hat ones/);
+ }
+ assert.ok(stepWith(run(F.amzn2023), `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh php8.3-common php8.3-fpm`));
+ assert.match(all(run(F.amzn2)), /Amazon Linux 2 reached the end of its support on 30 June 2026/);
+ assert.match(all(run(F.amzn2, 'ko')), /Amazon Linux 2는 2026년 6월 30일에 지원이 종료/);
+ assert.doesNotMatch(all(run(F.amzn2023)), /end of its support/);
+});
+
+test('3. RHEL 10 php8.4 packages the server script did not list are still Red Hat\'s: the dnf route, not a source build', () => {
+ for (const r of both(F.rhel10)) {
+ assert.doesNotMatch(all(r), /php\.net\/distributions|make install/);
+ assert.ok(stepWith(r, `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh 'php*'`));
+ assert.match(stepWith(r, /^sudo dnf updateinfo/).text, /RHSA-2026:87071/);
+ // RHSA-2026:87071 is a rebase to 8.4.27, whose changelog entry names the version, not the CVEs (round 3).
+ assert.ok(stepWith(r, "rpm -qa 'php*'").text.includes('8.4.27-1.el10_2'));
+ assert.doesNotMatch(all(r), /rpm -q --changelog/);
+ assert.ok(stepWith(r, 'sudo systemctl restart php-fpm'));
+ }
+});
+
+test('3. a Remi collection is updated by its own names, restarted by its own service, and its pool file is tested first', () => {
+ for (const r of both(F.remiScl)) {
+ const upgrade = stepWith(r, 'sudo dnf upgrade --refresh php82-php-common php82-php-fpm');
+ assert.match(upgrade.text, /8\.2\.35/);
+ assert.ok(stepWith(r, 'sudo systemctl restart php82-php-fpm'));
+ assert.match(stepWith(r, /\/etc\/opt\/remi\/php82\/php-fpm\.d\/zz-php2shell-mitigation\.conf/).code, /\nsudo \/opt\/remi\/php82\/root\/usr\/sbin\/php-fpm -t && sudo systemctl reload php82-php-fpm$/);
+ }
+ // An end-of-life Remi collection gets Remi's own backport, and no self-built php-src branch next to it.
+ for (const r of both(F.remi74)) {
+ assert.ok(stepWith(r, 'sudo dnf upgrade --refresh php74-php-common php74-php-fpm'));
+ assert.doesNotMatch(all(r), /git clone|7\.4\.33-20\.el9\.remi, so|7\.4\.33-20\.el9\.remi 그대로/);
+ }
+});
+
+// ── 4. Self-built or upstream builds on a supported branch ─────────
+
+test('4. self-built PHP: the fixed release from php.net, the same configure options, and the SAPI binary to confirm', () => {
+ const [en, ko] = both(F.selfFpm);
+ for (const r of [en, ko]) {
+ const get = stepWith(r, 'curl -fsSLO https://www.php.net/distributions/php-8.3.36.tar.xz\nsha256sum php-8.3.36.tar.xz');
+ assert.ok(get.text.includes('8.3.20') && get.text.includes('8.3.36'));
+ assert.ok(stepWith(r, /^php-fpm -i \| grep -i 'configure command'\ntar -xf php-8\.3\.36\.tar\.xz\ncd php-8\.3\.36\n\.\/configure <current-options>\nmake -j"\$\(nproc\)"\nsudo make install$/));
+ assert.ok(stepWith(r, 'sudo systemctl restart <php-fpm-service>'));
+ assert.ok(stepWith(r, 'php-fpm -v').text.includes('8.3.36'));
+ assert.ok(!r.actions.some((a) => a.code === 'php -v'), 'never the command-line php');
+ }
+ assert.match(stepWith(en, 'php-fpm -v').text, /not the command-line php/);
+ // mod_php has no binary of its own: the probe confirms, and Apache keeps the distribution's service name.
+ for (const r of both(F.selfApache)) {
+ assert.ok(stepWith(r, 'sudo systemctl restart apache2'));
+ const last = r.actions.findIndex((a) => /8\.4\.27/.test(a.text) && !a.code);
+ assert.ok(last >= 0, 'a probe-based confirm step');
+ }
+ // The mitigation goes into the php.ini of the configuration directory this PHP reports.
+ for (const r of both(F.selfFpm)) {
+ assert.equal(stepWith(r, /php\.ini/).code, "echo 'session.upload_progress.enabled = 0' | sudo tee -a /usr/local/lib/php.ini >/dev/null\nsudo systemctl restart <php-fpm-service>");
+ }
+});
+
+test('4. -v output or a bare version: the probe first, no claim about where PHP comes from, and never php -v as proof', () => {
+ const [en, ko] = both(F.cliVersion);
+ for (const r of [en, ko]) {
+ assert.match(r.actions[0].code, /php2shell-selfcheck\.php\ncurl -s 'https:\/\/<site>\/<file>\.php\?k=<TOKEN>'$/);
+ assert.ok(!r.actions.some((a) => a.code === 'php -v'));
+ assert.doesNotMatch(all(r), /is not a distribution package/);
+ }
+ assert.match(en.actions[1].text, /^Nothing in this input shows where PHP 8\.4\.20 comes from/);
+ assert.match(ko.actions[1].text, /^이 입력만으로는 PHP 8\.4\.20 버전을 어디서 설치했는지 알 수 없습니다/);
+ assert.match(all(en), /Do not rely on php -v/);
+ // With the probe, the step says the version matches no distribution package in the data, not that it is none.
+ assert.match(all(run(F.selfFpm)), /does not match a distribution package in the data of this check/);
+});
+
+test('4. other systems: service names and package commands where the distribution fixes them, root shells on Alpine', () => {
+ for (const r of both(F.fedora)) {
+ assert.ok(stepWith(r, "sudo dnf upgrade --refresh 'php*'"));
+ assert.ok(stepWith(r, 'sudo systemctl restart php-fpm'));
+ assert.match(stepWith(r, /zz-php2shell/).code, /^sudo tee \/etc\/php-fpm\.d\/zz-php2shell-mitigation\.conf[\s\S]*\nsudo php-fpm -t && sudo systemctl reload php-fpm$/);
+ }
+ for (const r of both(F.suse)) {
+ assert.ok(stepWith(r, 'sudo systemctl restart apache2'));
+ assert.doesNotMatch(all(r), /<apache-service>|<php-fpm-service>/);
+ }
+ for (const r of both(F.alpine)) {
+ assert.ok(stepWith(r, "apk update\napk upgrade $(apk info | grep '^php83')"));
+ assert.ok(stepWith(r, 'rc-service php-fpm83 restart'));
+ assert.ok(stepWith(r, 'sh php2shell-server-check.sh'));
+ assert.doesNotMatch(all(r), /sudo/);
+ }
+ // CGI: whatever starts php-cgi is restarted, and the processes left are listed; the ini goes in the CGI scan dir.
+ for (const r of both(F.cgi)) {
+ assert.ok(stepWith(r, "sudo systemctl restart apache2 # Apache mod_fcgid\nps -eo pid,lstart,args | grep -E '[p]hp-cgi|[c]gi-bin/php'"));
+ assert.doesNotMatch(all(r), /php8\.2-fpm|# PHP-FPM/);
+ assert.match(stepWith(r, /99-php2shell-mitigation\.ini/).code, /^echo 'session\.upload_progress\.enabled = 0' \| sudo tee \/etc\/php\/8\.2\/cgi\/conf\.d\/99-php2shell-mitigation\.ini >\/dev\/null\n/);
+ }
+});
+
+// ── 5. End-of-life branches ─────────────────────────────────────────
+
+test('5. end of life: supported branches with their fixed releases, packages that carry the backport, the php-src branch', () => {
+ const [en, ko] = both(F.eol81);
+ for (const r of [en, ko]) {
+ const move = r.actions[0];
+ for (const v of ['8.4', '8.4.27', '8.3', '8.3.36']) assert.ok(move.text.includes(v), v);
+ // The mitigation is the only thing that helps tonight, so it comes right after the explanation.
+ assert.match(r.actions[1].code, /^echo 'session\.upload_progress\.enabled = 0' \| sudo tee -a \/usr\/local\/lib\/php\.ini >\/dev\/null/);
+ // The closest fixed PHP on this very server comes first, the other carriers after it.
+ const host = stepWith(r, 'sudo apt-get update\nsudo apt-get install php8.2-fpm');
+ assert.match(host.text, /Debian 12/);
+ assert.match(host.text, /8\.2\.35-1~deb12u1/);
+ assert.match(host.text, /Ubuntu 22\.04 \(php8\.1 8\.1\.2-1ubuntu2\.28\)/);
+ const backport = stepWith(r, /git clone/);
+ assert.equal(backport.code, [
+ "php-fpm -i | grep -i 'configure command'",
+ 'git clone --depth 1 --branch PHP-8.1-CVE-2026-109618-109619 https://github.com/php/php-src.git',
+ 'cd php-src',
+ './buildconf --force',
+ './configure <current-options>',
+ 'make -j"$(nproc)"',
+ 'sudo make install',
+ ].join('\n'));
+ assert.ok(backport.text.includes('8.1.30'));
+ assert.match(backport.text, /autoconf, bison/);
+ }
+ assert.match(en.actions[0].text, /test your application/);
+ assert.match(ko.actions[0].text, /애플리케이션을 테스트하세요/);
+ assert.match(en.actions[1].text, /^Until this site runs on a supported branch/);
+ assert.match(ko.actions[1].text, /^지원되는 브랜치로 옮길 때까지는/);
+ assert.match(stepWith(en, /git clone/).text, /still say Vulnerable/);
+ assert.match(stepWith(ko, /git clone/).text, /계속 취약으로/);
+ // 8.0: the RHEL 9 package carries it; 7.3 has neither a carrier nor a backport branch.
+ for (const r of both(F.eol80)) assert.match(all(r), /RHEL 9 \(php-0:8\.0\.30-8\.el9_8\.1, RHSA-2026:87151\)/);
+ for (const r of both(F.eol73)) {
+ assert.doesNotMatch(all(r), /git clone/);
+ assert.match(all(r), /long-term support|장기 지원/);
+ }
+ // Installing the Apache module package next to a loaded PHP module would clash, so no command is given for it.
+ for (const r of both(F.eol74apache)) {
+ assert.match(all(r), /libapache2-mod-php8\.2/);
+ assert.doesNotMatch(codes(r).join('\n'), /apt-get install libapache2-mod-php/);
+ }
+ // An end-of-life official image also gets the mitigation right after the step that moves it.
+ for (const r of both(F.dockerEol)) assert.match(r.actions[1].code, /PHP_INI_DIR/);
+});
+
+// ── 6. Patched on disk, still old in memory ─────────────────────────
+
+test('6. restart: why the old build is still loaded, the full restart for the SAPI and distribution, and how to confirm', () => {
+ const [en, ko] = both(F.jammyRestart);
+ for (const r of [en, ko]) {
+ assert.equal(r.actions.length, 3);
+ assert.equal(r.actions[0].code, 'sudo systemctl restart apache2');
+ // The old build served requests until now, so the compromise check applies.
+ assert.equal(r.actions[2].code, 'sudo sh php2shell-server-check.sh --logs --webroot <webroot>');
+ }
+ assert.match(en.actions[0].text, /loaded the old build before the update/);
+ assert.match(en.actions[0].text, /mod_php.*full restart of Apache, not a reload or a graceful restart/);
+ assert.match(ko.actions[0].text, /업데이트 전에 이전 빌드를 로드했고/);
+ assert.match(en.actions[1].text, /Run check.*Restart needed/);
+ assert.match(ko.actions[1].text, /결과확인.*재시작 필요/);
+ // From the server script: the service of the stale processes, and the script again to see none left.
+ const [enD, koD] = both(F.debianStale);
+ for (const r of [enD, koD]) {
+ assert.equal(r.actions[0].code, 'sudo systemctl restart php8.2-fpm');
+ assert.equal(r.actions[1].code, 'sudo sh php2shell-server-check.sh');
+ }
+ // A PHP-FPM reload re-executes the master from disk; only mod_php keeps the old build through a reload.
+ for (const r of [enD, koD, ...both(F.jammyFpm), ...both(F.rockyModule)]) {
+ assert.doesNotMatch(all(r), /keeps the old PHP build loaded|graceful|이전 PHP 빌드가 메모리에 그대로 남습니다/);
+ }
+ for (const r of both(F.rockyStale)) assert.equal(r.actions[0].code, 'sudo systemctl restart httpd');
+ for (const r of [en, ko, enD, koD, ...both(F.rockyStale)]) {
+ for (const c of codes(r)) assert.doesNotMatch(c, /reload|graceful/);
+ }
+ // Processes no checked build accounts for: a way to find what started them.
+ for (const r of both(F.staleOther)) assert.equal(r.actions[0].code, 'ps -o pid,user,cgroup,args -C php-fpm8.3');
+ assert.match(run(F.staleOther).actions[0].text, /CGROUP column.*\.service.*docker-<id>\.scope/);
+});
+
+// ── 7. Distribution fix pending ─────────────────────────────────────
+
+test('7. pending: what it means, where to watch, how to see the fix arrive, and the mitigation as an Apache snippet', () => {
+ const [en, ko] = both(F.forkyApache);
+ for (const r of [en, ko]) {
+ assert.equal(r.actions[0].code, 'https://security-tracker.debian.org/tracker/CVE-2026-109618\nhttps://security-tracker.debian.org/tracker/CVE-2026-109619');
+ assert.ok(stepWith(r, 'sudo apt-get update\napt list --upgradable 2>/dev/null | grep php8.4\nsudo apt-get install --only-upgrade libapache2-mod-php8.4 php8.4-common'));
+ const mit = stepWith(r, /php2shell-mitigation\.conf/);
+ assert.equal(mit.code, [
+ "sudo tee /etc/apache2/conf-available/php2shell-mitigation.conf >/dev/null <<'EOF'",
+ '<IfModule php_module>',
+ ' php_admin_flag session.upload_progress.enabled off',
+ ' # php_admin_value memory_limit -1',
+ '</IfModule>',
+ 'EOF',
+ 'sudo a2enconf php2shell-mitigation',
+ 'sudo apachectl configtest && sudo systemctl reload apache2',
+ ].join('\n'));
+ assert.match(r.actions[r.actions.length - 1].text, /compromise|침해 지표/);
+ // Nothing can be installed yet, so the mitigation comes straight after the explanation, before the steps
+ // that wait for the fix.
+ assert.match(r.actions[1].code, /php2shell-mitigation\.conf/);
+ }
+ const mitEn = stepWith(en, /php2shell-mitigation/).text;
+ assert.match(en.actions[0].text, /no fixed php8\.4 package yet/);
+ assert.match(mitEn, /^Until the fixed package is published/);
+ assert.match(mitEn, /does not replace the patch/);
+ assert.match(mitEn, /only with a memory cap outside PHP, such as MemoryMax=/);
+ assert.match(mitEn, /uploads keep working/);
+ assert.doesNotMatch(mitEn, /pm\.max_children|MaxRequestWorkers/);
+ const mitKo = stepWith(ko, /php2shell-mitigation/).text;
+ assert.match(mitKo, /^수정 패키지가 공개될 때까지는/);
+ assert.match(mitKo, /패치를 대신하지 않습니다/);
+});
+
+test('7. the PHP-FPM pool snippet uses the pool directory, binary and service of the distribution', () => {
+ const [enFpm] = both(F.forkyFpm);
+ assert.match(stepWith(enFpm, /zz-php2shell/).text, /"\[pool www\] user has not been defined".*put your pool's name in the brackets/);
+ for (const r of both(F.forkyFpm)) {
+ assert.equal(stepWith(r, /zz-php2shell-mitigation/).code, [
+ "sudo tee /etc/php/8.4/fpm/pool.d/zz-php2shell-mitigation.conf >/dev/null <<'EOF'",
+ '[www]',
+ 'php_admin_flag[session.upload_progress.enabled] = off',
+ '; php_admin_value[memory_limit] = -1',
+ 'EOF',
+ 'sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm',
+ ].join('\n'));
+ }
+ for (const r of both(F.rockyModule)) assert.match(stepWith(r, /zz-php2shell/).code, /^sudo tee \/etc\/php-fpm\.d\/zz-php2shell-mitigation\.conf[\s\S]*\nsudo php-fpm -t && sudo systemctl reload php-fpm$/);
+ // PHP 7 names its Apache module php7_module; PHP 8.5 also turns off fatal_error_backtraces.
+ for (const r of both(F.focalPro)) assert.match(stepWith(r, /php2shell-mitigation/).code, /<IfModule php7_module>/);
+ for (const r of both(F.resolute85)) {
+ const mit = stepWith(r, /php2shell-mitigation/);
+ assert.match(mit.code, /php_admin_flag fatal_error_backtraces off/);
+ assert.match(mit.text, /fatal_error_backtraces/);
+ }
+});
+
+test('7. Ubuntu points to its own CVE pages, and a mitigation already in place is not repeated as a snippet', () => {
+ const data = JSON.parse(JSON.stringify(distro));
+ data.ubuntu['24.04'].packages['php8.3'].fixed = { 'CVE-2026-109618': null, 'CVE-2026-109619': null };
+ const text = probe({ php_version: '8.3.6-0ubuntu0.24.04.4', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '24.04' }, dpkg_packages: [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.4', source: 'php8.3' }], ini: INI });
+ for (const r of both(text, data)) {
+ assert.equal(r.verdict, 'pending');
+ assert.equal(r.actions[0].code, 'https://ubuntu.com/security/CVE-2026-109618\nhttps://ubuntu.com/security/CVE-2026-109619');
+ }
+ const off = probe({ php_version: '8.4.26', sapi: 'fpm-fcgi', config_path: '/etc/php/8.4/fpm', os_release: { ID: 'debian', VERSION_CODENAME: 'forky' }, dpkg_packages: [{ name: 'php8.4-fpm', version: '8.4.26-1', source: 'php8.4' }], ini: { ...INI, 'session.upload_progress.enabled': '0' } });
+ for (const r of both(off)) {
+ assert.doesNotMatch(all(r), /zz-php2shell-mitigation/);
+ assert.match(all(r), /listed in this result|이 결과에 나온/);
+ }
+});
+
+// ── 8. Undetermined: what to collect next ───────────────────────────
+
+test('8. an unread OS release asks for the server script as root, or the distribution in the list, and nothing else to run', () => {
+ const [en, ko] = both(F.osUnread);
+ for (const r of [en, ko]) {
+ assert.equal(r.actions[0].code, `${FETCH}\nsudo sh php2shell-server-check.sh`);
+ assert.equal(r.actions[1].code, undefined);
+ assert.doesNotMatch(all(r), /apt-get|dnf|yum|FROM php|zz-php2shell/);
+ }
+ assert.match(en.actions[0].text, /as root/);
+ assert.match(en.actions[0].text, /compare the hash with the one on the Server script tab/);
+ assert.match(en.actions[1].text, /Distribution list next to Run check/);
+ assert.match(ko.actions[1].text, /배포판 목록/);
+ assert.match(ko.actions[1].text, /결과확인/);
+});
+
+test('8. each open question gets its own next step', () => {
+ // Processes hidden without root: run as root; the package already has the fix, so restart if unsure.
+ for (const r of both(F.hidden)) {
+ assert.equal(r.actions[0].code, 'sudo sh php2shell-server-check.sh');
+ assert.equal(r.actions[1].code, 'sudo systemctl restart php8.3-fpm');
+ assert.doesNotMatch(all(r), /apt-get|zz-php2shell/);
+ }
+ assert.match(run(F.hidden).actions[0].text, /again as root/);
+ // A cut-off server script output: copy it again. The package already records both fixes, so no mitigation.
+ for (const r of both(F.truncated)) {
+ assert.match(r.actions[0].text, /BEGIN.*END/);
+ assert.doesNotMatch(all(r), /interim mitigation|임시 완화|memory_limit/);
+ }
+ // PHP processes no checked build accounts for: check them where they run (as root in a container), without
+ // naming a service of this build.
+ for (const r of both(F.unaccounted)) {
+ assert.equal(r.actions[0].code, 'docker cp php2shell-server-check.sh <container>:/tmp/\ndocker exec -u 0 <container> sh /tmp/php2shell-server-check.sh');
+ assert.doesNotMatch(all(r), /php8\.1-fpm|zz-php2shell/);
+ }
+ assert.match(run(F.unaccounted).actions[0].text, /-u 0 runs it as root inside the container/);
+ // Probe and server script from different moments: run both together.
+ for (const r of both(F.conflict)) assert.match(r.actions[0].text, /together|함께/);
+ // A revision Ubuntu does not build: find its repository.
+ for (const r of both(F.foreign)) assert.equal(r.actions[0].code, 'apt-cache policy php8.3-fpm');
+ // The server script ran but the release is unknown: the distribution list, not the script again, as the only
+ // way forward (no "Or"); an unknown Debian release is a systemd one.
+ const [enN, koN] = both(F.debianNoRelease);
+ for (const r of [enN, koN]) {
+ assert.equal(r.actions[0].code, undefined);
+ assert.match(r.actions[0].text, /Distribution list|배포판 목록/);
+ assert.doesNotMatch(codes(r).join('\n'), /sudo service /);
+ assert.ok(stepWith(r, 'sudo systemctl restart php8.2-fpm'));
+ }
+ assert.match(enN.actions[0].text, /^The server script output does not name the release of this system\./);
+ assert.match(koN.actions[0].text, /^서버 스크립트 출력에 이 시스템의 릴리스가 나오지 않습니다\./);
+ assert.doesNotMatch(koN.actions[0].text, /눌러도 됩니다/);
+ // Nothing readable: the probe through the web server, then the server script.
+ const [enV, koV] = both(F.noVersion);
+ for (const r of [enV, koV]) assert.deepEqual(codes(r), [PROBE, `${FETCH}\nsudo sh php2shell-server-check.sh`]);
+ assert.match(enV.actions[0].text, /PHP probe tab.*set \$TOKEN at the top of the file to 12 or more random letters and digits/);
+ assert.match(koV.actions[0].text, /\$TOKEN을 영문자와 숫자로 된 12자 이상의 임의 문자열로/);
+ // A bare version that may be a distribution package: the probe, the server script, or the distribution list.
+ for (const r of both('8.0.30')) {
+ assert.equal(r.verdict, 'unknown');
+ assert.deepEqual(codes(r).slice(0, 3), [PROBE, `${FETCH}\nsudo sh php2shell-server-check.sh`, '']);
+ assert.match(r.actions[2].text, /Distribution list|배포판 목록/);
+ }
+ // RHEL from the probe alone: the server script first, then the update steps.
+ for (const r of both(F.rhelProbe)) {
+ assert.equal(r.actions[0].code, `${FETCH}\nsudo sh php2shell-server-check.sh`);
+ assert.ok(stepWith(r, `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh 'php*'`));
+ }
+ // The compromise check says how to run it.
+ for (const r of both(F.jammyFpm)) assert.equal(r.actions[r.actions.length - 1].code, 'sudo sh php2shell-server-check.sh --logs --webroot <webroot>');
+ assert.match(run(F.jammyFpm).actions.at(-1).text, /--webroot pointing at the site's directory \(for example \/var\/www\)/);
+});
+
+// ── 9. Patched ──────────────────────────────────────────────────────
+
+test('9. patched: other sites and pools, optional expose_php hardening for the SAPI, upload progress can stay off', () => {
+ const [en, ko] = both(F.patchedFpm);
+ for (const r of [en, ko]) {
+ assert.equal(r.actions.length, 3);
+ assert.equal(r.actions[1].code, "echo 'expose_php = Off' | sudo tee /etc/php/8.3/fpm/conf.d/99-hardening.ini\nsudo systemctl restart php8.3-fpm");
+ assert.doesNotMatch(all(r), /compromise|침해 지표|php2shell-mitigation/);
+ }
+ assert.match(en.actions[0].text, /vhost.*pool/);
+ assert.match(en.actions[1].text, /^Optional hardening/);
+ assert.match(en.actions[2].text, /session\.upload_progress\.enabled/);
+ for (const r of both(F.patchedDocker)) {
+ assert.equal(r.actions[1].code, 'RUN echo \'expose_php = Off\' > "$PHP_INI_DIR/conf.d/zz-hardening.ini"');
+ assert.match(r.actions[2].text, /keep it off|꺼 두세요/);
+ // In a container, the other containers and images matter, not vhosts.
+ assert.match(r.actions[0].text, /other container|다른 컨테이너/);
+ assert.doesNotMatch(r.actions[0].text, /vhost/);
+ }
+ // A hardening step only changes a setting: no "new build" sentence borrowed from the update path.
+ for (const r of both(F.patchedApache)) {
+ const expose = stepWith(r, /expose_php = Off/);
+ assert.equal(expose.code, "echo 'expose_php = Off' | sudo tee /etc/php/8.2/apache2/conf.d/99-hardening.ini\nsudo systemctl restart apache2");
+ assert.doesNotMatch(expose.text, /new build|새 빌드/);
+ }
+ // expose_php already off: no hardening step for it. A header shows it on, with no layout to name a file.
+ for (const r of both(F.patchedQuiet)) assert.doesNotMatch(all(r), /expose_php/);
+ for (const r of both(F.patchedHeader)) {
+ assert.match(r.actions[1].text, /expose_php = Off/);
+ assert.equal(r.actions[1].code, undefined);
+ }
+});
+
+// ── Every result ────────────────────────────────────────────────────
+
+const PLACEHOLDERS = new Set(['container', 'your-image', 'token', 'variant', 'current-options', 'php-fpm-service', 'apache-service', 'site', 'file', 'TOKEN', 'webroot']);
+
+test('every step: same commands in both languages, nothing destructive, only marked placeholders, plain text', () => {
+ for (const [name, text] of Object.entries(F)) {
+ const [en, ko] = both(text);
+ assert.deepEqual(codes(ko), codes(en), `${name}: the same commands in both languages`);
+ for (const r of [en, ko]) {
+ for (const a of r.actions) {
+ assert.deepEqual(Object.keys(a).sort(), a.code ? ['code', 'text'] : ['text'], name);
+ assert.equal(typeof a.text, 'string');
+ assert.doesNotMatch(a.text, /\{\w+\}|undefined|NaN|\bnull\b/, `${name}: ${a.text}`);
+ const code = a.code || '';
+ assert.doesNotMatch(code, /\brm\b|\bremove\b|\bpurge\b|\berase\b|--allowerasing|docker rmi?\b|prune|autoremove|\bkill\b/, `${name}: ${code}`);
+ assert.doesNotMatch(code.replaceAll('/dev/null', ''), /undefined|NaN|\bnull\b/, name);
+ for (const m of code.replace(/<<'EOF'/g, '').matchAll(/<([A-Za-z-]+)>/g)) assert.ok(PLACEHOLDERS.has(m[1]), `${name}: placeholder <${m[1]}>`);
+ // A reload only applies a configuration change; restarts that load a new build never reload.
+ if (/\breload\b/.test(code)) assert.match(a.text, /a reload is enough for a configuration change|설정 변경에는 reload로 충분합니다/, name);
+ }
+ for (const a of ko.actions) {
+ assert.match(a.text, /[가-힣]/, `${name}: Korean text`);
+ assert.doesNotMatch(a.text, /\uD310\uC815/, name);
+ }
+ for (const a of en.actions) assert.doesNotMatch(a.text, /recogniz|behavior|initializ|analyz|\bcolor/, `${name}: British spelling`);
+ }
+ // A mitigation never claims to fix the bug.
+ for (const a of [...en.actions, ...ko.actions].filter((x) => /interim mitigation|임시 완화/.test(x.text))) {
+ assert.match(a.text, /not replace|대신하지 않습니다/, `${name}: ${a.text}`);
+ }
+ }
+});
diff --git a/test/retest.test.mjs b/test/retest.test.mjs
@@ -0,0 +1,364 @@
+// Regression tests for the second round of tester reports. Each test replays the tester's input against the
+// real data files in site/data/. Numbers follow the report: tester 1 #1-#12, tester 2 #13-#30.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge } from '../site/lib/judge.js';
+import { parsePhpVersion } from '../site/lib/version.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+
+const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
+const server = (lines, closed = true) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${lines.join('\n')}\n${closed ? '-----END PHP2SHELL SERVERCHECK-----\n' : ''}`;
+const run = (text, options = {}) => judge(parseInput(text, { lang: options.lang }), upstream, distro, options);
+const reasons = (r) => r.reasons.join(' ');
+const notes = (r) => r.notes.join(' ');
+const fact = (r, label) => r.facts.find((f) => f.label === label)?.value;
+const actionText = (r) => r.actions.map((a) => `${a.text} ${a.code || ''}`).join(' ');
+const both = (text, options = {}) => ['en', 'ko'].map((lang) => run(text, { ...options, lang }));
+
+const NOBLE = { ID: 'ubuntu', VERSION_ID: '24.04', VERSION_CODENAME: 'noble' };
+const JAMMY = { ID: 'ubuntu', VERSION_ID: '22.04' };
+const ROCKY94 = { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.4' };
+const NOBLE_HOST = ['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3'];
+const ROCKY_RPMS = ['pkg|rpm|php-common|8.2.13-1.module+el9.4.0+20827+3f2b0b48|', 'pkg|rpm|php-fpm|8.2.13-1.module+el9.4.0+20827+3f2b0b48|',
+ 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|1', 'changelog|php-fpm|CVE-2026-109618|1', 'changelog|php-fpm|CVE-2026-109619|1'];
+
+// ── Tester 1 ────────────────────────────────────────────────────────
+
+test('#1 a container probe that could not read its own OS is not judged by host packages', () => {
+ const text = probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, open_basedir: false, os_release: null, dpkg_packages: null, config_path: '/etc/php/8.3/fpm', uname_release: '6.8.0-45-generic' })
+ + server([...NOBLE_HOST, 'proc|php-fpm8.3|4|ok']);
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(notes(r), /docker exec/);
+ }
+ assert.equal(parseInput(text).packages.length, 0, 'host packages are not used');
+});
+
+test('#2 a container probe with an unreadable package list is not tied to same-OS host output', () => {
+ const r = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, open_basedir: false, os_release: NOBLE, dpkg_packages: null, config_path: '/etc/php/8.3/fpm' })
+ + server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|4|ok']));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(notes(r), /nothing in its output shows that the server script ran in the same container/);
+ // Positive control: the server script run inside the container lists the same packages as the probe.
+ const pkgs = [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
+ const linked = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, os_release: NOBLE, dpkg_packages: pkgs, config_path: '/etc/php/8.3/fpm' })
+ + server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|4|ok']));
+ assert.equal(linked.verdict, 'patched');
+});
+
+test('#3 a changelog read at one version is never credited to another version of the package', () => {
+ const host = server(['os|ID|debian', 'os|VERSION_ID|11', 'env|root|1', 'pkg|dpkg|php7.4-fpm|7.4.33-1+deb11u9|php7.4',
+ 'changelog|php7.4-fpm|CVE-2026-109618|1', 'changelog|php7.4-fpm|CVE-2026-109619|1', 'proc|php-fpm7.4|3|ok']);
+ const u5 = { php_version: '7.4.33-1+deb11u5', sapi: 'fpm-fcgi', open_basedir: false, os_release: { ID: 'debian', VERSION_ID: '11', VERSION_CODENAME: 'bullseye' },
+ dpkg_packages: [{ name: 'php7.4-fpm', version: '7.4.33-1+deb11u5', source: 'php7.4' }], config_path: '/etc/php/7.4/fpm' };
+ for (const container of [true, false]) {
+ const r = run(probe({ ...u5, container }) + host);
+ assert.equal(r.verdict, 'unknown', `container ${container}`);
+ assert.doesNotMatch(r.reasons.filter((x) => !/^Server script output/.test(x)).join(' '), /treated as fixed/);
+ }
+ // The changelog is kept with the version it was read at.
+ const obs = parseInput(host);
+ assert.deepEqual(obs.packages[0].changelog, { 'CVE-2026-109618': 1, 'CVE-2026-109619': 1 });
+ assert.equal(obs.packages[0].version, '7.4.33-1+deb11u9');
+});
+
+test('#4 a non-root server script run that saw no PHP process is not Patched', () => {
+ const ubuntu = server(['os|ID|ubuntu', 'os|VERSION_ID|22.04', 'env|root|0', 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1']);
+ const alma = server(['os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.6', 'env|root|0', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|', 'pkg|rpm|php-fpm|8.0.30-8.el9_8.1|',
+ 'changelog|php-common|CVE-2026-109618|2', 'changelog|php-common|CVE-2026-109619|2', 'changelog|php-fpm|CVE-2026-109618|2', 'changelog|php-fpm|CVE-2026-109619|2']);
+ for (const text of [ubuntu, alma]) {
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(reasons(r), /hidepid/);
+ }
+ }
+ // With a probe whose PHP_VERSION cannot show a restart (Ubuntu 24.04), it is the same gap; it never says no PHP is
+ // running.
+ const pkgs = [{ name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }, { name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
+ const noble = server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|0', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3']);
+ const withProbe = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', os_release: NOBLE, dpkg_packages: pkgs, config_path: '/etc/php/8.3/fpm' }) + noble);
+ assert.equal(withProbe.verdict, 'unknown');
+ assert.doesNotMatch(reasons(withProbe), /No running PHP process/);
+ // As root, with no PHP process found, the probe's PHP still runs: no "none found" claim either.
+ const rootNone = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', os_release: NOBLE, dpkg_packages: pkgs, config_path: '/etc/php/8.3/fpm' })
+ + server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3']));
+ assert.equal(rootNone.verdict, 'patched');
+ assert.doesNotMatch(reasons(rootNone), /No running PHP process/);
+ assert.match(reasons(rootNone), /found no running process of this PHP/);
+});
+
+test('#5 a wrong manual distribution never overrides the os-release or the revision vendor', () => {
+ const deb9 = probe({ php_version: '7.0.33-0+deb9u12', sapi: 'apache2handler', os_release: { ID: 'debian', VERSION_ID: '9', VERSION_CODENAME: 'stretch' },
+ dpkg_packages: [{ name: 'libapache2-mod-php7.0', version: '7.0.33-0+deb9u12', source: 'php7.0' }], config_path: '/etc/php/7.0/apache2' });
+ for (const text of [deb9, 'X-Powered-By: PHP/7.0.33-0+deb9u12']) {
+ for (const [lang, label] of [['en', 'Distribution'], ['ko', '배포판']]) {
+ const r = run(text, { distro: 'ubuntu:16.04', lang });
+ assert.equal(r.verdict, 'unknown');
+ assert.doesNotMatch(reasons(r), /backported the fixes|백포트했습니다/);
+ assert.match(fact(r, label), /Debian 9/);
+ assert.match(notes(r), /Ubuntu 16\.04/);
+ }
+ }
+});
+
+test('#6 a revision Ubuntu never built is not credited with the Ubuntu backport', () => {
+ const ppa = run(server(['os|ID|ubuntu', 'os|VERSION_ID|24.04', 'env|root|1', 'pkg|dpkg|php8.3-fpm|8.3.6-1+ppa1|php8.3', 'proc|php-fpm8.3|2|ok']));
+ assert.equal(ppa.verdict, 'unknown');
+ assert.match(reasons(ppa), /not one that Ubuntu builds/);
+ const debStyle = run(probe({ php_version: '8.1.2-2', sapi: 'apache2handler', os_release: JAMMY,
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-2', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }));
+ assert.equal(debStyle.verdict, 'unknown');
+ // Positive control: Ubuntu's own revision is still Patched, and an older Ubuntu revision still Vulnerable.
+ assert.equal(run('X-Powered-By: PHP/8.1.2-1ubuntu2.28').verdict, 'patched');
+ assert.equal(run('X-Powered-By: PHP/8.1.2-1ubuntu2.20').verdict, 'vulnerable');
+});
+
+test('#7 an installed package downgraded below the running fixed build is not Patched', () => {
+ const [en, ko] = both(probe({ php_version: '8.1.2-1ubuntu2.28', sapi: 'apache2handler', os_release: JAMMY,
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }));
+ assert.equal(en.verdict, 'vulnerable');
+ assert.match(reasons(en), /next restart of the service loads the vulnerable build/);
+ assert.equal(ko.verdict, 'vulnerable');
+});
+
+test('#8 a bare 8.0.30 or 7.4.33 could be a RHEL package with the fix backported', () => {
+ for (const text of ['X-Powered-By: PHP/8.0.30', 'PHP 8.0.30 (cli) (built: Oct 20 2026 12:00:00) (NTS)', 'X-Powered-By: PHP/7.4.33',
+ probe({ php_version: '8.0.30', sapi: 'fpm-fcgi', os_release: null, open_basedir: false, uname_release: '', config_path: '/etc', dpkg_packages: null })]) {
+ const r = run(text);
+ assert.equal(r.verdict, 'unknown', text);
+ assert.match(reasons(r), /RHEL (8|9)/, text);
+ }
+ // Positive controls: a version no distribution ships with a backport is still judged by upstream.
+ assert.equal(run('X-Powered-By: PHP/8.0.29').verdict, 'vulnerable');
+ assert.equal(run('8.1.30').verdict, 'vulnerable');
+});
+
+test('#9 a manual release that contradicts the os-release is not used', () => {
+ const r = run(probe({ php_version: '8.5.4-0ubuntu1.5', sapi: 'apache2handler', os_release: { ID: 'ubuntu', VERSION_ID: '26.04', VERSION_CODENAME: 'resolute' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.5', version: '8.5.4-0ubuntu1.5', source: 'php8.5' }], config_path: '/etc/php/8.5/apache2' }), { distro: 'ubuntu:26.10' });
+ assert.equal(r.verdict, 'patched');
+ assert.match(notes(r), /Distribution list is set to Ubuntu 26\.10/);
+});
+
+test('#10 a manual release that contradicts the release the version string belongs to is not used', () => {
+ const r = run('X-Powered-By: PHP/8.5.9-0ubuntu2.1', { distro: 'ubuntu:26.04' });
+ assert.notEqual(r.verdict, 'vulnerable');
+ assert.match(notes(r), /Ubuntu 26\.10/);
+});
+
+test('#11 "Upstream build" is not applied to a PHP_VERSION with a distribution revision', () => {
+ const r = run(probe({ php_version: '8.1.2-1ubuntu2.28', sapi: 'apache2handler', os_release: JAMMY,
+ dpkg_packages: [{ name: 'libapache2-mod-php8.1', version: '8.1.2-1ubuntu2.28', source: 'php8.1' }], config_path: '/etc/php/8.1/apache2' }), { distro: 'upstream' });
+ assert.equal(r.verdict, 'patched');
+ assert.match(notes(r), /Upstream build/);
+ // Positive control: an upstream choice for a plain version is still used.
+ assert.equal(run('X-Powered-By: PHP/8.3.6', { distro: 'upstream' }).verdict, 'vulnerable');
+});
+
+test('#12 a Debian derivative with an unknown codename takes the release from the installed package', () => {
+ const r = run(probe({ php_version: '8.2.26', sapi: 'apache2handler', os_release: { ID: 'linuxmint', ID_LIKE: 'debian', VERSION_ID: '6', VERSION_CODENAME: 'faye', PRETTY_NAME: 'LMDE 6 (faye)' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1~deb12u1', source: 'php8.2' }], config_path: '/etc/php/8.2/apache2' }));
+ assert.equal(r.verdict, 'restart');
+ assert.match(fact(r, 'Distribution'), /Debian 12/);
+ // Even with no release at all, an installed package with the upstream fix shows a missed restart.
+ const sid = run(probe({ php_version: '8.2.26', sapi: 'apache2handler', os_release: { ID: 'debian', VERSION_CODENAME: 'sid' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.2', version: '8.2.35-1', source: 'php8.2' }], config_path: '/etc/php/8.2/apache2' }));
+ assert.equal(sid.verdict, 'restart');
+});
+
+// ── Tester 2 ────────────────────────────────────────────────────────
+
+test('#13 every X-Powered-By header in a curl -IL paste is judged', () => {
+ const chain = 'HTTP/1.1 301 Moved Permanently\nServer: nginx\nX-Powered-By: PHP/8.4.27\nLocation: https://www.example.com/\n\nHTTP/1.1 200 OK\nServer: nginx\nX-Powered-By: PHP/8.4.20\n';
+ for (const r of both(chain)) assert.equal(r.verdict, 'vulnerable');
+ assert.equal(run('HTTP/1.1 200 OK\nX-Powered-By: PHP/8.2.35\nX-Powered-By: PHP/8.1.20\n').verdict, 'vulnerable');
+ assert.match(notes(run(chain)), /2 separate outputs/);
+ // The same version on every hop is one output.
+ assert.equal(parseInput('HTTP/1.1 301\nX-Powered-By: PHP/8.2.35\n\nHTTP/1.1 200 OK\nX-Powered-By: PHP/8.2.35\n').parts, undefined);
+});
+
+test('#14 every -v line is judged, so an old php-fpm is not hidden by a new CLI', () => {
+ const r = run('$ php -v\nPHP 8.4.27 (cli) (built: Oct 21 2026 10:00:00) (NTS)\n$ php-fpm8.4 -v\nPHP 8.4.20 (fpm-fcgi) (built: Jun 10 2026 10:00:00)\n');
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(reasons(r), /8\.4\.20.*fpm-fcgi/);
+});
+
+test('#15 with the server script, other installed builds next to the probe PHP are judged too', () => {
+ const sury = '8.4.27-1+0~20261021.1+debian12~1.gbp1a2b3c';
+ const text = probe({ php_version: '8.4.27', sapi: 'apache2handler', config_path: '/etc/php/8.4/apache2', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' },
+ dpkg_packages: [{ name: 'libapache2-mod-php8.4', version: sury, source: 'php8.4' }, { name: 'php8.2-fpm', version: '8.2.29-1~deb12u1', source: 'php8.2' }] })
+ + server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', `pkg|dpkg|libapache2-mod-php8.4|${sury}|php8.4`,
+ 'pkg|dpkg|php8.2-fpm|8.2.29-1~deb12u1|php8.2', 'proc|apache2|5|ok', 'proc|php-fpm8.2|6|ok']);
+ for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
+ const r = run(text);
+ assert.match(reasons(r), /php8\.2-fpm 8\.2\.29-1~deb12u1/);
+ assert.match(actionText(r), /restart php8\.2-fpm/);
+ assert.equal(fact(r, 'Other installed PHP builds'), 'php8.2-fpm 8.2.29-1~deb12u1');
+});
+
+test('#16 a RHEL container probe is not judged by a same-OS host changelog', () => {
+ const r = run(probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', container: true, open_basedir: false, uname_release: '5.14.0-427.el9_4.x86_64', os_release: ROCKY94, dpkg_packages: null })
+ + server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS, 'proc|php-fpm|6|ok']));
+ assert.equal(r.verdict, 'unknown');
+ assert.match(notes(r), /docker exec/);
+ const ubuntu = run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', container: true, os_release: NOBLE, dpkg_packages: [], config_path: '/etc/php/8.3/fpm' })
+ + server([...NOBLE_HOST, 'proc|php-fpm8.3|4|ok']));
+ assert.equal(ubuntu.verdict, 'unknown');
+});
+
+test('#17 a probe and a server script that disagree about the packages are not Patched', () => {
+ const pkgs = [{ name: 'php8.3-fpm', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }, { name: 'php8.3-common', version: '8.3.6-0ubuntu0.24.04.13', source: 'php8.3' }];
+ const text = probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ...NOBLE, ID_LIKE: 'debian' }, dpkg_packages: pkgs })
+ + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble',
+ 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.5|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.5|php8.3', 'proc|php-fpm8.3|3|ok']);
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown');
+ assert.match(notes(run(text)), /different times/);
+});
+
+test('#18 hidepid without root: missing proc lines are not "no PHP running"', () => {
+ const rocky = server(['tool|php2shell-server-check|1.0.0', 'env|root|0', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS, 'webuser_proc|unknown']);
+ const noble = server(['tool|php2shell-server-check|1.0.0', 'env|root|0', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble',
+ 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3', 'webuser_proc|unknown']);
+ assert.equal(run(rocky).verdict, 'unknown');
+ assert.equal(run(noble).verdict, 'unknown');
+ assert.equal(run(probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: NOBLE, dpkg_packages: null }) + noble).verdict, 'unknown');
+ // Positive controls: as root, with the process checked, it is Patched. A CLI-only build has no service process
+ // that could keep an old build loaded, so a non-root run does not make it Undetermined.
+ assert.equal(run(server([...NOBLE_HOST, 'proc|php-fpm8.3|3|ok'])).verdict, 'patched');
+ assert.equal(run(server(['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', 'pkg|dpkg|php8.3-cli|8.3.6-0ubuntu0.24.04.13|php8.3', 'pkg|dpkg|php8.3-common|8.3.6-0ubuntu0.24.04.13|php8.3'])).verdict, 'patched');
+});
+
+test('#19 a server script block cut off before its END line is not Patched', () => {
+ const head = ['tool|php2shell-server-check|1.0.0', 'env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS];
+ for (const lines of [head, [...head, 'proc|php-fpm|6|sta']]) {
+ for (const r of both(server(lines, false))) {
+ assert.equal(r.verdict, 'unknown');
+ assert.ok(r.notes.some((n) => /END/.test(n)));
+ }
+ }
+ // A status other than ok or stale is a process that was not checked, even with the END line.
+ const bad = run(server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...ROCKY_RPMS, 'proc|php-fpm|6|']));
+ assert.equal(bad.verdict, 'unknown');
+ // Positive control: the same block with its END line and a checked process is Patched.
+ assert.equal(run(server([...head, 'proc|php-fpm|6|ok'])).verdict, 'patched');
+});
+
+test('#20 a php-fpm or apache2 that no installed Debian package can run is not checked, so not Patched', () => {
+ const docker = run(server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy',
+ 'pkg|dpkg|php8.1-cli|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm|12|ok']));
+ assert.equal(docker.verdict, 'unknown');
+ assert.match(reasons(docker), /belong to none of the PHP builds/);
+ const apache = run(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12',
+ 'pkg|dpkg|php8.2-cli|8.2.35-1~deb12u1|php8.2', 'pkg|dpkg|php8.2-common|8.2.35-1~deb12u1|php8.2', 'proc|apache2|9|ok']));
+ assert.equal(apache.verdict, 'unknown');
+});
+
+test('#21 a running php-fpm8.3 with no php8.3 package is not checked, so not Patched', () => {
+ const base = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|5|ok'];
+ assert.equal(run(server([...base, 'proc|php-fpm8.3|4|ok'])).verdict, 'unknown');
+ const stale = run(server([...base, 'proc|php-fpm8.3|4|stale']));
+ assert.equal(stale.verdict, 'restart');
+ assert.match(reasons(stale), /php-fpm8\.3/);
+ assert.doesNotMatch(actionText(stale), /restart php8\.1-fpm/, 'the restart command names no unrelated service');
+ // Positive control: the same server without the extra process is Patched.
+ assert.equal(run(server(base)).verdict, 'patched');
+});
+
+test('#22 several outputs in one paste are all judged', () => {
+ const deb12 = { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' };
+ const pools = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: deb12, dpkg_packages: [] })
+ + probe({ php_version: '8.1.33', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', os_release: deb12, dpkg_packages: [] });
+ const hosts = server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok'])
+ + server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.29-1~deb12u1|php8.2', 'proc|php-fpm8.2|3|ok']);
+ const probeHeader = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: deb12, dpkg_packages: [] }) + 'HTTP/1.1 200 OK\nX-Powered-By: PHP/8.4.20\n';
+ const headerCli = 'X-Powered-By: PHP/8.4.27\n\n$ php -v\nPHP 8.4.20 (cli) (built: Oct 20 2026 12:00:00) (NTS)\n';
+ for (const text of [pools, hosts, probeHeader, headerCli]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
+ }
+ // The same output pasted twice is one output, and the server script's own indented text is never read as a header.
+ const one = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: deb12, dpkg_packages: [] });
+ assert.equal(parseInput(one + one).parts, undefined);
+ assert.equal(run(one + one).verdict, 'patched');
+ assert.equal(parseInput(server(['env|root|1']) + server(['env|root|1'])).parts, undefined);
+ assert.equal(parseInput(`${server(['env|root|1', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2'])} X-Powered-By: PHP/8.2.20\n`).parts, undefined);
+});
+
+test('#23 a container probe with no OS of its own is not judged by an Ubuntu host', () => {
+ const r = run(probe({ php_version: '8.3.6', sapi: 'frankenphp', config_path: '/etc/frankenphp', container: true, open_basedir: false, os_release: null, dpkg_packages: null })
+ + server([...NOBLE_HOST, 'proc|php-fpm8.3|3|ok']));
+ assert.notEqual(r.verdict, 'patched');
+ assert.doesNotMatch(r.reasons.filter((x) => !/^Server script output/.test(x)).join(' '), /backported the fixes/);
+});
+
+test('#24 php-fpm -v shows the binary on disk, so Patched carries the restart caveat', () => {
+ for (const text of ['PHP 8.1.2-1ubuntu2.28 (fpm-fcgi) (built: Oct 20 2026 12:00:00)\nCopyright (c) The PHP Group\n', 'PHP 8.2.35 (fpm-fcgi) (built: Oct 20 2026 12:00:00)\n']) {
+ const [en, ko] = both(text);
+ assert.equal(en.verdict, 'patched');
+ assert.match(reasons(en), /binary installed on disk/);
+ assert.match(reasons(ko), /디스크에 설치된 바이너리/);
+ }
+});
+
+test('#25 php -v with CLI in capitals or extra spaces still gets the CLI note', () => {
+ for (const text of ['PHP 8.2.35 (CLI) (built: Oct 21 2026 10:00:00) (NTS)\n', 'PHP 8.2.35 (cli) (built: Oct 21 2026 10:00:00) (NTS)\n']) {
+ const r = run(text);
+ assert.equal(r.verdict, 'patched');
+ assert.match(notes(r), /command-line PHP/, text);
+ assert.equal(r.exposure.level, 'info', text);
+ }
+});
+
+test('#26 Red Hat\'s "Not affected" for plain php covers only the branch Red Hat ships', () => {
+ const r = run(server(['env|root|1', 'os|ID|centos', 'os|ID_LIKE|rhel fedora', 'os|VERSION_ID|7', 'pkg|rpm|php-common|8.1.20-1.el7|', 'pkg|rpm|php-fpm|8.1.20-1.el7|',
+ 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|0', 'changelog|php-fpm|CVE-2026-109618|1', 'changelog|php-fpm|CVE-2026-109619|0', 'proc|php-fpm|3|ok']));
+ assert.equal(r.verdict, 'unknown');
+ assert.doesNotMatch(reasons(r), /not affected/);
+});
+
+test('#27 Alpine _rc and _pre builds are pre-releases', () => {
+ assert.equal(parsePhpVersion('8.5.12_rc1-r0').pre, 'rc1');
+ assert.equal(parsePhpVersion('8.5.0_pre1').pre, 'pre1');
+ assert.equal(parsePhpVersion('8.5.12_p1-r0').pre, '');
+ assert.equal(run(server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.0', 'pkg|apk|php85|8.5.12_rc1-r0|', 'pkg|apk|php85-fpm|8.5.12_rc1-r0|', 'proc|php-fpm85|2|ok'])).verdict, 'unknown');
+ assert.equal(run('8.2.35_rc1').verdict, 'unknown');
+ // Positive control: the release itself on Alpine is Patched, and php-fpm85 belongs to the php85 build.
+ assert.equal(run(server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.0', 'pkg|apk|php85|8.5.12-r0|', 'pkg|apk|php85-fpm|8.5.12-r0|', 'proc|php-fpm85|2|ok'])).verdict, 'patched');
+});
+
+test('#28 dropped host output does not turn a RHEL container probe into an upstream build', () => {
+ const container = { php_version: '8.0.30', sapi: 'fpm-fcgi', config_path: '/etc', container: true, open_basedir: false, os_release: { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.6' }, dpkg_packages: null };
+ const alone = run(probe(container));
+ assert.equal(alone.verdict, 'unknown');
+ const ubuntuHost = run(probe(container) + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble', 'pkg|dpkg|php8.3-fpm|8.3.6-0ubuntu0.24.04.13|php8.3', 'proc|php-fpm8.3|3|ok']));
+ assert.equal(ubuntuHost.verdict, 'unknown');
+ assert.doesNotMatch(notes(ubuntuHost), /found no PHP installed with rpm/);
+ const olderHost = run(probe(container) + server(['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', 'pkg|rpm|php-common|8.0.30-8.el9_8.1|',
+ 'changelog|php-common|CVE-2026-109618|1', 'changelog|php-common|CVE-2026-109619|1']));
+ assert.equal(olderHost.verdict, 'unknown');
+});
+
+test('#29 a bare 8.0.30 header, with or without a RHEL Server header, is Undetermined', () => {
+ for (const text of ['X-Powered-By: PHP/8.0.30\n', 'PHP 8.0.30 (cli) (built: Aug 3 2026 17:13:08) ( NTS gcc x86_64 )', 'HTTP/1.1 200 OK\nServer: Apache/2.4.57 (Rocky Linux)\nX-Powered-By: PHP/8.0.30\n', 'X-Powered-By: PHP/7.4.33\n']) {
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown', text);
+ }
+ const rocky = run('HTTP/1.1 200 OK\nServer: Apache/2.4.57 (Rocky Linux)\nX-Powered-By: PHP/8.2.13\n');
+ assert.equal(rocky.verdict, 'unknown', 'the Server header names a RHEL rebuild');
+ assert.match(fact(rocky, 'Distribution'), /Server header/);
+});
+
+test('#30 a Remi collection of an EOL branch is judged by its changelog when it records both fixes', () => {
+ const block = server(['env|root|1', 'os|ID|centos', 'os|VERSION_ID|7', 'pkg|rpm|php74-php-common|7.4.33-18.el7.remi|', 'pkg|rpm|php74-php-fpm|7.4.33-18.el7.remi|',
+ 'changelog|php74-php-common|CVE-2026-109618|1', 'changelog|php74-php-common|CVE-2026-109619|1', 'changelog|php74-php-fpm|CVE-2026-109618|1', 'changelog|php74-php-fpm|CVE-2026-109619|1', 'proc|php-fpm|4|ok']);
+ for (const r of both(block)) assert.equal(r.verdict, 'patched');
+ assert.match(reasons(run(block)), /Remi/);
+ const withProbe = run(probe({ php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php74', os_release: { ID: 'centos', VERSION_ID: '7' }, dpkg_packages: null }) + block);
+ assert.equal(withProbe.verdict, 'patched');
+ // Positive control: without the changelog entries a Remi EOL build is still judged by upstream.
+ const none = run(server(['env|root|1', 'os|ID|centos', 'os|VERSION_ID|7', 'pkg|rpm|php74-php-common|7.4.33-18.el7.remi|',
+ 'changelog|php74-php-common|CVE-2026-109618|1', 'changelog|php74-php-common|CVE-2026-109619|0']));
+ assert.equal(none.verdict, 'vulnerable');
+});
diff --git a/test/round3.test.mjs b/test/round3.test.mjs
@@ -0,0 +1,582 @@
+// Regression tests for the third round of tester reports, and for the engine notes of the same round. Each test
+// replays the tester's input (real probe and server script outputs where the tester had them) against the real
+// data files in site/data/. Groups: D (Debian and Ubuntu), R (RHEL family and Docker), C (crafted inputs),
+// X (rendering and robustness), N (notes from the server script and probe work).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { parseInput } from '../site/lib/parse.js';
+import { judge, sapiLabel } from '../site/lib/judge.js';
+
+const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
+const distro = JSON.parse(readFileSync(new URL('../site/data/distro.json', import.meta.url), 'utf8'));
+
+// A probe block as the probe prints it (the fields in the probe's own order).
+const probe = (fields) => `-----BEGIN PHP2SHELL SELFCHECK-----\n${JSON.stringify({ tool: 'php2shell-selfcheck', tool_version: '1.0.0', schema: 1, ...fields })}\n-----END PHP2SHELL SELFCHECK-----\n`;
+const serverLines = (lines) => ['tool|php2shell-server-check|1.0.0', ...lines].join('\n');
+const server = (lines) => `-----BEGIN PHP2SHELL SERVERCHECK-----\n${serverLines(lines)}\n-----END PHP2SHELL SERVERCHECK-----\n`;
+const run = (text, options = {}) => judge(parseInput(text, { lang: options.lang }), upstream, distro, options);
+const both = (text, options = {}) => ['en', 'ko'].map((lang) => run(text, { ...options, lang }));
+const reasons = (r) => r.reasons.join(' ');
+const notes = (r) => r.notes.join(' ');
+const actionText = (r) => r.actions.map((a) => `${a.text}\n${a.code || ''}`).join('\n');
+const all = (r) => [...r.reasons, ...r.notes, actionText(r)].join('\n');
+const codes = (r) => r.actions.map((a) => a.code || '').join('\n');
+const CVES = ['CVE-2026-109618', 'CVE-2026-109619'];
+const logged = (pkgs, n) => pkgs.flatMap((p) => CVES.map((c) => `changelog|${p}|${c}|${typeof n === 'number' ? n : n[c]}`));
+const verdicts = (text, options) => both(text, options).map((r) => r.verdict);
+
+// ── Real outputs from the tester's containers ───────────────────────
+
+const PROBE_BASE = { open_basedir: false, container: false, self_deleted: true, extensions: { session: true, opcache: true } };
+const JAMMY_OS = { PRETTY_NAME: 'Ubuntu 22.04.5 LTS', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy', ID: 'ubuntu', ID_LIKE: 'debian' };
+const NOBLE_OS = { PRETTY_NAME: 'Ubuntu 24.04.5 LTS', VERSION_ID: '24.04', VERSION_CODENAME: 'noble', ID: 'ubuntu', ID_LIKE: 'debian' };
+const BOOKWORM_OS = { PRETTY_NAME: 'Debian GNU/Linux 12 (bookworm)', VERSION_ID: '12', VERSION_CODENAME: 'bookworm', ID: 'debian' };
+const deb = (names, version, source) => names.map((name) => ({ name, version, source }));
+const JAMMY_LINES = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'os|PRETTY_NAME|Ubuntu 22.04.5 LTS'];
+const NOBLE_LINES = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|24.04', 'os|VERSION_CODENAME|noble', 'os|PRETTY_NAME|Ubuntu 24.04.5 LTS'];
+const BOOKWORM_LINES = ['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', 'os|PRETTY_NAME|Debian GNU/Linux 12 (bookworm)'];
+const LOGS = ['webuser_proc|0', 'log|oom|0', 'log|crash|unknown', 'log|bigsession|0'];
+
+const JAMMY_FIXED = '8.1.2-1ubuntu2.28';
+const NOBLE_FIXED = '8.3.6-0ubuntu0.24.04.13';
+const MOD81 = ['libapache2-mod-php8.1', 'php8.1-cli', 'php8.1-common'];
+const NOBLE3 = ['php8.3-cli', 'php8.3-common', 'php8.3-fpm'];
+const DEB82 = ['php8.2-cli', 'php8.2-common', 'php8.2-fpm'];
+
+// Ubuntu 22.04, Apache mod_php, updated from the jammy GA build with no restart (p2s-pa-u22-stale-probe.txt).
+const F1 = probe({ ...PROBE_BASE, php_version: '8.1.2', php_extra_version: '', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', server_software: 'Apache/2.4.52 (Ubuntu)', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, JAMMY_FIXED, 'php8.1') });
+// Debian 12 php8.2-fpm running 8.2.35, downgraded on disk to 8.2.32 with no restart (p2s-pa-d12-downgrade-probe.txt).
+const F2 = probe({ ...PROBE_BASE, php_version: '8.2.35', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', server_software: 'nginx/1.22.1', os_release: BOOKWORM_OS, dpkg_packages: deb(DEB82, '8.2.32-1~deb12u1', 'php8.2') });
+// Ubuntu 24.04 php8.3-fpm updated with no restart: the probe, and the server script output copied without its BEGIN line.
+const F3_PROBE = probe({ ...PROBE_BASE, php_version: '8.3.6', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', server_software: 'nginx/1.24.0', os_release: NOBLE_OS, dpkg_packages: deb(NOBLE3, NOBLE_FIXED, 'php8.3') });
+const F3_LINES = [...NOBLE_LINES, ...NOBLE3.map((p) => `pkg|dpkg|${p}|${NOBLE_FIXED}|php8.3`), ...logged(NOBLE3, { 'CVE-2026-109618': 2, 'CVE-2026-109619': 1 }), 'proc|php-fpm8.3|1|stale', ...LOGS];
+const F3_HEADLESS = `${serverLines(F3_LINES)}\n-----END PHP2SHELL SERVERCHECK-----\n`;
+// Ubuntu 24.04 interrupted upgrade: php8.3-fpm half-configured at 8.3.6-0maysync1, so neither collector lists it.
+const F6_PROBE = probe({ ...PROBE_BASE, extensions: { session: true, opcache: false }, php_version: '8.3.6', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', server_software: 'nginx/1.24.0', os_release: NOBLE_OS, dpkg_packages: deb(['php8.3-cli', 'php8.3-common'], NOBLE_FIXED, 'php8.3') });
+const F6_ROOT = server([...NOBLE_LINES, `pkg|dpkg|php8.3-cli|${NOBLE_FIXED}|php8.3`, `pkg|dpkg|php8.3-common|${NOBLE_FIXED}|php8.3`, ...logged(['php8.3-cli', 'php8.3-common'], { 'CVE-2026-109618': 2, 'CVE-2026-109619': 1 }), 'proc|php-fpm8.3|1|ok', ...LOGS]);
+// Ubuntu 22.04: a probe while .28 ran, and a server script output after a downgrade to the GA build with no restart.
+const F7_PROBE = probe({ ...PROBE_BASE, php_version: JAMMY_FIXED, php_extra_version: '-1ubuntu2.28', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', server_software: 'Apache/2.4.52 (Ubuntu)', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, JAMMY_FIXED, 'php8.1') });
+const F7_SERVER = server([...JAMMY_LINES, ...MOD81.map((p) => `pkg|dpkg|${p}|8.1.2-1ubuntu2|php8.1`), ...logged(MOD81, 0), 'proc|apache2|1|stale', ...LOGS]);
+// deb.sury.org PHP 8.1 (end of life upstream) whose 8.1.34-9 changelog records the backported fixes.
+const SURY_DEB = '8.1.34-9+0~20261021.82+debian12~1.gbp4e430a';
+const SURY_PPA = '8.1.34-9+ubuntu22.04.1+deb.sury.org+1';
+const FPM81 = ['php8.1-cli', 'php8.1-common', 'php8.1-fpm'];
+const F4_DEB_PROBE = probe({ ...PROBE_BASE, php_version: '8.1.34', php_extra_version: '', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', server_software: 'nginx/1.22.1', os_release: BOOKWORM_OS, dpkg_packages: deb(FPM81, SURY_DEB, 'php8.1') });
+const F4_DEB = F4_DEB_PROBE + server([...BOOKWORM_LINES, ...FPM81.map((p) => `pkg|dpkg|${p}|${SURY_DEB}|php8.1`), ...logged(FPM81, 1), 'proc|php-fpm8.1|1|ok', 'webuser_proc|0']);
+const F4_PPA_PROBE = probe({ ...PROBE_BASE, php_version: '8.1.34', php_extra_version: '', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', server_software: 'Apache/2.4.52 (Ubuntu)', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, SURY_PPA, 'php8.1') });
+const F4_PPA = F4_PPA_PROBE + server(['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', ...MOD81.map((p) => `pkg|dpkg|${p}|${SURY_PPA}|php8.1`), ...logged(MOD81, 1), 'proc|apache2|1|ok', 'webuser_proc|0']);
+// Debian 12 downgrade: the probe (8.2.35 running, 8.2.32 on disk) and the root server script of the same moment.
+const F5_SERVER = server([...BOOKWORM_LINES, ...DEB82.map((p) => `pkg|dpkg|${p}|8.2.32-1~deb12u1|php8.2`), ...logged(DEB82, 0), 'proc|php-fpm8.2|1|stale', ...LOGS]);
+const F5 = F2 + F5_SERVER;
+
+// ── D: Debian and Ubuntu ────────────────────────────────────────────
+
+test('D1 Ubuntu 22.04: a plain 8.1.2 next to the updated .28 package is the GA build loaded before the update', () => {
+ const header = 'HTTP/1.1 200 OK\nDate: Fri, 23 Oct 2026 06:00:00 GMT\nServer: Apache/2.4.52 (Ubuntu)\nContent-Type: text/html; charset=UTF-8\n';
+ for (const text of [F1, F1 + F1, `${header}\n${F1}`]) {
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'restart', text.slice(0, 40));
+ assert.doesNotMatch(reasons(r), /is the same for the old and the fixed build|이전 빌드와 수정된 빌드가 같으므로/);
+ }
+ assert.match(reasons(run(text)), /original build of the release reports the plain version/);
+ assert.ok(run(text).actions.some((a) => a.code === 'sudo systemctl restart apache2'));
+ }
+ // Positive controls: Ubuntu 24.04 really reports a plain 8.3.6 after the fix, and the GA package itself is Vulnerable.
+ assert.equal(run(F3_PROBE).verdict, 'patched');
+ assert.equal(run(probe({ ...PROBE_BASE, php_version: '8.1.2', sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, '8.1.2-1ubuntu2', 'php8.1') })).verdict, 'vulnerable');
+});
+
+test('D2 Debian 12: PHP-FPM still running 8.2.35 after a downgrade to 8.2.32 is Vulnerable, with no reload before the reinstall', () => {
+ for (const text of [F2, F2 + F2]) {
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(reasons(r), /8\.2\.32-1~deb12u1/);
+ assert.doesNotMatch(codes(r), /reload/, 'no reload while the vulnerable build is on disk');
+ }
+ }
+ const r = run(F2);
+ assert.match(reasons(r), /next restart of the service loads the vulnerable build/);
+ assert.equal(r.actions[0].code, 'sudo apt-get update\nsudo apt-get install --only-upgrade php8.2-cli php8.2-common php8.2-fpm');
+ assert.match(r.actions[0].text, /install php8\.2 8\.2\.35-1~deb12u1 or later again before the service restarts/);
+ // Positive controls: the fixed package on disk is Patched, and an older package that has the fix gets a note only.
+ assert.equal(run(probe({ ...PROBE_BASE, php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: BOOKWORM_OS, dpkg_packages: deb(DEB82, '8.2.35-1~deb12u1', 'php8.2') })).verdict, 'patched');
+ const sury = run(probe({ ...PROBE_BASE, php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: NOBLE_OS, dpkg_packages: deb(NOBLE3, NOBLE_FIXED, 'php8.3') }));
+ assert.equal(sury.verdict, 'patched');
+ assert.match(notes(sury), /older than the running PHP .* but it has the fix too/);
+});
+
+test('D3 a server script output copied without its BEGIN line is still read', () => {
+ for (const text of [F3_PROBE + F3_HEADLESS, F3_HEADLESS + F3_PROBE, F3_HEADLESS]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'restart', text.slice(0, 40));
+ assert.match(notes(run(text)), /no BEGIN line/);
+ }
+ // Positive control: the same text with its BEGIN line.
+ assert.equal(run(F3_PROBE + server(F3_LINES)).verdict, 'restart');
+ // Cut further down (the tool, env and os lines lost): never Patched, and the step asks for the whole output.
+ const cut = `${[`pkg|dpkg|php8.3-fpm|${NOBLE_FIXED}|php8.3`, ...logged(['php8.3-fpm'], 1), 'proc|php-fpm8.3|3|ok'].join('\n')}\n-----END PHP2SHELL SERVERCHECK-----\n`;
+ for (const r of both(cut)) assert.equal(r.verdict, 'unknown');
+ assert.match(reasons(run(cut)), /no BEGIN line and starts below its first line/);
+ // A paste with no marker at all is unchanged.
+ assert.equal(parseInput('8.2.35').phpVersion, '8.2.35');
+});
+
+test('D4 a running php-fpm8.3 whose php8.3-fpm package is not installed is not vouched for by php8.3-common', () => {
+ for (const text of [F6_PROBE, F6_ROOT, F6_PROBE + F6_ROOT]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown', text.slice(0, 40));
+ }
+ assert.match(reasons(run(F6_PROBE)), /php8\.3-fpm/);
+ assert.match(reasons(run(F6_ROOT)), /belong to none of the PHP builds/);
+ const steps = run(F6_PROBE);
+ assert.ok(steps.actions[0].code.includes('sudo dpkg --audit'));
+ // Positive control: with php8.3-fpm installed and its process checked, the backport is Patched.
+ const whole = server([...NOBLE_LINES, ...NOBLE3.map((p) => `pkg|dpkg|${p}|${NOBLE_FIXED}|php8.3`), ...logged(NOBLE3, 1), 'proc|php-fpm8.3|1|ok']);
+ assert.equal(run(whole).verdict, 'patched');
+ assert.equal(run(F3_PROBE + whole).verdict, 'patched');
+});
+
+test('D5 a probe and a server script that disagree are not Patched, even when the probe shows a fixed revision', () => {
+ for (const r of both(F7_PROBE + F7_SERVER)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.ok(r.actions.some((a) => /together|함께/.test(a.text)));
+ }
+ const debProbe = probe({ ...PROBE_BASE, php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: BOOKWORM_OS, dpkg_packages: deb(DEB82, '8.2.35-1~deb12u1', 'php8.2') });
+ assert.equal(run(debProbe + F5_SERVER).verdict, 'unknown');
+ // The server script output alone is Vulnerable, and the probe alone is Patched.
+ assert.equal(run(F7_SERVER).verdict, 'vulnerable');
+ assert.equal(run(F7_PROBE).verdict, 'patched');
+});
+
+test('D6 deb.sury.org PHP 8.1 whose changelog records both backported fixes is Patched', () => {
+ for (const text of [F4_DEB, F4_PPA]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'patched', text.slice(0, 60));
+ assert.match(reasons(run(text)), /backported by deb\.sury\.org/);
+ }
+ // Without the changelog the build is Undetermined, never Vulnerable, and the steps never replace a package with itself.
+ for (const text of [F4_DEB_PROBE, F4_PPA_PROBE]) {
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.doesNotMatch(codes(r), /make install|php-fpm -i|apt-get install libapache2-mod-php8\.1\b/);
+ }
+ }
+ const ppa = run(F4_PPA_PROBE);
+ assert.ok(ppa.actions.some((a) => /--allow-downgrades/.test(a.code || '') && /print \$1"=8\.1\.2-1ubuntu2\.28"/.test(a.code)));
+ assert.doesNotMatch(actionText(ppa), /turn off the Apache PHP module/);
+ // A changelog that was read without the fixes, or a Debian build made before the fixed releases, is Vulnerable.
+ const noFix = F4_DEB_PROBE + server([...BOOKWORM_LINES, ...FPM81.map((p) => `pkg|dpkg|${p}|${SURY_DEB}|php8.1`), ...logged(FPM81, 0), 'proc|php-fpm8.1|1|ok']);
+ assert.equal(run(noFix).verdict, 'vulnerable');
+ const old = '8.1.31-1+0~20250101.1+debian12~1.gbp1234567';
+ const before = run(probe({ ...PROBE_BASE, php_version: '8.1.31', sapi: 'fpm-fcgi', config_path: '/etc/php/8.1/fpm', os_release: BOOKWORM_OS, dpkg_packages: deb(FPM81, old, 'php8.1') }));
+ assert.equal(before.verdict, 'vulnerable');
+ assert.match(reasons(before), /before the fixed releases/);
+});
+
+test('D7 after a downgrade, no step reloads or restarts PHP before the fixed package is back', () => {
+ for (const r of both(F5)) {
+ assert.equal(r.verdict, 'vulnerable');
+ assert.doesNotMatch(notes(r), /restart the service when convenient|적당한 때에 서비스를 재시작/);
+ assert.doesNotMatch(codes(r), /reload/);
+ }
+ assert.match(notes(run(F5)), /Do not restart or reload them before the fixed package is installed again/);
+ // The server script output alone: stale processes over a build without the fix are not "from before the update".
+ const noble = server([...NOBLE_LINES, ...NOBLE3.map((p) => `pkg|dpkg|${p}|8.3.6-0maysync1|php8.3`), ...logged(NOBLE3, 0), 'proc|php-fpm8.3|1|stale']);
+ for (const text of [noble, F5_SERVER]) {
+ const r = run(text);
+ assert.equal(r.verdict, 'vulnerable');
+ assert.match(reasons(r), /by an update or a downgrade/);
+ assert.doesNotMatch(reasons(r), /from before the update\./);
+ const mitigation = r.actions.find((a) => /reload/.test(a.code || ''));
+ assert.ok(mitigation, 'the interim mitigation is still offered');
+ assert.match(mitigation.text, /if that change was a downgrade, install the fixed package first/);
+ }
+ // Apache: logrotate's graceful restart does not reload mod_php, so the reason no longer says it does.
+ const apache = run(probe({ ...PROBE_BASE, php_version: JAMMY_FIXED, sapi: 'apache2handler', config_path: '/etc/php/8.1/apache2', os_release: JAMMY_OS, dpkg_packages: deb(MOD81, '8.1.2-1ubuntu2.20', 'php8.1') }));
+ assert.equal(apache.verdict, 'vulnerable');
+ assert.doesNotMatch(reasons(apache), /log rotation|reloads Apache/);
+});
+
+// ── R: RHEL family and Docker ───────────────────────────────────────
+
+const ALMA9_LINES = ['env|root|1', 'os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.8', 'os|PRETTY_NAME|AlmaLinux 9.8 (Olive Jaguar)'];
+const ALMA10_LINES = ['env|root|1', 'os|ID|almalinux', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|10.2', 'os|PRETTY_NAME|AlmaLinux 10.2 (Lavender Lion)'];
+const AL2023_LINES = ['env|root|1', 'os|ID|amzn', 'os|ID_LIKE|fedora', 'os|VERSION_ID|2023', 'os|PRETTY_NAME|Amazon Linux 2023.12.20261021'];
+const INI = { memory_limit: '128M', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1' };
+const RHEL_PROBE = { uname_release: '6.18.54.1-microsoft-standard-WSL2', open_basedir: false, dpkg_packages: null, extensions: { session: true, opcache: false }, ini: INI, self_deleted: true };
+const rpm = (names, version) => names.map((n) => `pkg|rpm|${n}|${version}|`);
+
+test('R1 server script output from another system is judged on its own, not dropped', () => {
+ const docker = probe({ ...RHEL_PROBE, php_version: '8.4.27', sapi: 'apache2handler', config_path: '/usr/local/etc/php', server_software: 'Apache/2.4.69 (Debian)', container: true, os_release: { PRETTY_NAME: 'Debian GNU/Linux 13 (trixie)', ID: 'debian', VERSION_ID: '13', VERSION_CODENAME: 'trixie' }, dpkg_packages: [] })
+ + server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.20.10', 'os|PRETTY_NAME|Alpine Linux v3.20', ...rpm([], ''), 'pkg|apk|php83|8.3.15-r0|', 'pkg|apk|php83-common|8.3.15-r0|', 'pkg|apk|php83-fpm|8.3.15-r0|', 'proc|php-fpm83|1|ok', 'webuser_proc|0']);
+ const remi = probe({ ...RHEL_PROBE, php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc', server_software: 'Apache/2.4.62 (AlmaLinux)', container: true, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.8', PRETTY_NAME: 'AlmaLinux 9.8 (Olive Jaguar)' } })
+ + server([...ALMA9_LINES, ...rpm(['php74-php-common', 'php74-php-cli', 'php74-php-fpm'], '7.4.33-29.el9.remi'), ...logged(['php74-php-common', 'php74-php-cli', 'php74-php-fpm'], 0), 'proc|php-fpm|1|ok', 'webuser_proc|0']);
+ for (const text of [docker, remi]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
+ assert.match(notes(run(text)), /2 separate outputs/);
+ }
+ assert.match(reasons(run(docker)), /PHP 8\.3\.15/);
+});
+
+test('R2 a stale php-fpm next to a current one of the same name is another PHP, not the probe\'s', () => {
+ const text = probe({ ...RHEL_PROBE, php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc', server_software: 'Apache/2.4.63 (AlmaLinux)', container: false, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '10.2', PRETTY_NAME: 'AlmaLinux 10.2 (Lavender Lion)' } })
+ + server([...ALMA10_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.3.36-1.el10_2'), ...logged(['php-common', 'php-fpm', 'php-cli'], 0), 'proc|php-fpm|1|ok', 'proc|php-fpm|1|stale', 'webuser_proc|0']);
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'restart');
+ assert.ok(r.actions.some((a) => a.code === 'ps -o pid,user,cgroup,args -C php-fpm'));
+ }
+ // Positive control: stale processes alone, of the probe's SAPI, are the probe's own PHP waiting for a restart.
+ const self = run(probe({ ...RHEL_PROBE, php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc', container: false, os_release: { ID: 'almalinux', VERSION_ID: '10.2' } })
+ + server([...ALMA10_LINES, ...rpm(['php-common', 'php-fpm'], '8.3.36-1.el10_2'), 'proc|php-fpm|3|stale']));
+ assert.equal(self.verdict, 'patched');
+});
+
+test('R3 Alpine Apache with mod_php83.so, which the server script cannot see, is not "nothing running"', () => {
+ const lines = ['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.22.7', 'os|PRETTY_NAME|Alpine Linux v3.22', 'pkg|apk|php83|8.3.36-r0|', 'pkg|apk|php83-apache2|8.3.36-r0|', 'pkg|apk|php83-common|8.3.36-r0|'];
+ for (const r of both(server([...lines, 'webuser_proc|0']))) {
+ assert.equal(r.verdict, 'unknown');
+ assert.ok(r.actions.some((a) => a.code === "ps -o pid,args | grep '[h]ttpd'"));
+ assert.ok(r.actions.some((a) => a.code === 'rc-service apache2 restart'));
+ }
+ // Positive control: once the script reports the Apache processes, they are checked like any other.
+ assert.equal(run(server([...lines, 'proc|httpd|4|ok'])).verdict, 'patched');
+ assert.equal(run(server([...lines, 'proc|httpd|4|stale'])).verdict, 'restart');
+});
+
+test('R4 an open_basedir probe that cannot tell whether it runs in a container is not tied to a server script output', () => {
+ const text = probe({ ...RHEL_PROBE, php_version: '8.0.30', php_extra_version: '', sapi: 'fpm-fcgi', php_os: 'Linux', config_path: '/etc', server_software: 'Apache/2.4.62 (AlmaLinux)', open_basedir: true, container: null, os_release: null })
+ + server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.0.30-8.el9_8.1'), ...logged(['php-common', 'php-fpm', 'php-cli'], 1), 'proc|php-fpm|1|ok', 'webuser_proc|0']);
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown');
+ assert.match(notes(run(text)), /could not see whether it runs in a container|open_basedir kept the PHP probe from seeing/);
+ assert.equal(parseInput(text).parts[0].packages.length, 0, 'the server script packages are not credited to the probe');
+ // Positive control: a probe that knows it is not in a container is still combined.
+ const host = probe({ ...RHEL_PROBE, php_version: '8.0.30', sapi: 'fpm-fcgi', config_path: '/etc', container: false, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.8' } })
+ + server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.0.30-8.el9_8.1'), ...logged(['php-common', 'php-fpm', 'php-cli'], 1), 'proc|php-fpm|1|ok']);
+ assert.equal(run(host).verdict, 'patched');
+});
+
+test('R6 a Remi collection of an end-of-life branch with no changelog read is Undetermined, not Vulnerable', () => {
+ const remiProbe = probe({ ...RHEL_PROBE, php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php74', server_software: 'Apache/2.4.62 (AlmaLinux)', container: false, os_release: { ID: 'almalinux', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.8', PRETTY_NAME: 'AlmaLinux 9.8 (Olive Jaguar)' } });
+ for (const r of both(remiProbe)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(r.actions[0].code, /php2shell-server-check\.sh$/);
+ }
+ assert.match(reasons(run(remiProbe)), /changelog/);
+ assert.doesNotMatch(all(run(remiProbe)), /follows upstream versions/);
+ // With the changelog of exactly this build: Patched when it records both, Vulnerable when it was read without them.
+ const pkgs = ['php74-php-common', 'php74-php-cli', 'php74-php-fpm'];
+ assert.equal(run(remiProbe + server([...ALMA9_LINES, ...rpm(pkgs, '7.4.33-31.el9.remi'), ...logged(pkgs, 1), 'proc|php-fpm|1|ok'])).verdict, 'patched');
+ assert.equal(run(remiProbe + server([...ALMA9_LINES, ...rpm(pkgs, '7.4.33-29.el9.remi'), ...logged(pkgs, 0), 'proc|php-fpm|1|ok'])).verdict, 'vulnerable');
+});
+
+test('R7 a RHEL build older than the build Red Hat names as fixed, with no changelog entry, is Vulnerable', () => {
+ const alma = server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm', 'php-cli'], '8.0.30-8.el9_8'), ...logged(['php-common', 'php-fpm', 'php-cli'], 0), 'proc|php-fpm|1|ok', 'webuser_proc|0']);
+ const rhel10 = server([...ALMA10_LINES, ...rpm(['php-common', 'php-fpm'], '8.3.30-1.el10_1'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']);
+ const al2 = server(['env|root|1', 'os|ID|amzn', 'os|ID_LIKE|centos rhel fedora', 'os|VERSION_ID|2', ...rpm(['php-common', 'php-fpm'], '8.2.30-1.amzn2.0.1'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']);
+ for (const text of [alma, rhel10, al2]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'vulnerable', text.slice(60, 160));
+ }
+ assert.match(reasons(run(alma)), /older than 8\.0\.30-8\.el9_8\.1, the build that fixes both CVEs \(RHSA-2026:87151\)/);
+ // A module stream build: the advisory names no package version, so it stays Undetermined.
+ const module = server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm'], '8.2.13-1.module_el9.4.0+20844+7c41d8d0'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']);
+ assert.equal(run(module).verdict, 'unknown');
+});
+
+test('R8 Amazon Linux 2023: no "built from source" claim, and the update is confirmed by its version', () => {
+ const text = probe({ ...RHEL_PROBE, php_version: '8.3.20', sapi: 'fpm-fcgi', config_path: '/etc', server_software: 'Apache/2.4.69 (Amazon Linux)', container: false, os_release: { ID: 'amzn', ID_LIKE: 'fedora', VERSION_ID: '2023', PRETTY_NAME: 'Amazon Linux 2023.12.20261021' } })
+ + server([...AL2023_LINES, 'proc|php-fpm|1|ok', 'webuser_proc|0']);
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'vulnerable');
+ assert.doesNotMatch(codes(r), /rpm -q --changelog php-common/);
+ }
+ assert.doesNotMatch(notes(run(text)), /so this PHP was built from source or comes/);
+ assert.match(notes(run(text)), /current server script/);
+ assert.match(actionText(run(text)), /each package version below should now start with 8\.3\.36/);
+ // The server script now lists php8.3-* packages: the upstream fixed release is Patched, an older one Vulnerable.
+ const pkgs = ['php8.3-common', 'php8.3-cli', 'php8.3-fpm'];
+ assert.equal(run(server([...AL2023_LINES, ...rpm(pkgs, '8.3.36-1.amzn2023.0.1'), ...logged(pkgs, 0), 'proc|php-fpm|1|ok'])).verdict, 'patched');
+ assert.equal(run(server([...AL2023_LINES, ...rpm(pkgs, '8.3.20-1.amzn2023.0.1'), ...logged(pkgs, 0), 'proc|php-fpm|1|ok'])).verdict, 'vulnerable');
+});
+
+// ── C: crafted inputs ───────────────────────────────────────────────
+
+const D12_PROBE = probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' }, dpkg_packages: [{ name: 'php8.2-fpm', version: '8.2.35-1~deb12u1', source: 'php8.2' }], container: false, open_basedir: false });
+const D12_SERVER = ['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'os|VERSION_CODENAME|bookworm', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2'];
+
+test('C1 ok and stale lines of the same php-fpm name are two masters: the stale one is not the probe\'s', () => {
+ for (const r of both(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok', 'proc|php-fpm8.2|2|stale']))) assert.equal(r.verdict, 'restart');
+ // Positive control: all stale, so the probe's own pool waits for a restart.
+ assert.equal(run(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|stale'])).verdict, 'patched');
+});
+
+test('C2 PHP processes the server script could not check rule out Patched, also next to a probe that shows the fix', () => {
+ for (const last of ['proc|unreadable|4|unknown', 'proc|apache2|4|']) {
+ for (const r of both(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok', last]))) assert.equal(r.verdict, 'unknown', last);
+ }
+ assert.equal(run(D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok'])).verdict, 'patched');
+});
+
+test('C3 without root, processes the script saw are not all that run: a web-serving build is not Patched', () => {
+ const jammy = server(['env|root|0', 'os|ID|ubuntu', 'os|VERSION_ID|22.04', 'os|VERSION_CODENAME|jammy', 'pkg|dpkg|libapache2-mod-php8.1|8.1.2-1ubuntu2.28|php8.1',
+ 'pkg|dpkg|php8.1-fpm|8.1.2-1ubuntu2.28|php8.1', 'pkg|dpkg|php8.1-common|8.1.2-1ubuntu2.28|php8.1', 'proc|php-fpm8.1|2|ok']);
+ const rocky = server(['env|root|0', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', ...rpm(['php-common', 'php-fpm'], '8.2.13-1.module+el9.4.0+20827+3f2b0b48'),
+ ...logged(['php-common', 'php-fpm'], 1), 'proc|php-fpm|3|ok']);
+ for (const text of [jammy, rocky]) {
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(r.actions[0].text, /sudo|root/);
+ }
+ }
+});
+
+test('C4 a fixed running PHP over an older unfixed package of its own SAPI is Vulnerable', () => {
+ const os12 = { ID: 'debian', VERSION_ID: '12', VERSION_CODENAME: 'bookworm' };
+ const cases = [
+ probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/etc/php/8.2/fpm', os_release: os12, dpkg_packages: deb(['php8.2-fpm', 'php8.2-common'], '8.2.29-1~deb12u1', 'php8.2'), container: false, open_basedir: false }),
+ probe({ php_version: '8.2.35', sapi: 'apache2handler', config_path: '/etc/php/8.2/apache2', os_release: os12, dpkg_packages: deb(['libapache2-mod-php8.2'], '8.2.29-1~deb12u1', 'php8.2'), container: false, open_basedir: false }),
+ probe({ php_version: '8.3.36', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '24.04' }, dpkg_packages: deb(['php8.3-fpm'], '8.3.6-0ubuntu0.24.04.4', 'php8.3'), container: false, open_basedir: false }),
+ ];
+ for (const text of cases) {
+ for (const r of both(text)) assert.equal(r.verdict, 'vulnerable');
+ }
+ // Positive control: a PHP configured outside the package's directory is not that package.
+ assert.equal(run(probe({ php_version: '8.2.35', sapi: 'fpm-fcgi', config_path: '/opt/php/etc', os_release: os12, dpkg_packages: deb(['php8.2-fpm'], '8.2.29-1~deb12u1', 'php8.2') })).verdict, 'patched');
+});
+
+test('C5 C6 every PHP version in a paste is judged: bare lines, Server headers and merged X-Powered-By values', () => {
+ const pastes = [
+ '8.4.27\n8.4.20\n',
+ 'PHP/8.4.27\nPHP/8.4.20\n',
+ 'HTTP/1.1 301 Moved Permanently\nServer: Apache/2.4.62 (Unix) PHP/8.4.27\nLocation: https://www.example.com/\n\nHTTP/1.1 200 OK\nServer: Apache/2.4.29 (Unix) PHP/8.4.20\n',
+ 'HTTP/1.1 301 Moved Permanently\nX-Powered-By: PHP/8.4.27\n\nHTTP/2 200\nx-powered-by: PleskLin, PHP/8.4.20\n',
+ 'X-Powered-By: PHP/8.4.27, PHP/8.4.20\n',
+ ];
+ for (const text of pastes) {
+ for (const r of both(text)) assert.equal(r.verdict, 'vulnerable', text);
+ assert.equal(parseInput(text).parts.length, 2, text);
+ }
+ // The tester's own versions (8.2.20, 7.4.33) may be RHEL module builds: Undetermined, never Patched.
+ for (const text of ['8.4.27\n8.2.20\n', 'X-Powered-By: PHP/8.4.27, PHP/8.2.20\n', 'HTTP/1.1 301 Moved\nServer: Apache/2.4.62 (Unix) PHP/8.4.27\n\nHTTP/1.1 200 OK\nServer: Apache/2.4.29 (Unix) PHP/7.4.33\n']) {
+ assert.notEqual(run(text).verdict, 'patched', text);
+ }
+ // Positive controls: one version alone, and the same version twice, are one output.
+ assert.equal(run('8.4.27').verdict, 'patched');
+ assert.equal(parseInput('X-Powered-By: PHP/8.4.27\nServer: nginx PHP/8.4.27\n').parts, undefined);
+});
+
+test('C7 php5-fpm, which the server script cannot see, is not "no running PHP"; a php5-fpm line maps to its build', () => {
+ const trusty = ['env|root|1', 'os|ID|ubuntu', 'os|ID_LIKE|debian', 'os|VERSION_ID|14.04', 'os|PRETTY_NAME|Ubuntu 14.04.6 LTS',
+ 'pkg|dpkg|php5-fpm|5.5.9+dfsg-1ubuntu4.29+esm18|php5', 'pkg|dpkg|php5-common|5.5.9+dfsg-1ubuntu4.29+esm18|php5', 'pkg|dpkg|php5-cli|5.5.9+dfsg-1ubuntu4.29+esm18|php5'];
+ for (const r of both(server(trusty))) {
+ assert.equal(r.verdict, 'unknown');
+ assert.ok(r.actions.some((a) => a.code === 'ps -o pid,lstart,args -C php5-fpm'));
+ assert.ok(r.actions.some((a) => a.code === 'sudo service php5-fpm restart'));
+ }
+ assert.equal(run(server([...trusty, 'proc|php5-fpm|3|ok'])).verdict, 'patched');
+ assert.equal(run(server([...trusty, 'proc|php5-fpm|3|stale'])).verdict, 'restart');
+});
+
+test('C8 an open_basedir probe of unknown container status is not judged by an Ubuntu host\'s package', () => {
+ const text = probe({ php_version: '8.3.6', sapi: 'fpm-fcgi', config_path: '/etc/php/8.3/fpm', os_release: null, open_basedir: true, container: null, dpkg_packages: null, uname_release: '6.8.0-45-generic' })
+ + server(['env|root|1', 'os|ID|ubuntu', 'os|VERSION_ID|24.04', `pkg|dpkg|php8.3-fpm|${NOBLE_FIXED}|php8.3`, 'proc|php-fpm8.3|4|ok']);
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown');
+});
+
+test('C9 a Debian snapshot version with "~" before the release is a pre-release', () => {
+ for (const r of both(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|13', 'pkg|dpkg|php8.4-fpm|8.4.27~git20261001-1|php8.4', 'proc|php-fpm8.4|2|ok']))) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(reasons(r), /8\.4\.27~git20261001-1/);
+ }
+ assert.equal(run(server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|13', 'pkg|dpkg|php8.4-fpm|8.4.27-1~deb13u1|php8.4', 'proc|php-fpm8.4|2|ok'])).verdict, 'patched');
+});
+
+test('C10 a bare 8.2.x or 8.3.x below the fix may be a RHEL module stream build', () => {
+ for (const text of ['X-Powered-By: PHP/8.2.13\n', 'PHP 8.2.13 (cli) (built: Oct 20 2026 12:00:00) (NTS)', 'X-Powered-By: PHP/8.3.20\n']) {
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown', text);
+ assert.match(reasons(run(text)), /RHEL (8, RHEL 9|9)/, text);
+ }
+ // Positive controls: a branch with no such fix, and a Distribution list choice, are judged by version.
+ assert.equal(run('X-Powered-By: PHP/8.4.20').verdict, 'vulnerable');
+ assert.equal(run('X-Powered-By: PHP/8.2.13', { distro: 'debian:12' }).verdict, 'vulnerable');
+});
+
+test('C11 a Remi end-of-life collection whose changelog was not read is Undetermined', () => {
+ const remi = { php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/opt/remi/php74', os_release: { ID: 'rocky', ID_LIKE: 'rhel centos fedora', VERSION_ID: '9.4' }, dpkg_packages: null, container: false, open_basedir: false };
+ const lines = ['env|root|1', 'os|ID|rocky', 'os|ID_LIKE|rhel centos fedora', 'os|VERSION_ID|9.4', 'pkg|rpm|php74-php-common|7.4.33-18.el9.remi|', 'pkg|rpm|php74-php-fpm|7.4.33-18.el9.remi|', 'proc|php-fpm|3|ok'];
+ for (const text of [probe(remi), server(lines), server([...lines, ...logged(['php74-php-common', 'php74-php-fpm'], { 'CVE-2026-109618': 'unknown', 'CVE-2026-109619': 'unknown' })])]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown', text.slice(0, 80));
+ }
+});
+
+test('C12 SUSE backports into its own PHP packages: Undetermined, with zypper and no RHEL package or source build', () => {
+ const sles = probe({ php_version: '8.0.30', sapi: 'apache2handler', config_path: '/etc/php8/apache2', os_release: { ID: 'sles', ID_LIKE: 'suse', VERSION_ID: '15.5', PRETTY_NAME: 'SUSE Linux Enterprise Server 15 SP5' }, dpkg_packages: null, container: false, open_basedir: false });
+ for (const r of both(sles)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.doesNotMatch(codes(r), /make install|git clone|dnf|apt-get/);
+ assert.ok(r.actions.some((a) => a.code === "sudo zypper refresh\nsudo zypper update 'php8*'"));
+ assert.ok(r.actions.some((a) => /rpm -q --changelog php8 \| grep -e CVE-2026-109618 -e CVE-2026-109619/.test(a.code || '')));
+ }
+ assert.doesNotMatch(actionText(run(sles)), /RHEL 9 \(/);
+});
+
+test('C13 CentOS 6: the server script\'s "rhel" from /etc/redhat-release is the same system as the probe\'s CentOS', () => {
+ const text = probe({ php_version: '5.3.3', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' }, open_basedir: false, container: false, dpkg_packages: null, uname_release: '2.6.32-754.35.1.el6.x86_64' })
+ + server(['env|root|1', 'os|ID|rhel', 'os|VERSION_ID|6', 'pkg|rpm|php|5.3.3-50.el6_10|', 'pkg|rpm|php-common|5.3.3-50.el6_10|', ...logged(['php', 'php-common'], 1), 'proc|httpd|5|ok']);
+ for (const r of both(text)) assert.equal(r.verdict, 'patched');
+ assert.doesNotMatch(notes(run(text)), /another system/);
+ // A different release is still another system.
+ const other = probe({ php_version: '5.3.3', sapi: 'apache2handler', config_path: '/etc', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' }, dpkg_packages: null })
+ + server(['env|root|1', 'os|ID|rhel', 'os|VERSION_ID|7', 'pkg|rpm|php|5.4.16-48.el7|', 'proc|httpd|5|ok']);
+ assert.match(notes(run(other)), /another system/);
+});
+
+test('C14 processes unreadable as root: the advice is the host or the probe, not sudo again', () => {
+ const text = server(['env|root|1', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|unreadable|3|unknown']);
+ for (const r of both(text)) {
+ assert.equal(r.verdict, 'unknown');
+ assert.match(r.actions[0].text, /CAP_SYS_PTRACE/);
+ }
+ const r = run(text);
+ assert.doesNotMatch(notes(r), /missing permissions/);
+ assert.doesNotMatch(r.actions[0].text, /^Run the server script again as root \(with sudo\)/);
+ // Without root it is a matter of permissions.
+ const nonRoot = run(server(['env|root|0', 'os|ID|debian', 'os|VERSION_ID|12', 'pkg|dpkg|php8.2-fpm|8.2.35-1~deb12u1|php8.2', 'proc|unreadable|3|unknown']));
+ assert.match(notes(nonRoot), /missing permissions/);
+});
+
+test('C15 end-of-life steps use the real binary names, warn about deb.sury.org, and build from source only for a self-built PHP', () => {
+ const jammySury = probe({ php_version: '7.4.33', sapi: 'fpm-fcgi', config_path: '/etc/php/7.4/fpm', os_release: { ID: 'ubuntu', VERSION_ID: '22.04', VERSION_CODENAME: 'jammy' },
+ dpkg_packages: [{ name: 'php7.4-fpm', version: '1:7.4.33-1+ubuntu22.04.1+deb.sury.org+1', source: 'php7.4' }], container: false, open_basedir: false });
+ for (const r of both(jammySury)) {
+ assert.notEqual(r.verdict, 'patched');
+ assert.doesNotMatch(codes(r), /php-fpm -i|make install/);
+ const host = r.actions.find((a) => /apt-get install php8\.1-fpm/.test(a.code || ''));
+ assert.ok(host);
+ assert.match(host.code, /apt-cache policy php8\.1-fpm/);
+ assert.match(host.text, /deb\.sury\.org/);
+ }
+ const alpine = server(['env|root|1', 'os|ID|alpine', 'os|VERSION_ID|3.19.4', 'pkg|apk|php81|8.1.31-r0|', 'pkg|apk|php81-fpm|8.1.31-r0|', 'proc|php-fpm81|2|ok']);
+ for (const r of both(alpine)) {
+ assert.equal(r.verdict, 'vulnerable');
+ assert.doesNotMatch(codes(r), /php-fpm -i|make install/);
+ }
+ // A self-built PHP still gets the php-src backport branch, with the binary of its SAPI.
+ const self = run(probe({ php_version: '8.1.30', sapi: 'fpm-fcgi', config_path: '/usr/local/lib', os_release: { ID: 'debian', VERSION_ID: '12' }, dpkg_packages: [] }));
+ assert.match(codes(self), /^php-fpm -i \| grep -i 'configure command'\ngit clone/m);
+});
+
+// ── X: rendering and robustness ─────────────────────────────────────
+
+test('X1 a SAPI named like an Object member never throws and is shown as it is', () => {
+ for (const sapi of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__', 'isPrototypeOf', 'toLocaleString', '__defineGetter__']) {
+ for (const os of [{ ID: 'ubuntu', VERSION_ID: '22.04' }, null]) {
+ const text = probe({ php_version: '8.1.2-1ubuntu2.20', sapi, config_path: '/etc/php/8.1/fpm', os_release: os, dpkg_packages: [{ name: 'php8.1-common', version: '8.1.2-1ubuntu2.20', source: 'php8.1' }] });
+ for (const r of both(text)) {
+ assert.ok(['vulnerable', 'unknown'].includes(r.verdict), `${sapi}: ${r.verdict}`);
+ for (const value of [...r.facts.map((f) => f.value), r.exposure?.text || '', ...r.actions.map((a) => `${a.text} ${a.code || ''}`)]) {
+ assert.doesNotMatch(value, /function |native code|\[object /, `${sapi}: ${value}`);
+ }
+ }
+ }
+ assert.equal(sapiLabel(sapi), sapi);
+ }
+});
+
+test('X2 X3 settings and OS release values that are not text are left out, never shown as [object Object]', () => {
+ const texts = [
+ JSON.stringify({ tool: 'php2shell-selfcheck', php_version: '8.2.20', sapi: 'fpm-fcgi', ini: { expose_php: { toString: 0 } } }),
+ JSON.stringify({ tool: 'php2shell-selfcheck', php_version: '8.2.20', sapi: 'fpm-fcgi', ini: { memory_limit: {}, 'session.upload_progress.enabled': [], expose_php: 1 } }),
+ probe({ php_version: '8.2.20', sapi: 'fpm-fcgi', os_release: { ID: { a: 1 }, VERSION_ID: '9' } }),
+ probe({ php_version: '8.2.20', sapi: 'fpm-fcgi', os_release: { ID: 'ubuntu', VERSION_ID: ['2', '2'] } }),
+ probe({ php_version: '8.2.20', sapi: 'fpm-fcgi', dpkg_packages: [{ name: { x: 1 }, version: '1' }, { name: 'php8.2-fpm', version: ['8.2.20'] }] }),
+ ];
+ for (const text of texts) {
+ for (const r of both(text)) {
+ const shown = [...r.facts.map((f) => f.value), ...r.notes, ...r.reasons, ...r.mitigations.map((m) => `${m.name} ${m.value} ${m.note}`)].join('\n');
+ assert.doesNotMatch(shown, /\[object |undefined/, text);
+ }
+ }
+ const mit = run(texts[1]).mitigations;
+ assert.equal(mit.find((m) => m.name === 'memory_limit').value, '(not set)');
+ assert.equal(mit.find((m) => m.name === 'expose_php').value, '1');
+});
+
+test('X4 a large paste is read in time proportional to its size', () => {
+ const markers = '-----BEGIN PHP2SHELL SELFCHECK-----\n'.repeat(58000);
+ let start = Date.now();
+ const r1 = run(markers);
+ assert.ok(Date.now() - start < 3000, `marker paste took ${Date.now() - start} ms`);
+ assert.notEqual(r1.verdict, 'patched');
+ start = Date.now();
+ const r2 = run(`PHP 8.1.${'7'.repeat(64000)}`);
+ assert.ok(Date.now() - start < 1000, `digit paste took ${Date.now() - start} ms`);
+ assert.notEqual(r2.verdict, 'patched');
+ // An input over the limit is cut, and what was cut may be worse, so it is never Patched.
+ const long = `X-Powered-By: PHP/8.4.27\n${' '.repeat(2000001)}\nX-Powered-By: PHP/8.2.20\n`;
+ const r3 = run(long);
+ assert.notEqual(r3.verdict, 'patched');
+ assert.match(notes(r3), /2 million characters/);
+});
+
+test('X5 a changelog line cannot change the changelog of another package', () => {
+ const text = server(['env|root|1', 'os|ID|rocky', 'os|VERSION_ID|9.4', 'pkg|rpm|php-common|8.2.13-1.module+el9.4.0+20827+3f2b0b48|', 'pkg|rpm|php-fpm|8.2.13-1.module+el9.4.0+20827+3f2b0b48|',
+ 'changelog|__proto__|CVE-2026-109618|1', 'changelog|__proto__|CVE-2026-109619|1', 'changelog|php-common|__proto__|1', 'log|__proto__|1', 'proc|php-fpm|2|ok']);
+ for (const r of both(text)) assert.equal(r.verdict, 'unknown');
+ assert.equal({}['CVE-2026-109618'], undefined, 'nothing reaches Object.prototype');
+});
+
+test('X6 the engine never throws, so the page always shows a result', () => {
+ for (const obs of [{}, { parts: [{}, null] }, { phpVersion: '8.2.20', packages: null }]) {
+ for (const lang of ['en', 'ko']) {
+ const r = judge(obs, upstream, distro, { lang });
+ assert.equal(r.verdict, 'unknown');
+ assert.equal(typeof r.title, 'string');
+ assert.ok(r.reasons.length > 0);
+ for (const k of ['actions', 'facts', 'mitigations', 'notes', 'advisories', 'findings']) assert.ok(Array.isArray(r[k]), k);
+ }
+ }
+ // A probe output copied without its BEGIN line is read too.
+ const headless = `php2shell-selfcheck 1.0.0\n${JSON.stringify({ tool: 'php2shell-selfcheck', php_version: '8.4.27', sapi: 'fpm-fcgi' })}\n-----END PHP2SHELL SELFCHECK-----\n`;
+ assert.equal(parseInput(headless).phpVersion, '8.4.27');
+});
+
+// ── N: notes from the server script and probe work ──────────────────
+
+const ALMA10_84 = (version, n = 0) => [...ALMA10_LINES, ...rpm(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4'], version),
+ ...logged(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4'], n)];
+const AL2023_84 = (version) => [...AL2023_LINES, ...rpm(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4-modphp'], version),
+ ...logged(['php8.4-common', 'php8.4-cli', 'php8.4-fpm', 'php8.4-modphp'], 0)];
+
+test('N1 RHEL 10 and Amazon Linux 2023 php8.4 packages run php-fpm and httpd', () => {
+ for (const text of [server([...ALMA10_84('8.4.27-1.el10_2'), 'proc|php-fpm|1|ok', 'webuser_proc|unknown']),
+ server([...AL2023_84('8.4.27-1.amzn2023.0.1'), 'proc|httpd|1|ok', 'proc|php-fpm|1|ok'])]) {
+ for (const r of both(text)) assert.equal(r.verdict, 'patched');
+ }
+ for (const text of [server([...ALMA10_84('8.4.27-1.el10_2'), 'proc|php-fpm|1|stale']), server([...AL2023_84('8.4.27-1.amzn2023.0.1'), 'proc|php-fpm|1|stale'])]) {
+ const r = run(text);
+ assert.equal(r.verdict, 'restart');
+ assert.equal(r.actions[0].code, 'sudo systemctl restart php-fpm');
+ }
+});
+
+test('N2 an update that is a new upstream release is confirmed by its version, not by CVE lines in the changelog', () => {
+ const alma = run(server([...ALMA10_84('8.4.24-1.el10_2'), 'proc|php-fpm|1|ok']));
+ assert.equal(alma.verdict, 'vulnerable');
+ assert.ok(alma.actions.some((a) => a.code === 'rpm -q php8.4-common php8.4-cli php8.4-fpm php8.4' && /8\.4\.27-1\.el10_2 or later/.test(a.text)));
+ assert.doesNotMatch(codes(alma), /rpm -q --changelog/);
+ const amazon = run(server([...AL2023_84('8.4.26-1.amzn2023.0.1'), 'proc|php-fpm|1|ok']));
+ assert.equal(amazon.verdict, 'vulnerable');
+ assert.doesNotMatch(codes(amazon), /rpm -q --changelog/);
+ // A Red Hat backport is still confirmed in the changelog.
+ const rhel9 = run(server([...ALMA9_LINES, ...rpm(['php-common', 'php-fpm'], '8.0.30-8.el9_8'), ...logged(['php-common', 'php-fpm'], 0), 'proc|php-fpm|1|ok']));
+ assert.match(codes(rhel9), /rpm -q --changelog php-common \| grep -e CVE-2026-109618 -e CVE-2026-109619/);
+});
+
+test('every result of this round is the same shape in both languages', () => {
+ const inputs = [F1, F2, F3_PROBE + F3_HEADLESS, F6_PROBE, F7_PROBE + F7_SERVER, F4_DEB, F4_PPA_PROBE, F5, D12_PROBE + server([...D12_SERVER, 'proc|php-fpm8.2|3|ok', 'proc|unreadable|4|unknown'])];
+ for (const text of inputs) {
+ const [en, ko] = both(text);
+ assert.equal(ko.verdict, en.verdict);
+ for (const k of ['reasons', 'notes', 'actions', 'facts', 'advisories']) assert.equal(ko[k].length, en[k].length, k);
+ for (const a of ko.actions) assert.match(a.text, /[가-힣]/);
+ assert.deepEqual(verdicts(text), [en.verdict, en.verdict]);
+ }
+});
diff --git a/test/version.test.mjs b/test/version.test.mjs
@@ -0,0 +1,54 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { compareDebian, comparePhp, parsePhpVersion, isSuryBuild } from '../site/lib/version.js';
+
+// [a, b, expected]: must match the result of dpkg --compare-versions.
+export const DEB_CASES = [
+ ['1.0', '1.0-1', -1],
+ ['1.0~rc1', '1.0', -1],
+ ['1.0~rc1-1', '1.0-1', -1],
+ ['1:1.0', '2.0', 1],
+ ['8.2.35-1~deb12u1', '8.2.34-1~deb12u1', 1],
+ ['8.2.35-1~deb12u1', '8.2.35-1', -1],
+ ['8.2.26-1~deb12u1', '8.2.35-1~deb12u1', -1],
+ ['8.1.2-1ubuntu2.28', '8.1.2-1ubuntu2.9', 1],
+ ['8.1.2-1ubuntu2.20', '8.1.2-1ubuntu2.28', -1],
+ ['8.1.2-1ubuntu2.28', '8.1.2-1ubuntu2.28', 0],
+ ['7.4.3-4ubuntu2.29+esm5', '7.4.3-4ubuntu2.29', 1],
+ ['5.5.9+dfsg-1ubuntu4.29+esm18', '5.5.9+dfsg-1ubuntu4.29+esm9', 1],
+ ['8.3.6-0ubuntu0.24.04.13', '8.3.6-0ubuntu0.24.04.4', 1],
+ ['1.0a', '1.0+', -1],
+ ['1.0', '1.00', 0],
+ ['2.0.0', '10.0', -1],
+];
+
+test('compareDebian gives the same results as dpkg', () => {
+ for (const [a, b, want] of DEB_CASES) {
+ assert.equal(compareDebian(a, b), want, `${a} vs ${b}`);
+ assert.equal(compareDebian(b, a), want === 0 ? 0 : -want, `${b} vs ${a}`);
+ }
+});
+
+test('parsePhpVersion separates the distribution revision and pre-releases', () => {
+ assert.deepEqual(
+ (({ branch, upstream, revision, pre }) => ({ branch, upstream, revision, pre }))(parsePhpVersion('8.1.2-1ubuntu2.20')),
+ { branch: '8.1', upstream: '8.1.2', revision: '1ubuntu2.20', pre: '' },
+ );
+ assert.equal(parsePhpVersion('8.6.0RC3').pre, 'RC3');
+ assert.equal(parsePhpVersion('hello'), null);
+});
+
+test('comparePhp sorts pre-releases below final releases', () => {
+ assert.equal(comparePhp('8.4.27', '8.4.27'), 0);
+ assert.equal(comparePhp('8.4.26', '8.4.27'), -1);
+ assert.equal(comparePhp('8.4.100', '8.4.27'), 1);
+ assert.equal(comparePhp('8.6.0RC4', '8.6.0'), -1);
+ assert.equal(comparePhp('8.2.35-1~deb12u1', '8.2.35'), 0);
+});
+
+test('isSuryBuild recognises deb.sury.org builds', () => {
+ assert.ok(isSuryBuild('8.3.14-1+ubuntu22.04.1+deb.sury.org+1'));
+ assert.ok(isSuryBuild('8.3.14-1+0~20241122.43+debian12~1.gbp9f1e3a'));
+ assert.ok(!isSuryBuild('8.2.35-1~deb12u1'));
+ assert.ok(!isSuryBuild('8.1.2-1ubuntu2.28'));
+});
diff --git a/tools/build-site.mjs b/tools/build-site.mjs
@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+// Assemble the deployable site into _site/: the static page from site/, the
+// two server-side files under downloads/ with a SHA256SUMS file, and
+// config.json pointing at the source repository. The deploy workflow and
+// `npm run serve` both use this, so a local preview matches production.
+
+import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+
+const ROOT = new URL('..', import.meta.url);
+const OUT = new URL('_site/', ROOT);
+const DOWNLOADS = [
+ ['check/php2shell-selfcheck.php', 'php2shell-selfcheck.php'],
+ ['scripts/php2shell-server-check.sh', 'php2shell-server-check.sh'],
+];
+
+const server = process.env.GITHUB_SERVER_URL || 'https://github.com';
+const repository = process.env.GITHUB_REPOSITORY || 'sqzer-x/php2shell-scanner';
+
+await rm(OUT, { recursive: true, force: true });
+await cp(new URL('site/', ROOT), OUT, { recursive: true });
+await mkdir(new URL('downloads/', OUT), { recursive: true });
+
+const sums = [];
+for (const [from, name] of DOWNLOADS) {
+ const data = await readFile(new URL(from, ROOT));
+ await writeFile(new URL(`downloads/${name}`, OUT), data);
+ sums.push(`${createHash('sha256').update(data).digest('hex')} ${name}`);
+}
+await writeFile(new URL('downloads/SHA256SUMS', OUT), sums.join('\n') + '\n');
+await writeFile(new URL('config.json', OUT), JSON.stringify({ repository: `${server}/${repository}` }) + '\n');
+
+console.log(`Built _site/ (${DOWNLOADS.length} downloads, repository ${server}/${repository})`);
diff --git a/tools/serve.mjs b/tools/serve.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+// Serve the assembled site in _site/ at http://127.0.0.1:8790 for a local
+// preview. No dependencies. Run `node tools/build-site.mjs` first, or use
+// `npm run serve`, which does both. Set PORT to use another port.
+//
+// It answers GET and HEAD only, serves index.html for a directory, and
+// redirects a directory without a trailing slash, so relative links on /kr/
+// work as on GitHub Pages. A path is served only if its real path, with
+// symlinks resolved, is that same file inside _site/, so nothing outside
+// _site/ can be read. Where the file system reports the true case (Windows),
+// a wrong-case link fails here as it would on GitHub Pages. Downloads (.php,
+// .sh) are sent as plain text.
+
+import { createServer } from 'node:http';
+import { createReadStream } from 'node:fs';
+import { realpath, stat } from 'node:fs/promises';
+import { extname, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HOST = '127.0.0.1';
+const PORT = Number(process.env.PORT) || 8790;
+const ROOT = fileURLToPath(new URL('../_site/', import.meta.url));
+
+const TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.woff2': 'font/woff2',
+ '.php': 'text/plain; charset=utf-8',
+ '.sh': 'text/plain; charset=utf-8',
+ '.txt': 'text/plain; charset=utf-8',
+ '': 'text/plain; charset=utf-8', // SHA256SUMS
+};
+
+// The request target as a URL. A path such as //kr stays a path; it is not read as a host.
+function parseTarget(target) {
+ try {
+ return target.startsWith('/') ? new URL(`http://${HOST}${target}`) : new URL(target);
+ } catch {
+ return null;
+ }
+}
+
+// The decoded path segments, or null if one could step outside _site/ or is
+// not a plain file name (separators, a drive or stream colon, NUL).
+function segmentsOf(pathname) {
+ const out = [];
+ for (const raw of pathname.split('/')) {
+ if (raw === '') continue;
+ let s;
+ try {
+ s = decodeURIComponent(raw);
+ } catch {
+ return null;
+ }
+ if (s === '.' || s === '..' || /[\\/:\0]/.test(s)) return null;
+ out.push(s);
+ }
+ return out;
+}
+
+// The real path of file relative to the real _site/ (symlinks resolved, true case).
+async function realRelative(file) {
+ const [root, real] = await Promise.all([realpath(ROOT), realpath(file)]);
+ return relative(root, real);
+}
+
+function send(res, status, body, headers = {}) {
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...headers });
+ res.end(res.req.method === 'HEAD' ? undefined : body + '\n');
+}
+
+async function handle(req, res) {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' });
+
+ const url = parseTarget(req.url || '/');
+ const segments = url && segmentsOf(url.pathname);
+ if (!segments) return send(res, 400, 'Bad request');
+
+ let info = await stat(join(ROOT, ...segments)).catch(() => null);
+ if (info?.isDirectory()) {
+ if (!url.pathname.endsWith('/')) {
+ const location = ['', ...segments.map(encodeURIComponent), ''].join('/') + url.search;
+ return send(res, 301, 'Moved', { location });
+ }
+ segments.push('index.html');
+ info = await stat(join(ROOT, ...segments)).catch(() => null);
+ }
+ const file = join(ROOT, ...segments);
+ if (!info?.isFile() || (await realRelative(file)) !== join(...segments)) return send(res, 404, 'Not found');
+
+ res.writeHead(200, {
+ 'content-type': TYPES[extname(file).toLowerCase()] || 'application/octet-stream',
+ 'content-length': info.size,
+ 'cache-control': 'no-store',
+ 'x-content-type-options': 'nosniff',
+ });
+ if (req.method === 'HEAD') return res.end();
+ createReadStream(file).on('error', () => res.destroy()).pipe(res);
+}
+
+const server = createServer((req, res) => {
+ handle(req, res).catch((err) => {
+ console.error(err);
+ if (!res.headersSent) send(res, 500, 'Internal error');
+ else res.destroy();
+ });
+});
+
+server.on('error', (err) => {
+ console.error(err.code === 'EADDRINUSE' ? `Port ${PORT} on ${HOST} is already in use. Set PORT to use another one.` : err.message);
+ process.exit(1);
+});
+
+stat(join(ROOT, 'index.html')).then(
+ () => server.listen(PORT, HOST, () => console.log(`Serving _site/ at http://${HOST}:${PORT}/ (Ctrl+C to stop)`)),
+ () => {
+ console.error('_site/index.html not found. Run `node tools/build-site.mjs` first, or `npm run serve`.');
+ process.exit(1);
+ },
+);
diff --git a/tools/update-distro-data.mjs b/tools/update-distro-data.mjs
@@ -0,0 +1,270 @@
+#!/usr/bin/env node
+// Fetches distribution security data and writes it to site/data/distro.json.
+//
+// Sources
+// - OSV: fixed versions per package for Debian and Ubuntu (https://osv.dev)
+// - Red Hat Security Data API: RHEL advisories (RHSA) and package states
+// - php-src GitHub security advisories: checks that the fixed versions match upstream.json
+//
+// - distro.json is rewritten only when its content changes (commit history = data change history).
+// - status.json is written on every run (last check time, success per source). It is not committed.
+// - If one source fails, its previous data is kept as is and the failure is recorded.
+// - A 404 means "no record yet", which is normal before a distribution publishes anything.
+// But if the previous distro.json has entries for that CVE, a 404 is treated as a fault
+// on the source side: the previous entries for that CVE are kept, and the source is
+// recorded as failed in status.json with the CVEs in kept_previous. To accept a record
+// that really was withdrawn, delete its entries from distro.json by hand.
+// - If the fixed versions in the official advisories differ from upstream.json, exit with code 1 (a person must check).
+
+import { readFile, writeFile } from 'node:fs/promises';
+
+const ROOT = new URL('..', import.meta.url);
+const UPSTREAM_FILE = new URL('site/data/upstream.json', ROOT);
+const DISTRO_FILE = new URL('site/data/distro.json', ROOT);
+const STATUS_FILE = new URL('site/data/status.json', ROOT);
+
+const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
+
+// Print a warning, and in GitHub Actions also an annotation on the run.
+function warn(message) {
+ console.error(message);
+ if (process.env.GITHUB_ACTIONS === 'true') console.log(`::warning::${message.replace(/\s+/g, ' ')}`);
+}
+
+// Returns null on 404 (no record).
+async function getJson(url, headers = {}) {
+ const res = await fetch(url, {
+ headers: { accept: 'application/json', 'user-agent': 'php2shell-scanner-data-updater', ...headers },
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
+ return res.json();
+}
+
+async function readJson(file, fallback) {
+ try {
+ return JSON.parse(await readFile(file, 'utf8'));
+ } catch {
+ return fallback;
+ }
+}
+
+function purlCodename(purl) {
+ const query = String(purl || '').split('?')[1] || '';
+ const distro = new URLSearchParams(query).get('distro') || '';
+ return distro.split('/').pop() || null;
+}
+
+function addOsv(target, family, cve, record) {
+ const prefix = family === 'debian' ? 'Debian' : 'Ubuntu';
+ for (const a of record?.affected || []) {
+ const parts = String(a.package?.ecosystem || '').split(':');
+ if (parts[0] !== prefix) continue;
+ const pro = parts[1] === 'Pro';
+ const release = pro ? parts[2] : parts[1];
+ if (!release || !a.package?.name) continue;
+ const codename = purlCodename(a.package.purl);
+ const rel = (target[release] ||= { codename, packages: {} });
+ if (!rel.codename && codename) rel.codename = codename;
+ const pkg = (rel.packages[a.package.name] ||= { pro, fixed: {} });
+ pkg.pro = pkg.pro && pro;
+ const fixed = (a.ranges || []).flatMap((range) => range.events || []).find((e) => e.fixed);
+ pkg.fixed[cve] = fixed ? fixed.fixed : null;
+ }
+}
+
+// The record for cve returned 404. Copy that CVE's entries from the previous data
+// into target. Returns true if there were any, that is, the record existed before.
+function keepOsv(target, previous, cve) {
+ let found = false;
+ for (const [release, prevRel] of Object.entries(previous || {})) {
+ for (const [name, prevPkg] of Object.entries(prevRel?.packages || {})) {
+ if (!Object.hasOwn(prevPkg?.fixed || {}, cve)) continue;
+ found = true;
+ const rel = (target[release] ||= { codename: prevRel.codename ?? null, packages: {} });
+ if (!rel.codename && prevRel.codename) rel.codename = prevRel.codename;
+ const pkg = (rel.packages[name] ||= { pro: prevPkg.pro === true, fixed: {} });
+ pkg.pro = pkg.pro && prevPkg.pro === true;
+ pkg.fixed[cve] = prevPkg.fixed[cve];
+ }
+ }
+ return found;
+}
+
+async function fetchOsv(family, list, previous, kept) {
+ const out = {};
+ for (const c of list) {
+ const record = await getJson(`https://api.osv.dev/v1/vulns/${family.toUpperCase()}-${c.id}`);
+ if (record) addOsv(out, family, c.id, record);
+ else if (keepOsv(out, previous, c.id)) kept.push(c.id);
+ }
+ return out;
+}
+
+function addRedhat(target, notes, cve, record) {
+ if (!record) return;
+ notes[cve] = {
+ severity: record.threat_severity || null,
+ cvss3: record.cvss3?.cvss3_base_score || null,
+ statement: record.statement || null,
+ };
+ const relOf = (cpe) => /enterprise_linux:(\d+)/.exec(cpe || '')?.[1];
+ for (const a of record.affected_release || []) {
+ const rel = relOf(a.cpe);
+ if (!rel) continue;
+ (target[rel] ||= { advisories: [], states: [] }).advisories.push({ cve, advisory: a.advisory, package: a.package });
+ }
+ for (const s of record.package_state || []) {
+ const rel = relOf(s.cpe);
+ if (!rel) continue;
+ (target[rel] ||= { advisories: [], states: [] }).states.push({ cve, package: s.package_name, state: s.fix_state });
+ }
+}
+
+// Same as keepOsv, for the Red Hat releases and notes.
+function keepRedhat(releases, notes, previous, previousNotes, cve) {
+ let found = false;
+ if (previousNotes && Object.hasOwn(previousNotes, cve)) {
+ notes[cve] = previousNotes[cve];
+ found = true;
+ }
+ for (const [rel, prev] of Object.entries(previous || {})) {
+ const advisories = (prev?.advisories || []).filter((a) => a.cve === cve);
+ const states = (prev?.states || []).filter((s) => s.cve === cve);
+ if (!advisories.length && !states.length) continue;
+ found = true;
+ const target = (releases[rel] ||= { advisories: [], states: [] });
+ target.advisories.push(...advisories);
+ target.states.push(...states);
+ }
+ return found;
+}
+
+// fn(kept) returns the source's data, and pushes onto kept every CVE whose record
+// returned 404 although the previous data had entries for it. Such a source still
+// counts as failed: part of its data is not current.
+async function fetchSource(name, fn) {
+ const kept = [];
+ try {
+ const data = await fn(kept);
+ if (!kept.length) return { ok: true, data };
+ const error = `no record any more for ${kept.join(', ')} (HTTP 404); the previous entries were kept`;
+ warn(`[${name}] ${error}`);
+ return { ok: false, data, error, kept };
+ } catch (err) {
+ warn(`[${name}] failed: ${err.message}`);
+ return { ok: false, error: err.message };
+ }
+}
+
+function sortDeep(value) {
+ if (Array.isArray(value)) return value.map(sortDeep);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortDeep(value[k])]));
+ }
+ return value;
+}
+
+const byKey = (...keys) => (a, b) => {
+ for (const k of keys) {
+ const c = String(a[k]).localeCompare(String(b[k]));
+ if (c) return c;
+ }
+ return 0;
+};
+
+async function main() {
+ const upstream = JSON.parse(await readFile(UPSTREAM_FILE, 'utf8'));
+ const previous = await readJson(DISTRO_FILE, {});
+ const cves = upstream.cves;
+
+ const sources = {
+ 'osv-debian': await fetchSource('osv-debian', (kept) => fetchOsv('debian', cves, previous.debian, kept)),
+ 'osv-ubuntu': await fetchSource('osv-ubuntu', (kept) => fetchOsv('ubuntu', cves, previous.ubuntu, kept)),
+ redhat: await fetchSource('redhat', async (kept) => {
+ const releases = {};
+ const notes = {};
+ for (const c of cves) {
+ const record = await getJson(`https://access.redhat.com/hydra/rest/securitydata/cve/${c.id}.json`);
+ if (record) addRedhat(releases, notes, c.id, record);
+ else if (keepRedhat(releases, notes, previous.redhat, previous.redhat_notes, c.id)) kept.push(c.id);
+ }
+ for (const rel of Object.values(releases)) {
+ rel.advisories.sort(byKey('cve', 'advisory', 'package'));
+ rel.states.sort(byKey('cve', 'package'));
+ }
+ return { releases, notes };
+ }),
+ ghsa: await fetchSource('ghsa', async () => {
+ const out = {};
+ const auth = GITHUB_TOKEN ? { authorization: `Bearer ${GITHUB_TOKEN}` } : {};
+ for (const c of cves) {
+ const g = await getJson(`https://api.github.com/repos/php/php-src/security-advisories/${c.ghsa}`, auth);
+ if (!g) throw new Error(`advisory ${c.ghsa} not found`);
+ out[c.id] = {};
+ for (const v of g.vulnerabilities || []) {
+ const branch = /^(\d+\.\d+)\./.exec(v.patched_versions || '')?.[1];
+ if (branch) out[c.id][branch] = v.patched_versions;
+ }
+ }
+ return out;
+ }),
+ };
+
+ // A source with data (fully fresh, or fresh with some CVEs kept from before) replaces the
+ // previous data. A source that failed outright leaves the previous data as it was.
+ const pick = (name, fresh, old) => (sources[name].data !== undefined ? fresh : old);
+ const content = sortDeep({
+ schema: 1,
+ debian: pick('osv-debian', sources['osv-debian'].data, previous.debian || {}),
+ ubuntu: pick('osv-ubuntu', sources['osv-ubuntu'].data, previous.ubuntu || {}),
+ redhat: pick('redhat', sources.redhat.data?.releases, previous.redhat || {}),
+ redhat_notes: pick('redhat', sources.redhat.data?.notes, previous.redhat_notes || {}),
+ });
+
+ const { updated_at: _ignored, ...previousContent } = previous;
+ const changed = JSON.stringify(sortDeep(previousContent)) !== JSON.stringify(content);
+ const now = new Date().toISOString();
+ if (changed) {
+ await writeFile(DISTRO_FILE, JSON.stringify({ updated_at: now, ...content }, null, 2) + '\n');
+ console.log('distro.json updated');
+ } else {
+ console.log('distro.json unchanged');
+ }
+
+ let exitCode = 0;
+ const mismatches = [];
+ if (sources.ghsa.ok) {
+ for (const [cve, fixed] of Object.entries(sources.ghsa.data)) {
+ const branches = new Set([...Object.keys(fixed), ...Object.keys(upstream.fixed)]);
+ for (const b of branches) {
+ if (fixed[b] !== upstream.fixed[b]) mismatches.push(`${cve} ${b}: official advisory ${fixed[b] ?? 'none'} / upstream.json ${upstream.fixed[b] ?? 'none'}`);
+ }
+ }
+ }
+ if (mismatches.length) {
+ console.error('The fixed versions in the official advisories differ from upstream.json. Check upstream.json:\n ' + mismatches.join('\n '));
+ exitCode = 1;
+ }
+
+ const status = {
+ checked_at: now,
+ data_updated_at: changed ? now : previous.updated_at || null,
+ upstream_matches_advisory: sources.ghsa.ok ? mismatches.length === 0 : null,
+ sources: Object.fromEntries(Object.entries(sources).map(([k, v]) => [
+ k,
+ v.ok ? { ok: true } : { ok: false, error: v.error, ...(v.kept ? { kept_previous: v.kept } : {}) },
+ ])),
+ };
+ await writeFile(STATUS_FILE, JSON.stringify(status, null, 2) + '\n');
+
+ const failed = Object.entries(sources).filter(([, v]) => v.data === undefined).map(([k]) => k);
+ if (failed.length === Object.keys(sources).length) {
+ console.error('No data could be fetched from any source.');
+ exitCode = 1;
+ }
+ process.exitCode = exitCode;
+}
+
+await main();
|