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 });