php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git

test/remediation.test.mjs (63878B)

      1 // Remediation steps (r.actions): for each kind of result, the steps name what runs now, the release or package that
      2 // fixes it, the exact commands built from the observation (package names, image tag, service names) and how to
      3 // confirm the fix, in English and Korean. Runs against site/data/upstream.json and a frozen copy of
      4 // site/data/distro.json (fixtures/distro-snapshot.json), so data refreshes do not move the verdicts.
      5 import { test } from 'node:test';
      6 import assert from 'node:assert/strict';
      7 import { readFileSync } from 'node:fs';
      8 import { parseInput } from '../site/lib/parse.js';
      9 import { judge } from '../site/lib/judge.js';
     10 
     11 const upstream = JSON.parse(readFileSync(new URL('../site/data/upstream.json', import.meta.url), 'utf8'));
     12 const distro = JSON.parse(readFileSync(new URL('./fixtures/distro-snapshot.json', import.meta.url), 'utf8'));
     13 
     14 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`;
     15 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' : ''}`;
     16 const run = (text, lang = 'en', data = distro, options = {}) => judge(parseInput(text, { lang }), upstream, data, { ...options, lang });
     17 const both = (text, data = distro, options = {}) => ['en', 'ko'].map((lang) => run(text, lang, data, options));
     18 const codes = (r) => r.actions.map((a) => a.code || '');
     19 const all = (r) => r.actions.map((a) => `${a.text}\n${a.code || ''}`).join('\n');
     20 // The step whose code block matches re (a string is matched exactly).
     21 const stepWith = (r, re) => r.actions.find((a) => (typeof re === 'string' ? a.code === re : re.test(a.code || '')));
     22 const indexWith = (r, re) => r.actions.findIndex((a) => (typeof re === 'string' ? a.code === re : re.test(a.code || '')));
     23 
     24 const INI = { memory_limit: '128M', 'session.upload_progress.enabled': '1', 'session.auto_start': '0', expose_php: '1' };
     25 const CVES = 'CVE-2026-109618 --cve CVE-2026-109619';
     26 const GREP = 'grep -e CVE-2026-109618 -e CVE-2026-109619';
     27 const MODULE = '8.2.13-1.module+el9.4.0+20827+3f2b0b48';
     28 // The download lines of the page's Server script and PHP probe tabs.
     29 const FETCH = 'curl -fsSLO https://php2shell.sqzer.com/downloads/php2shell-server-check.sh\nsha256sum php2shell-server-check.sh';
     30 const PROBE = "curl -fsSLO https://php2shell.sqzer.com/downloads/php2shell-selfcheck.php\ncurl -s 'https://<site>/<file>.php?k=<TOKEN>'";
     31 
     32 // Every fixture used below, with the verdict it must keep: the remediation work changes no verdict.
     33 const F = {
     34   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 }),
     35   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 }),
     36   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 }),
     37   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 }),
     38   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 }),
     39   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 }),
     40   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 }),
     41   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 }),
     42   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 }),
     43   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 }),
     44   nobleHeader: 'X-Powered-By: PHP/8.3.6-0ubuntu0.24.04.4',
     45   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' } }),
     46   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']),
     47   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']),
     48   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 }),
     49   centos6: probe({ php_version: '5.3.3', sapi: 'apache2handler', os_release: { 'LEGACY_REDHAT-RELEASE': 'CentOS release 6.10 (Final)' }, ini: INI }),
     50   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']),
     51   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']),
     52   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 }),
     53   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 }),
     54   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 }),
     55   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 }),
     56   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 }),
     57   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 }),
     58   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']),
     59   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']),
     60   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 }),
     61   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 }),
     62   osUnread: probe({ php_version: '8.2.13', sapi: 'fpm-fcgi', config_path: '/etc', os_release: null, open_basedir: false, dpkg_packages: null, ini: INI }),
     63   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']),
     64   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),
     65   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']),
     66   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' }] })
     67     + 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']),
     68   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' }] }),
     69   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']),
     70   noVersion: 'hello world',
     71   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 }),
     72   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 }),
     73   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' } }),
     74   patchedQuiet: probe({ php_version: '8.4.27', sapi: 'fpm-fcgi', config_path: '/etc/php/8.4/fpm', os_release: {}, ini: { ...INI, expose_php: '0' } }),
     75   patchedHeader: 'X-Powered-By: PHP/8.4.27',
     76   // Cases from the review of the remediation steps.
     77   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 }),
     78   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 }),
     79   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 }),
     80   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 }),
     81   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']),
     82   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 }),
     83   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 }),
     84   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']),
     85   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 }),
     86   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 }),
     87   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']),
     88   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']),
     89   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 })
     90     + server(['env|root|1', 'os|ID|almalinux', 'os|VERSION_ID|10.2', 'proc|php-fpm|3|ok']),
     91   rockyHeader: 'Server: Apache/2.4.57 (Rocky Linux)\nX-Powered-By: PHP/8.2.13',
     92   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']),
     93   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']),
     94   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']),
     95   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 }),
     96   cliVersion: 'PHP 8.4.20 (cli) (built: Mar 1 2026) (NTS)',
     97   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']),
     98   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 }),
     99 };
    100 const VERDICTS = {
    101   dockerApache: 'vulnerable', dockerFpmAlpine: 'vulnerable', dockerCliTrixie: 'vulnerable', dockerBullseye: 'vulnerable', dockerEol: 'vulnerable',
    102   containerDeb: 'vulnerable', containerRestart: 'restart', jammyFpm: 'vulnerable', focalPro: 'vulnerable', trusty: 'vulnerable', nobleHeader: 'vulnerable',
    103   resolute85: 'vulnerable', rockyModule: 'unknown', rhel9Base: 'vulnerable', rhel7Probe: 'unknown', centos6: 'unknown', alma84: 'unknown', remiScl: 'vulnerable',
    104   selfFpm: 'vulnerable', selfApache: 'vulnerable', eol81: 'vulnerable', eol80: 'vulnerable', eol73: 'vulnerable', jammyRestart: 'restart',
    105   debianStale: 'restart', rockyStale: 'restart', forkyApache: 'pending', forkyFpm: 'pending', osUnread: 'unknown', hidden: 'unknown', truncated: 'unknown',
    106   unaccounted: 'unknown', conflict: 'unknown', foreign: 'unknown', debianNoRelease: 'unknown', noVersion: 'unknown', rhelProbe: 'unknown',
    107   patchedFpm: 'patched', patchedDocker: 'patched', patchedQuiet: 'patched', patchedHeader: 'patched',
    108   dockerWp: 'vulnerable', dockerAlpine324: 'vulnerable', focalContainer: 'vulnerable', rhelContainer: 'unknown', deb13both: 'vulnerable',
    109   deb11: 'unknown', cgi: 'vulnerable', alpine: 'vulnerable', fedora: 'vulnerable', suse: 'unknown', rhel9php81: 'unknown', centos7srv: 'unknown',
    110   rhel10: 'vulnerable', rockyHeader: 'unknown', amzn2023: 'unknown', amzn2: 'unknown', remi74: 'unknown', eol74apache: 'vulnerable',
    111   cliVersion: 'vulnerable', staleOther: 'restart', patchedApache: 'patched',
    112 };
    113 
    114 test('the remediation work changes no verdict, and both languages agree', () => {
    115   assert.deepEqual(Object.keys(VERDICTS).sort(), Object.keys(F).sort());
    116   for (const [name, text] of Object.entries(F)) {
    117     const [en, ko] = both(text);
    118     assert.equal(en.verdict, VERDICTS[name], name);
    119     assert.equal(ko.verdict, VERDICTS[name], name);
    120   }
    121 });
    122 
    123 // ── 1. Official Docker image and other containers ──────────────────
    124 
    125 test('1. official image: the exact tag of the fixed release in the same variant, rebuilt with --pull, confirmed inside', () => {
    126   for (const [text, tag, current, floating] of [
    127     [F.dockerApache, 'php:8.3.36-apache-bookworm', '8.3.12', 'php:8.3-apache-bookworm'],
    128     [F.dockerFpmAlpine, 'php:8.4.27-fpm-alpine', '8.4.20', 'php:8.4-fpm-alpine'],
    129     [F.dockerCliTrixie, 'php:8.4.27-cli-trixie', '8.4.20', 'php:8.4-cli-trixie'],
    130   ]) {
    131     for (const r of both(text)) {
    132       const from = stepWith(r, `FROM ${tag}`);
    133       assert.ok(from, `${r.verdict}: FROM ${tag}`);
    134       assert.ok(from.text.includes(current), 'names the running version');
    135       assert.ok(from.text.includes(tag.split(':')[1].split('-')[0]), 'names the fixed release');
    136       assert.ok(from.text.includes(floating), 'explains the floating tag');
    137       // Images built on the PHP image keep their own name; services that use an image directly are pulled.
    138       assert.match(from.text, /wordpress/);
    139       assert.match(from.text, /docker run --rm <your-image> php -v/);
    140       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$/);
    141       const confirm = indexWith(r, "docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'");
    142       assert.ok(indexWith(r, `FROM ${tag}`) < rebuild && rebuild < confirm, 'tag, rebuild, confirm in that order');
    143       assert.ok(r.actions[confirm].text.includes(tag.split(':')[1].split('-')[0]), 'the confirm step names the version to expect');
    144       assert.doesNotMatch(all(r), /apt-get|dnf |apk upgrade|systemctl/, 'no package or host service commands for the official image');
    145     }
    146   }
    147   const [en, ko] = both(F.dockerApache);
    148   assert.match(stepWith(en, /^FROM/).text, /does not pin the version/);
    149   assert.match(stepWith(ko, /^FROM/).text, /버전을 고정하지 않으므로/);
    150   assert.match(stepWith(ko, /^FROM/).text, /이미지 종류\(-apache-bookworm\)/);
    151   assert.doesNotMatch(stepWith(ko, /^FROM/).text, /변형|유동 태그/);
    152   assert.match(stepWith(ko, /docker exec <container> php -r/).text, /결과확인/);
    153   // The interim mitigation goes into the running container and into the image.
    154   for (const r of [en, ko]) {
    155     assert.equal(stepWith(r, /PHP_INI_DIR/).code, [
    156       'docker exec -u 0 <container> sh -c \'printf "session.upload_progress.enabled = 0\\n" > "$PHP_INI_DIR/conf.d/zz-php2shell-mitigation.ini"\'',
    157       'docker restart <container>',
    158       '# Dockerfile',
    159       'RUN printf \'session.upload_progress.enabled = 0\\n\' > "$PHP_INI_DIR/conf.d/zz-php2shell-mitigation.ini"',
    160     ].join('\n'));
    161   }
    162   assert.match(stepWith(en, /PHP_INI_DIR/).text, /does not replace the patch/);
    163 });
    164 
    165 test('1. an image on a Debian release that gets no new PHP images moves to the nearest one that does, and says so', () => {
    166   const [en, ko] = both(F.dockerBullseye);
    167   for (const r of [en, ko]) {
    168     const from = stepWith(r, 'FROM php:8.3.36-apache-bookworm');
    169     assert.ok(from);
    170     assert.match(from.text, /Debian 11 ?\(bullseye\)/);
    171     assert.match(from.text, /Debian 12 ?\(bookworm\)/);
    172   }
    173   assert.match(stepWith(en, /^FROM/).text, /keeps your variant \(-apache\)\./);
    174 });
    175 
    176 test('1. Alpine images: the Alpine release is kept when its tags are published, otherwise the floating -alpine tag is explained', () => {
    177   for (const r of both(F.dockerAlpine324)) {
    178     assert.ok(stepWith(r, 'FROM php:8.4.27-fpm-alpine3.24'));
    179     assert.doesNotMatch(stepWith(r, /^FROM/).text, /newest Alpine|최신 Alpine/);
    180   }
    181   const [en, ko] = both(F.dockerFpmAlpine);
    182   assert.match(stepWith(en, /^FROM/).text, /newer than the Alpine 3\.20 your image uses now.*php:8\.4\.27-fpm-alpine3\.24/);
    183   assert.match(stepWith(ko, /^FROM/).text, /Alpine 3\.20보다 새 릴리스/);
    184 });
    185 
    186 test('1. a branch with an announced end of life says so next to its fixed release', () => {
    187   const [en, ko] = both(F.dockerWp);
    188   assert.ok(stepWith(en, 'FROM php:8.2.35-apache-bookworm'));
    189   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\./);
    190   assert.match(stepWith(ko, /^FROM/).text, /PHP 8\.2 브랜치는 2026년 12월 31일에 지원이 종료되므로 8\.3 또는 8\.4 브랜치로/);
    191   for (const r of both(F.remiScl)) assert.match(stepWith(r, /^sudo dnf upgrade/).text, /31 December 2026|2026년 12월 31일/);
    192   for (const r of both(F.dockerApache)) assert.doesNotMatch(all(r), /end of life on|지원이 종료되므로/);
    193 });
    194 
    195 test('1. an end-of-life official image moves to a supported branch and is tested first', () => {
    196   const [en, ko] = both(F.dockerEol);
    197   for (const r of [en, ko]) {
    198     const from = stepWith(r, 'FROM php:8.4.27-apache-bookworm');
    199     assert.ok(from);
    200     assert.match(from.text, /7\.4\.33/);
    201     assert.match(from.text, /8\.3\.36/);
    202     assert.ok(stepWith(r, /docker exec <container> php -r/).text.includes('8.4.27'));
    203   }
    204   assert.match(stepWith(en, /^FROM/).text, /end of life.*test your application/);
    205   assert.match(stepWith(ko, /^FROM/).text, /지원이 종료\(EOL\).*테스트하세요/);
    206 });
    207 
    208 test('1. PHP from distribution packages inside a container gets the package route, not a FROM tag', () => {
    209   for (const r of both(F.containerDeb)) {
    210     assert.doesNotMatch(all(r), /FROM php:/);
    211     const update = stepWith(r, 'RUN apt-get update && apt-get install -y --only-upgrade libapache2-mod-php8.3 php8.3-common');
    212     assert.ok(update);
    213     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');
    214     assert.ok(stepWith(r, /docker build --pull/));
    215     const confirm = stepWith(r, 'docker exec <container> dpkg-query -W libapache2-mod-php8.3 php8.3-common');
    216     assert.ok(confirm.text.includes('8.3.6-0ubuntu0.24.04.13'));
    217   }
    218 });
    219 
    220 test('1. a container whose packages were updated in place is restarted, and the update goes into the image', () => {
    221   const [en, ko] = both(F.containerRestart);
    222   for (const r of [en, ko]) {
    223     assert.ok(stepWith(r, 'docker restart <container>'));
    224     assert.ok(stepWith(r, 'RUN apt-get update && apt-get install -y --only-upgrade libapache2-mod-php8.1'));
    225     assert.doesNotMatch(all(r), /systemctl/);
    226   }
    227   assert.match(stepWith(en, /^RUN/).text, /goes back to the old build/);
    228   assert.match(stepWith(ko, /^RUN/).text, /이전 빌드로 돌아갑니다/);
    229   // The vulnerable build served requests until the restart.
    230   for (const r of [en, ko]) assert.match(r.actions[r.actions.length - 1].code, /--logs --webroot <webroot>$/);
    231 });
    232 
    233 test('1. an Ubuntu Pro fix in a container is installed while Pro is attached for the build only', () => {
    234   for (const r of both(F.focalContainer)) {
    235     assert.equal(stepWith(r, /^RUN/).code, [
    236       'RUN --mount=type=secret,id=pro-attach-config \\',
    237       '    apt-get update && apt-get install -y --no-install-recommends ubuntu-advantage-tools ca-certificates && \\',
    238       '    pro attach --attach-config /run/secrets/pro-attach-config && \\',
    239       '    apt-get update && apt-get install -y --only-upgrade libapache2-mod-php7.4 && \\',
    240       '    pro detach --assume-yes',
    241     ].join('\n'));
    242     assert.match(stepWith(r, /^RUN/).text, /pro-attach-config\.yaml/);
    243     assert.ok(stepWith(r, /^# Docker\ndocker build --pull --secret id=pro-attach-config,src=pro-attach-config\.yaml -t <your-image> \./));
    244   }
    245 });
    246 
    247 test('1. a RHEL-family container is confirmed by its changelog, since the fixed build keeps its version', () => {
    248   for (const r of both(F.rhelContainer)) {
    249     const confirm = stepWith(r, /rpm -q --changelog/);
    250     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"');
    251     assert.doesNotMatch(confirm.text, /8\.2\.35/);
    252   }
    253 });
    254 
    255 // ── 2. Debian and Ubuntu packages ───────────────────────────────────
    256 
    257 test('2. Ubuntu package below the fixed version: installed and fixed versions, apt with the detected names, dpkg-query, restart, re-check', () => {
    258   const [en, ko] = both(F.jammyFpm);
    259   for (const r of [en, ko]) {
    260     const update = indexWith(r, 'sudo apt-get update\nsudo apt-get install --only-upgrade php8.1-fpm php8.1-common php8.1-cli');
    261     const confirm = indexWith(r, 'dpkg-query -W php8.1-fpm php8.1-common php8.1-cli');
    262     const restart = indexWith(r, 'sudo systemctl restart php8.1-fpm');
    263     assert.ok(update >= 0 && update < confirm && confirm < restart, 'update, confirm, restart in that order');
    264     assert.ok(r.actions[update].text.includes('php8.1-fpm 8.1.2-1ubuntu2.20'), 'the installed package');
    265     assert.ok(r.actions[update].text.includes('8.1.2-1ubuntu2.28'), 'the fixed package');
    266     assert.ok(r.actions[confirm].text.includes('8.1.2-1ubuntu2.28'));
    267     assert.match(r.actions[update].text, /apt full-upgrade/);
    268     assert.match(r.actions[restart + 1].text, en === r ? /Run check.*Patched/ : /결과확인.*패치됨/);
    269   }
    270   assert.match(en.actions[0].text, /extension packages/);
    271   assert.match(ko.actions[0].text, /확장 패키지/);
    272 });
    273 
    274 test('2. Ubuntu Pro: attach first (free for personal use), then update to the ESM version', () => {
    275   const [en, ko] = both(F.focalPro);
    276   for (const r of [en, ko]) {
    277     assert.equal(r.actions[0].code, 'sudo pro attach <token>');
    278     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'));
    279     assert.ok(stepWith(r, 'sudo systemctl restart apache2'));
    280   }
    281   assert.match(en.actions[0].text, /free for personal use/);
    282   assert.match(ko.actions[0].text, /개인 사용이면.*무료/);
    283   // Ubuntu 14.04 has the older client (ua) and no systemd, and is past its ESM window: the fix needs the paid
    284   // Legacy Support add-on, not the free personal subscription.
    285   const [enT, koT] = both(F.trusty);
    286   for (const r of [enT, koT]) {
    287     assert.equal(r.actions[0].code, 'sudo ua attach <token>');
    288     assert.ok(stepWith(r, 'sudo service apache2 restart'));
    289     assert.doesNotMatch(all(r), /systemctl/);
    290     assert.match(all(r), /<IfModule php5_module>/);
    291   }
    292   assert.match(enT.actions[0].text, /Legacy Support add-on, which is paid and not part of the free personal subscription/);
    293   assert.doesNotMatch(enT.actions[0].text, /free for personal use/);
    294   assert.match(koT.actions[0].text, /유료인 Ubuntu Pro Legacy Support 애드온/);
    295   assert.match(enT.notes.join(' '), /Legacy Support add-on/);
    296 });
    297 
    298 test('2. a build from another repository is not "updated": the distribution build is installed at its exact version', () => {
    299   const [en, ko] = both(F.foreign);
    300   for (const r of [en, ko]) {
    301     assert.equal(r.actions[0].code, 'apt-cache policy php8.3-fpm');
    302     assert.doesNotMatch(all(r), /--only-upgrade|or later|이상으로/);
    303     const install = stepWith(r, /--allow-downgrades/);
    304     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\"}')");
    305     assert.ok(stepWith(r, "dpkg -l 'php8.3-*' 'libapache2-mod-php8.3' | grep '^ii'"));
    306   }
    307   assert.match(stepWith(en, /--allow-downgrades/).text, /turn off the repository/);
    308   assert.match(stepWith(en, /grep '\^ii'/).text, /exactly 8\.3\.6-0ubuntu0\.24\.04\.13, the Ubuntu revision/);
    309   assert.match(stepWith(ko, /grep '\^ii'/).text, /정확히 8\.3\.6-0ubuntu0\.24\.04\.13 버전/);
    310 });
    311 
    312 test('2. a release the data does not cover: compare with the tracker by hand, then let the server script read the changelog', () => {
    313   const [en, ko] = both(F.deb11);
    314   for (const r of [en, ko]) {
    315     assert.match(r.actions[0].code, /^https:\/\/security-tracker\.debian\.org\/tracker\/CVE-2026-109618/);
    316     assert.ok(stepWith(r, 'dpkg-query -W php7.4-fpm'));
    317     assert.equal(stepWith(r, 'sudo sh php2shell-server-check.sh').text.includes('changelog'), true);
    318     assert.doesNotMatch(all(r), /PHP probe again through the web server and press Run check: the result should be Patched/);
    319   }
    320   assert.match(stepWith(en, 'dpkg-query -W php7.4-fpm').text, /Debian pages in the first step/);
    321   // The fix.debOpen wording: no jargon, and no "before the result" in Korean.
    322   assert.match(all(en), /Even while the result is Undetermined, install the latest Debian updates for php7\.4 now\./);
    323   assert.match(all(ko), /결과가 판단 불가이더라도 Debian의 최신 php7\.4 업데이트는 지금 설치하세요\./);
    324 });
    325 
    326 test('2. Apache mod_php and PHP-FPM both running this build: both are restarted, and both get the mitigation', () => {
    327   const [en, ko] = both(F.deb13both);
    328   for (const r of [en, ko]) {
    329     const restart = stepWith(r, 'sudo systemctl restart php8.4-fpm\nsudo systemctl restart apache2');
    330     assert.ok(restart);
    331     assert.match(restart.text, /full restart of Apache|Apache를 완전히 재시작/);
    332     assert.ok(stepWith(r, /conf-available\/php2shell-mitigation\.conf/));
    333     assert.ok(stepWith(r, /pool\.d\/zz-php2shell-mitigation\.conf/));
    334   }
    335   // The second mitigation step does not repeat the first one's introduction.
    336   assert.match(stepWith(en, /pool\.d\/zz-php2shell/).text, /^PHP-FPM runs this PHP as well/);
    337 });
    338 
    339 test('2. without a package list the installed packages are looked up, and the restart names both services', () => {
    340   for (const r of both(F.nobleHeader)) {
    341     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}')");
    342     assert.ok(update);
    343     assert.ok(update.text.includes('8.3.6-0ubuntu0.24.04.4') && update.text.includes('8.3.6-0ubuntu0.24.04.13'));
    344     assert.ok(stepWith(r, "dpkg -l 'php8.3-*' 'libapache2-mod-php8.3' | grep '^ii'"));
    345     assert.ok(stepWith(r, 'sudo systemctl restart apache2   # Apache mod_php\nsudo systemctl restart php8.3-fpm   # PHP-FPM'));
    346   }
    347 });
    348 
    349 // ── 3. RHEL family ──────────────────────────────────────────────────
    350 
    351 test('3. RHEL module stream: the advisory, dnf updateinfo and upgrade with the detected names, the stream, the changelog', () => {
    352   const [en, ko] = both(F.rockyModule);
    353   for (const r of [en, ko]) {
    354     const upgrade = stepWith(r, `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh php-common php-fpm php-cli`);
    355     assert.ok(upgrade);
    356     assert.match(upgrade.text, /RHSA-2026:87072/);
    357     assert.match(upgrade.text, /RLSA/);
    358     assert.doesNotMatch(upgrade.text, /same number|번호는 대개/);
    359     assert.match(stepWith(r, 'dnf module list php').text, /php:8\.2-9080020261020145734\.9/);
    360     // The stream is checked before the upgrade.
    361     assert.ok(indexWith(r, 'dnf module list php') < indexWith(r, /^sudo dnf updateinfo/));
    362     assert.ok(stepWith(r, `rpm -q php-common php-fpm php-cli\nrpm -q --changelog php-common | ${GREP}`));
    363     assert.ok(stepWith(r, 'sudo systemctl restart php-fpm'));
    364     assert.ok(stepWith(r, 'sudo sh php2shell-server-check.sh'), 'the server script confirms the changelog');
    365   }
    366   assert.match(stepWith(en, 'dnf module list php').text, /^Before you update.*do not need to switch streams/);
    367 });
    368 
    369 test('3. a stream with no fix next to newer streams that have one: switch streams after testing', () => {
    370   const [en, ko] = both(F.rhel9php81);
    371   for (const r of [en, ko]) {
    372     const sw = stepWith(r, 'sudo dnf module switch-to php:8.2');
    373     assert.match(sw.text, /RHSA-2026:87072/);
    374     assert.match(sw.text, /RHSA-2026:87079/);
    375     assert.match(sw.text, /php:8\.2-9080020261020145734\.9/);
    376   }
    377   assert.match(stepWith(en, 'sudo dnf module switch-to php:8.2').text, /Test your application with php:8\.2/);
    378   assert.match(stepWith(en, /^sudo dnf updateinfo/).text, /^If you stay on php:8\.1, there is nothing to install for it yet/);
    379 });
    380 
    381 test('3. RHEL base package: the fixed NVR from the advisory is what rpm -q should show', () => {
    382   for (const r of both(F.rhel9Base)) {
    383     assert.match(stepWith(r, /^sudo dnf updateinfo/).text, /RHSA-2026:87151/);
    384     assert.ok(stepWith(r, `rpm -q php-common php\nrpm -q --changelog php-common | ${GREP}`).text.includes('8.0.30-8.el9_8.1'));
    385     assert.ok(stepWith(r, 'sudo systemctl restart httpd'));
    386   }
    387 });
    388 
    389 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', () => {
    390   const [en7, ko7] = both(F.rhel7Probe);
    391   for (const r of [en7, ko7]) {
    392     assert.match(r.actions[0].code, /\nsudo sh php2shell-server-check\.sh$/, 'the probe alone cannot read the changelog');
    393     assert.ok(stepWith(r, `sudo yum updateinfo list --cve ${CVES}\nsudo yum update 'php*'`));
    394     assert.ok(stepWith(r, 'sudo systemctl restart httpd'));
    395     assert.doesNotMatch(all(r), /dnf/);
    396     // Red Hat lists CVE-2026-109619 as not affected for RHEL 7: no changelog line is expected for it.
    397     assert.ok(stepWith(r, "rpm -qa 'php*'\nrpm -q --changelog php-common | grep -e CVE-2026-109618"));
    398   }
    399   assert.match(all(en7), /RHEL 7 is past the end of its regular support/);
    400   assert.match(all(ko7), /RHEL 7 릴리스는 정규 지원이 끝났습니다/);
    401   assert.match(stepWith(en7, /grep -e CVE-2026-109618$/).text, /CVE-2026-109619 as not affected for RHEL 7, so no line is expected/);
    402   const [en6, ko6] = both(F.centos6);
    403   for (const r of [en6, ko6]) {
    404     assert.ok(stepWith(r, "sudo yum update 'php*'"));
    405     assert.ok(stepWith(r, 'sudo service httpd restart'));
    406     assert.match(all(r), /sudo service httpd reload/);
    407     assert.doesNotMatch(all(r), /systemctl|dnf/);
    408   }
    409   assert.match(all(en6), /CentOS 6 has reached end of life/);
    410   assert.match(all(en6), /RHEL 9, Rocky Linux 9 or AlmaLinux 9/);
    411   assert.match(all(ko6), /CentOS 6 릴리스는 지원이 종료/);
    412   // No updates come any more: the mitigation follows the steps that explain that, before the update steps.
    413   for (const r of [en6, ko6, ...both(F.centos7srv)]) {
    414     const watch = indexWith(r, /^https:\/\/access\.redhat\.com/);
    415     assert.match(r.actions[watch + 1].code, /php2shell-mitigation\.conf/);
    416     assert.ok(watch + 1 < indexWith(r, /yum update/));
    417   }
    418   assert.match(stepWith(en6, /php2shell-mitigation/).text, /^Until this server runs a supported release/);
    419   assert.match(stepWith(ko6, /php2shell-mitigation/).text, /^지원되는 릴리스로 옮길 때까지는/);
    420 });
    421 
    422 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', () => {
    423   const [en, ko] = both(F.alma84);
    424   for (const r of [en, ko]) {
    425     const watch = stepWith(r, 'https://access.redhat.com/security/cve/CVE-2026-109618\nhttps://access.redhat.com/security/cve/CVE-2026-109619');
    426     assert.match(watch.text, /Affected|영향받음/);
    427     assert.doesNotMatch(all(r), /ALSA/);
    428     assert.equal(r.actions.indexOf(watch), 0);
    429     assert.match(r.actions[1].code, /zz-php2shell-mitigation\.conf/);
    430     assert.match(stepWith(r, /^sudo dnf updateinfo/).text, /nothing to install for this yet|아직 설치할 수정이 없습니다/);
    431     assert.match(stepWith(r, 'sudo sh php2shell-server-check.sh').text, /^Once the update is installed|^업데이트를 설치한 뒤/);
    432   }
    433   assert.match(en.actions[1].text, /^Until the fixed package is published/);
    434 });
    435 
    436 test('3. a RHEL release that is not known: the advisories of every release for this branch, never "RHEL ?"', () => {
    437   const [en, ko] = both(F.rockyHeader);
    438   for (const r of [en, ko]) assert.doesNotMatch(all(r), /RHEL \?|RHEL \{/);
    439   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/);
    440   assert.match(stepWith(ko, /^sudo dnf updateinfo/).text, /RHEL 8\(RHSA-2026:87073\), RHEL 9\(RHSA-2026:87072\)의 PHP 8\.2를 수정했으며/);
    441 });
    442 
    443 test('3. Amazon Linux: its own ALAS pages, never "RHEL 2023", and Amazon Linux 2 is out of support', () => {
    444   for (const name of ['amzn2023', 'amzn2']) {
    445     const [en, ko] = both(F[name]);
    446     for (const r of [en, ko]) {
    447       assert.ok(stepWith(r, 'https://explore.alas.aws.amazon.com/CVE-2026-109618.html\nhttps://explore.alas.aws.amazon.com/CVE-2026-109619.html'));
    448       assert.doesNotMatch(all(r), /RHEL 20|RHEL 2\b|access\.redhat\.com/);
    449     }
    450     assert.match(all(en), /ALAS advisories, not in Red Hat ones/);
    451   }
    452   assert.ok(stepWith(run(F.amzn2023), `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh php8.3-common php8.3-fpm`));
    453   assert.match(all(run(F.amzn2)), /Amazon Linux 2 reached the end of its support on 30 June 2026/);
    454   assert.match(all(run(F.amzn2, 'ko')), /Amazon Linux 2는 2026년 6월 30일에 지원이 종료/);
    455   assert.doesNotMatch(all(run(F.amzn2023)), /end of its support/);
    456 });
    457 
    458 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', () => {
    459   for (const r of both(F.rhel10)) {
    460     assert.doesNotMatch(all(r), /php\.net\/distributions|make install/);
    461     assert.ok(stepWith(r, `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh 'php*'`));
    462     assert.match(stepWith(r, /^sudo dnf updateinfo/).text, /RHSA-2026:87071/);
    463     // RHSA-2026:87071 is a rebase to 8.4.27, whose changelog entry names the version, not the CVEs (round 3).
    464     assert.ok(stepWith(r, "rpm -qa 'php*'").text.includes('8.4.27-1.el10_2'));
    465     assert.doesNotMatch(all(r), /rpm -q --changelog/);
    466     assert.ok(stepWith(r, 'sudo systemctl restart php-fpm'));
    467   }
    468 });
    469 
    470 test('3. a Remi collection is updated by its own names, restarted by its own service, and its pool file is tested first', () => {
    471   for (const r of both(F.remiScl)) {
    472     const upgrade = stepWith(r, 'sudo dnf upgrade --refresh php82-php-common php82-php-fpm');
    473     assert.match(upgrade.text, /8\.2\.35/);
    474     assert.ok(stepWith(r, 'sudo systemctl restart php82-php-fpm'));
    475     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$/);
    476   }
    477   // An end-of-life Remi collection gets Remi's own backport, and no self-built php-src branch next to it.
    478   for (const r of both(F.remi74)) {
    479     assert.ok(stepWith(r, 'sudo dnf upgrade --refresh php74-php-common php74-php-fpm'));
    480     assert.doesNotMatch(all(r), /git clone|7\.4\.33-20\.el9\.remi, so|7\.4\.33-20\.el9\.remi 그대로/);
    481   }
    482 });
    483 
    484 // ── 4. Self-built or upstream builds on a supported branch ─────────
    485 
    486 test('4. self-built PHP: the fixed release from php.net, the same configure options, and the SAPI binary to confirm', () => {
    487   const [en, ko] = both(F.selfFpm);
    488   for (const r of [en, ko]) {
    489     const get = stepWith(r, 'curl -fsSLO https://www.php.net/distributions/php-8.3.36.tar.xz\nsha256sum php-8.3.36.tar.xz');
    490     assert.ok(get.text.includes('8.3.20') && get.text.includes('8.3.36'));
    491     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$/));
    492     assert.ok(stepWith(r, 'sudo systemctl restart <php-fpm-service>'));
    493     assert.ok(stepWith(r, 'php-fpm -v').text.includes('8.3.36'));
    494     assert.ok(!r.actions.some((a) => a.code === 'php -v'), 'never the command-line php');
    495   }
    496   assert.match(stepWith(en, 'php-fpm -v').text, /not the command-line php/);
    497   // mod_php has no binary of its own: the probe confirms, and Apache keeps the distribution's service name.
    498   for (const r of both(F.selfApache)) {
    499     assert.ok(stepWith(r, 'sudo systemctl restart apache2'));
    500     const last = r.actions.findIndex((a) => /8\.4\.27/.test(a.text) && !a.code);
    501     assert.ok(last >= 0, 'a probe-based confirm step');
    502   }
    503   // The mitigation goes into the php.ini of the configuration directory this PHP reports.
    504   for (const r of both(F.selfFpm)) {
    505     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>");
    506   }
    507 });
    508 
    509 test('4. -v output or a bare version: the probe first, no claim about where PHP comes from, and never php -v as proof', () => {
    510   const [en, ko] = both(F.cliVersion);
    511   for (const r of [en, ko]) {
    512     assert.match(r.actions[0].code, /php2shell-selfcheck\.php\ncurl -s 'https:\/\/<site>\/<file>\.php\?k=<TOKEN>'$/);
    513     assert.ok(!r.actions.some((a) => a.code === 'php -v'));
    514     assert.doesNotMatch(all(r), /is not a distribution package/);
    515   }
    516   assert.match(en.actions[1].text, /^Nothing in this input shows where PHP 8\.4\.20 comes from/);
    517   assert.match(ko.actions[1].text, /^이 입력만으로는 PHP 8\.4\.20 버전을 어디서 설치했는지 알 수 없습니다/);
    518   assert.match(all(en), /Do not rely on php -v/);
    519   // With the probe, the step says the version matches no distribution package in the data, not that it is none.
    520   assert.match(all(run(F.selfFpm)), /does not match a distribution package in the data of this check/);
    521 });
    522 
    523 test('4. other systems: service names and package commands where the distribution fixes them, root shells on Alpine', () => {
    524   for (const r of both(F.fedora)) {
    525     assert.ok(stepWith(r, "sudo dnf upgrade --refresh 'php*'"));
    526     assert.ok(stepWith(r, 'sudo systemctl restart php-fpm'));
    527     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$/);
    528   }
    529   for (const r of both(F.suse)) {
    530     assert.ok(stepWith(r, 'sudo systemctl restart apache2'));
    531     assert.doesNotMatch(all(r), /<apache-service>|<php-fpm-service>/);
    532   }
    533   for (const r of both(F.alpine)) {
    534     assert.ok(stepWith(r, "apk update\napk upgrade $(apk info | grep '^php83')"));
    535     assert.ok(stepWith(r, 'rc-service php-fpm83 restart'));
    536     assert.ok(stepWith(r, 'sh php2shell-server-check.sh'));
    537     assert.doesNotMatch(all(r), /sudo/);
    538   }
    539   // CGI: whatever starts php-cgi is restarted, and the processes left are listed; the ini goes in the CGI scan dir.
    540   for (const r of both(F.cgi)) {
    541     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'"));
    542     assert.doesNotMatch(all(r), /php8\.2-fpm|# PHP-FPM/);
    543     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/);
    544   }
    545 });
    546 
    547 // ── 5. End-of-life branches ─────────────────────────────────────────
    548 
    549 test('5. end of life: supported branches with their fixed releases, packages that carry the backport, the php-src branch', () => {
    550   const [en, ko] = both(F.eol81);
    551   for (const r of [en, ko]) {
    552     const move = r.actions[0];
    553     for (const v of ['8.4', '8.4.27', '8.3', '8.3.36']) assert.ok(move.text.includes(v), v);
    554     // The mitigation is the only thing that helps tonight, so it comes right after the explanation.
    555     assert.match(r.actions[1].code, /^echo 'session\.upload_progress\.enabled = 0' \| sudo tee -a \/usr\/local\/lib\/php\.ini >\/dev\/null/);
    556     // The closest fixed PHP on this very server comes first, the other carriers after it.
    557     const host = stepWith(r, 'sudo apt-get update\nsudo apt-get install php8.2-fpm');
    558     assert.match(host.text, /Debian 12/);
    559     assert.match(host.text, /8\.2\.35-1~deb12u1/);
    560     assert.match(host.text, /Ubuntu 22\.04 \(php8\.1 8\.1\.2-1ubuntu2\.28\)/);
    561     const backport = stepWith(r, /git clone/);
    562     assert.equal(backport.code, [
    563       "php-fpm -i | grep -i 'configure command'",
    564       'git clone --depth 1 --branch PHP-8.1-CVE-2026-109618-109619 https://github.com/php/php-src.git',
    565       'cd php-src',
    566       './buildconf --force',
    567       './configure <current-options>',
    568       'make -j"$(nproc)"',
    569       'sudo make install',
    570     ].join('\n'));
    571     assert.ok(backport.text.includes('8.1.30'));
    572     assert.match(backport.text, /autoconf, bison/);
    573   }
    574   assert.match(en.actions[0].text, /test your application/);
    575   assert.match(ko.actions[0].text, /애플리케이션을 테스트하세요/);
    576   assert.match(en.actions[1].text, /^Until this site runs on a supported branch/);
    577   assert.match(ko.actions[1].text, /^지원되는 브랜치로 옮길 때까지는/);
    578   assert.match(stepWith(en, /git clone/).text, /still say Vulnerable/);
    579   assert.match(stepWith(ko, /git clone/).text, /계속 취약으로/);
    580   // 8.0: the RHEL 9 package carries it; 7.3 has neither a carrier nor a backport branch.
    581   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\)/);
    582   for (const r of both(F.eol73)) {
    583     assert.doesNotMatch(all(r), /git clone/);
    584     assert.match(all(r), /long-term support|장기 지원/);
    585   }
    586   // Installing the Apache module package next to a loaded PHP module would clash, so no command is given for it.
    587   for (const r of both(F.eol74apache)) {
    588     assert.match(all(r), /libapache2-mod-php8\.2/);
    589     assert.doesNotMatch(codes(r).join('\n'), /apt-get install libapache2-mod-php/);
    590   }
    591   // An end-of-life official image also gets the mitigation right after the step that moves it.
    592   for (const r of both(F.dockerEol)) assert.match(r.actions[1].code, /PHP_INI_DIR/);
    593 });
    594 
    595 // ── 6. Patched on disk, still old in memory ─────────────────────────
    596 
    597 test('6. restart: why the old build is still loaded, the full restart for the SAPI and distribution, and how to confirm', () => {
    598   const [en, ko] = both(F.jammyRestart);
    599   for (const r of [en, ko]) {
    600     assert.equal(r.actions.length, 3);
    601     assert.equal(r.actions[0].code, 'sudo systemctl restart apache2');
    602     // The old build served requests until now, so the compromise check applies.
    603     assert.equal(r.actions[2].code, 'sudo sh php2shell-server-check.sh --logs --webroot <webroot>');
    604   }
    605   assert.match(en.actions[0].text, /loaded the old build before the update/);
    606   assert.match(en.actions[0].text, /mod_php.*full restart of Apache, not a reload or a graceful restart/);
    607   assert.match(ko.actions[0].text, /업데이트 전에 이전 빌드를 로드했고/);
    608   assert.match(en.actions[1].text, /Run check.*Restart needed/);
    609   assert.match(ko.actions[1].text, /결과확인.*재시작 필요/);
    610   // From the server script: the service of the stale processes, and the script again to see none left.
    611   const [enD, koD] = both(F.debianStale);
    612   for (const r of [enD, koD]) {
    613     assert.equal(r.actions[0].code, 'sudo systemctl restart php8.2-fpm');
    614     assert.equal(r.actions[1].code, 'sudo sh php2shell-server-check.sh');
    615   }
    616   // A PHP-FPM reload re-executes the master from disk; only mod_php keeps the old build through a reload.
    617   for (const r of [enD, koD, ...both(F.jammyFpm), ...both(F.rockyModule)]) {
    618     assert.doesNotMatch(all(r), /keeps the old PHP build loaded|graceful|이전 PHP 빌드가 메모리에 그대로 남습니다/);
    619   }
    620   for (const r of both(F.rockyStale)) assert.equal(r.actions[0].code, 'sudo systemctl restart httpd');
    621   for (const r of [en, ko, enD, koD, ...both(F.rockyStale)]) {
    622     for (const c of codes(r)) assert.doesNotMatch(c, /reload|graceful/);
    623   }
    624   // Processes no checked build accounts for: a way to find what started them.
    625   for (const r of both(F.staleOther)) assert.equal(r.actions[0].code, 'ps -o pid,user,cgroup,args -C php-fpm8.3');
    626   assert.match(run(F.staleOther).actions[0].text, /CGROUP column.*\.service.*docker-<id>\.scope/);
    627 });
    628 
    629 // ── 7. Distribution fix pending ─────────────────────────────────────
    630 
    631 test('7. pending: what it means, where to watch, how to see the fix arrive, and the mitigation as an Apache snippet', () => {
    632   const [en, ko] = both(F.forkyApache);
    633   for (const r of [en, ko]) {
    634     assert.equal(r.actions[0].code, 'https://security-tracker.debian.org/tracker/CVE-2026-109618\nhttps://security-tracker.debian.org/tracker/CVE-2026-109619');
    635     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'));
    636     const mit = stepWith(r, /php2shell-mitigation\.conf/);
    637     assert.equal(mit.code, [
    638       "sudo tee /etc/apache2/conf-available/php2shell-mitigation.conf >/dev/null <<'EOF'",
    639       '<IfModule php_module>',
    640       '    php_admin_flag session.upload_progress.enabled off',
    641       '    # php_admin_value memory_limit -1',
    642       '</IfModule>',
    643       'EOF',
    644       'sudo a2enconf php2shell-mitigation',
    645       'sudo apachectl configtest && sudo systemctl reload apache2',
    646     ].join('\n'));
    647     assert.match(r.actions[r.actions.length - 1].text, /compromise|침해 지표/);
    648     // Nothing can be installed yet, so the mitigation comes straight after the explanation, before the steps
    649     // that wait for the fix.
    650     assert.match(r.actions[1].code, /php2shell-mitigation\.conf/);
    651   }
    652   const mitEn = stepWith(en, /php2shell-mitigation/).text;
    653   assert.match(en.actions[0].text, /no fixed php8\.4 package yet/);
    654   assert.match(mitEn, /^Until the fixed package is published/);
    655   assert.match(mitEn, /does not replace the patch/);
    656   assert.match(mitEn, /only with a memory cap outside PHP, such as MemoryMax=/);
    657   assert.match(mitEn, /uploads keep working/);
    658   assert.doesNotMatch(mitEn, /pm\.max_children|MaxRequestWorkers/);
    659   const mitKo = stepWith(ko, /php2shell-mitigation/).text;
    660   assert.match(mitKo, /^수정 패키지가 공개될 때까지는/);
    661   assert.match(mitKo, /패치를 대신하지 않습니다/);
    662 });
    663 
    664 test('7. the PHP-FPM pool snippet uses the pool directory, binary and service of the distribution', () => {
    665   const [enFpm] = both(F.forkyFpm);
    666   assert.match(stepWith(enFpm, /zz-php2shell/).text, /"\[pool www\] user has not been defined".*put your pool's name in the brackets/);
    667   for (const r of both(F.forkyFpm)) {
    668     assert.equal(stepWith(r, /zz-php2shell-mitigation/).code, [
    669       "sudo tee /etc/php/8.4/fpm/pool.d/zz-php2shell-mitigation.conf >/dev/null <<'EOF'",
    670       '[www]',
    671       'php_admin_flag[session.upload_progress.enabled] = off',
    672       '; php_admin_value[memory_limit] = -1',
    673       'EOF',
    674       'sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm',
    675     ].join('\n'));
    676   }
    677   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$/);
    678   // PHP 7 names its Apache module php7_module; PHP 8.5 also turns off fatal_error_backtraces.
    679   for (const r of both(F.focalPro)) assert.match(stepWith(r, /php2shell-mitigation/).code, /<IfModule php7_module>/);
    680   for (const r of both(F.resolute85)) {
    681     const mit = stepWith(r, /php2shell-mitigation/);
    682     assert.match(mit.code, /php_admin_flag fatal_error_backtraces off/);
    683     assert.match(mit.text, /fatal_error_backtraces/);
    684   }
    685 });
    686 
    687 test('7. Ubuntu points to its own CVE pages, and a mitigation already in place is not repeated as a snippet', () => {
    688   const data = JSON.parse(JSON.stringify(distro));
    689   data.ubuntu['24.04'].packages['php8.3'].fixed = { 'CVE-2026-109618': null, 'CVE-2026-109619': null };
    690   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 });
    691   for (const r of both(text, data)) {
    692     assert.equal(r.verdict, 'pending');
    693     assert.equal(r.actions[0].code, 'https://ubuntu.com/security/CVE-2026-109618\nhttps://ubuntu.com/security/CVE-2026-109619');
    694   }
    695   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' } });
    696   for (const r of both(off)) {
    697     assert.doesNotMatch(all(r), /zz-php2shell-mitigation/);
    698     assert.match(all(r), /listed in this result|이 결과에 나온/);
    699   }
    700 });
    701 
    702 // ── 8. Undetermined: what to collect next ───────────────────────────
    703 
    704 test('8. an unread OS release asks for the server script as root, or the distribution in the list, and nothing else to run', () => {
    705   const [en, ko] = both(F.osUnread);
    706   for (const r of [en, ko]) {
    707     assert.equal(r.actions[0].code, `${FETCH}\nsudo sh php2shell-server-check.sh`);
    708     assert.equal(r.actions[1].code, undefined);
    709     assert.doesNotMatch(all(r), /apt-get|dnf|yum|FROM php|zz-php2shell/);
    710   }
    711   assert.match(en.actions[0].text, /as root/);
    712   assert.match(en.actions[0].text, /compare the hash with the one on the Server script tab/);
    713   assert.match(en.actions[1].text, /Distribution list next to Run check/);
    714   assert.match(ko.actions[1].text, /배포판 목록/);
    715   assert.match(ko.actions[1].text, /결과확인/);
    716 });
    717 
    718 test('8. each open question gets its own next step', () => {
    719   // Processes hidden without root: run as root; the package already has the fix, so restart if unsure.
    720   for (const r of both(F.hidden)) {
    721     assert.equal(r.actions[0].code, 'sudo sh php2shell-server-check.sh');
    722     assert.equal(r.actions[1].code, 'sudo systemctl restart php8.3-fpm');
    723     assert.doesNotMatch(all(r), /apt-get|zz-php2shell/);
    724   }
    725   assert.match(run(F.hidden).actions[0].text, /again as root/);
    726   // A cut-off server script output: copy it again. The package already records both fixes, so no mitigation.
    727   for (const r of both(F.truncated)) {
    728     assert.match(r.actions[0].text, /BEGIN.*END/);
    729     assert.doesNotMatch(all(r), /interim mitigation|임시 완화|memory_limit/);
    730   }
    731   // PHP processes no checked build accounts for: check them where they run (as root in a container), without
    732   // naming a service of this build.
    733   for (const r of both(F.unaccounted)) {
    734     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');
    735     assert.doesNotMatch(all(r), /php8\.1-fpm|zz-php2shell/);
    736   }
    737   assert.match(run(F.unaccounted).actions[0].text, /-u 0 runs it as root inside the container/);
    738   // Probe and server script from different moments: run both together.
    739   for (const r of both(F.conflict)) assert.match(r.actions[0].text, /together|함께/);
    740   // A revision Ubuntu does not build: find its repository.
    741   for (const r of both(F.foreign)) assert.equal(r.actions[0].code, 'apt-cache policy php8.3-fpm');
    742   // The server script ran but the release is unknown: the distribution list, not the script again, as the only
    743   // way forward (no "Or"); an unknown Debian release is a systemd one.
    744   const [enN, koN] = both(F.debianNoRelease);
    745   for (const r of [enN, koN]) {
    746     assert.equal(r.actions[0].code, undefined);
    747     assert.match(r.actions[0].text, /Distribution list|배포판 목록/);
    748     assert.doesNotMatch(codes(r).join('\n'), /sudo service /);
    749     assert.ok(stepWith(r, 'sudo systemctl restart php8.2-fpm'));
    750   }
    751   assert.match(enN.actions[0].text, /^The server script output does not name the release of this system\./);
    752   assert.match(koN.actions[0].text, /^서버 스크립트 출력에 이 시스템의 릴리스가 나오지 않습니다\./);
    753   assert.doesNotMatch(koN.actions[0].text, /눌러도 됩니다/);
    754   // Nothing readable: the probe through the web server, then the server script.
    755   const [enV, koV] = both(F.noVersion);
    756   for (const r of [enV, koV]) assert.deepEqual(codes(r), [PROBE, `${FETCH}\nsudo sh php2shell-server-check.sh`]);
    757   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/);
    758   assert.match(koV.actions[0].text, /\$TOKEN을 영문자와 숫자로 된 12자 이상의 임의 문자열로/);
    759   // A bare version that may be a distribution package: the probe, the server script, or the distribution list.
    760   for (const r of both('8.0.30')) {
    761     assert.equal(r.verdict, 'unknown');
    762     assert.deepEqual(codes(r).slice(0, 3), [PROBE, `${FETCH}\nsudo sh php2shell-server-check.sh`, '']);
    763     assert.match(r.actions[2].text, /Distribution list|배포판 목록/);
    764   }
    765   // RHEL from the probe alone: the server script first, then the update steps.
    766   for (const r of both(F.rhelProbe)) {
    767     assert.equal(r.actions[0].code, `${FETCH}\nsudo sh php2shell-server-check.sh`);
    768     assert.ok(stepWith(r, `sudo dnf updateinfo list --cve ${CVES}\nsudo dnf upgrade --refresh 'php*'`));
    769   }
    770   // The compromise check says how to run it.
    771   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>');
    772   assert.match(run(F.jammyFpm).actions.at(-1).text, /--webroot pointing at the site's directory \(for example \/var\/www\)/);
    773 });
    774 
    775 // ── 9. Patched ──────────────────────────────────────────────────────
    776 
    777 test('9. patched: other sites and pools, optional expose_php hardening for the SAPI, upload progress can stay off', () => {
    778   const [en, ko] = both(F.patchedFpm);
    779   for (const r of [en, ko]) {
    780     assert.equal(r.actions.length, 3);
    781     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");
    782     assert.doesNotMatch(all(r), /compromise|침해 지표|php2shell-mitigation/);
    783   }
    784   assert.match(en.actions[0].text, /vhost.*pool/);
    785   assert.match(en.actions[1].text, /^Optional hardening/);
    786   assert.match(en.actions[2].text, /session\.upload_progress\.enabled/);
    787   for (const r of both(F.patchedDocker)) {
    788     assert.equal(r.actions[1].code, 'RUN echo \'expose_php = Off\' > "$PHP_INI_DIR/conf.d/zz-hardening.ini"');
    789     assert.match(r.actions[2].text, /keep it off|꺼 두세요/);
    790     // In a container, the other containers and images matter, not vhosts.
    791     assert.match(r.actions[0].text, /other container|다른 컨테이너/);
    792     assert.doesNotMatch(r.actions[0].text, /vhost/);
    793   }
    794   // A hardening step only changes a setting: no "new build" sentence borrowed from the update path.
    795   for (const r of both(F.patchedApache)) {
    796     const expose = stepWith(r, /expose_php = Off/);
    797     assert.equal(expose.code, "echo 'expose_php = Off' | sudo tee /etc/php/8.2/apache2/conf.d/99-hardening.ini\nsudo systemctl restart apache2");
    798     assert.doesNotMatch(expose.text, /new build|새 빌드/);
    799   }
    800   // expose_php already off: no hardening step for it. A header shows it on, with no layout to name a file.
    801   for (const r of both(F.patchedQuiet)) assert.doesNotMatch(all(r), /expose_php/);
    802   for (const r of both(F.patchedHeader)) {
    803     assert.match(r.actions[1].text, /expose_php = Off/);
    804     assert.equal(r.actions[1].code, undefined);
    805   }
    806 });
    807 
    808 // ── Every result ────────────────────────────────────────────────────
    809 
    810 const PLACEHOLDERS = new Set(['container', 'your-image', 'token', 'variant', 'current-options', 'php-fpm-service', 'apache-service', 'site', 'file', 'TOKEN', 'webroot']);
    811 
    812 test('every step: same commands in both languages, nothing destructive, only marked placeholders, plain text', () => {
    813   for (const [name, text] of Object.entries(F)) {
    814     const [en, ko] = both(text);
    815     assert.deepEqual(codes(ko), codes(en), `${name}: the same commands in both languages`);
    816     for (const r of [en, ko]) {
    817       for (const a of r.actions) {
    818         assert.deepEqual(Object.keys(a).sort(), a.code ? ['code', 'text'] : ['text'], name);
    819         assert.equal(typeof a.text, 'string');
    820         assert.doesNotMatch(a.text, /\{\w+\}|undefined|NaN|\bnull\b/, `${name}: ${a.text}`);
    821         const code = a.code || '';
    822         assert.doesNotMatch(code, /\brm\b|\bremove\b|\bpurge\b|\berase\b|--allowerasing|docker rmi?\b|prune|autoremove|\bkill\b/, `${name}: ${code}`);
    823         assert.doesNotMatch(code.replaceAll('/dev/null', ''), /undefined|NaN|\bnull\b/, name);
    824         for (const m of code.replace(/<<'EOF'/g, '').matchAll(/<([A-Za-z-]+)>/g)) assert.ok(PLACEHOLDERS.has(m[1]), `${name}: placeholder <${m[1]}>`);
    825         // A reload only applies a configuration change; restarts that load a new build never reload.
    826         if (/\breload\b/.test(code)) assert.match(a.text, /a reload is enough for a configuration change|설정 변경에는 reload로 충분합니다/, name);
    827       }
    828       for (const a of ko.actions) {
    829         assert.match(a.text, /[가-힣]/, `${name}: Korean text`);
    830         assert.doesNotMatch(a.text, /\uD310\uC815/, name);
    831       }
    832       for (const a of en.actions) assert.doesNotMatch(a.text, /recogniz|behavior|initializ|analyz|\bcolor/, `${name}: British spelling`);
    833     }
    834     // A mitigation never claims to fix the bug.
    835     for (const a of [...en.actions, ...ko.actions].filter((x) => /interim mitigation|임시 완화/.test(x.text))) {
    836       assert.match(a.text, /not replace|대신하지 않습니다/, `${name}: ${a.text}`);
    837     }
    838   }
    839 });